diff --git a/e2e/cohort.ts b/e2e/cohort.ts index 6b42fca..9b75d37 100644 --- a/e2e/cohort.ts +++ b/e2e/cohort.ts @@ -272,10 +272,22 @@ export const ANON_TIER = 'anonymous' /** * Resolve the provision identity for a LIVE resource spec. Prefers the minted * pro account (authed) when E2E_SESSION_JWT is set; else the anon bypass path. + * + * `forceAnon` pins the anonymous bypass path even when a minted session exists. + * Used for the QUEUE flow: the authed (pro) /queue/new path attempts isolated + * per-tenant NATS provisioning, which HANGS on prod until the operator seeds the + * NATS operator/sys NKeys (CLAUDE.md P1 known gap). The anonymous path returns + * fast with auth_mode=legacy_open. The resulting anon resource is reaped by its + * 24h TTL (no authed DELETE exists for anon resources); the spec treats a + * no-bearer resource's reap as best-effort (TTL-backed) rather than asserting a + * 200 it can never get. */ -export function provisionIdentity(extra: Record = {}): ProvisionIdentity { +export function provisionIdentity( + extra: Record = {}, + forceAnon = false, +): ProvisionIdentity { const minted = mintedSession() - if (minted?.token) { + if (minted?.token && !forceAnon) { return { headers: authedProvisionHeaders(minted.token, extra), bearer: minted.token, diff --git a/e2e/live-anon-provision.spec.ts b/e2e/live-anon-provision.spec.ts index 55f31b2..6047593 100644 --- a/e2e/live-anon-provision.spec.ts +++ b/e2e/live-anon-provision.spec.ts @@ -79,6 +79,14 @@ interface ProvisionFlow { connPattern: RegExp /** Optional extra per-service assertion on the parsed JSON body. */ extra?: (body: Record) => void + /** + * Pin the anonymous bypass path even when a minted pro session exists. Only + * `queue` sets this: the authed /queue/new path attempts isolated per-tenant + * NATS provisioning which HANGS on prod (operator NKeys unseeded — CLAUDE.md + * P1). The anon path returns fast with auth_mode=legacy_open; the anon + * resource is TTL-reaped (no authed DELETE for anon resources). + */ + forceAnon?: boolean } // The registry. The matrix's seventh service (db) is covered by @@ -110,6 +118,9 @@ const PROVISION_FLOWS: ProvisionFlow[] = [ path: '/queue/new', connKey: 'connection_url', connPattern: /^nats:\/\//, + // Pin anon: the authed (pro) /queue/new isolated-NATS path hangs on prod + // (P1 NKeys gap). Anon returns fast with auth_mode=legacy_open. + forceAnon: true, extra: (b) => // auth_mode is legacy_open in prod today (CLAUDE.md P1) but the field // must always be present so the caller can branch on it. @@ -192,7 +203,7 @@ test.describe('LIVE — every anonymous provision flow → backend-assert → re // fingerprint bypass (a fresh fingerprint per call → no recycle gate). // A cohort name is always sent: /vector & /db REQUIRE a name (CLAUDE.md) // and it is harmless (cohort-tagging) on the rest. - const id = provisionIdentity() + const id = provisionIdentity({}, flow.forceAnon ?? false) const resp = await request.fetch(`${API_URL}${flow.path}`, { method: 'POST', headers: id.headers, @@ -268,22 +279,40 @@ test.describe('LIVE — every anonymous provision flow → backend-assert → re // Per-service extra shape assertion (mode / extension / auth_mode / …). flow.extra?.(body) - // ── Reap inline (happy path). afterAll + reap-cohort.ts are the backstops - // if this is skipped by a thrown assertion above. ──────────────────── + // ── Reap inline. afterAll + reap-cohort.ts are the backstops if this is + // skipped by a thrown assertion above. ───────────────────────────────── const result = await reapEntities(request, [ { ...entity, recordedAt: new Date().toISOString() }, ]) - expect( - result.failed.length, - `reaping the provisioned ${flow.label} resource failed: ${JSON.stringify(result.failed)}`, - ).toBe(0) - expect( - result.deleted + result.alreadyGone, - `the ${flow.label} resource should be deleted (or already gone) after reap`, - ).toBeGreaterThan(0) + if (id.bearer) { + // Authed (minted-account) resource: the reaper's DELETE is authorized → + // must succeed (200) or be already gone. A 401 here is a real reap bug. + expect( + result.failed.length, + `reaping the provisioned ${flow.label} resource failed: ${JSON.stringify(result.failed)}`, + ).toBe(0) + expect( + result.deleted + result.alreadyGone, + `the ${flow.label} resource should be deleted (or already gone) after reap`, + ).toBeGreaterThan(0) + } else { + // Anonymous resource (no bearer): there is NO unauthed resource-delete on + // the api, so the authed DELETE 401s by design. Anonymous resources carry + // a 24h TTL and are TTL-reaped — NOT a leak. We assert the reaper failed + // for exactly the expected reason (401 / missing-credentials), so a + // DIFFERENT failure (e.g. 500) would still red. This is the documented + // "anon resources are TTL-reaped" path (queue flow on prod). + const onlyAuthFailures = result.failed.every((f) => f.status === 401 || f.status === 403) + expect( + onlyAuthFailures, + `anonymous ${flow.label} reap should only ever fail with 401/403 (no unauthed delete ` + + `exists; the resource is TTL-reaped). Got: ${JSON.stringify(result.failed)}`, + ).toBe(true) + } // Clear the ledger so afterAll doesn't re-try a now-deleted token (a no-op // 404, but keeps the ledger truthful between serial tests in this file). + // For the anon (no-bearer) path the resource is left to its 24h TTL. clearLedger() }) } diff --git a/e2e/live-auth.spec.ts b/e2e/live-auth.spec.ts index f6c0585..a43b24c 100644 --- a/e2e/live-auth.spec.ts +++ b/e2e/live-auth.spec.ts @@ -120,6 +120,12 @@ interface ClaimedIdentity { email: string /** The provisioned cache resource token — recorded + reaped (rule 24). */ resourceToken: string + /** + * The REAL session token the claim minted (onboarding.go:537). A disposable + * bearer for this throwaway team — safe to revoke (A10) without touching the + * shared workflow-minted E2E_SESSION_JWT. Empty if the claim omitted it. + */ + sessionToken: string } // Provision an anonymous cache → claim it into a REAL user/team (Brevo-free). @@ -166,11 +172,21 @@ async function provisionAndClaim( `POST /claim should return ${STATUS_CREATED}; got ${claimResp.status()}. ` + `Body: ${await claimResp.text().catch(() => '')}`, ).toBe(STATUS_CREATED) - const claim = (await claimResp.json()) as { user_id: string; team_id: string } + const claim = (await claimResp.json()) as { + user_id: string + team_id: string + session_token?: string + } expect(claim.user_id, 'claim must return a user_id').toBeTruthy() expect(claim.team_id, 'claim must return a team_id').toBeTruthy() - return { userID: claim.user_id, teamID: claim.team_id, email, resourceToken: cache.token } + return { + userID: claim.user_id, + teamID: claim.team_id, + email, + resourceToken: cache.token, + sessionToken: String(claim.session_token ?? ''), + } } test.describe('LIVE — auth/login seams (W1: OAuth, logout-revocation, CLI, /auth/me, CORS, magic-link)', () => { @@ -463,26 +479,40 @@ 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. + // CRITICAL: A10 REVOKES the bearer it tests, so it must NEVER revoke the + // shared workflow-minted E2E_SESSION_JWT — the claim/conversion, deploy, + // and provision-smoke specs (which run AFTER this one, serial+alphabetical) + // all provision/reap AS that minted session. Revoking it here would 401 + // every later authed leg (the regression this run hit). So A10 always uses + // a DISPOSABLE bearer it owns: + // - Prefer a throwaway claimed team: provisionAndClaim returns a real + // session_token (onboarding.go:537) we can revoke harmlessly. Works on + // prod via the anon fingerprint bypass — no E2E_JWT_SECRET needed. + // - If the claim path can't mint a session_token, fall back to a + // self-minted JWT (needs E2E_JWT_SECRET) for the claimed team. + // The minted account is reaped out-of-band by the workflow regardless. const minted = mintedSession() + const canClaim = !!process.env.E2E_TEST_TOKEN || !!minted test.skip( - !minted && !JWT_SECRET, - 'Neither E2E_SESSION_JWT nor E2E_JWT_SECRET set — cannot obtain a session JWT to revoke. ' + - 'Set one to run A10.', + !canClaim && !JWT_SECRET, + 'No way to obtain a DISPOSABLE session to revoke: neither the anon claim path ' + + '(E2E_TEST_TOKEN / minted run) nor E2E_JWT_SECRET is available. A10 must never revoke ' + + 'the shared E2E_SESSION_JWT, so it skips here. Set E2E_TEST_TOKEN or E2E_JWT_SECRET to run it.', ) let token: string let inlineReap: CohortEntity[] = [] - if (minted) { - token = minted.token + const identity = await provisionAndClaim(request) + inlineReap = [ + { kind: 'resource', id: identity.resourceToken, apiUrl: API_URL, token: identity.sessionToken || undefined, note: 'A10 logout leg', recordedAt: new Date().toISOString() }, + ] + if (identity.sessionToken) { + // The disposable claim session — safe to revoke (own throwaway team). + token = identity.sessionToken } else { - const identity = await provisionAndClaim(request) + // Claim omitted a session_token: self-mint one for the same team. This + // path needs E2E_JWT_SECRET; if absent the skip above already fired. 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. @@ -493,10 +523,18 @@ test.describe('LIVE — auth/login seams (W1: OAuth, logout-revocation, CLI, /au }) expect( pre.status(), - `pre-logout /auth/me with the minted bearer should be 200; got ${pre.status()}. ` + + `pre-logout /auth/me with the disposable bearer should be 200; got ${pre.status()}. ` + `If this is already 401 the jti isn't recognized and the revocation assertion below is meaningless.`, ).toBe(STATUS_OK) + // Reap the throwaway team's resource NOW, while its session is still valid + // — the logout below revokes the only bearer that authorizes the DELETE, so + // reaping afterward would 401 and leak the resource. (The minted account is + // reaped out-of-band by the workflow; this leg owns a SEPARATE claimed team.) + const reapResult = await reapEntities(request, inlineReap) + expect(reapResult.failed.length, `pre-logout reap failed: ${JSON.stringify(reapResult.failed)}`).toBe(0) + clearLedger() + // 2) Logout — revokes the jti. const logout = await request.fetch(`${API_URL}/auth/logout`, { method: 'POST', @@ -520,14 +558,6 @@ test.describe('LIVE — auth/login seams (W1: OAuth, logout-revocation, CLI, /au `${post.status()}. A 200 here is the EXACT login/logout regression class from the ` + `2026-05-30 incident — a session that survives its own logout.`, ).toBe(STATUS_UNAUTHORIZED) - - // 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() - } }) })