fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)

* fix(security): gate replace_sie_import behind owner/admin membership

The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no
company_members lookup, no auth.uid() reference and no unauthorized raise,
while setting gnubok.allow_delete to disarm the BFL immutability and
retention triggers. Any caller holding a company_id and an import id could
hard delete another tenant's verifikationer. Confirmed live in production.

Applies the same fail closed owner/admin guard that undo_sie_import already
carries (migration 20260624120000), resolving the actor from
COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then
revokes EXECUTE from PUBLIC and anon. search_path and the raised
statement_timeout are restated, since CREATE OR REPLACE drops settings that
are not repeated.

userId is a required parameter on replaceSIEImport: the service client has a
NULL auth.uid(), so a caller without an explicit actor now fails to compile
rather than hitting the closed gate at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): validate arcim OAuth callback state server side

The callback route is skipAuth and decoded the state parameter as plain
base64url JSON, trusting consentId and provider from it. A one time code was
minted at flow start and never read. An unauthenticated attacker who learned
a consent id could run an OAuth flow on their own provider account and post
the callback with a forged state, landing their tokens on another tenant's
consent, so the victim's next migration imported the attacker's ledger.

State is now an opaque randomBytes(32) pointer to a provider_otc row,
consumed by a single atomic UPDATE guarded on used_at IS NULL and
expires_at, so a replay loses the row lock race and updates nothing.
provider is read from provider_consents rather than trusted from the client.
provider_otc already existed for exactly this purpose and was never wired up.

Also scopes getConsent to an owning company, closing a cross tenant status
oracle where the preview and migrate paths echoed a consent's status before
the scoped check ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): scope documents storage to company_id (phase A)

The documents bucket policies matched on auth.uid(), and upload keys were
documents/{userId}/..., so company membership was never consulted. Removing a
member revoked nothing: their session still authenticated and they kept
direct Storage read access to every receipt, supplier invoice and bank
statement they had uploaded. The same bug was fixed for sie-files in
20260416120000; this bucket was left behind.

Phase A is additive. Company scoped policies are added alongside the
uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and
reads accept either layout so nothing breaks mid migration. Phase C, which
drops the old policies, is gated on the backfill reporting zero remaining
legacy prefix objects.

The policy compares the company segment as text rather than casting to uuid
the way sie-files does: this bucket holds keys whose second segment is not a
uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix
qual runs before the cast, so a planner reordering would raise 22P02 and fail
the whole query instead of filtering the row out.

deleteDocument now removes both candidate keys. Removing only the stored
pointer would leave a readable orphan copy of a document the user asked to
erase.

The backfill script is included but has never been run. It defaults to dry
run, refuses .env.local by name, and verifies each copy is readable and
SHA-256 identical before repointing the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): enforce events:read scope and membership on /api/events

This was the only one of the three validateApiKey call sites with no
downstream guard: v1 and the MCP server both check scope and re-verify
company membership, this route did neither. An events:read scope existed and
was documented as gating the endpoint but was never called, so a legacy key
falling back to DEFAULT_SCOPES read the full log. The bound company id went
straight from the api_keys row into a service role query, so a key whose user
had been removed from the company kept reading.

Adds the scope check before any database access, re-verifies company_members
with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead
of ignoring it, applies minimisePayload so the pull surface can never return
a wider payload than the push surface, and replaces the three flat error
strings with the canonical envelope.

Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is
gated on mutations in with-api-v1, so a read gets the same treatment as every
other v1 read endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(bookkeeping): sweep remaining journal_entries!inner embeds

A previous refactor removed this pattern from lib/reports and introduced
fetchEntryLines, but the class was never swept. Seventeen sites remained and
had become the top application consumer of production database time:
measured across the resulting query shapes, 32,694 calls and 25,848 seconds
of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at
7,962ms against the 8s statement_timeout, which surfaced to users as 500s on
the booking path.

PostgREST compiles an embed with filters on the embedded side into a
correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops
Postgres reordering the join, so each query walked the whole
journal_entry_lines table across all tenants. Driving from the entries side
instead turns that into two indexed round trips.

Converted sites keep their existing shape: the helper reattaches the parent
entry under the same key the embed produced. Several conversions also remove
a latent silent truncation where an unpaginated query was capped at
PostgREST's 1000 row ceiling.

Two deliberate exceptions. The free text ilike legs of the MCP display query
stay on the embed, because each is capped at legLimit and that cap drives the
truncation contract the tool reports, while the helper is unbounded. The
accounts route moves to the existing get_account_usage_counts RPC instead,
since its embed was a head count and the helper returns rows.

commitEntry's write path is untouched: the change there is confined to the
read query of the pre-commit dimension rule check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): anchor v1 list cursors on created_at

Page two returned page one, forever, while still advertising a fresh
next_cursor. The three routes sorted by and encoded a Postgres date column,
which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor
timestamp as full ISO-8601 and returned null, so the keyset filter was never
applied and has_more never went false. An integrator syncing verifikat looped
on the newest rows indefinitely.

The transactions route already solved this and its comment names the trap;
the fix was never ported. All three now order and encode on created_at with
an id tie break, matching the transactions keyset predicate exactly.
ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change
sort semantics on the route that currently works.

Default ordering therefore moves from business date to insert order. Every
business date is still on the row, and the invoices list gains date_from and
date_to filters so a date range is still reachable; the other two already had
them.

The tests use an in-memory PostgREST that actually evaluates the filters,
because the repo's pass-through mock cannot catch this class of bug: the bug
is that the filter is never sent. They walk to exhaustion with a hard
iteration cap, so an unterminated walk fails instead of hanging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): separate dry run from commit in the idempotency hash

The request hash was built from url.pathname, which excludes the query
string, so a dry run and its commit hashed identically. Following the flow
documented in dry-run.ts, re-issuing the request with the same
Idempotency-Key returned the cached preview with Idempotent-Replayed set and
wrote nothing, while reporting 200. An agent or integrator saw success for a
write that never happened.

dry_run is folded into the hash only when true, not as an unconditional
boolean. Including it as false would change the hash of every ordinary write,
and with a 24h idempotency TTL any key in flight across the deploy would fail
the request_hash comparison and 409 on a legitimate retry. Both hash call
sites now go through one shared helper so they cannot drift into a permanent
cache miss, and dry run responses are no longer stored at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: install the Bedrock SDK out of tree in the compliance review

The Swedish accounting compliance gate had failed ten consecutive runs and so
was posting nothing. With --no-package-lock npm discarded the lockfile and
re-resolved the whole tree from package.json, floating @hookform/resolvers to
5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0.

Installing into the parent of the checkout resolves only that one package, so
an unrelated peer conflict can never take the gate down again. Node still
finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH
would not have worked, as it is CommonJS only. --legacy-peer-deps was
rejected because it masks future genuine peer conflicts and still reifies the
full tree.

The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that
package.json and check:guards enforce after the streaming outage. That drift
went unnoticed because the pin guard only inspects package.json and the
lockfile, never workflow files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build(docker): generate crontabs from vercel.json

vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were
byte identical to each other. Self hosted deployments therefore never sent
recurring invoices, never dispatched webhooks and never cleaned up
idempotency keys. tax-deadlines also ran once a year on 2 January instead of
daily, and documents/verify weekly instead of daily.

Extension crons are included rather than excluded. The Dockerfile copies the
whole tree before building, so every extension cron route is compiled into
the image regardless of the enabled preset, and each returns 200 when its
extension is unconfigured, so curl -sf logs no failure. Two such entries were
already present in the crontab for extensions absent from the preset, which
settles the intent.

documents/verify is treated as drift rather than a self hosted concession:
the weekly cadence was present in the hosted crontab too, and the run is
capped at 200 documents walking a nulls-first queue, so weekly drains the
integrity queue seven times slower on a check that exists for BFL retention.

webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day
on self hosted. A gentler tick would silently stretch the first retry, since
the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line
place to change that.

A parity test asserts the path sets match minus a documented exclusion list,
and ratchets three cron routes that are currently scheduled nowhere so they
are named rather than silently rotting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(observability): add a provider agnostic error sink

There is no error tracking in this codebase: logs go to console and Vercel
retention and nowhere else, nothing alerts on the 16 cron jobs, and seven
code comments across lib, app, components and extensions asserted that Sentry
captures errors when Sentry is not a dependency. The two most recent bug
fixes on this repo were both discovered by customer email.

This adds the sink, not a vendor. No dependency is taken: the interface has a
no-op default and a registration point, so behaviour is unchanged until an
adapter is registered. Releases are tagged from the build id already inlined
by next.config.ts.

Redaction moved out of lib/logger.ts into a leaf module that both the logger
and the sink import, so there is one denylist and no path from application
data to a third party can skip the personnummer regex, including direct sink
calls that bypass the logger. That matters here because these logs carry
personnummer and financial data.

verifyCronSecret now reports its own 401s, which covers all 16 jobs without
touching a route file and catches the case where CRON_SECRET is rotated
without updating the scheduler and every job silently 401s forever. The
threshold is one failure rather than the backup alert's three: suppressing
the first occurrence is precisely how an outage stays invisible.

The seven misleading comments are corrected to describe what the code
actually does, including the two cases that still are not covered: the client
side one, since the sink is server side, and a warn level call that is not
forwarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: remediate the 2026-07-26 similar-sweep findings across all surfaces

Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with
one agent per finding; every behavioural fix carries a regression test proven
to fail at HEAD. Full status, corrections to the sweep, refusals and open
decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md.

Structural roots closed:
- resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking
  1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING
- ledger-line-amount.ts: journal_entry_lines.currency labels the document,
  not the amount; SQL pre-filter decoy proven and fixed
- sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the
  exploitable salary payslip-line PATCH and KPI preferences sinks fixed
- tests/schema: migration-replay phantom-column guard (13k+ refs, closed
  CHECK sets, onConflict targets); found 28 real defects, all fixed, all
  four baselines now empty
- three new ratchet guards: sek-labelled-amount, cross-extension-import,
  ungated-extension-route

Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap),
RC input VAT mismatch wired on web + both MCP callers, missing-underlag
resource delegates to the shared RPC predicate, push-notifications consent
polarity fail-closed, deadlines undo honours requested state, silent-failure
and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/
Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites
with isSwedishUserMessage extended.

Also includes the parallel session's MCP invoice tools (update_invoice,
recurring schedules, invoice deliveries) which share files with the sweep
work and are verified green together.

13 new migrations are NOT applied anywhere; they apply via branch merge.
20260726120000 backfills 1247 supplier-invoice rows. pg tests for new
DDL are written but unrun (no local Postgres).

Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0
errors, check:guards passing, MCP payload 57475/57500.

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

* fix(migrations): rename replace_sie_import migration off main's 20260726090000 version

origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping
our replace_sie_import migration on the same version would abort the Supabase
apply with a schema_migrations_pkey duplicate at merge time.

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

* fix(review): remediate pre-publish deep-review findings across all slices

A 13-agent review of the full branch diff surfaced 1 critical, 5 high and
~45 further findings; this commit resolves them in one pass:

- replace_sie_import / undo_sie_import: p_user_id honored only for
  service_role callers; any other caller is pinned to auth.uid()
  (impersonation gate bypass), authz raise errcode 42501 mapped to a
  Swedish 403 in the route, new caller-guard migration for undo
- bulk_book_transactions refuses homogeneous non-SEK batches instead of
  writing foreign magnitudes into SEK ledger columns
- credit-note cap trigger: company-match on credited_invoice_id, no
  cross-tenant figures in exception text
- link_voucher RPCs resolve NULL invoice currency as SEK end to end
- personal-number ciphertext CHECK split into NOT VALID + VALIDATE
- same-currency foreign settlements clear 1510 at booking rate and book
  realized diff to 3960/7960; rate-less foreign write paths refuse
- receivables revaluation covers partially_paid and outstanding amounts
- period lock guard paginates candidates past the PostgREST 1000 cap
- documents: service-client storage removals after authz, dual-layout
  reads in integrity cron and archive export, backfill delete-source
  sweep actually deletes with hash verification and shared-key grouping
- invoice matching normalizes NULL/lowercase currencies (regression),
  duplicate candidates stop claiming amount matches they never ran
