From cd1f4c1298c0d2620848ffcfe798a067acc46be0 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 20 Oct 2026 21:15:31 -0400 Subject: [PATCH] ci: run the suite and an upgrade check against real Postgres Adds a postgres workflow, run on PRs that touch migrations, models or the services that send their own SQL, nightly, and on dispatch. - suite: with TEST_DB=postgres the run migrates a real database and adds a postgres vitest project (tests/postgres) that exercises the audit hash chain triggers, retention and the raw SQL services without mocks. The old global setup synced no models and so tested nothing; it is removed. - upgrade: migrates to the newest v* tag not on HEAD, seeds users, credentials, sessions, chained auth events, system_config and the rest, runs the current migrations, then compares every seeded row and checks the chain verifies, the schema guard is satisfied and every model can read. Closes #436 --- .github/workflows/postgres.yml | 164 +++++++++++++++ CONTRIBUTING.md | 14 +- scripts/upgrade-check/db.mjs | 45 ++++ scripts/upgrade-check/seed.mjs | 290 ++++++++++++++++++++++++++ scripts/upgrade-check/verify.mjs | 127 +++++++++++ tests/postgres/auditChain.spec.ts | 121 +++++++++++ tests/postgres/globalSetup.ts | 34 +++ tests/postgres/rawSqlServices.spec.ts | 231 ++++++++++++++++++++ tests/setup/db.ts | 15 -- tests/setup/globalSetup.ts | 9 - vitest.config.ts | 34 ++- 11 files changed, 1053 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/postgres.yml create mode 100644 scripts/upgrade-check/db.mjs create mode 100644 scripts/upgrade-check/seed.mjs create mode 100644 scripts/upgrade-check/verify.mjs create mode 100644 tests/postgres/auditChain.spec.ts create mode 100644 tests/postgres/globalSetup.ts create mode 100644 tests/postgres/rawSqlServices.spec.ts delete mode 100644 tests/setup/db.ts delete mode 100644 tests/setup/globalSetup.ts diff --git a/.github/workflows/postgres.yml b/.github/workflows/postgres.yml new file mode 100644 index 00000000..8afb7427 --- /dev/null +++ b/.github/workflows/postgres.yml @@ -0,0 +1,164 @@ +# The main suite runs on a mock database. These jobs run against real Postgres: the +# suite with the specs that need one, and an upgrade from the previous release's schema +# with data in it. +name: postgres + +on: + pull_request: + paths: + - 'src/migrations/**' + - 'src/models/**' + - 'src/config/config.cjs' + - 'src/config/database.cjs' + - '.sequelizerc' + # Services and controllers that send their own SQL. + - 'src/controllers/admin.ts' + - 'src/controllers/internalMetrics.ts' + - 'src/services/auditExport.ts' + - 'src/services/auditIntegrity.ts' + - 'src/services/auditRetention.ts' + - 'src/services/coverageReport.ts' + - 'src/services/enrollmentService.ts' + - 'src/services/funnelMetrics.ts' + - 'src/services/reviewAccountUsage.ts' + - 'src/services/schemaGuard.ts' + - 'src/services/signInMetrics.ts' + - 'tests/postgres/**' + - 'scripts/upgrade-check/**' + - 'vitest.config.ts' + - '.github/workflows/postgres.yml' + schedule: + - cron: '23 5 * * *' + workflow_dispatch: + inputs: + previous-ref: + description: 'Ref whose schema the upgrade starts from (default: the newest v* tag not on HEAD)' + required: false + default: '' + +permissions: + contents: read + +env: + DB_HOST: localhost + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: postgres + NODE_ENV: test + +jobs: + suite: + name: Suite on Postgres + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: seamless_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + DB_NAME: seamless_test + TEST_DB: postgres + + steps: + - name: Checkout repo + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + # Migrates the database, then runs the unit project and the tests/postgres specs. + - name: Run tests against Postgres + run: npm run test:run + + upgrade: + name: Upgrade from the previous release + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: seamless_upgrade + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + DB_NAME: seamless_upgrade + + steps: + - name: Checkout repo + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Resolve the previous release + id: previous + env: + REQUESTED: ${{ inputs.previous-ref }} + run: | + set -euo pipefail + ref="${REQUESTED:-$(git tag --list 'v*' --sort=-v:refname --no-contains HEAD | head -n1)}" + if [ -z "${ref}" ]; then + echo "No previous release tag found" >&2 + exit 1 + fi + echo "Upgrading from ${ref}" + echo "ref=${ref}" >> "${GITHUB_OUTPUT}" + { + echo "PREVIOUS_DIR=${RUNNER_TEMP}/previous" + echo "SNAPSHOT=${RUNNER_TEMP}/snapshot.json" + } >> "${GITHUB_ENV}" + + - name: Check out the previous release + env: + REF: ${{ steps.previous.outputs.ref }} + run: git worktree add --detach "${PREVIOUS_DIR}" "${REF}" + + - name: Migrate to the previous release's schema + run: | + set -euo pipefail + cd "${PREVIOUS_DIR}" + npm ci --ignore-scripts + npx sequelize-cli db:migrate + + - name: Seed data on the previous schema + run: node scripts/upgrade-check/seed.mjs "${SNAPSHOT}" + + - name: Run the current migrations + run: npx sequelize-cli db:migrate + + - name: Verify the data survived + run: npx tsx scripts/upgrade-check/verify.mjs "${SNAPSHOT}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ceb63523..3a5d21ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,12 +126,22 @@ npx vitest run tests/unit/utils # a directory npx vitest tests/unit/utils/redaction.spec.ts # watch mode ``` -Only a few tests exercise real database behavior. To run those against a running Postgres: +The specs under [`tests/postgres/`](./tests/postgres) need a real database: they cover the +audit hash chain triggers and the services that send their own SQL. With `TEST_DB=postgres` +the run migrates the database named by the `DB_*` variables (default `seamless_test` on +localhost), then runs the unit suite and those specs. The schema is dropped first, so the +database name must contain `test`. ```bash -TEST_DB=postgres npm run test:run +TEST_DB=postgres DB_USER=postgres DB_PASSWORD=postgres npm run test:run ``` +The `postgres` workflow also checks upgrades: it migrates an empty database to the previous +release's schema, seeds it with [`scripts/upgrade-check/seed.mjs`](./scripts/upgrade-check/seed.mjs), +runs the current migrations and compares every seeded row with +[`scripts/upgrade-check/verify.mjs`](./scripts/upgrade-check/verify.mjs). A migration that is +meant to change or remove existing data lists it in `EXPECTED_CHANGES` there. + ### Writing a test Use the shared factories in [`tests/factories/`](./tests/factories) to build valid domain diff --git a/scripts/upgrade-check/db.mjs b/scripts/upgrade-check/db.mjs new file mode 100644 index 00000000..06a9df30 --- /dev/null +++ b/scripts/upgrade-check/db.mjs @@ -0,0 +1,45 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { createRequire } from 'module'; +import pg from 'pg'; + +const require = createRequire(import.meta.url); +const { buildDatabaseUrl } = require('../../src/config/database.cjs'); + +export async function connect() { + const client = new pg.Client({ connectionString: buildDatabaseUrl() }); + await client.connect(); + return client; +} + +/** + * Every row of every table, keyed by table and then by primary key, as `row_to_json` + * renders it. `SequelizeMeta` is left out because migrating is meant to change it. + */ +export async function snapshot(client) { + const { rows: tables } = await client.query(` + SELECT c.relname AS name, + array_agg(a.attname::text ORDER BY array_position(i.indkey, a.attnum)) AS pk + FROM pg_index i + JOIN pg_class c ON c.oid = i.indrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY (i.indkey) + WHERE i.indisprimary AND n.nspname = 'public' AND c.relname <> 'SequelizeMeta' + GROUP BY c.relname + ORDER BY c.relname`); + + const result = {}; + + for (const { name, pk } of tables) { + const { rows } = await client.query(`SELECT row_to_json(t) AS row FROM public."${name}" t`); + result[name] = Object.fromEntries( + rows.map(({ row }) => [JSON.stringify(pk.map((column) => row[column])), row]), + ); + } + + return result; +} diff --git a/scripts/upgrade-check/seed.mjs b/scripts/upgrade-check/seed.mjs new file mode 100644 index 00000000..59a7959b --- /dev/null +++ b/scripts/upgrade-check/seed.mjs @@ -0,0 +1,290 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +/** + * Seeds a database migrated to the previous release with the rows a running deployment + * holds, then writes a snapshot of every table for verify.mjs to compare after the + * current migrations have run. + * + * Usage: node scripts/upgrade-check/seed.mjs + * + * Only columns the previous schema has are written, so a column a later migration drops + * does not need a change here once that migration has been released. + */ + +import { writeFile } from 'fs/promises'; + +import { connect, snapshot } from './db.mjs'; + +const ALICE = '00000000-0000-4000-8000-00000000a11c'; +const BOB = '00000000-0000-4000-8000-000000000b0b'; +const CAROL = '00000000-0000-4000-8000-0000000ca201'; +const ORG = '00000000-0000-4000-8000-0000000000a1'; +const SESSION_OLD = '00000000-0000-4000-8000-000000005e01'; +const SESSION_NEW = '00000000-0000-4000-8000-000000005e02'; + +const day = (n) => new Date(Date.UTC(2026, 0, n, 9, 30)).toISOString(); +const stamps = (n) => ({ createdAt: day(n), updatedAt: day(n) }); +const snake = (n) => ({ created_at: day(n), updated_at: day(n) }); + +const ROWS = { + users: [ + { + id: ALICE, + email: 'alice@example.com', + phone: '+14155550101', + roles: '{user,admin}', + verified: true, + email_verified: true, + phone_verified: true, + last_login: day(20), + ...snake(1), + }, + { id: BOB, email: 'bob@example.com', roles: '{user}', verified: true, ...snake(2) }, + { id: CAROL, email: 'carol@example.com', roles: '{user}', revoked: true, ...snake(3) }, + ], + credentials: [ + { + id: 'Y3JlZGVudGlhbC1hbGljZS0x', + userId: ALICE, + publicKey: Buffer.from('a5010203262001215820', 'hex'), + counter: 7, + transports: JSON.stringify(['internal', 'hybrid']), + backedup: true, + deviceType: 'multiDevice', + friendlyName: 'Laptop', + lastUsedAt: day(20), + platform: 'macOS', + browser: 'Safari', + prfCapable: true, + aaguid: 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd', + attestationFormat: 'none', + ...stamps(4), + }, + { + id: 'Y3JlZGVudGlhbC1ib2ItMQ', + userId: BOB, + publicKey: Buffer.from('a50102032620012158ff', 'hex'), + counter: 0, + deviceType: 'singleDevice', + ...stamps(5), + }, + ], + totp_credentials: [ + { + id: '00000000-0000-4000-8000-0000000070a1', + userId: ALICE, + secretCiphertext: 'c2VjcmV0LWNpcGhlcnRleHQ=', + secretIv: 'aXYtMTIzNDU2Nzg=', + secretTag: 'dGFnLTEyMzQ1Njc4', + issuer: 'Seamless', + accountName: 'alice@example.com', + enabled: true, + verifiedAt: day(6), + lastUsedCounter: 59000000, + ...stamps(6), + }, + ], + organizations: [ + { + id: ORG, + name: 'Acme', + slug: 'acme', + created_by_user_id: ALICE, + metadata: JSON.stringify({ plan: 'pilot' }), + ...snake(7), + }, + ], + organization_memberships: [ + { + id: '00000000-0000-4000-8000-00000000e301', + organization_id: ORG, + user_id: ALICE, + roles: JSON.stringify(['owner']), + ...snake(7), + }, + { + id: '00000000-0000-4000-8000-00000000e302', + organization_id: ORG, + user_id: BOB, + ...snake(8), + }, + ], + sessions: [ + { + id: SESSION_OLD, + userId: ALICE, + infraId: 'upgrade-check', + mode: 'web', + userAgent: 'Mozilla/5.0 (Macintosh)', + ipAddress: '203.0.113.10', + lastUsedAt: day(19), + expiresAt: day(40), + idleExpiresAt: day(25), + replacedBySessionId: SESSION_NEW, + revokedAt: day(19), + revokedReason: 'rotated', + refreshTokenLookup: 'a'.repeat(64), + organizationId: ORG, + chainStartedAt: day(18), + ...stamps(18), + }, + { + id: SESSION_NEW, + userId: ALICE, + infraId: 'upgrade-check', + mode: 'web', + lastUsedAt: day(20), + expiresAt: day(41), + idleExpiresAt: day(26), + refreshTokenLookup: 'b'.repeat(64), + stepUpVerifiedAt: day(20), + stepUpMethod: 'passkey', + organizationId: ORG, + chainStartedAt: day(18), + ...stamps(19), + }, + ], + magic_link_tokens: [ + { + id: '00000000-0000-4000-8000-0000000003a1', + user_id: BOB, + token_hash: 'c'.repeat(64), + redirect_url: 'https://app.example.com/welcome', + expires_at: day(10), + used_at: day(9), + created_at: day(9), + }, + ], + oauth_identities: [ + { + id: '00000000-0000-4000-8000-0000000000a7', + user_id: BOB, + provider_id: 'github', + provider_subject: '424242', + email: 'bob@example.com', + profile: JSON.stringify({ login: 'bob' }), + ...snake(11), + }, + ], + user_external_ids: [ + { + id: '00000000-0000-4000-8000-0000000000e1', + user_id: BOB, + source: 'okta', + external_id: '00u1abcd', + ...snake(12), + }, + ], + auth_failures: [ + { + id: '00000000-0000-4000-8000-0000000000f1', + user_id: BOB, + type: 'otp', + occurred_at: day(13), + ...snake(13), + }, + ], + system_config: [ + ['app_name', 'Upgrade Check'], + ['default_roles', ['user']], + ['available_roles', ['user', 'admin']], + ['access_token_ttl', '15m'], + ['refresh_token_ttl', '7d'], + ['session_idle_ttl', '8h'], + ['rate_limit', 100], + ['delay_after', 50], + ['rpid', 'localhost'], + ['origins', ['http://localhost:5174']], + ['frontend_url', 'http://localhost:5174'], + ].map(([key, value]) => ({ key, value: JSON.stringify(value), updatedBy: ALICE, ...stamps(1) })), +}; + +// Written in this order so the previous release's trigger chains them in it. +const EVENTS = [ + ['user_created', ALICE, { method: 'passkey' }], + ['registration_success', ALICE, null], + ['login_success', ALICE, null], + ['webauthn_login_success', ALICE, { credentialId: 'Y3JlZGVudGlhbC1hbGljZS0x' }], + ['user_created', BOB, null], + ['otp_success', BOB, { channel: 'email' }], + ['verify_otp_failed', BOB, { channel: 'email' }], + ['verify_otp_success', BOB, { channel: 'email' }], + ['magic_link_requested', BOB, null], + ['magic_link_poll_completed_successfully', BOB, null], + ['refresh_token_success', ALICE, null], + ['internal_user_updated_by_owner', CAROL, { revoked: true }], +].map(([type, userId, metadata], index) => ({ + id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + user_id: userId, + actor_user_id: type === 'internal_user_updated_by_owner' ? ALICE : null, + session_id: type === 'refresh_token_success' ? SESSION_NEW : null, + type, + ip_address: '203.0.113.10', + user_agent: 'Mozilla/5.0 (Macintosh)', + deployment_id: 'upgrade-check', + device_class: 'desktop', + mail_provider: 'other', + owner: userId === ALICE, + attempt_id: null, + metadata: metadata && JSON.stringify(metadata), + created_at: new Date(Date.UTC(2026, 0, 14, 9, index)).toISOString(), + updated_at: new Date(Date.UTC(2026, 0, 14, 9, index)).toISOString(), +})); + +async function columnsOf(client, table) { + const { rows } = await client.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1`, + [table], + ); + return new Set(rows.map((row) => row.column_name)); +} + +async function insertAll(client, table, rows) { + const columns = await columnsOf(client, table); + + if (columns.size === 0) { + console.log(`skip ${table}: not in the previous schema`); + return; + } + + for (const row of rows) { + const entries = Object.entries(row).filter(([column]) => columns.has(column)); + await client.query( + `INSERT INTO public."${table}" (${entries.map(([c]) => `"${c}"`).join(', ')}) + VALUES (${entries.map((_, i) => `$${i + 1}`).join(', ')}) + ${table === 'system_config' ? 'ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, "updatedBy" = EXCLUDED."updatedBy"' : ''}`, + entries.map(([, value]) => value), + ); + } + + console.log(`seeded ${table}: ${rows.length}`); +} + +const [out] = process.argv.slice(2); + +if (!out) { + console.error('Usage: node scripts/upgrade-check/seed.mjs '); + process.exit(2); +} + +const client = await connect(); + +try { + await client.query('BEGIN'); + for (const [table, rows] of Object.entries(ROWS)) { + await insertAll(client, table, rows); + } + await insertAll(client, 'auth_events', EVENTS); + await client.query('COMMIT'); + + const tables = await snapshot(client); + await writeFile(out, JSON.stringify(tables, null, 2)); + console.log(`snapshot of ${Object.keys(tables).length} tables written to ${out}`); +} finally { + await client.end(); +} diff --git a/scripts/upgrade-check/verify.mjs b/scripts/upgrade-check/verify.mjs new file mode 100644 index 00000000..f220e5a6 --- /dev/null +++ b/scripts/upgrade-check/verify.mjs @@ -0,0 +1,127 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +/** + * Checks that the current migrations kept the data seed.mjs wrote on the previous + * release's schema, and that this build can run against the result. + * + * Usage: npx tsx scripts/upgrade-check/verify.mjs + */ + +import { readFile } from 'fs/promises'; + +import { bootstrapSystemConfig } from '../../src/config/bootstrapSystemConfig.js'; +import { getSystemConfig } from '../../src/config/getSystemConfig.js'; +import { getSequelize, initializeModels } from '../../src/models/index.js'; +import { verifyAuthEventChain } from '../../src/services/auditIntegrity.js'; +import { findPendingMigrations } from '../../src/services/schemaGuard.js'; +import { connect, snapshot } from './db.mjs'; + +/** + * Values a migration is meant to change or remove, as `table` or `table.column`, with the + * reason. Anything else that differs from the snapshot fails the check. + */ +const EXPECTED_CHANGES = {}; + +const failures = []; +const describe = (error) => error?.parent?.message ?? error?.stack ?? String(error); +const fail = (message) => failures.push(message); +const expected = (table, column) => + EXPECTED_CHANGES[table] ?? (column && EXPECTED_CHANGES[`${table}.${column}`]); + +function compare(before, after) { + for (const [table, rows] of Object.entries(before)) { + if (!after[table]) { + if (!expected(table)) fail(`${table}: table is gone`); + continue; + } + + for (const [key, row] of Object.entries(rows)) { + const now = after[table][key]; + + if (!now) { + if (!expected(table)) fail(`${table} ${key}: row is gone`); + continue; + } + + for (const [column, value] of Object.entries(row)) { + if (!(column in now)) { + if (!expected(table, column)) fail(`${table}.${column}: column is gone`); + } else if (JSON.stringify(now[column]) !== JSON.stringify(value)) { + if (!expected(table, column)) { + fail( + `${table}.${column} ${key}: ${JSON.stringify(value)} became ${JSON.stringify(now[column])}`, + ); + } + } + } + } + + console.log(`${table}: ${Object.keys(rows).length} rows checked`); + } +} + +const [path] = process.argv.slice(2); + +if (!path) { + console.error('Usage: npx tsx scripts/upgrade-check/verify.mjs '); + process.exit(2); +} + +const before = JSON.parse(await readFile(path, 'utf8')); +const client = await connect(); +try { + compare(before, await snapshot(client)); +} finally { + await client.end(); +} + +const models = await initializeModels(); +const sequelize = getSequelize(); + +try { + const pending = await findPendingMigrations(sequelize); + if (pending.length) fail(`pending migrations after upgrade: ${pending.join(', ')}`); + + const chain = await verifyAuthEventChain(); + const seededEvents = Object.keys(before.auth_events ?? {}).length; + if (!chain.verified) fail(`audit chain does not verify: ${JSON.stringify(chain.firstFailure)}`); + if (chain.rowsChecked !== seededEvents) { + fail(`audit chain holds ${chain.rowsChecked} events, ${seededEvents} were seeded`); + } + + // What the server does at boot: fill in new config keys, then parse the whole set. + await bootstrapSystemConfig(); + await getSystemConfig(); + + // Every model selects all of its attributes, so one the schema lacks fails here. + for (const [name, model] of Object.entries(models)) { + if (typeof model?.findAll === 'function' && name !== 'sequelize') { + await model.findAll({ limit: 1 }).catch((error) => fail(`${name}: ${describe(error)}`)); + } + } + + if ((await models.User.count()) !== Object.keys(before.users).length) { + fail('the User model does not see every seeded user'); + } + + await models.AuthEvent.create({ type: 'informational', metadata: { upgradeCheck: true } }); + const extended = await verifyAuthEventChain(); + if (!extended.verified || extended.rowsChecked !== seededEvents + 1) { + fail(`audit chain does not verify after a new event: ${JSON.stringify(extended)}`); + } +} catch (error) { + fail(describe(error)); +} finally { + await sequelize.close(); +} + +if (failures.length) { + console.error(`Upgrade check failed:\n ${failures.join('\n ')}`); + process.exit(1); +} + +console.log('Upgrade check passed: seeded data survived and the chain verifies.'); diff --git a/tests/postgres/auditChain.spec.ts b/tests/postgres/auditChain.spec.ts new file mode 100644 index 00000000..e0b48f86 --- /dev/null +++ b/tests/postgres/auditChain.spec.ts @@ -0,0 +1,121 @@ +import { mkdtemp, readFile, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { QueryTypes } from 'sequelize'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { getSequelize, initializeModels } from '../../src/models/index.js'; +import { verifyAuthEventChain } from '../../src/services/auditIntegrity.js'; +import { runAuditRetention } from '../../src/services/auditRetention.js'; + +type AuthEventModel = Awaited>['AuthEvent']; + +let AuthEvent: AuthEventModel; + +async function insertEvents(count: number) { + return Promise.all( + Array.from({ length: count }, (_, index) => + AuthEvent.create({ type: 'login_success', metadata: { index } }), + ), + ); +} + +async function rowsFor(ids: string[]) { + return getSequelize().query<{ id: string; seq: string; prev_hash: string; hash: string }>( + 'SELECT id, seq, prev_hash, hash FROM auth_events WHERE id IN (:ids) ORDER BY seq', + { replacements: { ids }, type: QueryTypes.SELECT }, + ); +} + +beforeAll(async () => { + ({ AuthEvent } = await initializeModels()); +}); + +afterAll(async () => { + await getSequelize().close(); +}); + +describe('auth_events hash chain', () => { + it('links concurrent inserts into one unbroken chain', async () => { + const events = await insertEvents(20); + const rows = await rowsFor(events.map((event) => event.id)); + + expect(rows).toHaveLength(20); + rows.slice(1).forEach((row, index) => { + expect(Number(row.seq)).toBe(Number(rows[index].seq) + 1); + expect(row.prev_hash).toBe(rows[index].hash); + }); + + const report = await verifyAuthEventChain(); + + expect(report.verified).toBe(true); + expect(report.head).toEqual({ seq: Number(rows[19].seq), hash: rows[19].hash }); + }); + + it.each([ + ['UPDATE', "UPDATE auth_events SET type = 'tampered'"], + ['DELETE', 'DELETE FROM auth_events'], + ['TRUNCATE', 'TRUNCATE auth_events'], + ])('refuses %s', async (_op, sql) => { + await insertEvents(1); + + await expect(getSequelize().query(sql)).rejects.toThrow(/append-only/); + }); + + it('reports a row whose content changed, and verifies again once it is restored', async () => { + const [event] = await insertEvents(1); + const [row] = await rowsFor([event.id]); + const sequelize = getSequelize(); + + await sequelize.query('ALTER TABLE auth_events DISABLE TRIGGER auth_events_append_only'); + try { + await sequelize.query(`UPDATE auth_events SET metadata = '{"index":99}' WHERE id = :id`, { + replacements: { id: event.id }, + }); + + expect((await verifyAuthEventChain()).firstFailure).toEqual({ + seq: Number(row.seq), + id: event.id, + reason: 'hash_mismatch', + }); + + await sequelize.query(`UPDATE auth_events SET metadata = '{"index":0}' WHERE id = :id`, { + replacements: { id: event.id }, + }); + } finally { + await sequelize.query('ALTER TABLE auth_events ENABLE TRIGGER auth_events_append_only'); + } + + expect((await verifyAuthEventChain()).verified).toBe(true); + }); + + it('archives a prefix of the chain on retention and what remains still verifies', async () => { + await insertEvents(5); + const before = await verifyAuthEventChain(); + const archiveDir = await mkdtemp(join(tmpdir(), 'audit-archive-')); + + try { + const result = await runAuditRetention( + { retentionDays: 1, archiveDir }, + new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), + ); + + expect(result).toMatchObject({ status: 'done', archived: before.rowsChecked - 1 }); + + const after = await verifyAuthEventChain(); + const lines = (await readFile((result as { files: string[] }).files[0], 'utf8')) + .trim() + .split('\n'); + const lastArchived = JSON.parse(lines[lines.length - 2]); + + expect(after).toMatchObject({ verified: true, rowsChecked: 1, lastSeq: before.lastSeq }); + expect(after.anchorHash).toBe(lastArchived.hash); + + await insertEvents(1); + + expect(await verifyAuthEventChain()).toMatchObject({ verified: true, rowsChecked: 2 }); + } finally { + await rm(archiveDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/postgres/globalSetup.ts b/tests/postgres/globalSetup.ts new file mode 100644 index 00000000..f74e3b40 --- /dev/null +++ b/tests/postgres/globalSetup.ts @@ -0,0 +1,34 @@ +import '../setup/env.js'; + +import { execFileSync } from 'child_process'; +import { createRequire } from 'module'; +import pg from 'pg'; + +const require = createRequire(import.meta.url); +const { buildDatabaseUrl } = require('../../src/config/database.cjs'); + +// Builds the schema the way a deployment does, by running the migration chain, so the +// triggers and functions the migrations create are the ones under test. +export default async () => { + const client = new pg.Client({ connectionString: buildDatabaseUrl() }); + await client.connect(); + try { + const { rows } = await client.query('SELECT current_database() AS name'); + + // The schema is dropped next, so a database that is not obviously disposable is refused. + if (!/test/i.test(rows[0].name)) { + throw new Error( + `Refusing to reset database "${rows[0].name}": its name must contain "test".`, + ); + } + + await client.query('DROP SCHEMA public CASCADE; CREATE SCHEMA public;'); + } finally { + await client.end(); + } + + execFileSync(process.execPath, [require.resolve('sequelize-cli/lib/sequelize'), 'db:migrate'], { + env: { ...process.env, NODE_ENV: 'test' }, + stdio: ['ignore', 'ignore', 'inherit'], + }); +}; diff --git a/tests/postgres/rawSqlServices.spec.ts b/tests/postgres/rawSqlServices.spec.ts new file mode 100644 index 00000000..247acaf4 --- /dev/null +++ b/tests/postgres/rawSqlServices.spec.ts @@ -0,0 +1,231 @@ +import { randomUUID } from 'crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { bootstrapSystemConfig } from '../../src/config/bootstrapSystemConfig.js'; +import { getDatabaseSize } from '../../src/controllers/admin.js'; +import { getSequelize, initializeModels } from '../../src/models/index.js'; +import { streamAuthEventExport } from '../../src/services/auditExport.js'; +import { buildCoverageReport } from '../../src/services/coverageReport.js'; +import { listEnrollment } from '../../src/services/enrollmentService.js'; +import { getFunnelMetrics } from '../../src/services/funnelMetrics.js'; +import { getReviewAccounts } from '../../src/services/reviewAccountUsage.js'; +import { findPendingMigrations } from '../../src/services/schemaGuard.js'; +import { getSignInMetrics } from '../../src/services/signInMetrics.js'; +import { buildSystemConfig } from '../factories/systemConfigFactory.js'; + +// Everything is seeded in March 2001 and every query is windowed to it, so rows the other +// files write do not reach these figures. +const at = (time: string) => new Date(`2001-03-05T${time}.000Z`); +const window = { from: new Date('2001-03-01T00:00:00Z'), to: new Date('2001-03-31T00:00:00Z') }; + +const enrolled = randomUUID(); +const pending = randomUUID(); +const organization = randomUUID(); +const registration = randomUUID(); +const login = randomUUID(); + +async function insert(table: string, row: Record) { + const columns = Object.keys(row); + await getSequelize().query( + `INSERT INTO ${table} (${columns.map((column) => `"${column}"`).join(', ')}) + VALUES (${columns.map((column) => `:${column}`).join(', ')})`, + { + replacements: Object.fromEntries( + Object.entries(row).map(([key, value]) => [ + key, + value !== null && typeof value === 'object' && !(value instanceof Date) + ? JSON.stringify(value) + : value, + ]), + ), + }, + ); +} + +function event(type: string, userId: string | null, time: string, extra = {}) { + return insert('auth_events', { + id: randomUUID(), + user_id: userId, + type, + device_class: 'desktop', + created_at: at(time), + updated_at: at(time), + ...extra, + }); +} + +function user(id: string, email: string) { + return insert('users', { + id, + email, + roles: '{user}', + created_at: at('10:00:00'), + updated_at: at('10:00:00'), + }); +} + +beforeAll(async () => { + await initializeModels(); + + // The keys with no default, as an operator would seed them; bootstrap fills in the rest. + for (const [key, value] of Object.entries( + buildSystemConfig({ frontend_url: 'http://localhost:5174' }), + )) { + await getSequelize().query( + `INSERT INTO system_config (key, value, "createdAt", "updatedAt") + VALUES (:key, :value, now(), now()) + ON CONFLICT (key) DO NOTHING`, + { replacements: { key, value: JSON.stringify(value) } }, + ); + } + await bootstrapSystemConfig(); + + await user(enrolled, `enrolled-${enrolled}@example.com`); + await user(pending, `pending-${pending}@example.com`); + + await insert('credentials', { + id: `cred-${enrolled}`, + userId: enrolled, + publicKey: Buffer.from('public-key'), + deviceType: 'multiDevice', + backedup: true, + createdAt: at('10:05:00'), + updatedAt: at('10:05:00'), + }); + + await insert('organizations', { + id: organization, + name: 'Postgres Suite', + slug: `postgres-suite-${organization}`, + created_at: at('09:00:00'), + updated_at: at('09:00:00'), + }); + + for (const member of [enrolled, pending]) { + await insert('organization_memberships', { + id: randomUUID(), + organization_id: organization, + user_id: member, + created_at: at('10:00:00'), + updated_at: at('10:00:00'), + }); + } + + // Registration: account created, code sent, code verified (written twice, as it is). + await event('user_created', enrolled, '10:00:00', { attempt_id: registration }); + await event('otp_success', enrolled, '10:00:10', { attempt_id: registration }); + await event('verify_otp_success', enrolled, '10:01:00', { + attempt_id: registration, + metadata: { channel: 'email', reviewAccount: 'true' }, + }); + await event('verify_otp_success', enrolled, '10:01:00', { + attempt_id: registration, + metadata: { channel: 'email', reviewAccount: 'true' }, + }); + + // Login: one failed passkey assertion, then a successful one, in the same attempt. + await event('login_success', pending, '11:00:00', { attempt_id: login }); + await event('webauthn_login_failed', pending, '11:00:30', { attempt_id: login }); + await event('webauthn_login_success', pending, '11:00:40', { attempt_id: login }); + + // A magic link failure from before attempt ids were recorded. + await event('magic_link_failed', null, '12:00:00'); +}); + +afterAll(async () => { + await getSequelize().close(); +}); + +describe('raw SQL services against the migrated schema', () => { + it('sign-in metrics fold events by attempt and method', async () => { + const metrics = await getSignInMetrics(window); + + expect(metrics.attempts).toEqual({ started: 2, delivered: 1, presented: 2, completed: 2 }); + expect(metrics.signIns).toEqual({ success: 2, failed: 1, successRate: 2 / 3 }); + expect( + metrics.breakdown.map(({ method, success, failed }) => ({ method, success, failed })), + ).toEqual([ + { method: 'magic_link', success: 0, failed: 1 }, + { method: 'otp', success: 1, failed: 0 }, + { method: 'passkey', success: 1, failed: 0 }, + ]); + }); + + it('funnel metrics measure registration, login and passkey adoption', async () => { + const funnel = await getFunnelMetrics(window); + + expect(funnel.timeToRegistration).toEqual({ count: 1, medianSeconds: 60, p90Seconds: 60 }); + expect(funnel.timeToLogin).toEqual({ count: 1, medianSeconds: 40, p90Seconds: 40 }); + expect(funnel.passkeyAdoption).toEqual({ users: 2, withPasskey: 1, rate: 0.5 }); + expect(funnel.timeToFirstPasskey).toMatchObject({ count: 1, medianSeconds: 300 }); + }); + + it('the coverage report reads users, credentials, organizations and sign-ins', async () => { + const report = await buildCoverageReport( + { from: '2001-03-01', to: '2001-03-31', organizationId: organization, bucket: 'week' }, + new Date('2001-04-01T00:00:00Z'), + ); + + expect(report.coverage).toEqual({ users: 2, passkeyUsers: 1, percent: 50 }); + // The first week closes at midnight on Monday 5 March, before either account existed. + expect(report.trend[0]).toMatchObject({ end: '2001-03-04', users: 0, passkeyUsers: 0 }); + expect(report.trend[1]).toMatchObject({ users: 2, passkeyUsers: 1 }); + expect(report.byOrganization).toEqual([ + { + organizationId: organization, + name: 'Postgres Suite', + users: 2, + passkeyUsers: 1, + percent: 50, + }, + ]); + expect(report.authenticatorMix).toEqual([ + expect.objectContaining({ credentials: 1, users: 1, backupEligible: 1, backedUp: 1 }), + ]); + expect(report.signInMix.total).toBe(2); + }); + + it('enrollment lists an organization by credential count', async () => { + const result = await listEnrollment({ organizationId: organization, limit: 10, offset: 0 }); + + expect(result.summary).toEqual({ total: 2, none: 1, one: 1, twoOrMore: 0 }); + expect(result.users.map((row) => [row.id, row.credentialCount])).toEqual( + expect.arrayContaining([ + [enrolled, 1], + [pending, 0], + ]), + ); + }); + + it('review account usage counts flagged sign-ins once per attempt', async () => { + const usage = await getReviewAccounts({ days: 30 }, new Date('2001-03-31T00:00:00Z')); + + expect(usage.recentSignIns).toMatchObject({ count: 1, failedVerifications: 0 }); + }); + + it('the audit export is a contiguous, linked run of the chain', async () => { + const lines: string[] = []; + const manifest = await streamAuthEventExport({ + from: window.from, + to: window.to, + write: (line) => void lines.push(line), + }); + const events = lines.slice(0, -1).map((line) => JSON.parse(line)); + + expect(manifest.count).toBe(events.length); + expect(events.length).toBeGreaterThanOrEqual(8); + events.slice(1).forEach((exported, index) => { + expect(exported.seq).toBe(events[index].seq + 1); + expect(exported.prevHash).toBe(events[index].hash); + }); + expect(manifest.lastHash).toBe(events[events.length - 1].hash); + }); + + it('the schema guard finds no pending migrations', async () => { + expect(await findPendingMigrations(getSequelize())).toEqual([]); + }); + + it('reports the database size', async () => { + expect(await getDatabaseSize()).toBeGreaterThan(0); + }); +}); diff --git a/tests/setup/db.ts b/tests/setup/db.ts deleted file mode 100644 index 054a96a0..00000000 --- a/tests/setup/db.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getSequelize } from '../../src/models/index.js'; - -export async function setupTestDb() { - if (process.env.TEST_DB === 'postgres') { - const sequelize = getSequelize(); - await sequelize.sync({ force: true }); - } -} - -export async function teardownTestDb() { - if (process.env.TEST_DB === 'postgres') { - const sequelize = getSequelize(); - await sequelize.close(); - } -} diff --git a/tests/setup/globalSetup.ts b/tests/setup/globalSetup.ts deleted file mode 100644 index 7b089b4c..00000000 --- a/tests/setup/globalSetup.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { setupTestDb, teardownTestDb } from './db'; - -export default async () => { - await setupTestDb(); - - return async () => { - await teardownTestDb(); - }; -}; diff --git a/vitest.config.ts b/vitest.config.ts index dac2b5a6..e04533c6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,11 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig, type TestProjectInlineConfiguration } from 'vitest/config'; -export default defineConfig({ +const unit: TestProjectInlineConfiguration = { + extends: true, test: { - globals: true, - environment: 'node', + name: 'unit', include: ['tests/**/*.spec.ts'], + exclude: ['tests/postgres/**'], // mockClear() empties call history but leaves the mockResolvedValueOnce queue intact, so a // value queued by one test and never consumed is returned to a later, unrelated one. That @@ -30,8 +31,31 @@ export default defineConfig({ './tests/setup/mocks.ts', './tests/setup/supertestServer.ts', ], + }, +}; + +// Runs against a real database built by the migration chain, with none of the shared +// mocks, so the triggers and raw SQL are exercised as deployed. Only with TEST_DB=postgres. +const postgres: TestProjectInlineConfiguration = { + extends: true, + test: { + name: 'postgres', + include: ['tests/postgres/**/*.spec.ts'], + setupFiles: ['./tests/setup/env.ts'], + globalSetup: ['./tests/postgres/globalSetup.ts'], + // Every file shares the one database. + fileParallelism: false, + testTimeout: 30000, + hookTimeout: 120000, + }, +}; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', - globalSetup: ['./tests/setup/globalSetup.ts'], + projects: process.env.TEST_DB === 'postgres' ? [unit, postgres] : [unit], coverage: { provider: 'v8',