diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md index 1645c6b4..5e8f5aa6 100644 --- a/docs/MIGRATIONS.md +++ b/docs/MIGRATIONS.md @@ -18,6 +18,86 @@ Edit the generated file with your SQL. Always make migrations **idempotent** whe ## Applying Migrations +### The rule: cloud changes go through `supabase db push` + +**Never apply a migration to cloud with the MCP `apply_migration` tool, the SQL +editor, or a raw Management API call unless you also stamp it with the version +from its filename.** This is not style preference — it is what keeps drift +detectable. + +`supabase_migrations.schema_migrations` is the only record of what cloud has +run. `supabase db push` stamps each migration with **the timestamp from its +filename**. Every ad-hoc path stamps a **fresh** timestamp instead. Once the +ledger holds a stamp that matches no file in `supabase/migrations/`, the +question "what is missing from cloud?" stops having a usable answer: the +re-stamped migrations show up as *pending* (false positives), and a genuinely +missing migration is indistinguishable from them. + +That is not hypothetical. It is how issue #541 happened: + +| Repo file | Cloud stamp before #541 | +|---|---| +| `20260721120000_add_binance_personal_provider` | `20260725213441` | +| `20260725110000_transaction_split_snapshot_backstop` | `20260725213508` | +| `20260725160000_entitlement_gated_enrollment_inserts` | `20260725213610` | +| `20260725170000_transactions_column_hardening` | `20260725192946` | +| `20260725180000_transactions_insert_lockdown` | **never applied** | + +Four migrations were applied through `apply_migration` and re-stamped. The +fifth — the `transactions` INSERT lockdown from #538, a `severity:critical` +payments fix — was never applied at all, and hid among the four false +positives for a day. Diffing the repo against the ledger reported all five as +pending, so the real gap looked like more of the same noise. + +If you do have to apply SQL out of band, **immediately** repair the stamp: + +```bash +# Preferred — the CLI's purpose-built command for this +supabase migration repair --status applied +supabase migration repair --status reverted +``` + +Then run `npm run verify:cloud` (below) and confirm it is clean before moving on. + +### Verifying cloud matches this repo + +```bash +SUPABASE_ACCESS_TOKEN=sbp_... npm run verify:cloud +``` + +Queries the **live** database and exits non-zero on drift. It asserts both +halves of the problem above: + +- **Ledger integrity** — every repo migration is stamped on cloud under its own + filename, and cloud carries no stamp that matches no file. +- **Payment invariants** (#512 / #528 / #538) — `authenticated` and `anon` hold + no INSERT on `transactions`, no column-level INSERT grant survives, + `service_role` still has INSERT, the `authenticated` UPDATE grant is exactly + `(status, provider_subscription_id, stripe_payment_intent_id)`, the INSERT + policy pins `status` plus all four `settlement_*` columns to NULL, and both + `before_transaction_split_snapshot_*` triggers exist and are enabled. + +It reads catalog state, not migration text, so it also catches a *later* +migration re-widening something — including a schema dump re-applying the +original `GRANT ALL ON TABLE transactions TO authenticated`. + +**Run it from `master`, after merging.** The two ledger checks compare cloud +against the migrations in your *current checkout*. On a feature branch, any +migration another in-flight branch has already applied to cloud shows up as an +orphan, and any migration on your branch not yet applied shows up as pending — +neither is real drift. The payment-invariant checks are branch-independent and +meaningful anywhere. + +The token is a [personal access token](https://supabase.com/dashboard/account/tokens). +It uses the Management API over HTTPS rather than a Postgres connection because +port 5432 is blocked on some networks this project is developed from — the same +reason Option 2 below exists. + +> Unit tests cannot do this job. `tests/unit/verify-cloud-schema.test.ts` and +> `tests/unit/transaction-split-snapshot-backstop.test.ts` prove the *rules* are +> right; neither opens a database connection. A green `npm run test:unit` says +> nothing about whether a migration was ever applied. Only `verify:cloud` does. + ### Option 1: Supabase CLI (preferred) ```bash @@ -30,7 +110,10 @@ This connects via the Supabase connection pooler and applies all pending migrati ### Option 2: Management API (fallback) -When the CLI can't connect, push migrations via the Supabase Management API: +When the CLI can't connect, push migrations via the Supabase Management API. +**This is the path that creates drift** — the `INSERT INTO schema_migrations` +line below is not optional bookkeeping, it is the entire reason this fallback is +safe to use: ```bash # 1. Get your access token (stored in macOS keychain by the CLI) @@ -91,16 +174,22 @@ process.stdout.write(JSON.stringify({query: 'SELECT version, name FROM supabase_ ### What's pending locally? -Compare local files against remote records: +```bash +SUPABASE_ACCESS_TOKEN=sbp_... npm run verify:cloud +``` + +Use this rather than eyeballing a diff. The manual comparison — ```bash -# Local migration versions ls supabase/migrations/*.sql | sed 's|.*/||' | sed 's/_.*//' - -# Remote versions (use one of the methods above) ``` -Any local version not in the remote list needs to be applied. +— is only trustworthy when the ledger is clean. If any migration was ever +applied out of band, a local version missing from the remote list means *either* +"never applied" *or* "applied under a different stamp", and nothing in the diff +tells you which. `verify:cloud` reports the two cases separately (`not on cloud` +vs `orphan stamps`) and additionally checks that the payment invariants actually +hold, which no filename comparison can do. ## Running the Production Seed @@ -158,15 +247,26 @@ DELETE FROM courses WHERE author_id NOT IN (SELECT id FROM profiles); ### Migration version mismatch -If the remote `schema_migrations` table has different version numbers than local filenames (e.g. migrations were applied via dashboard with auto-generated timestamps): +If the remote `schema_migrations` table has different version numbers than local filenames (e.g. migrations were applied via dashboard or `apply_migration` with auto-generated timestamps), prefer the CLI's purpose-built command: + +```bash +supabase migration repair --status applied +supabase migration repair --status reverted +``` + +If the CLI cannot reach the pooler, the equivalent write is: ```sql --- Update the remote record to match the local filename +-- Re-stamp the remote record to match the local filename. +-- Verify FIRST that the DDL really is live (query the grants/policies/triggers +-- it created) — this only repairs the ledger, it does not apply anything. UPDATE supabase_migrations.schema_migrations SET version = '' WHERE version = '' AND name = ''; ``` +Confirm with `npm run verify:cloud` afterwards. + ### Prepared statement errors (port 6543) If you try the session pooler (port 6543) and get `prepared statement already exists`, use port 5432 or the Management API instead. The session pooler doesn't work well with the Supabase CLI's connection handling. diff --git a/package.json b/package.json index 7fa0da9f..469d00e8 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "db:reset": "supabase db reset", "db:push": "supabase db push", "db:types": "supabase gen types typescript --linked --schema public > lib/database.types.ts", + "verify:cloud": "tsx scripts/verify-cloud-schema.ts", "mcp:build": "cd mcp-server && npm run build", "backup-course": "tsx scripts/backup-english-course.ts", "restore-course": "tsx scripts/restore-english-course.ts", diff --git a/scripts/lib/verify-cloud-schema-checks.ts b/scripts/lib/verify-cloud-schema-checks.ts new file mode 100644 index 00000000..7f5f3b1d --- /dev/null +++ b/scripts/lib/verify-cloud-schema-checks.ts @@ -0,0 +1,181 @@ +/** + * Issue #541 — the assertion half of `npm run verify:cloud`, kept pure. + * + * Split out from `scripts/verify-cloud-schema.ts` so the rules can be exercised + * without a network round-trip or a live database. That matters here for a + * specific reason: the acceptance criterion for #541 is "the check FAILS if you + * re-grant INSERT", and the honest way to demonstrate that is a test that feeds + * in a re-granted fixture — not briefly re-opening a write grant on the + * payments table of the only production database this project has. + * + * `tests/unit/verify-cloud-schema.test.ts` drives every rule below from both a + * healthy fixture and a drifted one. + */ + +export type MigrationLedger = { + /** Version prefixes of `supabase/migrations/*.sql`, e.g. `20260725180000`. */ + repoVersions: string[] + /** `version` column of `supabase_migrations.schema_migrations` on cloud. */ + cloudVersions: string[] +} + +export type TransactionPrivileges = { + auth_insert: boolean + anon_insert: boolean + service_insert: boolean + /** Comma-joined column list, or null when no column-level grant exists. */ + auth_col_insert: string | null + auth_col_update: string | null +} + +export type InsertPolicy = { policyname: string; with_check: string | null } +export type TriggerState = { tgname: string; enabled: boolean } + +export type CloudFacts = { + ledger: MigrationLedger + priv: TransactionPrivileges + insertPolicies: InsertPolicy[] + triggers: TriggerState[] +} + +export type Check = { name: string; ok: boolean; detail: string } + +/** The exact `authenticated` UPDATE column grant #528 established. */ +export const EXPECTED_UPDATE_COLS = + 'provider_subscription_id, status, stripe_payment_intent_id' + +/** What the #538 INSERT policy must pin. Rendered form, after normalisation. */ +export const REQUIRED_POLICY_PINS = [ + "status = 'pending'", + 'settlement_currency IS NULL', + 'settlement_base IS NULL', + 'settlement_mint IS NULL', + 'settlement_sol_usd IS NULL', +] + +export const SPLIT_SNAPSHOT_TRIGGERS = [ + 'before_transaction_split_snapshot_insert', + 'before_transaction_split_snapshot_update', +] + +/** + * Strip the catalog's type annotations and collapse whitespace, so a pin written + * `status = 'pending'` matches how Postgres renders it back + * (`status = 'pending'::transaction_status`). + */ +export function normalisePolicyExpr(expr: string): string { + return expr.replace(/::[a-z_]+/g, '').replace(/\s+/g, ' ') +} + +export function evaluateChecks(facts: CloudFacts): Check[] { + const checks: Check[] = [] + const add = (name: string, ok: boolean, detail: string) => + checks.push({ name, ok, detail }) + + // ------------------------------------------------------------------------- + // A. Ledger integrity — what makes any future drift detectable at all. + // ------------------------------------------------------------------------- + const cloudSet = new Set(facts.ledger.cloudVersions) + const repoSet = new Set(facts.ledger.repoVersions) + const pending = facts.ledger.repoVersions.filter((v) => !cloudSet.has(v)) + const orphans = facts.ledger.cloudVersions.filter((v) => !repoSet.has(v)) + + add( + 'every repo migration is applied to cloud', + pending.length === 0, + pending.length + ? `not on cloud: ${pending.join(', ')}` + : `all ${facts.ledger.repoVersions.length} applied` + ) + + // An orphan stamp is the failure mode that hid #538: it matches no file, so it + // pads the ledger and makes the pending list above untrustworthy. + // + // RUN THIS FROM `master`, AFTER MERGING. Both lists compare the ledger against + // the migrations present on the CURRENT checkout, so on a feature branch every + // migration another in-flight branch has already applied to cloud reads as an + // orphan, and every migration on this branch not yet applied reads as pending. + // Neither is drift. The detail line below says so rather than leaving someone + // to rediscover it and conclude the check is noisy. + add( + 'cloud carries no migration stamp that matches no repo file', + orphans.length === 0, + orphans.length + ? `orphan stamps (applied ad hoc, not via db push — or applied by an ` + + `in-flight branch this checkout does not have; re-check on master): ` + + orphans.join(', ') + : 'ledger matches repo filenames exactly' + ) + + // ------------------------------------------------------------------------- + // B. The #528 / #538 payment invariants, against live catalog state. + // ------------------------------------------------------------------------- + add( + 'authenticated holds no INSERT on transactions (#538)', + facts.priv.auth_insert === false, + facts.priv.auth_insert + ? 'GRANT IS PRESENT — a caller can open a self-priced row' + : 'revoked' + ) + + add( + 'anon holds no INSERT on transactions (#538)', + facts.priv.anon_insert === false, + facts.priv.anon_insert ? 'GRANT IS PRESENT' : 'revoked' + ) + + // A table-level revoke leaves no column grants, but a later migration could + // add one and reopen the path without restoring the table grant. + add( + 'no column-level INSERT grant to authenticated survives', + facts.priv.auth_col_insert === null, + facts.priv.auth_col_insert ? `columns: ${facts.priv.auth_col_insert}` : '(none)' + ) + + // The lockdown must not have taken the server-side writers with it. + add( + 'service_role retains INSERT (webhooks, reconcilers, crons, admin routes)', + facts.priv.service_insert === true, + facts.priv.service_insert + ? 'granted' + : 'REVOKED — every payment write path is broken' + ) + + add( + `authenticated UPDATE grant is exactly (${EXPECTED_UPDATE_COLS}) (#528)`, + facts.priv.auth_col_update === EXPECTED_UPDATE_COLS, + `actual: ${facts.priv.auth_col_update ?? '(none)'}` + ) + + // The policy is unreachable while the grant is revoked. It is asserted anyway + // because the revoke is one `GRANT INSERT` away from being undone, and these + // pins are what keep the #538 under-quote impossible if that ever happens. + if (facts.insertPolicies.length === 0) { + add( + 'transactions INSERT policy pins status + all four settlement columns (#538)', + false, + 'no INSERT policy found on public.transactions' + ) + } else { + for (const p of facts.insertPolicies) { + const normalised = normalisePolicyExpr(p.with_check ?? '') + const missing = REQUIRED_POLICY_PINS.filter((pin) => !normalised.includes(pin)) + add( + `INSERT policy "${p.policyname}" pins status + all four settlement columns (#538)`, + missing.length === 0, + missing.length ? `missing pins: ${missing.join('; ')}` : 'all pins present' + ) + } + } + + for (const name of SPLIT_SNAPSHOT_TRIGGERS) { + const row = facts.triggers.find((t) => t.tgname === name) + add( + `trigger ${name} exists and is enabled (#512)`, + row?.enabled === true, + !row ? 'MISSING' : row.enabled ? 'enabled' : 'DISABLED' + ) + } + + return checks +} diff --git a/scripts/verify-cloud-schema.ts b/scripts/verify-cloud-schema.ts new file mode 100644 index 00000000..9ea47215 --- /dev/null +++ b/scripts/verify-cloud-schema.ts @@ -0,0 +1,188 @@ +#!/usr/bin/env tsx +/** + * Issue #541 — assert that the CLOUD database matches what this repo claims. + * + * WHY THIS EXISTS. The `transactions` INSERT lockdown (#538 / + * `20260725180000_transactions_insert_lockdown.sql`) sat unapplied on cloud + * while every signal a developer normally trusts said it had shipped: the + * migration was in `supabase/migrations/`, the PR was merged, and the unit + * suite was green. None of those touch the database — + * `tests/unit/transaction-split-snapshot-backstop.test.ts` says so in its own + * header ("a green run of this file means nothing about whether the migration + * was ever applied"). + * + * It got lost because sibling migrations had been applied through the MCP + * `apply_migration` tool, which stamps `supabase_migrations.schema_migrations` + * with a FRESH timestamp instead of the repo filename. Once the ledger holds + * stamps that match no file, "diff the repo against the ledger" produces false + * positives — and a genuinely missing migration hides in the noise. See + * `docs/MIGRATIONS.md`. + * + * So this asserts two different things, and both matter: + * + * A. LEDGER INTEGRITY — every repo migration is stamped on cloud under its own + * filename, and cloud carries no stamp matching no file. This is what makes + * drift detectable at all; without it the other checks only cover the + * invariants someone thought to write down. + * + * B. THE #528/#538 PAYMENT INVARIANTS — asserted against the LIVE grant and + * policy state, not migration text, so a later migration re-widening things + * (or a schema dump re-applying the original `GRANT ALL ... TO + * authenticated` from 20260126190500_lms_complete.sql) fails here too. + * + * RUN IT: + * + * SUPABASE_ACCESS_TOKEN=sbp_... npm run verify:cloud + * + * The token is a Supabase personal access token + * (https://supabase.com/dashboard/account/tokens). This goes over the Management + * API deliberately rather than a Postgres connection: the pooler on port 5432 is + * blocked on some networks this project is developed from, which is the same + * reason `docs/MIGRATIONS.md` documents an HTTPS fallback for pushes. + * + * The rules themselves live in `scripts/lib/verify-cloud-schema-checks.ts` and + * are unit-tested in `tests/unit/verify-cloud-schema.test.ts`. + * + * Exit codes: 0 all checks passed · 1 drift detected · 2 could not run. + */ + +import { readdirSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + evaluateChecks, + type CloudFacts, + type InsertPolicy, + type TransactionPrivileges, + type TriggerState, +} from './lib/verify-cloud-schema-checks.ts' + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const MIGRATIONS_DIR = resolve(REPO_ROOT, 'supabase/migrations') + +try { + process.loadEnvFile(resolve(REPO_ROOT, '.env.local')) +} catch { + // Absent in CI; the vars below may still be supplied directly. +} + +const PROJECT_REF = + process.env.SUPABASE_PROJECT_REF ?? + process.env.NEXT_PUBLIC_SUPABASE_URL?.match(/https:\/\/([^.]+)\./)?.[1] ?? + 'tcqqnjfwmbfwcyhafbbt' + +const ACCESS_TOKEN = process.env.SUPABASE_ACCESS_TOKEN + +async function query(sql: string): Promise { + const res = await fetch( + `https://api.supabase.com/v1/projects/${PROJECT_REF}/database/query`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${ACCESS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query: sql }), + } + ) + + if (!res.ok) { + throw new Error( + `Management API ${res.status} ${res.statusText}: ${await res.text()}` + ) + } + return (await res.json()) as T[] +} + +async function collectFacts(): Promise { + const repoVersions = readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith('.sql')) + .map((f) => f.split('_')[0]) + .sort() + + const ledgerRows = await query<{ version: string }>( + 'select version from supabase_migrations.schema_migrations order by version' + ) + + const [priv] = await query(` + select + has_table_privilege('authenticated','public.transactions','INSERT') as auth_insert, + has_table_privilege('anon','public.transactions','INSERT') as anon_insert, + has_table_privilege('service_role','public.transactions','INSERT') as service_insert, + ( + select string_agg(a.attname, ', ' order by a.attname) + from pg_attribute a + where a.attrelid = 'public.transactions'::regclass + and a.attnum > 0 and not a.attisdropped + and has_column_privilege('authenticated', a.attrelid, a.attname, 'INSERT') + ) as auth_col_insert, + ( + select string_agg(a.attname, ', ' order by a.attname) + from pg_attribute a + where a.attrelid = 'public.transactions'::regclass + and a.attnum > 0 and not a.attisdropped + and has_column_privilege('authenticated', a.attrelid, a.attname, 'UPDATE') + ) as auth_col_update + `) + + const insertPolicies = await query(` + select polname as policyname, pg_get_expr(polwithcheck, polrelid) as with_check + from pg_policy + where polrelid = 'public.transactions'::regclass and polcmd = 'a' + `) + + const triggers = await query(` + select t.tgname, (t.tgenabled <> 'D') as enabled + from pg_trigger t + where t.tgrelid = 'public.transactions'::regclass + and not t.tgisinternal + and t.tgname in ('before_transaction_split_snapshot_insert', + 'before_transaction_split_snapshot_update') + `) + + return { + ledger: { repoVersions, cloudVersions: ledgerRows.map((r) => r.version) }, + priv, + insertPolicies, + triggers, + } +} + +async function main() { + if (!ACCESS_TOKEN) { + console.error( + 'SUPABASE_ACCESS_TOKEN is not set.\n\n' + + 'Create a personal access token at https://supabase.com/dashboard/account/tokens\n' + + 'then re-run:\n\n' + + ' SUPABASE_ACCESS_TOKEN=sbp_... npm run verify:cloud\n' + ) + process.exit(2) + } + + console.log(`Verifying cloud project ${PROJECT_REF}\n`) + + const checks = evaluateChecks(await collectFacts()) + + const width = Math.max(...checks.map((c) => c.name.length)) + for (const c of checks) { + console.log(`${c.ok ? 'PASS' : 'FAIL'} ${c.name.padEnd(width)} ${c.detail}`) + } + + const failed = checks.filter((c) => !c.ok) + console.log() + if (failed.length) { + console.error( + `${failed.length} of ${checks.length} checks FAILED.\n` + + 'Cloud does not match this repo. See docs/MIGRATIONS.md — cloud changes go\n' + + 'through `supabase db push`, never through ad-hoc apply_migration.' + ) + process.exit(1) + } + console.log(`All ${checks.length} checks passed.`) +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err) + process.exit(2) +}) diff --git a/supabase/migrations/20260726005843_repair_migration_ledger_541.sql b/supabase/migrations/20260726005843_repair_migration_ledger_541.sql new file mode 100644 index 00000000..9a80761d --- /dev/null +++ b/supabase/migrations/20260726005843_repair_migration_ledger_541.sql @@ -0,0 +1,52 @@ +-- Issue #541 — repair the cloud migration ledger. +-- +-- Four migrations were applied through the MCP `apply_migration` tool, which +-- stamps supabase_migrations.schema_migrations with a FRESH timestamp instead of +-- the migration's own filename. A fifth (20260725180000_transactions_insert_lockdown) +-- was never applied at all and hid among the four false positives: +-- +-- repo file cloud stamp before this +-- 20260721120000_add_binance_personal_provider 20260725213441 +-- 20260725110000_transaction_split_snapshot_backstop 20260725213508 +-- 20260725160000_entitlement_gated_enrollment_inserts 20260725213610 +-- 20260725170000_transactions_column_hardening 20260725192946 +-- 20260725180000_transactions_insert_lockdown (never applied) +-- +-- The DDL of all four was confirmed genuinely live by querying what it created +-- (the 'binance_personal' CHECK value on products.payment_provider, +-- has_course_access() in the enrollments INSERT policy, both +-- before_transaction_split_snapshot_* triggers, and the three-column +-- authenticated UPDATE grant on transactions) BEFORE this repair was written. +-- So this re-stamps the ledger only; it re-runs no DDL. +-- +-- 20260725180000's DDL was applied separately in this same issue, which is why +-- its stamp is inserted here rather than moved. +-- +-- Idempotent by construction: on a fresh database (`supabase db reset`) the four +-- UPDATEs match nothing, and 20260725180000 is already stamped by the time this +-- file runs, so the INSERT no-ops. Safe to re-run anywhere. +-- +-- The rule this incident produced is in docs/MIGRATIONS.md: cloud changes go +-- through `supabase db push`, never through ad-hoc apply_migration, precisely +-- because the latter breaks drift detection. `npm run verify:cloud` now fails +-- loudly if it happens again. + +UPDATE supabase_migrations.schema_migrations + SET version = '20260721120000' + WHERE version = '20260725213441' AND name = 'add_binance_personal_provider'; + +UPDATE supabase_migrations.schema_migrations + SET version = '20260725110000' + WHERE version = '20260725213508' AND name = 'transaction_split_snapshot_backstop'; + +UPDATE supabase_migrations.schema_migrations + SET version = '20260725160000' + WHERE version = '20260725213610' AND name = 'entitlement_gated_enrollment_inserts'; + +UPDATE supabase_migrations.schema_migrations + SET version = '20260725170000' + WHERE version = '20260725192946' AND name = 'transactions_column_hardening'; + +INSERT INTO supabase_migrations.schema_migrations (version, name) +VALUES ('20260725180000', 'transactions_insert_lockdown') +ON CONFLICT (version) DO NOTHING; diff --git a/tests/unit/verify-cloud-schema.test.ts b/tests/unit/verify-cloud-schema.test.ts new file mode 100644 index 00000000..32d8d898 --- /dev/null +++ b/tests/unit/verify-cloud-schema.test.ts @@ -0,0 +1,212 @@ +/** + * Issue #541 — proof that `npm run verify:cloud` actually FAILS on drift. + * + * The acceptance criterion for #541 is that the drift check "fails if you + * manually re-grant INSERT". Demonstrating that against the live project would + * mean briefly restoring a write grant on the payments table of the only + * production database this project has, purely to watch a script go red. These + * fixtures do it repeatably and in CI instead, with no window where the grant is + * open. + * + * The HEALTHY fixture is not invented: it is the exact catalog state read back + * from the cloud project on 2026-07-26 after + * `20260725180000_transactions_insert_lockdown.sql` was applied — including how + * Postgres renders the policy expression back (`'pending'::transaction_status`), + * which is the part a hand-written fixture would get wrong. + * + * Note this suite shares the limitation of its neighbours: it proves the RULES + * are right, not that cloud satisfies them. Only `npm run verify:cloud` does + * that, because only it queries the database. + */ +import { describe, it, expect } from 'vitest' + +import { + evaluateChecks, + normalisePolicyExpr, + EXPECTED_UPDATE_COLS, + type CloudFacts, +} from '@/scripts/lib/verify-cloud-schema-checks' + +/** Exactly what cloud returned after the lockdown was applied. */ +const LIVE_WITH_CHECK = + "((( SELECT auth.uid() AS uid) = user_id) AND (tenant_id = ( SELECT get_tenant_id() AS get_tenant_id)) " + + "AND (status = 'pending'::transaction_status) AND (settlement_currency IS NULL) " + + 'AND (settlement_base IS NULL) AND (settlement_mint IS NULL) AND (settlement_sol_usd IS NULL))' + +/** The #528 shape that was live on cloud BEFORE this issue was fixed. */ +const PRE_FIX_WITH_CHECK = + "((( SELECT auth.uid() AS uid) = user_id) AND (tenant_id = ( SELECT get_tenant_id() AS get_tenant_id)) " + + "AND (status = 'pending'::transaction_status))" + +function healthy(): CloudFacts { + return { + ledger: { + repoVersions: ['20260725170000', '20260725180000'], + cloudVersions: ['20260725170000', '20260725180000'], + }, + priv: { + auth_insert: false, + anon_insert: false, + service_insert: true, + auth_col_insert: null, + auth_col_update: EXPECTED_UPDATE_COLS, + }, + insertPolicies: [ + { policyname: 'Users can create own transactions', with_check: LIVE_WITH_CHECK }, + ], + triggers: [ + { tgname: 'before_transaction_split_snapshot_insert', enabled: true }, + { tgname: 'before_transaction_split_snapshot_update', enabled: true }, + ], + } +} + +const failuresOf = (facts: CloudFacts) => + evaluateChecks(facts).filter((c) => !c.ok) + +describe('verify-cloud-schema checks', () => { + it('passes on the state cloud is actually in after #541', () => { + expect(failuresOf(healthy())).toEqual([]) + }) + + it('normalises the catalog rendering so type annotations do not break pin matching', () => { + expect(normalisePolicyExpr("(status = 'pending'::transaction_status)")).toContain( + "status = 'pending'" + ) + }) + + // --- the criterion #541 names explicitly --------------------------------- + + it('FAILS when INSERT is re-granted to authenticated', () => { + const facts = healthy() + facts.priv.auth_insert = true + + const failures = failuresOf(facts) + expect(failures).toHaveLength(1) + expect(failures[0].name).toContain('authenticated holds no INSERT') + expect(failures[0].detail).toContain('GRANT IS PRESENT') + }) + + it('FAILS when INSERT is re-granted to anon', () => { + const facts = healthy() + facts.priv.anon_insert = true + expect(failuresOf(facts).map((f) => f.name)).toContain( + 'anon holds no INSERT on transactions (#538)' + ) + }) + + it('FAILS when a column-level INSERT grant is reintroduced without the table grant', () => { + const facts = healthy() + facts.priv.auth_col_insert = 'amount, settlement_base' + const failures = failuresOf(facts) + expect(failures).toHaveLength(1) + expect(failures[0].detail).toContain('settlement_base') + }) + + // --- the shape that was actually live before this fix -------------------- + + it('FAILS on the pre-fix #528 policy, which omits the four settlement pins', () => { + const facts = healthy() + facts.insertPolicies = [ + { policyname: 'Users can create own transactions', with_check: PRE_FIX_WITH_CHECK }, + ] + + const failures = failuresOf(facts) + expect(failures).toHaveLength(1) + expect(failures[0].detail).toContain('settlement_base IS NULL') + expect(failures[0].detail).toContain('settlement_currency IS NULL') + expect(failures[0].detail).toContain('settlement_mint IS NULL') + expect(failures[0].detail).toContain('settlement_sol_usd IS NULL') + }) + + it('FAILS when the INSERT policy is dropped entirely', () => { + const facts = healthy() + facts.insertPolicies = [] + expect(failuresOf(facts).map((f) => f.detail)).toContain( + 'no INSERT policy found on public.transactions' + ) + }) + + // --- the #528 UPDATE half ------------------------------------------------ + + it('FAILS when the UPDATE column grant is widened', () => { + const facts = healthy() + facts.priv.auth_col_update = `amount, settlement_base, ${EXPECTED_UPDATE_COLS}` + const failures = failuresOf(facts) + expect(failures).toHaveLength(1) + expect(failures[0].name).toContain('UPDATE grant is exactly') + }) + + it('FAILS when service_role loses INSERT, which would break every payment write', () => { + const facts = healthy() + facts.priv.service_insert = false + expect(failuresOf(facts)[0].detail).toContain('every payment write path is broken') + }) + + // --- the #512 split-snapshot triggers ------------------------------------ + + it('FAILS when a split-snapshot trigger is missing or disabled', () => { + const missing = healthy() + missing.triggers = missing.triggers.filter( + (t) => t.tgname !== 'before_transaction_split_snapshot_update' + ) + expect(failuresOf(missing)[0].detail).toBe('MISSING') + + const disabled = healthy() + disabled.triggers[0].enabled = false + expect(failuresOf(disabled)[0].detail).toBe('DISABLED') + }) + + // --- ledger drift: the root cause #541 was filed for --------------------- + + it('FAILS when a repo migration was never applied to cloud', () => { + const facts = healthy() + facts.ledger.cloudVersions = ['20260725170000'] // 180000 never applied + + const failures = failuresOf(facts) + expect(failures).toHaveLength(1) + expect(failures[0].detail).toContain('20260725180000') + }) + + it('FAILS on an orphan stamp — the ad-hoc apply_migration signature', () => { + const facts = healthy() + // What cloud actually looked like: applied, but stamped with a fresh + // timestamp that matches no file in supabase/migrations/. + facts.ledger.cloudVersions = ['20260725170000', '20260725192946'] + + const failures = failuresOf(facts) + expect(failures.map((f) => f.detail.slice(0, 20))).toEqual([ + expect.stringContaining('not on cloud'), + expect.stringContaining('orphan stamps'), + ]) + expect(failures[1].detail).toContain('20260725192946') + }) + + it('reports the real pre-#541 cloud ledger as drifted on both counts', () => { + // The genuine numbers from the issue: four stamps applied ad hoc, one + // migration never applied at all. + const facts = healthy() + facts.ledger = { + repoVersions: [ + '20260721120000', + '20260725110000', + '20260725160000', + '20260725170000', + '20260725180000', + ], + cloudVersions: [ + '20260725192946', + '20260725213441', + '20260725213508', + '20260725213610', + ], + } + + const failures = failuresOf(facts) + expect(failures).toHaveLength(2) + // Every repo file reads as pending, which is exactly why the genuinely + // missing 20260725180000 was invisible in the noise. + expect(failures[0].detail).toContain('20260725180000') + expect(failures[1].detail).toContain('20260725213441') + }) +})