Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/e2e-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions e2e/cohort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
// minted-account run — i.e. it carries a mint token (E2E_ACCOUNT_TOKEN, used by
// the CI workflow to mint/reap the account) or an already-minted session JWT
// (E2E_SESSION_JWT). A prod target WITHOUT either is still refused, so a stray /
// mis-configured invocation can never provision-and-leak against prod.

Check warning on line 91 in e2e/cohort.ts

View workflow job for this annotation

GitHub Actions / typos

"mis" should be "miss" or "mist".

/** The prod api host. A prod target is only allowed for a sanctioned minted run. */
export const PROD_API_HOST = 'api.instanode.dev'
Expand Down Expand Up @@ -167,3 +167,39 @@
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<string, string> = {}): Record<string, string> {
const headers: Record<string, string> = {
'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()}`
}
17 changes: 7 additions & 10 deletions e2e/live-anon-provision.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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' })

Expand Down Expand Up @@ -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,
})
Expand Down
44 changes: 28 additions & 16 deletions e2e/live-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(/=+$/, '')
}
Expand Down Expand Up @@ -129,9 +129,13 @@ interface ClaimedIdentity {
async function provisionAndClaim(
request: APIRequestContext,
): Promise<ClaimedIdentity> {
// 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(
Expand Down Expand Up @@ -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(() => '<unreadable>')}`,
).toBe(true)
const pollBody = (await poll.json()) as Record<string, unknown>
// 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,
Expand Down
16 changes: 14 additions & 2 deletions e2e/live-claim-deploy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<AnonProvision> {
// 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(
Expand Down
14 changes: 5 additions & 9 deletions e2e/live-provision-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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' })

Expand Down Expand Up @@ -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,
})
Expand Down
Loading