Skip to content

Commit 3ee434c

Browse files
test(e2e): W2 LIVE anon provision coverage for all 7 resource flows
Extends the WS1-P1 LIVE real-backend harness (PR #170) to cover every anonymous resource-provision flow end-to-end against the real api: vector/cache/nosql/queue/storage/webhook (db is already covered by live-provision-smoke.spec.ts). - e2e/live-anon-provision.spec.ts: registry-driven (rule 18) one test per service. Asserts POST /S/new -> 201, usable connection_url/receive_url matching the per-service scheme (postgres/redis/mongodb/nats/s3/webhook), tier='anonymous', echoed env (rule 11), and the cohort marker on the name. Per-service extras: storage mode, vector pgvector extension, queue auth_mode. - Cohort-safe + reaped (rule 24): ledger-before-assert, inline reap on the happy path, afterAll backstop, and the out-of-process reap-cohort.ts in CI teardown. Anon 24h TTL is a backstop we never rely on. - Gated behind E2E_LIVE=1; per-service 503 skips loudly. Named live-*.spec.ts so the default mocked config testIgnores it, the live config testMatches it, and `npm run reap:live` + the e2e-live workflow pick it up with no config or CI change. Verified: npm run gate green (tsc + build + 1115 vitest pass); live config lists 7 tests across 2 files; cleanly skips (6 skipped) when E2E_LIVE unset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e700436 commit 3ee434c

1 file changed

Lines changed: 277 additions & 0 deletions

File tree

e2e/live-anon-provision.spec.ts

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
// WS1-P2 (matrix W2) — real-backend (LIVE) E2E covering EVERY anonymous
2+
// resource-provision flow end-to-end against the real api.
3+
//
4+
// Plan: docs/sessions/2026-06-04/USER-FLOW-INVENTORY-AND-TEST-MATRIX.md
5+
// - §B1 "Anon provision (each service)" — tier='anonymous', env echoed.
6+
// - §C1.S "Provision S happy" — usable connection/URL returned per service.
7+
// - §W2 "Extend live-provision-smoke.spec.ts pattern to
8+
// vector/cache/nosql/queue/storage/webhook" (this file).
9+
//
10+
// db (/db/new) is already covered by live-provision-smoke.spec.ts (WS1-P1).
11+
// This file covers the SIX remaining anonymous provision flows so all seven
12+
// services have a real-backend, cohort-safe, reaped LIVE spec:
13+
//
14+
// /vector/new /cache/new /nosql/new /queue/new /storage/new /webhook/new
15+
//
16+
// It mirrors the WS1-P1 smoke spec EXACTLY (E2E_LIVE=1 gating, cohort branding,
17+
// ledger-before-assert, inline reap + afterAll backstop + standalone reaper) so
18+
// the safety properties proven there carry over unchanged. The only new thing
19+
// is breadth: a single registry (PROVISION_FLOWS) drives one identical test per
20+
// service, so adding/removing a service is a one-line table edit and a future
21+
// route-iterating done-bar test (rule 18) can see all flows as covered without
22+
// a hand-typed checklist.
23+
//
24+
// ── Gating ───────────────────────────────────────────────────────────────────
25+
// Gated behind E2E_LIVE=1. In normal PR CI (no live backend) the whole file
26+
// SKIPS loudly — normal CI must NEVER depend on a live backend. It runs only
27+
// when an operator/scheduled job sets E2E_LIVE=1 + E2E_API_URL to a reachable
28+
// (staging) api. A 503 from a per-service provisioning backend ALSO skips
29+
// (loudly) for that service only, so a stack with (say) NATS disabled reports
30+
// queue as skipped rather than a false red.
31+
//
32+
// ── Cohort safety (rule 24) ──────────────────────────────────────────────────
33+
// Every provisioned resource is cohort-branded (cohortName) and recorded to the
34+
// on-disk cleanup ledger the instant it is created — BEFORE any throwing
35+
// assertion — then reaped inline on the happy path, by afterAll on a thrown
36+
// assertion, and by the out-of-process reap-cohort.ts in CI teardown if the
37+
// whole process dies. Anonymous resources also carry a 24h TTL, but per rule 24
38+
// we never rely on it — we reap explicitly.
39+
//
40+
// Until the backend skip-cohort guards ship (separate api/worker PR), run this
41+
// against STAGING, not prod — see cohort.ts:14-19.
42+
43+
import { expect, test, type APIRequestContext } from '@playwright/test'
44+
45+
import { cohortName, COHORT_MARKER } from './cohort'
46+
import {
47+
recordEntity,
48+
loadLedger,
49+
reapEntities,
50+
clearLedger,
51+
type CohortEntity,
52+
} from './cleanup-ledger'
53+
54+
const LIVE = process.env.E2E_LIVE === '1'
55+
const API_URL = (process.env.E2E_API_URL ?? process.env.AGENT_API_URL ?? '')
56+
.toString()
57+
.replace(/\/$/, '')
58+
59+
/** HTTP 201 — every /S/new provision returns Created. */
60+
const STATUS_CREATED = 201
61+
/** HTTP 503 — per-service backend disabled in this stack: skip (not a red). */
62+
const STATUS_BACKEND_UNAVAILABLE = 503
63+
64+
/**
65+
* One anonymous provision flow. `connKey` is the response field carrying the
66+
* usable connection/URL (webhook uses `receive_url`, everything else
67+
* `connection_url`). `connPattern` proves the value is a real, usable endpoint
68+
* for that service (a live backend really minted it). `extra` runs any
69+
* service-specific shape assertion (e.g. storage echoes `mode`).
70+
*/
71+
interface ProvisionFlow {
72+
/** Human label, also used in the cohort name + ledger note. */
73+
label: string
74+
/** Anon provision endpoint, e.g. `/cache/new`. */
75+
path: string
76+
/** Response field holding the usable connection string / URL. */
77+
connKey: 'connection_url' | 'receive_url'
78+
/** Regex the connection value must match (proves a usable, real endpoint). */
79+
connPattern: RegExp
80+
/** Optional extra per-service assertion on the parsed JSON body. */
81+
extra?: (body: Record<string, unknown>) => void
82+
}
83+
84+
// The registry. The matrix's seventh service (db) is covered by
85+
// live-provision-smoke.spec.ts; the six below complete the set. Editing this
86+
// table is the ONLY change needed to add/remove a covered service.
87+
const PROVISION_FLOWS: ProvisionFlow[] = [
88+
{
89+
label: 'vector',
90+
path: '/vector/new',
91+
connKey: 'connection_url',
92+
connPattern: /^postgres(ql)?:\/\//,
93+
extra: (b) =>
94+
expect(b.extension, 'vector provision echoes pgvector extension').toBe('pgvector'),
95+
},
96+
{
97+
label: 'cache',
98+
path: '/cache/new',
99+
connKey: 'connection_url',
100+
connPattern: /^rediss?:\/\//,
101+
},
102+
{
103+
label: 'nosql',
104+
path: '/nosql/new',
105+
connKey: 'connection_url',
106+
connPattern: /^mongodb(\+srv)?:\/\//,
107+
},
108+
{
109+
label: 'queue',
110+
path: '/queue/new',
111+
connKey: 'connection_url',
112+
connPattern: /^nats:\/\//,
113+
extra: (b) =>
114+
// auth_mode is legacy_open in prod today (CLAUDE.md P1) but the field
115+
// must always be present so the caller can branch on it.
116+
expect(b.auth_mode, 'queue provision echoes an auth_mode').toBeTruthy(),
117+
},
118+
{
119+
label: 'storage',
120+
path: '/storage/new',
121+
connKey: 'connection_url',
122+
// Bucket URL — http(s) (DO Spaces public endpoint) or s3:// form.
123+
connPattern: /^(https?|s3):\/\//,
124+
extra: (b) =>
125+
// /storage/new always echoes the resolved isolation mode (rule: surface
126+
// the tenant's real isolation). prefix-scoped on today's do-spaces prod.
127+
expect(b.mode, 'storage provision echoes a resolved isolation mode').toBeTruthy(),
128+
},
129+
{
130+
label: 'webhook',
131+
path: '/webhook/new',
132+
connKey: 'receive_url',
133+
connPattern: /^https?:\/\/.+\/webhook\/receive\//,
134+
},
135+
]
136+
137+
// Unique source IP per call so the per-fingerprint dedup cap (5/day, rule 6)
138+
// doesn't hand back an EXISTING token — mirrors live-provision-smoke.spec.ts
139+
// and auth-roundtrip.spec.ts uniqueIP().
140+
function uniqueIP(): string {
141+
const b = () => Math.floor(Math.random() * 254) + 1
142+
return `10.${b()}.${b()}.${b()}`
143+
}
144+
145+
test.describe('LIVE — every anonymous provision flow → backend-assert → reap', () => {
146+
test.describe.configure({ mode: 'serial' })
147+
148+
// Hard skip in normal CI: the LIVE harness must never make the per-PR gate
149+
// depend on a reachable backend.
150+
test.skip(
151+
!LIVE,
152+
'E2E_LIVE!=1 — real-backend anon-provision suite is opt-in. Set E2E_LIVE=1 + ' +
153+
'E2E_API_URL=<staging api> to run it.',
154+
)
155+
test.skip(
156+
LIVE && !API_URL,
157+
'E2E_LIVE=1 but E2E_API_URL/AGENT_API_URL is unset — no backend to target.',
158+
)
159+
160+
// Backstop reaper (rule 24): even if a per-service test throws before its
161+
// inline reap, afterAll reaps every still-ledgered entity. The standalone
162+
// reap-cohort.ts re-runs this same path in CI teardown if the whole process
163+
// dies — belt, braces, and a 24h TTL we never rely on.
164+
test.afterAll(async ({ playwright }) => {
165+
const entities = loadLedger()
166+
if (entities.length === 0) return
167+
const ctx = await playwright.request.newContext()
168+
try {
169+
const result = await reapEntities(ctx, entities)
170+
// eslint-disable-next-line no-console
171+
console.log(
172+
`[anon-provision afterAll] reaped attempted=${result.attempted} ` +
173+
`deleted=${result.deleted} alreadyGone=${result.alreadyGone} ` +
174+
`failed=${result.failed.length}`,
175+
)
176+
if (result.failed.length === 0) clearLedger()
177+
} finally {
178+
await ctx.dispose()
179+
}
180+
})
181+
182+
for (const flow of PROVISION_FLOWS) {
183+
test(`provision anon ${flow.label} (${flow.path}), assert usable + tier+env, then reap`, async ({
184+
request,
185+
}: {
186+
request: APIRequestContext
187+
}) => {
188+
const name = cohortName(`anon-${flow.label}`)
189+
190+
// ── Create: real anonymous provision against the live api ──────────────
191+
const resp = await request.fetch(`${API_URL}${flow.path}`, {
192+
method: 'POST',
193+
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': uniqueIP() },
194+
data: JSON.stringify({ name }),
195+
failOnStatusCode: false,
196+
})
197+
198+
test.skip(
199+
resp.status() === STATUS_BACKEND_UNAVAILABLE,
200+
`${flow.label} service returned 503 at ${API_URL} — provisioning backend not ` +
201+
`enabled in this stack; cannot create a resource to reap. Reports skipped.`,
202+
)
203+
204+
expect(
205+
resp.status(),
206+
`POST ${flow.path} should return ${STATUS_CREATED}; got ${resp.status()}. ` +
207+
`Body: ${await resp.text().catch(() => '<unreadable>')}`,
208+
).toBe(STATUS_CREATED)
209+
210+
const body = (await resp.json()) as Record<string, unknown>
211+
const token = String(body.token ?? '')
212+
213+
// ── Record to the ledger IMMEDIATELY, before any throwing assertion, so a
214+
// failed assert below still leaves a reapable record (rule 24). ───────
215+
const entity: Omit<CohortEntity, 'recordedAt'> = {
216+
kind: 'resource',
217+
id: token,
218+
apiUrl: API_URL,
219+
note: `${flow.label} ${name}`,
220+
}
221+
recordEntity(entity)
222+
// Storage objects are tenant-prefix-scoped under the owning resource; log
223+
// the prefix entry so the reaper report shows the bucket prefix is reaped
224+
// via its owning resource (deletePath() no-ops on this kind).
225+
if (flow.label === 'storage') {
226+
recordEntity({
227+
kind: 'storage-prefix',
228+
id: token,
229+
apiUrl: API_URL,
230+
note: `storage prefix for ${name}`,
231+
})
232+
}
233+
234+
// ── Backend assertions: a real, usable resource was created. ───────────
235+
expect(body.ok, `${flow.label}/new ok flag`).toBe(true)
236+
expect(token, `${flow.path} must return a resource token`).toBeTruthy()
237+
expect(body.id, `${flow.path} must return a resource id`).toBeTruthy()
238+
expect(body.tier, `anon ${flow.label} provision is tier=anonymous`).toBe('anonymous')
239+
// env is echoed on every provision (rule 11 — default 'development').
240+
expect(body.env, `${flow.path} must echo the resolved env (rule 11)`).toBeTruthy()
241+
// The usable connection/URL — proves a real backend minted a live endpoint.
242+
const conn = String(body[flow.connKey] ?? '')
243+
expect(
244+
conn,
245+
`${flow.path} must return a usable ${flow.connKey} matching ${flow.connPattern} ` +
246+
`(proves a real ${flow.label} endpoint was provisioned); got '${conn}'`,
247+
).toMatch(flow.connPattern)
248+
// Cohort branding round-trips through the backend — confirms the marker
249+
// the future skip-cohort guards key on actually lands on the row's name.
250+
expect(
251+
body.name,
252+
`provisioned name must carry the cohort marker '${COHORT_MARKER}' so backend ` +
253+
`guards can identify it; got '${String(body.name)}'`,
254+
).toContain(COHORT_MARKER)
255+
// Per-service extra shape assertion (mode / extension / auth_mode / …).
256+
flow.extra?.(body)
257+
258+
// ── Reap inline (happy path). afterAll + reap-cohort.ts are the backstops
259+
// if this is skipped by a thrown assertion above. ────────────────────
260+
const result = await reapEntities(request, [
261+
{ ...entity, recordedAt: new Date().toISOString() },
262+
])
263+
expect(
264+
result.failed.length,
265+
`reaping the provisioned ${flow.label} resource failed: ${JSON.stringify(result.failed)}`,
266+
).toBe(0)
267+
expect(
268+
result.deleted + result.alreadyGone,
269+
`the ${flow.label} resource should be deleted (or already gone) after reap`,
270+
).toBeGreaterThan(0)
271+
272+
// Clear the ledger so afterAll doesn't re-try a now-deleted token (a no-op
273+
// 404, but keeps the ledger truthful between serial tests in this file).
274+
clearLedger()
275+
})
276+
}
277+
})

0 commit comments

Comments
 (0)