diff --git a/.github/workflows/test-pg-real.yml b/.github/workflows/test-pg-real.yml new file mode 100644 index 00000000..c54ce984 --- /dev/null +++ b/.github/workflows/test-pg-real.yml @@ -0,0 +1,66 @@ +name: pg-real tests + +on: [pull_request] + +concurrency: + group: pg-real-${{ github.ref }} + cancel-in-progress: true + +jobs: + pg-real: + runs-on: ubuntu-latest + + services: + postgres: + # Supabase image ships the auth schema, auth.uid(), and the extensions + # (uuid-ossp, pg_cron, btree_gist, vector) this repo's migrations need. + # Plain postgres:15 would require manual bootstrap SQL. + image: supabase/postgres:15.8.1.060 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres + PGPASSWORD: postgres + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + - name: Install psql client + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client + + - name: Bootstrap storage schema + # The supabase/postgres image ships a partial storage schema; the rest + # is provisioned by the storage-api service at runtime, which we do + # not run in CI. This aligns the schema with what migrations expect. + run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -f tests/pg/bootstrap.sql + + - name: Apply migrations + run: | + set -euo pipefail + shopt -s nullglob + files=(supabase/migrations/*.sql) + if [ ${#files[@]} -eq 0 ]; then + echo "No migration files found" + exit 1 + fi + for f in "${files[@]}"; do + echo "Applying $f" + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q -f "$f" + done + + - run: npm run test:pg diff --git a/CLAUDE.md b/CLAUDE.md index 76def15f..e6b1c483 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -355,6 +355,16 @@ export async function POST(request: Request) { **Patterns**: Always mock `@/lib/supabase/server`. Use `vi.clearAllMocks()` and `eventBus.clear()` in `beforeEach`. API route tests: mock `@/lib/init` and lib functions, test auth (401), validation (400), not found (404), errors (500), happy path. +### Testing database-level logic (pg-real) + +Mocked Supabase clients cannot exercise Postgres triggers, RPCs, or RLS policies. A parallel Vitest project `pg-real` runs against a real Postgres instance in CI (GitHub Actions `supabase/postgres:15` service container, migrations replayed from `supabase/migrations/` before the suite runs). Locally: `npm run test:pg` against a DATABASE_URL pointing at any Postgres with the Supabase `auth` schema and migrations applied. + +**File convention**: `*.pg.test.ts`. The `unit` project excludes this suffix; only `pg-real` picks it up. + +**Helpers**: `tests/pg/setup.ts` exposes `getPool()` and `withUserContext(userId, fn)` (sets `ROLE authenticated` + `request.jwt.claims` for RLS tests). `tests/pg/fixtures.ts` has `seedCompany()`, `insertDraftJournalEntry()`, `insertBalancedLines()`, etc. + +**When to add a pg-real test**: any PR that creates or modifies a trigger, RPC, RLS policy, or DEFERRABLE constraint must include or extend a `*.pg.test.ts` test covering the new behavior. Mock coverage is not sufficient — it will pass on a broken migration. The suite is intentionally small (compliance gate, not a rewrite of the mock suite); add only smoke-level tests for new DB-layer constructs. + --- ## Database & Migrations diff --git a/app/api/audit-trail/__tests__/audit-log.pg.test.ts b/app/api/audit-trail/__tests__/audit-log.pg.test.ts new file mode 100644 index 00000000..bc5d6e3f --- /dev/null +++ b/app/api/audit-trail/__tests__/audit-log.pg.test.ts @@ -0,0 +1,37 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getPool } from '@/tests/pg/setup' +import { insertAuthUser } from '@/tests/pg/fixtures' + +describe('audit-log.pg — append-only immutability', () => { + it('rejects UPDATE on audit_log rows', async () => { + const userId = await insertAuthUser() + const auditId = randomUUID() + await getPool().query( + `INSERT INTO public.audit_log (id, user_id, action, description) + VALUES ($1, $2, 'SECURITY_EVENT', 'pg-real seed')`, + [auditId, userId], + ) + + await expect( + getPool().query( + `UPDATE public.audit_log SET description = 'tampered' WHERE id = $1`, + [auditId], + ), + ).rejects.toThrow(/cannot be modified or deleted/i) + }) + + it('rejects DELETE on audit_log rows', async () => { + const userId = await insertAuthUser() + const auditId = randomUUID() + await getPool().query( + `INSERT INTO public.audit_log (id, user_id, action, description) + VALUES ($1, $2, 'SECURITY_EVENT', 'pg-real seed')`, + [auditId, userId], + ) + + await expect( + getPool().query(`DELETE FROM public.audit_log WHERE id = $1`, [auditId]), + ).rejects.toThrow(/cannot be modified or deleted/i) + }) +}) diff --git a/lib/bookkeeping/__tests__/engine.pg.test.ts b/lib/bookkeeping/__tests__/engine.pg.test.ts new file mode 100644 index 00000000..b5de22b5 --- /dev/null +++ b/lib/bookkeeping/__tests__/engine.pg.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { getPool } from '@/tests/pg/setup' +import { + insertBalancedLines, + insertDraftJournalEntry, + seedCompany, +} from '@/tests/pg/fixtures' + +describe('engine.pg — triggers & RPCs that mocks cannot catch', () => { + it('rejects INSERT into journal_entries when the fiscal period is closed', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany({ isClosed: true }) + + await expect( + insertDraftJournalEntry({ userId, companyId, fiscalPeriodId }), + ).rejects.toThrow(/locked\/closed fiscal period/i) + }) + + it('commit_journal_entry assigns sequential voucher numbers under concurrency', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + + const entryA = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId }) + const entryB = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId }) + await insertBalancedLines(entryA) + await insertBalancedLines(entryB) + + // Two dedicated clients so the row-level lock on voucher_sequences is + // actually exercised — not just a single connection serialising calls. + const clientA = await getPool().connect() + const clientB = await getPool().connect() + try { + const [resA, resB] = await Promise.all([ + clientA.query<{ voucher_number: number }>( + `SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`, + [companyId, entryA], + ), + clientB.query<{ voucher_number: number }>( + `SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`, + [companyId, entryB], + ), + ]) + const numbers = [resA.rows[0]!.voucher_number, resB.rows[0]!.voucher_number].sort( + (a, b) => a - b, + ) + expect(numbers).toEqual([1, 2]) + } finally { + clientA.release() + clientB.release() + } + }) + + it('rejects UPDATE to a posted journal entry (committed immutability)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + + // Bypass commit_journal_entry by inserting directly as 'posted'. The + // immutability trigger fires on UPDATE, not INSERT, so this is legal + // setup on the superuser connection. + const entryId = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + status: 'posted', + voucherNumber: 1, + }) + + await expect( + getPool().query( + `UPDATE public.journal_entries SET description = 'tampered' WHERE id = $1`, + [entryId], + ), + ).rejects.toThrow(/Cannot modify a posted journal entry/i) + }) +}) diff --git a/lib/supabase/__tests__/rls.pg.test.ts b/lib/supabase/__tests__/rls.pg.test.ts new file mode 100644 index 00000000..743c2e27 --- /dev/null +++ b/lib/supabase/__tests__/rls.pg.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { insertDraftJournalEntry, seedCompany } from '@/tests/pg/fixtures' +import { withUserContext } from '@/tests/pg/setup' + +describe('rls.pg — tenant isolation on journal_entries', () => { + it('returns only the authenticated user\'s company rows', async () => { + const a = await seedCompany() + const b = await seedCompany() + + // One entry in each tenant. + const entryA = await insertDraftJournalEntry({ + userId: a.userId, + companyId: a.companyId, + fiscalPeriodId: a.fiscalPeriodId, + }) + await insertDraftJournalEntry({ + userId: b.userId, + companyId: b.companyId, + fiscalPeriodId: b.fiscalPeriodId, + }) + + // user A should see exactly entryA and nothing from tenant B. + const rows = await withUserContext(a.userId, async (client) => { + const res = await client.query<{ id: string; company_id: string }>( + `SELECT id, company_id FROM public.journal_entries`, + ) + return res.rows + }) + + expect(rows).toHaveLength(1) + expect(rows[0]!.id).toBe(entryA) + expect(rows[0]!.company_id).toBe(a.companyId) + }) +}) diff --git a/package-lock.json b/package-lock.json index b83c9d9d..f8496b7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "@types/sharp": "^0.31.1", @@ -66,6 +67,7 @@ "dotenv": "^17.2.3", "eslint": "^9", "eslint-config-next": "16.1.5", + "pg": "^8.20.0", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.0.18" @@ -5548,6 +5550,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/phoenix": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", @@ -11593,6 +11607,103 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11679,6 +11790,49 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -12668,6 +12822,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/ssf": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", @@ -14157,6 +14321,16 @@ "node": ">=0.8" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/package.json b/package.json index 6712f19d..3372e0c2 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "build": "next build", "start": "next start", "lint": "eslint", - "test": "vitest run" + "test": "vitest run --project unit", + "test:pg": "vitest run --project pg-real" }, "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1022.0", @@ -64,6 +65,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "@types/sharp": "^0.31.1", @@ -71,6 +73,7 @@ "dotenv": "^17.2.3", "eslint": "^9", "eslint-config-next": "16.1.5", + "pg": "^8.20.0", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.0.18" diff --git a/tests/pg/bootstrap.sql b/tests/pg/bootstrap.sql new file mode 100644 index 00000000..cbbfa480 --- /dev/null +++ b/tests/pg/bootstrap.sql @@ -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, '/'); +$$; diff --git a/tests/pg/fixtures.ts b/tests/pg/fixtures.ts new file mode 100644 index 00000000..bdff2e0f --- /dev/null +++ b/tests/pg/fixtures.ts @@ -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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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], + ) +} diff --git a/tests/pg/setup.ts b/tests/pg/setup.ts new file mode 100644 index 00000000..3dba5468 --- /dev/null +++ b/tests/pg/setup.ts @@ -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 { + 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( + userId: string, + fn: (client: PoolClient) => Promise, +): Promise { + 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 + } +}) diff --git a/vitest.config.ts b/vitest.config.ts index 6accecdb..949970ba 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,14 +1,46 @@ import { defineConfig } from 'vitest/config' import path from 'path' +const alias = { '@': path.resolve(__dirname, '.') } + +const unitProject = { + resolve: { alias }, + test: { + name: 'unit', + globals: true, + environment: 'node' as const, + include: ['**/*.test.ts'], + exclude: ['**/node_modules/**', '**/*.pg.test.ts'], + }, +} + +const pgRealProject = { + resolve: { alias }, + test: { + name: 'pg-real', + globals: true, + environment: 'node' as const, + include: ['**/*.pg.test.ts'], + exclude: ['**/node_modules/**'], + setupFiles: ['tests/pg/setup.ts'], + // One-connection-at-a-time to avoid cross-file DB contention. + fileParallelism: false, + testTimeout: 15000, + }, +} + +// Only register the pg-real project when DATABASE_URL is set. Local devs +// running a bare `vitest run` would otherwise hit the schema sanity check +// against a non-existent DB. `npm run test:pg` is the opt-in entry point. +const projects = process.env.DATABASE_URL + ? [unitProject, pgRealProject] + : [unitProject] + export default defineConfig({ + resolve: { alias }, test: { globals: true, environment: 'node', - }, - resolve: { - alias: { - '@': path.resolve(__dirname, '.'), - }, + projects, }, })