fix: recover empty database migrations and persist Docker icons
This commit is contained in:
1 parent
2db0c75498
commit
35bd2e1828
20 files changed
+577
-62
No files matched your search
@@ -89,3 +89,5 @@ NETWORK_RATE_LIMIT_WINDOW_MS=900000
|
||||
NETWORK_AUTH_RATE_LIMIT_MAX=30
|
||||
# 每个来源在窗口内访问单个 OAuth 授权、令牌、撤销或注册端点的次数。
|
||||
MCP_AUTH_RATE_LIMIT_MAX=100
|
||||
# Optional filesystem icon persistence; Docker configures /app/data/icons.
|
||||
ICON_STORAGE_DIR=
|
||||
@@ -5,7 +5,7 @@
|
||||
"dev": "node scripts/dev.cjs",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts test/web.test.ts ../web/test/quick-entry.test.ts ../web/test/net-worth-chart.test.ts ../web/test/login-preferences.test.ts",
|
||||
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts test/web.test.ts test/database-plan.test.ts test/icon-files.test.ts ../web/test/quick-entry.test.ts ../web/test/net-worth-chart.test.ts ../web/test/login-preferences.test.ts",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "node scripts/database.cjs deploy",
|
||||
"db:status": "node scripts/database.cjs status",
|
||||
|
||||
+161
-17
@@ -1,19 +1,163 @@
|
||||
require('dotenv').config({ quiet: true });
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const command = process.argv[2];
|
||||
if (!['deploy', 'status'].includes(command)) process.exit(1);
|
||||
const p = spawnSync(
|
||||
process.execPath,
|
||||
[require.resolve('prisma/build/index.js'), 'migrate', command],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
// Prisma datasource lines can expose local connection metadata; omit them.
|
||||
const output = (p.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.filter((s) => !s.startsWith('Datasource ') && !s.includes('Environment variables'))
|
||||
.join('\n');
|
||||
if (p.status === 0) console.log(output);
|
||||
else
|
||||
console.error(
|
||||
'Database migration command failed. Check local database access and migration compatibility. No reset was performed.',
|
||||
const { mkdtempSync, copyFileSync, mkdirSync, rmSync } = require('node:fs');
|
||||
const { join, resolve, dirname, basename } = require('node:path');
|
||||
const { tmpdir } = require('node:os');
|
||||
const { createHash } = require('node:crypto');
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
// Preserve historical names and SQL. Deploy their prerequisites first on a new database.
|
||||
const initialMigrations = [
|
||||
'202610010001_initial',
|
||||
'202610010002_import_origin',
|
||||
'202610010003_revision_sequence',
|
||||
'202610010004_privacy_time_settings',
|
||||
];
|
||||
const schema = resolve(__dirname, '../prisma/schema.prisma');
|
||||
const migrations = resolve(__dirname, '../prisma/migrations');
|
||||
function bootstrapPlan(tables, history) {
|
||||
const applied = new Set(
|
||||
history.filter((r) => r.finished_at && !r.rolled_back_at).map((r) => r.migration_name),
|
||||
);
|
||||
process.exitCode = p.status || 0;
|
||||
const failed = history.filter((r) => !r.finished_at && !r.rolled_back_at);
|
||||
const businessTables = tables.filter((name) => name.toLowerCase() !== '_prisma_migrations');
|
||||
const recover =
|
||||
failed.length === 1 &&
|
||||
failed[0].migration_name === '005_account_icons' &&
|
||||
Number(failed[0].applied_steps_count) === 0 &&
|
||||
/1824/.test(failed[0].logs || '') &&
|
||||
/Failed to open the referenced table/i.test(failed[0].logs || '') &&
|
||||
businessTables.length === 0 &&
|
||||
applied.size === 0;
|
||||
if (failed.length && !recover)
|
||||
throw new Error(
|
||||
'Unresolved migration requires manual recovery; no database reset was performed.',
|
||||
);
|
||||
if (initialMigrations.every((name) => applied.has(name)))
|
||||
return { bootstrap: false, recover: false };
|
||||
const prefix = initialMigrations.slice(0, applied.size);
|
||||
if (
|
||||
[...applied].some((name) => !prefix.includes(name)) ||
|
||||
(businessTables.length && !applied.size)
|
||||
)
|
||||
throw new Error('Unrecognized partial schema/history; no bootstrap or reset was performed.');
|
||||
return { bootstrap: true, recover };
|
||||
}
|
||||
function runPrisma(args) {
|
||||
const p = spawnSync(process.execPath, [require.resolve('prisma/build/index.js'), ...args], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const output = (p.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.filter((s) => !s.startsWith('Datasource ') && !s.includes('Environment variables'))
|
||||
.join('\n');
|
||||
if (p.status === 0) {
|
||||
if (output.trim()) console.log(output);
|
||||
} else {
|
||||
const codes = [...new Set(((p.stdout || '') + (p.stderr || '')).match(/P\d{4}/g) || [])];
|
||||
console.error(
|
||||
'Prisma command failed' +
|
||||
(codes.length ? ': ' + codes.join(', ') : '') +
|
||||
'. No database reset was performed.',
|
||||
);
|
||||
}
|
||||
return p.status === 0 ? 0 : p.status || 1;
|
||||
}
|
||||
async function deploy() {
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
const database = decodeURIComponent(url.pathname.slice(1));
|
||||
if (url.protocol !== 'mysql:' || !database) throw new Error('Invalid MySQL configuration.');
|
||||
const connection = await mysql.createConnection({
|
||||
host: url.hostname,
|
||||
port: Number(url.port || 3306),
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
charset: 'utf8mb4',
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
const lock =
|
||||
'worthpath:migrate:' +
|
||||
createHash('sha256').update(database.toLowerCase()).digest('hex').slice(0, 32);
|
||||
let locked = false,
|
||||
folder;
|
||||
try {
|
||||
const [lockRows] = await connection.execute('SELECT GET_LOCK(?, 30) AS acquired', [lock]);
|
||||
if (Number(lockRows[0].acquired) !== 1) throw new Error('Migration lock is busy; retry later.');
|
||||
locked = true;
|
||||
const [rows] = await connection.execute(
|
||||
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=?',
|
||||
[database],
|
||||
);
|
||||
const tables = rows.map((r) => r.TABLE_NAME);
|
||||
const quotedDatabase = '`' + database.replace(/`/g, '``') + '`';
|
||||
const [history] = tables.some((name) => name.toLowerCase() === '_prisma_migrations')
|
||||
? await connection.query(
|
||||
'SELECT migration_name, finished_at, rolled_back_at, applied_steps_count, logs FROM ' +
|
||||
quotedDatabase +
|
||||
'.`_prisma_migrations`',
|
||||
)
|
||||
: [[]];
|
||||
const plan = bootstrapPlan(tables, history);
|
||||
if (plan.recover) {
|
||||
console.log('Verified empty database: recovering the failed 005_account_icons attempt.');
|
||||
const status = runPrisma([
|
||||
'migrate',
|
||||
'resolve',
|
||||
'--rolled-back',
|
||||
'005_account_icons',
|
||||
'--schema',
|
||||
schema,
|
||||
]);
|
||||
if (status) return status;
|
||||
}
|
||||
if (plan.bootstrap) {
|
||||
folder = mkdtempSync(join(tmpdir(), 'worthpath-migrations-'));
|
||||
copyFileSync(schema, join(folder, 'schema.prisma'));
|
||||
mkdirSync(join(folder, 'migrations'));
|
||||
copyFileSync(
|
||||
join(migrations, 'migration_lock.toml'),
|
||||
join(folder, 'migrations/migration_lock.toml'),
|
||||
);
|
||||
for (const name of initialMigrations) {
|
||||
mkdirSync(join(folder, 'migrations', name));
|
||||
copyFileSync(
|
||||
join(migrations, name, 'migration.sql'),
|
||||
join(folder, 'migrations', name, 'migration.sql'),
|
||||
);
|
||||
}
|
||||
console.log('Deploying initial prerequisites before historical 005-007 migrations.');
|
||||
const status = runPrisma(['migrate', 'deploy', '--schema', join(folder, 'schema.prisma')]);
|
||||
if (status) return status;
|
||||
}
|
||||
return runPrisma(['migrate', 'deploy', '--schema', schema]);
|
||||
} finally {
|
||||
if (
|
||||
folder &&
|
||||
dirname(resolve(folder)) === resolve(tmpdir()) &&
|
||||
basename(folder).startsWith('worthpath-migrations-')
|
||||
)
|
||||
rmSync(folder, { recursive: true, force: true });
|
||||
if (locked) await connection.execute('SELECT RELEASE_LOCK(?)', [lock]);
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
module.exports = { bootstrapPlan, initialMigrations };
|
||||
if (require.main === module) {
|
||||
const command = process.argv[2];
|
||||
if (command === 'status') process.exitCode = runPrisma(['migrate', 'status', '--schema', schema]);
|
||||
else if (command === 'deploy')
|
||||
deploy()
|
||||
.then((code) => {
|
||||
process.exitCode = code;
|
||||
})
|
||||
.catch((error) => {
|
||||
const safe = /^(Unresolved migration|Unrecognized partial|Migration lock)/.test(
|
||||
error.message,
|
||||
)
|
||||
? error.message
|
||||
: 'Database migration command failed. Check configuration and database access. No reset was performed.';
|
||||
console.error(safe);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
else process.exitCode = 1;
|
||||
}
|
||||
@@ -4,8 +4,9 @@ const mysql = require('mysql2/promise');
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
const { randomBytes } = require('node:crypto');
|
||||
const net = require('node:net');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { mkdtempSync, rmSync } = require('node:fs');
|
||||
const { join, resolve, dirname, basename } = require('node:path');
|
||||
const { tmpdir } = require('node:os');
|
||||
const pkg = require('../package.json');
|
||||
async function main() {
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
@@ -17,9 +18,11 @@ async function main() {
|
||||
password: decodeURIComponent(url.password),
|
||||
});
|
||||
let api;
|
||||
let iconDirectory;
|
||||
try {
|
||||
await connection.query('CREATE DATABASE `' + name + '`');
|
||||
url.pathname = '/' + name;
|
||||
iconDirectory = mkdtempSync(join(tmpdir(), 'wp_test_icons_'));
|
||||
const port = await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.on('error', reject);
|
||||
@@ -31,6 +34,7 @@ async function main() {
|
||||
const env = {
|
||||
...process.env,
|
||||
DATABASE_URL: url.toString(),
|
||||
ICON_STORAGE_DIR: iconDirectory,
|
||||
ADMIN_USERNAME: 'admin',
|
||||
ADMIN_PASSWORD: 'admin',
|
||||
PORT: String(port),
|
||||
@@ -44,14 +48,7 @@ async function main() {
|
||||
TEST_API_URL: 'http://127.0.0.1:' + port + '/api',
|
||||
};
|
||||
const run = (args) => spawnSync(process.execPath, args, { env, encoding: 'utf8' });
|
||||
// Historical 005-007 directories sort before the initial migration. Use the
|
||||
// current schema only in this disposable database, then exercise our SQL.
|
||||
const migration = run([
|
||||
require.resolve('prisma/build/index.js'),
|
||||
'db',
|
||||
'push',
|
||||
'--skip-generate',
|
||||
]);
|
||||
const migration = run([require.resolve('./database.cjs'), 'deploy']);
|
||||
if (migration.status !== 0) {
|
||||
const safe = (migration.stdout + migration.stderr)
|
||||
.split(/\r?\n/)
|
||||
@@ -60,31 +57,6 @@ async function main() {
|
||||
console.error(safe);
|
||||
throw Error('Disposable database migration failed');
|
||||
}
|
||||
await connection.query('USE `' + name + '`');
|
||||
// db push omits the historical column comments asserted by integration tests.
|
||||
await connection.query(
|
||||
"ALTER TABLE `User` MODIFY COLUMN `accountGroupOrder` JSON NULL COMMENT '账户分组显示顺序;空值表示沿用默认顺序'",
|
||||
);
|
||||
await connection.query(
|
||||
'ALTER TABLE `User` DROP COLUMN `role`, DROP COLUMN `banned`, DROP COLUMN `mustChangePassword`',
|
||||
);
|
||||
await connection.query(
|
||||
fs.readFileSync(
|
||||
path.join(__dirname, '../prisma/migrations/20261005130000_admin_accounts/migration.sql'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
// Verify the icon column removal migration against its former shape too.
|
||||
await connection.query('ALTER TABLE `Icon` ADD COLUMN `source` VARCHAR(500) NULL');
|
||||
await connection.query(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
__dirname,
|
||||
'../prisma/migrations/20261005150000_remove_icon_source/migration.sql',
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const build = run([require.resolve('typescript/bin/tsc')]);
|
||||
if (build.status !== 0) {
|
||||
console.log(build.stdout);
|
||||
@@ -110,6 +82,7 @@ async function main() {
|
||||
'test/admin-integration.test.ts',
|
||||
'test/mcp.test.ts',
|
||||
'test/oauth-duration.test.ts',
|
||||
'test/database-migrations.test.ts',
|
||||
];
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
@@ -125,6 +98,12 @@ async function main() {
|
||||
}
|
||||
await connection.query('DROP DATABASE `' + name + '`');
|
||||
await connection.end();
|
||||
if (
|
||||
iconDirectory &&
|
||||
dirname(resolve(iconDirectory)) === resolve(tmpdir()) &&
|
||||
basename(iconDirectory).startsWith('wp_test_icons_')
|
||||
)
|
||||
rmSync(iconDirectory, { recursive: true, force: true });
|
||||
console.log('Disposable test database and API cleaned up.');
|
||||
}
|
||||
}
|
||||
@@ -133,7 +112,7 @@ main().catch((e) => {
|
||||
'Isolated test run failed: ' +
|
||||
(/Disposable|API build/.test(e.message)
|
||||
? e.message
|
||||
: 'check test configuration or database access'),
|
||||
: 'check test configuration or database access (' + (e.code || e.name) + ')'),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { createHash } from 'node:crypto';
|
||||
import { toBusinessDate } from './validation';
|
||||
import { iconName, validateStoredIcon } from './icons';
|
||||
import { persistIconFile } from './icon-files';
|
||||
import { day, businessTime } from './calculation';
|
||||
const timestamp = z.iso
|
||||
.datetime()
|
||||
@@ -656,7 +657,11 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
||||
.parse(raw),
|
||||
b = validateBackup(backup);
|
||||
const iconData = new Map<string, Buffer>();
|
||||
for (const i of b.icons) iconData.set(i.id, await validateStoredIcon(i.image, i.hash));
|
||||
for (const i of b.icons) {
|
||||
const data = await validateStoredIcon(i.image, i.hash);
|
||||
iconData.set(i.id, data);
|
||||
await persistIconFile(i.hash, data);
|
||||
}
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const ps = await tx.position.findMany({
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdir, readFile, writeFile, rename, unlink } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
const digest = (data: Buffer) => createHash('sha256').update(data).digest('hex');
|
||||
function target(hash: string) {
|
||||
if (!/^[a-f0-9]{64}$/.test(hash)) throw Error('Invalid icon hash');
|
||||
const directory = process.env.ICON_STORAGE_DIR?.trim();
|
||||
return directory ? join(resolve(directory), hash + '.png') : undefined;
|
||||
}
|
||||
|
||||
export async function initializeIconDirectory() {
|
||||
const configured = process.env.ICON_STORAGE_DIR?.trim();
|
||||
if (!configured) return;
|
||||
const directory = resolve(configured);
|
||||
await mkdir(directory, { recursive: true, mode: 0o750 });
|
||||
const probe = join(directory, '.write-check-' + randomBytes(16).toString('hex'));
|
||||
await writeFile(probe, '', { flag: 'wx', mode: 0o600 });
|
||||
await unlink(probe);
|
||||
}
|
||||
|
||||
// Immutable content-addressed files can be shared by multiple icon records.
|
||||
// Keep the database copy for existing installations and self-contained ZIP backups.
|
||||
export async function persistIconFile(hash: string, data: Buffer) {
|
||||
const file = target(hash);
|
||||
if (!file) return;
|
||||
if (digest(data) !== hash) throw Error('Invalid icon contents');
|
||||
try {
|
||||
if (digest(await readFile(file)) === hash) return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
await mkdir(resolve(process.env.ICON_STORAGE_DIR!.trim()), { recursive: true, mode: 0o750 });
|
||||
const temporary = file + '.' + randomBytes(16).toString('hex') + '.tmp';
|
||||
try {
|
||||
await writeFile(temporary, data, { flag: 'wx', mode: 0o600 });
|
||||
try {
|
||||
await rename(temporary, file);
|
||||
} catch (error) {
|
||||
// Windows may reject replacing a file another upload has just published.
|
||||
// Accept only an already complete file with exactly the expected content.
|
||||
const existing = await readFile(file).catch(() => undefined);
|
||||
if (!existing || digest(existing) !== hash) throw error;
|
||||
}
|
||||
} finally {
|
||||
await unlink(temporary).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function readIconFile(hash: string, databaseData: Buffer) {
|
||||
const file = target(hash);
|
||||
if (!file) return databaseData;
|
||||
try {
|
||||
const data = await readFile(file);
|
||||
if (digest(data) === hash) return data;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
await persistIconFile(hash, databaseData);
|
||||
return databaseData;
|
||||
}
|
||||
+21
-2
@@ -12,6 +12,7 @@ import {
|
||||
UseInterceptors,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
@@ -21,6 +22,7 @@ import { createHash } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { initializeIconDirectory, persistIconFile, readIconFile } from './icon-files';
|
||||
|
||||
export const iconName = z.string().trim().min(1).max(100);
|
||||
export const iconHash = (data: Buffer) => createHash('sha256').update(data).digest('hex');
|
||||
@@ -78,8 +80,24 @@ export class IconsService {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IconsBusinessService {
|
||||
export class IconsBusinessService implements OnModuleInit {
|
||||
constructor(private db: Database) {}
|
||||
async onModuleInit() {
|
||||
if (!process.env.ICON_STORAGE_DIR?.trim()) return;
|
||||
await initializeIconDirectory();
|
||||
let cursor: string | undefined;
|
||||
for (;;) {
|
||||
const rows = await this.db.icon.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
take: 100,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
select: { id: true, hash: true, data: true },
|
||||
});
|
||||
for (const row of rows) await persistIconFile(row.hash, Buffer.from(row.data));
|
||||
if (rows.length < 100) break;
|
||||
cursor = rows[rows.length - 1].id;
|
||||
}
|
||||
}
|
||||
async list(r: UserRequest, q = '', page = '1') {
|
||||
const query = z.string().trim().max(100).parse(q);
|
||||
const index = z.coerce.number().int().min(1).max(100000).parse(page);
|
||||
@@ -105,7 +123,7 @@ export class IconsBusinessService {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
// Uploads, built-in seeding and imports already validate stored PNG data.
|
||||
// Preserve essential white artwork rather than applying the cutout twice.
|
||||
res.send(Buffer.from(icon.data));
|
||||
res.send(await readIconFile(icon.hash, Buffer.from(icon.data)));
|
||||
}
|
||||
|
||||
async upload(r: UserRequest, raw: unknown, file?: Express.Multer.File) {
|
||||
@@ -123,6 +141,7 @@ export class IconsBusinessService {
|
||||
if (!file) throw new BadRequestException('请选择图标文件');
|
||||
const data = await normalizeIcon(file.buffer),
|
||||
hash = iconHash(data);
|
||||
await persistIconFile(hash, data);
|
||||
const icon = await this.db.icon.upsert({
|
||||
where: { ownerId_hash_shared: { ownerId: r.userId, hash, shared } },
|
||||
create: { name: v.name, ownerId: r.userId, shared, hash, data },
|
||||
|
||||
@@ -139,6 +139,8 @@ async function bootstrap() {
|
||||
console.log('WorthPath API ready');
|
||||
}
|
||||
void bootstrap().catch(() => {
|
||||
console.error('API startup failed. Check local configuration and database availability.');
|
||||
console.error(
|
||||
'API startup failed. Check configuration, icon directory permissions and database availability.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
|
||||
// Every scenario owns its random database; never run against the configured business database.
|
||||
test(
|
||||
'real migrations initialize, recover failed 005, preserve data and reject untracked schemas',
|
||||
{ skip: process.env.TEST_ISOLATED !== 'true' },
|
||||
async () => {
|
||||
const url = new URL(process.env.DATABASE_URL!);
|
||||
const db = await mysql.createConnection({
|
||||
host: url.hostname,
|
||||
port: Number(url.port || 3306),
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
});
|
||||
const directory = resolve('prisma/migrations');
|
||||
const expected = readdirSync(directory, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
.sort();
|
||||
const names: string[] = [];
|
||||
const run = (database: string, raw = false) => {
|
||||
const target = new URL(url);
|
||||
target.pathname = '/' + database;
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
raw
|
||||
? [require.resolve('prisma/build/index.js'), 'migrate', 'deploy']
|
||||
: [resolve('scripts/database.cjs'), 'deploy'],
|
||||
{ env: { ...process.env, DATABASE_URL: target.toString() }, encoding: 'utf8' },
|
||||
);
|
||||
};
|
||||
const create = async () => {
|
||||
const name = 'wp_test_migrations_' + randomBytes(8).toString('hex');
|
||||
await db.query('CREATE DATABASE `' + name + '`');
|
||||
names.push(name);
|
||||
await db.query('USE `' + name + '`');
|
||||
return name;
|
||||
};
|
||||
const verify = async () => {
|
||||
const [rows] = await db.query<any[]>(
|
||||
'SELECT migration_name, checksum FROM `_prisma_migrations` WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL ORDER BY migration_name',
|
||||
);
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.migration_name),
|
||||
expected,
|
||||
);
|
||||
for (const row of rows)
|
||||
assert.equal(
|
||||
row.checksum,
|
||||
createHash('sha256')
|
||||
.update(readFileSync(join(directory, row.migration_name, 'migration.sql')))
|
||||
.digest('hex'),
|
||||
);
|
||||
const [columns] = await db.query<any[]>("SHOW COLUMNS FROM `Icon` LIKE 'source'");
|
||||
assert.equal(columns.length, 0);
|
||||
};
|
||||
try {
|
||||
const fresh = await create();
|
||||
assert.equal(run(fresh).status, 0, 'empty database deploy must succeed');
|
||||
await verify();
|
||||
await db.query('CREATE TABLE `migration_test_marker` (`value` INT NOT NULL)');
|
||||
await db.query('INSERT INTO `migration_test_marker` VALUES (42)');
|
||||
const [before] = await db.query('SELECT * FROM `_prisma_migrations` ORDER BY id');
|
||||
assert.equal(run(fresh).status, 0, 'repeat deploy must succeed');
|
||||
const [after] = await db.query('SELECT * FROM `_prisma_migrations` ORDER BY id');
|
||||
assert.deepEqual(after, before, 'existing migration history must stay unchanged');
|
||||
const [marker] = await db.query<any[]>('SELECT * FROM `migration_test_marker`');
|
||||
assert.equal(marker[0].value, 42);
|
||||
|
||||
const failed = await create();
|
||||
const broken = run(failed, true);
|
||||
assert.notEqual(broken.status, 0);
|
||||
assert.match(broken.stdout + broken.stderr, /1824/);
|
||||
assert.equal(run(failed).status, 0, 'known failed 005 on an empty database must recover');
|
||||
await verify();
|
||||
const [rolled] = await db.query<any[]>(
|
||||
"SELECT * FROM `_prisma_migrations` WHERE migration_name='005_account_icons' AND rolled_back_at IS NOT NULL",
|
||||
);
|
||||
assert.equal(rolled.length, 1);
|
||||
|
||||
const untracked = await create();
|
||||
await db.query('CREATE TABLE `existing_data` (`value` INT NOT NULL)');
|
||||
await db.query('INSERT INTO `existing_data` VALUES (7)');
|
||||
assert.notEqual(run(untracked).status, 0, 'untracked existing schema must be refused');
|
||||
const [preserved] = await db.query<any[]>('SELECT * FROM `existing_data`');
|
||||
assert.equal(preserved[0].value, 7);
|
||||
} finally {
|
||||
for (const name of names) await db.query('DROP DATABASE `' + name + '`');
|
||||
await db.end();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
const { bootstrapPlan, initialMigrations } = require('../scripts/database.cjs');
|
||||
const applied = (name: string) => ({ migration_name: name, finished_at: new Date() });
|
||||
const failure = {
|
||||
migration_name: '005_account_icons',
|
||||
applied_steps_count: 0,
|
||||
logs: "Database error code: 1824\nFailed to open the referenced table 'user'",
|
||||
};
|
||||
test('bootstrap accepts empty databases and prerequisite prefixes; preserves applied histories', () => {
|
||||
assert.deepEqual(bootstrapPlan([], []), { bootstrap: true, recover: false });
|
||||
assert.deepEqual(bootstrapPlan(['_prisma_migrations'], [failure]), {
|
||||
bootstrap: true,
|
||||
recover: true,
|
||||
});
|
||||
assert.deepEqual(bootstrapPlan(['User'], [applied(initialMigrations[0])]), {
|
||||
bootstrap: true,
|
||||
recover: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
bootstrapPlan(['User'], [...initialMigrations.map(applied), applied('005_account_icons')]),
|
||||
{ bootstrap: false, recover: false },
|
||||
);
|
||||
});
|
||||
test('bootstrap refuses unknown failures, partially applied SQL and untracked schemas', () => {
|
||||
for (const row of [
|
||||
{ ...failure, migration_name: '006_navigation_transfers' },
|
||||
{ ...failure, logs: 'Other database error' },
|
||||
{ ...failure, applied_steps_count: 1 },
|
||||
])
|
||||
assert.throws(() => bootstrapPlan([], [row]), /manual recovery/);
|
||||
assert.throws(() => bootstrapPlan(['Icon'], [failure]), /manual recovery/);
|
||||
assert.throws(() => bootstrapPlan(['User'], []), /Unrecognized/);
|
||||
assert.throws(() => bootstrapPlan(['User'], [applied(initialMigrations[1])]), /Unrecognized/);
|
||||
assert.throws(
|
||||
() => bootstrapPlan(['User'], [...initialMigrations.map(applied), failure]),
|
||||
/manual recovery/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'reflect-metadata';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, writeFile, readdir, rm } from 'node:fs/promises';
|
||||
import { join, resolve, dirname, basename } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { persistIconFile, readIconFile } from '../src/icon-files';
|
||||
import { IconsBusinessService } from '../src/icons';
|
||||
import { Database } from '../src/database';
|
||||
|
||||
test('icon files persist concurrently, repair damage, reject unsafe names and backfill historical pages', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'wp_test_icon_files_'));
|
||||
const previous = process.env.ICON_STORAGE_DIR;
|
||||
process.env.ICON_STORAGE_DIR = directory;
|
||||
const data = Buffer.from('stored icon bytes');
|
||||
const hash = createHash('sha256').update(data).digest('hex');
|
||||
const file = join(directory, hash + '.png');
|
||||
try {
|
||||
await Promise.all(Array.from({ length: 5 }, () => persistIconFile(hash, data)));
|
||||
assert.deepEqual(await readFile(file), data);
|
||||
assert.deepEqual(await readdir(directory), [hash + '.png']);
|
||||
await writeFile(file, 'damaged');
|
||||
assert.deepEqual(await readIconFile(hash, data), data);
|
||||
assert.deepEqual(await readFile(file), data);
|
||||
await assert.rejects(persistIconFile('../escape', data), /Invalid icon hash/);
|
||||
await assert.rejects(persistIconFile('0'.repeat(64), data), /Invalid icon contents/);
|
||||
|
||||
const historical = Buffer.from('historical icon on the second page');
|
||||
const historicalHash = createHash('sha256').update(historical).digest('hex');
|
||||
const rows = Array.from({ length: 101 }, (_, n) => ({
|
||||
id: String(n).padStart(3, '0'),
|
||||
hash: n === 100 ? historicalHash : hash,
|
||||
data: n === 100 ? historical : data,
|
||||
}));
|
||||
const database = {
|
||||
icon: {
|
||||
findMany: async (args: any) => {
|
||||
const start = args.cursor ? rows.findIndex((r) => r.id === args.cursor.id) + 1 : 0;
|
||||
return rows.slice(start, start + args.take);
|
||||
},
|
||||
},
|
||||
} as unknown as Database;
|
||||
await new IconsBusinessService(database).onModuleInit();
|
||||
assert.deepEqual(await readFile(join(directory, historicalHash + '.png')), historical);
|
||||
assert.equal(
|
||||
(await readdir(directory)).filter((name) => name.startsWith('.write-check')).length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.ICON_STORAGE_DIR;
|
||||
else process.env.ICON_STORAGE_DIR = previous;
|
||||
assert.equal(dirname(resolve(directory)), resolve(tmpdir()));
|
||||
assert.ok(basename(directory).startsWith('wp_test_icon_files_'));
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import sharp from 'sharp';
|
||||
import { readBackupZip } from '../src/zip';
|
||||
import { readFile, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
||||
test('private and shared icons, account reuse and complete ZIP restoration preserve isolation', async () => {
|
||||
const db = new PrismaClient(),
|
||||
@@ -74,6 +76,21 @@ test('private and shared icons, account reuse and complete ZIP restoration prese
|
||||
assert.equal((await call('/icons')).status, 401);
|
||||
const own = await upload(a.cookie, '我的银行');
|
||||
assert.equal(own.status, 201);
|
||||
const stored = await db.icon.findUniqueOrThrow({ where: { id: own.data.id } });
|
||||
const persistedFile = process.env.ICON_STORAGE_DIR
|
||||
? join(process.env.ICON_STORAGE_DIR, stored.hash + '.png')
|
||||
: undefined;
|
||||
if (process.env.TEST_ISOLATED === 'true') {
|
||||
assert.ok(persistedFile);
|
||||
assert.deepEqual(await readFile(persistedFile), Buffer.from(stored.data));
|
||||
await unlink(persistedFile);
|
||||
assert.equal((await call('/icons/' + own.data.id + '/image', a.cookie)).status, 200);
|
||||
assert.deepEqual(await readFile(persistedFile), Buffer.from(stored.data));
|
||||
await writeFile(persistedFile, 'corrupt file');
|
||||
const repaired = await call('/icons/' + own.data.id + '/image', a.cookie);
|
||||
assert.deepEqual(repaired.data, Buffer.from(stored.data));
|
||||
assert.deepEqual(await readFile(persistedFile), Buffer.from(stored.data));
|
||||
}
|
||||
assert.equal('source' in own.data, false);
|
||||
const ownList = await call('/icons?q=' + encodeURIComponent('我的银行'), a.cookie);
|
||||
assert.equal('source' in ownList.data.items.find((i: any) => i.id === own.data.id), false);
|
||||
@@ -172,6 +189,7 @@ test('private and shared icons, account reuse and complete ZIP restoration prese
|
||||
400,
|
||||
);
|
||||
assert.equal(await db.icon.count(), before);
|
||||
if (process.env.TEST_ISOLATED === 'true') await unlink(persistedFile!);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/backup/restore-fixture', b.cookie, 'POST', {
|
||||
@@ -181,6 +199,8 @@ test('private and shared icons, account reuse and complete ZIP restoration prese
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
if (process.env.TEST_ISOLATED === 'true')
|
||||
assert.deepEqual(await readFile(persistedFile!), Buffer.from(stored.data));
|
||||
const imported = await db.position.findMany({
|
||||
where: { userId: b.id, importedFromId: { not: null } },
|
||||
include: { icon: true },
|
||||
|
||||
Reference in new issue
Block a user