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:
co-authored by
Claude Fable 5
parent
858ad49852
commit
526f0315d0
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user