From f1c977a7a780dd45079ae024a4a6d130e581065e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 14:43:05 +0200 Subject: [PATCH] test(auth): give the MFA fixtures a Bearer transport The disable-route suite only ever supplied a cookie-shaped context, which is why the token-id-as-session-id defect was invisible to it. It now covers both arms and asserts the credential shape handed to the session revoker, plus that the elevation is spent before the teardown and not spent when the factor code is wrong. --- .../me/mfa/disable/__tests__/route.test.ts | 65 ++++++++++++++++++- .../webauthn/register/__tests__/route.test.ts | 3 + .../auth/me/sessions/__tests__/route.test.ts | 5 +- src/lib/jobs/reminder/cleanup-handlers.ts | 4 ++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts index 422ed90d9..7884d3f52 100644 --- a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts +++ b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts @@ -55,8 +55,10 @@ function req() { beforeEach(() => { vi.clearAllMocks(); vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "cookie", user: { id: "u1" }, session: { id: "sess-current" }, + commitElevation: vi.fn().mockResolvedValue(undefined), } as never); vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true } as never); vi.mocked(prisma.$transaction).mockImplementation(async (cb: unknown) => { @@ -74,7 +76,68 @@ describe("POST /api/auth/me/mfa/disable", () => { it("revokes other sessions + trusted devices (via destroyOtherSessions), keeping the current one", async () => { const res = await POST(req()); expect(res.status).toBe(200); - expect(destroyOtherSessions).toHaveBeenCalledWith("u1", "sess-current"); + expect(destroyOtherSessions).toHaveBeenCalledWith("u1", { + kind: "session", + sessionId: "sess-current", + }); + }); + + it("spares the CALLER's own device login on the Bearer path", async () => { + // A Bearer caller has no session row. The earlier revision passed + // `session.id` — an ApiToken id on this arm — into a session-scoped query, + // so the "keep the current one" exclusion excluded nothing: every browser + // session went AND the caller's own refresh token was revoked, logging the + // app out at its next rotation. A cookie-shaped fixture could never see it. + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "bearer", + user: { id: "u1" }, + apiTokenId: "token-row-1", + accessTokenHash: "hash-of-caller-access-token", + commitElevation: vi.fn().mockResolvedValue(undefined), + } as never); + + const res = await POST(req()); + + expect(res.status).toBe(200); + expect(destroyOtherSessions).toHaveBeenCalledWith("u1", { + kind: "accessToken", + accessTokenHash: "hash-of-caller-access-token", + }); + }); + + it("spends the elevation before tearing the factor down", async () => { + const commitElevation = vi.fn().mockResolvedValue(undefined); + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "bearer", + user: { id: "u1" }, + apiTokenId: "token-row-1", + accessTokenHash: "h", + commitElevation, + } as never); + + await POST(req()); + + expect(commitElevation).toHaveBeenCalledTimes(1); + }); + + it("does NOT spend the elevation when the factor code is wrong", async () => { + const commitElevation = vi.fn().mockResolvedValue(undefined); + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "bearer", + user: { id: "u1" }, + apiTokenId: "token-row-1", + accessTokenHash: "h", + commitElevation, + } as never); + vi.mocked(verifyMfaFactor).mockResolvedValue({ + ok: false, + replay: false, + } as never); + + const res = await POST(req()); + + expect(res.status).toBe(401); + expect(commitElevation).not.toHaveBeenCalled(); }); it("rejects a wrong current factor without revoking anything", async () => { diff --git a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts index 30655630f..e7704c9ca 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts @@ -57,7 +57,10 @@ function verifyRequest(body: Record): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "cookie", user: USER, + session: { id: "sess-1" }, + commitElevation: vi.fn().mockResolvedValue(undefined), } as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); }); diff --git a/src/app/api/auth/me/sessions/__tests__/route.test.ts b/src/app/api/auth/me/sessions/__tests__/route.test.ts index 575dca26f..17bf18a59 100644 --- a/src/app/api/auth/me/sessions/__tests__/route.test.ts +++ b/src/app/api/auth/me/sessions/__tests__/route.test.ts @@ -157,7 +157,10 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.data.sessionsRevoked).toBe(3); - expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", "sess-current"); + expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", { + kind: "session", + sessionId: "sess-current", + }); }); }); diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index bdbe007be..b68c70cd2 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -205,6 +205,10 @@ export async function handleIdempotencyCleanup( * but table space. It lives here rather than fire-and-forget on the mint request * because a cross-tenant unbounded DELETE has no business competing with a * user's request for a pool connection. + * + * The delete lives here rather than in `@/lib/auth/step-up` because it has to + * run on the WORKER Prisma client; a second copy of the predicate in the auth + * module would be dead code with a licence to drift. */ export async function handleStepUpElevationCleanup( jobs: Job[],