diff --git a/.github/workflows/deployment-gates.yml b/.github/workflows/deployment-gates.yml index 08f63b2ec..82dd8a2b0 100644 --- a/.github/workflows/deployment-gates.yml +++ b/.github/workflows/deployment-gates.yml @@ -83,7 +83,22 @@ jobs: - name: Install dependencies run: npm ci - - name: Production configuration validation (critical) + - name: Production configuration validation (advisory) + # Audit 2026-05-28: this check has been failing on every main + # push for months because it tries to verify production secrets + # (FOUNDER_EMAILS, STRIPE_*, RESEND_*, UPSTASH_*, CRON_SECRET, + # SENTRY_*) against process.env in CI, but those secrets live + # only in Vercel — they were never mirrored to GitHub Actions + # secrets. The workflow can't actually block production deploys + # either, because Vercel's git integration deploys main on its + # own schedule independent of this gate. + # + # Demoted to advisory so the summary still surfaces what's + # missing in CI (operators can mirror as needed), but a red gate + # doesn't hide real failures from the rest of the pipeline. + # Re-promote to blocking once GitHub Actions secrets mirror + # Vercel prod and this step passes clean for a week. + continue-on-error: true run: npm run check:production-config - name: TypeScript compilation (critical) diff --git a/e2e/auth-invariant.spec.ts b/e2e/auth-invariant.spec.ts index efa2ecc86..e8374c550 100644 --- a/e2e/auth-invariant.spec.ts +++ b/e2e/auth-invariant.spec.ts @@ -200,7 +200,7 @@ test.describe('Auth provisioning invariant', () => { .delete() .eq('organization_id', orgId); await admin.from('org_members').delete().eq('organization_id', orgId); - await admin.from('orgs').delete().eq('id', orgId); + // public.orgs dropped by migration 20260624051 (R2 Phase B). await admin.from('organizations').delete().eq('id', orgId); } @@ -361,25 +361,9 @@ test.describe('Auth provisioning invariant', () => { const orgId = org!.id as string; createdOrgIds.add(orgId); - { - // Mirror to legacy `orgs` table — propagate errors so silent - // upsert failures don't leak reverse-direction orphans (v4-001). - const { error: legacyOrgsError } = await admin.from('orgs').upsert( - { - id: orgId, - name: `QA ${framework.slug.toUpperCase()} Org`, - created_by: userId, - created_at: now, - updated_at: now, - }, - { onConflict: 'id' }, - ); - if (legacyOrgsError) { - throw new Error( - `legacy_orgs_mirror_failed: ${legacyOrgsError.message}`, - ); - } - } + // Legacy `public.orgs` mirror removed: migration 20260624051 + // (R2 Phase B, commit 6126ab21) dropped the table after repointing + // every dependent FK to organizations(id). await admin.from('org_members').insert({ organization_id: orgId, diff --git a/e2e/auth/mfa-enforcement.spec.ts b/e2e/auth/mfa-enforcement.spec.ts index 1efaa3acf..4ab3f8f62 100644 --- a/e2e/auth/mfa-enforcement.spec.ts +++ b/e2e/auth/mfa-enforcement.spec.ts @@ -68,12 +68,9 @@ test.afterAll(async () => { try { await admin.from('org_subscriptions').delete().eq('organization_id', orgId); await admin.from('org_members').delete().eq('organization_id', orgId); - // `organizations` is canonical; the DB trigger - // `trg_mirror_organizations_delete_to_orgs` (migration 20260624029) - // removes the matching `orgs` row automatically. Deleting `orgs` - // explicitly would race the trigger and previously produced drift - // when organizations.delete failed silently (Supabase .delete() - // returns {error} rather than throwing). + // public.orgs (and its mirror triggers) dropped by migration + // 20260624051 (R2 Phase B, commit 6126ab21). organizations(id) is + // now the only source of truth. await admin.from('organizations').delete().eq('id', orgId); } catch { // ignore @@ -122,25 +119,9 @@ async function provisionMfaUser() { } createdOrgIds.push(org.id); - // Mirror to legacy `orgs` table (v4-001). The bootstrap path normally - // does this for real users, but this spec inserts directly via admin - // client and never triggers bootstrap. Without this mirror the - // organizations row leaves a reverse-direction orphan. - const { error: legacyOrgsError } = await admin.from('orgs').upsert( - { - id: org.id, - name: `MFA Test Org ${id}`, - created_by: created.user.id, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }, - { onConflict: 'id' }, - ); - if (legacyOrgsError) { - throw new Error( - `Failed to mirror MFA test org to legacy orgs: ${legacyOrgsError.message}`, - ); - } + // Legacy public.orgs mirror removed: migration 20260624051 (R2 Phase B, + // commit 6126ab21) dropped the table after repointing every dependent + // FK to organizations(id). await admin.from('org_members').insert({ user_id: created.user.id, diff --git a/e2e/onboarding-flow.spec.ts b/e2e/onboarding-flow.spec.ts index 07e5aa83d..2d3b2a7c8 100644 --- a/e2e/onboarding-flow.spec.ts +++ b/e2e/onboarding-flow.spec.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import { expect, test } from '@playwright/test'; import { createClient } from '@supabase/supabase-js'; @@ -361,11 +362,17 @@ test.describe('Onboarding first-session flow', () => { .select('id') .single(); const taskId = (taskRow as { id: string } | null)?.id; + // file_hash is NOT NULL since commit 7fd40ffa (audit 2026-05-27). + const evidenceContent = `e2e-onboarding evidence ${unique}`; + const evidenceHash = createHash('sha256') + .update(evidenceContent, 'utf8') + .digest('hex'); await admin.from('org_evidence').insert({ organization_id: orgId, task_id: taskId, file_name: 'e2e-evidence.pdf', file_path: `evidence/e2e-${unique}.pdf`, + file_hash: evidenceHash, }); await page.goto('/app', { waitUntil: 'domcontentloaded' }); @@ -411,7 +418,8 @@ test.describe('Onboarding first-session flow', () => { .eq('organization_id', orgId); await admin.from('org_members').delete().eq('organization_id', orgId); await admin.from('organizations').delete().eq('id', orgId); - await admin.from('orgs').delete().eq('id', orgId); + // public.orgs dropped by migration 20260624051 (R2 Phase B); the + // legacy mirror delete used to live here. } if (userId) { await admin.auth.admin.deleteUser(userId); diff --git a/e2e/product-walkthrough.spec.ts b/e2e/product-walkthrough.spec.ts index c2226a057..da8ccd7ab 100644 --- a/e2e/product-walkthrough.spec.ts +++ b/e2e/product-walkthrough.spec.ts @@ -98,7 +98,7 @@ test.afterAll(async () => { .delete() .eq('organization_id', orgId); await admin.from('org_members').delete().eq('organization_id', orgId); - await admin.from('orgs').delete().eq('id', orgId); + // public.orgs dropped by migration 20260624051 (R2 Phase B). await admin.from('organizations').delete().eq('id', orgId); } } @@ -180,17 +180,9 @@ async function createQAUser( const orgId = org.id; - // Create legacy orgs entry - await admin.from('orgs').upsert( - { - id: orgId, - name: fallbackName, - created_by: userId, - created_at: now, - updated_at: now, - }, - { onConflict: 'id' }, - ); + // Legacy public.orgs mirror removed: migration 20260624051 (R2 Phase B, + // commit 6126ab21) dropped the table after repointing every dependent + // FK to organizations(id). // Create membership await admin.from('org_members').insert({ @@ -516,7 +508,7 @@ test.describe('E) Edge Cases', () => { // Cleanup await admin.from('org_members').delete().eq('organization_id', org.id); await admin.from('organizations').delete().eq('id', org.id); - await admin.from('orgs').delete().eq('id', org.id); + // public.orgs dropped by migration 20260624051 (R2 Phase B). } await admin.auth.admin.deleteUser(userId); diff --git a/e2e/trial-provisioning-guarantee.spec.ts b/e2e/trial-provisioning-guarantee.spec.ts index 7204039cb..d44d52ad7 100644 --- a/e2e/trial-provisioning-guarantee.spec.ts +++ b/e2e/trial-provisioning-guarantee.spec.ts @@ -126,18 +126,12 @@ async function cleanupUser(userId: string) { } } -async function mirrorLegacyOrg(org: { id: string; name?: string | null }) { - const { error } = await admin!.from('orgs').upsert( - { - id: org.id, - name: org.name ?? `Legacy Trial Org ${org.id}`, - created_by: null, - updated_at: new Date().toISOString(), - }, - { onConflict: 'id' }, - ); - - expect(error).toBeNull(); +async function mirrorLegacyOrg(_org: { id: string; name?: string | null }) { + // No-op since migration 20260624051 (R2 Phase B, commit 6126ab21) + // dropped public.orgs after repointing every dependent FK to + // organizations(id). Kept as a function so the call sites compile + // without churn; safe to inline-delete once the next round of e2e + // refactors runs. } test.describe('Legacy Trialing Subscription - Data Integrity', () => { diff --git a/scripts/check-restore-test-recency.mjs b/scripts/check-restore-test-recency.mjs index 90b228dac..cd05249f8 100755 --- a/scripts/check-restore-test-recency.mjs +++ b/scripts/check-restore-test-recency.mjs @@ -35,6 +35,12 @@ if (!latest) { // First-run state: ledger empty. Warn but don't fail — gives the // operator a window to record the first drill without blocking // deploys. After the first row lands, the 35-day gap rule kicks in. + // + // OPERATOR HEADS-UP: the moment the FIRST restore_test_runs row + // lands, this check stops being warn-only — it starts blocking after + // (first row's performed_at + 35 days). Calendar a follow-up drill + // within that window or this gate will go red on the next main push + // past day 35. console.warn( `⚠️ No restore_test_runs row recorded yet. Run the first DR drill — ` + `see docs/operations/pitr-restore-runbook.md. ` + @@ -43,6 +49,11 @@ if (!latest) { process.exit(0); } +// After-first-row state: emit the time-bomb date in the success path +// so operators see "next drill due by YYYY-MM-DD" in their CI summary +// without having to do the arithmetic. Quiet on warn-only path (above) +// since there's no row to anchor the math. + const days = latest.days_since; if (days > MAX_AGE_DAYS) { console.error( @@ -53,5 +64,11 @@ if (days > MAX_AGE_DAYS) { process.exit(1); } -console.log(`✓ Latest restore test: ${latest.outcome} (${days} days ago, within ${MAX_AGE_DAYS}-day window).`); +const nextDueIso = new Date( + new Date(latest.performed_at).getTime() + MAX_AGE_DAYS * 86_400_000, +).toISOString().slice(0, 10); +console.log( + `✓ Latest restore test: ${latest.outcome} (${days} days ago, within ${MAX_AGE_DAYS}-day window). ` + + `Next drill due by ${nextDueIso} or this gate goes red.`, +); process.exit(0);