From 680060866d868d99123f07e42c05a5125d789d07 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 5 Jun 2026 10:02:05 +0530 Subject: [PATCH] ci(e2e): prod LIVE E2E via on-the-fly minted cohort account Add the production E2E CI workflow that mints an ephemeral, cohort-scoped test account against prod, runs the real-backend live specs with it, and reaps the account + any spec-created resources. - .github/workflows/e2e-prod.yml: workflow_dispatch + schedule (every 30m) + repository_dispatch(e2e-prod-from-deploy). No-ops cleanly when secrets.E2E_ACCOUNT_TOKEN is empty. MINT (POST /internal/e2e/account, X-E2E-Token, {"tier":"pro"}) -> mask+export session_jwt/team_id -> RUN (E2E_LIVE=1, E2E_API_URL=prod, E2E_SESSION_JWT) -> REAP (always: DELETE the account + npm run reap:live; reaper exits non-zero on leak). Prod sibling of e2e-live.yml (staging); that file is kept. - e2e/cohort.ts: mintedSession() surfaces the workflow-minted account (E2E_SESSION_JWT + companion identity env) so authed legs use a real cohort account instead of self-minting from E2E_JWT_SECRET. assertSafeApiTarget() relaxes the prod-refusal: prod is ALLOWED only for a sanctioned minted-account run (E2E_ACCOUNT_TOKEN/E2E_SESSION_JWT present), still REFUSED otherwise so a stray run can't hammer prod. - live-auth A8/A10 prefer mintedSession() when set (assert minted email/tier; reap only spec-created resources, account reaped by the workflow); anon legs unchanged. All four live specs now call assertSafeApiTarget() at module load. npm run gate green (build + 1115 vitest pass / 3 skip). Workflow no-ops until the api mint endpoint deploys + E2E_ACCOUNT_TOKEN secret is set. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e-prod.yml | 192 +++++++++++++++++++++++++++++++ e2e/cohort.ts | 97 +++++++++++++++- e2e/live-anon-provision.spec.ts | 6 +- e2e/live-auth.spec.ts | 123 ++++++++++++++------ e2e/live-claim-deploy.spec.ts | 8 +- e2e/live-provision-smoke.spec.ts | 6 +- 6 files changed, 390 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/e2e-prod.yml diff --git a/.github/workflows/e2e-prod.yml b/.github/workflows/e2e-prod.yml new file mode 100644 index 0000000..b3f1d77 --- /dev/null +++ b/.github/workflows/e2e-prod.yml @@ -0,0 +1,192 @@ +# Real-backend (LIVE) E2E against PRODUCTION (api.instanode.dev) using an +# ephemeral, cohort-scoped account minted on the fly. This is the prod sibling +# of e2e-live.yml (which targets STAGING) — DO NOT delete that one. +# +# Plan: docs/sessions/2026-06-04 TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md. +# +# WHY this is safe to run against prod (and e2e-live.yml is not): +# - The api guards a mint endpoint (PR #260) that creates an account with +# is_test_cohort=true. The live worker skip-guards neuter +# billing/churn/email/quota for that team, so a LIVE run can never charge a +# card, burn a real quota budget, send a "we miss you" email, or churn a +# real customer. +# - The account + every resource it creates is reaped: this job DELETEs the +# minted account (DELETE /internal/e2e/account/{team_id}) AND runs the +# per-run ledger reaper (npm run reap:live) in an `if: always()` teardown. +# The reaper exits non-zero on any leak, failing the job loudly (rule 24). +# - cohort.ts assertSafeApiTarget() ALLOWS a prod E2E_API_URL only when a mint +# token / minted session is present (a sanctioned run); an un-tokened prod +# target is still refused, so a stray invocation can never hammer prod. +# +# HOW it mints/runs/reaps: +# 1. MINT — POST https://api.instanode.dev/internal/e2e/account with header +# X-E2E-Token: $E2E_ACCOUNT_TOKEN and body {"tier":"pro"} → +# {team_id, user_id, email, tier, session_jwt, expires_at}. The +# session_jwt + team_id are masked and exported to later steps. +# 2. RUN — E2E_LIVE=1 E2E_API_URL=https://api.instanode.dev +# E2E_SESSION_JWT= npx playwright test +# --config=playwright.live.config.ts. The authed legs use the +# minted account (cohort.ts mintedSession()); anon legs run as-is. +# 3. REAP — (always) DELETE the minted account, then npm run reap:live to +# sweep any spec-created resources from the on-disk ledger. +# +# Triggers: +# - workflow_dispatch (operator on demand). +# - schedule every 30 min (continuous prod integration signal). +# - repository_dispatch type `e2e-prod-from-deploy` (post-deploy hook the api +# repo can fire after a prod rollout). +# +# Guard: if secrets.E2E_ACCOUNT_TOKEN is empty (not yet configured) the job +# no-ops cleanly with a ::notice:: — it NEVER reds when unconfigured. The +# workflow ships before the secret exists and goes green only once the operator +# sets E2E_ACCOUNT_TOKEN and the api mint endpoint is deployed. + +name: E2E LIVE (prod, minted account) + +on: + workflow_dispatch: {} + schedule: + # Every 30 minutes — continuous prod integration signal. + - cron: '*/30 * * * *' + repository_dispatch: + types: [e2e-prod-from-deploy] + +concurrency: + # One prod LIVE run at a time: they mint a real cohort account + create real + # resources; overlapping runs could interleave ledger writes / dedup state. + group: e2e-prod-${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + e2e-prod: + name: LIVE against prod via minted account + reap + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + # Fixed prod target — this workflow is prod-only by design. + E2E_API_URL: https://api.instanode.dev + E2E_LIVE_RUN_ID: ${{ github.run_id }} + # The mint-endpoint guard token. Empty until the operator configures it → + # the gate step below no-ops the job cleanly. + E2E_ACCOUNT_TOKEN: ${{ secrets.E2E_ACCOUNT_TOKEN }} + steps: + - name: Gate on configured mint token + # No token configured → no-op cleanly (never a false red). Sets RUN=0 + # so every later step is skipped. + run: | + set -euo pipefail + if [ -z "${E2E_ACCOUNT_TOKEN:-}" ]; then + echo "::notice::secrets.E2E_ACCOUNT_TOKEN not configured — skipping prod LIVE E2E (no-op)." + echo "RUN=0" >> "$GITHUB_ENV" + else + echo "RUN=1" >> "$GITHUB_ENV" + fi + + - uses: actions/checkout@v6 + if: env.RUN == '1' + + - uses: actions/setup-node@v6 + if: env.RUN == '1' + with: + node-version: '22' + cache: 'npm' + + - name: Install deps + if: env.RUN == '1' + run: npm ci + + - name: Install Chromium + if: env.RUN == '1' + run: npx playwright install --with-deps chromium + + - name: Mint ephemeral cohort account + id: mint + if: env.RUN == '1' + # POST the guarded mint endpoint → capture session_jwt + team_id, mask + # them, and export to later steps. Fails the job (non-2xx) so a broken + # mint endpoint surfaces immediately rather than running un-authed. + run: | + set -euo pipefail + resp="$(curl -sS -w '\n%{http_code}' \ + -X POST "${E2E_API_URL}/internal/e2e/account" \ + -H "X-E2E-Token: ${E2E_ACCOUNT_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d '{"tier":"pro"}')" + code="$(printf '%s' "$resp" | tail -n1)" + body="$(printf '%s' "$resp" | sed '$d')" + if [ "$code" != "200" ]; then + echo "::error::mint endpoint returned HTTP $code (expected 200). Body: $body" + exit 1 + fi + jwt="$(printf '%s' "$body" | jq -r '.session_jwt // empty')" + team="$(printf '%s' "$body" | jq -r '.team_id // empty')" + email="$(printf '%s' "$body" | jq -r '.email // empty')" + tier="$(printf '%s' "$body" | jq -r '.tier // empty')" + if [ -z "$jwt" ] || [ -z "$team" ]; then + echo "::error::mint response missing session_jwt or team_id. Body: $body" + exit 1 + fi + # Mask the secrets so they never appear in logs. + echo "::add-mask::$jwt" + echo "::add-mask::$team" + # session_jwt + team_id are secret-ish → env only (not step outputs). + # team_id is also a non-secret output for the reap step's `if`. + { + echo "MINTED_SESSION_JWT=$jwt" + echo "MINTED_TEAM_ID=$team" + echo "MINTED_EMAIL=$email" + echo "MINTED_TIER=$tier" + } >> "$GITHUB_ENV" + echo "minted=1" >> "$GITHUB_OUTPUT" + echo "Minted cohort account (tier=$tier) — session + team_id masked." + + - name: Run LIVE E2E against prod (minted account) + if: env.RUN == '1' && steps.mint.outputs.minted == '1' + env: + E2E_LIVE: '1' + # The minted account drives the authed legs (cohort.ts mintedSession); + # anon legs run as-is. assertSafeApiTarget() permits the prod target + # because E2E_SESSION_JWT is present (a sanctioned run). + E2E_SESSION_JWT: ${{ env.MINTED_SESSION_JWT }} + E2E_TEAM_ID: ${{ env.MINTED_TEAM_ID }} + E2E_ACCOUNT_EMAIL: ${{ env.MINTED_EMAIL }} + E2E_ACCOUNT_TIER: ${{ env.MINTED_TIER }} + run: npm run test:e2e:live + + - name: Reap minted account (teardown) + # ALWAYS runs (even on test failure/cancel) so the minted account + its + # resources are deleted out-of-band. Idempotent: 404 == already gone. + if: always() && env.RUN == '1' && env.MINTED_TEAM_ID != '' + run: | + set -euo pipefail + code="$(curl -sS -o /dev/null -w '%{http_code}' \ + -X DELETE "${E2E_API_URL}/internal/e2e/account/${MINTED_TEAM_ID}" \ + -H "X-E2E-Token: ${E2E_ACCOUNT_TOKEN}")" + case "$code" in + 200|202|204|404|410) + echo "Reaped minted account (HTTP $code)." ;; + *) + echo "::error::DELETE minted account returned HTTP $code — possible leak." + exit 1 ;; + esac + + - name: Reap cohort resources from ledger (teardown) + # The per-run ledger reaper sweeps any resource a spec created. Exits + # non-zero on any leak, failing the job loudly (rule 24). + if: always() && env.RUN == '1' + run: npm run reap:live + + - name: Upload LIVE trace + ledger on failure + if: failure() && env.RUN == '1' + uses: actions/upload-artifact@v4 + with: + name: e2e-prod-trace-${{ github.run_id }} + path: | + test-results/ + playwright-report-live/ + e2e/.cleanup-ledger.json + if-no-files-found: ignore + retention-days: 14 diff --git a/e2e/cohort.ts b/e2e/cohort.ts index d7fb584..674dfd8 100644 --- a/e2e/cohort.ts +++ b/e2e/cohort.ts @@ -12,9 +12,12 @@ // real quota budget, or attempt a real charge. // // This is the instanode-web side ONLY. The backend `is_test_cohort` column + -// the guards that read it are intentionally NOT in this PR (one-tree -// discipline) — see the PR body's follow-up note. Until those guards exist, -// LIVE runs MUST target STAGING, never prod. +// the guards that read it ship in the api/worker tree (PR #260): a minted team +// is `is_test_cohort=true`, and the live worker skip-guards neuter +// billing/churn/email/quota for it. With those guards + the mint endpoint +// (cohort-scoped) + the reaper all live, a SANCTIONED minted-account run MAY +// target prod (see assertSafeApiTarget below). An un-sanctioned/un-tokened run +// against prod is still REFUSED so a stray invocation can never hammer prod. // // The contract the backend guards will key on (kept here as the single source // of truth for the string the two repos share): @@ -76,3 +79,91 @@ export function cohortName(label = 'res'): string { export function isCohortBranded(value: string | null | undefined): boolean { return !!value && value.includes(COHORT_MARKER) } + +// ── Prod-target safety (item 3) ────────────────────────────────────────────── +// LIVE specs create REAL backend resources. Originally cohort.ts refused any +// prod E2E_API_URL outright (only STAGING was safe). Now that the backend +// `is_test_cohort` skip-guards (PR #260), the cohort-scoped mint endpoint, and +// the reaper are all live, a prod run is safe IFF it is a SANCTIONED +// 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. + +/** The prod api host. A prod target is only allowed for a sanctioned minted run. */ +export const PROD_API_HOST = 'api.instanode.dev' + +/** True when the resolved api base points at the prod api host. */ +export function isProdApiTarget(apiUrl: string): boolean { + if (!apiUrl) return false + try { + return new URL(apiUrl).host.toLowerCase() === PROD_API_HOST + } catch { + // Not a parseable URL — be conservative and substring-match the host so a + // malformed-but-prod-looking value can't slip past as "not prod". + return apiUrl.toLowerCase().includes(PROD_API_HOST) + } +} + +/** + * True when this process is a SANCTIONED minted-account run: it holds a mint + * token (the workflow mints/reaps the account out-of-band) or an already-minted + * session JWT. Either proves the run is the cohort-scoped, reaped, skip-guarded + * path rather than a stray prod invocation. + */ +export function isSanctionedMintedRun(): boolean { + return !!(process.env.E2E_ACCOUNT_TOKEN || process.env.E2E_SESSION_JWT) +} + +/** + * Guard a LIVE spec's resolved api target. Throws (failing the spec loudly, + * never silently passing) when E2E_API_URL points at prod WITHOUT a sanctioned + * minted-account run. Staging targets and sanctioned prod runs pass through. + * Specs call this once at module load (before any provision) via topGuard(). + */ +export function assertSafeApiTarget(apiUrl: string): void { + if (isProdApiTarget(apiUrl) && !isSanctionedMintedRun()) { + throw new Error( + `Refusing to run LIVE E2E against prod (${PROD_API_HOST}) without a sanctioned ` + + `minted-account run. Set E2E_ACCOUNT_TOKEN (CI mints+reaps a cohort account) ` + + `or E2E_SESSION_JWT (a pre-minted cohort session), or point E2E_API_URL at staging. ` + + `This guard exists so a stray run can never provision-and-leak real prod resources.`, + ) + } +} + +// ── Workflow-minted account (item 2) ───────────────────────────────────────── +// The prod E2E workflow mints an ephemeral cohort account up front +// (POST /internal/e2e/account) and exports its session JWT + identity into the +// env. When E2E_SESSION_JWT is set, the authed legs use THAT account's bearer +// instead of self-minting from E2E_JWT_SECRET — so the authed flow runs against +// prod as a real, skip-guarded cohort team. Anon legs are unaffected. + +/** The minted account's identity + bearer, surfaced from the workflow env. */ +export interface MintedSession { + /** Bearer token for authed requests (the api session JWT). */ + token: string + /** The minted team's id (the workflow reaps the account by this out-of-band). */ + teamID: string + /** The minted user's email, when the workflow exported it. */ + email: string + /** The minted tier (e.g. 'pro'), when the workflow exported it. */ + tier: string +} + +/** + * Returns the workflow-minted session when E2E_SESSION_JWT is set, else null. + * Authed legs prefer this over self-minting so a prod run uses a real cohort + * account. E2E_TEAM_ID / E2E_ACCOUNT_EMAIL / E2E_ACCOUNT_TIER are the companion + * fields the workflow exports from the mint response. + */ +export function mintedSession(): MintedSession | null { + const token = process.env.E2E_SESSION_JWT + if (!token) return null + return { + token, + teamID: process.env.E2E_TEAM_ID ?? '', + email: process.env.E2E_ACCOUNT_EMAIL ?? '', + tier: process.env.E2E_ACCOUNT_TIER ?? '', + } +} diff --git a/e2e/live-anon-provision.spec.ts b/e2e/live-anon-provision.spec.ts index 47c6c3b..8f08626 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 } from './cohort' +import { cohortName, COHORT_MARKER, assertSafeApiTarget } from './cohort' import { recordEntity, loadLedger, @@ -157,6 +157,10 @@ test.describe('LIVE — every anonymous provision flow → backend-assert → re 'E2E_LIVE=1 but E2E_API_URL/AGENT_API_URL is unset — no backend to target.', ) + // Prod-target safety (item 3): refuse an un-sanctioned prod target; allow it + // only for a minted-account run (E2E_ACCOUNT_TOKEN/E2E_SESSION_JWT present). + if (LIVE && API_URL) assertSafeApiTarget(API_URL) + // Backstop reaper (rule 24): even if a per-service test throws before its // inline reap, afterAll reaps every still-ledgered entity. The standalone // reap-cohort.ts re-runs this same path in CI teardown if the whole process diff --git a/e2e/live-auth.spec.ts b/e2e/live-auth.spec.ts index 572a2c4..fc85f85 100644 --- a/e2e/live-auth.spec.ts +++ b/e2e/live-auth.spec.ts @@ -39,7 +39,7 @@ import { createHmac, randomUUID } from 'node:crypto' import { expect, test, type APIRequestContext } from '@playwright/test' -import { cohortEmail, COHORT_MARKER } from './cohort' +import { cohortEmail, COHORT_MARKER, assertSafeApiTarget, mintedSession } from './cohort' import { recordEntity, loadLedger, @@ -184,6 +184,12 @@ test.describe('LIVE — auth/login seams (W1: OAuth, logout-revocation, CLI, /au 'E2E_LIVE=1 but E2E_API_URL/AGENT_API_URL is unset — no backend to target.', ) + // Prod-target safety (item 3): a prod E2E_API_URL is only allowed for a + // sanctioned minted-account run (E2E_ACCOUNT_TOKEN / E2E_SESSION_JWT present); + // otherwise this throws and fails the suite loudly rather than provisioning + // against prod. Staging targets pass through unconditionally. + if (LIVE && API_URL) assertSafeApiTarget(API_URL) + // Backstop reaper (rule 24): even if an account-minting leg throws before its // inline reap, afterAll reaps every still-ledgered entity; reap-cohort.ts // re-runs the same path out-of-process in CI teardown if the process dies. @@ -373,13 +379,36 @@ test.describe('LIVE — auth/login seams (W1: OAuth, logout-revocation, CLI, /au }) test('valid synthetic bearer → 200 with the claimed user + a tier', async ({ request }) => { + // Prefer the workflow-minted account (item 2): when E2E_SESSION_JWT is set + // the bearer is a REAL cohort session against the (prod) api, so we assert + // against the minted identity + tier. Otherwise fall back to the + // self-minted (E2E_JWT_SECRET) claimed-team path (tier='free'). + const minted = mintedSession() test.skip( - !JWT_SECRET, - 'E2E_JWT_SECRET unset — cannot mint a valid session JWT. Set it (the api JWT_SECRET) ' + - 'to run the A8 valid-bearer leg.', + !minted && !JWT_SECRET, + 'Neither E2E_SESSION_JWT (workflow-minted account) nor E2E_JWT_SECRET set — ' + + 'cannot obtain a valid session JWT to run the A8 valid-bearer leg.', ) - const identity = await provisionAndClaim(request) - const { token } = mintSessionJWT(identity.userID, identity.teamID, identity.email) + + let token: string + let expectedEmail: string + let expectedTier: string + let inlineReap: CohortEntity[] = [] + if (minted) { + token = minted.token + expectedEmail = minted.email + expectedTier = minted.tier + } else { + const identity = await provisionAndClaim(request) + token = mintSessionJWT(identity.userID, identity.teamID, identity.email).token + expectedEmail = identity.email + // A freshly-claimed (unpaid) self-minted team is 'free'. + expectedTier = 'free' + inlineReap = [ + { kind: 'resource', id: identity.resourceToken, apiUrl: API_URL, note: 'A8 valid leg', recordedAt: new Date().toISOString() }, + ] + } + const resp = await request.fetch(`${API_URL}/auth/me`, { method: 'GET', headers: { Authorization: `Bearer ${token}` }, @@ -387,34 +416,40 @@ test.describe('LIVE — auth/login seams (W1: OAuth, logout-revocation, CLI, /au }) expect( resp.status(), - `GET /auth/me with a valid synthetic bearer must return 200; got ${resp.status()}. ` + + `GET /auth/me with a valid bearer must return 200; got ${resp.status()}. ` + `Body: ${await resp.text().catch(() => '')}`, ).toBe(STATUS_OK) const me = (await resp.json()) as Record - // Right identity: the email must be the one we just claimed. - expect( - me.email, - `/auth/me returned 200 but email=${String(me.email)}, expected the claimed ${identity.email}.`, - ).toBe(identity.email) - // Right tier: a freshly-claimed (unpaid) team is 'free'. Assert a tier - // field is present and is the expected claimed-unpaid tier. (`tier` or - // `plan`/`plan_tier` depending on the /auth/me shape — accept any.) + // Right identity: the email must match the account behind the bearer. (The + // workflow may not export the email; only assert when we know it.) + if (expectedEmail) { + expect( + me.email, + `/auth/me returned 200 but email=${String(me.email)}, expected ${expectedEmail}.`, + ).toBe(expectedEmail) + } + // Right tier: a tier field must be present (`tier`/`plan`/`plan_tier`). const tier = (me.tier ?? me.plan ?? me.plan_tier) as string | undefined expect( tier, `/auth/me must surface the team's tier; got none in ${JSON.stringify(me)}.`, ).toBeTruthy() - expect( - tier, - `a freshly-claimed, unpaid team should be tier='free'; got '${tier}'.`, - ).toBe('free') - - // Reap inline (afterAll + reap-cohort.ts back this up). - const result = await reapEntities(request, [ - { kind: 'resource', id: identity.resourceToken, apiUrl: API_URL, note: 'A8 valid leg', recordedAt: new Date().toISOString() }, - ]) - expect(result.failed.length, `reap failed: ${JSON.stringify(result.failed)}`).toBe(0) - clearLedger() + // And it must match the account behind the bearer when we know it + // (minted tier, or 'free' for the self-minted claimed team). + if (expectedTier) { + expect( + tier, + `/auth/me tier should be '${expectedTier}' for this account; got '${tier}'.`, + ).toBe(expectedTier) + } + + // Reap inline only what THIS leg created (the minted account is reaped + // out-of-band by the workflow's DELETE /internal/e2e/account step). + if (inlineReap.length > 0) { + const result = await reapEntities(request, inlineReap) + expect(result.failed.length, `reap failed: ${JSON.stringify(result.failed)}`).toBe(0) + clearLedger() + } }) }) @@ -424,12 +459,27 @@ test.describe('LIVE — auth/login seams (W1: OAuth, logout-revocation, CLI, /au // the past incident missed: a "successful" login that a logout could not undo. test.describe('Logout revocation — reused bearer/jti after logout → 401 (A10)', () => { test('valid bearer 200 → logout → SAME bearer on /auth/me → 401', async ({ request }) => { + // Prefer the workflow-minted account's bearer (item 2). Revoking it is + // safe: A10 is the LAST authed leg (serial), and the workflow reaps the + // whole account out-of-band afterward, so a dead session is expected. + const minted = mintedSession() test.skip( - !JWT_SECRET, - 'E2E_JWT_SECRET unset — cannot mint the session JWT to revoke. Set it to run A10.', + !minted && !JWT_SECRET, + 'Neither E2E_SESSION_JWT nor E2E_JWT_SECRET set — cannot obtain a session JWT to revoke. ' + + 'Set one to run A10.', ) - const identity = await provisionAndClaim(request) - const { token } = mintSessionJWT(identity.userID, identity.teamID, identity.email) + + let token: string + let inlineReap: CohortEntity[] = [] + if (minted) { + token = minted.token + } else { + const identity = await provisionAndClaim(request) + token = mintSessionJWT(identity.userID, identity.teamID, identity.email).token + inlineReap = [ + { kind: 'resource', id: identity.resourceToken, apiUrl: API_URL, note: 'A10 logout leg', recordedAt: new Date().toISOString() }, + ] + } // 1) The bearer works pre-logout. const pre = await request.fetch(`${API_URL}/auth/me`, { @@ -467,12 +517,13 @@ test.describe('LIVE — auth/login seams (W1: OAuth, logout-revocation, CLI, /au `2026-05-30 incident — a session that survives its own logout.`, ).toBe(STATUS_UNAUTHORIZED) - // Reap. - const result = await reapEntities(request, [ - { kind: 'resource', id: identity.resourceToken, apiUrl: API_URL, note: 'A10 logout leg', recordedAt: new Date().toISOString() }, - ]) - expect(result.failed.length, `reap failed: ${JSON.stringify(result.failed)}`).toBe(0) - clearLedger() + // Reap only what THIS leg created (the minted account is reaped + // out-of-band by the workflow's DELETE /internal/e2e/account step). + if (inlineReap.length > 0) { + const result = await reapEntities(request, inlineReap) + expect(result.failed.length, `reap failed: ${JSON.stringify(result.failed)}`).toBe(0) + clearLedger() + } }) }) diff --git a/e2e/live-claim-deploy.spec.ts b/e2e/live-claim-deploy.spec.ts index c80fab0..087396e 100644 --- a/e2e/live-claim-deploy.spec.ts +++ b/e2e/live-claim-deploy.spec.ts @@ -52,7 +52,7 @@ import { gzipSync } from 'node:zlib' import { expect, test, type APIRequestContext } from '@playwright/test' -import { cohortEmail, cohortName, COHORT_MARKER, isCohortBranded } from './cohort' +import { cohortEmail, cohortName, COHORT_MARKER, isCohortBranded, assertSafeApiTarget } from './cohort' import { recordEntity, loadLedger, @@ -231,6 +231,12 @@ test.describe('LIVE — W3 claim/conversion + deploy-lifecycle + env-switcher (c 'E2E_LIVE=1 but E2E_API_URL/AGENT_API_URL is unset — no backend to target.', ) + // Prod-target safety (item 3): refuse an un-sanctioned prod target; allow it + // only for a minted-account run (E2E_ACCOUNT_TOKEN/E2E_SESSION_JWT present). + // The claim/conversion + env-switcher legs create real claimed cohort teams + // (reaped via the ledger); the deploy-lifecycle leg self-skips on prod. + if (LIVE && API_URL) assertSafeApiTarget(API_URL) + // Backstop reaper (rule 24): even if a leg throws before its inline reap, // afterAll reaps every still-ledgered entity; reap-cohort.ts re-runs the same // path out-of-process in CI teardown if the process dies. diff --git a/e2e/live-provision-smoke.spec.ts b/e2e/live-provision-smoke.spec.ts index 1a9b03a..7a558ff 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 } from './cohort' +import { cohortName, COHORT_MARKER, assertSafeApiTarget } from './cohort' import { recordEntity, loadLedger, @@ -68,6 +68,10 @@ test.describe('LIVE smoke — anonymous provision → backend-assert → reap', 'E2E_LIVE=1 but E2E_API_URL/AGENT_API_URL is unset — no backend to target.', ) + // Prod-target safety (item 3): refuse an un-sanctioned prod target; allow it + // only for a minted-account run (E2E_ACCOUNT_TOKEN/E2E_SESSION_JWT present). + if (LIVE && API_URL) assertSafeApiTarget(API_URL) + // Backstop reaper: even if the in-test cleanup below throws, afterAll reaps // every ledgered entity. The standalone reap-cohort.ts re-runs this same // path in CI teardown if the whole process dies (rule 24, belt-and-braces).