fix: recover empty database migrations and persist Docker icons

This commit is contained in:
陈煜 committed 2026-10-05 19:44:18 +08:00
1 parent 2db0c75498
commit 35bd2e1828
20 files changed
+577 -62

No files matched your search

+2
View File
@@ -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=
+1 -1
View File
@@ -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
View File
@@ -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;
}
+15 -36
View File
@@ -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;
});
+6 -1
View File
@@ -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({
+63
View File
@@ -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
View File
@@ -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 },
+3 -1
View File
@@ -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;
});
+99
View File
@@ -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();
}
},
);
+39
View File
@@ -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/,
);
});
+57
View File
@@ -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 });
}
});
+20
View File
@@ -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 },