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

+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 },