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
16 changes: 14 additions & 2 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 @@ -272,10 +272,22 @@
/**
* 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<string, string> = {}): ProvisionIdentity {
export function provisionIdentity(
extra: Record<string, string> = {},
forceAnon = false,
): ProvisionIdentity {
const minted = mintedSession()
if (minted?.token) {
if (minted?.token && !forceAnon) {
return {
headers: authedProvisionHeaders(minted.token, extra),
bearer: minted.token,
Expand Down
51 changes: 40 additions & 11 deletions e2e/live-anon-provision.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ interface ProvisionFlow {
connPattern: RegExp
/** Optional extra per-service assertion on the parsed JSON body. */
extra?: (body: Record<string, unknown>) => 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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
})
}
Expand Down
76 changes: 53 additions & 23 deletions e2e/live-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -166,11 +172,21 @@ async function provisionAndClaim(
`POST /claim should return ${STATUS_CREATED}; got ${claimResp.status()}. ` +
`Body: ${await claimResp.text().catch(() => '<unreadable>')}`,
).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)', () => {
Expand Down Expand Up @@ -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.
Expand All @@ -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',
Expand All @@ -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()
}
})
})

Expand Down
Loading