feat(relay): Phase1 foundation — P3 Postgres store + async capability verifier + compose
RELAY-PHASE1 Wave A (2/3) + E1 infra: - A1: createPgStores() Postgres adapter for all 9 P3 store ports + runMigrations(); writable-CTE atomic INV8 status swaps; 0002_routes.sql. 23/23 pg tests, 96.72% cov. - A3: real capability verifier delegating to relay-auth verifyCapabilityToken; sync->async seam across authz/provision/main. Full CP suite 15 files/101 pass, tsc clean. - E1: deploy/docker-compose.yml (Postgres16+Redis7, loopback-only) + .env.example. - docs: PLAN_RELAY_PHASE1.md file-level execution spec; PROGRESS_LOG RELAY-PHASE1 section.
This commit is contained in:
390
control-plane/test/store/pg.test.ts
Normal file
390
control-plane/test/store/pg.test.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* 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<typeof createPgPool> | 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<AccountRecord> {
|
||||
const rec = newAccount()
|
||||
await stores.accounts.insert(rec)
|
||||
return rec
|
||||
}
|
||||
async function makeHost(accountId: string, subdomain = `sub-${randomUUID().slice(0, 8)}`): Promise<HostRecord> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user