Skip to content

Commit f2b9e0f

Browse files
fix(e2e): prod LIVE provisions + reaps as minted pro account (#176)
Two prod-only failures from run 26997362193: 1. Anon provisions 402 (free_tier_recycle_requires_claim) — the X-E2E-Test-Token bypass was sending X-Forwarded-For, but the api's fingerprint middleware reads the dedicated X-E2E-Source-IP header for the override (XFF is overwritten by ingress-nginx on prod). So every anon provision collapsed onto one fingerprint that had a recycle_seen marker → 402. 2. Anon reap 401 — there is no unauthed resource-delete on the api, so the ledger reaper's DELETE /api/v1/resources/:id 401'd on anon resources. Fix (decisive: provision + reap AS the minted account on prod): - cohort.ts: anonProvisionHeaders now sends X-E2E-Source-IP (the header the bypass actually reads) with a unique IP per call → fresh fingerprint → no recycle gate. Adds authedProvisionHeaders() + provisionIdentity(): when E2E_SESSION_JWT is set (sanctioned prod run) resources are provisioned AS the minted is_test_cohort PRO account (no recycle gate, tier has headroom) and the provision bearer is recorded on the ledger so the reaper's authed DELETE returns 200, not 401. - live-provision-smoke + live-anon-provision: use provisionIdentity(); assert the identity's tier ('pro' authed / 'anonymous' otherwise); record id.bearer for the authed reap. - live-claim-deploy: anon claim legs get fresh fingerprints via the source-IP fix; the deploy-lifecycle leg now deploys AS the minted pro account directly (deployments_apps=10) instead of skipping on prod — falls back to the dev-only set-tier path on staging. npm run gate green (tsc + build + vitest: 1115 passed, 3 skipped). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e7757c9 commit f2b9e0f

4 files changed

Lines changed: 212 additions & 65 deletions

File tree

e2e/cohort.ts

Lines changed: 109 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -169,35 +169,128 @@ export function mintedSession(): MintedSession | null {
169169
}
170170

171171
// ── Per-fingerprint bypass for anonymous provisions (ISSUE 1) ────────────────
172-
// Prod does NOT trust X-Forwarded-For, so the CI runner's many anon provisions
173-
// all share ONE real fingerprint (SHA256(/24 + ASN), rule 6) and trip the
174-
// free-tier recycle/dedup gate → 402 free_tier_recycle_requires_claim. The api
175-
// exposes a real bypass: the X-E2E-Test-Token header (api internal/middleware/
176-
// fingerprint.go) — a matching token skips the per-fingerprint cap. The token is
177-
// a CI secret (E2E_TEST_TOKEN); when unset (local dev / un-tokened run) we send
178-
// no bypass header and rely on X-Forwarded-For varying the fingerprint instead.
179-
180-
/** The header name the api's fingerprint middleware honours to skip the cap. */
172+
// Prod does NOT trust X-Forwarded-For (ingress-nginx overwrites it with the real
173+
// client IP), so the CI runner's many anon provisions all collapse onto ONE real
174+
// fingerprint (SHA256(/24 + ASN), rule 6). That single fingerprint trips the
175+
// free-tier recycle gate → 402 free_tier_recycle_requires_claim the moment a
176+
// previous run's anonymous resource has aged out (recycle_seen:<fp> set, no
177+
// active resource — api internal/handlers/provision_helper.go).
178+
//
179+
// The api's REAL bypass (internal/middleware/fingerprint.go): a request bearing
180+
// a valid X-E2E-Test-Token (matched against the cluster's E2E_TEST_TOKEN secret)
181+
// may override the fingerprint source IP via the dedicated **X-E2E-Source-IP**
182+
// header — NOT X-Forwarded-For (which the ingress overwrites). A unique source
183+
// IP per call yields a fresh fingerprint per provision → no recycle_seen marker
184+
// → no 402. Sending only X-Forwarded-For (the previous bug) did nothing on prod
185+
// because the middleware never reads XFF for the override. We now send BOTH the
186+
// token AND X-E2E-Source-IP so the override actually takes on prod; XFF is kept
187+
// for staging/local where the proxy IS trusted.
188+
189+
/** The header name the api's fingerprint middleware honours to accept the bypass token. */
181190
export const E2E_TEST_TOKEN_HEADER = 'X-E2E-Test-Token'
191+
/** The header the api's fingerprint middleware reads for the override source IP. */
192+
export const E2E_SOURCE_IP_HEADER = 'X-E2E-Source-IP'
182193

183194
/**
184-
* Headers an anonymous provision should carry: a unique X-Forwarded-For (varies
185-
* the fingerprint where the proxy IS trusted — staging/local) PLUS the
186-
* X-E2E-Test-Token bypass when E2E_TEST_TOKEN is set (the only thing that gets
187-
* past the per-fingerprint recycle gate on prod, which ignores X-Forwarded-For).
188-
* Both are harmless together: the bypass wins on prod, the XFF varies elsewhere.
195+
* Headers an anonymous provision should carry. On prod the load-bearing pair is
196+
* X-E2E-Test-Token (the bypass token) + X-E2E-Source-IP (a unique override IP →
197+
* a fresh fingerprint per call → no recycle gate). X-Forwarded-For is also set
198+
* with the same unique IP for staging/local where the proxy is trusted. All are
199+
* harmless together: on prod the override wins, elsewhere the XFF varies.
189200
*/
190201
export function anonProvisionHeaders(extra: Record<string, string> = {}): Record<string, string> {
202+
const ip = uniqueIP()
191203
const headers: Record<string, string> = {
192204
'Content-Type': 'application/json',
193-
'X-Forwarded-For': uniqueIP(),
205+
'X-Forwarded-For': ip,
194206
...extra,
195207
}
196208
const token = process.env.E2E_TEST_TOKEN
197-
if (token) headers[E2E_TEST_TOKEN_HEADER] = token
209+
if (token) {
210+
headers[E2E_TEST_TOKEN_HEADER] = token
211+
// The override source IP the middleware actually reads (XFF is ignored on
212+
// prod). A unique value per call → a fresh fingerprint → no recycle gate.
213+
headers[E2E_SOURCE_IP_HEADER] = ip
214+
}
198215
return headers
199216
}
200217

218+
// ── Authed provision (workflow-minted pro account) ───────────────────────────
219+
// On a SANCTIONED prod run the workflow mints an is_test_cohort=true PRO account
220+
// and exports its session JWT (E2E_SESSION_JWT). Provisioning AS that account is
221+
// the decisive fix for both prod anon failures:
222+
// 1. No 402: authed provisioning skips the anonymous recycle gate entirely
223+
// (the gate runs only on the anon path), and tier=pro has ample headroom.
224+
// 2. No 401 on reap: the resource is OWNED by the minted team, so the reaper's
225+
// authed DELETE /api/v1/resources/:id (with the same bearer) returns 200 —
226+
// there is NO unauthed resource-delete on the api, so anon resources could
227+
// only ever be TTL-cleaned (the 401 the last run hit).
228+
// The minted account is reaped wholesale by the workflow teardown (cascade), so
229+
// every resource it owns is double-covered.
230+
231+
/**
232+
* Headers for an AUTHENTICATED provision as the given bearer (the minted pro
233+
* account's session JWT). A unique X-Forwarded-For keeps per-call fingerprints
234+
* distinct on trusted proxies, but the authed path is not fingerprint-gated.
235+
*/
236+
export function authedProvisionHeaders(
237+
token: string,
238+
extra: Record<string, string> = {},
239+
): Record<string, string> {
240+
return {
241+
'Content-Type': 'application/json',
242+
Authorization: `Bearer ${token}`,
243+
'X-Forwarded-For': uniqueIP(),
244+
...extra,
245+
}
246+
}
247+
248+
/**
249+
* The provision identity a LIVE spec should use for a resource it will reap via
250+
* the authed DELETE surface. When a minted session exists (sanctioned prod run)
251+
* we provision + reap AS that pro account (no 402, authed reap 200). Otherwise
252+
* (staging/local) we use the anonymous path with the fingerprint bypass, and the
253+
* resource is reaped by TTL / the un-authed staging delete path.
254+
*
255+
* - `headers` : the provision request headers (authed or anon-with-bypass).
256+
* - `bearer` : the token to record on the ledger so the reaper's DELETE is
257+
* authorized (undefined on the anon path).
258+
* - `expectTier` : the tier the provision response will echo ('pro' authed,
259+
* 'anonymous' otherwise) — specs assert against this.
260+
* - `authed` : true when provisioning as the minted account.
261+
*/
262+
export interface ProvisionIdentity {
263+
headers: Record<string, string>
264+
bearer?: string
265+
expectTier: string
266+
authed: boolean
267+
}
268+
269+
/** Tier echoed by an anonymous provision. */
270+
export const ANON_TIER = 'anonymous'
271+
272+
/**
273+
* Resolve the provision identity for a LIVE resource spec. Prefers the minted
274+
* pro account (authed) when E2E_SESSION_JWT is set; else the anon bypass path.
275+
*/
276+
export function provisionIdentity(extra: Record<string, string> = {}): ProvisionIdentity {
277+
const minted = mintedSession()
278+
if (minted?.token) {
279+
return {
280+
headers: authedProvisionHeaders(minted.token, extra),
281+
bearer: minted.token,
282+
expectTier: minted.tier || 'pro',
283+
authed: true,
284+
}
285+
}
286+
return {
287+
headers: anonProvisionHeaders(extra),
288+
bearer: undefined,
289+
expectTier: ANON_TIER,
290+
authed: false,
291+
}
292+
}
293+
201294
// Unique source IP per call (varies the fingerprint where the proxy is trusted).
202295
function uniqueIP(): string {
203296
const b = () => Math.floor(Math.random() * 254) + 1

e2e/live-anon-provision.spec.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242

4343
import { expect, test, type APIRequestContext } from '@playwright/test'
4444

45-
import { cohortName, COHORT_MARKER, assertSafeApiTarget, anonProvisionHeaders } from './cohort'
45+
import { cohortName, COHORT_MARKER, assertSafeApiTarget, provisionIdentity } from './cohort'
4646
import {
4747
recordEntity,
4848
loadLedger,
@@ -183,15 +183,19 @@ test.describe('LIVE — every anonymous provision flow → backend-assert → re
183183
}) => {
184184
const name = cohortName(`anon-${flow.label}`)
185185

186-
// ── Create: real anonymous provision against the live api ──────────────
187-
// anonProvisionHeaders() adds the X-E2E-Test-Token fingerprint-bypass when
188-
// E2E_TEST_TOKEN is set (the only thing that gets past prod's per-
189-
// fingerprint recycle gate, which ignores X-Forwarded-For) plus a unique
190-
// XFF for staging/local. A cohort name is always sent: /vector & /db
191-
// REQUIRE a name (CLAUDE.md) and it is harmless (cohort-tagging) on the rest.
186+
// ── Create: real provision against the live api ────────────────────────
187+
// On a sanctioned prod run (E2E_SESSION_JWT set) provisionIdentity()
188+
// provisions AS the minted PRO account: authed provisioning skips the
189+
// anonymous recycle gate (no 402) and the resource is team-owned so the
190+
// reaper's authed DELETE returns 200 (no 401). Otherwise (staging/local)
191+
// it uses the anon path with the X-E2E-Test-Token + X-E2E-Source-IP
192+
// fingerprint bypass (a fresh fingerprint per call → no recycle gate).
193+
// A cohort name is always sent: /vector & /db REQUIRE a name (CLAUDE.md)
194+
// and it is harmless (cohort-tagging) on the rest.
195+
const id = provisionIdentity()
192196
const resp = await request.fetch(`${API_URL}${flow.path}`, {
193197
method: 'POST',
194-
headers: anonProvisionHeaders(),
198+
headers: id.headers,
195199
data: JSON.stringify({ name }),
196200
failOnStatusCode: false,
197201
})
@@ -217,6 +221,10 @@ test.describe('LIVE — every anonymous provision flow → backend-assert → re
217221
kind: 'resource',
218222
id: token,
219223
apiUrl: API_URL,
224+
// Record the provision bearer (minted pro session on prod) so the
225+
// reaper's authed DELETE is authorized → 200, not 401. Undefined on the
226+
// anon path (resource is TTL/staging-reaped).
227+
token: id.bearer,
220228
note: `${flow.label} ${name}`,
221229
}
222230
recordEntity(entity)
@@ -236,7 +244,11 @@ test.describe('LIVE — every anonymous provision flow → backend-assert → re
236244
expect(body.ok, `${flow.label}/new ok flag`).toBe(true)
237245
expect(token, `${flow.path} must return a resource token`).toBeTruthy()
238246
expect(body.id, `${flow.path} must return a resource id`).toBeTruthy()
239-
expect(body.tier, `anon ${flow.label} provision is tier=anonymous`).toBe('anonymous')
247+
// Tier reflects the provision identity: 'pro' as the minted account
248+
// (sanctioned prod run), 'anonymous' on the anon path (staging/local).
249+
expect(body.tier, `${flow.label} provision tier should be '${id.expectTier}'`).toBe(
250+
id.expectTier,
251+
)
240252
// env is echoed on every provision (rule 11 — default 'development').
241253
expect(body.env, `${flow.path} must echo the resolved env (rule 11)`).toBeTruthy()
242254
// The usable connection/URL — proves a real backend minted a live endpoint.

e2e/live-claim-deploy.spec.ts

Lines changed: 65 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
isCohortBranded,
6060
assertSafeApiTarget,
6161
anonProvisionHeaders,
62+
mintedSession,
6263
} from './cohort'
6364
import {
6465
recordEntity,
@@ -478,31 +479,50 @@ test.describe('LIVE — W3 claim/conversion + deploy-lifecycle + env-switcher (c
478479
// shipped 2026-05-30, #200) → DELETE → 404 gone. That is the user-visible
479480
// integration boundary; the build-to-live leg is exercised by /instant-e2e
480481
// and the per-deploy synthetic, not here.
481-
test.describe('deploy lifecycle — create(202) → events surface → delete → gone [STAGING-ONLY]', () => {
482+
test.describe('deploy lifecycle — create(202) → events surface → delete → gone', () => {
482483
test('POST /deploy/new accepted → events timeline → DELETE → gone', async ({ request }) => {
483-
const anon = await provisionAnonCache(request)
484-
const team = await claim(request, anon.upgradeJWT)
485-
486-
// Elevate the free team to a deployable tier via the dev-only endpoint.
487-
// 404 ⇒ not a dev/staging stack ⇒ skip loudly (never 402-wall or charge).
488-
const setTier = await request.fetch(`${API_URL}${SET_TIER_PATH}`, {
489-
method: 'POST',
490-
headers: { 'Content-Type': 'application/json' },
491-
data: JSON.stringify({ team_id: team.teamID, tier: DEPLOY_TIER }),
492-
failOnStatusCode: false,
493-
})
494-
test.skip(
495-
setTier.status() === STATUS_NOT_FOUND,
496-
`${SET_TIER_PATH} returned 404 — this is a prod stack (dev-only endpoint not registered). ` +
497-
`The deploy-lifecycle leg is STAGING-ONLY: a free team can't deploy (402) and we must ` +
498-
`never cross a real billing path to elevate it. Reports skipped. Run against a ` +
499-
`dev/staging api (ENVIRONMENT=development) to exercise it.`,
500-
)
501-
expect(
502-
setTier.status(),
503-
`${SET_TIER_PATH} should return ${STATUS_OK} on a dev/staging stack; got ${setTier.status()}. ` +
504-
`Body: ${await setTier.text().catch(() => '<unreadable>')}`,
505-
).toBe(STATUS_OK)
484+
// Resolve a team with deployment headroom WITHOUT crossing a billing path:
485+
// - Prod (sanctioned run): the workflow-minted account is already PRO
486+
// (deployments_apps=10), so we deploy AS it directly — no claim, no
487+
// dev-only set-tier. This is the brief's "use the minted pro account".
488+
// - Staging/local: the Brevo-free claim mints a `free` team (0 deploy
489+
// headroom → 402), so we elevate it via the DEV-ONLY set-tier endpoint.
490+
// If that endpoint 404s AND there is no minted session, we skip loudly
491+
// (never 402-wall or charge).
492+
const minted = mintedSession()
493+
let deployBearer: string
494+
let baseResource: { token: string; bearer: string } | null = null
495+
496+
if (minted?.token) {
497+
// Deploy as the minted pro account — has deployment headroom already.
498+
deployBearer = minted.token
499+
} else {
500+
const anon = await provisionAnonCache(request)
501+
const team = await claim(request, anon.upgradeJWT)
502+
baseResource = { token: anon.token, bearer: team.sessionToken }
503+
504+
// Elevate the free team to a deployable tier via the dev-only endpoint.
505+
// 404 ⇒ not a dev/staging stack AND no minted account ⇒ skip loudly.
506+
const setTier = await request.fetch(`${API_URL}${SET_TIER_PATH}`, {
507+
method: 'POST',
508+
headers: { 'Content-Type': 'application/json' },
509+
data: JSON.stringify({ team_id: team.teamID, tier: DEPLOY_TIER }),
510+
failOnStatusCode: false,
511+
})
512+
test.skip(
513+
setTier.status() === STATUS_NOT_FOUND,
514+
`${SET_TIER_PATH} returned 404 — this is a prod stack (dev-only endpoint not registered) ` +
515+
`and no minted pro account is present. The deploy leg needs deployment headroom and we ` +
516+
`must never cross a real billing path to elevate a free team. Reports skipped. Run ` +
517+
`against a dev/staging api (ENVIRONMENT=development) or a sanctioned minted-account run.`,
518+
)
519+
expect(
520+
setTier.status(),
521+
`${SET_TIER_PATH} should return ${STATUS_OK} on a dev/staging stack; got ${setTier.status()}. ` +
522+
`Body: ${await setTier.text().catch(() => '<unreadable>')}`,
523+
).toBe(STATUS_OK)
524+
deployBearer = team.sessionToken
525+
}
506526

507527
// ── Create: a minimal tarball deploy. We do NOT wait for the Kaniko build
508528
// to go live (deferred) — only the accepted contract + events surface +
@@ -523,7 +543,7 @@ test.describe('LIVE — W3 claim/conversion + deploy-lifecycle + env-switcher (c
523543
}
524544
const create = await request.fetch(`${API_URL}/deploy/new`, {
525545
method: 'POST',
526-
headers: { Authorization: `Bearer ${team.sessionToken}` },
546+
headers: { Authorization: `Bearer ${deployBearer}` },
527547
multipart: form,
528548
failOnStatusCode: false,
529549
})
@@ -552,7 +572,7 @@ test.describe('LIVE — W3 claim/conversion + deploy-lifecycle + env-switcher (c
552572
kind: 'deployment',
553573
id: appID,
554574
apiUrl: API_URL,
555-
token: team.sessionToken,
575+
token: deployBearer,
556576
note: `W3 deploy ${deployName}`,
557577
})
558578
// Accepted-contract assertions: a real building deployment in the env we
@@ -584,7 +604,7 @@ test.describe('LIVE — W3 claim/conversion + deploy-lifecycle + env-switcher (c
584604
// surface EXISTS and is team-scoped, not that a specific event landed. ─
585605
const events = await request.fetch(
586606
`${API_URL}/api/v1/deployments/${encodeURIComponent(appID)}/events`,
587-
{ method: 'GET', headers: { Authorization: `Bearer ${team.sessionToken}` }, failOnStatusCode: false },
607+
{ method: 'GET', headers: { Authorization: `Bearer ${deployBearer}` }, failOnStatusCode: false },
588608
)
589609
expect(
590610
events.status(),
@@ -617,7 +637,7 @@ test.describe('LIVE — W3 claim/conversion + deploy-lifecycle + env-switcher (c
617637
// so the dashboard would correctly show it as removed. ────────────────
618638
const del = await request.fetch(
619639
`${API_URL}/api/v1/deployments/${encodeURIComponent(appID)}`,
620-
{ method: 'DELETE', headers: { Authorization: `Bearer ${team.sessionToken}` }, failOnStatusCode: false },
640+
{ method: 'DELETE', headers: { Authorization: `Bearer ${deployBearer}` }, failOnStatusCode: false },
621641
)
622642
expect(
623643
del.status() >= 200 && del.status() < 300,
@@ -632,7 +652,7 @@ test.describe('LIVE — W3 claim/conversion + deploy-lifecycle + env-switcher (c
632652
// load-bearing assertion is that the team can no longer act on it as live.
633653
const afterDelete = await request.fetch(
634654
`${API_URL}/api/v1/deployments/${encodeURIComponent(appID)}/events`,
635-
{ method: 'GET', headers: { Authorization: `Bearer ${team.sessionToken}` }, failOnStatusCode: false },
655+
{ method: 'GET', headers: { Authorization: `Bearer ${deployBearer}` }, failOnStatusCode: false },
636656
)
637657
expect(
638658
[STATUS_OK, STATUS_NOT_FOUND].includes(afterDelete.status()),
@@ -641,10 +661,22 @@ test.describe('LIVE — W3 claim/conversion + deploy-lifecycle + env-switcher (c
641661
).toBe(true)
642662

643663
// Reap is now idempotent (404 == alreadyGone). Clears the ledger.
644-
const result = await reapEntities(request, [
645-
{ kind: 'deployment', id: appID, apiUrl: API_URL, token: team.sessionToken, note: 'W3 deploy reap', recordedAt: new Date().toISOString() },
646-
{ kind: 'resource', id: anon.token, apiUrl: API_URL, token: team.sessionToken, note: 'W3 deploy base resource', recordedAt: new Date().toISOString() },
647-
])
664+
const reapTargets = [
665+
{ kind: 'deployment' as const, id: appID, apiUrl: API_URL, token: deployBearer, note: 'W3 deploy reap', recordedAt: new Date().toISOString() },
666+
]
667+
// The staging path created an anon base resource (claimed → team-owned);
668+
// reap it too. The minted-account path created no base resource.
669+
if (baseResource) {
670+
reapTargets.push({
671+
kind: 'resource' as const,
672+
id: baseResource.token,
673+
apiUrl: API_URL,
674+
token: baseResource.bearer,
675+
note: 'W3 deploy base resource',
676+
recordedAt: new Date().toISOString(),
677+
})
678+
}
679+
const result = await reapEntities(request, reapTargets)
648680
expect(result.failed.length, `reap failed: ${JSON.stringify(result.failed)}`).toBe(0)
649681
clearLedger()
650682
})

0 commit comments

Comments
 (0)