Skip to content

Commit e7757c9

Browse files
fix(e2e): green prod LIVE suite — fingerprint bypass + real CLI-poll contract (#175)
Prod doesn't trust X-Forwarded-For, so the runner's anon provisions shared one fingerprint and tripped the free-tier recycle gate (402 free_tier_recycle_requires_claim). Add the api's X-E2E-Test-Token bypass (api internal/middleware/fingerprint.go) on every anonymous provision via a shared anonProvisionHeaders() helper, gated on E2E_TEST_TOKEN, and always send a cohort name (required by /db & /vector, harmless elsewhere). Also fix the CLI device-flow poll assertion to the REAL prod contract: pre-approval returns HTTP 202 {ok:true, pending:true} (no `status` field). Workflow exports E2E_TEST_TOKEN to the run step. Files: cohort.ts (helper), live-anon-provision / live-provision-smoke / live-claim-deploy / live-auth specs, e2e-prod.yml. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 295900e commit e7757c9

6 files changed

Lines changed: 96 additions & 37 deletions

File tree

.github/workflows/e2e-prod.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,12 @@ jobs:
154154
E2E_TEAM_ID: ${{ env.MINTED_TEAM_ID }}
155155
E2E_ACCOUNT_EMAIL: ${{ env.MINTED_EMAIL }}
156156
E2E_ACCOUNT_TIER: ${{ env.MINTED_TIER }}
157+
# Fingerprint bypass for the ANON provision legs: prod does NOT trust
158+
# X-Forwarded-For, so the runner's anon provisions share one real
159+
# fingerprint and trip the free-tier recycle gate (402). The api's
160+
# X-E2E-Test-Token header skips the per-fingerprint cap when it matches
161+
# this secret (api internal/middleware/fingerprint.go).
162+
E2E_TEST_TOKEN: ${{ secrets.E2E_TEST_TOKEN }}
157163
run: npm run test:e2e:live
158164

159165
- name: Reap minted account (teardown)

e2e/cohort.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,39 @@ export function mintedSession(): MintedSession | null {
167167
tier: process.env.E2E_ACCOUNT_TIER ?? '',
168168
}
169169
}
170+
171+
// ── 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. */
181+
export const E2E_TEST_TOKEN_HEADER = 'X-E2E-Test-Token'
182+
183+
/**
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.
189+
*/
190+
export function anonProvisionHeaders(extra: Record<string, string> = {}): Record<string, string> {
191+
const headers: Record<string, string> = {
192+
'Content-Type': 'application/json',
193+
'X-Forwarded-For': uniqueIP(),
194+
...extra,
195+
}
196+
const token = process.env.E2E_TEST_TOKEN
197+
if (token) headers[E2E_TEST_TOKEN_HEADER] = token
198+
return headers
199+
}
200+
201+
// Unique source IP per call (varies the fingerprint where the proxy is trusted).
202+
function uniqueIP(): string {
203+
const b = () => Math.floor(Math.random() * 254) + 1
204+
return `10.${b()}.${b()}.${b()}`
205+
}

e2e/live-anon-provision.spec.ts

