fix(sandbox): loop small cleanup batches (8s cap is real), clear last FK blockers (#1452)

* fix(sandbox): loop small cleanup batches (8s cap is real), clear last FK blockers

Draining the prod backlog exposed two final issues:

- The function-level statement_timeout shipped in 20260807150000 does NOT
  lift authenticator's 8s cap: the timer arms when the top-level statement
  starts (verified empirically on prod: SET LOCAL 2s canceled the RPC
  despite its 290s proconfig; matches the 2026-08-04 SIE-import finding).
  The route now loops batches of 10 (~220ms/user with the account_id
  index, so ~2.2s per batch), each rpc() call being its own statement with
  its own 8s window. The loop stops when a batch makes no progress or the
  240s time budget nears; capacity is 250 users/night.
- processing_history.company_id and invoice_deliveries.company_id are
  plain NO ACTION FKs, so sandboxes whose visitor produced AI telemetry or
  sent a demo invoice could never 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 such tables with
  sandbox rows. cleanup_sandbox_user (migration 20260807160000) deletes
  them explicitly; the pg fixture now seeds a processing_history row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): use valid processing_history aggregate_type/event_type in sandbox fixture

aggregate_type is CHECK-constrained and event_type is an FK to the seeded processing_event_types lookup; the guessed values failed all five fixture-dependent pg tests in CI. Validated against staging: Document/DocumentIngested inserts and tears down cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sandbox): bypass invoice-delivery delete guard in teardown, cover both blocker tables in pg fixture

CodeRabbit's fixture ask exposed a real gap: enforce_invoice_delivery_immutability silently swallows DELETEs (RETURN NULL plus a SECURITY_EVENT audit row) for terminal rows, so the explicit invoice_deliveries delete was a no-op and the companies FK still blocked teardown for sandboxes that sent a demo invoice. The trigger's DELETE branch now honors the gnubok.sandbox_cleanup flag with the same per-row sandbox re-verification as every other guard; base definition 20260803224000, all other branches untouched. The pg fixture seeds an invoice plus a marked_sent manual delivery, and a new test pins the zero-settings refusal path the Swedish review asked about. Validated on staging end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-07 15:18:59 +02:00
committed by GitHub
parent 858ad49852
commit 526f0315d0
4 changed files with 422 additions and 63 deletions
@@ -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()
+53 -30
View File
@@ -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 })
})
@@ -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;
$$;
+32
View File
@@ -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(