diff --git a/.github/workflows/e2e-prod.yml b/.github/workflows/e2e-prod.yml index b3f1d77..9c02e80 100644 --- a/.github/workflows/e2e-prod.yml +++ b/.github/workflows/e2e-prod.yml @@ -154,6 +154,12 @@ jobs: E2E_TEAM_ID: ${{ env.MINTED_TEAM_ID }} E2E_ACCOUNT_EMAIL: ${{ env.MINTED_EMAIL }} E2E_ACCOUNT_TIER: ${{ env.MINTED_TIER }} + # Fingerprint bypass for the ANON provision legs: prod does NOT trust + # X-Forwarded-For, so the runner's anon provisions share one real + # fingerprint and trip the free-tier recycle gate (402). The api's + # X-E2E-Test-Token header skips the per-fingerprint cap when it matches + # this secret (api internal/middleware/fingerprint.go). + E2E_TEST_TOKEN: ${{ secrets.E2E_TEST_TOKEN }} run: npm run test:e2e:live - name: Reap minted account (teardown) diff --git a/e2e/cohort.ts b/e2e/cohort.ts index 674dfd8..8b7ec8a 100644 --- a/e2e/cohort.ts +++ b/e2e/cohort.ts @@ -167,3 +167,39 @@ export function mintedSession(): MintedSession | null { tier: process.env.E2E_ACCOUNT_TIER ?? '', } } + +// ── Per-fingerprint bypass for anonymous provisions (ISSUE 1) ──────────────── +// Prod does NOT trust X-Forwarded-For, so the CI runner's many anon provisions +// all share ONE real fingerprint (SHA256(/24 + ASN), rule 6) and trip the +// free-tier recycle/dedup gate → 402 free_tier_recycle_requires_claim. The api +// exposes a real bypass: the X-E2E-Test-Token header (api internal/middleware/ +// fingerprint.go) — a matching token skips the per-fingerprint cap. The token is +// a CI secret (E2E_TEST_TOKEN); when unset (local dev / un-tokened run) we send +// no bypass header and rely on X-Forwarded-For varying the fingerprint instead. + +/** The header name the api's fingerprint middleware honours to skip the cap. */ +export const E2E_TEST_TOKEN_HEADER = 'X-E2E-Test-Token' + +/** + * Headers an anonymous provision should carry: a unique X-Forwarded-For (varies + * the fingerprint where the proxy IS trusted — staging/local) PLUS the + * X-E2E-Test-Token bypass when E2E_TEST_TOKEN is set (the only thing that gets + * past the per-fingerprint recycle gate on prod, which ignores X-Forwarded-For). + * Both are harmless together: the bypass wins on prod, the XFF varies elsewhere. + */ +export function anonProvisionHeaders(extra: Record = {}): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Forwarded-For': uniqueIP(), + ...extra, + } + const token = process.env.E2E_TEST_TOKEN + if (token) headers[E2E_TEST_TOKEN_HEADER] = token + return headers +} + +// Unique source IP per call (varies the fingerprint where the proxy is trusted). +function uniqueIP(): string { + const b = () => Math.floor(Math.random() * 254) + 1 + return `10.${b()}.${b()}.${b()}` +} diff --git a/e2e/live-anon-provision.spec.ts b/e2e/live-anon-provision.spec.ts index 8f08626..f437222 100644 --- a/e2e/live-anon-provision.spec.ts +++ b/e2e/live-anon-provision.spec.ts @@ -42,7 +42,7 @@ import { expect, test, type APIRequestContext } from '@playwright/test' -import { cohortName, COHORT_MARKER, assertSafeApiTarget } from './cohort' +import { cohortName, COHORT_MARKER, assertSafeApiTarget, anonProvisionHeaders } from './cohort' import { recordEntity, loadLedger, @@ -134,14 +134,6 @@ const PROVISION_FLOWS: ProvisionFlow[] = [ }, ] -// Unique source IP per call so the per-fingerprint dedup cap (5/day, rule 6) -// doesn't hand back an EXISTING token — mirrors live-provision-smoke.spec.ts -// and auth-roundtrip.spec.ts uniqueIP(). -function uniqueIP(): string { - const b = () => Math.floor(Math.random() * 254) + 1 - return `10.${b()}.${b()}.${b()}` -} - test.describe('LIVE — every anonymous provision flow → backend-assert → reap', () => { test.describe.configure({ mode: 'serial' }) @@ -192,9 +184,14 @@ test.describe('LIVE — every anonymous provision flow → backend-assert → re const name = cohortName(`anon-${flow.label}`) // ── Create: real anonymous provision against the live api ────────────── + // anonProvisionHeaders() adds the X-E2E-Test-Token fingerprint-bypass when + // E2E_TEST_TOKEN is set (the only thing that gets past prod's per- + // fingerprint recycle gate, which ignores X-Forwarded-For) plus a unique + // XFF for staging/local. A cohort name is always sent: /vector & /db + // REQUIRE a name (CLAUDE.md) and it is harmless (cohort-tagging) on the rest. const resp = await request.fetch(`${API_URL}${flow.path}`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': uniqueIP() }, + headers: anonProvisionHeaders(), data: JSON.stringify({ name }), failOnStatusCode: false, }) diff --git a/e2e/live-auth.spec.ts b/e2e/live-auth.spec.ts index fc85f85..f6c0585 100644 --- a/e2e/live-auth.spec.ts +++ b/e2e/live-auth.spec.ts @@ -39,7 +39,14 @@ import { createHmac, randomUUID } from 'node:crypto' import { expect, test, type APIRequestContext } from '@playwright/test' -import { cohortEmail, COHORT_MARKER, assertSafeApiTarget, mintedSession } from './cohort' +import { + cohortEmail, + cohortName, + COHORT_MARKER, + assertSafeApiTarget, + mintedSession, + anonProvisionHeaders, +} from './cohort' import { recordEntity, loadLedger, @@ -71,13 +78,6 @@ const STATUS_ACCEPTED = 202 const STATUS_UNAUTHORIZED = 401 const STATUS_BACKEND_UNAVAILABLE = 503 -// Unique source IP per provision so the per-fingerprint dedup cap (5/day, rule 6) -// doesn't hand back an EXISTING token — mirrors live-anon-provision.spec.ts. -function uniqueIP(): string { - const b = () => Math.floor(Math.random() * 254) + 1 - return `10.${b()}.${b()}.${b()}` -} - function base64url(buf: Buffer): string { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } @@ -129,9 +129,13 @@ interface ClaimedIdentity { async function provisionAndClaim( request: APIRequestContext, ): Promise { + // anonProvisionHeaders() carries the X-E2E-Test-Token fingerprint-bypass when + // E2E_TEST_TOKEN is set (prod ignores X-Forwarded-For, tripping the recycle + // gate) + a unique XFF for staging/local. A cohort name keeps it cohort-tagged. const cacheResp = await request.fetch(`${API_URL}/cache/new`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': uniqueIP() }, + headers: anonProvisionHeaders(), + data: JSON.stringify({ name: cohortName('auth-cache') }), failOnStatusCode: false, }) test.skip( @@ -578,23 +582,31 @@ test.describe('LIVE — auth/login seams (W1: OAuth, logout-revocation, CLI, /au `cannot derive a CLI session id to poll from ${JSON.stringify(body)} / ${authURL}.`, ).toBeTruthy() - // A13: poll BEFORE approval → a status, not an api_token. (We never approve - // in-browser here — that's the staging UI leg; the poll contract is what - // the CLI depends on.) + // A13: poll BEFORE approval. The REAL prod contract (verified against + // api.instanode.dev) is HTTP 202 with {ok:true, pending:true} — there is NO + // `status` field pre-approval; the CLI branches on `pending`. The api_token + // is minted ONLY after the user approves in-browser, so it must be absent. const poll = await request.fetch(`${API_URL}/auth/cli/${encodeURIComponent(sessionId)}`, { method: 'GET', failOnStatusCode: false, }) expect( [STATUS_OK, STATUS_ACCEPTED].includes(poll.status()), - `GET /auth/cli/:id pre-approval should return 200/202 with a status; got ${poll.status()}. ` + + `GET /auth/cli/:id pre-approval should return 200/202; got ${poll.status()}. ` + `Body: ${await poll.text().catch(() => '')}`, ).toBe(true) const pollBody = (await poll.json()) as Record + // The poll must be answerable: ok:true (request accepted) AND pending:true + // (not yet approved). This is the contract the CLI polls on. expect( - pollBody.status, - `pre-approval poll must carry a 'status' (e.g. 'pending'); got ${JSON.stringify(pollBody)}.`, - ).toBeTruthy() + pollBody.ok, + `pre-approval poll must return ok:true; got ${JSON.stringify(pollBody)}.`, + ).toBe(true) + expect( + pollBody.pending, + `pre-approval poll must signal pending:true (the user hasn't approved yet); got ` + + `${JSON.stringify(pollBody)}.`, + ).toBe(true) // Pre-approval there must be NO api_token (it appears only after approve). expect( pollBody.api_token, diff --git a/e2e/live-claim-deploy.spec.ts b/e2e/live-claim-deploy.spec.ts index 087396e..4a2b99c 100644 --- a/e2e/live-claim-deploy.spec.ts +++ b/e2e/live-claim-deploy.spec.ts @@ -52,7 +52,14 @@ import { gzipSync } from 'node:zlib' import { expect, test, type APIRequestContext } from '@playwright/test' -import { cohortEmail, cohortName, COHORT_MARKER, isCohortBranded, assertSafeApiTarget } from './cohort' +import { + cohortEmail, + cohortName, + COHORT_MARKER, + isCohortBranded, + assertSafeApiTarget, + anonProvisionHeaders, +} from './cohort' import { recordEntity, loadLedger, @@ -110,9 +117,14 @@ interface AnonProvision { // assertion (rule 24). Returns the token + the anon-upgrade JWT for /claim. // Skips loudly if the cache backend is 503 (can't mint a claimable resource). async function provisionAnonCache(request: APIRequestContext): Promise { + // anonProvisionHeaders() carries the X-E2E-Test-Token fingerprint-bypass when + // E2E_TEST_TOKEN is set (prod ignores X-Forwarded-For, tripping the recycle + // gate) + a unique XFF for staging/local. A cohort name keeps the resource + // cohort-tagged (harmless for /cache/new, which does not require a name). const resp = await request.fetch(`${API_URL}/cache/new`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': uniqueIP() }, + headers: anonProvisionHeaders(), + data: JSON.stringify({ name: cohortName('w3-anon-cache') }), failOnStatusCode: false, }) test.skip( diff --git a/e2e/live-provision-smoke.spec.ts b/e2e/live-provision-smoke.spec.ts index 7a558ff..b6d79dc 100644 --- a/e2e/live-provision-smoke.spec.ts +++ b/e2e/live-provision-smoke.spec.ts @@ -23,7 +23,7 @@ import { expect, test, type APIRequestContext } from '@playwright/test' -import { cohortName, COHORT_MARKER, assertSafeApiTarget } from './cohort' +import { cohortName, COHORT_MARKER, assertSafeApiTarget, anonProvisionHeaders } from './cohort' import { recordEntity, loadLedger, @@ -46,13 +46,6 @@ interface DbProvision { tier: string } -// Unique source IP per call so the per-fingerprint dedup cap (5/day) doesn't -// hand back an EXISTING token — mirrors auth-roundtrip.spec.ts uniqueIP(). -function uniqueIP(): string { - const b = () => Math.floor(Math.random() * 254) + 1 - return `10.${b()}.${b()}.${b()}` -} - test.describe('LIVE smoke — anonymous provision → backend-assert → reap', () => { test.describe.configure({ mode: 'serial' }) @@ -101,9 +94,12 @@ test.describe('LIVE smoke — anonymous provision → backend-assert → reap', const name = cohortName('smoke-db') // ── Create: real anonymous Postgres against the live api ────────────── + // anonProvisionHeaders() carries the X-E2E-Test-Token fingerprint-bypass when + // E2E_TEST_TOKEN is set (prod ignores X-Forwarded-For) + a unique XFF + // elsewhere. /db/new REQUIRES a name (CLAUDE.md) — already sent below. const resp = await request.fetch(`${API_URL}/db/new`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': uniqueIP() }, + headers: anonProvisionHeaders(), data: JSON.stringify({ name }), failOnStatusCode: false, })