/** * A1 — integration tests for the Postgres store adapter (src/store/pg.ts) + migration runner * (src/db/migrate.ts). Exercises EVERY port method and the tricky semantics that must match * store/memory.ts: INV8 version history, casRedeem single-winner, registerFailure, subdomain * reserve/isTaken, route TTL fail-closed + listByNode, append-only metering/audit time windows. * * Gated on PG_TEST_URL. If unset, a disposable postgres:16 container is started on port 5433. */ import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { execSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { setTimeout as sleep } from 'node:timers/promises' import { createPgPool, createQuery, type QueryFn } from '../../src/db/pool.js' import { runMigrations } from '../../src/db/migrate.js' import { createPgStores } from '../../src/store/pg.js' import type { Stores } from '../../src/store/ports.js' import type { AccountRecord, HostRecord } from '../../src/model/records.js' const CONTAINER = 'cp_pg_a1_test' const PORT = 5433 const READY_TIMEOUT_MS = 60_000 let pool: ReturnType | undefined let query: QueryFn let stores: Stores let startedContainer = false beforeAll(async () => { let url = process.env.PG_TEST_URL if (!url) { try { execSync(`docker rm -f ${CONTAINER}`, { stdio: 'ignore' }) } catch { /* nothing to remove */ } execSync( `docker run -d --rm --name ${CONTAINER} -e POSTGRES_PASSWORD=test -e POSTGRES_DB=cp -p ${PORT}:5432 postgres:16`, { stdio: 'ignore' }, ) startedContainer = true url = `postgres://postgres:test@127.0.0.1:${PORT}/cp` } pool = createPgPool(url) query = createQuery(pool) // Poll until the server accepts queries. const deadline = Date.now() + READY_TIMEOUT_MS for (;;) { try { await query('SELECT 1', []) break } catch (err) { if (Date.now() > deadline) throw err await sleep(500) } } await runMigrations(query) // Idempotency: a second run must not throw (all migrations are IF NOT EXISTS). await runMigrations(query) stores = createPgStores(query) }, 180_000) afterAll(async () => { await pool?.end() if (startedContainer) { try { execSync(`docker rm -f ${CONTAINER}`, { stdio: 'ignore' }) } catch { /* best effort */ } } }) // ---- fixtures --------------------------------------------------------------------------------- function newAccount(): AccountRecord { return { accountId: randomUUID(), plan: 'free', createdAt: new Date().toISOString(), status: 'active' } } async function makeAccount(): Promise { const rec = newAccount() await stores.accounts.insert(rec) return rec } async function makeHost(accountId: string, subdomain = `sub-${randomUUID().slice(0, 8)}`): Promise { const rec: HostRecord = { hostId: randomUUID(), accountId, subdomain, agentPubkey: new Uint8Array([1, 2, 3, 4]), enrollFpr: `fpr-${subdomain}`, status: 'offline', lastSeen: new Date().toISOString(), createdAt: new Date().toISOString(), revokedAt: null, } await stores.hosts.insert(rec) return rec } // ---- AccountStore ----------------------------------------------------------------------------- describe('AccountStore', () => { test('insert + get round-trips the record', async () => { const rec = await makeAccount() expect(await stores.accounts.get(rec.accountId)).toEqual(rec) }) test('get returns null for unknown account', async () => { expect(await stores.accounts.get(randomUUID())).toBeNull() }) test('duplicate insert throws', async () => { const rec = await makeAccount() await expect(stores.accounts.insert(rec)).rejects.toThrow('duplicate accountId') }) test('swapStatus bumps status + appends INV8 version history atomically', async () => { const rec = await makeAccount() expect(await stores.accounts.versions(rec.accountId)).toEqual([ { accountId: rec.accountId, version: 1, status: 'active', changedAt: rec.createdAt, changedBy: 'system' }, ]) const swapped = await stores.accounts.swapStatus(rec.accountId, 'suspended', 'admin-1') expect(swapped.status).toBe('suspended') expect(swapped.accountId).toBe(rec.accountId) expect((await stores.accounts.get(rec.accountId))?.status).toBe('suspended') const versions = await stores.accounts.versions(rec.accountId) expect(versions.length).toBe(2) expect(versions[1]).toMatchObject({ version: 2, status: 'suspended', changedBy: 'admin-1' }) }) test('swapStatus on unknown account throws', async () => { await expect(stores.accounts.swapStatus(randomUUID(), 'suspended', 'x')).rejects.toThrow('account not found') }) }) // ---- HostStore -------------------------------------------------------------------------------- describe('HostStore', () => { test('insert + get + getBySubdomain + listByAccount', async () => { const acct = await makeAccount() const host = await makeHost(acct.accountId) expect(await stores.hosts.get(host.hostId)).toEqual(host) expect(await stores.hosts.getBySubdomain(host.subdomain)).toEqual(host) expect(await stores.hosts.listByAccount(acct.accountId)).toEqual([host]) }) test('get / getBySubdomain return null when absent', async () => { expect(await stores.hosts.get(randomUUID())).toBeNull() expect(await stores.hosts.getBySubdomain(`missing-${randomUUID()}`)).toBeNull() }) test('duplicate hostId and duplicate subdomain throw distinctly', async () => { const acct = await makeAccount() const host = await makeHost(acct.accountId) await expect(stores.hosts.insert(host)).rejects.toThrow('duplicate hostId') const clash: HostRecord = { ...host, hostId: randomUUID() } await expect(stores.hosts.insert(clash)).rejects.toThrow('duplicate subdomain') }) test('swapStatus to revoked sets revokedAt + version history', async () => { const acct = await makeAccount() const host = await makeHost(acct.accountId) const revokedAt = new Date().toISOString() const swapped = await stores.hosts.swapStatus(host.hostId, 'revoked', revokedAt, 'admin-2') expect(swapped.status).toBe('revoked') expect(swapped.revokedAt).toBe(revokedAt) const versions = await stores.hosts.versions(host.hostId) expect(versions.length).toBe(2) expect(versions[0]).toMatchObject({ version: 1, status: 'offline', revokedAt: null, changedBy: 'system' }) expect(versions[1]).toMatchObject({ version: 2, status: 'revoked', revokedAt, changedBy: 'admin-2' }) }) test('swapStatus on unknown host throws', async () => { await expect(stores.hosts.swapStatus(randomUUID(), 'online', null, 'x')).rejects.toThrow('host not found') }) test('touchLastSeen updates the advisory cache (no version row)', async () => { const acct = await makeAccount() const host = await makeHost(acct.accountId) const ts = new Date(Date.now() + 5000).toISOString() await stores.hosts.touchLastSeen(host.hostId, ts) expect((await stores.hosts.get(host.hostId))?.lastSeen).toBe(ts) expect((await stores.hosts.versions(host.hostId)).length).toBe(1) // unchanged await stores.hosts.touchLastSeen(randomUUID(), ts) // no-op on absent host, no throw }) }) // ---- SessionStore ----------------------------------------------------------------------------- describe('SessionStore', () => { test('insert + get + duplicate + missing', async () => { const acct = await makeAccount() const host = await makeHost(acct.accountId) const rec = { sessionId: randomUUID(), hostId: host.hostId, accountId: acct.accountId, createdAt: new Date().toISOString(), lastAttachAt: new Date().toISOString(), } await stores.sessions.insert(rec) expect(await stores.sessions.get(rec.sessionId)).toEqual(rec) await expect(stores.sessions.insert(rec)).rejects.toThrow('duplicate sessionId') expect(await stores.sessions.get(randomUUID())).toBeNull() }) }) // ---- SubdomainStore --------------------------------------------------------------------------- describe('SubdomainStore', () => { test('reserve/isTaken reflect host ownership of the label', async () => { const sub = `claim-${randomUUID().slice(0, 8)}` expect(await stores.subdomains.isTaken(sub)).toBe(false) expect(await stores.subdomains.reserve(sub)).toBe(true) // free (no host owns it yet) const acct = await makeAccount() await makeHost(acct.accountId, sub) expect(await stores.subdomains.isTaken(sub)).toBe(true) expect(await stores.subdomains.reserve(sub)).toBe(false) // a host now owns it }) }) // ---- PairingStore ----------------------------------------------------------------------------- describe('PairingStore', () => { test('insert + get + casRedeem single-winner + registerFailure', async () => { const acct = await makeAccount() const codeHash = `code-${randomUUID()}` const rec = { codeHash, accountId: acct.accountId, expiresAt: new Date(Date.now() + 60_000).toISOString(), redeemedAt: null, } await stores.pairing.insert(rec) await expect(stores.pairing.insert(rec)).rejects.toThrow('duplicate code_hash') const got = await stores.pairing.get(codeHash) expect(got).toEqual({ record: rec, redeemAttempts: 0 }) const now = new Date().toISOString() expect(await stores.pairing.casRedeem(codeHash, now)).toBe('ok') expect(await stores.pairing.casRedeem(codeHash, now)).toBe('already_redeemed') expect(await stores.pairing.casRedeem(`unknown-${randomUUID()}`, now)).toBe('unknown') expect((await stores.pairing.get(codeHash))?.record.redeemedAt).toBe(now) }) test('registerFailure increments; unknown code returns 0', async () => { const acct = await makeAccount() const codeHash = `code-${randomUUID()}` await stores.pairing.insert({ codeHash, accountId: acct.accountId, expiresAt: new Date(Date.now() + 60_000).toISOString(), redeemedAt: null, }) expect(await stores.pairing.registerFailure(codeHash)).toBe(1) expect(await stores.pairing.registerFailure(codeHash)).toBe(2) expect((await stores.pairing.get(codeHash))?.redeemAttempts).toBe(2) expect(await stores.pairing.registerFailure(`unknown-${randomUUID()}`)).toBe(0) }) test('get returns null for unknown code', async () => { expect(await stores.pairing.get(`nope-${randomUUID()}`)).toBeNull() }) }) // ---- RouteStore ------------------------------------------------------------------------------- describe('RouteStore', () => { test('set → get → refreshTtl → delete', async () => { const hostId = randomUUID() const entry = { relayNodeId: 'node-A', updatedAt: new Date().toISOString() } await stores.routes.set(hostId, entry, 60) expect(await stores.routes.get(hostId)).toEqual(entry) expect(await stores.routes.refreshTtl(hostId, 60)).toBe(true) await stores.routes.delete(hostId) expect(await stores.routes.get(hostId)).toBeNull() }) test('expired route fails closed (get null, refreshTtl false)', async () => { const hostId = randomUUID() const entry = { relayNodeId: 'node-A', updatedAt: new Date().toISOString() } await stores.routes.set(hostId, entry, -1) // already expired expect(await stores.routes.get(hostId)).toBeNull() expect(await stores.routes.refreshTtl(hostId, 60)).toBe(false) expect(await stores.routes.refreshTtl(randomUUID(), 60)).toBe(false) // absent }) test('listByNode returns only live routes targeting the node', async () => { const node = `node-${randomUUID().slice(0, 8)}` const live1 = randomUUID() const live2 = randomUUID() const expired = randomUUID() const otherNode = randomUUID() await stores.routes.set(live1, { relayNodeId: node, updatedAt: new Date().toISOString() }, 60) await stores.routes.set(live2, { relayNodeId: node, updatedAt: new Date().toISOString() }, 60) await stores.routes.set(expired, { relayNodeId: node, updatedAt: new Date().toISOString() }, -1) await stores.routes.set(otherNode, { relayNodeId: 'someone-else', updatedAt: new Date().toISOString() }, 60) const listed = await stores.routes.listByNode(node) const hostIds = listed.map((r) => r.hostId).sort() expect(hostIds).toEqual([live1, live2].sort()) expect(listed.every((r) => r.entry.relayNodeId === node)).toBe(true) }) }) // ---- NodeStore -------------------------------------------------------------------------------- describe('NodeStore', () => { test('upsert + get + setStatus + touch + delete', async () => { const nodeId = `relay-${randomUUID().slice(0, 8)}` const row = { nodeId, addr: '10.0.0.1:9000', status: 'active' as const, lastSeen: new Date().toISOString() } await stores.nodes.upsert(row) expect(await stores.nodes.get(nodeId)).toEqual(row) await stores.nodes.upsert({ ...row, addr: '10.0.0.2:9000' }) // upsert overwrites expect((await stores.nodes.get(nodeId))?.addr).toBe('10.0.0.2:9000') await stores.nodes.setStatus(nodeId, 'draining') expect((await stores.nodes.get(nodeId))?.status).toBe('draining') const later = new Date(Date.now() + 1000).toISOString() await stores.nodes.touch(nodeId, later) expect((await stores.nodes.get(nodeId))?.lastSeen).toBe(later) await stores.nodes.delete(nodeId) expect(await stores.nodes.get(nodeId)).toBeNull() }) test('setStatus on unknown node throws; touch is a silent no-op', async () => { await expect(stores.nodes.setStatus(`ghost-${randomUUID()}`, 'draining')).rejects.toThrow('node not found') await stores.nodes.touch(`ghost-${randomUUID()}`, new Date().toISOString()) // no throw }) }) // ---- MeteringStore ---------------------------------------------------------------------------- describe('MeteringStore', () => { test('append-only + inclusive [from,to] time-window query filtered by account', async () => { const acct = await makeAccount() const other = await makeAccount() const host = await makeHost(acct.accountId) const otherHost = await makeHost(other.accountId) const t0 = '2026-01-01T00:00:00.000Z' const t1 = '2026-01-01T01:00:00.000Z' const t2 = '2026-01-01T02:00:00.000Z' await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 3, sampledAt: t0 }) await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 5, sampledAt: t1 }) await stores.metering.append({ hostId: host.hostId, accountId: acct.accountId, nodeId: 'n1', concurrentViewers: 9, sampledAt: t2 }) await stores.metering.append({ hostId: otherHost.hostId, accountId: other.accountId, nodeId: 'n1', concurrentViewers: 1, sampledAt: t1 }) const inWindow = await stores.metering.query(acct.accountId, t0, t1) expect(inWindow.map((r) => r.concurrentViewers)).toEqual([3, 5]) // t2 excluded, other acct excluded expect(inWindow[0]).toMatchObject({ hostId: host.hostId, accountId: acct.accountId, sampledAt: t0 }) const all = await stores.metering.query(acct.accountId, t0, t2) expect(all.length).toBe(3) }) }) // ---- AuditStore ------------------------------------------------------------------------------- describe('AuditStore', () => { test('append-only + time-window query + null hostId + meta jsonb round-trip', async () => { const accountId = randomUUID() const t0 = '2026-02-01T00:00:00.000Z' const t1 = '2026-02-01T01:00:00.000Z' const t2 = '2026-02-01T02:00:00.000Z' await stores.audit.append({ action: 'provision', principalId: 'p1', accountId, hostId: 'h1', ts: t0, meta: { ip: '1.2.3.4' } }) await stores.audit.append({ action: 'revoke', principalId: 'p2', accountId, hostId: null, ts: t1, meta: {} }) await stores.audit.append({ action: 'late', principalId: 'p3', accountId, hostId: null, ts: t2, meta: {} }) await stores.audit.append({ action: 'other', principalId: 'p9', accountId: randomUUID(), hostId: null, ts: t1, meta: {} }) const rows = await stores.audit.query(accountId, t0, t1) expect(rows.map((r) => r.action)).toEqual(['provision', 'revoke']) // t2 + other account excluded expect(rows[0]).toEqual({ action: 'provision', principalId: 'p1', accountId, hostId: 'h1', ts: t0, meta: { ip: '1.2.3.4' } }) expect(rows[1]?.hostId).toBeNull() }) })