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[],