Lines changed: 7 additions & 10 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 } from './cohort'
45+
import { cohortName, COHORT_MARKER, assertSafeApiTarget, anonProvisionHeaders } from './cohort'
4646
import {
4747
recordEntity,
4848
loadLedger,
@@ -134,14 +134,6 @@ const PROVISION_FLOWS: ProvisionFlow[] = [
134134
},
135135
]
136136

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-
145137
test.describe('LIVE — every anonymous provision flow → backend-assert → reap', () => {
146138
test.describe.configure({ mode: 'serial' })
147139

@@ -192,9 +184,14 @@ test.describe('LIVE — every anonymous provision flow → backend-assert → re
192184
const name = cohortName(`anon-${flow.label}`)
193185

194186
// ── 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.
195192
const resp = await request.fetch(`${API_URL}${flow.path}`, {
196193
method: 'POST',
197-
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': uniqueIP() },
194+
headers: anonProvisionHeaders(),
198195
data: JSON.stringify({ name }),
199196
failOnStatusCode: false,
200197
})

e2e/live-auth.spec.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ import { createHmac, randomUUID } from 'node:crypto'
3939

4040
import { expect, test, type APIRequestContext } from '@playwright/test'
4141

42-
import { cohortEmail, COHORT_MARKER, assertSafeApiTarget, mintedSession } from './cohort'
42+
import {
43+
cohortEmail,
44+
cohortName,
45+
COHORT_MARKER,
46+
assertSafeApiTarget,
47+
mintedSession,
48+
anonProvisionHeaders,
49+
} from './cohort'
4350
import {
4451
recordEntity,
4552
loadLedger,
@@ -71,13 +78,6 @@ const STATUS_ACCEPTED = 202
7178
const STATUS_UNAUTHORIZED = 401
7279
const STATUS_BACKEND_UNAVAILABLE = 503
7380

74-
// Unique source IP per provision so the per-fingerprint dedup cap (5/day, rule 6)
75-
// doesn't hand back an EXISTING token — mirrors live-anon-provision.spec.ts.
76-
function uniqueIP(): string {
77-
const b = () => Math.floor(Math.random() * 254) + 1
78-
return `10.${b()}.${b()}.${b()}`
79-
}
80-
8181
function base64url(buf: Buffer): string {
8282
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
8383
}
@@ -129,9 +129,13 @@ interface ClaimedIdentity {
129129
async function provisionAndClaim(
130130
request: APIRequestContext,
131131
): Promise<ClaimedIdentity> {
132+
// anonProvisionHeaders() carries the X-E2E-Test-Token fingerprint-bypass when
133+
// E2E_TEST_TOKEN is set (prod ignores X-Forwarded-For, tripping the recycle
134+
// gate) + a unique XFF for staging/local. A cohort name keeps it cohort-tagged.
132135
const cacheResp = await request.fetch(`${API_URL}/cache/new`, {
133136
method: 'POST',
134-
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': uniqueIP() },
137+
headers: anonProvisionHeaders(),
138+
data: JSON.stringify({ name: cohortName('auth-cache') }),
135139
failOnStatusCode: false,
136140
})
137141
test.skip(
@@ -578,23 +582,31 @@ test.describe('LIVE — auth/login seams (W1: OAuth, logout-revocation, CLI, /au
578582
`cannot derive a CLI session id to poll from ${JSON.stringify(body)} / ${authURL}.`,
579583
).toBeTruthy()
580584

581-
// A13: poll BEFORE approval → a status, not an api_token. (We never approve
582-
// in-browser here — that's the staging UI leg; the poll contract is what
583-
// the CLI depends on.)
585+
// A13: poll BEFORE approval. The REAL prod contract (verified against
586+
// api.instanode.dev) is HTTP 202 with {ok:true, pending:true} — there is NO
587+
// `status` field pre-approval; the CLI branches on `pending`. The api_token
588+
// is minted ONLY after the user approves in-browser, so it must be absent.
584589
const poll = await request.fetch(`${API_URL}/auth/cli/${encodeURIComponent(sessionId)}`, {
585590
method: 'GET',
586591
failOnStatusCode: false,
587592
})
588593
expect(
589594
[STATUS_OK, STATUS_ACCEPTED].includes(poll.status()),
590-
`GET /auth/cli/:id pre-approval should return 200/202 with a status; got ${poll.status()}. ` +
595+
`GET /auth/cli/:id pre-approval should return 200/202; got ${poll.status()}. ` +
591596
`Body: ${await poll.text().catch(() => '<unreadable>')}`,
592597
).toBe(true)
593598
const pollBody = (await poll.json()) as Record<string, unknown>
599+
// The poll must be answerable: ok:true (request accepted) AND pending:true
600+
// (not yet approved). This is the contract the CLI polls on.
594601
expect(
595-
pollBody.status,
596-
`pre-approval poll must carry a 'status' (e.g. 'pending'); got ${JSON.stringify(pollBody)}.`,
597-
).toBeTruthy()
602+
pollBody.ok,
603+
`pre-approval poll must return ok:true; got ${JSON.stringify(pollBody)}.`,
604+
).toBe(true)
605+
expect(
606+
pollBody.pending,
607+
`pre-approval poll must signal pending:true (the user hasn't approved yet); got ` +
608+
`${JSON.stringify(pollBody)}.`,
609+
).toBe(true)
598610
// Pre-approval there must be NO api_token (it appears only after approve).
599611
expect(
600612
pollBody.api_token,

e2e/live-claim-deploy.spec.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,14 @@ import { gzipSync } from 'node:zlib'
5252

5353
import { expect, test, type APIRequestContext } from '@playwright/test'
5454

55-
import { cohortEmail, cohortName, COHORT_MARKER, isCohortBranded, assertSafeApiTarget } from './cohort'
55+
import {
56+
cohortEmail,
57+
cohortName,
58+
COHORT_MARKER,
59+
isCohortBranded,
60+
assertSafeApiTarget,
61+
anonProvisionHeaders,
62+
} from './cohort'
5663
import {
5764
recordEntity,
5865
loadLedger,
@@ -110,9 +117,14 @@ interface AnonProvision {
110117
// assertion (rule 24). Returns the token + the anon-upgrade JWT for /claim.
111118
// Skips loudly if the cache backend is 503 (can't mint a claimable resource).
112119
async function provisionAnonCache(request: APIRequestContext): Promise<AnonProvision> {
120+
// anonProvisionHeaders() carries the X-E2E-Test-Token fingerprint-bypass when
121+
// E2E_TEST_TOKEN is set (prod ignores X-Forwarded-For, tripping the recycle
122+
// gate) + a unique XFF for staging/local. A cohort name keeps the resource
123+
// cohort-tagged (harmless for /cache/new, which does not require a name).
113124
const resp = await request.fetch(`${API_URL}/cache/new`, {
114125
method: 'POST',
115-
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': uniqueIP() },
126+
headers: anonProvisionHeaders(),
127+
data: JSON.stringify({ name: cohortName('w3-anon-cache') }),
116128
failOnStatusCode: false,
117129
})
118130
test.skip(

e2e/live-provision-smoke.spec.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
import { expect, test, type APIRequestContext } from '@playwright/test'
2525

26-
import { cohortName, COHORT_MARKER, assertSafeApiTarget } from './cohort'
26+
import { cohortName, COHORT_MARKER, assertSafeApiTarget, anonProvisionHeaders } from './cohort'
2727
import {
2828
recordEntity,
2929
loadLedger,
@@ -46,13 +46,6 @@ interface DbProvision {
4646
tier: string
4747
}
4848

49-
// Unique source IP per call so the per-fingerprint dedup cap (5/day) doesn't
50-
// hand back an EXISTING token — mirrors auth-roundtrip.spec.ts uniqueIP().
51-
function uniqueIP(): string {
52-
const b = () => Math.floor(Math.random() * 254) + 1
53-
return `10.${b()}.${b()}.${b()}`
54-
}
55-
5649
test.describe('LIVE smoke — anonymous provision → backend-assert → reap', () => {
5750
test.describe.configure({ mode: 'serial' })
5851

@@ -101,9 +94,12 @@ test.describe('LIVE smoke — anonymous provision → backend-assert → reap',
10194
const name = cohortName('smoke-db')
10295

10396
// ── Create: real anonymous Postgres against the live api ──────────────
97+
// anonProvisionHeaders() carries the X-E2E-Test-Token fingerprint-bypass when
98+
// E2E_TEST_TOKEN is set (prod ignores X-Forwarded-For) + a unique XFF
99+
// elsewhere. /db/new REQUIRES a name (CLAUDE.md) — already sent below.
104100
const resp = await request.fetch(`${API_URL}/db/new`, {
105101
method: 'POST',
106-
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': uniqueIP() },
102+
headers: anonProvisionHeaders(),
107103
data: JSON.stringify({ name }),
108104
failOnStatusCode: false,
109105
})

0 commit comments

Comments
 (0)