diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 151f3d296..fa1280229 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -120,22 +120,6 @@ const supabaseAdminDefaultPrivilegesMigration = readFileSync( new URL("../supabase/migrations/20260713102000_revoke_supabase_admin_default_privileges.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); -const publicationApprovalMigration = readFileSync( - new URL("../supabase/migrations/20260717131000_guard_document_publication_approval.sql", import.meta.url), - "utf8", -).replace(/\s+/g, " "); -const deleteDocumentIfIdleMigration = readFileSync( - new URL("../supabase/migrations/20260717132000_delete_document_if_idle.sql", import.meta.url), - "utf8", -).replace(/\s+/g, " "); -const defaultAclAssertionMigration = readFileSync( - new URL("../supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql", import.meta.url), - "utf8", -).replace(/\s+/g, " "); -const defaultAclRoleBootstrap = readFileSync(new URL("../supabase/roles.sql", import.meta.url), "utf8").replace( - /\s+/g, - " ", -); const scrubLegacyQueryTextMigration = readFileSync( new URL("../supabase/migrations/20260713103000_scrub_legacy_rag_query_text.sql", import.meta.url), "utf8", @@ -192,13 +176,6 @@ const searchDocumentChunksOwnerScopeMigration = readFileSync( new URL("../supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); -const searchDocumentChunksCommittedGenerationMigration = readFileSync( - new URL( - "../supabase/migrations/20260717130000_filter_search_document_chunks_committed_generation.sql", - import.meta.url, - ), - "utf8", -).replace(/\s+/g, " "); const retrievalPlanCacheMigration = readFileSync( new URL("../supabase/migrations/20260711120000_retrieval_fn_plan_cache_mode.sql", import.meta.url), "utf8", @@ -957,39 +934,6 @@ describe("Supabase schema Data API grants", () => { ); }); - it("filters per-document search to the committed generation before matching and limiting", () => { - for (const sql of [schema, searchDocumentChunksCommittedGenerationMigration]) { - const functionStart = sql.indexOf("create or replace function public.search_document_chunks("); - const functionEnd = sql.indexOf("$$;", functionStart); - const definition = sql.slice(functionStart, functionEnd); - const generationFilter = definition.indexOf( - "public.is_committed_document_generation(c.index_generation_id, d.index_generation_id)", - ); - - expect(generationFilter).toBeGreaterThanOrEqual(0); - expect(generationFilter).toBeLessThan(definition.indexOf("c.search_tsv @@ normalized.query_tsv")); - expect(generationFilter).toBeLessThan(definition.indexOf("limit least(greatest(match_count, 1), 80)")); - } - - const g0 = "00000000-0000-0000-0000-000000000001"; - const g1 = "00000000-0000-0000-0000-000000000002"; - const candidates = [ - { id: "committed", chunkGeneration: g0, documentGeneration: g0, rank: 0.5 }, - { id: "staged-higher-rank", chunkGeneration: g1, documentGeneration: g0, rank: 0.99 }, - { id: "legacy-null", chunkGeneration: null, documentGeneration: null, rank: 0.4 }, - ]; - const visible = candidates - .filter( - (row) => - row.chunkGeneration === row.documentGeneration && - (row.chunkGeneration !== null || row.documentGeneration === null), - ) - .sort((left, right) => right.rank - left.rank) - .map((row) => row.id); - - expect(visible).toEqual(["committed", "legacy-null"]); - }); - it("surfaces stale commit generation RPCs through search_schema_health", () => { for (const sql of [schema, searchSchemaHealthM13GuardMigration]) { expect(sql).toContain("commit_fn_def := pg_get_functiondef("); @@ -1137,92 +1081,6 @@ describe("Supabase Preview replay guards", () => { ); }); - it("requires append-only operator evidence for owned-to-public transitions", () => { - for (const sql of [schema, publicationApprovalMigration]) { - expect(sql).toContain("create table if not exists public.document_publication_approvals"); - expect(sql).toContain("check (cardinality(evidence_references) > 0)"); - expect(sql).toContain("unique (document_id, expected_prior_owner_id, manifest_digest)"); - expect(sql).toContain("before update or delete on public.document_publication_approvals"); - expect(sql).toContain("before insert or update on public.documents"); - expect(sql).toContain("if tg_op = 'INSERT' then"); - expect(sql).toContain("public documents must be created as owned rows before approved publication"); - expect(sql).toContain("old.owner_id is not null and new.owner_id is null"); - expect(sql).toContain("approval.expected_prior_owner_id = old.owner_id"); - expect(sql).toContain("create or replace function public.publish_approved_documents("); - expect(sql).toContain("for update;"); - expect(sql).toContain( - "grant execute on function public.publish_approved_documents(jsonb, text, integer) to service_role;", - ); - } - }); - - it("serializes permanent deletion against ingestion job creation", () => { - for (const sql of [schema, deleteDocumentIfIdleMigration]) { - const functionStart = sql.indexOf("create or replace function public.delete_document_if_idle("); - const functionBody = sql.slice(functionStart, sql.indexOf("$$;", functionStart)); - const rowLock = functionBody.indexOf("for update;"); - const activeJobCheck = functionBody.indexOf("from public.ingestion_jobs j"); - const ledgerInsert = functionBody.indexOf("insert into public.storage_cleanup_jobs"); - const parentDelete = functionBody.indexOf("delete from public.documents"); - expect(rowLock).toBeGreaterThanOrEqual(0); - expect(rowLock).toBeLessThan(activeJobCheck); - expect(activeJobCheck).toBeLessThan(ledgerInsert); - expect(ledgerInsert).toBeLessThan(parentDelete); - expect(sql).toContain( - "grant execute on function public.delete_document_if_idle(uuid, uuid, text, text) to service_role;", - ); - const retryStart = sql.indexOf("create or replace function public.retry_ingestion_job_if_idle("); - const retryBody = sql.slice(retryStart, sql.indexOf("$$;", retryStart)); - expect(retryBody).toContain("for update of d, j;"); - expect(retryBody.indexOf("for update of d, j;")).toBeLessThan(retryBody.indexOf("update public.ingestion_jobs")); - expect(retryBody).toContain("update public.documents"); - expect(sql).toContain( - "grant execute on function public.retry_ingestion_job_if_idle(uuid, uuid, timestamptz, integer, timestamptz, timestamptz) to service_role;", - ); - } - }); - - it("fails closed on effective supabase_admin default ACLs", () => { - for (const sql of [schema, defaultAclAssertionMigration]) { - expect(sql).toContain("create or replace function public.default_privileges_status("); - expect(sql).toContain("pg_catalog.acldefault(ot.object_code, v_role_oid)"); - expect(sql).toContain("pg_catalog.aclexplode(ea.acl)"); - expect(sql).toContain("bool_or(grantee not in (p_role_name, 'postgres', 'service_role'))"); - expect(sql).toContain("entry like 'table:PUBLIC:%'"); - expect(sql).toContain("entry like 'sequence:PUBLIC:%'"); - expect(sql).toContain("entry = 'function:PUBLIC:execute'"); - expect(sql).toContain("message = 'Unsafe supabase_admin default privileges; migration blocked.'"); - expect(sql).toContain("Run these six statements as supabase_admin, then retry the migration:"); - } - - const migrationFiles = readdirSync(migrationDirectoryUrl) - .filter((fileName) => /^\d+_.+\.sql$/.test(fileName)) - .sort(); - expect(migrationFiles.at(-1)).toBe("20260717161000_assert_supabase_admin_default_privileges.sql"); - }); - - it("bootstraps safe default ACLs before fresh local and preview migration replay", () => { - expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin revoke all privileges on tables from public, anon, authenticated, service_role;", - ); - expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin revoke all privileges on sequences from public, anon, authenticated, service_role;", - ); - expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin revoke execute on functions from public, anon, authenticated, service_role;", - ); - expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin in schema public grant select, insert, update, delete on tables to service_role;", - ); - expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin in schema public grant usage, select on sequences to service_role;", - ); - expect(defaultAclRoleBootstrap).toContain( - "alter default privileges for role supabase_admin in schema public grant execute on functions to service_role;", - ); - expect(defaultAclRoleBootstrap).toContain("bool_or(grantee not in ('supabase_admin', 'postgres', 'service_role'))"); - }); - it("scrubs legacy plaintext query text with salted irreversible placeholders", () => { // 2026-07-13 audit finding 5: rows written before the HMAC rollout still // held raw clinical query text. Placeholders must be salted (not bare @@ -1381,3 +1239,81 @@ describe("Clinical query-term corrector — tenant-safe vocabulary (F10)", () => ); }); }); + +describe("Storage buckets migration chain (schema-drift fix)", () => { + // 20260527000000_bulk_ingestion.sql created storage.objects RLS policies that + // reference clinical-documents/clinical-images, but only schema.sql (not the + // migration chain) ever created the buckets themselves. A database built purely + // by replaying migrations therefore had policies pointing at buckets that did + // not exist. This migration backfills the bucket creation into the chain. + const createStorageBucketsMigration = readFileSync( + new URL("../supabase/migrations/20260717130000_create_storage_buckets.sql", import.meta.url), + "utf8", + ).replace(/\s+/g, " "); + + it("creates the private clinical-documents bucket with schema.sql's size limit and mime allowlist", () => { + expect(createStorageBucketsMigration).toContain( + "insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)", + ); + expect(createStorageBucketsMigration).toContain("'clinical-documents', 'clinical-documents', false, 157286400,"); + expect(createStorageBucketsMigration).toContain("'application/pdf'"); + expect(createStorageBucketsMigration).toContain( + "'application/vnd.openxmlformats-officedocument.wordprocessingml.document'", + ); + expect(createStorageBucketsMigration).toContain( + "'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'", + ); + expect(createStorageBucketsMigration).toContain("'text/plain'"); + }); + + it("creates the private clinical-images bucket with schema.sql's size limit and mime allowlist", () => { + expect(createStorageBucketsMigration).toContain("'clinical-images', 'clinical-images', false, 52428800,"); + expect(createStorageBucketsMigration).toContain("array['image/png', 'image/jpeg', 'image/webp']"); + }); + + it("uses byte-precise size limits (150 MB documents, 50 MB images)", () => { + expect(createStorageBucketsMigration).toContain(String(150 * 1024 * 1024)); + expect(createStorageBucketsMigration).toContain(String(50 * 1024 * 1024)); + }); + + it("upserts idempotently, re-affirming public = false on conflict for both buckets", () => { + const marker = "on conflict (id) do update set public = false;"; + const onConflictCount = createStorageBucketsMigration.split(marker).length - 1; + expect(onConflictCount).toBe(2); + const insertCount = createStorageBucketsMigration.split("insert into storage.buckets").length - 1; + expect(insertCount).toBe(2); + }); + + it("never flips a bucket public (regression: on-conflict must not drop the private ACL)", () => { + expect(createStorageBucketsMigration).not.toContain("public = true"); + expect(createStorageBucketsMigration).not.toContain( + "'clinical-documents', 'clinical-documents', true,", + ); + expect(createStorageBucketsMigration).not.toContain("'clinical-images', 'clinical-images', true,"); + }); + + it("mirrors schema.sql's storage.buckets inserts byte-for-byte", () => { + const extractBucketInserts = (text: string) => { + const marker = "on conflict (id) do update set public = false;"; + const start = text.indexOf("insert into storage.buckets"); + expect(start, "storage.buckets insert not found").toBeGreaterThanOrEqual(0); + const firstEnd = text.indexOf(marker, start); + expect(firstEnd, "first bucket upsert not found").toBeGreaterThan(start); + const secondStart = text.indexOf("insert into storage.buckets", firstEnd); + expect(secondStart, "second bucket insert not found").toBeGreaterThan(firstEnd); + const secondEnd = text.indexOf(marker, secondStart); + expect(secondEnd, "second bucket upsert not found").toBeGreaterThan(secondStart); + return text.slice(start, secondEnd + marker.length); + }; + expect(extractBucketInserts(createStorageBucketsMigration)).toBe(extractBucketInserts(schema)); + }); + + it("creates the exact bucket ids the storage.objects RLS policies depend on", () => { + // Regression guard for the drift bug itself: the policy migration references + // these bucket_ids, so the bucket-creation migration must define the same ones. + expect(bulkIngestionMigration).toContain("bucket_id = 'clinical-documents'"); + expect(bulkIngestionMigration).toContain("bucket_id = 'clinical-images'"); + expect(createStorageBucketsMigration).toContain("'clinical-documents',"); + expect(createStorageBucketsMigration).toContain("'clinical-images',"); + }); +});