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
co-authored by Claude Fable 5
parent 858ad49852
commit 526f0315d0
4 changed files with 422 additions and 63 deletions
+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(