Skip to content

Commit f971f6e

Browse files
test(v2): verify synthetic phase gate persistence in PostgreSQL
1 parent dc974a2 commit f971f6e

4 files changed

Lines changed: 378 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,31 @@ jobs:
150150
- name: Apply database migrations
151151
run: npm run db:v2:migrate:deploy
152152

153+
- name: Prepare synthetic phase gate PostgreSQL proof database
154+
run: |
155+
postgres_container="$(docker ps --filter 'ancestor=pgvector/pgvector:0.8.5-pg16-trixie' --format '{{.ID}}')"
156+
test -n "$postgres_container"
157+
docker exec -e PGPASSWORD=apollo-ci "$postgres_container" createdb -U apollo apollo_synthetic_gate_e2e
158+
npm run db:v2:migrate:deploy
159+
env:
160+
V2_DATABASE_URL: postgresql://apollo:apollo-ci@127.0.0.1:5432/apollo_synthetic_gate_e2e?schema=public&application_name=apollo-video-e2e-synthetic-phase-gate-migrate-${{ github.run_id }}&connection_limit=5&pool_timeout=10&connect_timeout=10
161+
162+
- name: Run synthetic phase gate PostgreSQL proof
163+
run: npm run test:integration:synthetic-phase-gate-pg
164+
env:
165+
APOLLO_SYNTHETIC_PHASE_GATE_PG_E2E: "1"
166+
V2_DATABASE_URL: postgresql://apollo:apollo-ci@127.0.0.1:5432/apollo_synthetic_gate_e2e?schema=public&application_name=apollo-video-e2e-synthetic-phase-gate-${{ github.run_id }}&connection_limit=5&pool_timeout=10&connect_timeout=10
167+
168+
- name: Verify synthetic phase gate PostgreSQL cleanup
169+
if: always()
170+
run: |
171+
postgres_container="$(docker ps --filter 'ancestor=pgvector/pgvector:0.8.5-pg16-trixie' --format '{{.ID}}')"
172+
test -n "$postgres_container"
173+
orphans="$(docker exec -e PGPASSWORD=apollo-ci "$postgres_container" psql -U apollo -d postgres -Atc "select count(*) from pg_stat_activity where application_name = '$APOLLO_SYNTHETIC_PHASE_GATE_APP_NAME'")"
174+
test "$orphans" = 0
175+
env:
176+
APOLLO_SYNTHETIC_PHASE_GATE_APP_NAME: apollo-video-e2e-synthetic-phase-gate-${{ github.run_id }}
177+
153178
- name: Run source cleanup and separation FFmpeg goldens
154179
run: npm run test:integration:source-cleanup-goldens
155180

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@
134134
"test:integration:quality-iteration": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --env-file=.env --test tests/v2/prisma-quality-iteration.integration.mjs",
135135
"test:integration:project-duplication": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --env-file=.env --test tests/v2/prisma-project-duplication.integration.mjs",
136136
"test:integration:mvp-core-gate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --env-file=.env --test tests/v2/prisma-mvp-core-gate.integration.mjs",
137+
"test:integration:synthetic-phase-gate-pg": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --env-file-if-exists=.env --test tests/v2/prisma-synthetic-phase-gate.integration.mjs",
137138
"test:integration:speech-segments": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --env-file=.env --test tests/v2/prisma-speech-segment-catalog.integration.mjs",
138139
"test:integration:evidence-segments": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --env-file=.env --test tests/v2/prisma-speech-segment-catalog.integration.mjs",
139140
"test:integration:long-form-moments": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --env-file=.env --test tests/v2/prisma-long-form-moment-catalog.integration.mjs",
Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
import assert from 'node:assert/strict'
2+
import { randomUUID } from 'node:crypto'
3+
import test from 'node:test'
4+
5+
import { PrismaClient } from '../../generated/prisma-v2/index.js'
6+
7+
const RUN = process.env.APOLLO_SYNTHETIC_PHASE_GATE_PG_E2E === '1'
8+
const SKIP = RUN
9+
? false
10+
: 'set APOLLO_SYNTHETIC_PHASE_GATE_PG_E2E=1 with a migrated local E2E PostgreSQL'
11+
12+
function assertSafeDatabaseUrl() {
13+
assert.ok(process.env.V2_DATABASE_URL, 'V2_DATABASE_URL must name a disposable local PostgreSQL')
14+
const url = new URL(process.env.V2_DATABASE_URL)
15+
assert.ok(
16+
['localhost', '127.0.0.1', '::1'].includes(url.hostname),
17+
'the synthetic phase gate proof is restricted to local PostgreSQL',
18+
)
19+
assert.match(
20+
url.pathname.slice(1),
21+
/(?:^|_)e2e(?:_|$)/,
22+
'the database name must explicitly identify an E2E database',
23+
)
24+
const applicationName = url.searchParams.get('application_name') ?? ''
25+
assert.match(
26+
applicationName,
27+
/^apollo-video-e2e-synthetic-phase-gate-[a-z0-9-]+$/,
28+
'application_name must identify one supervised synthetic phase gate run',
29+
)
30+
for (const [parameter, maximum] of [
31+
['connection_limit', 5],
32+
['pool_timeout', 10],
33+
['connect_timeout', 10],
34+
]) {
35+
const value = Number(url.searchParams.get(parameter))
36+
assert.ok(
37+
Number.isInteger(value) && value >= 1 && value <= maximum,
38+
`${parameter} must be an integer between 1 and ${maximum}`,
39+
)
40+
}
41+
return applicationName
42+
}
43+
44+
function actorFor(createExternalAuditContext, { workspaceId, clientId, credentialId }) {
45+
const auditContext = createExternalAuditContext({
46+
workspaceId,
47+
clientId,
48+
credentialId,
49+
environment: 'production',
50+
})
51+
return Object.freeze({
52+
...auditContext,
53+
scopes: new Set(['projects:write']),
54+
authenticationKind: 'bearer',
55+
clientKillSwitchEngaged: false,
56+
workspaceKillSwitchEngaged: false,
57+
clientAccessStatus: 'active',
58+
workspaceAccessStatus: 'active',
59+
auditContext,
60+
})
61+
}
62+
63+
async function seedWorkspace(prisma, { workspaceId, clientId, now }) {
64+
await prisma.v2Workspace.create({
65+
data: {
66+
id: workspaceId,
67+
slug: workspaceId,
68+
name: `Synthetic phase gate ${workspaceId}`,
69+
createdAt: now,
70+
updatedAt: now,
71+
},
72+
})
73+
await prisma.v2ApiClient.create({
74+
data: {
75+
id: clientId,
76+
workspaceId,
77+
name: `Synthetic phase gate ${clientId}`,
78+
allowedEnvironmentsJson: '["production"]',
79+
scopeGrantsJson: '["projects:write"]',
80+
createdBy: 'synthetic-phase-gate-pg-e2e',
81+
createdAt: now,
82+
updatedAt: now,
83+
},
84+
})
85+
}
86+
87+
async function seedProjectVersion(prisma, {
88+
workspaceId,
89+
clientId,
90+
projectId,
91+
versionId,
92+
versionHash,
93+
now,
94+
}) {
95+
await prisma.v2Project.create({
96+
data: {
97+
id: projectId,
98+
workspaceId,
99+
name: 'Synthetic phase gate PostgreSQL proof',
100+
status: 'reviewing-proxy',
101+
objective: 'awareness',
102+
format: '16:9',
103+
locale: 'pt-BR',
104+
createdByType: 'api-client',
105+
createdById: clientId,
106+
createdAt: now,
107+
updatedAt: now,
108+
},
109+
})
110+
const snapshots = [
111+
{ kind: 'brief', suffix: 'brief', hash: 'a'.repeat(64), schemaVersion: 1 },
112+
{ kind: 'edit-plan', suffix: 'edit-plan', hash: 'b'.repeat(64), schemaVersion: 2 },
113+
{ kind: 'policies', suffix: 'policies', hash: 'c'.repeat(64), schemaVersion: 1 },
114+
]
115+
await prisma.v2ProjectSnapshot.createMany({
116+
data: snapshots.map((snapshot) => ({
117+
id: `${projectId}-snapshot-${snapshot.suffix}`,
118+
workspaceId,
119+
projectId,
120+
kind: snapshot.kind,
121+
schemaVersion: snapshot.schemaVersion,
122+
contentJson: JSON.stringify({ kind: snapshot.kind }),
123+
contentHash: snapshot.hash,
124+
createdAt: now,
125+
})),
126+
})
127+
await prisma.v2ProjectVersion.create({
128+
data: {
129+
id: versionId,
130+
workspaceId,
131+
projectId,
132+
sequence: 1,
133+
briefSnapshotId: `${projectId}-snapshot-brief`,
134+
editPlanSnapshotId: `${projectId}-snapshot-edit-plan`,
135+
policiesSnapshotId: `${projectId}-snapshot-policies`,
136+
baseHash: versionHash,
137+
createdBy: clientId,
138+
createdAt: now,
139+
},
140+
})
141+
await prisma.v2Project.update({
142+
where: { id: projectId },
143+
data: { currentVersionId: versionId },
144+
})
145+
}
146+
147+
async function removeFixtures(prisma, workspaceIds) {
148+
await prisma.$transaction(async (transaction) => {
149+
await transaction.v2SyntheticPhaseGate.deleteMany({
150+
where: { workspaceId: { in: workspaceIds } },
151+
})
152+
await transaction.v2Project.updateMany({
153+
where: { workspaceId: { in: workspaceIds } },
154+
data: { currentVersionId: null },
155+
})
156+
await transaction.v2ProjectVersion.deleteMany({
157+
where: { workspaceId: { in: workspaceIds } },
158+
})
159+
await transaction.v2ProjectSnapshot.deleteMany({
160+
where: { workspaceId: { in: workspaceIds } },
161+
})
162+
await transaction.v2Project.deleteMany({
163+
where: { workspaceId: { in: workspaceIds } },
164+
})
165+
await transaction.v2ApiClient.deleteMany({
166+
where: { workspaceId: { in: workspaceIds } },
167+
})
168+
await transaction.v2Workspace.deleteMany({
169+
where: { id: { in: workspaceIds } },
170+
})
171+
})
172+
}
173+
174+
test('T-F3-GATE persists a truthful internal rejection in PostgreSQL and enforces its fences', {
175+
skip: SKIP,
176+
timeout: 30_000,
177+
}, async () => {
178+
const applicationName = assertSafeDatabaseUrl()
179+
const { createExternalAuditContext } = await import(
180+
'../../src/v2/application/authenticate-api-client.ts'
181+
)
182+
const { runSyntheticPhaseGateService } = await import(
183+
'../../src/v2/application/run-synthetic-phase-gate.ts'
184+
)
185+
const { PrismaSyntheticPhaseGateRepository } = await import(
186+
'../../src/v2/infrastructure/prisma/synthetic-phase-gate-repository.ts'
187+
)
188+
189+
const prisma = new PrismaClient()
190+
const suffix = randomUUID().replaceAll('-', '').slice(0, 12)
191+
const workspaceId = `synthetic-gate-e2e-${suffix}`
192+
const otherWorkspaceId = `synthetic-gate-other-e2e-${suffix}`
193+
const clientId = `synthetic-gate-client-${suffix}`
194+
const otherClientId = `synthetic-gate-other-client-${suffix}`
195+
const projectId = `synthetic-gate-project-${suffix}`
196+
const versionId = `synthetic-gate-version-${suffix}`
197+
const versionHash = 'd'.repeat(64)
198+
const now = new Date('2026-09-09T12:00:00.000Z')
199+
const workspaceIds = [workspaceId, otherWorkspaceId]
200+
let primaryError
201+
202+
try {
203+
const session = await prisma.$queryRawUnsafe(
204+
"SELECT current_setting('application_name') AS name",
205+
)
206+
assert.equal(session[0]?.name, applicationName)
207+
208+
await seedWorkspace(prisma, { workspaceId, clientId, now })
209+
await seedWorkspace(prisma, {
210+
workspaceId: otherWorkspaceId,
211+
clientId: otherClientId,
212+
now,
213+
})
214+
await seedProjectVersion(prisma, {
215+
workspaceId,
216+
clientId,
217+
projectId,
218+
versionId,
219+
versionHash,
220+
now,
221+
})
222+
223+
const actor = actorFor(createExternalAuditContext, {
224+
workspaceId,
225+
clientId,
226+
credentialId: `synthetic-gate-credential-${suffix}`,
227+
})
228+
const otherActor = actorFor(createExternalAuditContext, {
229+
workspaceId: otherWorkspaceId,
230+
clientId: otherClientId,
231+
credentialId: `synthetic-gate-other-credential-${suffix}`,
232+
})
233+
const repository = new PrismaSyntheticPhaseGateRepository(prisma)
234+
const run = runSyntheticPhaseGateService({
235+
repository,
236+
clock: () => now,
237+
createId: () => `synthetic-phase-gate-${suffix}`,
238+
})
239+
const request = {
240+
workspaceId,
241+
projectId,
242+
projectVersionId: versionId,
243+
projectVersionHash: versionHash,
244+
actor,
245+
idempotencyKey: `synthetic-phase-gate-${suffix}`,
246+
}
247+
248+
const created = await run(request)
249+
assert.equal(created.replayed, false)
250+
assert.equal(created.gate.report.approved, false)
251+
assert.equal(created.gate.report.covered, 0)
252+
assert.equal(created.gate.report.passed, 0)
253+
assert.equal(created.gate.report.total, 4)
254+
assert.deepEqual(created.gate.report.evidence, [])
255+
assert.match(created.gate.reportFingerprint, /^[a-f0-9]{64}$/)
256+
assert.match(created.gate.recordHash, /^[a-f0-9]{64}$/)
257+
258+
const stored = await prisma.v2SyntheticPhaseGate.findUnique({
259+
where: { id: created.gate.id },
260+
include: { evidence: true },
261+
})
262+
assert.ok(stored)
263+
assert.equal(stored.approved, false)
264+
assert.equal(stored.covered, 0)
265+
assert.equal(stored.createdById, clientId)
266+
assert.equal(stored.actorCredentialId, actor.credentialId)
267+
assert.deepEqual(stored.evidence, [])
268+
269+
const replay = await run(request)
270+
assert.equal(replay.replayed, true)
271+
assert.equal(replay.gate.id, created.gate.id)
272+
assert.equal(replay.gate.recordHash, created.gate.recordHash)
273+
assert.equal(await prisma.v2SyntheticPhaseGate.count({ where: { workspaceId } }), 1)
274+
275+
await assert.rejects(
276+
run({ ...request, projectVersionHash: 'f'.repeat(64) }),
277+
(error) => error.code === 'IDEMPOTENCY_PAYLOAD_MISMATCH',
278+
)
279+
await assert.rejects(
280+
run({
281+
...request,
282+
projectVersionHash: 'f'.repeat(64),
283+
idempotencyKey: `synthetic-phase-gate-stale-${suffix}`,
284+
}),
285+
(error) => error.code === 'VERSION_CONFLICT',
286+
)
287+
await assert.rejects(
288+
run({
289+
...request,
290+
workspaceId: otherWorkspaceId,
291+
actor: otherActor,
292+
idempotencyKey: `synthetic-phase-gate-isolation-${suffix}`,
293+
}),
294+
(error) => error.code === 'PROJECT_NOT_FOUND',
295+
)
296+
assert.deepEqual(await repository.list({
297+
workspaceId: otherWorkspaceId,
298+
projectId,
299+
limit: 10,
300+
}), [])
301+
assert.equal(await prisma.v2SyntheticPhaseGate.count({ where: { workspaceId } }), 1)
302+
303+
await prisma.v2SyntheticPhaseGate.update({
304+
where: { id: created.gate.id },
305+
data: { reportFingerprint: 'f'.repeat(64) },
306+
})
307+
await assert.rejects(
308+
repository.list({ workspaceId, projectId, limit: 10 }),
309+
(error) => error.code === 'PERSISTENCE_CONFLICT',
310+
)
311+
await prisma.v2SyntheticPhaseGate.update({
312+
where: { id: created.gate.id },
313+
data: { reportFingerprint: stored.reportFingerprint },
314+
})
315+
316+
await prisma.v2SyntheticPhaseGate.update({
317+
where: { id: created.gate.id },
318+
data: { recordHash: 'e'.repeat(64) },
319+
})
320+
await assert.rejects(
321+
repository.list({ workspaceId, projectId, limit: 10 }),
322+
(error) => error.code === 'PERSISTENCE_CONFLICT',
323+
)
324+
await prisma.v2SyntheticPhaseGate.update({
325+
where: { id: created.gate.id },
326+
data: { recordHash: stored.recordHash },
327+
})
328+
} catch (error) {
329+
primaryError = error
330+
throw error
331+
} finally {
332+
const teardownErrors = []
333+
try {
334+
await removeFixtures(prisma, workspaceIds)
335+
} catch (error) {
336+
teardownErrors.push(error)
337+
}
338+
try {
339+
await prisma.$disconnect()
340+
} catch (error) {
341+
teardownErrors.push(error)
342+
}
343+
if (teardownErrors.length > 0) {
344+
throw new AggregateError(
345+
primaryError ? [primaryError, ...teardownErrors] : teardownErrors,
346+
primaryError
347+
? 'synthetic phase gate proof and teardown both failed'
348+
: 'synthetic phase gate proof teardown failed',
349+
)
350+
}
351+
}
352+
})

tests/v2/synthetic-phase-gate-prisma.test.mjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,3 @@ test('T-F3-GATE Prisma adapter persists a truthful rejected gate and replays it'
117117
(error) => error.code === 'PERSISTENCE_CONFLICT',
118118
)
119119
})
120-

0 commit comments

Comments
 (0)