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
@@ -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