diff --git a/app/api/sandbox/cleanup/cron/__tests__/route.test.ts b/app/api/sandbox/cleanup/cron/__tests__/route.test.ts index 8dbcb715..ac223346 100644 --- a/app/api/sandbox/cleanup/cron/__tests__/route.test.ts +++ b/app/api/sandbox/cleanup/cron/__tests__/route.test.ts @@ -1,9 +1,10 @@ /** - * 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. + * Tests for the sandbox cleanup cron route: the run loops small RPC batches + * (each PostgREST statement gets its own 8s window; a function-level + * statement_timeout cannot lift it), stops when a batch makes no progress, + * aggregates totals across batches, accepts the legacy bare-integer return + * shape, and logs failures at error level: the failure mode this route + * chain fixes was months of silently swallowed cleanup errors. */ import { describe, it, expect, vi, beforeEach } from 'vitest' @@ -33,48 +34,67 @@ function cronRequest(): Request { return new Request('http://localhost:3000/api/sandbox/cleanup/cron') } -describe('GET /api/sandbox/cleanup/cron', () => { - it('reserves enough function time for a full batch', () => { - // 60 users at ~3s each must fit inside the route budget and the RPC's - // 290s statement_timeout (migration 20260807150000). - expect(maxDuration).toBe(300) - }) +function batch(cleaned: number, failed = 0, orphans = 0) { + return { data: { cleaned, failed, orphans_removed: orphans }, error: null } +} +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, - }) + it('reserves enough function time for the batch loop', () => { + expect(maxDuration).toBe(300) + }) + + it('loops full batches and stops on the first partial one, aggregating totals', async () => { + h.rpc + .mockResolvedValueOnce(batch(10)) + .mockResolvedValueOnce(batch(10, 0, 0)) + .mockResolvedValueOnce(batch(3, 1, 2)) + .mockResolvedValueOnce(batch(0)) const res = await GET(cronRequest()) const body = await res.json() expect(h.rpc).toHaveBeenCalledWith('cleanup_expired_sandbox_users', { p_max_age_hours: 24, - p_limit: 60, + p_limit: 10, }) + // Third batch still made progress (cleaned + orphans > 0), so a fourth + // call runs and returns zero progress, ending the loop. + expect(h.rpc).toHaveBeenCalledTimes(4) 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() + expect(body).toEqual({ + success: true, + cleaned: 23, + failed: 1, + orphans_removed: 2, + batches: 4, + }) }) - it('logs at error level when the summary reports failures', async () => { - h.rpc.mockResolvedValue({ - data: { cleaned: 1, failed: 4, orphans_removed: 0 }, - error: null, - }) + it('stops immediately when the backlog is empty', async () => { + h.rpc.mockResolvedValue(batch(0)) const res = await GET(cronRequest()) const body = await res.json() - expect(res.status).toBe(200) + expect(h.rpc).toHaveBeenCalledTimes(1) + expect(body.batches).toBe(1) + expect(h.logInfo).toHaveBeenCalled() + expect(h.logError).not.toHaveBeenCalled() + }) + + it('stops when a batch yields only failures, and logs at error level', async () => { + h.rpc.mockResolvedValueOnce(batch(0, 4, 0)) + + const res = await GET(cronRequest()) + const body = await res.json() + + expect(h.rpc).toHaveBeenCalledTimes(1) expect(body.failed).toBe(4) expect(h.logError).toHaveBeenCalledWith( 'sandbox cleanup completed with failures', @@ -83,20 +103,19 @@ describe('GET /api/sandbox/cleanup/cron', () => { }) it('still accepts the legacy bare-integer return shape', async () => { - h.rpc.mockResolvedValue({ data: 5, error: null }) + h.rpc.mockResolvedValueOnce({ data: 5, error: null }).mockResolvedValueOnce(batch(0)) 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 }) + expect(body.cleaned).toBe(5) }) - it('returns an error envelope when the RPC fails', async () => { - h.rpc.mockResolvedValue({ - data: null, - error: { message: 'boom', code: 'XX000' }, - }) + it('returns an error envelope when the RPC fails mid-loop', async () => { + h.rpc + .mockResolvedValueOnce(batch(10)) + .mockResolvedValueOnce({ data: null, error: { message: 'boom', code: 'XX000' } }) const res = await GET(cronRequest()) const body = await res.json() diff --git a/app/api/sandbox/cleanup/cron/route.ts b/app/api/sandbox/cleanup/cron/route.ts index be809c50..18967b51 100644 --- a/app/api/sandbox/cleanup/cron/route.ts +++ b/app/api/sandbox/cleanup/cron/route.ts @@ -7,16 +7,22 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure * GET /api/sandbox/cleanup/cron: daily 04:00 UTC. * Removes expired sandbox users (>24h old). * - * One teardown costs ~3s on prod (the auth.users delete fans out over ~250 - * FK triggers), so the run is bounded: BATCH_LIMIT users per night, sized to - * finish inside both the RPC's 290s statement_timeout (migration - * 20260807150000) and this route's maxDuration. The nightly intake is a - * fraction of this; a backlog drains over a few nights instead of timing - * out and rolling back wholesale. + * Every PostgREST statement runs under authenticator's statement_timeout of + * 8s, and a function-level SET statement_timeout does NOT lift it (the timer + * arms when the top-level statement starts; verified empirically on prod + * 2026-08-07, same finding as the SIE import RPCs). One teardown costs + * ~220ms with the account_id index, so the run loops SMALL batches: each + * rpc() call is its own statement with its own 8s window, and the loop + * stops when a batch makes no progress (nothing left, or only failing + * users remain) or the route's time budget nears. Capacity per night is + * MAX_BATCHES * BATCH_LIMIT users; the nightly intake is a small fraction + * of that. */ export const maxDuration = 300 -const BATCH_LIMIT = 60 +const BATCH_LIMIT = 10 +const MAX_BATCHES = 25 +const TIME_BUDGET_MS = 240_000 export const GET = withCronContext('cron.sandbox_cleanup', async (_request, ctx) => { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL @@ -31,35 +37,52 @@ export const GET = withCronContext('cron.sandbox_cleanup', async (_request, ctx) const supabase = createClient(supabaseUrl, supabaseServiceKey) - const { data, error } = await supabase.rpc('cleanup_expired_sandbox_users', { - p_max_age_hours: 24, - p_limit: BATCH_LIMIT, - }) + const started = Date.now() + const totals = { cleaned: 0, failed: 0, orphans_removed: 0, batches: 0 } - if (error) { - ctx.log.error('sandbox cleanup rpc failed', error) - return errorResponse(error, ctx.log, { requestId: ctx.requestId }) + for (let i = 0; i < MAX_BATCHES; i++) { + if (Date.now() - started > TIME_BUDGET_MS) break + + const { data, error } = await supabase.rpc('cleanup_expired_sandbox_users', { + p_max_age_hours: 24, + p_limit: BATCH_LIMIT, + }) + + if (error) { + ctx.log.error('sandbox cleanup rpc failed', { error, ...totals }) + return errorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + // 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 batch = + 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), + } + + totals.cleaned += batch.cleaned + totals.failed += batch.failed + totals.orphans_removed += batch.orphans_removed + totals.batches += 1 + + // No progress means only permanently-failing users (retried nightly and + // reported below) or an empty backlog: looping further would spin on the + // same rows. + if (batch.cleaned + batch.orphans_removed === 0) break } - // 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), - } - // 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) + if (totals.failed > 0) { + ctx.log.error('sandbox cleanup completed with failures', totals) } else { - ctx.log.info('sandbox cleanup summary', summary) + ctx.log.info('sandbox cleanup summary', totals) } - return NextResponse.json({ success: true, ...summary }) + return NextResponse.json({ success: true, ...totals }) }) diff --git a/supabase/migrations/20260807160000_sandbox_cleanup_remaining_blockers.sql b/supabase/migrations/20260807160000_sandbox_cleanup_remaining_blockers.sql new file mode 100644 index 00000000..4655ee86 --- /dev/null +++ b/supabase/migrations/20260807160000_sandbox_cleanup_remaining_blockers.sql @@ -0,0 +1,285 @@ +-- Last two NO ACTION FK blockers in sandbox teardown, found by draining the +-- prod backlog: processing_history.company_id and +-- invoice_deliveries.company_id reference companies without a cascade, so a +-- sandbox whose visitor exercised AI processing or invoice sending cannot be +-- deleted (7 of ~510 backlog users). A data-driven sweep of every NO ACTION +-- FK into companies confirms these two plus the already-handled audit_log +-- are the only tables with rows for stale sandboxes. +-- +-- Body otherwise identical to 20260807130000's cleanup_sandbox_user. + +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. + 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. + 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; + + -- Guarded tables that must go while company_settings still exists (their + -- delete-protect triggers re-verify sandbox-ness through it). + 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 + ); + + -- Plain NO ACTION company FKs with no cascade: telemetry and delivery + -- logs the sandbox demo can produce. + 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 + ); + + -- Purge the sandbox company's audit rows while company_settings still + -- exists (audit_log_immutable re-verifies sandbox-ness through it). + 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 runs with them still armed. + 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; + +-- ============================================================================= +-- 2. enforce_invoice_delivery_immutability: allow sandbox-teardown DELETE +-- ============================================================================= + +-- The DELETE branch swallows deletions silently (RETURN NULL plus a +-- SECURITY_EVENT audit row) for anything but stale preparing rows, so the +-- explicit invoice_deliveries delete above was a no-op and the companies FK +-- still blocked teardown for sandboxes that sent a demo invoice. Base +-- definition: 20260803224000; only the DELETE branch gains the bypass. + +CREATE OR REPLACE FUNCTION public.enforce_invoice_delivery_immutability() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + -- Sandbox teardown (cleanup_sandbox_user) removes the whole demo + -- company; without this the DELETE is silently swallowed (RETURN NULL + -- plus a SECURITY_EVENT audit row) and the companies FK blocks the + -- teardown. Same transaction-local flag plus per-row sandbox + -- re-verification as the other guards in 20260807130000. + 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 = 'preparing' + AND OLD.created_at <= now() - interval '15 minutes' + THEN + RETURN OLD; + END IF; + + INSERT INTO public.audit_log ( + user_id, company_id, action, table_name, record_id, actor_id, + old_state, description + ) VALUES ( + OLD.user_id, OLD.company_id, 'SECURITY_EVENT', 'invoice_deliveries', + OLD.id, auth.uid(), public.invoice_delivery_audit_state(OLD), + 'Blocked deletion of immutable invoice delivery history.' + ); + RETURN NULL; + END IF; + + IF OLD.status = 'preparing' THEN + IF NEW.status <> 'pending' + OR NEW.company_id IS DISTINCT FROM OLD.company_id + OR NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id + OR NEW.channel IS DISTINCT FROM OLD.channel + OR NEW.provider IS NOT NULL + OR NEW.provider_message_id IS NOT NULL + OR NEW.provider_status IS NOT NULL + OR NEW.provider_status_at IS NOT NULL + OR NEW.provider_status_detail IS NOT NULL + OR NEW.provider_recipient_statuses <> '{}'::jsonb + OR NEW.error_code IS NOT NULL + OR NEW.sent_at IS NOT NULL + OR NEW.failed_at IS NOT NULL + OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at + OR NEW.pii_redacted_at IS NOT NULL + OR NEW.created_at IS DISTINCT FROM OLD.created_at + THEN + RAISE EXCEPTION 'preparing invoice delivery may only capture its pending payload' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END IF; + + IF OLD.status = 'pending' THEN + IF NEW.status NOT IN ('sent', 'failed') THEN + RAISE EXCEPTION 'pending invoice delivery may only transition to sent or failed' + USING ERRCODE = '23514'; + END IF; + + IF NEW.company_id IS DISTINCT FROM OLD.company_id + OR NEW.user_id IS DISTINCT FROM OLD.user_id + OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id + OR NEW.channel IS DISTINCT FROM OLD.channel + OR NEW.to_addresses IS DISTINCT FROM OLD.to_addresses + OR NEW.cc_addresses IS DISTINCT FROM OLD.cc_addresses + OR NEW.bcc_addresses IS DISTINCT FROM OLD.bcc_addresses + OR NEW.reply_to IS DISTINCT FROM OLD.reply_to + OR NEW.from_name IS DISTINCT FROM OLD.from_name + OR NEW.subject IS DISTINCT FROM OLD.subject + OR NEW.body_text IS DISTINCT FROM OLD.body_text + OR NEW.body_html IS DISTINCT FROM OLD.body_html + OR NEW.attachment_filename IS DISTINCT FROM OLD.attachment_filename + OR NEW.attachment_content_type IS DISTINCT FROM OLD.attachment_content_type + OR NEW.attachment_sha256 IS DISTINCT FROM OLD.attachment_sha256 + OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at + OR NEW.pii_redacted_at IS DISTINCT FROM OLD.pii_redacted_at + OR NEW.created_at IS DISTINCT FROM OLD.created_at + OR NEW.provider_status IS NOT NULL + OR NEW.provider_status_at IS NOT NULL + OR NEW.provider_status_detail IS NOT NULL + OR NEW.provider_recipient_statuses <> '{}'::jsonb + OR ( + NEW.status = 'sent' + AND NEW.document_attachment_id IS DISTINCT FROM OLD.document_attachment_id + ) + OR (NEW.status = 'failed' AND NEW.document_attachment_id IS NOT NULL) + THEN + RAISE EXCEPTION 'invoice delivery payload is immutable' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END IF; + + IF OLD.status = 'sent' + AND OLD.pii_redacted_at IS NULL + AND NEW.provider_status IS NOT NULL + AND (to_jsonb(NEW) + - 'provider_status' + - 'provider_status_at' + - 'provider_status_detail' + - 'provider_recipient_statuses' + - 'updated_at') + IS NOT DISTINCT FROM + (to_jsonb(OLD) + - 'provider_status' + - 'provider_status_at' + - 'provider_status_detail' + - 'provider_recipient_statuses' + - 'updated_at') + THEN + RETURN NEW; + END IF; + + IF OLD.status IN ('sent', 'failed') + AND OLD.pii_redacted_at IS NULL + AND CURRENT_DATE >= OLD.retention_expires_at + AND NEW.pii_redacted_at IS NOT NULL + AND NEW.company_id IS NOT DISTINCT FROM OLD.company_id + AND NEW.user_id IS NOT DISTINCT FROM OLD.user_id + AND NEW.invoice_id IS NOT DISTINCT FROM OLD.invoice_id + AND NEW.channel IS NOT DISTINCT FROM OLD.channel + AND NEW.status IS NOT DISTINCT FROM OLD.status + AND cardinality(NEW.to_addresses) = 0 + AND cardinality(NEW.cc_addresses) = 0 + AND cardinality(NEW.bcc_addresses) = 0 + AND NEW.reply_to IS NULL + AND NEW.from_name IS NULL + AND NEW.subject IS NULL + AND NEW.body_text IS NULL + AND NEW.body_html IS NULL + AND NEW.provider IS NOT DISTINCT FROM OLD.provider + AND NEW.provider_message_id IS NULL + AND NEW.provider_status IS NOT DISTINCT FROM OLD.provider_status + AND NEW.provider_status_at IS NOT DISTINCT FROM OLD.provider_status_at + AND NEW.provider_status_detail IS NULL + AND NEW.provider_recipient_statuses = '{}'::jsonb + AND NEW.error_code IS NOT DISTINCT FROM OLD.error_code + AND NEW.document_attachment_id IS NOT DISTINCT FROM OLD.document_attachment_id + AND NEW.attachment_filename IS NULL + AND NEW.attachment_content_type IS NOT DISTINCT FROM OLD.attachment_content_type + AND NEW.attachment_sha256 IS NULL + AND NEW.sent_at IS NOT DISTINCT FROM OLD.sent_at + AND NEW.failed_at IS NOT DISTINCT FROM OLD.failed_at + AND NEW.retention_expires_at IS NOT DISTINCT FROM OLD.retention_expires_at + AND NEW.created_at IS NOT DISTINCT FROM OLD.created_at + THEN + RETURN NEW; + END IF; + + RAISE EXCEPTION 'terminal invoice delivery (%) is immutable', OLD.status + USING ERRCODE = '23514'; +END; +$$; diff --git a/tests/pg/sandbox-cleanup.pg.test.ts b/tests/pg/sandbox-cleanup.pg.test.ts index 9f2849e9..f0bc8d6a 100644 --- a/tests/pg/sandbox-cleanup.pg.test.ts +++ b/tests/pg/sandbox-cleanup.pg.test.ts @@ -51,6 +51,30 @@ async function seedSandboxUser(settingsCreatedAt?: string): Promise<{ VALUES ($1, $2, 'categorize_transaction', 'Sandbox cleanup test op', 'rejected')`, [userId, companyId], ) + // processing_history references companies with a plain NO ACTION FK and no + // cascade; without the explicit delete (migration 20260807160000) a + // sandbox that produced telemetry cannot be torn down. + await getPool().query( + `INSERT INTO public.processing_history + (company_id, correlation_id, aggregate_type, aggregate_id, event_type, actor, occurred_at) + VALUES ($1, $2, 'Document', $3, 'DocumentIngested', '{"type":"system"}', now())`, + [companyId, randomUUID(), randomUUID()], + ) + // invoice_deliveries has the same NO ACTION company FK AND a delete guard + // that silently swallows deletes (RETURN NULL) outside the teardown + // bypass; a marked_sent manual delivery is the minimal terminal row. + const invoiceId = randomUUID() + await getPool().query( + `INSERT INTO public.invoices (id, user_id, company_id, invoice_date, due_date) + VALUES ($1, $2, $3, '2026-01-10', '2026-02-10')`, + [invoiceId, userId, companyId], + ) + await getPool().query( + `INSERT INTO public.invoice_deliveries + (company_id, user_id, invoice_id, channel, status, sent_at, retention_expires_at) + VALUES ($1, $2, $3, 'manual', 'marked_sent', now(), '2033-12-31')`, + [companyId, userId, invoiceId], + ) return { userId, companyId, entryId } } @@ -106,6 +130,14 @@ describe('sandbox cleanup RPCs (pg)', () => { expect(lines[0]!.n).toBe(0) }) + it('refuses a user with no company_settings rows at all', async () => { + const { userId } = await seedCompany() + 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('refuses a user whose company is not a sandbox', async () => { const { userId, companyId } = await seedCompany() await getPool().query(