test: add real-Postgres smoke gate (pg-real) (#357)

* test: add real-Postgres smoke gate (pg-real)

Mocked Supabase tests cannot exercise triggers, RPCs, or RLS policies —
a migration that drops enforce_period_lock, mangles user_company_ids(),
or weakens an RLS policy ships green today. Closes that gap with a
small Vitest project `pg-real` running 5 smoke tests against a real
supabase/postgres:15 container in CI.

Covers: closed-period INSERT rejection, commit_journal_entry voucher
atomicity under concurrency, posted-entry immutability, RLS tenant
isolation on journal_entries, and audit_log UPDATE/DELETE rejection.

Also lands the bankid anonymization migration that was sitting
untracked from a prior task.

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

* test(pg-real): fix storage schema bootstrap + de-scope + PR review fixes

- Drop bankid anonymization migration from this PR. That change is
  separate scope (and has open compliance questions flagged by the
  Swedish review bot on #357); it will land in its own PR.
- Add tests/pg/bootstrap.sql to align storage.buckets/objects/foldername
  with what migrations expect before the replay loop. The supabase/postgres
  image ships only a partial storage schema; the rest comes from the
  storage-api service at runtime, which CI does not run. First pg-real run
  failed at migration 24 on "column public of relation buckets does not exist".
- Add concurrency group to the workflow so stacked PR commits cancel
  in-progress runs instead of queueing.
- Gate the pg-real vitest project on DATABASE_URL so a bare `vitest run`
  with no DB configured runs only the unit project. npm run test:pg is
  the opt-in entry point.

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

* test(pg-real): widen JWT claim setup so auth.uid() resolves under RLS

The rls.pg test came back with 0 rows instead of 1 — user_company_ids()
returned empty because auth.uid() didn't resolve to the seeded user.
Two fixes:
- Set both request.jwt.claims (whole object) and request.jwt.claim.sub
  (individual claim). Different Supabase auth.uid() versions read one or
  the other.
- Assert auth.uid() = expected userId immediately after the context
  switch, so the next failure points at the right layer instead of an
  unrelated empty-result assertion.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-24 11:24:45 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 0222e084bb
commit ab63da8324
11 changed files with 737 additions and 6 deletions
+48
View File
@@ -0,0 +1,48 @@
-- pg-real CI bootstrap.
--
-- The Supabase Postgres image ships a partial `storage` schema; the remaining
-- columns and functions are provisioned at runtime by the storage-api
-- service, which we do not run in CI. This bootstrap aligns the schema with
-- what our migrations expect so the replay loop succeeds. It is idempotent
-- and safe to run against a freshly-initialised container.
CREATE SCHEMA IF NOT EXISTS storage;
CREATE TABLE IF NOT EXISTS storage.buckets (
id text PRIMARY KEY,
name text NOT NULL,
owner uuid,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
ALTER TABLE storage.buckets
ADD COLUMN IF NOT EXISTS public boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS file_size_limit bigint,
ADD COLUMN IF NOT EXISTS allowed_mime_types text[];
CREATE TABLE IF NOT EXISTS storage.objects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
bucket_id text REFERENCES storage.buckets(id) ON DELETE CASCADE,
name text,
owner uuid,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
last_accessed_at timestamptz DEFAULT now(),
metadata jsonb,
version text,
owner_id text
);
ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
-- storage.foldername(): splits a slash-delimited object name into segments.
-- Migrations use `(storage.foldername(name))[n]` to derive tenant scoping
-- from the object path.
CREATE OR REPLACE FUNCTION storage.foldername(name text)
RETURNS text[]
LANGUAGE sql
IMMUTABLE
AS $$
SELECT string_to_array(name, '/');
$$;
+139
View File
@@ -0,0 +1,139 @@
import { randomUUID } from 'node:crypto'
import { getPool } from './setup'
// Minimal fixture inserters for pg-real tests. All inserts go through the
// pool (superuser `postgres`), which bypasses RLS — that is intentional for
// seeding. RLS is exercised only where a test explicitly opens a user
// context via withUserContext().
export async function insertAuthUser(id: string = randomUUID()): Promise<string> {
// auth.users has many columns but most default. We only need `id` and a
// non-conflicting `email`. Everything else (role, aud, timestamps, etc.)
// has a default or is nullable in the supabase/postgres image.
await getPool().query(
`INSERT INTO auth.users (id, email, instance_id)
VALUES ($1, $2, '00000000-0000-0000-0000-000000000000'::uuid)`,
[id, `pg-real-${id}@test.invalid`],
)
return id
}
export async function insertCompany(params: {
createdBy: string
name?: string
entityType?: 'enskild_firma' | 'aktiebolag'
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.companies (id, name, entity_type, created_by)
VALUES ($1, $2, $3, $4)`,
[id, params.name ?? 'Test AB', params.entityType ?? 'aktiebolag', params.createdBy],
)
return id
}
export async function insertCompanyMember(params: {
companyId: string
userId: string
role?: 'owner' | 'admin' | 'member' | 'viewer'
}): Promise<void> {
await getPool().query(
`INSERT INTO public.company_members (company_id, user_id, role)
VALUES ($1, $2, $3)`,
[params.companyId, params.userId, params.role ?? 'owner'],
)
}
export async function insertFiscalPeriod(params: {
userId: string
companyId: string
isClosed?: boolean
periodStart?: string
periodEnd?: string
name?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.fiscal_periods
(id, user_id, company_id, name, period_start, period_end, is_closed, closed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
id,
params.userId,
params.companyId,
params.name ?? '2026',
params.periodStart ?? '2026-01-01',
params.periodEnd ?? '2026-12-31',
params.isClosed ?? false,
params.isClosed ? new Date() : null,
],
)
return id
}
// One-call helper: creates user + company + owner membership + open fiscal
// period. Returns the IDs tests need.
export async function seedCompany(overrides: { isClosed?: boolean } = {}): Promise<{
userId: string
companyId: string
fiscalPeriodId: string
}> {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({
userId,
companyId,
isClosed: overrides.isClosed,
})
return { userId, companyId, fiscalPeriodId }
}
// Insert a draft journal entry and return its id. Uses a placeholder
// voucher_number=0 which commit_journal_entry() will overwrite on commit.
export async function insertDraftJournalEntry(params: {
userId: string
companyId: string
fiscalPeriodId: string
entryDate?: string
description?: string
voucherSeries?: string
status?: 'draft' | 'posted'
voucherNumber?: number
}): Promise<string> {
const id = 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, $5, $6, $7, $8, 'manual', $9)`,
[
id,
params.userId,
params.companyId,
params.fiscalPeriodId,
params.voucherNumber ?? 0,
params.voucherSeries ?? 'A',
params.entryDate ?? '2026-06-01',
params.description ?? 'Test entry',
params.status ?? 'draft',
],
)
return id
}
// Insert a balanced pair of journal entry lines (1 debit row + 1 credit row
// at the given amount). Needed before commit_journal_entry() because the
// balance constraint trigger fires on draft→posted.
export async function insertBalancedLines(
journalEntryId: string,
amount: number = 1000,
): Promise<void> {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', $2, 0),
($1, '3001', 0, $2)`,
[journalEntryId, amount],
)
}
+116
View File
@@ -0,0 +1,116 @@
import { Pool, type PoolClient } from 'pg'
import { afterAll, beforeAll } from 'vitest'
// Shared pool for the pg-real project. DATABASE_URL must point at a Postgres
// instance that already has every migration from supabase/migrations/ applied
// and that includes the Supabase `auth` schema (supabase/postgres image).
const databaseUrl =
process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/postgres'
let pool: Pool | null = null
export function getPool(): Pool {
if (!pool) {
pool = new Pool({ connectionString: databaseUrl, max: 8 })
}
return pool
}
// Acquire a fresh client. Caller must always .release().
export async function getClient(): Promise<PoolClient> {
return getPool().connect()
}
// Run `fn` inside a role/JWT context that auth.uid() / user_company_ids() will
// observe. Uses SET LOCAL inside a transaction so the role reverts on commit
// or rollback. Always rolls back so the test's writes do not persist — tests
// that need to seed data must do that on the superuser connection first.
export async function withUserContext<T>(
userId: string,
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await getClient()
try {
await client.query('BEGIN')
// Set JWT claims BEFORE switching role: non-superuser set_config on a
// namespaced GUC is fine, but keeping order explicit avoids surprises.
// Set both the whole-claims object and the individual `sub` claim —
// different versions of Supabase's auth.uid() read one or the other.
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
JSON.stringify({ sub: userId, role: 'authenticated' }),
])
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
await client.query(`SET LOCAL ROLE authenticated`)
// Fail loudly and early if the JWT context did not land the way we
// expect — otherwise RLS policies return empty and the real test
// failure points at an unrelated assertion.
const authCheck = await client.query<{ uid: string | null }>(
`SELECT auth.uid()::text AS uid`,
)
if (authCheck.rows[0]?.uid !== userId) {
throw new Error(
`withUserContext: auth.uid() resolved to ${authCheck.rows[0]?.uid ?? 'NULL'}, ` +
`expected ${userId}. Check request.jwt.claims setup.`,
)
}
const result = await fn(client)
await client.query('ROLLBACK')
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).
beforeAll(async () => {
const client = await getClient()
try {
const result = await client.query<{ name: string; kind: 'trigger' | 'function' | 'table' }>(`
SELECT 'trigger'::text AS kind, tgname AS name FROM pg_trigger
WHERE tgname IN ('enforce_period_lock', 'audit_log_no_update')
UNION ALL
SELECT 'function'::text, proname FROM pg_proc
WHERE proname IN ('commit_journal_entry', 'user_company_ids', 'audit_log_immutable')
UNION ALL
SELECT 'table'::text, tablename FROM pg_tables
WHERE schemaname = 'public'
AND tablename IN ('journal_entries', 'companies', 'company_members',
'fiscal_periods', 'audit_log', 'voucher_sequences')
`)
const found = new Set(result.rows.map((r) => `${r.kind}:${r.name}`))
const required = [
'trigger:enforce_period_lock',
'trigger:audit_log_no_update',
'function:commit_journal_entry',
'function:user_company_ids',
'function:audit_log_immutable',
'table:journal_entries',
'table:companies',
'table:company_members',
'table:fiscal_periods',
'table:audit_log',
'table:voucher_sequences',
]
const missing = required.filter((r) => !found.has(r))
if (missing.length > 0) {
throw new Error(
`pg-real schema sanity check failed. Missing: ${missing.join(', ')}. ` +
`Did every migration in supabase/migrations/ apply cleanly to ${databaseUrl}?`,
)
}
} finally {
client.release()
}
})
afterAll(async () => {
if (pool) {
await pool.end()
pool = null
}
})