diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ae5245d5..5b29925e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2458,3 +2458,19 @@ - 相关记录:BUG-126、BUG-132 - 复发自:BUG-126(模式:新增页面未同步能力审计精确路由集合) - 修复版本:待 follow-up commit / gate + +## BUG-144 | db/migrations 副本依赖业务 schema 导致 identity-only fixture 迁移失败 + +- 状态:resolved(local candidate,远端 gate 待 follow-up 更新) +- 首次发现:2026-08-07 +- 最近更新:2026-08-07 +- 影响面:`frontend/db/migrations/20260807020000_redeem_security.sql`(已删除)、`frontend/tests/redeem-orders-contract.test.ts`、`frontend/tests/database-redeem-security.test.ts`;staging quality gate run `1552` frontend 1054 passed / 2 failed,Python quick 291 passed / 1 skipped,publish / deploy 均未发生。 +- 用户现象:gate run `1552` 数据库真实失败,publish 被跳过,自动 deploy 未发生。 +- 触发条件:新增依赖业务 schema(`public.redemption_codes`、`public.credit_transactions`)的 redemption 安全迁移时,同时在 `frontend/db/migrations` 与 `frontend/supabase/migrations` 各放一份;`database-local-business` 聚合迁移(两目录全量)通过,而只应用 `frontend/db/migrations` 的 identity-only fixture 中业务 schema 尚不存在。 +- 根因:`database-self-hosted-identity` 与 `identity-auth-integration` 只应用 `frontend/db/migrations`(identity foundation),新的 db 副本假设 `public.redemption_codes` 已由 supabase compatibility 迁移创建;identity-only 序列因此 `migration failed`,与 BUG-127 同类(新增业务表必须精确选择迁移位置/全迁移),但不是 BUG-127 或 BUG-143 的复发,属本变更独立根因。 +- 修复:删除 `frontend/db/migrations/20260807020000_redeem_security.sql`,仅保留 `frontend/supabase/migrations/20260807030000_redeem_security.sql`;contract 测试只审该单一 migration,删除 byte-identical 双副本断言,并新增硬防线:断言 `frontend/db/migrations/20260807020000_redeem_security.sql` 不存在(`existsSync === false`);DB security 测试 aggregate runner 只期望 `applied 20260807030000_redeem_security.sql`,不再期待 070200,并断言 stdout 不含 `20260807020000_redeem_security`;SQL 内容未作任何变更。 +- 验证:本机无 Docker,无法执行 identity-only 与 aggregate DB 实测;运行 `npx tsx --test tests/redeem-orders-contract.test.ts`(7 passed,原 6 项 + 新增 existsSync 防复发守卫 1 项)、`tsc --noEmit`(clean)与 `git diff --check` 通过;目标 identity DB 实测(`database-self-hosted-identity`、`identity-auth-integration`)与 aggregate DB 测试(`database-local-business`、`database-redeem-security`)留待远端 gate 确认,本记录不提前声称远端收口。 +- 防复发:`frontend/db/migrations` 只放 identity foundation 独立可执行迁移;依赖业务 schema(`public.redemption_codes`、`payment_orders` 等)的迁移只进 `frontend/supabase/migrations`;任何新增/删除迁移必须在本变更中同时跑 identity-only 两测试(`database-self-hosted-identity`、`identity-auth-integration`)与 aggregate DB 测试(`database-local-business` 等)。 +- 相关记录:BUG-127、BUG-143 +- 复发自:无(独立根因,非 BUG-127 / BUG-143 复发) +- 修复版本:待 follow-up commit / gate diff --git a/frontend/db/migrations/20260807020000_redeem_security.sql b/frontend/db/migrations/20260807020000_redeem_security.sql deleted file mode 100644 index 62288541..00000000 --- a/frontend/db/migrations/20260807020000_redeem_security.sql +++ /dev/null @@ -1,131 +0,0 @@ -begin; - --- Account-level, cross-instance rate limiting for consecutive failed --- redemption attempts. The audit table records only who failed and when; --- neither the plaintext code nor its hash is ever stored here. It is --- failure-only: a successful redemption deletes the account's rows, so no --- permanent success row is kept. -create table if not exists public.redemption_attempts ( - id bigint generated always as identity primary key, - user_id uuid not null references auth.users(id) on delete cascade, - created_at timestamptz not null default now() -); - -create index if not exists redemption_attempts_user_created_idx - on public.redemption_attempts (user_id, created_at desc); - -alter table public.redemption_attempts enable row level security; -revoke all on table public.redemption_attempts from anon, authenticated; -grant select on table public.redemption_attempts to service_role; - --- CREATE OR REPLACE cannot change a function's return type, so the previous --- 3-column redeem_code(text) is dropped and rebuilt inside the same --- transaction, then its revoke/grant ACLs are restored below. -drop function if exists public.redeem_code(text); - --- redeem_code keeps the redemption-code row lock and the credit_transactions --- unique constraint. It additionally serializes per-account attempts, counts --- business failures within a rolling 10-minute window, and returns --- rate_limited once an account reaches 5 failures. Every business failure --- writes an audit row; a successful redemption deletes the account's --- attempts. account_not_eligible is intentionally not fabricated here: there --- is no account-eligibility restriction model yet. The audit table's --- identity sequence needs no authenticated USAGE grant because the --- security-definer function runs as its owner. -create or replace function public.redeem_code(p_code_hash text) -returns table (success boolean, credits integer, awarded_credits integer, error_code text) -language plpgsql -security definer -set search_path = public, pg_temp -as $$ -declare - v_user_id uuid := auth.uid(); - v_email text := auth.jwt() ->> 'email'; - v_code public.redemption_codes%rowtype; - v_balance integer; - v_failed integer; -begin - if v_user_id is null then - return query select false, null::integer, null::integer, 'unauthorized'::text; - return; - end if; - - perform pg_advisory_xact_lock(hashtextextended('redeem:' || v_user_id::text, 0)); - - delete from public.redemption_attempts - where user_id = v_user_id and created_at < now() - interval '10 minutes'; - - select count(*) into v_failed - from public.redemption_attempts - where user_id = v_user_id; - - if v_failed >= 5 then - return query select false, null::integer, null::integer, 'rate_limited'::text; - return; - end if; - - if p_code_hash is null or p_code_hash !~ '^[0-9a-f]{64}$' then - insert into public.redemption_attempts (user_id) values (v_user_id); - return query select false, null::integer, null::integer, 'invalid_code'::text; - return; - end if; - - select rc.* into v_code - from public.redemption_codes rc - where rc.code_hash = p_code_hash - for update; - if not found then - insert into public.redemption_attempts (user_id) values (v_user_id); - return query select false, null::integer, null::integer, 'invalid_code'::text; - return; - end if; - if v_code.redeemed_by is not null then - insert into public.redemption_attempts (user_id) values (v_user_id); - return query select false, null::integer, null::integer, 'already_redeemed'::text; - return; - end if; - if v_code.revoked_at is not null then - insert into public.redemption_attempts (user_id) values (v_user_id); - return query select false, null::integer, null::integer, 'revoked_code'::text; - return; - end if; - if v_code.expires_at is not null and v_code.expires_at <= now() then - insert into public.redemption_attempts (user_id) values (v_user_id); - return query select false, null::integer, null::integer, 'expired_code'::text; - return; - end if; - - select p.credits into v_balance - from public.profiles p - where p.id = v_user_id - for update; - if not found then - return query select false, null::integer, null::integer, 'profile_missing'::text; - return; - end if; - - update public.redemption_codes rc - set redeemed_by = v_user_id, redeemed_email = v_email, redeemed_at = now() - where rc.id = v_code.id; - - update public.profiles p - set credits = p.credits + v_code.credits, updated_at = now() - where p.id = v_user_id - returning p.credits into v_balance; - - insert into public.credit_transactions ( - user_id, transaction_type, amount, balance_after, request_id, redemption_code_id - ) values ( - v_user_id, 'redeem', v_code.credits, v_balance, v_code.id::text, v_code.id - ); - - delete from public.redemption_attempts where user_id = v_user_id; - - return query select true, v_balance, v_code.credits, null::text; -end; -$$; - -revoke all on function public.redeem_code(text) from public, anon; -grant execute on function public.redeem_code(text) to authenticated; - -commit; diff --git a/frontend/tests/database-redeem-security.test.ts b/frontend/tests/database-redeem-security.test.ts index 755926bb..66ef23f3 100644 --- a/frontend/tests/database-redeem-security.test.ts +++ b/frontend/tests/database-redeem-security.test.ts @@ -33,8 +33,11 @@ test("redeem security: case-sensitive hashing, rate limiting, idempotency and or }, }); assert.equal(migration.status, 0, migration.stderr); - assert.match(migration.stdout, /applied 20260807020000_redeem_security\.sql/); + // Only the supabase compatibility migration exists; the db/migrations + // copy was removed because identity-only fixtures apply that directory + // without the business schema (see BUG-144). assert.match(migration.stdout, /applied 20260807030000_redeem_security\.sql/); + assert.doesNotMatch(migration.stdout, /20260807020000_redeem_security/); fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` insert into identity.users (name, email, email_verified, email_verified_at) diff --git a/frontend/tests/redeem-orders-contract.test.ts b/frontend/tests/redeem-orders-contract.test.ts index 913abf80..f887082b 100644 --- a/frontend/tests/redeem-orders-contract.test.ts +++ b/frontend/tests/redeem-orders-contract.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import test from "node:test"; import { formatPaymentOrders, @@ -18,11 +18,10 @@ const ordersRoute = readFileSync( new URL("../src/app/api/payment/orders/route.ts", import.meta.url), "utf8", ); -const dbMigration = readFileSync( - new URL("../db/migrations/20260807020000_redeem_security.sql", import.meta.url), - "utf8", -); -const supabaseMigration = readFileSync( +// Only the supabase compatibility migration exists: business-schema +// migrations must not be duplicated into db/migrations (identity-only +// fixtures apply that directory without the business schema; see BUG-144). +const redeemSecurityMigration = readFileSync( new URL("../supabase/migrations/20260807030000_redeem_security.sql", import.meta.url), "utf8", ); @@ -170,7 +169,7 @@ test("payment orders summary is desensitized to the allowlist", () => { }); test("redeem security migration adds a hash-free failure-only audit table with least privilege", () => { - const tableBlock = dbMigration.match( + const tableBlock = redeemSecurityMigration.match( /create table if not exists public\.redemption_attempts[\s\S]*?\);/, ); assert.ok(tableBlock, "redemption_attempts table must be created"); @@ -180,26 +179,33 @@ test("redeem security migration adds a hash-free failure-only audit table with l // Failure-only: no success column, no plaintext code and no code hash. assert.doesNotMatch(tableBlock[0], /success|code|hash|mask/i); - assert.match(dbMigration, /drop function if exists public\.redeem_code\(text\);/); - assert.match(dbMigration, /alter table public\.redemption_attempts enable row level security/); - assert.match(dbMigration, /revoke all on table public\.redemption_attempts from anon, authenticated/); - assert.match(dbMigration, /grant select on table public\.redemption_attempts to service_role/); - assert.match(dbMigration, /pg_advisory_xact_lock/); - assert.match(dbMigration, /interval '10 minutes'/); - assert.match(dbMigration, /v_failed >= 5/); + assert.match(redeemSecurityMigration, /drop function if exists public\.redeem_code\(text\);/); + assert.match(redeemSecurityMigration, /alter table public\.redemption_attempts enable row level security/); + assert.match(redeemSecurityMigration, /revoke all on table public\.redemption_attempts from anon, authenticated/); + assert.match(redeemSecurityMigration, /grant select on table public\.redemption_attempts to service_role/); + assert.match(redeemSecurityMigration, /pg_advisory_xact_lock/); + assert.match(redeemSecurityMigration, /interval '10 minutes'/); + assert.match(redeemSecurityMigration, /v_failed >= 5/); // Business failures insert a single-column audit row; success deletes all // of the account's attempts instead of inserting a permanent success row. - assert.match(dbMigration, /insert into public\.redemption_attempts \(user_id\) values \(v_user_id\)/); - assert.match(dbMigration, /delete from public\.redemption_attempts where user_id = v_user_id;/); + assert.match(redeemSecurityMigration, /insert into public\.redemption_attempts \(user_id\) values \(v_user_id\)/); + assert.match(redeemSecurityMigration, /delete from public\.redemption_attempts where user_id = v_user_id;/); // The redemption-code row lock is kept and the credit_transactions // unique constraint is preserved untouched (nothing is dropped). - assert.match(dbMigration, /from public\.redemption_codes rc\s+where rc\.code_hash = p_code_hash\s+for update/); - assert.doesNotMatch(dbMigration, /alter table public\.credit_transactions/); - assert.match(dbMigration, /grant execute on function public\.redeem_code\(text\) to authenticated/); - assert.match(dbMigration, /revoke all on function public\.redeem_code\(text\) from public, anon/); + assert.match(redeemSecurityMigration, /from public\.redemption_codes rc\s+where rc\.code_hash = p_code_hash\s+for update/); + assert.doesNotMatch(redeemSecurityMigration, /alter table public\.credit_transactions/); + assert.match(redeemSecurityMigration, /grant execute on function public\.redeem_code\(text\) to authenticated/); + assert.match(redeemSecurityMigration, /revoke all on function public\.redeem_code\(text\) from public, anon/); }); -test("supabase compatibility migration is byte-identical to the primary migration", () => { - assert.equal(supabaseMigration, dbMigration); - assert.match(supabaseMigration, /create table if not exists public\.redemption_attempts/); +test("db/migrations must not carry the business-schema redemption copy (BUG-144 guard)", () => { + assert.equal( + existsSync( + new URL( + "../db/migrations/20260807020000_redeem_security.sql", + import.meta.url, + ), + ), + false, + ); });