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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Generated
+174
@@ -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",
|
||||
|
||||
+4
-1
@@ -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"
|
||||
|
||||
@@ -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, '/');
|
||||
$$;
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
+37
-5
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user