From 53e04da7db8a1147ea434f4fea0dd5928de5fd01 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 30 Jul 2026 15:45:23 +0800 Subject: [PATCH] fix(deploy): verify all retired staging migrations --- docs/BUG_HISTORY.md | 6 +-- frontend/scripts/db-migrate.mjs | 16 ++++++++ .../tests/migration-retired-ledger.test.ts | 39 ++++++++++++++----- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 2c02aeda..6401bd99 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1780,9 +1780,9 @@ - 最近更新:2026-07-30 - 影响面:staging migration check、exact-SHA 应用发布 - 用户现象:生时纠正 V6 修复已经通过质量门并合入 `main`,但 staging 在切换镜像前报 `migration file missing: 20260727010000_admin_users.sql`,因此仍运行旧版本。 -- 根因:staging 的 append-only migration 账本包含 `20260727010000_admin_users.sql`,而该历史文件已不在任何受审仓库历史中;现有 runner 要求每一条账本记录都对应当前文件,因此安全停止。 -- 修复:在 migration runner 中加入一条静态 retired migration 记录,固定校验该文件已观测到的 SHA-256;只有文件名和 checksum 同时完全匹配时才允许继续。checksum 漂移、其他遗失 migration 或非法文件名仍然失败关闭;不删除账本记录,也不动态信任数据库返回值。 -- 验证:新增无数据库单元测试覆盖 retired migration 正确 checksum 通过、错误 checksum 拒绝、未声明遗失 migration 继续拒绝;staging 发布仍须通过原 migration check、exact digest 和 exact SHA 门禁。 +- 根因:staging 的 append-only migration 账本包含 5 条已不在任何受审仓库历史中的支付/管理 migration 记录;现有 runner 要求每一条账本记录都对应当前文件,因此按顺序安全停止。 +- 修复:在 migration runner 中加入 5 条静态 retired migration 记录,逐条固定校验从 staging 只读账本核对到的 SHA-256;只有文件名和 checksum 同时完全匹配时才允许继续。checksum 漂移、其他遗失 migration 或非法文件名仍然失败关闭;不删除账本记录,也不动态信任数据库返回值。 +- 验证:新增无数据库单元测试覆盖全部 5 条 retired migration 正确 checksum 通过、任一错误 checksum 拒绝、未声明遗失 migration 继续拒绝;staging 发布仍须通过原 migration check、exact digest 和 exact SHA 门禁。 - 防复发:历史 migration 文件不得从受审仓库删除;若必须兼容已遗失记录,只能用代码审查过的静态 filename + checksum,并保留 fail-closed 测试。 - 相关记录:BUG-099 - 修复版本:staging migration integrity compatibility diff --git a/frontend/scripts/db-migrate.mjs b/frontend/scripts/db-migrate.mjs index 4cca8f2f..446364b6 100644 --- a/frontend/scripts/db-migrate.mjs +++ b/frontend/scripts/db-migrate.mjs @@ -11,6 +11,22 @@ const retiredMigrationChecksums = new Map([ "20260727010000_admin_users.sql", "785f4fdc65db1028623cc7b5a2571217b913ef9e55f5a17b01658a71612976de", ], + [ + "20260727020000_epay_packages_orders.sql", + "e922177b4d60d04ba9380b19badba1ffbe792304b1580f8748f7ad1b6e855e1b", + ], + [ + "20260727030000_payment_admin_stats.sql", + "b71e46ca696d0ef2b74f239829f9f808dd910742e32d3e3f7dc641a1ad7e767d", + ], + [ + "20260729010000_epay_settings.sql", + "dc3ed919b463e96b79473c19235dceb7cb491362b1f684db070aea80830cf6e9", + ], + [ + "20260730010000_admin_payment_permissions.sql", + "1744437eb133f860930898fd1a33c07440d4a63ff22a8f34c0b5e3ddb286c177", + ], ]); class SafeMigrationError extends Error {} diff --git a/frontend/tests/migration-retired-ledger.test.ts b/frontend/tests/migration-retired-ledger.test.ts index 06295d4b..d8b38706 100644 --- a/frontend/tests/migration-retired-ledger.test.ts +++ b/frontend/tests/migration-retired-ledger.test.ts @@ -3,20 +3,39 @@ import test from "node:test"; import { assertLedgerFilesPresent } from "../scripts/db-migrate.mjs"; -const retiredFilename = "20260727010000_admin_users.sql"; -const retiredChecksum = - "785f4fdc65db1028623cc7b5a2571217b913ef9e55f5a17b01658a71612976de"; +const retiredMigrations = new Map([ + [ + "20260727010000_admin_users.sql", + "785f4fdc65db1028623cc7b5a2571217b913ef9e55f5a17b01658a71612976de", + ], + [ + "20260727020000_epay_packages_orders.sql", + "e922177b4d60d04ba9380b19badba1ffbe792304b1580f8748f7ad1b6e855e1b", + ], + [ + "20260727030000_payment_admin_stats.sql", + "b71e46ca696d0ef2b74f239829f9f808dd910742e32d3e3f7dc641a1ad7e767d", + ], + [ + "20260729010000_epay_settings.sql", + "dc3ed919b463e96b79473c19235dceb7cb491362b1f684db070aea80830cf6e9", + ], + [ + "20260730010000_admin_payment_permissions.sql", + "1744437eb133f860930898fd1a33c07440d4a63ff22a8f34c0b5e3ddb286c177", + ], +]); -test("accepts the retired staging migration only with its reviewed checksum", () => { - assert.doesNotThrow(() => - assertLedgerFilesPresent(new Map([[retiredFilename, retiredChecksum]]), []), - ); +test("accepts every retired staging migration only with its reviewed checksum", () => { + assert.doesNotThrow(() => assertLedgerFilesPresent(retiredMigrations, [])); }); -test("rejects checksum drift for the retired staging migration", () => { +test("rejects checksum drift for a retired staging migration", () => { + const drifted = new Map(retiredMigrations); + drifted.set("20260727020000_epay_packages_orders.sql", "0".repeat(64)); assert.throws( - () => assertLedgerFilesPresent(new Map([[retiredFilename, "0".repeat(64)]]), []), - /migration checksum mismatch: 20260727010000_admin_users\.sql/, + () => assertLedgerFilesPresent(drifted, []), + /migration checksum mismatch: 20260727020000_epay_packages_orders\.sql/, ); });