diff --git a/supabase/migrations/20260901120000_sandbox_cleanup_completion.sql b/supabase/migrations/20260901120000_sandbox_cleanup_completion.sql new file mode 100644 index 00000000..8393462a --- /dev/null +++ b/supabase/migrations/20260901120000_sandbox_cleanup_completion.sql @@ -0,0 +1,377 @@ +-- Four more sandbox-teardown blockers, all added or missed since +-- 20260807170000. Nine sandbox tenants are stuck on prod (oldest settings row +-- 2026-07-22), retried by the nightly cron and failing forever, so anonymous +-- demo tenants accumulate instead of being torn down: +-- +-- 1. supplier_payment_batch_items references supplier_invoices through the +-- composite FK fk_supplier_payment_batch_items_invoice with ON DELETE +-- RESTRICT. The table shipped 2026-08-10, after the last cleanup fix, so +-- any sandbox visitor who generated a betalfil blocks the +-- supplier_invoices delete. The batch header cascades to its items +-- (fk_supplier_payment_batch_items_batch), so deleting the batches is +-- enough and no guard trigger stands in the way. +-- 2. fiscal_periods.opening_balance_entry_id and .closing_entry_id are both +-- plain NO ACTION FKs into journal_entries, so the journal delete fails +-- while a period still points at an IB or bokslut voucher. NULLing them +-- is itself blocked by enforce_opening_balance_immutability, which has +-- no sandbox bypass. +-- 3. webhook_deliveries rows in terminal status ('delivered', 'dead') are +-- blocked by block_webhook_delivery_terminal_delete, which raises +-- unconditionally. Those rows reach the trigger through the +-- auth.users -> companies -> webhook_deliveries cascade. +-- 4. payment_match_log rows whose company_id IS NULL fail the shared +-- audit_log_immutable() bypass, because that bypass requires +-- OLD.company_id IS NOT NULL. lib/invoices/match-log.ts has written +-- company_id-less rows since the table shipped (7103 of them on prod), +-- and payment_match_log_user_id_fkey ON DELETE CASCADE drags them into +-- the auth.users delete. The table is also reached as an UPDATE: +-- payment_match_log_supplier_invoice_id_fkey is ON DELETE SET NULL, so +-- the supplier_invoices delete rewrites any row that points at one, and +-- the UPDATE branch of the guard stays unconditional. The purge +-- therefore runs before the supplier_invoices delete, not just before +-- the auth.users one. +-- +-- Three decisions worth writing down, because the obvious reading of each +-- goes the other way: +-- +-- * closing_entry_id is cleared too, not just opening_balance_entry_id, and +-- the sandbox bypass covers the whole trigger rather than the +-- opening-balance clause alone. No stale sandbox has closing_entry_id set +-- today, so a narrower fix would also pass, and would then break again +-- the first time a sandbox visitor runs a bokslut: +-- fiscal_periods_closing_entry_id_fkey is NO ACTION as well. The year-end +-- immutability the clause protects guards a ledger that ceases to exist a +-- few statements later. +-- * the opening-balance RAISE loses its em dash (repo rule: no em or en +-- dashes) and gains the colon its sibling clause already uses. Both +-- assertions in the tree match the tail of the sentence +-- (lib/bookkeeping/__tests__/engine.pg.test.ts and +-- tests/pg/closing-entry-detach.pg.test.ts) and no runtime code maps the +-- text, so no caller notices. +-- * payment_match_log gets its own guard function instead of a relaxed +-- audit_log_immutable(). That function also guards audit_log, event_log +-- and processing_history; processing_history has no user_id column at +-- all, so a bypass branch reading OLD.user_id could not live there. +-- +-- Every bypass follows the 20260807 chain: a transaction-local flag that only +-- cleanup_sandbox_user sets, after its all-rows is_sandbox check, re-verified +-- per row against company_settings so the flag alone can never unlock a real +-- tenant. UPDATE stays forbidden on both audit-shaped logs. +-- +-- Known residual risk, deliberately not fixed here: cleanup_sandbox_user +-- enumerates blockers one table at a time, and prod still has unhandled +-- RESTRICT / NO ACTION FKs into the tables it deletes (accrual_schedules, +-- accrual_schedule_installments, assets.disposal_journal_entry_id, +-- depreciation_schedules, peppol_deliveries, rot_rut_payout_request_items, +-- agi_declarations, stripe_payment_events, stripe_payouts, +-- vacation_year_closures, webshop_orders), and it never deletes +-- public.invoices at all. A read-only sweep of every one of those tables +-- against the nine stuck sandboxes returns zero rows today, so this does +-- clear the whole current backlog; a sandbox that exercises one of those +-- surfaces will need the next entry in this chain. + +-- ============================================================================= +-- 1. enforce_opening_balance_immutability: allow sandbox-teardown link clearing +-- ============================================================================= + +-- Body otherwise identical to 20260720140000, minus the em dash. + +CREATE OR REPLACE FUNCTION public.enforce_opening_balance_immutability() +RETURNS trigger +LANGUAGE plpgsql +SET search_path TO 'public' +AS $$ +BEGIN + -- Sandbox teardown clears the period's voucher links so the journal delete + -- can proceed; the period itself dies with the tenant moments later. Flag + -- first so a normal tenant never pays for the company_settings lookup, and + -- per-row re-verification so the flag alone unlocks nothing. + IF current_setting('gnubok.sandbox_cleanup', true) = 'true' + AND EXISTS ( + SELECT 1 FROM public.company_settings cs + WHERE cs.company_id = OLD.company_id AND cs.is_sandbox = true + ) THEN + RETURN NEW; + END IF; + + -- Only check if opening_balance_entry_id is being changed + IF OLD.opening_balance_entry_id IS NOT NULL + AND OLD.opening_balances_set = true + AND NEW.opening_balance_entry_id IS DISTINCT FROM OLD.opening_balance_entry_id THEN + RAISE EXCEPTION 'Cannot modify opening_balance_entry_id on period "%": opening balances are immutable once set', + OLD.name; + END IF; + + -- Block changing closing_entry_id once set, UNLESS the referenced closing + -- entry has been reversed by a real storno (administrative year-end undo). + IF OLD.closing_entry_id IS NOT NULL + AND NEW.closing_entry_id IS DISTINCT FROM OLD.closing_entry_id THEN + + IF NOT EXISTS ( + SELECT 1 + FROM journal_entries je + JOIN journal_entries storno + ON storno.reverses_id = je.id + AND storno.source_type = 'storno' + AND storno.status = 'posted' + AND storno.company_id = OLD.company_id + WHERE je.id = OLD.closing_entry_id + AND je.company_id = OLD.company_id + AND je.status = 'reversed' + ) THEN + RAISE EXCEPTION 'Cannot modify closing_entry_id on period "%": year-end closing is immutable', + OLD.name; + END IF; + + IF NEW.closing_entry_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM journal_entries ne + WHERE ne.id = NEW.closing_entry_id + AND ne.company_id = NEW.company_id + AND ne.fiscal_period_id = NEW.id + AND ne.source_type = 'year_end' + AND ne.status = 'posted' + ) THEN + RAISE EXCEPTION 'closing_entry_id on period "%" must reference a posted year_end entry in the same period', + OLD.name; + END IF; + END IF; + + RETURN NEW; +END; +$$; + +-- ============================================================================= +-- 2. block_webhook_delivery_terminal_delete: allow sandbox-teardown DELETE +-- ============================================================================= + +-- Body otherwise identical to 20260515190000. webhook_deliveries carries its +-- own company_id (NOT NULL, asserted against the parent webhook at INSERT), +-- so the per-row sandbox re-check needs no join through webhooks, which is +-- what makes it safe here: webhook_id is nullable and ON DELETE SET NULL. + +CREATE OR REPLACE FUNCTION public.block_webhook_delivery_terminal_delete() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF current_setting('gnubok.sandbox_cleanup', true) = 'true' + AND EXISTS ( + SELECT 1 FROM public.company_settings cs + WHERE cs.company_id = OLD.company_id AND cs.is_sandbox = true + ) THEN + RETURN OLD; + END IF; + + IF OLD.status IN ('delivered', 'dead') THEN + RAISE EXCEPTION + 'webhook_deliveries row in terminal status (%) cannot be deleted (audit-log integrity policy; accounting-event rows additionally fall under BFL 7 kap 1 ยง retention)', + OLD.status + USING ERRCODE = 'check_violation'; + END IF; + RETURN OLD; +END; +$$; + +-- ============================================================================= +-- 3. payment_match_log gets its own immutability guard +-- ============================================================================= + +CREATE OR REPLACE FUNCTION public.payment_match_log_immutable() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +BEGIN + -- Sandbox teardown removes the whole demo tenant; its match log goes with + -- it. Two accepted row shapes: a company_id that provably belongs to a + -- sandbox company, or a NULL company_id whose user is provably a sandbox + -- user. The second branch is the whole point of splitting this off + -- audit_log_immutable(), and it only works while the company_settings row + -- still exists, which is why cleanup_sandbox_user deletes these rows + -- explicitly rather than letting the auth.users cascade reach them. + -- UPDATE stays forbidden even during teardown. + IF TG_OP = 'DELETE' + AND current_setting('gnubok.sandbox_cleanup', true) = 'true' + AND ( + (OLD.company_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM public.company_settings cs + WHERE cs.company_id = OLD.company_id AND cs.is_sandbox = true + )) + OR + (OLD.company_id IS NULL AND EXISTS ( + SELECT 1 FROM public.company_settings cs + WHERE cs.user_id = OLD.user_id AND cs.is_sandbox = true + )) + ) THEN + RETURN OLD; + END IF; + + -- Same wording as audit_log_immutable(): app/api/transactions/[id]/route.ts + -- matches this text to turn the cascade refusal into a Swedish 409. + RAISE EXCEPTION 'Audit log entries cannot be modified or deleted'; +END; +$$; + +DROP TRIGGER IF EXISTS payment_match_log_no_update ON public.payment_match_log; +CREATE TRIGGER payment_match_log_no_update + BEFORE UPDATE ON public.payment_match_log + FOR EACH ROW EXECUTE FUNCTION public.payment_match_log_immutable(); + +DROP TRIGGER IF EXISTS payment_match_log_no_delete ON public.payment_match_log; +CREATE TRIGGER payment_match_log_no_delete + BEFORE DELETE ON public.payment_match_log + FOR EACH ROW EXECUTE FUNCTION public.payment_match_log_immutable(); + +-- ============================================================================= +-- 4. cleanup_sandbox_user: clear the four blockers before the deletes +-- ============================================================================= + +-- Body otherwise identical to 20260807170000. + +CREATE OR REPLACE FUNCTION public.cleanup_sandbox_user(p_user_id uuid) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_deleted integer := 0; +BEGIN + -- Verify this is a sandbox user: at least one settings row, and EVERY + -- settings row flagged sandbox. + IF NOT EXISTS ( + SELECT 1 FROM public.company_settings cs WHERE cs.user_id = p_user_id + ) OR EXISTS ( + SELECT 1 FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox IS NOT TRUE + ) THEN + RAISE EXCEPTION 'User % is not a sandbox user', p_user_id; + END IF; + + PERFORM set_config('gnubok.allow_delete', 'true', true); + PERFORM set_config('gnubok.sandbox_cleanup', 'true', true); + + -- API keys must die with the sandbox, and api_keys.sod_acknowledged_by + -- (NO ACTION to auth.users) otherwise blocks the auth delete. + DELETE FROM public.api_keys WHERE user_id = p_user_id; + + -- WORM retag log: delete under the bypass while company_settings still + -- exists, and before the journal deletes whose cascade would otherwise + -- reach it. + DELETE FROM public.dimension_retag_log + WHERE company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + -- Match log: append-only, user-scoped on purpose, and purged this early for + -- two reasons. lib/invoices/match-log.ts writes rows with no company_id, and + -- the guard can only recognise those as sandbox rows while the + -- company_settings row still exists, which the auth.users cascade cannot + -- promise (company_settings.user_id cascades from the same delete, and + -- sibling cascade order is undefined). And payment_match_log.supplier_invoice_id + -- is ON DELETE SET NULL, so the supplier_invoices delete further down turns + -- into an UPDATE on any row that references one, which the guard still + -- refuses even during teardown. + DELETE FROM public.payment_match_log + WHERE user_id = p_user_id + OR company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + UPDATE public.document_attachments + SET journal_entry_id = NULL, journal_entry_line_id = NULL + WHERE user_id = p_user_id; + + DELETE FROM public.document_attachments WHERE user_id = p_user_id; + + UPDATE public.salary_runs + SET salary_entry_id = NULL, + avgifter_entry_id = NULL, + pension_entry_id = NULL, + vacation_entry_id = NULL + WHERE user_id = p_user_id; + + -- fiscal_periods points at its IB and bokslut vouchers with plain NO ACTION + -- FKs, and previous_period_id chains periods to each other the same way. + -- Clearing all three under the teardown bypass is what lets the journal + -- delete below run at all. + UPDATE public.fiscal_periods + SET opening_balance_entry_id = NULL, + closing_entry_id = NULL, + previous_period_id = NULL + WHERE company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + DELETE FROM public.journal_entry_lines + WHERE journal_entry_id IN ( + SELECT id FROM public.journal_entries WHERE user_id = p_user_id + ); + + DELETE FROM public.journal_entries WHERE user_id = p_user_id; + + -- Betalfil batches: their items reference supplier_invoices with + -- ON DELETE RESTRICT, so the batch headers must go first (the items + -- cascade off the header, and neither table has a delete guard). + DELETE FROM public.supplier_payment_batches + WHERE company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + DELETE FROM public.supplier_invoices WHERE user_id = p_user_id; + + DELETE FROM public.pending_operations WHERE user_id = p_user_id; + + DELETE FROM public.dimensions + WHERE company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + DELETE FROM public.processing_history + WHERE company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + DELETE FROM public.invoice_deliveries + WHERE company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + -- Terminal webhook deliveries: guarded against DELETE, and reached by the + -- auth.users -> companies cascade unless purged here under the bypass. + DELETE FROM public.webhook_deliveries + WHERE company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + DELETE FROM public.audit_log + WHERE company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + DELETE FROM auth.users WHERE id = p_user_id; + GET DIAGNOSTICS v_deleted = ROW_COUNT; + + PERFORM set_config('gnubok.allow_delete', '', true); + PERFORM set_config('gnubok.sandbox_cleanup', '', true); + + RETURN v_deleted; +END; +$$; + +REVOKE ALL ON FUNCTION public.cleanup_sandbox_user(uuid) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.cleanup_sandbox_user(uuid) TO service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/sandbox-cleanup.pg.test.ts b/tests/pg/sandbox-cleanup.pg.test.ts index 505bafbe..a38ba89d 100644 --- a/tests/pg/sandbox-cleanup.pg.test.ts +++ b/tests/pg/sandbox-cleanup.pg.test.ts @@ -1,7 +1,8 @@ import { randomUUID } from 'node:crypto' +import type { PoolClient } from 'pg' import { describe, expect, it } from 'vitest' import { getClient, getPool } from './setup' -import { insertPostedJournalEntry, seedCompany } from './fixtures' +import { insertPostedJournalEntry, insertTransaction, seedCompany } from './fixtures' /** * Sandbox cleanup RPCs (migration 20260807130000): @@ -14,12 +15,26 @@ import { insertPostedJournalEntry, seedCompany } from './fixtures' * deletes, non-sandbox users stay refused, immutability outside the RPC is * untouched, and the expired sweep also removes orphaned anonymous users * that never got a company_settings row. + * + * Extended by 20260901120000 with the four blockers that stalled nine + * sandboxes on prod (oldest 2026-07-22): a betalfil batch holding a supplier + * invoice through ON DELETE RESTRICT, a fiscal period pointing at its IB and + * bokslut vouchers through NO ACTION FKs, a terminal webhook delivery, and a + * payment_match_log row with company_id NULL. The seed now carries all four, + * and each relaxed guard gets a paired test proving it still refuses a + * NON-sandbox tenant even when the teardown flag is set: that, not the happy + * path, is the regression that matters. */ async function seedSandboxUser(settingsCreatedAt?: string): Promise<{ userId: string companyId: string + fiscalPeriodId: string entryId: string + batchId: string + supplierInvoiceId: string + deliveryId: string + matchLogId: string }> { const { userId, companyId, fiscalPeriodId } = await seedCompany() await getPool().query( @@ -96,7 +111,115 @@ async function seedSandboxUser(settingsCreatedAt?: string): Promise<{ VALUES ($1, $2, $3, '{}', '{"1":"BUTIK"}', 'Sandbox cleanup test retag')`, [companyId, entryId, lineRows[0]!.id], ) - return { userId, companyId, entryId } + // A betalfil batch: supplier_payment_batch_items references the supplier + // invoice with a composite ON DELETE RESTRICT FK, so the batch header must + // be purged before the supplier_invoices delete (20260901120000). + const supplierId = randomUUID() + await getPool().query( + `INSERT INTO public.suppliers (id, user_id, company_id, name, bankgiro) + VALUES ($1, $2, $3, 'Derome Bygg AB', '5050-1055')`, + [supplierId, userId, companyId], + ) + const supplierInvoiceId = randomUUID() + await getPool().query( + `INSERT INTO public.supplier_invoices + (id, user_id, company_id, supplier_id, arrival_number, + supplier_invoice_number, invoice_date, due_date, + subtotal, vat_amount, total, remaining_amount, status) + VALUES ($1, $2, $3, $4, floor(random() * 1000000)::int, + $5, '2026-06-23', '2026-07-07', + 590, 147.5, 737.5, 737.5, 'approved')`, + [supplierInvoiceId, userId, companyId, supplierId, `CD-${supplierInvoiceId.slice(0, 8)}`], + ) + const batchId = randomUUID() + await getPool().query( + `INSERT INTO public.supplier_payment_batches + (id, company_id, user_id, format, total_amount, item_count, msg_id, debtor_snapshot) + VALUES ($1, $2, $3, 'pain001', 737.5, 1, $4, + '{"name":"Sandbox AB","org_number":"556677-8899","iban":"SE3550000000054910000003","bic":"ESSESESS"}')`, + [batchId, companyId, userId, `ACCOUNTED-5566778899-B${batchId.slice(0, 8)}`], + ) + await getPool().query( + `INSERT INTO public.supplier_payment_batch_items + (batch_id, company_id, supplier_invoice_id, amount, payment_date, + payee_type, payee_bankgiro, payee_name, reference_type, reference) + VALUES ($1, $2, $3, 737.5, '2026-08-15', + 'bankgiro', '50501055', 'Derome Bygg AB', 'invoice_number', 'CD3014794407')`, + [batchId, companyId, supplierInvoiceId], + ) + // A period that has both an IB link (write-once once opening_balances_set) + // and a bokslut link: fiscal_periods_opening_balance_entry_id_fkey and + // fiscal_periods_closing_entry_id_fkey are both NO ACTION, so the journal + // delete fails until the teardown clears them, and clearing them is itself + // blocked by enforce_opening_balance_immutability outside the bypass. + const closingEntryId = await insertPostedJournalEntry({ + userId, + companyId, + fiscalPeriodId, + voucherNumber: 1, + sourceType: 'year_end', + description: 'Sandbox bokslut', + }) + await getPool().query( + `UPDATE public.fiscal_periods + SET opening_balance_entry_id = $2, opening_balances_set = true, closing_entry_id = $3 + WHERE id = $1`, + [fiscalPeriodId, entryId, closingEntryId], + ) + // A delivered webhook delivery: block_webhook_delivery_terminal_delete + // raises on the auth.users -> companies cascade outside the bypass. + const webhookId = randomUUID() + await getPool().query( + `INSERT INTO public.webhooks (id, company_id, event_type, webhook_url, secret) + VALUES ($1, $2, 'journal_entry.committed', 'https://example.invalid/hook', $3)`, + [webhookId, companyId, randomUUID()], + ) + const deliveryId = randomUUID() + await getPool().query( + `INSERT INTO public.webhook_deliveries + (id, webhook_id, company_id, event_type, payload, api_version, status, delivered_at) + VALUES ($1, $2, $3, 'journal_entry.committed', '{"id":"demo"}', '2026-05-12', + 'delivered', now())`, + [deliveryId, webhookId, companyId], + ) + // A match-log row WITHOUT company_id: the shape lib/invoices/match-log.ts + // has always written, and the one the shared audit_log_immutable() bypass + // could not recognise as a sandbox row. It points at the supplier invoice + // above on purpose: payment_match_log_supplier_invoice_id_fkey is ON DELETE + // SET NULL, so a teardown that purges the match log after the supplier + // invoices turns this row into an UPDATE, which the guard refuses even + // during teardown. The order of the two deletes is what this row pins. + const transactionId = randomUUID() + await getPool().query( + `INSERT INTO public.transactions + (id, company_id, user_id, currency, amount, date, description, category) + VALUES ($1, $2, $3, 'SEK', -737.5, '2026-06-24', 'Sandbox betalning', 'uncategorized')`, + [transactionId, companyId, userId], + ) + const { rows: matchRows } = await getPool().query<{ id: string }>( + `INSERT INTO public.payment_match_log + (user_id, transaction_id, supplier_invoice_id, action) + VALUES ($1, $2, $3, 'matched') RETURNING id`, + [userId, transactionId, supplierInvoiceId], + ) + return { + userId, + companyId, + fiscalPeriodId, + entryId, + batchId, + supplierInvoiceId, + deliveryId, + matchLogId: matchRows[0]!.id, + } +} + +async function countById(table: string, id: string): Promise { + const { rows } = await getPool().query<{ n: number }>( + `SELECT count(*)::int AS n FROM ${table} WHERE id = $1`, + [id], + ) + return rows[0]!.n } async function insertAnonymousAuthUser(createdAt: string): Promise { @@ -151,6 +274,31 @@ describe('sandbox cleanup RPCs (pg)', () => { expect(lines[0]!.n).toBe(0) }) + it('clears the batch, IB/bokslut, webhook and match-log blockers and leaves no tenant rows', async () => { + const seed = await seedSandboxUser() + + await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [seed.userId]) + + expect(await authUserExists(seed.userId)).toBe(false) + // Each of these is one of the four blockers: before 20260901120000 the + // RPC raised on the first of them and the whole teardown rolled back. + expect(await countById('public.supplier_payment_batches', seed.batchId)).toBe(0) + expect(await countById('public.supplier_invoices', seed.supplierInvoiceId)).toBe(0) + expect(await countById('public.fiscal_periods', seed.fiscalPeriodId)).toBe(0) + expect(await countById('public.webhook_deliveries', seed.deliveryId)).toBe(0) + expect(await countById('public.payment_match_log', seed.matchLogId)).toBe(0) + const { rows: items } = await getPool().query<{ n: number }>( + `SELECT count(*)::int AS n FROM public.supplier_payment_batch_items WHERE batch_id = $1`, + [seed.batchId], + ) + expect(items[0]!.n).toBe(0) + const { rows: leftovers } = await getPool().query<{ n: number }>( + `SELECT count(*)::int AS n FROM public.company_settings WHERE company_id = $1`, + [seed.companyId], + ) + expect(leftovers[0]!.n).toBe(0) + }) + it('refuses a user with no company_settings rows at all', async () => { const { userId } = await seedCompany() await expect( @@ -442,3 +590,174 @@ describe('sandbox cleanup RPCs (pg)', () => { expect(rows[0]!.authed_expired).toBe(false) }) }) + +/** + * The regression half of 20260901120000: three guards were relaxed for + * sandbox teardown, and each one still has to refuse a real tenant. Every + * case runs twice, once plain and once with gnubok.sandbox_cleanup set by + * hand, because a flag-only bypass would pass the first and fail the second. + */ +describe('sandbox teardown bypasses never reach a real tenant (pg)', () => { + async function withTeardownFlag(fn: (client: PoolClient) => Promise): Promise { + const client = await getClient() + try { + await client.query('BEGIN') + await client.query(`SELECT set_config('gnubok.sandbox_cleanup', 'true', true)`) + return await fn(client) + } finally { + await client.query('ROLLBACK').catch(() => {}) + client.release() + } + } + + async function seedRealCompany(): Promise<{ + userId: string + companyId: string + fiscalPeriodId: string + }> { + const ctx = await seedCompany() + await getPool().query( + `INSERT INTO public.company_settings (user_id, company_id, is_sandbox) + VALUES ($1, $2, false)`, + [ctx.userId, ctx.companyId], + ) + return ctx + } + + it('enforce_opening_balance_immutability still refuses to unlink a real IB voucher', async () => { + const { userId, companyId, fiscalPeriodId } = await seedRealCompany() + const entryId = await insertPostedJournalEntry({ userId, companyId, fiscalPeriodId }) + await getPool().query( + `UPDATE public.fiscal_periods + SET opening_balance_entry_id = $2, opening_balances_set = true + WHERE id = $1`, + [fiscalPeriodId, entryId], + ) + + await expect( + getPool().query( + `UPDATE public.fiscal_periods SET opening_balance_entry_id = NULL WHERE id = $1`, + [fiscalPeriodId], + ), + ).rejects.toThrow(/opening balances are immutable once set/i) + + await withTeardownFlag(async (client) => { + await expect( + client.query( + `UPDATE public.fiscal_periods SET opening_balance_entry_id = NULL WHERE id = $1`, + [fiscalPeriodId], + ), + ).rejects.toThrow(/opening balances are immutable once set/i) + }) + }) + + it('enforce_opening_balance_immutability still refuses to detach a real bokslut voucher', async () => { + const { userId, companyId, fiscalPeriodId } = await seedRealCompany() + const closingEntryId = await insertPostedJournalEntry({ + userId, + companyId, + fiscalPeriodId, + sourceType: 'year_end', + description: 'Bokslut 2026', + }) + await getPool().query( + `UPDATE public.fiscal_periods SET closing_entry_id = $2 WHERE id = $1`, + [fiscalPeriodId, closingEntryId], + ) + + await withTeardownFlag(async (client) => { + await expect( + client.query( + `UPDATE public.fiscal_periods SET closing_entry_id = NULL WHERE id = $1`, + [fiscalPeriodId], + ), + ).rejects.toThrow(/year-end closing is immutable/i) + }) + }) + + it('block_webhook_delivery_terminal_delete still refuses a real tenant terminal delivery', async () => { + const { companyId } = await seedRealCompany() + const deliveryId = randomUUID() + await getPool().query( + `INSERT INTO public.webhook_deliveries + (id, company_id, event_type, payload, api_version, status, delivered_at) + VALUES ($1, $2, 'journal_entry.committed', '{"id":"real"}', '2026-05-12', + 'delivered', now())`, + [deliveryId, companyId], + ) + + await expect( + getPool().query(`DELETE FROM public.webhook_deliveries WHERE id = $1`, [deliveryId]), + ).rejects.toThrow(/terminal status/i) + + await withTeardownFlag(async (client) => { + await expect( + client.query(`DELETE FROM public.webhook_deliveries WHERE id = $1`, [deliveryId]), + ).rejects.toThrow(/terminal status/i) + }) + + // Non-terminal rows stay deletable, flag or no flag: the guard's + // predicate is unchanged for everyone outside a sandbox. + const pendingId = randomUUID() + await getPool().query( + `INSERT INTO public.webhook_deliveries + (id, company_id, event_type, payload, api_version, status) + VALUES ($1, $2, 'journal_entry.committed', '{"id":"real"}', '2026-05-12', 'pending')`, + [pendingId, companyId], + ) + await getPool().query(`DELETE FROM public.webhook_deliveries WHERE id = $1`, [pendingId]) + }) + + it('payment_match_log stays append-only for a real tenant, company_id set or NULL', async () => { + const { userId, companyId } = await seedRealCompany() + const transactionId = await insertTransaction({ companyId, userId }) + const { rows } = await getPool().query<{ id: string }>( + `INSERT INTO public.payment_match_log (user_id, company_id, transaction_id, action) + VALUES ($1, $2, $3, 'matched') RETURNING id`, + [userId, companyId, transactionId], + ) + const tenantedId = rows[0]!.id + const { rows: untenanted } = await getPool().query<{ id: string }>( + `INSERT INTO public.payment_match_log (user_id, transaction_id, action) + VALUES ($1, $2, 'unmatched') RETURNING id`, + [userId, transactionId], + ) + const untenantedId = untenanted[0]!.id + + for (const id of [tenantedId, untenantedId]) { + await expect( + getPool().query(`DELETE FROM public.payment_match_log WHERE id = $1`, [id]), + ).rejects.toThrow(/cannot be modified or deleted/i) + // The company_id IS NULL branch is the one 20260901120000 added; it + // must resolve through company_settings.user_id, not through the flag. + await withTeardownFlag(async (client) => { + await expect( + client.query(`DELETE FROM public.payment_match_log WHERE id = $1`, [id]), + ).rejects.toThrow(/cannot be modified or deleted/i) + }) + } + + await expect( + getPool().query( + `UPDATE public.payment_match_log SET action = 'unmatched' WHERE id = $1`, + [tenantedId], + ), + ).rejects.toThrow(/cannot be modified or deleted/i) + }) + + it('payment_match_log UPDATE stays forbidden even inside a sandbox teardown', async () => { + const seed = await seedSandboxUser() + + await withTeardownFlag(async (client) => { + await expect( + client.query( + `UPDATE public.payment_match_log SET action = 'unmatched' WHERE id = $1`, + [seed.matchLogId], + ), + ).rejects.toThrow(/cannot be modified or deleted/i) + }) + + await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [seed.userId]) + expect(await authUserExists(seed.userId)).toBe(false) + }) +})