From f7f3a31f8eadebc447af8abb732642e4d97a43ce Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:51:01 +0200 Subject: [PATCH] fix(sandbox): repair the silently-failing nightly sandbox cleanup and lock down its RPCs (#1449) * fix(sandbox): repair the silently-failing nightly sandbox cleanup and lock down its RPCs The daily cleanup cron has deleted nothing for months: cleanup_sandbox_user died on the journal-line immutability trigger for every user (the seed posts vouchers since spring), and cleanup_expired_sandbox_users swallowed each failure as a WARNING while reporting success. 658 expired sandbox users plus 21 orphaned anonymous users had accumulated in prod auth.users. - cleanup_sandbox_user sets the sanctioned gnubok.allow_delete flag plus a new transaction-local gnubok.sandbox_cleanup flag, only after verifying is_sandbox; write_audit_log, audit_log_immutable (DELETE only, per-row sandbox re-check), enforce_dimension_registry_guards (DELETE only) and enforce_pending_operations_no_delete (DELETE only) respect it - clears salary_runs voucher-link FKs and purges the sandbox company's audit rows before the auth.users cascade - cleanup_expired_sandbox_users returns {cleaned, failed, orphans_removed}, additionally sweeps expired anonymous users that never got a company_settings row, and takes an optional p_limit for bounded batches; the cron route logs failures at error level and accepts both return shapes - both RPCs lose their default PUBLIC EXECUTE grant (anon and authenticated could call them via PostgREST) and are now service_role-only - validated by replaying the full delete chain against prod inside aborted transactions (21 users sampled across all seed eras, zero failures) and a committed staging run; pg-real suite + cron route unit tests added Co-Authored-By: Claude Fable 5 * fix(sandbox): per-row sandbox re-verification in teardown guards, is_anonymous column guard Resolution pass for PR #1449 review findings and the pg-real CI failure: - Swedish accounting review: enforce_dimension_registry_guards and enforce_pending_operations_no_delete now re-verify per row that OLD.company_id belongs to a sandbox company (same pattern as audit_log_immutable) instead of trusting the gnubok.sandbox_cleanup flag alone. Because that re-check needs company_settings to still exist, cleanup_sandbox_user deletes pending_operations and dimensions explicitly before the auth.users cascade. - pg-real CI: auth.users.is_anonymous does not exist in the CI supabase/postgres image (or on older self-hosted stacks); the orphan sweep in cleanup_expired_sandbox_users is now guarded on the column's existence, and the pg test skips the orphan assertions on such stacks. Re-validated on staging end-to-end: {cleaned: 5, failed: 0, orphans_removed: 1}, fresh users and non-sandbox rows untouched. Co-Authored-By: Claude Fable 5 * fix(sandbox): make company_settings.is_sandbox write-once, prove orphan sweep fails loudly Round-2 review findings (Swedish accounting review on PR #1449): - Every teardown bypass trusts company_settings.is_sandbox, and RLS lets an owner update their own settings row via PostgREST, so a real company that flipped the flag would become eligible for full deletion by the nightly cron. New trigger makes the flag write-once (no application path updates it; a future sandbox-to-real conversion would ship its own migration). - New pg test pins the reviewer's remaining concern: an anonymous user who somehow has bookkeeping but no company_settings row is NOT silently deleted by the orphan sweep; the unbypasseed immutability triggers make the deletion fail loudly into the summary's failed count. Validated on staging: flip blocked in both directions, unrelated company_settings updates unaffected. Co-Authored-By: Claude Fable 5 * fix(sandbox): guard is_sandbox provenance at INSERT, make orphan sweep exclusions explicit Round-3 review hardening, approved by Emil: - is_sandbox = true can now only be created by an anonymous-user JWT (the sandbox seed's actor), service_role, or a direct database session. A regular authenticated user could previously insert their settings row pre-flagged and have the nightly cron destroy their real books, which BFL 7 kap. forbids even self-inflicted. Claims are read from the request.jwt.* GUCs directly so the check behaves identically on hosted, self-hosted, and the CI auth shim. - The orphan sweep now explicitly excludes anonymous users attached to any companies or company_members row, instead of relying on downstream immutability triggers throwing (emergent safety) to protect half-seeded users. - pg tests updated accordingly: blocked/allowed provenance paths, and the half-seeded user is proven unreachable rather than merely failing loudly. Validated on staging: authed insert blocked, anonymous-claim insert allowed, half-seeded user untouched, sweep summary failed=0. Co-Authored-By: Claude Fable 5 * fix(sandbox): all-rows sandbox check, cleared bypass flags, tighter insert guard CodeRabbit review pass on PR #1449 (its first non-rate-limited run): - cleanup_sandbox_user now requires EVERY company_settings row of the user to be sandbox-flagged, not an arbitrary single row: a hypothetical mixed-company user would otherwise have their real company's rows reached by the user-scoped deletes. - Both bypass flags are cleared before the RPC returns, so later work in the same transaction (the expired loop's next iterations, the orphan sweep) never runs with them still armed. - The is_sandbox insert guard now treats ANY PostgREST claims context (claims json without a role claim included) as guarded, instead of falling open when the role claim is absent. - The flag-leak pg test now runs inside an explicit transaction (the old version could not observe transaction-local GUCs at all), and a new test covers the mixed sandbox/real user refusal. Declined: replacing the em dashes inside the two replicated Swedish exception messages; they are byte-identical copies of the strings already deployed by migration 20260702084500 and changing them would alter live user-facing errors out of scope. Validated on staging: mixed user refused, flags cleared post-teardown, role-less claims blocked. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../cleanup/cron/__tests__/route.test.ts | 111 ++++ app/api/sandbox/cleanup/cron/route.ts | 23 +- ...30000_fix_sandbox_cleanup_allow_delete.sql | 544 ++++++++++++++++++ tests/pg/sandbox-cleanup.pg.test.ts | 376 ++++++++++++ 5 files changed, 1052 insertions(+), 3 deletions(-) create mode 100644 app/api/sandbox/cleanup/cron/__tests__/route.test.ts create mode 100644 supabase/migrations/20260807130000_fix_sandbox_cleanup_allow_delete.sql create mode 100644 tests/pg/sandbox-cleanup.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 4becac2e..5caf5b43 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -821,3 +821,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-06] Bucket A defaults pass commits the /pending Godkänn pill directly for low/medium risk and keeps the ConfirmationDialog only for high risk: the Granskning row already states source, title, risk and offers Detaljer, so the dialog's second Godkänn restated the row (the audit's expert lens called double-Godkänn the thing professionals do not tolerate). The chat-side "Godkänn alla N" was DEFERRED, not built: ApprovalCard owns its whole state machine internally (commit fetch, account-activation retry, typed high-risk confirm) and a bulk commit from AgentChat would leave committed cards rendering as pending; that is assistant-redesign seam 8.8 (approval batching) and needs the state lifted, not a button. [2026-08-06] SIE-export period default left unchanged despite the choice-audit finding: FiscalYearSelector with includeAllOption=false already auto-selects the newest started period once loaded, so the "opens with nothing selected" claim is only fetch latency. FyPicker gained preferLatestEnded for helårsmoms instead, which also skips the shared per-company localStorage scope on that surface: a filing page defaulting to the current (unfilable) year because Balansräkningen was last viewed there is the one wrong default. [2026-08-06] Review-workflow triage on the Bucket A branch (13 confirmed findings): fixed 10, incl. the branch-killing one (setActiveCompany's cookie write throws in Server Component render, so the /select-company auto-forward silently never fired: the cookie set is now best-effort because the gnubok-company-id cookie is write-only compat nothing reads). Batch "Ingen moms" now goes over the wire as 'exempt' instead of collapsing to undefined, which had an explicit no-VAT choice booking the derived 25%; the same pre-existing collapse in QuickReviewDialog/CategoryExpandedDialog is left for a follow-up. Skipped by choice: generalizing AiFilledIndicator for history provenance (the note's copy already names the source) and converting BulkBookInboxDialog's hardcoded-Swedish option lists to i18n (whole-file migration, not this branch's divergence). Monthly momsdeklaration default is deadline-aware (M-2 until the 12th/17th, M-1 after; over-40M always M-1) mirroring deadline-config, not just calendar-ended. +[2026-08-07] Sandbox cleanup repaired via a dedicated gnubok.sandbox_cleanup transaction-local flag (respected by write_audit_log, audit_log_immutable, enforce_dimension_registry_guards, enforce_pending_operations_no_delete) instead of tombstoning sandbox users like the real account-deletion flow: sandbox data is synthetic demo content, not rakenskapsinformation, so full deletion is the correct GDPR/BFL posture and the audit-log DELETE bypass re-verifies per row that the company is a sandbox before letting anything through. Also revoked the default PUBLIC EXECUTE both cleanup RPCs had carried on prod since March (anon could call them via PostgREST), and validated the whole delete chain by replaying it against prod inside aborted transactions (21 sampled users across all seed eras) plus a committed staging run. diff --git a/app/api/sandbox/cleanup/cron/__tests__/route.test.ts b/app/api/sandbox/cleanup/cron/__tests__/route.test.ts new file mode 100644 index 00000000..838219c8 --- /dev/null +++ b/app/api/sandbox/cleanup/cron/__tests__/route.test.ts @@ -0,0 +1,111 @@ +/** + * Tests for the sandbox cleanup cron route: the RPC's jsonb summary + * ({cleaned, failed, orphans_removed}, migration 20260807130000) is passed + * through, the legacy bare-integer shape is still accepted (deploy/migration + * ordering), and per-user failures are logged at error level: the failure + * mode this fixes was months of silently swallowed cleanup errors. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const h = vi.hoisted(() => ({ + logInfo: vi.fn(), + logError: vi.fn(), + rpc: vi.fn(), +})) + +vi.mock('@/lib/api/with-cron-context', () => ({ + withCronContext: + (_name: string, handler: (req: Request, ctx: unknown) => Promise) => + (req: Request) => + handler(req, { + log: { info: h.logInfo, error: h.logError, warn: vi.fn() }, + requestId: 'req_test', + }), +})) + +vi.mock('@supabase/supabase-js', () => ({ + createClient: vi.fn(() => ({ rpc: h.rpc })), +})) + +import { GET } from '../route' + +function cronRequest(): Request { + return new Request('http://localhost:3000/api/sandbox/cleanup/cron') +} + +describe('GET /api/sandbox/cleanup/cron', () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://example.supabase.co' + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role-key' + }) + + it('passes the jsonb summary through and logs at info level when nothing failed', async () => { + h.rpc.mockResolvedValue({ + data: { cleaned: 3, failed: 0, orphans_removed: 2 }, + error: null, + }) + + const res = await GET(cronRequest()) + const body = await res.json() + + expect(h.rpc).toHaveBeenCalledWith('cleanup_expired_sandbox_users', { + p_max_age_hours: 24, + }) + expect(res.status).toBe(200) + expect(body).toEqual({ success: true, cleaned: 3, failed: 0, orphans_removed: 2 }) + expect(h.logInfo).toHaveBeenCalled() + expect(h.logError).not.toHaveBeenCalled() + }) + + it('logs at error level when the summary reports failures', async () => { + h.rpc.mockResolvedValue({ + data: { cleaned: 1, failed: 4, orphans_removed: 0 }, + error: null, + }) + + const res = await GET(cronRequest()) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.failed).toBe(4) + expect(h.logError).toHaveBeenCalledWith( + 'sandbox cleanup completed with failures', + expect.objectContaining({ failed: 4 }), + ) + }) + + it('still accepts the legacy bare-integer return shape', async () => { + h.rpc.mockResolvedValue({ data: 5, error: null }) + + const res = await GET(cronRequest()) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body).toEqual({ success: true, cleaned: 5, failed: 0, orphans_removed: 0 }) + }) + + it('returns an error envelope when the RPC fails', async () => { + h.rpc.mockResolvedValue({ + data: null, + error: { message: 'boom', code: 'XX000' }, + }) + + const res = await GET(cronRequest()) + const body = await res.json() + + expect(res.status).toBeGreaterThanOrEqual(400) + expect(body.error).toBeDefined() + expect(h.logError).toHaveBeenCalled() + }) + + it('returns an error when Supabase configuration is missing', async () => { + delete process.env.SUPABASE_SERVICE_ROLE_KEY + + const res = await GET(cronRequest()) + const body = await res.json() + + expect(res.status).toBeGreaterThanOrEqual(500) + expect(body.error).toBeDefined() + }) +}) diff --git a/app/api/sandbox/cleanup/cron/route.ts b/app/api/sandbox/cleanup/cron/route.ts index d3b7e4dc..00f2f502 100644 --- a/app/api/sandbox/cleanup/cron/route.ts +++ b/app/api/sandbox/cleanup/cron/route.ts @@ -29,8 +29,25 @@ export const GET = withCronContext('cron.sandbox_cleanup', async (_request, ctx) return errorResponse(error, ctx.log, { requestId: ctx.requestId }) } - const cleaned = data ?? 0 - ctx.log.info('sandbox cleanup summary', { cleaned }) + // Migration 20260807130000 changed the RPC's return from a bare integer to + // a {cleaned, failed, orphans_removed} summary; accept both shapes so + // deploy/migration ordering cannot break the cron. + const summary = + typeof data === 'number' + ? { cleaned: data, failed: 0, orphans_removed: 0 } + : { + cleaned: Number(data?.cleaned ?? 0), + failed: Number(data?.failed ?? 0), + orphans_removed: Number(data?.orphans_removed ?? 0), + } - return NextResponse.json({ success: true, cleaned }) + // Per-user failures used to be swallowed as Postgres WARNINGs, which is how + // the cleanup sat broken for months; surface them at error level instead. + if (summary.failed > 0) { + ctx.log.error('sandbox cleanup completed with failures', summary) + } else { + ctx.log.info('sandbox cleanup summary', summary) + } + + return NextResponse.json({ success: true, ...summary }) }) diff --git a/supabase/migrations/20260807130000_fix_sandbox_cleanup_allow_delete.sql b/supabase/migrations/20260807130000_fix_sandbox_cleanup_allow_delete.sql new file mode 100644 index 00000000..26e1a182 --- /dev/null +++ b/supabase/migrations/20260807130000_fix_sandbox_cleanup_allow_delete.sql @@ -0,0 +1,544 @@ +-- Fix: the nightly sandbox cleanup has been a silent no-op since the sandbox +-- seed started posting vouchers. cleanup_sandbox_user deletes +-- journal_entry_lines without setting the gnubok.allow_delete bypass, so +-- enforce_journal_entry_line_immutability raises "Cannot DELETE lines of a +-- posted journal entry", cleanup_expired_sandbox_users swallows the error as +-- a WARNING, and the cron reports success every night while expired anonymous +-- users accumulate in auth.users (658 overdue on prod at the time of writing, +-- oldest from 2026-03). +-- +-- Behind that first failure hide two more, confirmed by replaying the fixed +-- delete chain against prod inside an aborted transaction: +-- +-- * salary_runs references its booked vouchers with plain NO ACTION FKs, +-- so the journal entry delete fails while a booked run points at them. +-- * the auth.users delete cascades through the sandbox company, and +-- write_audit_log fires mid-cascade, inserting audit rows that reference +-- the company being deleted in that same cascade: FK violation. Existing +-- audit rows from seeding block the company delete the same way, and +-- audit_log_no_delete forbids removing them. +-- +-- The teardown therefore gets its own transaction-local flag, +-- gnubok.sandbox_cleanup, set only inside cleanup_sandbox_user after its +-- is_sandbox check: +-- +-- 1. write_audit_log skips while the flag is set (sandbox demo data needs +-- no audit trail, and the company the rows would reference is being +-- deleted anyway). +-- 2. audit_log_immutable allows DELETE only when the flag is set AND the +-- row's company_id provably belongs to a sandbox company. UPDATE stays +-- forbidden unconditionally. Real companies' audit rows remain WORM: +-- the trigger re-verifies sandbox-ness per row instead of trusting the +-- flag alone. +-- 3. enforce_dimension_registry_guards allows the DELETE cascade through +-- the system dimensions (every post-2026-07 sandbox has dims 1/6 via +-- ensure_company_dimensions) while the flag is set. +-- 4. enforce_pending_operations_no_delete allows the cascade through +-- terminal-state demo operations while the flag is set. +-- 4b. company_settings.is_sandbox becomes write-once and insert-guarded +-- (trigger): every bypass above trusts that flag, so a real company +-- must never be able to flip it, and is_sandbox = true can only be +-- created by anonymous-user JWTs, service_role, or direct DB sessions. +-- 5. cleanup_sandbox_user sets gnubok.allow_delete (the sanctioned +-- delete_last_voucher flag that every delete-path trigger on +-- journal_entries / journal_entry_lines respects) plus the new flag, +-- clears the salary_runs voucher links, and purges the sandbox +-- company's audit rows before deleting auth.users. +-- +-- cleanup_expired_sandbox_users additionally returns a jsonb summary +-- {cleaned, failed, orphans_removed} instead of a bare count so the cron can +-- log failures instead of hiding them, sweeps expired anonymous auth users +-- that never got a company_settings row (a seed that failed before writing +-- settings left them invisible to the old query, leaking them forever), and +-- takes an optional p_limit for bounded batch runs against a backlog. +-- +-- Both RPCs also lose their default PUBLIC EXECUTE grant: on prod, anon and +-- authenticated could call them via PostgREST. + +-- ============================================================================= +-- 1. write_audit_log: skip during sandbox teardown +-- ============================================================================= + +CREATE OR REPLACE FUNCTION public.write_audit_log() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +AS $function$ +DECLARE + v_user_id uuid; + v_company_id uuid; + v_action text; + v_old_state jsonb; + v_new_state jsonb; + v_record_id uuid; + v_desc text; +BEGIN + -- Sandbox teardown (cleanup_sandbox_user) deletes the company row itself; + -- audit rows inserted mid-cascade would reference the vanishing company + -- and violate audit_log_company_id_fkey. The flag is transaction-local and + -- only set after the RPC's is_sandbox check. + IF current_setting('gnubok.sandbox_cleanup', true) = 'true' THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + v_old_state := to_jsonb(OLD); + v_new_state := NULL; + v_record_id := OLD.id; + v_user_id := (v_old_state->>'user_id')::uuid; + v_company_id := (v_old_state->>'company_id')::uuid; + v_action := 'DELETE'; + v_desc := 'Deleted ' || TG_TABLE_NAME || ' record'; + ELSIF TG_OP = 'INSERT' THEN + v_old_state := NULL; + v_new_state := to_jsonb(NEW); + v_record_id := NEW.id; + v_user_id := (v_new_state->>'user_id')::uuid; + v_company_id := (v_new_state->>'company_id')::uuid; + v_action := 'INSERT'; + v_desc := 'Created ' || TG_TABLE_NAME || ' record'; + ELSIF TG_OP = 'UPDATE' THEN + v_old_state := to_jsonb(OLD); + v_new_state := to_jsonb(NEW); + v_record_id := COALESCE(NEW.id, OLD.id); + v_user_id := COALESCE((v_new_state->>'user_id')::uuid, (v_old_state->>'user_id')::uuid); + v_company_id := COALESCE((v_new_state->>'company_id')::uuid, (v_old_state->>'company_id')::uuid); + v_action := 'UPDATE'; + v_desc := 'Updated ' || TG_TABLE_NAME || ' record'; + + IF TG_TABLE_NAME = 'journal_entries' THEN + IF OLD.status = 'draft' AND NEW.status = 'posted' THEN + v_action := 'COMMIT'; + v_desc := 'Committed journal entry ' || NEW.voucher_series || NEW.voucher_number; + ELSIF OLD.status = 'posted' AND NEW.status = 'reversed' THEN + v_action := 'REVERSE'; + v_desc := 'Reversed journal entry ' || OLD.voucher_series || OLD.voucher_number; + END IF; + END IF; + + IF TG_TABLE_NAME = 'fiscal_periods' THEN + IF (OLD.locked_at IS NULL AND NEW.locked_at IS NOT NULL) THEN + v_action := 'LOCK_PERIOD'; + v_desc := 'Locked fiscal period "' || NEW.name || '"'; + ELSIF (NOT OLD.is_closed AND NEW.is_closed) THEN + v_action := 'CLOSE_PERIOD'; + v_desc := 'Closed fiscal period "' || NEW.name || '"'; + END IF; + END IF; + END IF; + + -- Fall back to auth.uid() when the row does not carry user_id + v_user_id := COALESCE(v_user_id, auth.uid()); + + INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, actor_id, old_state, new_state, description, actor_type, actor_label) + VALUES ( + v_user_id, v_company_id, v_action, TG_TABLE_NAME, v_record_id, v_user_id, v_old_state, v_new_state, v_desc, + COALESCE(nullif(current_setting('gnubok.actor_type', true), ''), 'user'), + nullif(current_setting('gnubok.actor_label', true), '') + ); + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$function$; + +-- ============================================================================= +-- 2. audit_log_immutable: allow sandbox-teardown DELETE, verified per row +-- ============================================================================= + +CREATE OR REPLACE FUNCTION public.audit_log_immutable() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = public +AS $function$ +BEGIN + -- Sandbox teardown may delete audit rows, but only rows whose company is + -- provably a sandbox: the per-row re-check means a set flag alone can + -- never unlock real companies' audit trail. UPDATE stays forbidden even + -- during teardown. Rows must be purged while the company_settings row + -- still exists (cleanup_sandbox_user does this before auth.users). + 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 + ) THEN + RETURN OLD; + END IF; + + RAISE EXCEPTION 'Audit log entries cannot be modified or deleted'; +END; +$function$; + +-- ============================================================================= +-- 3. enforce_dimension_registry_guards: allow sandbox-teardown DELETE +-- ============================================================================= + +-- Every sandbox seeded since the dimensions substrate (2026-07) carries the +-- system dimensions 1/6 via ensure_company_dimensions, and this guard blocks +-- their DELETE unconditionally, so the auth.users cascade dies on +-- "Systemdimensionen 1 (Kostnadsställe) kan inte tas bort" (found by probing +-- the fixed cleanup against staging's stale sandboxes). During teardown the +-- whole company is being deleted; keeping its dimension registry rows is +-- neither possible nor meaningful. UPDATE guards stay untouched. + +CREATE OR REPLACE FUNCTION public.enforce_dimension_registry_guards() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF NEW.sie_dim_no <> OLD.sie_dim_no THEN + RAISE EXCEPTION 'Dimensionsnumret kan inte ändras (rader är taggade med numret).'; + END IF; + IF NEW.is_system <> OLD.is_system THEN + RAISE EXCEPTION 'is_system kan inte ändras.'; + END IF; + RETURN NEW; + END IF; + + -- DELETE + -- Sandbox teardown deletes the entire company; its registry rows go with + -- it. Transaction-local flag, only set by cleanup_sandbox_user after its + -- is_sandbox check, plus a per-row re-verification (same pattern as + -- audit_log_immutable) so the flag alone can never unlock a real + -- company's registry. cleanup_sandbox_user deletes these rows explicitly + -- while company_settings still exists; the re-check would fail mid-cascade + -- once that row is gone. + 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.is_system THEN + RAISE EXCEPTION 'Systemdimensionen % (%) kan inte tas bort — avaktivera den istället.', + OLD.sie_dim_no, OLD.name; + END IF; + IF EXISTS ( + SELECT 1 + FROM public.journal_entries je + JOIN public.journal_entry_lines jel ON jel.journal_entry_id = je.id + WHERE je.company_id = OLD.company_id + AND je.status IN ('posted', 'reversed') + AND jel.dimensions ? OLD.sie_dim_no::text + ) THEN + RAISE EXCEPTION 'Dimensionen % (%) används på bokförda verifikat och kan inte tas bort — avaktivera den istället.', + OLD.sie_dim_no, OLD.name; + END IF; + RETURN OLD; +END; +$$; + +-- ============================================================================= +-- 4. enforce_pending_operations_no_delete: allow sandbox-teardown DELETE +-- ============================================================================= + +-- Sandbox visitors approve/reject the pre-staged demo operations, leaving +-- terminal-state pending_operations rows that this guard refuses to delete +-- (BFL 7 kap. protection for real books; found by probing prod's backlog). +-- Same teardown rule as above: the demo company is being deleted wholesale. +-- The UPDATE immutability trigger stays untouched. Base definition: +-- 20260722134114 (includes 'failed_partial'). + +CREATE OR REPLACE FUNCTION public.enforce_pending_operations_no_delete() +RETURNS TRIGGER AS $$ +BEGIN + -- Sandbox teardown: transaction-local flag, only set by + -- cleanup_sandbox_user after its is_sandbox check, plus a per-row + -- re-verification (same pattern as audit_log_immutable) so the flag alone + -- can never unlock a real company's rows. cleanup_sandbox_user deletes + -- these rows explicitly while company_settings still exists. + 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 ('committed', 'rejected', 'failed_partial') THEN + RAISE EXCEPTION + 'pending_operations row % is in terminal state % and cannot be deleted (BFL 7 kap.)', + OLD.id, OLD.status + USING ERRCODE = 'check_violation'; + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================= +-- 5. cleanup_sandbox_user: set bypass flags, clear blocking FKs, purge audit +-- ============================================================================= + +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. A single-row read would pick an arbitrary + -- row for a hypothetical multi-company user and the user-scoped deletes + -- below would then reach the real company's rows. + 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; + + -- Sanctioned trigger bypasses, transaction-local and only reachable after + -- the is_sandbox check above, so real companies can never enter this path. + -- allow_delete is the delete_last_voucher flag the journal immutability + -- and retention triggers respect; sandbox_cleanup gates the audit-log + -- behavior (see the trigger functions above). + PERFORM set_config('gnubok.allow_delete', 'true', true); + PERFORM set_config('gnubok.sandbox_cleanup', 'true', true); + + -- Clear RESTRICT FKs on document_attachments + 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; + + -- salary_runs references its booked vouchers with plain NO ACTION FKs + -- (salary_entry_id, avgifter_entry_id, pension_entry_id, + -- vacation_entry_id); the seed links one booked run, so the journal entry + -- delete below would otherwise fail. + 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; + + -- Delete journal entry lines (child of journal_entries) + 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; + + -- Delete supplier invoices before suppliers cascade + DELETE FROM public.supplier_invoices WHERE user_id = p_user_id; + + -- Terminal pending operations and the dimension registry carry delete + -- guards whose bypass re-verifies sandbox-ness through company_settings, + -- so delete them explicitly while that row still exists instead of + -- leaving them to the auth.users cascade (cascade order is unspecified + -- and may remove company_settings first). + 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 + ); + + -- Purge the sandbox company's audit rows while company_settings still + -- exists (audit_log_immutable re-verifies sandbox-ness through it). + -- audit_log.company_id has a plain NO ACTION FK, so leftover rows would + -- block the companies delete inside the auth.users cascade. + 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 cascades everything else + DELETE FROM auth.users WHERE id = p_user_id; + GET DIAGNOSTICS v_deleted = ROW_COUNT; + + -- Drop the bypasses before returning so nothing later in the same + -- transaction (the expired-users loop, the orphan sweep) runs with them + -- still armed. + PERFORM set_config('gnubok.allow_delete', '', true); + PERFORM set_config('gnubok.sandbox_cleanup', '', true); + + RETURN v_deleted; +END; +$$; + +-- ============================================================================= +-- 6. cleanup_expired_sandbox_users: jsonb summary + orphaned-anonymous sweep +-- ============================================================================= + +-- Return type changes from integer to jsonb, so CREATE OR REPLACE cannot be +-- used; drop the old signature first. +DROP FUNCTION IF EXISTS public.cleanup_expired_sandbox_users(int); + +CREATE FUNCTION public.cleanup_expired_sandbox_users( + p_max_age_hours int DEFAULT 24, + p_limit int DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_user_id uuid; + v_cleaned integer := 0; + v_failed integer := 0; + v_orphans integer := 0; +BEGIN + FOR v_user_id IN + SELECT cs.user_id + FROM public.company_settings cs + WHERE cs.is_sandbox = true + AND cs.created_at < now() - interval '1 hour' * p_max_age_hours + ORDER BY cs.created_at + LIMIT p_limit + LOOP + BEGIN + PERFORM public.cleanup_sandbox_user(v_user_id); + v_cleaned := v_cleaned + 1; + EXCEPTION WHEN OTHERS THEN + v_failed := v_failed + 1; + RAISE WARNING 'Failed to clean up sandbox user %: %', v_user_id, SQLERRM; + END; + END LOOP; + + -- Anonymous users whose seed never reached the company_settings insert are + -- invisible to the query above and would otherwise leak forever. They have + -- no bookkeeping (the seed writes settings before any vouchers), so a + -- plain auth.users delete cascades the little they do have. Non-anonymous + -- users are never touched here. auth.users.is_anonymous arrived with + -- GoTrue's anonymous sign-ins; older self-hosted stacks (and the CI + -- supabase/postgres image) predate it, and without anonymous sign-ins no + -- orphans can exist, so the sweep is skipped there. plpgsql resolves the + -- loop query only when this branch executes. + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' AND table_name = 'users' + AND column_name = 'is_anonymous' + ) THEN + FOR v_user_id IN + SELECT u.id + FROM auth.users u + WHERE u.is_anonymous = true + AND u.created_at < now() - interval '1 hour' * p_max_age_hours + AND NOT EXISTS ( + SELECT 1 FROM public.company_settings cs WHERE cs.user_id = u.id + ) + -- Explicit safety, not emergent: an anonymous user attached to ANY + -- company (a seed that died between company creation and the + -- settings insert) is out of scope for this blind delete. Such + -- half-seeded users are rare and can be handled manually; a user + -- with real data must never depend on a downstream trigger throwing. + AND NOT EXISTS ( + SELECT 1 FROM public.companies c WHERE c.created_by = u.id + ) + AND NOT EXISTS ( + SELECT 1 FROM public.company_members cm WHERE cm.user_id = u.id + ) + ORDER BY u.created_at + LIMIT p_limit + LOOP + BEGIN + DELETE FROM auth.users WHERE id = v_user_id; + v_orphans := v_orphans + 1; + EXCEPTION WHEN OTHERS THEN + v_failed := v_failed + 1; + RAISE WARNING 'Failed to clean up orphaned anonymous user %: %', v_user_id, SQLERRM; + END; + END LOOP; + END IF; + + RETURN jsonb_build_object( + 'cleaned', v_cleaned, + 'failed', v_failed, + 'orphans_removed', v_orphans + ); +END; +$$; + +-- ============================================================================= +-- 7. company_settings.is_sandbox: write-once, and true only for sandbox actors +-- ============================================================================= + +-- Every teardown bypass above trusts company_settings.is_sandbox, so the +-- flag needs guarded provenance in both directions: +-- +-- * UPDATE: write-once. RLS lets an owner update their own settings row +-- via PostgREST, and a real company that flipped is_sandbox = true +-- would become eligible for full deletion by the nightly cron. No +-- application path updates the flag; a future sandbox-to-real +-- conversion ships its own migration relaxing this deliberately. +-- * INSERT: is_sandbox = true may only be written by an anonymous-user +-- JWT (the sandbox seed's actor), service_role, or a direct database +-- session (no PostgREST claims at all: migrations, seeds, tests). A +-- regular authenticated user must not be able to provision their real +-- company as a sandbox and have the cron destroy their books, which +-- BFL 7 kap. forbids even self-inflicted. +-- +-- Claims are read from request.jwt.* GUCs directly (both the modern json +-- and the legacy per-claim style) rather than auth helpers, so the check +-- behaves identically on hosted, self-hosted, and the CI auth shim. + +CREATE OR REPLACE FUNCTION public.enforce_company_settings_sandbox_immutable() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_claims jsonb; + v_role text; +BEGIN + IF TG_OP = 'UPDATE' THEN + IF NEW.is_sandbox IS DISTINCT FROM OLD.is_sandbox THEN + RAISE EXCEPTION 'company_settings.is_sandbox is write-once: it is set at company creation and cannot be changed'; + END IF; + RETURN NEW; + END IF; + + -- INSERT + IF NEW.is_sandbox = true THEN + v_claims := nullif(current_setting('request.jwt.claims', true), '')::jsonb; + v_role := coalesce( + nullif(current_setting('request.jwt.claim.role', true), ''), + v_claims->>'role' + ); + -- Any PostgREST context at all (claims json or a per-claim role) must + -- prove itself; a claims blob without a role claim is still not a + -- direct database session. + IF (v_claims IS NOT NULL OR v_role IS NOT NULL) + AND coalesce(v_role, '') <> 'service_role' + AND coalesce((v_claims->>'is_anonymous')::boolean, false) = false THEN + RAISE EXCEPTION 'company_settings.is_sandbox = true can only be created for anonymous sandbox users'; + END IF; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS company_settings_sandbox_immutable ON public.company_settings; +CREATE TRIGGER company_settings_sandbox_immutable + BEFORE INSERT OR UPDATE ON public.company_settings + FOR EACH ROW EXECUTE FUNCTION public.enforce_company_settings_sandbox_immutable(); + +-- ============================================================================= +-- 8. Grants: service_role only (both were PUBLIC-executable on prod) +-- ============================================================================= + +REVOKE ALL ON FUNCTION public.cleanup_sandbox_user(uuid) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.cleanup_expired_sandbox_users(int, int) FROM PUBLIC, anon, authenticated; + +GRANT EXECUTE ON FUNCTION public.cleanup_sandbox_user(uuid) TO service_role; +GRANT EXECUTE ON FUNCTION public.cleanup_expired_sandbox_users(int, int) TO service_role; diff --git a/tests/pg/sandbox-cleanup.pg.test.ts b/tests/pg/sandbox-cleanup.pg.test.ts new file mode 100644 index 00000000..e3860e5a --- /dev/null +++ b/tests/pg/sandbox-cleanup.pg.test.ts @@ -0,0 +1,376 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getClient, getPool } from './setup' +import { insertPostedJournalEntry, seedCompany } from './fixtures' + +/** + * Sandbox cleanup RPCs (migration 20260807130000): + * + * The nightly cron was a silent no-op for months: cleanup_sandbox_user + * deleted journal_entry_lines without setting the gnubok.allow_delete + * bypass, so the BFL immutability trigger rejected the delete and the outer + * loop swallowed the error as a WARNING. These tests pin the fixed behavior: + * a sandbox company with posted vouchers and a booked salary run actually + * 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. + */ + +async function seedSandboxUser(settingsCreatedAt?: string): Promise<{ + userId: string + companyId: string + entryId: string +}> { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + await getPool().query( + `INSERT INTO public.company_settings (user_id, company_id, is_sandbox, created_at) + VALUES ($1, $2, true, COALESCE($3::timestamptz, now()))`, + [userId, companyId, settingsCreatedAt ?? null], + ) + const entryId = await insertPostedJournalEntry({ + userId, + companyId, + fiscalPeriodId, + }) + // The seed links a booked salary run to its vouchers with plain NO ACTION + // FKs; recreate that so the test fails if the RPC forgets to clear them. + await getPool().query( + `INSERT INTO public.salary_runs + (company_id, user_id, period_year, period_month, payment_date, salary_entry_id) + VALUES ($1, $2, 2026, 1, '2026-01-25', $3)`, + [companyId, userId, entryId], + ) + // System dimensions (undeletable outside teardown) and a terminal-state + // pending operation (delete-protected per BFL 7 kap.): both exist in every + // modern sandbox and both blocked the auth.users cascade before the + // gnubok.sandbox_cleanup bypass. + await getPool().query(`SELECT public.ensure_company_dimensions($1)`, [companyId]) + await getPool().query( + `INSERT INTO public.pending_operations + (user_id, company_id, operation_type, title, status) + VALUES ($1, $2, 'categorize_transaction', 'Sandbox cleanup test op', 'rejected')`, + [userId, companyId], + ) + return { userId, companyId, entryId } +} + +async function insertAnonymousAuthUser(createdAt: string): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO auth.users (id, email, instance_id, is_anonymous, created_at) + VALUES ($1, NULL, '00000000-0000-0000-0000-000000000000'::uuid, true, $2::timestamptz)`, + [id, createdAt], + ) + return id +} + +// auth.users.is_anonymous arrived with GoTrue anonymous sign-ins; the CI +// supabase/postgres image predates it. The RPC skips the orphan sweep on such +// stacks, so the test skips the matching assertions rather than fabricating a +// schema hosted Supabase would not have. +async function hasIsAnonymousColumn(): Promise { + const { rows } = await getPool().query<{ has: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'auth' AND table_name = 'users' + AND column_name = 'is_anonymous' + ) AS has`, + ) + return rows[0]!.has +} + +async function authUserExists(id: string): Promise { + const { rows } = await getPool().query<{ n: number }>( + `SELECT count(*)::int AS n FROM auth.users WHERE id = $1`, + [id], + ) + return rows[0]!.n > 0 +} + +describe('sandbox cleanup RPCs (pg)', () => { + it('deletes a sandbox user whose books contain posted vouchers and a booked salary run', async () => { + const { userId, entryId } = await seedSandboxUser() + + await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [userId]) + + expect(await authUserExists(userId)).toBe(false) + const { rows: entries } = await getPool().query<{ n: number }>( + `SELECT count(*)::int AS n FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + expect(entries[0]!.n).toBe(0) + const { rows: lines } = await getPool().query<{ n: number }>( + `SELECT count(*)::int AS n FROM public.journal_entry_lines WHERE journal_entry_id = $1`, + [entryId], + ) + expect(lines[0]!.n).toBe(0) + }) + + it('refuses a user whose company is not a sandbox', async () => { + const { userId, companyId } = await seedCompany() + await getPool().query( + `INSERT INTO public.company_settings (user_id, company_id, is_sandbox) + VALUES ($1, $2, false)`, + [userId, companyId], + ) + + await expect( + getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [userId]), + ).rejects.toThrow(/is not a sandbox user/i) + expect(await authUserExists(userId)).toBe(true) + }) + + it('does not loosen posted-entry immutability outside the RPC', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertPostedJournalEntry({ userId, companyId, fiscalPeriodId }) + + const client = await getClient() + try { + await client.query('BEGIN') + await expect( + client.query(`DELETE FROM public.journal_entry_lines WHERE journal_entry_id = $1`, [ + entryId, + ]), + ).rejects.toThrow(/posted journal entry/i) + } finally { + await client.query('ROLLBACK').catch(() => {}) + client.release() + } + }) + + it('the bypass flags are cleared before cleanup_sandbox_user returns', async () => { + const { userId } = await seedSandboxUser() + const client = await getClient() + try { + // Explicit transaction: a bare statement would end its own implicit + // transaction and discard transaction-local GUCs regardless, which is + // exactly the blind spot the old version of this test had. + await client.query('BEGIN') + await client.query(`SELECT public.cleanup_sandbox_user($1)`, [userId]) + const { rows } = await client.query<{ del: string | null; sc: string | null }>( + `SELECT current_setting('gnubok.allow_delete', true) AS del, + current_setting('gnubok.sandbox_cleanup', true) AS sc`, + ) + expect(rows[0]!.del ?? '').not.toBe('true') + expect(rows[0]!.sc ?? '').not.toBe('true') + await client.query('COMMIT') + } catch (err) { + await client.query('ROLLBACK').catch(() => {}) + throw err + } finally { + client.release() + } + }) + + it('refuses a user who has both a sandbox and a non-sandbox company', async () => { + const sandbox = await seedSandboxUser() + const otherCompanyId = randomUUID() + await getPool().query( + `INSERT INTO public.companies (id, name, entity_type, created_by) + VALUES ($1, 'Second Real Company', 'enskild_firma', $2)`, + [otherCompanyId, sandbox.userId], + ) + await getPool().query( + `INSERT INTO public.company_settings (user_id, company_id, is_sandbox) + VALUES ($1, $2, false)`, + [sandbox.userId, otherCompanyId], + ) + + await expect( + getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [sandbox.userId]), + ).rejects.toThrow(/is not a sandbox user/i) + expect(await authUserExists(sandbox.userId)).toBe(true) + + // Clean up: replace the non-sandbox settings row with a sandbox one + // (a direct DB session may insert is_sandbox = true), then the + // sanctioned teardown removes everything. + await getPool().query( + `DELETE FROM public.company_settings WHERE company_id = $1`, + [otherCompanyId], + ) + await getPool().query( + `INSERT INTO public.company_settings (user_id, company_id, is_sandbox) + VALUES ($1, $2, true)`, + [sandbox.userId, otherCompanyId], + ) + await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [sandbox.userId]) + expect(await authUserExists(sandbox.userId)).toBe(false) + }) + + it('sweeps expired sandbox users and orphaned anonymous users, keeps fresh ones, reports counts', async () => { + const anonSupported = await hasIsAnonymousColumn() + + // Ancient timestamps put our rows first in the ORDER BY created_at loops, + // so a bounded p_limit still covers them even on a shared database that + // has its own stale sandbox rows. + const expired = await seedSandboxUser('2000-01-02T00:00:00Z') + const fresh = await seedSandboxUser() + const expiredOrphan = anonSupported + ? await insertAnonymousAuthUser('2000-01-01T00:00:00Z') + : null + const freshOrphan = anonSupported + ? await insertAnonymousAuthUser(new Date().toISOString()) + : null + + const { rows } = await getPool().query<{ + summary: { cleaned: number; failed: number; orphans_removed: number } + }>(`SELECT public.cleanup_expired_sandbox_users(24, 25) AS summary`) + const summary = rows[0]!.summary + + expect(await authUserExists(expired.userId)).toBe(false) + expect(await authUserExists(fresh.userId)).toBe(true) + expect(summary.cleaned).toBeGreaterThanOrEqual(1) + expect(summary.failed).toBe(0) + if (anonSupported && expiredOrphan && freshOrphan) { + expect(await authUserExists(expiredOrphan)).toBe(false) + expect(await authUserExists(freshOrphan)).toBe(true) + expect(summary.orphans_removed).toBeGreaterThanOrEqual(1) + } else { + expect(summary.orphans_removed).toBe(0) + } + + // Leave nothing behind on a shared database. + await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [fresh.userId]) + if (freshOrphan) { + await getPool().query(`DELETE FROM auth.users WHERE id = $1`, [freshOrphan]) + } + }) + + it('company_settings.is_sandbox is write-once in both directions', async () => { + const real = await seedCompany() + await getPool().query( + `INSERT INTO public.company_settings (user_id, company_id, is_sandbox) + VALUES ($1, $2, false)`, + [real.userId, real.companyId], + ) + await expect( + getPool().query( + `UPDATE public.company_settings SET is_sandbox = true WHERE company_id = $1`, + [real.companyId], + ), + ).rejects.toThrow(/write-once/i) + + const sandbox = await seedSandboxUser() + await expect( + getPool().query( + `UPDATE public.company_settings SET is_sandbox = false WHERE company_id = $1`, + [sandbox.companyId], + ), + ).rejects.toThrow(/write-once/i) + // Other columns stay updatable. + await getPool().query( + `UPDATE public.company_settings SET is_sandbox = is_sandbox, company_name = 'Still Updatable' + WHERE company_id = $1`, + [real.companyId], + ) + await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [sandbox.userId]) + }) + + it('the orphan sweep never reaches an anonymous user who has a company but no settings row', async () => { + if (!(await hasIsAnonymousColumn())) return + + const userId = await insertAnonymousAuthUser('2000-01-03T00:00:00Z') + const companyId = randomUUID() + await getPool().query( + `INSERT INTO public.companies (id, name, entity_type, created_by) + VALUES ($1, 'Orphan With Books', 'enskild_firma', $2)`, + [companyId, userId], + ) + const { rows: fpRows } = await getPool().query<{ id: string }>( + `INSERT INTO public.fiscal_periods (user_id, company_id, name, period_start, period_end) + VALUES ($1, $2, 'Orphan 2026', '2026-01-01', '2026-12-31') RETURNING id`, + [userId, companyId], + ) + await insertPostedJournalEntry({ + userId, + companyId, + fiscalPeriodId: fpRows[0]!.id, + }) + + await getPool().query(`SELECT public.cleanup_expired_sandbox_users(24, 25)`) + + // Excluded from the sweep by the explicit companies/company_members + // guards, not by an incidental downstream trigger failure. + expect(await authUserExists(userId)).toBe(true) + + // Clean up via the sanctioned teardown: give the company a sandbox + // settings row (a direct DB session may insert is_sandbox = true; only + // flips and PostgREST-authenticated inserts are blocked). + await getPool().query( + `INSERT INTO public.company_settings (user_id, company_id, is_sandbox) + VALUES ($1, $2, true)`, + [userId, companyId], + ) + await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [userId]) + expect(await authUserExists(userId)).toBe(false) + }) + + it('is_sandbox = true cannot be inserted by a regular authenticated user, but can by an anonymous one', async () => { + const { userId, companyId } = await seedCompany() + + const client = await getClient() + try { + await client.query('BEGIN') + await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [ + JSON.stringify({ sub: userId, role: 'authenticated' }), + ]) + await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId]) + await client.query(`SELECT set_config('request.jwt.claim.role', 'authenticated', true)`) + await client.query(`SET LOCAL ROLE authenticated`) + await expect( + client.query( + `INSERT INTO public.company_settings (user_id, company_id, is_sandbox) + VALUES ($1, $2, true)`, + [userId, companyId], + ), + ).rejects.toThrow(/anonymous sandbox users/i) + } finally { + await client.query('ROLLBACK').catch(() => {}) + client.release() + } + + const anonClient = await getClient() + try { + await anonClient.query('BEGIN') + await anonClient.query(`SELECT set_config('request.jwt.claims', $1, true)`, [ + JSON.stringify({ sub: userId, role: 'authenticated', is_anonymous: true }), + ]) + await anonClient.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId]) + await anonClient.query( + `SELECT set_config('request.jwt.claim.role', 'authenticated', true)`, + ) + await anonClient.query(`SET LOCAL ROLE authenticated`) + await anonClient.query( + `INSERT INTO public.company_settings (user_id, company_id, is_sandbox) + VALUES ($1, $2, true)`, + [userId, companyId], + ) + // Rolled back below: this test only proves the guard's allow path. + } finally { + await anonClient.query('ROLLBACK').catch(() => {}) + anonClient.release() + } + }) + + it('is executable by service_role only', async () => { + const { rows } = await getPool().query<{ + svc_user: boolean + svc_expired: boolean + anon_user: boolean + anon_expired: boolean + authed_expired: boolean + }>( + `SELECT + has_function_privilege('service_role', 'public.cleanup_sandbox_user(uuid)', 'EXECUTE') AS svc_user, + has_function_privilege('service_role', 'public.cleanup_expired_sandbox_users(int, int)', 'EXECUTE') AS svc_expired, + has_function_privilege('anon', 'public.cleanup_sandbox_user(uuid)', 'EXECUTE') AS anon_user, + has_function_privilege('anon', 'public.cleanup_expired_sandbox_users(int, int)', 'EXECUTE') AS anon_expired, + has_function_privilege('authenticated', 'public.cleanup_expired_sandbox_users(int, int)', 'EXECUTE') AS authed_expired`, + ) + expect(rows[0]!.svc_user).toBe(true) + expect(rows[0]!.svc_expired).toBe(true) + expect(rows[0]!.anon_user).toBe(false) + expect(rows[0]!.anon_expired).toBe(false) + expect(rows[0]!.authed_expired).toBe(false) + }) +})