From 68e0a0724c97cdf9accee72af9b0678c21a0b360 Mon Sep 17 00:00:00 2001 From: israel Date: Thu, 3 Sep 2026 20:56:18 +0100 Subject: [PATCH] test(platform): isolate the 2FA lifecycle check on its own user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2FA lifecycle audit check runs on the harness's shared session. The block enables 2FA, regenerates backup codes, disables it, and registers and removes a passkey — so it leaves that user's second-factor state changed and their session invalidated. Five later checks fail that way: the 2FA grace check, and four chat checks that need a live session. Give it a dedicated throwaway user instead. Audit rows are org-scoped, so the user also needs a `member` row or their events have no org to land under and the trail reads back empty. The check already worked around the shared state with a "Leave 2FA OFF: later lanes sign this account in with password alone" note. With its own user there is nothing to leave in any particular state, so the note goes. --- .../platform/backend/integration-check.ts | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/services/platform/backend/integration-check.ts b/services/platform/backend/integration-check.ts index 90377e8227..bec1726ec9 100644 --- a/services/platform/backend/integration-check.ts +++ b/services/platform/backend/integration-check.ts @@ -31757,17 +31757,54 @@ async function checkTwoFactor( // port kept only the failure half, so an attacker who registered their own // passkey and turned TOTP off left no trace at all. Action names are 0.4's // verbatim — a rename would break any saved query grouping on them. + // + // A DEDICATED user, not the harness's own. This block enables 2FA, + // regenerates backup codes, disables it, and registers and removes a + // passkey — so running it on the shared session leaves that user's + // second-factor state changed and invalidates their session. Five later + // checks failed that way: the 2FA grace check, and four chat checks that + // need a live session. + const lifecycleSignUp = await fetch(`${base}/api/auth/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: base }, + body: JSON.stringify({ + email: `itest-2fa-lifecycle-${ctx.orgId.slice(0, 8)}@door.test`, + password: 'itest-password-1', + name: 'Lifecycle', + }), + }); + const lifecycleCookie = cookieHeaderFrom(lifecycleSignUp); + const lifecycleUser = z + .object({ user: z.object({ id: z.string() }) }) + .loose() + .safeParse(await lifecycleSignUp.json()); + const lifecycleUserId = lifecycleUser.success + ? lifecycleUser.data.user.id + : ''; + // Audit rows are org-scoped, so the lifecycle user needs a membership or + // their events have no org to land under and the trail reads empty. + await sql` + INSERT INTO "member" ("id", "organizationId", "userId", "role", + "createdAt") + VALUES (${`m-2fa-${lifecycleUserId}`}, ${ctx.orgId}, ${lifecycleUserId}, + 'member', ${new Date()}) + ON CONFLICT ("id") DO NOTHING + `; const authPost = (route: string, body: unknown): Promise => fetch(`${base}/api/auth${route}`, { method: 'POST', - headers: { 'content-type': 'application/json', cookie, origin: base }, + headers: { + 'content-type': 'application/json', + cookie: lifecycleCookie, + origin: base, + }, body: JSON.stringify(body), }); const lifecycleActions = async (): Promise => { const rows = await sql<{ action: string }[]>` SELECT action FROM app.audit_logs WHERE org_id = ${ctx.orgId} AND resource_type = 'twoFactorAuth' - AND resource_id = ${userId} + AND resource_id = ${lifecycleUserId} ORDER BY ts ASC `; return rows.map((row) => row.action); @@ -31796,7 +31833,7 @@ async function checkTwoFactor( SELECT category, status, (metadata->>'backupCodesRegenerated')::boolean AS regenerated FROM app.audit_logs - WHERE org_id = ${ctx.orgId} AND resource_id = ${userId} + WHERE org_id = ${ctx.orgId} AND resource_id = ${lifecycleUserId} AND action = '2fa_enrolled' AND metadata ? 'backupCodesRegenerated' ORDER BY ts DESC LIMIT 1 `; @@ -31816,7 +31853,7 @@ async function checkTwoFactor( 'passkey_removed', ] as const) { await recordTwoFactorLifecycleEvent(sql, { - userId, + userId: lifecycleUserId, action, actorEmail: email, ip: '203.0.113.9', @@ -31828,11 +31865,12 @@ async function checkTwoFactor( const shapes = await sql<{ category: string; status: string }[]>` SELECT DISTINCT category, status FROM app.audit_logs WHERE org_id = ${ctx.orgId} AND resource_type = 'twoFactorAuth' - AND resource_id = ${userId} + AND resource_id = ${lifecycleUserId} `; - // Leave 2FA OFF: later lanes sign this account in with password alone. + // The dedicated user ends with 2FA off, which is also what the assertion + // below reads — no later lane touches this account either way. const enrolled = await sql<{ enabled: boolean | null }[]>` - SELECT "twoFactorEnabled" AS enabled FROM "user" WHERE "id" = ${userId} + SELECT "twoFactorEnabled" AS enabled FROM "user" WHERE "id" = ${lifecycleUserId} `; record( 'two-factor: successful lifecycle events audit (#1508 action names)',