fix(sandbox): complete the teardown for the four FK and guard blockers added since August (#2112)
cleanup_sandbox_user deletes supplier_invoices and journal_entries without first clearing four blockers added or missed since it was last fixed: supplier_payment_batch_items (RESTRICT FK, that table was created 2026-08-10), fiscal_periods.opening_balance_entry_id (NO ACTION FK whose NULLing is itself blocked by enforce_opening_balance_immutability), terminal webhook_deliveries rows, and payment_match_log rows whose company_id IS NULL, which fail the shared audit bypass because it requires a non-null company. Prod: 9 stale sandbox users, oldest 2026-07-22, retried nightly and failing forever, so anonymous demo tenants accumulate indefinitely. The bypass stays scoped to sandbox teardown. The audit-log immutability and the webhook terminal-delete guard are not weakened for normal tenants, and the pg test asserts they still bite for a non-sandbox tenant. Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
088754d61a
commit
2c46d69d21
@@ -0,0 +1,377 @@
|
||||
-- Four more sandbox-teardown blockers, all added or missed since
|
||||
-- 20260807170000. Nine sandbox tenants are stuck on prod (oldest settings row
|
||||
-- 2026-07-22), retried by the nightly cron and failing forever, so anonymous
|
||||
-- demo tenants accumulate instead of being torn down:
|
||||
--
|
||||
-- 1. supplier_payment_batch_items references supplier_invoices through the
|
||||
-- composite FK fk_supplier_payment_batch_items_invoice with ON DELETE
|
||||
-- RESTRICT. The table shipped 2026-08-10, after the last cleanup fix, so
|
||||
-- any sandbox visitor who generated a betalfil blocks the
|
||||
-- supplier_invoices delete. The batch header cascades to its items
|
||||
-- (fk_supplier_payment_batch_items_batch), so deleting the batches is
|
||||
-- enough and no guard trigger stands in the way.
|
||||
-- 2. fiscal_periods.opening_balance_entry_id and .closing_entry_id are both
|
||||
-- plain NO ACTION FKs into journal_entries, so the journal delete fails
|
||||
-- while a period still points at an IB or bokslut voucher. NULLing them
|
||||
-- is itself blocked by enforce_opening_balance_immutability, which has
|
||||
-- no sandbox bypass.
|
||||
-- 3. webhook_deliveries rows in terminal status ('delivered', 'dead') are
|
||||
-- blocked by block_webhook_delivery_terminal_delete, which raises
|
||||
-- unconditionally. Those rows reach the trigger through the
|
||||
-- auth.users -> companies -> webhook_deliveries cascade.
|
||||
-- 4. payment_match_log rows whose company_id IS NULL fail the shared
|
||||
-- audit_log_immutable() bypass, because that bypass requires
|
||||
-- OLD.company_id IS NOT NULL. lib/invoices/match-log.ts has written
|
||||
-- company_id-less rows since the table shipped (7103 of them on prod),
|
||||
-- and payment_match_log_user_id_fkey ON DELETE CASCADE drags them into
|
||||
-- the auth.users delete. The table is also reached as an UPDATE:
|
||||
-- payment_match_log_supplier_invoice_id_fkey is ON DELETE SET NULL, so
|
||||
-- the supplier_invoices delete rewrites any row that points at one, and
|
||||
-- the UPDATE branch of the guard stays unconditional. The purge
|
||||
-- therefore runs before the supplier_invoices delete, not just before
|
||||
-- the auth.users one.
|
||||
--
|
||||
-- Three decisions worth writing down, because the obvious reading of each
|
||||
-- goes the other way:
|
||||
--
|
||||
-- * closing_entry_id is cleared too, not just opening_balance_entry_id, and
|
||||
-- the sandbox bypass covers the whole trigger rather than the
|
||||
-- opening-balance clause alone. No stale sandbox has closing_entry_id set
|
||||
-- today, so a narrower fix would also pass, and would then break again
|
||||
-- the first time a sandbox visitor runs a bokslut:
|
||||
-- fiscal_periods_closing_entry_id_fkey is NO ACTION as well. The year-end
|
||||
-- immutability the clause protects guards a ledger that ceases to exist a
|
||||
-- few statements later.
|
||||
-- * the opening-balance RAISE loses its em dash (repo rule: no em or en
|
||||
-- dashes) and gains the colon its sibling clause already uses. Both
|
||||
-- assertions in the tree match the tail of the sentence
|
||||
-- (lib/bookkeeping/__tests__/engine.pg.test.ts and
|
||||
-- tests/pg/closing-entry-detach.pg.test.ts) and no runtime code maps the
|
||||
-- text, so no caller notices.
|
||||
-- * payment_match_log gets its own guard function instead of a relaxed
|
||||
-- audit_log_immutable(). That function also guards audit_log, event_log
|
||||
-- and processing_history; processing_history has no user_id column at
|
||||
-- all, so a bypass branch reading OLD.user_id could not live there.
|
||||
--
|
||||
-- Every bypass follows the 20260807 chain: a transaction-local flag that only
|
||||
-- cleanup_sandbox_user sets, after its all-rows is_sandbox check, re-verified
|
||||
-- per row against company_settings so the flag alone can never unlock a real
|
||||
-- tenant. UPDATE stays forbidden on both audit-shaped logs.
|
||||
--
|
||||
-- Known residual risk, deliberately not fixed here: cleanup_sandbox_user
|
||||
-- enumerates blockers one table at a time, and prod still has unhandled
|
||||
-- RESTRICT / NO ACTION FKs into the tables it deletes (accrual_schedules,
|
||||
-- accrual_schedule_installments, assets.disposal_journal_entry_id,
|
||||
-- depreciation_schedules, peppol_deliveries, rot_rut_payout_request_items,
|
||||
-- agi_declarations, stripe_payment_events, stripe_payouts,
|
||||
-- vacation_year_closures, webshop_orders), and it never deletes
|
||||
-- public.invoices at all. A read-only sweep of every one of those tables
|
||||
-- against the nine stuck sandboxes returns zero rows today, so this does
|
||||
-- clear the whole current backlog; a sandbox that exercises one of those
|
||||
-- surfaces will need the next entry in this chain.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. enforce_opening_balance_immutability: allow sandbox-teardown link clearing
|
||||
-- =============================================================================
|
||||
|
||||
-- Body otherwise identical to 20260720140000, minus the em dash.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_opening_balance_immutability()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Sandbox teardown clears the period's voucher links so the journal delete
|
||||
-- can proceed; the period itself dies with the tenant moments later. Flag
|
||||
-- first so a normal tenant never pays for the company_settings lookup, and
|
||||
-- per-row re-verification so the flag alone unlocks nothing.
|
||||
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 NEW;
|
||||
END IF;
|
||||
|
||||
-- Only check if opening_balance_entry_id is being changed
|
||||
IF OLD.opening_balance_entry_id IS NOT NULL
|
||||
AND OLD.opening_balances_set = true
|
||||
AND NEW.opening_balance_entry_id IS DISTINCT FROM OLD.opening_balance_entry_id THEN
|
||||
RAISE EXCEPTION 'Cannot modify opening_balance_entry_id on period "%": opening balances are immutable once set',
|
||||
OLD.name;
|
||||
END IF;
|
||||
|
||||
-- Block changing closing_entry_id once set, UNLESS the referenced closing
|
||||
-- entry has been reversed by a real storno (administrative year-end undo).
|
||||
IF OLD.closing_entry_id IS NOT NULL
|
||||
AND NEW.closing_entry_id IS DISTINCT FROM OLD.closing_entry_id THEN
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM journal_entries je
|
||||
JOIN journal_entries storno
|
||||
ON storno.reverses_id = je.id
|
||||
AND storno.source_type = 'storno'
|
||||
AND storno.status = 'posted'
|
||||
AND storno.company_id = OLD.company_id
|
||||
WHERE je.id = OLD.closing_entry_id
|
||||
AND je.company_id = OLD.company_id
|
||||
AND je.status = 'reversed'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Cannot modify closing_entry_id on period "%": year-end closing is immutable',
|
||||
OLD.name;
|
||||
END IF;
|
||||
|
||||
IF NEW.closing_entry_id IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM journal_entries ne
|
||||
WHERE ne.id = NEW.closing_entry_id
|
||||
AND ne.company_id = NEW.company_id
|
||||
AND ne.fiscal_period_id = NEW.id
|
||||
AND ne.source_type = 'year_end'
|
||||
AND ne.status = 'posted'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'closing_entry_id on period "%" must reference a posted year_end entry in the same period',
|
||||
OLD.name;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. block_webhook_delivery_terminal_delete: allow sandbox-teardown DELETE
|
||||
-- =============================================================================
|
||||
|
||||
-- Body otherwise identical to 20260515190000. webhook_deliveries carries its
|
||||
-- own company_id (NOT NULL, asserted against the parent webhook at INSERT),
|
||||
-- so the per-row sandbox re-check needs no join through webhooks, which is
|
||||
-- what makes it safe here: webhook_id is nullable and ON DELETE SET NULL.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.block_webhook_delivery_terminal_delete()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
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 ('delivered', 'dead') THEN
|
||||
RAISE EXCEPTION
|
||||
'webhook_deliveries row in terminal status (%) cannot be deleted (audit-log integrity policy; accounting-event rows additionally fall under BFL 7 kap 1 § retention)',
|
||||
OLD.status
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. payment_match_log gets its own immutability guard
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.payment_match_log_immutable()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Sandbox teardown removes the whole demo tenant; its match log goes with
|
||||
-- it. Two accepted row shapes: a company_id that provably belongs to a
|
||||
-- sandbox company, or a NULL company_id whose user is provably a sandbox
|
||||
-- user. The second branch is the whole point of splitting this off
|
||||
-- audit_log_immutable(), and it only works while the company_settings row
|
||||
-- still exists, which is why cleanup_sandbox_user deletes these rows
|
||||
-- explicitly rather than letting the auth.users cascade reach them.
|
||||
-- UPDATE stays forbidden even during teardown.
|
||||
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
|
||||
))
|
||||
OR
|
||||
(OLD.company_id IS NULL AND EXISTS (
|
||||
SELECT 1 FROM public.company_settings cs
|
||||
WHERE cs.user_id = OLD.user_id AND cs.is_sandbox = true
|
||||
))
|
||||
) THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
-- Same wording as audit_log_immutable(): app/api/transactions/[id]/route.ts
|
||||
-- matches this text to turn the cascade refusal into a Swedish 409.
|
||||
RAISE EXCEPTION 'Audit log entries cannot be modified or deleted';
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS payment_match_log_no_update ON public.payment_match_log;
|
||||
CREATE TRIGGER payment_match_log_no_update
|
||||
BEFORE UPDATE ON public.payment_match_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.payment_match_log_immutable();
|
||||
|
||||
DROP TRIGGER IF EXISTS payment_match_log_no_delete ON public.payment_match_log;
|
||||
CREATE TRIGGER payment_match_log_no_delete
|
||||
BEFORE DELETE ON public.payment_match_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.payment_match_log_immutable();
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. cleanup_sandbox_user: clear the four blockers before the deletes
|
||||
-- =============================================================================
|
||||
|
||||
-- Body otherwise identical to 20260807170000.
|
||||
|
||||
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.
|
||||
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;
|
||||
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
PERFORM set_config('gnubok.sandbox_cleanup', 'true', true);
|
||||
|
||||
-- API keys must die with the sandbox, and api_keys.sod_acknowledged_by
|
||||
-- (NO ACTION to auth.users) otherwise blocks the auth delete.
|
||||
DELETE FROM public.api_keys WHERE user_id = p_user_id;
|
||||
|
||||
-- WORM retag log: delete under the bypass while company_settings still
|
||||
-- exists, and before the journal deletes whose cascade would otherwise
|
||||
-- reach it.
|
||||
DELETE FROM public.dimension_retag_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
|
||||
);
|
||||
|
||||
-- Match log: append-only, user-scoped on purpose, and purged this early for
|
||||
-- two reasons. lib/invoices/match-log.ts writes rows with no company_id, and
|
||||
-- the guard can only recognise those as sandbox rows while the
|
||||
-- company_settings row still exists, which the auth.users cascade cannot
|
||||
-- promise (company_settings.user_id cascades from the same delete, and
|
||||
-- sibling cascade order is undefined). And payment_match_log.supplier_invoice_id
|
||||
-- is ON DELETE SET NULL, so the supplier_invoices delete further down turns
|
||||
-- into an UPDATE on any row that references one, which the guard still
|
||||
-- refuses even during teardown.
|
||||
DELETE FROM public.payment_match_log
|
||||
WHERE user_id = p_user_id
|
||||
OR company_id IN (
|
||||
SELECT cs.company_id FROM public.company_settings cs
|
||||
WHERE cs.user_id = p_user_id AND cs.is_sandbox = true
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
-- fiscal_periods points at its IB and bokslut vouchers with plain NO ACTION
|
||||
-- FKs, and previous_period_id chains periods to each other the same way.
|
||||
-- Clearing all three under the teardown bypass is what lets the journal
|
||||
-- delete below run at all.
|
||||
UPDATE public.fiscal_periods
|
||||
SET opening_balance_entry_id = NULL,
|
||||
closing_entry_id = NULL,
|
||||
previous_period_id = NULL
|
||||
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.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;
|
||||
|
||||
-- Betalfil batches: their items reference supplier_invoices with
|
||||
-- ON DELETE RESTRICT, so the batch headers must go first (the items
|
||||
-- cascade off the header, and neither table has a delete guard).
|
||||
DELETE FROM public.supplier_payment_batches
|
||||
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.supplier_invoices WHERE user_id = p_user_id;
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
-- Terminal webhook deliveries: guarded against DELETE, and reached by the
|
||||
-- auth.users -> companies cascade unless purged here under the bypass.
|
||||
DELETE FROM public.webhook_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
|
||||
);
|
||||
|
||||
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 WHERE id = p_user_id;
|
||||
GET DIAGNOSTICS v_deleted = ROW_COUNT;
|
||||
|
||||
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;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,7 +1,8 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { PoolClient } from 'pg'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getClient, getPool } from './setup'
|
||||
import { insertPostedJournalEntry, seedCompany } from './fixtures'
|
||||
import { insertPostedJournalEntry, insertTransaction, seedCompany } from './fixtures'
|
||||
|
||||
/**
|
||||
* Sandbox cleanup RPCs (migration 20260807130000):
|
||||
@@ -14,12 +15,26 @@ import { insertPostedJournalEntry, seedCompany } from './fixtures'
|
||||
* 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.
|
||||
*
|
||||
* Extended by 20260901120000 with the four blockers that stalled nine
|
||||
* sandboxes on prod (oldest 2026-07-22): a betalfil batch holding a supplier
|
||||
* invoice through ON DELETE RESTRICT, a fiscal period pointing at its IB and
|
||||
* bokslut vouchers through NO ACTION FKs, a terminal webhook delivery, and a
|
||||
* payment_match_log row with company_id NULL. The seed now carries all four,
|
||||
* and each relaxed guard gets a paired test proving it still refuses a
|
||||
* NON-sandbox tenant even when the teardown flag is set: that, not the happy
|
||||
* path, is the regression that matters.
|
||||
*/
|
||||
|
||||
async function seedSandboxUser(settingsCreatedAt?: string): Promise<{
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
entryId: string
|
||||
batchId: string
|
||||
supplierInvoiceId: string
|
||||
deliveryId: string
|
||||
matchLogId: string
|
||||
}> {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
await getPool().query(
|
||||
@@ -96,7 +111,115 @@ async function seedSandboxUser(settingsCreatedAt?: string): Promise<{
|
||||
VALUES ($1, $2, $3, '{}', '{"1":"BUTIK"}', 'Sandbox cleanup test retag')`,
|
||||
[companyId, entryId, lineRows[0]!.id],
|
||||
)
|
||||
return { userId, companyId, entryId }
|
||||
// A betalfil batch: supplier_payment_batch_items references the supplier
|
||||
// invoice with a composite ON DELETE RESTRICT FK, so the batch header must
|
||||
// be purged before the supplier_invoices delete (20260901120000).
|
||||
const supplierId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.suppliers (id, user_id, company_id, name, bankgiro)
|
||||
VALUES ($1, $2, $3, 'Derome Bygg AB', '5050-1055')`,
|
||||
[supplierId, userId, companyId],
|
||||
)
|
||||
const supplierInvoiceId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_invoices
|
||||
(id, user_id, company_id, supplier_id, arrival_number,
|
||||
supplier_invoice_number, invoice_date, due_date,
|
||||
subtotal, vat_amount, total, remaining_amount, status)
|
||||
VALUES ($1, $2, $3, $4, floor(random() * 1000000)::int,
|
||||
$5, '2026-06-23', '2026-07-07',
|
||||
590, 147.5, 737.5, 737.5, 'approved')`,
|
||||
[supplierInvoiceId, userId, companyId, supplierId, `CD-${supplierInvoiceId.slice(0, 8)}`],
|
||||
)
|
||||
const batchId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_payment_batches
|
||||
(id, company_id, user_id, format, total_amount, item_count, msg_id, debtor_snapshot)
|
||||
VALUES ($1, $2, $3, 'pain001', 737.5, 1, $4,
|
||||
'{"name":"Sandbox AB","org_number":"556677-8899","iban":"SE3550000000054910000003","bic":"ESSESESS"}')`,
|
||||
[batchId, companyId, userId, `ACCOUNTED-5566778899-B${batchId.slice(0, 8)}`],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_payment_batch_items
|
||||
(batch_id, company_id, supplier_invoice_id, amount, payment_date,
|
||||
payee_type, payee_bankgiro, payee_name, reference_type, reference)
|
||||
VALUES ($1, $2, $3, 737.5, '2026-08-15',
|
||||
'bankgiro', '50501055', 'Derome Bygg AB', 'invoice_number', 'CD3014794407')`,
|
||||
[batchId, companyId, supplierInvoiceId],
|
||||
)
|
||||
// A period that has both an IB link (write-once once opening_balances_set)
|
||||
// and a bokslut link: fiscal_periods_opening_balance_entry_id_fkey and
|
||||
// fiscal_periods_closing_entry_id_fkey are both NO ACTION, so the journal
|
||||
// delete fails until the teardown clears them, and clearing them is itself
|
||||
// blocked by enforce_opening_balance_immutability outside the bypass.
|
||||
const closingEntryId = await insertPostedJournalEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
voucherNumber: 1,
|
||||
sourceType: 'year_end',
|
||||
description: 'Sandbox bokslut',
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods
|
||||
SET opening_balance_entry_id = $2, opening_balances_set = true, closing_entry_id = $3
|
||||
WHERE id = $1`,
|
||||
[fiscalPeriodId, entryId, closingEntryId],
|
||||
)
|
||||
// A delivered webhook delivery: block_webhook_delivery_terminal_delete
|
||||
// raises on the auth.users -> companies cascade outside the bypass.
|
||||
const webhookId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.webhooks (id, company_id, event_type, webhook_url, secret)
|
||||
VALUES ($1, $2, 'journal_entry.committed', 'https://example.invalid/hook', $3)`,
|
||||
[webhookId, companyId, randomUUID()],
|
||||
)
|
||||
const deliveryId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.webhook_deliveries
|
||||
(id, webhook_id, company_id, event_type, payload, api_version, status, delivered_at)
|
||||
VALUES ($1, $2, $3, 'journal_entry.committed', '{"id":"demo"}', '2026-05-12',
|
||||
'delivered', now())`,
|
||||
[deliveryId, webhookId, companyId],
|
||||
)
|
||||
// A match-log row WITHOUT company_id: the shape lib/invoices/match-log.ts
|
||||
// has always written, and the one the shared audit_log_immutable() bypass
|
||||
// could not recognise as a sandbox row. It points at the supplier invoice
|
||||
// above on purpose: payment_match_log_supplier_invoice_id_fkey is ON DELETE
|
||||
// SET NULL, so a teardown that purges the match log after the supplier
|
||||
// invoices turns this row into an UPDATE, which the guard refuses even
|
||||
// during teardown. The order of the two deletes is what this row pins.
|
||||
const transactionId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.transactions
|
||||
(id, company_id, user_id, currency, amount, date, description, category)
|
||||
VALUES ($1, $2, $3, 'SEK', -737.5, '2026-06-24', 'Sandbox betalning', 'uncategorized')`,
|
||||
[transactionId, companyId, userId],
|
||||
)
|
||||
const { rows: matchRows } = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.payment_match_log
|
||||
(user_id, transaction_id, supplier_invoice_id, action)
|
||||
VALUES ($1, $2, $3, 'matched') RETURNING id`,
|
||||
[userId, transactionId, supplierInvoiceId],
|
||||
)
|
||||
return {
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
entryId,
|
||||
batchId,
|
||||
supplierInvoiceId,
|
||||
deliveryId,
|
||||
matchLogId: matchRows[0]!.id,
|
||||
}
|
||||
}
|
||||
|
||||
async function countById(table: string, id: string): Promise<number> {
|
||||
const { rows } = await getPool().query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM ${table} WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
return rows[0]!.n
|
||||
}
|
||||
|
||||
async function insertAnonymousAuthUser(createdAt: string): Promise<string> {
|
||||
@@ -151,6 +274,31 @@ describe('sandbox cleanup RPCs (pg)', () => {
|
||||
expect(lines[0]!.n).toBe(0)
|
||||
})
|
||||
|
||||
it('clears the batch, IB/bokslut, webhook and match-log blockers and leaves no tenant rows', async () => {
|
||||
const seed = await seedSandboxUser()
|
||||
|
||||
await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [seed.userId])
|
||||
|
||||
expect(await authUserExists(seed.userId)).toBe(false)
|
||||
// Each of these is one of the four blockers: before 20260901120000 the
|
||||
// RPC raised on the first of them and the whole teardown rolled back.
|
||||
expect(await countById('public.supplier_payment_batches', seed.batchId)).toBe(0)
|
||||
expect(await countById('public.supplier_invoices', seed.supplierInvoiceId)).toBe(0)
|
||||
expect(await countById('public.fiscal_periods', seed.fiscalPeriodId)).toBe(0)
|
||||
expect(await countById('public.webhook_deliveries', seed.deliveryId)).toBe(0)
|
||||
expect(await countById('public.payment_match_log', seed.matchLogId)).toBe(0)
|
||||
const { rows: items } = await getPool().query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM public.supplier_payment_batch_items WHERE batch_id = $1`,
|
||||
[seed.batchId],
|
||||
)
|
||||
expect(items[0]!.n).toBe(0)
|
||||
const { rows: leftovers } = await getPool().query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM public.company_settings WHERE company_id = $1`,
|
||||
[seed.companyId],
|
||||
)
|
||||
expect(leftovers[0]!.n).toBe(0)
|
||||
})
|
||||
|
||||
it('refuses a user with no company_settings rows at all', async () => {
|
||||
const { userId } = await seedCompany()
|
||||
await expect(
|
||||
@@ -442,3 +590,174 @@ describe('sandbox cleanup RPCs (pg)', () => {
|
||||
expect(rows[0]!.authed_expired).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The regression half of 20260901120000: three guards were relaxed for
|
||||
* sandbox teardown, and each one still has to refuse a real tenant. Every
|
||||
* case runs twice, once plain and once with gnubok.sandbox_cleanup set by
|
||||
* hand, because a flag-only bypass would pass the first and fail the second.
|
||||
*/
|
||||
describe('sandbox teardown bypasses never reach a real tenant (pg)', () => {
|
||||
async function withTeardownFlag<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await getClient()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`SELECT set_config('gnubok.sandbox_cleanup', 'true', true)`)
|
||||
return await fn(client)
|
||||
} finally {
|
||||
await client.query('ROLLBACK').catch(() => {})
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function seedRealCompany(): Promise<{
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
}> {
|
||||
const ctx = await seedCompany()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id, is_sandbox)
|
||||
VALUES ($1, $2, false)`,
|
||||
[ctx.userId, ctx.companyId],
|
||||
)
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('enforce_opening_balance_immutability still refuses to unlink a real IB voucher', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedRealCompany()
|
||||
const entryId = await insertPostedJournalEntry({ userId, companyId, fiscalPeriodId })
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods
|
||||
SET opening_balance_entry_id = $2, opening_balances_set = true
|
||||
WHERE id = $1`,
|
||||
[fiscalPeriodId, entryId],
|
||||
)
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.fiscal_periods SET opening_balance_entry_id = NULL WHERE id = $1`,
|
||||
[fiscalPeriodId],
|
||||
),
|
||||
).rejects.toThrow(/opening balances are immutable once set/i)
|
||||
|
||||
await withTeardownFlag(async (client) => {
|
||||
await expect(
|
||||
client.query(
|
||||
`UPDATE public.fiscal_periods SET opening_balance_entry_id = NULL WHERE id = $1`,
|
||||
[fiscalPeriodId],
|
||||
),
|
||||
).rejects.toThrow(/opening balances are immutable once set/i)
|
||||
})
|
||||
})
|
||||
|
||||
it('enforce_opening_balance_immutability still refuses to detach a real bokslut voucher', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedRealCompany()
|
||||
const closingEntryId = await insertPostedJournalEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
sourceType: 'year_end',
|
||||
description: 'Bokslut 2026',
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods SET closing_entry_id = $2 WHERE id = $1`,
|
||||
[fiscalPeriodId, closingEntryId],
|
||||
)
|
||||
|
||||
await withTeardownFlag(async (client) => {
|
||||
await expect(
|
||||
client.query(
|
||||
`UPDATE public.fiscal_periods SET closing_entry_id = NULL WHERE id = $1`,
|
||||
[fiscalPeriodId],
|
||||
),
|
||||
).rejects.toThrow(/year-end closing is immutable/i)
|
||||
})
|
||||
})
|
||||
|
||||
it('block_webhook_delivery_terminal_delete still refuses a real tenant terminal delivery', async () => {
|
||||
const { companyId } = await seedRealCompany()
|
||||
const deliveryId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.webhook_deliveries
|
||||
(id, company_id, event_type, payload, api_version, status, delivered_at)
|
||||
VALUES ($1, $2, 'journal_entry.committed', '{"id":"real"}', '2026-05-12',
|
||||
'delivered', now())`,
|
||||
[deliveryId, companyId],
|
||||
)
|
||||
|
||||
await expect(
|
||||
getPool().query(`DELETE FROM public.webhook_deliveries WHERE id = $1`, [deliveryId]),
|
||||
).rejects.toThrow(/terminal status/i)
|
||||
|
||||
await withTeardownFlag(async (client) => {
|
||||
await expect(
|
||||
client.query(`DELETE FROM public.webhook_deliveries WHERE id = $1`, [deliveryId]),
|
||||
).rejects.toThrow(/terminal status/i)
|
||||
})
|
||||
|
||||
// Non-terminal rows stay deletable, flag or no flag: the guard's
|
||||
// predicate is unchanged for everyone outside a sandbox.
|
||||
const pendingId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.webhook_deliveries
|
||||
(id, company_id, event_type, payload, api_version, status)
|
||||
VALUES ($1, $2, 'journal_entry.committed', '{"id":"real"}', '2026-05-12', 'pending')`,
|
||||
[pendingId, companyId],
|
||||
)
|
||||
await getPool().query(`DELETE FROM public.webhook_deliveries WHERE id = $1`, [pendingId])
|
||||
})
|
||||
|
||||
it('payment_match_log stays append-only for a real tenant, company_id set or NULL', async () => {
|
||||
const { userId, companyId } = await seedRealCompany()
|
||||
const transactionId = await insertTransaction({ companyId, userId })
|
||||
const { rows } = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.payment_match_log (user_id, company_id, transaction_id, action)
|
||||
VALUES ($1, $2, $3, 'matched') RETURNING id`,
|
||||
[userId, companyId, transactionId],
|
||||
)
|
||||
const tenantedId = rows[0]!.id
|
||||
const { rows: untenanted } = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.payment_match_log (user_id, transaction_id, action)
|
||||
VALUES ($1, $2, 'unmatched') RETURNING id`,
|
||||
[userId, transactionId],
|
||||
)
|
||||
const untenantedId = untenanted[0]!.id
|
||||
|
||||
for (const id of [tenantedId, untenantedId]) {
|
||||
await expect(
|
||||
getPool().query(`DELETE FROM public.payment_match_log WHERE id = $1`, [id]),
|
||||
).rejects.toThrow(/cannot be modified or deleted/i)
|
||||
// The company_id IS NULL branch is the one 20260901120000 added; it
|
||||
// must resolve through company_settings.user_id, not through the flag.
|
||||
await withTeardownFlag(async (client) => {
|
||||
await expect(
|
||||
client.query(`DELETE FROM public.payment_match_log WHERE id = $1`, [id]),
|
||||
).rejects.toThrow(/cannot be modified or deleted/i)
|
||||
})
|
||||
}
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.payment_match_log SET action = 'unmatched' WHERE id = $1`,
|
||||
[tenantedId],
|
||||
),
|
||||
).rejects.toThrow(/cannot be modified or deleted/i)
|
||||
})
|
||||
|
||||
it('payment_match_log UPDATE stays forbidden even inside a sandbox teardown', async () => {
|
||||
const seed = await seedSandboxUser()
|
||||
|
||||
await withTeardownFlag(async (client) => {
|
||||
await expect(
|
||||
client.query(
|
||||
`UPDATE public.payment_match_log SET action = 'unmatched' WHERE id = $1`,
|
||||
[seed.matchLogId],
|
||||
),
|
||||
).rejects.toThrow(/cannot be modified or deleted/i)
|
||||
})
|
||||
|
||||
await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [seed.userId])
|
||||
expect(await authUserExists(seed.userId)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user