- match-invoice aborts on any booking failure (no paid-without-verifikat)
- refresh-exchange-rate reverts on concurrent booking (TOCTOU window)
- KPI preferences upsert arbiter aligned to the company-scoped constraint
- personnummer_last4 stripped from all salary responses incl. MCP tools
- worked-hours batch restores destroyed rows on conflict and error paths
- MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit
  on tag_journal_lines overflow, auto_send schedules stage as high risk
- observability sink redacts emails/IBANs/API keys and keeps redacted
  stacks in prod; assorted small guards (safe-return-to /@, dry_run=True,
  cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings
  call removed)

Full dispositions, deferred items and hand-verified accounting numbers
are documented in the PR body and DECISIONS.md.

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

* feat(personnummer): implement masking and encryption for personal numbers with tests

* fix(review): address CI and compliance-bot findings for PR #1215

pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role
GUC, so both service-role simulations (runAsServiceRole and the
invoice-delivery test's local helper) never satisfied auth.role() =
'service_role' and every legitimate p_user_id path failed closed; the
shared helper now sets both GUC shapes plus SET LOCAL ROLE with a
fail-loud sanity check, and the delivery test reuses it. The link-voucher
migration had recreated both RPCs from pre-rewrite file text,
reintroducing the NULL-unsafe membership pattern the
null-safe-tenant-guards ratchet bans; both guards now use
public.caller_is_company_member() with all currency changes preserved.

Compliance bots: the customers export now emits the standard masked form
instead of raw AES-256-GCM ciphertext in the Org-/personnummer column,
and maskCustomerRow returns a non-round-trippable placeholder on decrypt
failure instead of 500ing the list. MCP parity: gnubok_lock_period's
staging pre-check now runs the exact countUnbookedInPeriod the commit
path enforces (exported from period-service; local mirror deleted), and
gnubok_agi_status resolves AGI state run-scoped so a correction run no
longer renders as already filed.

Declined with evidence: PR-Agent's opening-balances null-zeroing concern
(all mergeable columns are NOT NULL with defaults per 20260713101000).

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

* fix(review): address codex review findings on PR #1215

- restore 20260726140000 to its preview-recorded content and restate the
  NULL-safe tenant guard under 20260727130000: a recorded migration version
  never re-runs, so the in-place edit could not reach the preview branch
- replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap
  warning texts and update the pinned test expectations
- drop the em dash in the fiscal-periods route comment
- strip trailing whitespace in import-existing.test.ts

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

* test(reports): raise timeout on real PDF render tests

renderToBuffer does real @react-pdf layout work and exceeds the 5s
default when the full suite saturates the CPU; tests pass in isolation.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-27 03:34:56 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent a43a8b03cf
commit f24b26a139
536 changed files with 68921 additions and 4851 deletions
@@ -0,0 +1,240 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
} from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* Covers 20260726100000_bulk_book_transactions_currency_guard:
*
* bulk_book_transactions used to sum v_tx.amount across the selection
* without looking at v_tx.currency, so a SEK + EUR batch produced a single
* scalar (100 EUR + 100 SEK = 200) and a samlingsverifikat stating an
* amount that matched no affärshändelse. BFL 4 kap 6 § requires one
* redovisningsvaluta; BFL 5 kap 7 § requires the verifikat's belopp to be
* the real one. The guard now refuses with BULK_BOOK_MIXED_CURRENCY.
*
* The SQL guard matters independently of the HTTP route: `authenticated`
* holds EXECUTE on this RPC, so a browser session can call it directly over
* PostgREST, and lib/pending-operations/commit.ts passes tx_ids straight
* through.
*
* A HOMOGENEOUS non-SEK batch is refused too (BULK_BOOK_FOREIGN_CURRENCY):
* journal_entry_lines.debit_amount / credit_amount are ALWAYS kronor, and the
* RPC has no exchange rate, so two EUR transactions of 100 + 200 would post a
* verifikat whose 300 reads as kronor in balansräkning, moms and SIE export.
*
* Cases:
* - create-new path refuses a mixed-currency selection
* - link-existing path refuses the same selection
* - NULL currency is read as the column default 'SEK' and stays bookable
* - a homogeneous non-SEK batch is refused and nothing is written
*/
async function insertTransaction(params: {
userId: string
companyId: string
amount: number
date?: string
currency?: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.transactions
(id, user_id, company_id, date, description, amount, currency, category)
VALUES ($1, $2, $3, $4, 'Bank tx', $5, $6, 'uncategorized')`,
[
id,
params.userId,
params.companyId,
params.date ?? '2026-06-05',
params.amount,
params.currency === undefined ? 'SEK' : params.currency,
],
)
return id
}
async function seedTenant() {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({
userId,
companyId,
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
})
await getPool().query(
`INSERT INTO public.chart_of_accounts
(user_id, company_id, account_number, account_name, account_class, account_type, normal_balance, is_active)
SELECT $1, $2, n, name, cls, atype, nbal, true
FROM (VALUES
('1930', 'Bankkonto', 1, 'asset', 'debit'),
('2611', 'Utgående moms 25%', 2, 'liability', 'credit'),
('3001', 'Försäljning 25% moms', 3, 'revenue', 'credit')
) AS t(n, name, cls, atype, nbal)`,
[userId, companyId],
)
return { userId, companyId, fiscalPeriodId }
}
interface RpcResult {
ok: boolean
code?: string
details?: { currencies?: string[]; currency?: string }
mode?: 'link_existing' | 'create_new'
journal_entry_id?: string
linked_tx_count?: number
tx_sum?: number
}
describe('bulk_book_transactions: currency homogeneity (BFL 4 kap 6 §)', () => {
it('refuses a mixed-currency selection on the create-new path', async () => {
const { userId, companyId } = await seedTenant()
const sekTx = await insertTransaction({ userId, companyId, amount: 100, currency: 'SEK' })
const eurTx = await insertTransaction({ userId, companyId, amount: 100, currency: 'EUR' })
// The caller-built lines look internally consistent against the naive
// scalar sum (200) the old body computed: that is exactly the booking
// the guard must stop.
const newEntry = {
description: 'Samlingsverifikation 2026-06-05',
lines: [
{ account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' },
{ account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' },
],
}
await withUserContext(userId, async (client) => {
const r = await client.query<{ bulk_book_transactions: RpcResult }>(
`SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
[[sekTx, eurTx], null, JSON.stringify(newEntry), companyId],
)
const result = r.rows[0]!.bulk_book_transactions
expect(result.ok).toBe(false)
expect(result.code).toBe('BULK_BOOK_MIXED_CURRENCY')
expect(result.details?.currencies).toEqual(expect.arrayContaining(['SEK', 'EUR']))
// Nothing was posted and nothing was linked.
const jes = await client.query(
`SELECT id FROM public.journal_entries WHERE company_id = $1`,
[companyId],
)
expect(jes.rows).toHaveLength(0)
const links = await client.query(
`SELECT transaction_id FROM public.transaction_voucher_links WHERE company_id = $1`,
[companyId],
)
expect(links.rows).toHaveLength(0)
})
})
it('refuses a mixed-currency selection on the link-existing path', async () => {
const { userId, companyId, fiscalPeriodId } = await seedTenant()
const sekTx = await insertTransaction({ userId, companyId, amount: 100, currency: 'SEK' })
const eurTx = await insertTransaction({ userId, companyId, amount: 100, currency: 'EUR' })
// Posted verifikat whose bank net (+200) matches the naive scalar sum,
// so only the currency guard can reject this.
const jeId = randomUUID()
await getPool().query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES ($1, $2, $3, $4, 1, 'A', '2026-06-05', 'Manual dagssumma', 'manual', 'draft')`,
[jeId, userId, companyId, fiscalPeriodId],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', 200, 0), ($1, '3001', 0, 200)`,
[jeId],
)
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [jeId])
await withUserContext(userId, async (client) => {
const r = await client.query<{ bulk_book_transactions: RpcResult }>(
`SELECT bulk_book_transactions($1::uuid[], $2, $3, $4)`,
[[sekTx, eurTx], jeId, null, companyId],
)
const result = r.rows[0]!.bulk_book_transactions
expect(result.ok).toBe(false)
expect(result.code).toBe('BULK_BOOK_MIXED_CURRENCY')
const links = await client.query(
`SELECT transaction_id FROM public.transaction_voucher_links WHERE journal_entry_id = $1`,
[jeId],
)
expect(links.rows).toHaveLength(0)
})
})
it('treats NULL currency as the column default SEK and still books', async () => {
const { userId, companyId } = await seedTenant()
const legacyTx = await insertTransaction({ userId, companyId, amount: 100, currency: null })
const sekTx = await insertTransaction({ userId, companyId, amount: 200, currency: 'SEK' })
const newEntry = {
description: 'Samlingsverifikation 2026-06-05',
lines: [
{ account_number: '1930', debit_amount: 300, credit_amount: 0, currency: 'SEK' },
{ account_number: '3001', debit_amount: 0, credit_amount: 300, currency: 'SEK' },
],
}
await withUserContext(userId, async (client) => {
const r = await client.query<{ bulk_book_transactions: RpcResult }>(
`SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
[[legacyTx, sekTx], null, JSON.stringify(newEntry), companyId],
)
const result = r.rows[0]!.bulk_book_transactions
expect(result.ok).toBe(true)
expect(result.linked_tx_count).toBe(2)
expect(result.tx_sum).toBe(300)
})
})
it('refuses a homogeneous non-SEK selection (the ledger columns are always kronor)', async () => {
const { userId, companyId } = await seedTenant()
const eur1 = await insertTransaction({ userId, companyId, amount: 100, currency: 'EUR' })
const eur2 = await insertTransaction({ userId, companyId, amount: 200, currency: 'EUR' })
// The old body accepted this batch and wrote 300 into the always-SEK
// debit/credit columns: a verifikat read as 300 kronor by balansräkning,
// moms and SIE while the affärshändelse was 300 euro.
const newEntry = {
description: 'Samlingsverifikation EUR 2026-06-05',
lines: [
{ account_number: '1930', debit_amount: 300, credit_amount: 0, currency: 'EUR' },
{ account_number: '3001', debit_amount: 0, credit_amount: 300, currency: 'EUR' },
],
}
await withUserContext(userId, async (client) => {
const r = await client.query<{ bulk_book_transactions: RpcResult }>(
`SELECT bulk_book_transactions($1::uuid[], $2, $3::jsonb, $4)`,
[[eur1, eur2], null, JSON.stringify(newEntry), companyId],
)
const result = r.rows[0]!.bulk_book_transactions
expect(result.ok).toBe(false)
expect(result.code).toBe('BULK_BOOK_FOREIGN_CURRENCY')
expect(result.details?.currency).toBe('EUR')
// Nothing was posted and nothing was linked.
const jes = await client.query(
`SELECT id FROM public.journal_entries WHERE company_id = $1`,
[companyId],
)
expect(jes.rows).toHaveLength(0)
const links = await client.query(
`SELECT transaction_id FROM public.transaction_voucher_links WHERE company_id = $1`,
[companyId],
)
expect(links.rows).toHaveLength(0)
})
})
})
+349
View File
@@ -0,0 +1,349 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from './setup'
import { seedCompany } from './fixtures'
/**
* Guards migration 20260726130000: the credit-note cap is on AMOUNT, not COUNT.
*
* ML (2023:200) 17 kap 22-23 SS permits a partial aendringsfaktura, so the
* previous UNIQUE index on (company_id, credited_invoice_id) was over-broad.
* What must stay impossible is crediting MORE than the original invoice.
*/
async function insertInvoice(input: {
userId: string
companyId: string
number: string
total: number
// Explicit null inserts a NULL. Both columns are nullable with a default
// (`status text default 'draft'`, `currency text default 'SEK'`) and neither
// CHECK rejects NULL, so these are reachable row shapes, not synthetic ones.
currency?: string | null
status?: string | null
creditedInvoiceId?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, invoice_number, invoice_date, due_date,
status, currency, total, credited_invoice_id)
VALUES ($1, $2, $3, $4, '2026-07-26', '2026-07-26', $5, $6, $7, $8)`,
[
id,
input.userId,
input.companyId,
input.number,
input.status === undefined ? 'sent' : input.status,
input.currency === undefined ? 'SEK' : input.currency,
input.total,
input.creditedInvoiceId ?? null,
],
)
return id
}
describe('credit note amount cap', () => {
it('drops the old one-credit-note-per-invoice unique index', async () => {
const index = await getPool().query(
`SELECT indexname FROM pg_indexes
WHERE schemaname = 'public' AND indexname = 'uq_invoices_company_credited_invoice'`,
)
expect(index.rows).toHaveLength(0)
})
it('allows two partial credit notes that stay within the original total', async () => {
const { userId, companyId } = await seedCompany()
const originalId = await insertInvoice({
userId,
companyId,
number: `F-${randomUUID()}`,
total: 12500,
})
await insertInvoice({
userId,
companyId,
number: `KR1-${randomUUID()}`,
total: -5000,
creditedInvoiceId: originalId,
})
await insertInvoice({
userId,
companyId,
number: `KR2-${randomUUID()}`,
total: -7500,
creditedInvoiceId: originalId,
})
const credited = await getPool().query<{ sum: string }>(
`SELECT COALESCE(SUM(ABS(total)), 0)::text AS sum
FROM public.invoices WHERE credited_invoice_id = $1`,
[originalId],
)
expect(Number(credited.rows[0].sum)).toBe(12500)
})
it('refuses a credit note that pushes the credited total past the original', async () => {
const { userId, companyId } = await seedCompany()
const originalId = await insertInvoice({
userId,
companyId,
number: `F-${randomUUID()}`,
total: 12500,
})
await insertInvoice({
userId,
companyId,
number: `KR1-${randomUUID()}`,
total: -10000,
creditedInvoiceId: originalId,
})
await expect(
insertInvoice({
userId,
companyId,
number: `KR2-${randomUUID()}`,
total: -2500.02,
creditedInvoiceId: originalId,
}),
).rejects.toThrow(/exceeds the invoice total/i)
})
it('still refuses two full mirrors of the same invoice (no double reversal)', async () => {
const { userId, companyId } = await seedCompany()
const originalId = await insertInvoice({
userId,
companyId,
number: `F-${randomUUID()}`,
total: 12500,
})
await insertInvoice({
userId,
companyId,
number: `KR1-${randomUUID()}`,
total: -12500,
creditedInvoiceId: originalId,
})
await expect(
insertInvoice({
userId,
companyId,
number: `KR2-${randomUUID()}`,
total: -12500,
creditedInvoiceId: originalId,
}),
).rejects.toThrow(/exceeds the invoice total/i)
})
it('frees the credited amount again when a credit note is cancelled', async () => {
const { userId, companyId } = await seedCompany()
const originalId = await insertInvoice({
userId,
companyId,
number: `F-${randomUUID()}`,
total: 12500,
})
const firstCreditId = await insertInvoice({
userId,
companyId,
number: `KR1-${randomUUID()}`,
total: -12500,
creditedInvoiceId: originalId,
})
await getPool().query(`UPDATE public.invoices SET status = 'cancelled' WHERE id = $1`, [
firstCreditId,
])
// A replacement credit note now fits again.
await expect(
insertInvoice({
userId,
companyId,
number: `KR2-${randomUUID()}`,
total: -12500,
creditedInvoiceId: originalId,
}),
).resolves.toBeTruthy()
})
// The sibling sum decides how much capacity is left, so a sibling that falls
// out of it hands out capacity that is already spent. `status <> 'cancelled'`
// is NULL for a NULL-status row and excludes it; COALESCE(status, '') counts
// it. These two cases are the whole reason for that COALESCE.
it('counts a NULL-status sibling against the cap', async () => {
const { userId, companyId } = await seedCompany()
const originalId = await insertInvoice({
userId,
companyId,
number: `F-${randomUUID()}`,
total: 12500,
})
await insertInvoice({
userId,
companyId,
number: `KR1-${randomUUID()}`,
total: -12500,
status: null,
creditedInvoiceId: originalId,
})
await expect(
insertInvoice({
userId,
companyId,
number: `KR2-${randomUUID()}`,
total: -12500,
creditedInvoiceId: originalId,
}),
).rejects.toThrow(/exceeds the invoice total/i)
})
it('caps an incoming NULL-status credit note instead of treating it as cancelled', async () => {
const { userId, companyId } = await seedCompany()
const originalId = await insertInvoice({
userId,
companyId,
number: `F-${randomUUID()}`,
total: 12500,
})
await insertInvoice({
userId,
companyId,
number: `KR1-${randomUUID()}`,
total: -12500,
creditedInvoiceId: originalId,
})
await expect(
insertInvoice({
userId,
companyId,
number: `KR2-${randomUUID()}`,
total: -12500,
status: null,
creditedInvoiceId: originalId,
}),
).rejects.toThrow(/exceeds the invoice total/i)
})
it('refuses a credit note denominated in another currency than the original', async () => {
const { userId, companyId } = await seedCompany()
const originalId = await insertInvoice({
userId,
companyId,
number: `F-${randomUUID()}`,
total: 12500,
currency: 'SEK',
})
await expect(
insertInvoice({
userId,
companyId,
number: `KR-${randomUUID()}`,
total: -1000,
currency: 'EUR',
creditedInvoiceId: originalId,
}),
).rejects.toThrow(/currency/i)
})
// invoices.currency is nullable with `default 'SEK'`, so the assert resolves a
// NULL to SEK rather than opting out of the comparison: skipping on NULL let a
// NULL-currency credit note be capped against a EUR original in a unit nobody
// had agreed on.
it('treats a NULL currency as SEK on both sides of the assert', async () => {
const { userId, companyId } = await seedCompany()
const eurOriginalId = await insertInvoice({
userId,
companyId,
number: `F-${randomUUID()}`,
total: 12500,
currency: 'EUR',
})
await expect(
insertInvoice({
userId,
companyId,
number: `KR-${randomUUID()}`,
total: -1000,
currency: null,
creditedInvoiceId: eurOriginalId,
}),
).rejects.toThrow(/currency/i)
const sekOriginalId = await insertInvoice({
userId,
companyId,
number: `F2-${randomUUID()}`,
total: 12500,
currency: 'SEK',
})
await expect(
insertInvoice({
userId,
companyId,
number: `KR2-${randomUUID()}`,
total: -1000,
currency: null,
creditedInvoiceId: sekOriginalId,
}),
).resolves.toBeTruthy()
})
// The trigger reads the original with SECURITY DEFINER (RLS bypassed), so
// the company match in its lookup is what stops a credit note in one
// company from FOR UPDATE-locking another tenant's invoice and leaking its
// total/currency through the exception text. A cross-company reference is
// treated as not found and the insert is rejected.
it('rejects a credit note that references another tenant\'s invoice', async () => {
const victim = await seedCompany()
const attacker = await seedCompany()
const victimInvoiceId = await insertInvoice({
userId: victim.userId,
companyId: victim.companyId,
number: `F-${randomUUID()}`,
total: 98765.43,
})
let raised: Error | null = null
try {
await insertInvoice({
userId: attacker.userId,
companyId: attacker.companyId,
number: `KR-${randomUUID()}`,
total: -1000,
creditedInvoiceId: victimInvoiceId,
})
} catch (err) {
raised = err as Error
}
expect(raised).not.toBeNull()
expect(raised!.message).toMatch(/not found for credit note/i)
// The exception must not leak the victim invoice's figures.
expect(raised!.message).not.toContain('98765.43')
})
it('leaves ordinary invoices untouched', async () => {
const { userId, companyId } = await seedCompany()
await expect(
insertInvoice({
userId,
companyId,
number: `F-${randomUUID()}`,
total: 12500,
}),
).resolves.toBeTruthy()
})
})
@@ -7,6 +7,7 @@ async function insertInvoice(input: {
userId: string
companyId: string
number: string
total?: number
creditedInvoiceId?: string
creationComplete?: boolean
}): Promise<string> {
@@ -14,13 +15,14 @@ async function insertInvoice(input: {
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, invoice_number, invoice_date, due_date,
credited_invoice_id, creation_complete)
VALUES ($1, $2, $3, $4, '2026-07-15', '2026-07-15', $5, $6)`,
total, credited_invoice_id, creation_complete)
VALUES ($1, $2, $3, $4, '2026-07-15', '2026-07-15', $5, $6, $7)`,
[
id,
input.userId,
input.companyId,
input.number,
input.total ?? 0,
input.creditedInvoiceId ?? null,
input.creationComplete ?? true,
],
@@ -29,14 +31,25 @@ async function insertInvoice(input: {
}
describe('credit note creation and posting guards', () => {
it('allows only one credit-note relationship per original and company', async () => {
// 20260726130000 replaced the one-credit-note-per-original UNIQUE index with an
// amount cap: partial credit notes are legal (ML 17 kap 22-23 SS), crediting
// beyond the original total is not. Full coverage of the new invariant lives in
// credit-note-amount-cap.pg.test.ts; this asserts the double-reversal case the
// old index existed to prevent is still blocked.
it('refuses a second full-value credit note for the same original', async () => {
const { userId, companyId } = await seedCompany()
const originalId = await insertInvoice({ userId, companyId, number: `F-${randomUUID()}` })
const originalId = await insertInvoice({
userId,
companyId,
number: `F-${randomUUID()}`,
total: 12500,
})
await insertInvoice({
userId,
companyId,
number: `KR-${randomUUID()}`,
total: -12500,
creditedInvoiceId: originalId,
})
@@ -45,9 +58,10 @@ describe('credit note creation and posting guards', () => {
userId,
companyId,
number: `KR-${randomUUID()}`,
total: -12500,
creditedInvoiceId: originalId,
}),
).rejects.toThrow(/duplicate|unique/i)
).rejects.toThrow(/exceeds the invoice total/i)
})
it('persists the completion marker and installs a unique posted-source guard', async () => {
@@ -0,0 +1,146 @@
import { randomUUID } from 'crypto'
import { describe, expect, it } from 'vitest'
import { seedCompany } from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
/**
* customers_personal_number_check after 20260726110000.
*
* The column stores AES-256-GCM ciphertext, not a personnummer. The original
* constraint (20260522130000) demanded the personnummer format, which no
* encrypted value can ever satisfy, so every write failed silently in
* production. These tests lock in the corrected contract:
*
* - ciphertext is accepted and round-trips (persist, read back, decrypt)
* - a bare personnummer is rejected, so a write path that forgets to
* encrypt fails loudly instead of persisting PII
* - the masked display form ('********-1234') is rejected
*
* All personnummer values below are synthetic.
*/
async function insertCustomer(params: {
userId: string
companyId: string
personalNumber: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.customers
(id, user_id, company_id, name, customer_type, personal_number)
VALUES ($1, $2, $3, 'Testkund', 'individual', $4)`,
[id, params.userId, params.companyId, params.personalNumber],
)
return id
}
describe('customers.personal_number ciphertext check.pg', () => {
it('is validated after full replay (NOT VALID in 20260726110000, VALIDATE in 20260726110001)', async () => {
// The constraint is added NOT VALID so a fork database holding a legacy
// plaintext row cannot stall the whole migration batch, then validated in
// its own migration. After replaying both, it must be fully validated:
// convalidated=false here means the VALIDATE migration was dropped.
const res = await getPool().query<{ convalidated: boolean }>(
`SELECT convalidated
FROM pg_constraint
WHERE conname = 'customers_personal_number_check'
AND conrelid = 'public.customers'::regclass`,
)
expect(res.rows).toHaveLength(1)
expect(res.rows[0]!.convalidated).toBe(true)
})
it('accepts NULL', async () => {
const { userId, companyId } = await seedCompany()
const id = await insertCustomer({ userId, companyId, personalNumber: null })
const res = await getPool().query<{ personal_number: string | null }>(
`SELECT personal_number FROM public.customers WHERE id = $1`,
[id],
)
expect(res.rows[0]!.personal_number).toBeNull()
})
it('persists encrypted personnummer and reads it back decryptable', async () => {
const { userId, companyId } = await seedCompany()
const plaintext = '19900101-1234'
const ciphertext = encryptPersonnummer(plaintext)
const id = await insertCustomer({ userId, companyId, personalNumber: ciphertext })
const res = await getPool().query<{ personal_number: string }>(
`SELECT personal_number FROM public.customers WHERE id = $1`,
[id],
)
const stored = res.rows[0]!.personal_number
expect(stored).toBe(ciphertext)
expect(decryptPersonnummer(stored)).toBe(plaintext)
})
it('accepts every ciphertext length the accepted plaintext formats produce', async () => {
const { userId, companyId } = await seedCompany()
// 10, 11, 12 and 13 characters of plaintext -> 76, 78, 80 and 82 hex chars.
for (const plaintext of ['9001011234', '900101-1234', '199001011234', '19900101-1234']) {
const ciphertext = encryptPersonnummer(plaintext)
const id = await insertCustomer({ userId, companyId, personalNumber: ciphertext })
const res = await getPool().query<{ personal_number: string }>(
`SELECT personal_number FROM public.customers WHERE id = $1`,
[id],
)
expect(decryptPersonnummer(res.rows[0]!.personal_number)).toBe(plaintext)
}
})
it('rejects a bare personnummer (a write path that forgot to encrypt)', async () => {
const { userId, companyId } = await seedCompany()
for (const plaintext of ['9001011234', '900101-1234', '199001011234', '19900101-1234']) {
await expect(
insertCustomer({ userId, companyId, personalNumber: plaintext }),
).rejects.toThrow(/customers_personal_number_check/)
}
})
it('rejects the masked display form', async () => {
const { userId, companyId } = await seedCompany()
await expect(
insertCustomer({ userId, companyId, personalNumber: '********-1234' }),
).rejects.toThrow(/customers_personal_number_check/)
})
it('rejects hex that is too short to be ciphertext', async () => {
const { userId, companyId } = await seedCompany()
await expect(
insertCustomer({ userId, companyId, personalNumber: 'a'.repeat(75) }),
).rejects.toThrow(/customers_personal_number_check/)
})
it('rejects uppercase hex (Buffer.toString(hex) emits lowercase)', async () => {
const { userId, companyId } = await seedCompany()
await expect(
insertCustomer({ userId, companyId, personalNumber: 'A'.repeat(80) }),
).rejects.toThrow(/customers_personal_number_check/)
})
it('rejects an UPDATE that replaces ciphertext with plaintext', async () => {
const { userId, companyId } = await seedCompany()
const id = await insertCustomer({
userId,
companyId,
personalNumber: encryptPersonnummer('19900101-1234'),
})
await expect(
getPool().query(`UPDATE public.customers SET personal_number = $1 WHERE id = $2`, [
'19900101-1234',
id,
]),
).rejects.toThrow(/customers_personal_number_check/)
})
})
@@ -0,0 +1,261 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { getPool, withUserContext } from './setup'
import { insertAuthUser, insertCompany, insertCompanyMember } from './fixtures'
/**
* Storage RLS for the `documents` bucket after
* 20260726092000_documents_bucket_company_scope.sql.
*
* The bug this guards: the original policies (20240101000024) scoped the
* bucket on the UPLOADER, `(storage.foldername(name))[2] = auth.uid()::text`.
* company_id appeared nowhere in the policy or the key, so deleting a
* company_members row revoked nothing: the ex-member's session still
* authenticated, auth.uid() was unchanged, and they kept direct Storage read
* access to every kvitto and leverantorsfaktura they had uploaded. Those are
* records under the BFL 7 kap 2 § seven-year retention requirement, so the
* exposure is both a GDPR Art. 32 access-control failure and long-lived.
*
* The company-scoped layout is documents/{companyId}/{userId}/{ts}_{file};
* access follows public.user_company_ids(), i.e. current membership.
*/
describe('documents bucket: company-scoped storage policies', () => {
const objectNames: string[] = []
let ownerA: string
let memberA: string
let outsider: string
let exMember: string
let companyA: string
let companyB: string
let keyUploadedByOwner: string
let keyUploadedByExMember: string
let keyInCompanyB: string
let legacyKey: string
async function seedObject(name: string): Promise<void> {
await getPool().query(
`INSERT INTO storage.objects (bucket_id, name) VALUES ('documents', $1)`,
[name],
)
objectNames.push(name)
}
beforeAll(async () => {
// The bucket row: the migration inserts it, but a bucket-less CI image
// would fail the FK on storage.objects with a confusing error.
await getPool().query(
`INSERT INTO storage.buckets (id, name, public)
VALUES ('documents', 'documents', false)
ON CONFLICT (id) DO NOTHING`,
)
// Real Supabase grants table privileges on storage.objects to the
// `authenticated` role; the bare postgres image used in CI may not.
// Without this the assertions below fail with "permission denied" and
// say nothing about the policies we are actually testing.
await getPool()
.query(`GRANT SELECT, INSERT ON storage.objects TO authenticated`)
.catch(() => {})
ownerA = await insertAuthUser()
memberA = await insertAuthUser()
outsider = await insertAuthUser()
exMember = await insertAuthUser()
companyA = await insertCompany({ createdBy: ownerA, name: 'Company A AB' })
companyB = await insertCompany({ createdBy: outsider, name: 'Company B AB' })
await insertCompanyMember({ companyId: companyA, userId: ownerA, role: 'owner' })
await insertCompanyMember({ companyId: companyA, userId: memberA, role: 'member' })
await insertCompanyMember({ companyId: companyB, userId: outsider, role: 'owner' })
// exMember uploaded underlag for company A while they were a member; the
// membership row is deleted further down, in the regression test itself.
await insertCompanyMember({ companyId: companyA, userId: exMember, role: 'member' })
keyUploadedByOwner = `documents/${companyA}/${ownerA}/1700000000000_kvitto.pdf`
keyUploadedByExMember = `documents/${companyA}/${exMember}/1700000000001_kvitto.pdf`
keyInCompanyB = `documents/${companyB}/${outsider}/1700000000002_kvitto.pdf`
legacyKey = `documents/${ownerA}/1700000000003_kvitto.pdf`
await seedObject(keyUploadedByOwner)
await seedObject(keyUploadedByExMember)
await seedObject(keyInCompanyB)
await seedObject(legacyKey)
// A non-document shape the bucket also holds: the MCP audit-package tool
// writes `{userId}/audit-packages/...`. Its second segment is NOT a UUID,
// which is exactly why the policy compares path segments as text instead
// of casting them like the sie-files precedent does.
await seedObject(`${ownerA}/audit-packages/1700000000004_archive.zip`)
})
afterAll(async () => {
// Best effort: seeding happens on the superuser pool (outside the
// rolled-back user transactions), so it must be swept. A residual FK from
// a trigger-seeded child row must not turn cleanup into a suite failure.
const sweep = (sql: string, params: unknown[]) =>
getPool()
.query(sql, params)
.catch(() => {})
if (objectNames.length > 0) {
await sweep(`DELETE FROM storage.objects WHERE name = ANY($1::text[])`, [objectNames])
}
await sweep(`DELETE FROM public.company_members WHERE company_id = ANY($1::uuid[])`, [
[companyA, companyB],
])
await sweep(`DELETE FROM public.companies WHERE id = ANY($1::uuid[])`, [[companyA, companyB]])
await sweep(`DELETE FROM auth.users WHERE id = ANY($1::uuid[])`, [
[ownerA, memberA, outsider, exMember],
])
})
async function visibleNames(userId: string): Promise<string[]> {
return withUserContext(userId, async (client) => {
const res = await client.query<{ name: string }>(
`SELECT name FROM storage.objects WHERE bucket_id = 'documents' AND name = ANY($1::text[])`,
[objectNames],
)
return res.rows.map((r) => r.name)
})
}
describe('policy shape', () => {
it('the company-scoped policies exist and the Phase A legacy ones are still present', async () => {
const res = await getPool().query<{ polname: string }>(
`SELECT polname FROM pg_policy WHERE polrelid = 'storage.objects'::regclass`,
)
const names = res.rows.map((r) => r.polname)
expect(names).toContain('documents_select_company')
expect(names).toContain('documents_insert_company')
// Phase A is additive: legacy objects must stay readable until the
// Phase B backfill reports zero legacy-prefix objects. Phase C drops
// these two, and only then.
expect(names).toContain('documents_select_own')
expect(names).toContain('documents_insert_own')
})
it('adds no UPDATE or DELETE policy: WORM semantics stay intact', async () => {
const res = await getPool().query<{ polcmd: string }>(
`SELECT polcmd FROM pg_policy
WHERE polrelid = 'storage.objects'::regclass
AND polname IN ('documents_select_company', 'documents_insert_company')`,
)
// 'r' = SELECT, 'a' = INSERT. Neither 'w' (UPDATE) nor 'd' (DELETE).
expect(res.rows.map((r) => r.polcmd).sort()).toEqual(['a', 'r'])
})
})
describe('company scoping', () => {
it('a member of company A can read an object under company A new-layout prefix', async () => {
const visible = await visibleNames(memberA)
expect(visible).toContain(keyUploadedByOwner)
// The point of the fix: a colleague can read underlag they did not
// upload themselves, because access follows the company, not auth.uid().
expect(visible).toContain(keyUploadedByExMember)
})
it('a non-member cannot read company A objects', async () => {
const visible = await visibleNames(outsider)
expect(visible).not.toContain(keyUploadedByOwner)
expect(visible).not.toContain(keyUploadedByExMember)
// ... and still sees their own company's object.
expect(visible).toContain(keyInCompanyB)
})
it('a member of A cannot read company B objects', async () => {
const visible = await visibleNames(memberA)
expect(visible).not.toContain(keyInCompanyB)
})
it('never raises on non-UUID path segments (MCP audit-package keys)', async () => {
// A `[2]::uuid` cast would raise 22P02 here and fail the whole SELECT
// instead of filtering the row out, because Postgres does not guarantee
// the `[1] = 'documents'` qual is evaluated first.
const visible = await visibleNames(memberA)
expect(visible).not.toContain(`${ownerA}/audit-packages/1700000000004_archive.zip`)
})
it('an insert into another company prefix is rejected', async () => {
await expect(
withUserContext(memberA, async (client) => {
await client.query(
`INSERT INTO storage.objects (bucket_id, name) VALUES ('documents', $1)`,
[`documents/${companyB}/${memberA}/1700000000005_kvitto.pdf`],
)
}),
).rejects.toThrow(/row-level security/i)
})
it('an insert into the own company prefix is accepted', async () => {
await withUserContext(memberA, async (client) => {
// withUserContext always rolls back, so this leaves no residue.
await client.query(
`INSERT INTO storage.objects (bucket_id, name) VALUES ('documents', $1)`,
[`documents/${companyA}/${memberA}/1700000000006_kvitto.pdf`],
)
})
})
})
describe('regression: removal from a company revokes storage access', () => {
it('a user removed from company A loses access to an object they uploaded themselves', async () => {
// Still a member: they can read their own upload.
const before = await visibleNames(exMember)
expect(before).toContain(keyUploadedByExMember)
await getPool().query(
`DELETE FROM public.company_members WHERE company_id = $1 AND user_id = $2`,
[companyA, exMember],
)
try {
const after = await visibleNames(exMember)
// THE bug: under the uploader-scoped policy this still returned the
// row, because auth.uid() is unchanged and company_members was never
// consulted. Under the company-scoped policy the membership deletion
// is what revokes access.
expect(after).not.toContain(keyUploadedByExMember)
expect(after).not.toContain(keyUploadedByOwner)
} finally {
await insertCompanyMember({ companyId: companyA, userId: exMember, role: 'member' })
}
})
it('a removed member also loses INSERT into the company prefix', async () => {
await getPool().query(
`DELETE FROM public.company_members WHERE company_id = $1 AND user_id = $2`,
[companyA, exMember],
)
try {
await expect(
withUserContext(exMember, async (client) => {
await client.query(
`INSERT INTO storage.objects (bucket_id, name) VALUES ('documents', $1)`,
[`documents/${companyA}/${exMember}/1700000000007_kvitto.pdf`],
)
}),
).rejects.toThrow(/row-level security/i)
} finally {
await insertCompanyMember({ companyId: companyA, userId: exMember, role: 'member' })
}
})
})
describe('Phase A coexistence', () => {
it('legacy uploader-scoped objects stay readable for their uploader', async () => {
// Until the Phase B backfill has re-homed them, `documents_select_own`
// is the only thing keeping legacy underlag readable. Phase C must not
// be applied while this is still true for any object.
const visible = await visibleNames(ownerA)
expect(visible).toContain(legacyKey)
})
it('legacy objects are NOT readable by a colleague: exactly what Phase B fixes', async () => {
const visible = await visibleNames(memberA)
expect(visible).not.toContain(legacyKey)
})
})
})
+51
View File
@@ -138,6 +138,57 @@ describe('full-archive coverage contract', () => {
}
})
it('denormalizes only parent columns that exist', () => {
const broken: string[] = []
for (const t of MASTER_DATA_DUMP_TABLES) {
if (!t.denormalize || !t.via) continue
const parentCols = columnsByTable.get(t.via.parent)
for (const column of t.denormalize.columns) {
if (!parentCols?.has(column)) broken.push(`${t.via.parent}.${column}`)
}
}
expect(
broken,
`denormalize columns missing from the parent schema: ${broken.join(', ')}`
).toEqual([])
})
it('never denormalizes over a real child column', () => {
const collisions: string[] = []
for (const t of MASTER_DATA_DUMP_TABLES) {
if (!t.denormalize) continue
const childCols = columnsByTable.get(t.name)
for (const column of t.denormalize.columns) {
const key = `${t.denormalize.prefix}${column}`
if (childCols?.has(key)) collisions.push(`${t.name}.${key}`)
}
}
expect(
collisions,
`denormalized keys collide with real child columns (the export keeps the ` +
`DB column and silently drops the parent value): ${collisions.join(', ')}`
).toEqual([])
})
it('gives every child of a multi-currency parent its own unit', () => {
// A child line row carries money but no currency of its own, so the unit
// is unrecoverable from its JSON file alone. Whenever the parent has a
// currency column, the child dump must copy it down.
const missing: string[] = []
for (const t of MASTER_DATA_DUMP_TABLES) {
if (!t.via) continue
if (!columnsByTable.get(t.via.parent)?.has('currency')) continue
if (!t.denormalize?.columns.includes('currency')) missing.push(t.name)
}
expect(
missing,
`child tables whose parent has a currency column but which do not ` +
`denormalize it: ${missing.join(', ')}. Add ` +
`denormalize: { prefix, columns: ['currency', ...] } in ` +
`lib/reports/full-archive-export.ts so the archived line states its unit.`
).toEqual([])
})
it('orders every dump query by columns that exist', () => {
const broken = MASTER_DATA_DUMP_TABLES.filter(
(t) => t.orderBy && !columnsByTable.get(t.name)?.has(t.orderBy)
@@ -0,0 +1,304 @@
/**
* pg-real test for public.list_invoice_delivery_summaries_for_service
* (20260727100000_list_invoice_delivery_summaries_for_service.sql).
*
* The function is the service-role sibling of list_invoice_delivery_summaries:
* the MCP server runs on a cookieless service client (auth.uid() IS NULL) and
* routes to the API key's company, so the cookie-session function rejects every
* MCP call. The sibling takes the acting user explicitly and re-verifies
* membership server-side, mirroring authorize_invoice_delivery_service_actor.
*
* What must hold:
* - only auth.role() = 'service_role' may call it; authenticated/anon have no
* EXECUTE, and even a privileged caller without service-role claims is
* rejected in-function (42501).
* - a p_user_id that is not a member of p_company_id is rejected (42501);
* any member including a viewer is served (read path has no role filter,
* matching the cookie-session sibling's audience).
* - To/CC come back masked to ***@domain and the provider reason text has
* address local parts masked; body_html, body_text and bcc_addresses are
* never in the result shape at all.
*
* The service-role simulation is the shared runAsServiceRole helper
* (tests/pg/setup.ts). A claims-JSON-only simulation (the
* gl_lines_rpc_tenant_guard technique) is NOT enough here: that guard parses
* `request.jwt.claims` itself, while this function asks auth.role(), which in
* the CI image's legacy auth shim reads only `request.jwt.claim.role`. The
* helper sets both GUC styles and fails loudly if auth.role() does not
* resolve.
*/
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool, runAsServiceRole, withUserContext } from './setup'
import { insertAuthUser, insertCompanyMember, seedCompany } from './fixtures'
const FN = `SELECT * FROM public.list_invoice_delivery_summaries_for_service($1, $2, $3)`
// All calls inside the service context are reads (seeding happens over the
// plain pool), so the shared helper's commit-on-success semantics are
// equivalent to the rolled-back local helper this file used to carry.
const withServiceRoleContext = runAsServiceRole
async function insertInvoice(params: {
userId: string
companyId: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, invoice_number, invoice_date, due_date,
status, currency, total)
VALUES ($1, $2, $3, $4, '2026-06-01', '2026-07-01', 'sent', 'SEK', 1250)`,
[id, params.userId, params.companyId, `F-${id.slice(0, 8)}`],
)
return id
}
async function insertDocumentAttachment(params: {
userId: string
companyId: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.document_attachments
(id, user_id, company_id, storage_path, file_name, sha256_hash)
VALUES ($1, $2, $3, $4, 'faktura-1042.pdf', $5)`,
[
id,
params.userId,
params.companyId,
`test/${id}.pdf`,
// 64-char hex string: sha256 placeholder for the test.
id.replace(/-/g, '').padEnd(64, '0'),
],
)
return id
}
// Seed a terminal 'sent' email delivery directly (INSERTs are the append path
// of the WORM table; immutability triggers guard UPDATE/DELETE). Payload and
// BCC use SECRET markers so a leak through the summary is detectable.
async function insertSentEmailDelivery(params: {
companyId: string
userId: string
invoiceId: string
documentAttachmentId: string
providerStatus?: 'delayed' | 'delivered' | 'complained' | 'bounced' | 'failed' | 'suppressed' | null
providerStatusDetail?: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.invoice_deliveries
(id, company_id, user_id, invoice_id, channel, status,
to_addresses, cc_addresses, bcc_addresses, from_name, subject,
body_text, body_html, provider, provider_message_id,
document_attachment_id, attachment_filename, attachment_content_type,
attachment_sha256, sent_at,
provider_status, provider_status_at, provider_status_detail)
VALUES ($1, $2, $3, $4, 'email', 'sent',
$5::text[], $6::text[], $7::text[], 'Test AB', 'Faktura F-1042',
'SECRET-BODY-TEXT', '<p>SECRET-BODY-HTML</p>', 'resend', $8,
$9, 'faktura-1042.pdf', 'application/pdf', $10, now(),
$11, CASE WHEN $11::text IS NULL THEN NULL ELSE now() END, $12)`,
[
id,
params.companyId,
params.userId,
params.invoiceId,
['kund.betalare@example.com'],
['ekonomi.kopia@example.org'],
['dold.kopia@example.net'],
`msg-${id}`,
params.documentAttachmentId,
id.replace(/-/g, '').padEnd(64, '0'),
params.providerStatus ?? null,
params.providerStatusDetail ?? null,
],
)
return id
}
async function seedSentDeliveryWithBounce(): Promise<{
companyId: string
userId: string
invoiceId: string
deliveryId: string
}> {
const a = await seedCompany()
const invoiceId = await insertInvoice({ userId: a.userId, companyId: a.companyId })
const documentAttachmentId = await insertDocumentAttachment({
userId: a.userId,
companyId: a.companyId,
})
const deliveryId = await insertSentEmailDelivery({
companyId: a.companyId,
userId: a.userId,
invoiceId,
documentAttachmentId,
providerStatus: 'bounced',
providerStatusDetail: 'smtp; 550 5.1.1 kund.betalare@example.com recipient rejected',
})
return { companyId: a.companyId, userId: a.userId, invoiceId, deliveryId }
}
describe('list_invoice_delivery_summaries_for_service: definition contract', () => {
it('is SECURITY DEFINER, STABLE, search_path-pinned, EXECUTE-able by service_role only', async () => {
const meta = await getPool().query<{
prosecdef: boolean
provolatile: string
proconfig: string[] | null
anon_exec: boolean
authenticated_exec: boolean
service_exec: boolean
}>(
`SELECT p.prosecdef, p.provolatile, p.proconfig,
has_function_privilege('anon', p.oid, 'EXECUTE') AS anon_exec,
has_function_privilege('authenticated', p.oid, 'EXECUTE') AS authenticated_exec,
has_function_privilege('service_role', p.oid, 'EXECUTE') AS service_exec
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public'
AND p.proname = 'list_invoice_delivery_summaries_for_service'`,
)
expect(meta.rows).toHaveLength(1)
const row = meta.rows[0]
expect(row.prosecdef).toBe(true)
expect(row.provolatile).toBe('s')
expect(row.proconfig ?? []).toContain('search_path=pg_catalog, public')
expect(row.anon_exec).toBe(false)
expect(row.authenticated_exec).toBe(false)
expect(row.service_exec).toBe(true)
})
})
describe('list_invoice_delivery_summaries_for_service: authorization', () => {
it('rejects a caller without service-role claims even when EXECUTE succeeds', async () => {
const seeded = await seedSentDeliveryWithBounce()
// Bare superuser pool: EXECUTE is not the barrier here, but auth.role()
// is NULL, so the in-function check must fire.
await expect(
getPool().query(FN, [seeded.companyId, seeded.userId, seeded.invoiceId]),
).rejects.toThrow(/service role/)
})
it('rejects an authenticated session at the privilege layer', async () => {
const seeded = await seedSentDeliveryWithBounce()
await expect(
withUserContext(seeded.userId, (client) =>
client.query(FN, [seeded.companyId, seeded.userId, seeded.invoiceId]),
),
).rejects.toThrow(/permission denied/i)
})
it('rejects a p_user_id that is not a member of p_company_id', async () => {
const seeded = await seedSentDeliveryWithBounce()
const outsider = await seedCompany() // member of another company only
await withServiceRoleContext(async (client) => {
await expect(
client.query(FN, [seeded.companyId, outsider.userId, seeded.invoiceId]),
).rejects.toThrow(/not a company member/)
})
})
it('rejects a NULL p_user_id', async () => {
const seeded = await seedSentDeliveryWithBounce()
await withServiceRoleContext(async (client) => {
await expect(
client.query(FN, [seeded.companyId, null, seeded.invoiceId]),
).rejects.toThrow(/not a company member/)
})
})
})
describe('list_invoice_delivery_summaries_for_service: masked result', () => {
it('returns the bounce with masked recipients and reason, and no payload columns', async () => {
const seeded = await seedSentDeliveryWithBounce()
await withServiceRoleContext(async (client) => {
const res = await client.query(FN, [seeded.companyId, seeded.userId, seeded.invoiceId])
expect(res.rows).toHaveLength(1)
const row = res.rows[0]
expect(row.id).toBe(seeded.deliveryId)
expect(row.channel).toBe('email')
expect(row.status).toBe('sent')
expect(row.provider).toBe('resend')
expect(row.provider_status).toBe('bounced')
expect(row.provider_status_at).not.toBeNull()
expect(row.attachment_filename).toBe('faktura-1042.pdf')
expect(row.sent_at).not.toBeNull()
expect(row.failed_at).toBeNull()
// Recipients masked to ***@domain, in order.
expect(row.to_addresses).toEqual(['***@example.com'])
expect(row.cc_addresses).toEqual(['***@example.org'])
// Provider reason keeps the diagnostic but masks the address local part.
expect(row.provider_status_detail).toBe(
'smtp; 550 5.1.1 ***@example.com recipient rejected',
)
// Exactly the sibling's masked shape: the payload and BCC columns are
// absent by construction, not filtered.
expect(Object.keys(row).sort()).toEqual(
[
'id',
'channel',
'status',
'to_addresses',
'cc_addresses',
'provider',
'provider_status',
'provider_status_at',
'provider_status_detail',
'error_code',
'document_attachment_id',
'attachment_filename',
'sent_at',
'failed_at',
'created_at',
].sort(),
)
const serialized = JSON.stringify(res.rows)
expect(serialized).not.toContain('SECRET-BODY-TEXT')
expect(serialized).not.toContain('SECRET-BODY-HTML')
expect(serialized).not.toContain('dold.kopia')
expect(serialized).not.toContain('kund.betalare')
})
})
it('serves every company member including viewers, and excludes preparing rows', async () => {
const seeded = await seedSentDeliveryWithBounce()
// A crashed-render reservation: payload-free and must stay invisible.
await getPool().query(
`INSERT INTO public.invoice_deliveries
(company_id, user_id, invoice_id, channel, status)
VALUES ($1, $2, $3, 'email', 'preparing')`,
[seeded.companyId, seeded.userId, seeded.invoiceId],
)
const viewerId = await insertAuthUser()
await insertCompanyMember({
companyId: seeded.companyId,
userId: viewerId,
role: 'viewer',
})
await withServiceRoleContext(async (client) => {
const res = await client.query(FN, [seeded.companyId, viewerId, seeded.invoiceId])
expect(res.rows).toHaveLength(1)
expect(res.rows[0].id).toBe(seeded.deliveryId)
expect(res.rows[0].status).toBe('sent')
})
})
it("returns nothing for another company's invoice id", async () => {
const seeded = await seedSentDeliveryWithBounce()
const other = await seedSentDeliveryWithBounce()
await withServiceRoleContext(async (client) => {
// Member of company A probing with company A but company B's invoice:
// the company_id + invoice_id conjunction must yield nothing.
const res = await client.query(FN, [seeded.companyId, seeded.userId, other.invoiceId])
expect(res.rows).toHaveLength(0)
})
})
})
+169 -3
View File
@@ -5,6 +5,11 @@
* counterparties across name variants, mines booked verifikat for spend, and
* detects recurrence. Verifies variant-merging, occurrence/spend rollup,
* dominant-account + share, recurrence cadence, and supplier entities.
*
* Also covers the SEK honesty contract from migration 20260726150000: a
* foreign-currency row is summed and ranked at its SEK value when one can be
* established, and is excluded from the total and counted in
* `unconverted_fx_count` when it cannot. SEK rows must be unaffected.
*/
import { describe, it, expect, beforeAll } from 'vitest'
import { randomUUID } from 'node:crypto'
@@ -34,6 +39,12 @@ async function bookMerchant(params: {
expenseAccount: string
amount: number
voucherNumber: number
/** Transaction currency. Omitted = SEK, the shape every pre-FX case uses. */
currency?: string
/** transactions.amount_sek: the value converted at ingest. Null = none stored. */
amountSek?: number | null
/** transactions.exchange_rate: the rate recorded on the row. Null = none. */
exchangeRate?: number | null
}): Promise<void> {
const entryId = await insertDraftJournalEntry({
userId: params.userId,
@@ -50,13 +61,57 @@ async function bookMerchant(params: {
])
await getPool().query(
`INSERT INTO public.transactions
(id, company_id, user_id, currency, amount, date, description, journal_entry_id, merchant_name, category)
VALUES ($1,$2,$3,'SEK',$4,$5,$6,$7,$8,'expense_software')`,
[randomUUID(), params.companyId, params.userId, -params.amount, params.date,
(id, company_id, user_id, currency, amount, amount_sek, exchange_rate,
date, description, journal_entry_id, merchant_name, category)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'expense_software')`,
[randomUUID(), params.companyId, params.userId, params.currency ?? 'SEK',
-params.amount, params.amountSek ?? null, params.exchangeRate ?? null, params.date,
`Payment ${params.merchantName}`, entryId, params.merchantName],
)
}
/**
* A supplier with exactly one invoice, in whatever currency shape the test
* needs. `totalSek`/`exchangeRate` null is the shape a foreign invoice gets
* when the caller omitted the rate: that is the case the RPC must not count as
* kronor.
*/
async function bookSupplierInvoice(params: {
userId: string
companyId: string
supplierName: string
date: string
account: string
total: number
currency?: string
totalSek?: number | null
exchangeRate?: number | null
arrivalNumber: number
}): Promise<void> {
const supplierId = randomUUID()
await getPool().query(
`INSERT INTO public.suppliers (id, user_id, company_id, name) VALUES ($1,$2,$3,$4)`,
[supplierId, params.userId, params.companyId, params.supplierName],
)
const invId = 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,status,vat_treatment,is_credit_note,
currency,subtotal,vat_amount,total,total_sek,exchange_rate)
VALUES ($1,$2,$3,$4,$5,$6,$7,$7,'registered','standard_25',false,
$8,$9,0,$9,$10,$11)`,
[invId, params.userId, params.companyId, supplierId, params.arrivalNumber,
`SI-${params.arrivalNumber}`, params.date, params.currency ?? 'SEK',
params.total, params.totalSek ?? null, params.exchangeRate ?? null],
)
await getPool().query(
`INSERT INTO public.supplier_invoice_items (supplier_invoice_id,description,quantity,unit_price,line_total,account_number,vat_rate,vat_amount)
VALUES ($1,'Line',1,$2,$2,$3,0,0)`,
[invId, params.total, params.account],
)
}
type DeepEntity = {
name: string
key: string
@@ -64,6 +119,7 @@ type DeepEntity = {
variant_count: number
occurrences: number
total_amount: number
unconverted_fx_count: number
first_seen: string
last_seen: string
cadence_days: number | null
@@ -121,6 +177,49 @@ describe('get_ledger_deep_context', () => {
[invId],
)
}
// --- FX cohort (migration 20260726150000) -------------------------------
// Four single-booking counterparties chosen so that reading the raw amount
// produces a DIFFERENT ranking than reading the SEK value, because
// total_amount is the sort key:
// SEK value EUROSTORE 1250, DOLLARDEPOT 1150, KRONKIOSK 800, YENYARD 0
// pre-fix EUROSTORE 1250, KRONKIOSK 800, YENYARD 500, DOLLARDEPOT 100
//
// EUR converted at ingest: amount_sek is what the row states.
await bookMerchant({
userId, companyId, fiscalPeriodId, merchantName: 'EUROSTORE', date: '2026-06-10',
expenseAccount: '5410', amount: 100, voucherNumber: 5,
currency: 'EUR', amountSek: -1250,
})
// EUR with no amount_sek but the rate on the row: deterministic arithmetic.
await bookMerchant({
userId, companyId, fiscalPeriodId, merchantName: 'DOLLARDEPOT', date: '2026-06-11',
expenseAccount: '5411', amount: 100, voucherNumber: 6,
currency: 'USD', exchangeRate: 11.5,
})
// Plain SEK, sitting between the two FX values so the ranking is decisive.
await bookMerchant({
userId, companyId, fiscalPeriodId, merchantName: 'KRONKIOSK', date: '2026-06-12',
expenseAccount: '5412', amount: 800, voucherNumber: 7,
})
// EUR with neither: no SEK value exists. Must be excluded and counted, and
// must NOT enter the total as the number 500.
await bookMerchant({
userId, companyId, fiscalPeriodId, merchantName: 'YENYARD', date: '2026-06-13',
expenseAccount: '5413', amount: 500, voucherNumber: 8,
currency: 'EUR',
})
// AP side, the worse of the two: total_sek is NULL on every foreign invoice
// registered without an exchange_rate, so this is live data, not legacy.
await bookSupplierInvoice({
userId, companyId, supplierName: 'Rated Foreign AB', date: '2026-06-10',
account: '6230', total: 100, currency: 'EUR', exchangeRate: 11.5, arrivalNumber: 10,
})
await bookSupplierInvoice({
userId, companyId, supplierName: 'Unrated Foreign AB', date: '2026-06-11',
account: '6231', total: 500, currency: 'EUR', arrivalNumber: 11,
})
})
it('merges counterparty name variants into one entity with spend + cadence', async () => {
@@ -136,6 +235,9 @@ describe('get_ledger_deep_context', () => {
expect(klarna!.dominant_account_count).toBe(3)
expect(klarna!.dominant_account_total).toBe(3)
expect(klarna!.total_amount).toBe(300)
// SEK rows: amount_sek is NULL by design, the coalesce still resolves to
// `amount`, and nothing is withheld.
expect(klarna!.unconverted_fx_count).toBe(0)
expect(klarna!.first_seen).toBe('2026-04-01')
expect(klarna!.last_seen).toBe('2026-06-01')
// Monthly cadence: gaps of 30 and 31 days -> median ~30.
@@ -163,6 +265,7 @@ describe('get_ledger_deep_context', () => {
expect(telia).toBeDefined()
expect(telia!.occurrences).toBe(2)
expect(telia!.total_amount).toBe(1000)
expect(telia!.unconverted_fx_count).toBe(0)
expect(telia!.dominant_account_number).toBe('6212')
// Laplace-smoothed (2+1)/(2+2).
expect(telia!.dominant_account_share).toBe(0.75)
@@ -172,6 +275,69 @@ describe('get_ledger_deep_context', () => {
expect(telia!.cadence_days).toBeGreaterThanOrEqual(30)
})
it('converts a foreign booking at the SEK value stored on the row', async () => {
const deep = await callRpc(companyId, null)
const e = deep.counterparty_entities.find((x) => x.name === 'EUROSTORE')
expect(e).toBeDefined()
// 100 EUR, amount_sek = -1250. The magnitude is 1250 kr, not 100.
expect(e!.total_amount).toBe(1250)
expect(e!.unconverted_fx_count).toBe(0)
expect(e!.occurrences).toBe(1)
})
it('converts a foreign booking through the exchange_rate on the row', async () => {
const deep = await callRpc(companyId, null)
const e = deep.counterparty_entities.find((x) => x.name === 'DOLLARDEPOT')
expect(e).toBeDefined()
// 100 USD at 11.5, no amount_sek stored: 1150 kr, not 100.
expect(e!.total_amount).toBe(1150)
expect(e!.unconverted_fx_count).toBe(0)
})
it('excludes a foreign booking with no establishable SEK value and counts it', async () => {
const deep = await callRpc(companyId, null)
const e = deep.counterparty_entities.find((x) => x.name === 'YENYARD')
expect(e).toBeDefined()
// 500 EUR with neither amount_sek nor exchange_rate. The old expression
// returned the scalar 500 and called it kronor.
expect(e!.total_amount).toBe(0)
expect(e!.unconverted_fx_count).toBe(1)
// Not silently dropped: the booking, its cadence basis and the account it
// went to are currency-free facts and survive.
expect(e!.occurrences).toBe(1)
expect(e!.dominant_account_number).toBe('5413')
})
it('ranks single-booking counterparties by SEK magnitude, not raw amount', async () => {
const deep = await callRpc(companyId, null)
const order = deep.counterparty_entities
.filter((e) => e.occurrences === 1)
.map((e) => e.name)
// SEK magnitudes 1250 > 1150 > 800 > 50 > 0. On the raw numbers the order
// would have been 800 (KRONKIOSK), 500 (YENYARD), 100, 100, 50.
expect(order).toEqual(['EUROSTORE', 'DOLLARDEPOT', 'KRONKIOSK', 'SL', 'YENYARD'])
})
it('applies the same rule to supplier invoices, where total_sek is usually NULL', async () => {
const deep = await callRpc(companyId, null)
const rated = deep.supplier_entities.find((e) => e.name === 'Rated Foreign AB')
const unrated = deep.supplier_entities.find((e) => e.name === 'Unrated Foreign AB')
// 100 EUR at 11.5 with total_sek NULL: converted, not read as 100 kr.
expect(rated!.total_amount).toBe(1150)
expect(rated!.unconverted_fx_count).toBe(0)
// 500 EUR with no rate at all: withheld and counted, never 500 kr.
expect(unrated!.total_amount).toBe(0)
expect(unrated!.unconverted_fx_count).toBe(1)
expect(unrated!.occurrences).toBe(1)
expect(unrated!.dominant_account_number).toBe('6231')
// The ranking flip: on the raw totals 500 outranked 100.
const singles = deep.supplier_entities.filter((e) => e.occurrences === 1).map((e) => e.name)
expect(singles).toEqual(['Rated Foreign AB', 'Unrated Foreign AB'])
})
it('respects the from_date bound', async () => {
const deep = await callRpc(companyId, '2026-05-15')
const klarna = deep.counterparty_entities.find((e) => e.key === 'klarna')
@@ -0,0 +1,65 @@
import { beforeAll, describe, expect, it } from 'vitest'
import { getPool } from './setup'
import { seedCompany } from './fixtures'
/**
* Constraint coverage for migration 20260727110000: the re-created
* pending_operations_operation_type_check must accept the two new
* recurring-schedule op types (and must not have silently dropped the
* concurrently-added update_invoice value: the constraint is re-created
* wholesale from a full value list, so an omission there revokes the type).
*
* The broader drift guard lives in pending-operations-op-type-audit.pg.test.ts
* (it derives op types from staging call sites + risk tiers); this file pins
* the specific values so a failure names the migration directly.
*/
const NEW_OP_TYPES = [
'create_recurring_schedule',
'update_recurring_schedule',
// Added by 20260727090000, re-listed by 20260727110000: must survive both.
'update_invoice',
] as const
describe('pending_operations operation_type CHECK: recurring schedules', () => {
let userId: string
let companyId: string
beforeAll(async () => {
const seeded = await seedCompany()
userId = seeded.userId
companyId = seeded.companyId
})
it.each(NEW_OP_TYPES)('accepts %s', async (opType) => {
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(
`INSERT INTO public.pending_operations (user_id, company_id, operation_type, title)
VALUES ($1, $2, $3, $4)`,
[userId, companyId, opType, `constraint check: ${opType}`],
)
} finally {
await client.query('ROLLBACK')
client.release()
}
})
it('still rejects an unknown operation type', async () => {
const client = await getPool().connect()
try {
await client.query('BEGIN')
await expect(
client.query(
`INSERT INTO public.pending_operations (user_id, company_id, operation_type, title)
VALUES ($1, $2, 'run_recurring_schedule_now', 'must not exist: deliberately deferred')`,
[userId, companyId],
),
).rejects.toThrow(/pending_operations_operation_type_check/)
} finally {
await client.query('ROLLBACK')
client.release()
}
})
})
@@ -0,0 +1,37 @@
import { beforeAll, describe, it } from 'vitest'
import { getPool } from './setup'
import { seedCompany } from './fixtures'
/**
* The pending_operations_operation_type_check constraint must accept the
* 'update_invoice' op type added by migration 20260727090000 (validated in
* 20260727090001). Without the constraint expansion, gnubok_update_invoice's
* staging INSERT fails with check_violation on every real call while dry_run
* (which skips the INSERT) previews clean: the exact bug class the op-type
* audit test documents for gnubok_link_document_to_voucher.
*/
describe('pending_operations operation_type CHECK: update_invoice', () => {
let userId: string
let companyId: string
beforeAll(async () => {
const seeded = await seedCompany()
userId = seeded.userId
companyId = seeded.companyId
})
it('accepts update_invoice (constraint expanded in 20260727090000)', async () => {
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(
`INSERT INTO public.pending_operations (user_id, company_id, operation_type, title)
VALUES ($1, $2, 'update_invoice', 'regression: uppdatera fakturautkast')`,
[userId, companyId],
)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
})
+54
View File
@@ -64,6 +64,60 @@ export async function withUserContext<T>(
}
}
// Run `fn` with service-role claims and the service_role DB role: the same
// presentation PostgREST gives a createServiceClient() call (auth.uid() NULL,
// auth.role() = 'service_role'). Unlike withUserContext this COMMITS on
// success: the SIE bulk-delete suites assert persisted effects over the plain
// pool afterwards. SET LOCAL scopes the claims and role to the transaction,
// so the pooled connection is clean again after COMMIT/ROLLBACK either way.
//
// BOTH claim GUC styles are set, for the same reason withUserContext sets
// both `request.jwt.claims` and `request.jwt.claim.sub`: the CI image
// (supabase/postgres:15.8.1.060) ships the LEGACY auth shim, where
// auth.role() reads ONLY `request.jwt.claim.role` (init-scripts/
// 00000000000001-auth-schema.sql; no in-image migration redefines it), while
// hosted Supabase runs the newer coalesce definition that also reads the
// `request.jwt.claims` JSON. Setting only the JSON works against hosted-style
// shims and silently leaves auth.role() NULL in CI, which is exactly the
// failure mode that broke the service-actor suites. Functions that parse
// `request.jwt.claims` themselves (the gl-lines guard shape) get the JSON.
export async function runAsServiceRole<T>(
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await getClient()
try {
await client.query('BEGIN')
await client.query(
`SELECT set_config('request.jwt.claims', '{"role":"service_role"}', true)`,
)
await client.query(
`SELECT set_config('request.jwt.claim.role', 'service_role', true)`,
)
await client.query('SET LOCAL ROLE service_role')
// Fail loudly if the simulation did not land: otherwise every
// service-gated RPC raises 42501 and the real test failure points at an
// unrelated assertion (same rationale as the withUserContext check).
const check = await client.query<{ role: string | null; uid: string | null }>(
`SELECT auth.role()::text AS role, auth.uid()::text AS uid`,
)
if (check.rows[0]?.role !== 'service_role' || check.rows[0]?.uid !== null) {
throw new Error(
`runAsServiceRole: auth.role() resolved to ${check.rows[0]?.role ?? 'NULL'} ` +
`and auth.uid() to ${check.rows[0]?.uid ?? 'NULL'}; expected service_role/NULL. ` +
'Check which GUCs this harness auth shim reads.',
)
}
const result = await fn(client)
await client.query('COMMIT')
return result
} catch (err) {
await client.query('ROLLBACK').catch(() => {})
throw err
} finally {
client.release()
}
}
// Schema sanity: fail loud if migrations did not apply, rather than letting
// every test fail with a cryptic "relation does not exist". Runs once per
// test file (vitest invokes setupFiles per worker).
+491
View File
@@ -0,0 +1,491 @@
import { describe, it, expect, beforeAll } from 'vitest'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
MASTER_DATA_DUMP_TABLES,
ARCHIVE_COVERED_ELSEWHERE_TABLES,
ARCHIVE_EXCLUDED_TABLES,
} from '@/lib/reports/full-archive-export'
import {
buildSchemaFromMigrations,
closedValueSets,
listSourceFiles,
scanColumnRefs,
scanSourceText,
type ScanResult,
type SchemaModel,
type TableModel,
} from './schema-guard'
/**
* The phantom-column net.
*
* `createQueuedMockSupabase()` is chainable and permissive: `.eq('nope', 1)` and
* `.select('does_not_exist')` resolve happily, so a green unit test says nothing
* about whether a column exists. That blind spot kept article delete broken for
* ten days, hid sixteen more phantom-column sites, two phantom CHECK values
* (`source_type = 'transaction'` against a real `bank_transaction`: 0 rows where
* prod has 3 917) and one `onConflict` naming a dropped unique constraint, which
* raised 42P10 on every call. Mocked tests provably cannot catch that class.
*
* This test compares two things that cannot lie to each other: the schema
* replayed from `supabase/migrations/*.sql`, and every Supabase query builder
* chain in the source, read through the TypeScript AST. It follows the shape of
* `tests/pg/full-archive-coverage.pg.test.ts`, which likewise reads the schema
* and asserts a contract against checked-in code.
*
* GROUND TRUTH: the migration files. Not a live database (a pg-real test would
* be authoritative but only runs in the `test-pg-real` CI job, so a developer's
* `npm test` would never see it), and not a checked-in snapshot (which rots).
* Replaying the migrations costs ~250ms, runs everywhere, and has no artifact to
* go stale: the migrations are already the repo's contract with prod. No test in
* this file connects to any database.
*
* FAILURE MODE: named columns that resolve confidently are asserted hard, minus
* an explicit baseline of pre-existing breakage below. Expressions the scanner
* cannot resolve (a payload built at runtime, an interpolated select) are counted
* against a documented ceiling instead of failing, because failing on every
* unresolvable expression would make the guard noise and get it disabled.
*/
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
/**
* Pre-existing phantom-column sites, found when this guard was introduced and
* deliberately NOT fixed here (the fix belongs with the owner of each surface).
* Keyed `table.column @ file` so a line shift does not churn but a NEW file
* adopting the same phantom column still fails.
*
* Remove an entry when the site is fixed: a stale entry fails this test on
* purpose, so the list can only shrink.
*/
const KNOWN_PHANTOM_COLUMNS: Record<string, string> = {}
/** Pre-existing `.from()` targets that no migration creates. */
const KNOWN_PHANTOM_TABLES: Record<string, string> = {}
/** Pre-existing literal values that no CHECK constraint allows. */
const KNOWN_PHANTOM_VALUES: Record<string, string> = {}
/** Pre-existing `onConflict` targets with no matching unique constraint. */
const KNOWN_STALE_ON_CONFLICT: Record<string, string> = {}
/**
* Ceiling on expressions the scanner cannot resolve to a concrete column.
* These are legitimate patterns (a payload assembled at runtime, an interpolated
* select, a spread), not defects: the ceiling exists so a refactor that hides a
* large slice of the query surface behind dynamic construction is visible rather
* than silent. Headroom is deliberate; lower it when the number drops.
*
* Baseline 2026-07-26: 346 (145 dynamic-payload, 116 dynamic-select,
* 48 dynamic-logical, 32 spread-payload, 4 dynamic-column, 1 computed key).
*/
const UNRESOLVED_CEILING = 360
/**
* Floor on statically resolved column references. Guards the guard: if a change
* to the scanner or to the client wrappers stops resolving chains, this fails
* instead of the net silently going slack.
*
* Baseline 2026-07-26: 13 734.
*/
const RESOLVED_COLUMN_FLOOR = 13_500
let schema: SchemaModel
let scan: ScanResult
const valueSets = new Map<string, Map<string, Set<string>>>()
function closedFor(table: TableModel): Map<string, Set<string>> {
let sets = valueSets.get(table.name)
if (!sets) {
sets = closedValueSets(table)
valueSets.set(table.name, sets)
}
return sets
}
// Explicit hook timeout: this replays every migration AND parses the whole
// app/lib/components/extensions/hooks/scripts tree through the TypeScript
// AST. It runs in ~3s on its own, but vitest's 10s default hook timeout is
// not enough headroom when the file is scheduled alongside the rest of the
// suite and CPU is contended (observed failing exactly that way, then passing
// on re-run). A flaky guard gets disabled, so give it room rather than let it
// half-run.
beforeAll(() => {
schema = buildSchemaFromMigrations(path.join(ROOT, 'supabase', 'migrations'))
scan = scanColumnRefs(
listSourceFiles([
path.join(ROOT, 'app'),
path.join(ROOT, 'lib'),
path.join(ROOT, 'components'),
path.join(ROOT, 'extensions'),
path.join(ROOT, 'hooks'),
path.join(ROOT, 'scripts'),
]),
schema,
ROOT
)
}, 120_000)
/** Split findings into new ones (fail) and baselined ones (tracked). */
function against(
findings: { key: string; where: string }[],
baseline: Record<string, string>
): { unexpected: string[]; stale: string[] } {
const seen = new Set(findings.map((f) => f.key))
return {
unexpected: [
...new Set(findings.filter((f) => !(f.key in baseline)).map((f) => `${f.key} (${f.where})`)),
].sort(),
stale: Object.keys(baseline).filter((k) => !seen.has(k)).sort(),
}
}
describe('schema replay (parser fidelity)', () => {
// If the replay is wrong the whole guard is wrong, in either direction: a
// missed column invents accusations, an invented column hides real ones. These
// anchors are facts established independently of the parser.
it('models the tables that deliberately have no company_id', () => {
const withoutCompanyId = [
'invoice_items',
'journal_entry_lines',
'supplier_invoice_items',
'recurring_invoice_schedule_items',
'rot_rut_payout_request_items',
'agent_messages',
]
for (const name of withoutCompanyId) {
const table = schema.tables.get(name)
expect(table, `${name} missing from the replayed schema`).toBeDefined()
expect(table!.columns.has('company_id'), `${name} should have no company_id`).toBe(false)
expect(table!.columns.size, `${name} parsed with no columns`).toBeGreaterThan(3)
}
})
it('models company_id on the company-scoped tables, including the ones added by dynamic DDL', () => {
// chart_of_accounts / api_keys / audit_log got company_id from an
// `EXECUTE format('ALTER TABLE %I ADD COLUMN ...')` loop in the multi-tenant
// refactor: if the replay misses that, the guard accuses hundreds of correct
// call sites.
const withCompanyId = [
'company_members',
'salary_line_items',
'transaction_voucher_links',
'invoice_deliveries',
'chart_of_accounts',
'api_keys',
'audit_log',
'journal_entries',
]
for (const name of withCompanyId) {
const table = schema.tables.get(name)
expect(table, `${name} missing from the replayed schema`).toBeDefined()
expect(table!.columns.has('company_id'), `${name} should have company_id`).toBe(true)
}
})
it('models a column renamed by DDL inside a DO block', () => {
// 20260515170000_webhooks_v2 renames automation_webhooks -> webhooks inside
// `DO $$ IF ... THEN ... END $$`.
expect(schema.tables.has('webhooks')).toBe(true)
expect(schema.tables.has('automation_webhooks')).toBe(false)
expect(schema.tables.get('supplier_invoice_payments')!.columns.has('user_id')).toBe(true)
})
it('agrees with the full-archive contract, which is validated against real Postgres', () => {
// Independent anchor. tests/pg/full-archive-coverage.pg.test.ts asserts this
// same contract against information_schema on a live database, so agreement
// here means the migration replay reproduces prod's company_id topology for
// 58 dump tables plus every classified table. A drift means the replay is
// wrong, not the contract.
const direct = MASTER_DATA_DUMP_TABLES.filter((t) => !t.via)
expect(
direct.filter((t) => !schema.tables.get(t.name)?.columns.has('company_id')).map((t) => t.name),
'direct-dump tables the replay thinks have no company_id'
).toEqual([])
expect(
MASTER_DATA_DUMP_TABLES.filter((t) => t.via && schema.tables.get(t.name)?.columns.has('company_id')).map(
(t) => t.name
),
'via-tables the replay thinks DO have company_id'
).toEqual([])
expect(
MASTER_DATA_DUMP_TABLES.filter((t) => !schema.tables.get(t.name)?.columns.has(t.pageKey ?? 'id')).map(
(t) => `${t.name}.${t.pageKey ?? 'id'}`
),
'page keys the replay cannot find'
).toEqual([])
const classified = new Set([
...MASTER_DATA_DUMP_TABLES.map((t) => t.name),
...Object.keys(ARCHIVE_COVERED_ELSEWHERE_TABLES),
...Object.keys(ARCHIVE_EXCLUDED_TABLES),
])
expect(
[...classified].filter((name) => !schema.tables.has(name)).sort(),
'tables the archive contract classifies but the replay never created'
).toEqual([])
const unclassified = [...schema.tables.values()]
.filter((t) => t.columns.has('company_id') && !classified.has(t.name))
.map((t) => t.name)
.sort()
expect(
unclassified,
'company-scoped tables in the replay that the archive contract does not classify. ' +
'Either the replay invented a company_id column, or a new table needs triaging in ' +
'lib/reports/full-archive-export.ts.'
).toEqual([])
})
it('tracks the current source_type CHECK across every widening migration', () => {
const sets = closedFor(schema.tables.get('journal_entries')!)
const sourceType = sets.get('source_type')
expect(sourceType, 'journal_entries.source_type should be a closed value set').toBeDefined()
expect(sourceType!.has('bank_transaction')).toBe(true)
expect(sourceType!.has('stripe_payout')).toBe(true)
// The phantom value that read 0 rows where prod had 3 917.
expect(sourceType!.has('transaction')).toBe(false)
})
})
describe('no phantom columns in Supabase queries', () => {
it('names only columns that exist on the table being queried', () => {
const findings = scan.columnRefs
.filter((ref) => {
const table = schema.tables.get(ref.table)
return table !== undefined && !table.columns.has(ref.column)
})
.map((ref) => ({
key: `${ref.table}.${ref.column} @ ${ref.file}`,
where: `${ref.kind}, line ${ref.line}`,
}))
const { unexpected, stale } = against(findings, KNOWN_PHANTOM_COLUMNS)
expect(
unexpected,
'Query references a column that no migration creates. PostgREST answers 42703 and ' +
'the whole select/filter fails at runtime, however green the mocked test is:\n ' +
unexpected.join('\n ')
).toEqual([])
expect(
stale,
'These phantom columns are fixed: delete them from KNOWN_PHANTOM_COLUMNS so the ' +
'baseline keeps shrinking:\n ' + stale.join('\n ')
).toEqual([])
})
it('reads only tables that exist', () => {
const findings = scan.tableRefs
.filter((ref) => !schema.tables.has(ref.table) && !schema.views.has(ref.table))
.map((ref) => ({ key: `${ref.table} @ ${ref.file}`, where: `line ${ref.line}` }))
const { unexpected, stale } = against(findings, KNOWN_PHANTOM_TABLES)
expect(
unexpected,
'Query targets a table that no migration creates (dropped, renamed, or never shipped):\n ' +
unexpected.join('\n ')
).toEqual([])
expect(
stale,
'These phantom tables are fixed: delete them from KNOWN_PHANTOM_TABLES:\n ' +
stale.join('\n ')
).toEqual([])
})
it('filters and writes only values a CHECK constraint allows', () => {
const findings = scan.valueRefs
.filter((ref) => {
const table = schema.tables.get(ref.table)
if (!table) return false
const allowed = closedFor(table).get(ref.column)
return allowed !== undefined && !allowed.has(ref.value)
})
.map((ref) => ({
key: `${ref.table}.${ref.column} = '${ref.value}' @ ${ref.file}`,
where: `line ${ref.line}`,
}))
const { unexpected, stale } = against(findings, KNOWN_PHANTOM_VALUES)
expect(
unexpected,
'Literal value is not a member of the column CHECK constraint. A filter on it ' +
'silently matches nothing; a write on it raises 23514:\n ' + unexpected.join('\n ')
).toEqual([])
expect(
stale,
'These phantom values are fixed: delete them from KNOWN_PHANTOM_VALUES:\n ' +
stale.join('\n ')
).toEqual([])
})
it('upserts onto a real unique constraint', () => {
const findings = scan.conflictRefs
.filter((ref) => {
const table = schema.tables.get(ref.table)
// A table with no unique set at all means the replay did not find one:
// stay silent rather than accuse.
if (!table || table.uniqueSets.size === 0) return false
return !table.uniqueSets.has([...ref.columns].sort().join(','))
})
.map((ref) => ({
key: `${ref.table} [${ref.columns.join(',')}] @ ${ref.file}`,
where: `line ${ref.line}`,
}))
const { unexpected, stale } = against(findings, KNOWN_STALE_ON_CONFLICT)
expect(
unexpected,
'upsert onConflict names columns with no matching unique constraint or index. ' +
'Postgres raises 42P10 on every call:\n ' + unexpected.join('\n ')
).toEqual([])
expect(
stale,
'These onConflict targets are fixed: delete them from KNOWN_STALE_ON_CONFLICT:\n ' +
stale.join('\n ')
).toEqual([])
})
})
describe('scanner behaviour (the net catches, and does not over-catch)', () => {
const phantoms = (code: string): string[] =>
scanSourceText(code, 'probe.ts', schema)
.columnRefs.filter((ref) => {
const table = schema.tables.get(ref.table)
return table !== undefined && !table.columns.has(ref.column)
})
.map((ref) => `${ref.table}.${ref.column}`)
it('catches a phantom column in a plain filter', () => {
expect(phantoms(`supabase.from('invoice_items').select('id').eq('company_id', c)`)).toEqual([
'invoice_items.company_id',
])
})
it('catches a phantom column inside a backtick template select', () => {
// Blind spot 1: the sweep's own scanner only read single-quoted selects.
const code = [
'const q = supabase',
" .from('invoices')",
' .select(`',
' id,',
' total_amount,',
' customer:customers(name)',
' `)',
].join('\n')
expect(phantoms(code)).toEqual(['invoices.total_amount'])
})
it('catches a phantom column added to a builder after assignment', () => {
// Blind spot 2: `let q = supabase.from(...)` then `q = q.eq(...)`.
const code = [
"let q = supabase.from('journal_entry_lines').select('id')",
"if (x) q = q.eq('company_id', c)",
"q = q.order('description', { ascending: true })",
].join('\n')
expect(phantoms(code).sort()).toEqual([
'journal_entry_lines.company_id',
'journal_entry_lines.description',
])
})
it('catches a phantom column in a dotted embedded filter', () => {
const code = [
"supabase.from('journal_entry_lines')",
" .select('id, journal_entries!inner(id)')",
" .eq('journal_entries.source_type', 'x')",
" .eq('journal_entries.nope', 'y')",
].join('\n')
expect(phantoms(code)).toEqual(['journal_entries.nope'])
})
it('does not accuse embedded resource names, aliases or casts', () => {
const code = [
"supabase.from('invoices')",
" .select('id, items:invoice_items(id, description), customer:customers!invoices_customer_id_fkey(name), total::text, default_dimensions->>project')",
" .order('name', { referencedTable: 'customers' })",
].join('\n')
expect(phantoms(code)).toEqual([])
})
it('resolves an embed named by its foreign key column', () => {
// `company:company_id(...)` embeds `companies` through the FK column.
const code = [
"supabase.from('company_members')",
" .select('role, company:company_id(id, name, nope)')",
].join('\n')
expect(phantoms(code)).toEqual(['companies.nope'])
})
it('never treats rpc arguments as columns', () => {
const code = "supabase.rpc('commit_journal_entry', { p_entry_id: id, not_a_column: 1 })"
const result = scanSourceText(code, 'probe.ts', schema)
expect(result.columnRefs).toEqual([])
expect(result.tableRefs).toEqual([])
})
it('never treats a storage bucket or Buffer.from as a table', () => {
const code = [
"supabase.storage.from('documents').upload(p, f)",
"const b = Buffer.from('abc')",
].join('\n')
expect(scanSourceText(code, 'probe.ts', schema).tableRefs).toEqual([])
})
it('reports a runtime-built payload as unresolved rather than guessing', () => {
const code = "supabase.from('invoices').insert(rows)"
const result = scanSourceText(code, 'probe.ts', schema)
expect(result.columnRefs).toEqual([])
expect(result.unresolved.map((u) => u.reason)).toEqual(['dynamic-payload'])
})
it('does not attribute a builder variable reused for two tables in one scope', () => {
const code = [
'function f() {',
" let q = supabase.from('invoices').select('id')",
" q = supabase.from('customers').select('id')",
" q = q.eq('nope_at_all', 1)",
'}',
].join('\n')
expect(phantoms(code)).toEqual([])
expect(
scanSourceText(code, 'probe.ts', schema).unresolved.map((u) => u.reason)
).toContain('unknown-builder-table')
})
it('catches a phantom value on a CHECK-constrained column', () => {
const code = "supabase.from('journal_entries').select('id').eq('source_type', 'transaction')"
const bad = scanSourceText(code, 'probe.ts', schema).valueRefs.filter((ref) => {
const allowed = closedFor(schema.tables.get(ref.table)!).get(ref.column)
return allowed !== undefined && !allowed.has(ref.value)
})
expect(bad.map((b) => `${b.column}=${b.value}`)).toEqual(['source_type=transaction'])
})
})
describe('scanner coverage', () => {
it('keeps the resolvable query surface large enough for the net to matter', () => {
expect(
scan.columnRefs.length,
`Only ${scan.columnRefs.length} column references resolved (floor ${RESOLVED_COLUMN_FLOOR}). ` +
'Either the scanner stopped following builder chains or a large surface moved behind ' +
'dynamic construction. Fix the scanner rather than lowering the floor.'
).toBeGreaterThanOrEqual(RESOLVED_COLUMN_FLOOR)
})
it('holds the unresolvable-expression ceiling', () => {
const byReason = new Map<string, number>()
for (const item of scan.unresolved) {
byReason.set(item.reason, (byReason.get(item.reason) ?? 0) + 1)
}
const breakdown = [...byReason.entries()]
.sort((a, b) => b[1] - a[1])
.map(([reason, count]) => `${count} ${reason}`)
.join(', ')
expect(
scan.unresolved.length,
`${scan.unresolved.length} unresolvable query expressions (ceiling ${UNRESOLVED_CEILING}): ` +
`${breakdown}. Each one is a column this guard cannot check. Prefer an object literal ` +
'or a literal select string over runtime construction, or raise the ceiling with a reason.'
).toBeLessThanOrEqual(UNRESOLVED_CEILING)
})
})
File diff suppressed because it is too large Load Diff