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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 14:43:05 +02:00
1 parent 4cd0adc46d
commit f1c977a7a7
4 files changed
+75 -2

No files matched your search

@@ -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 () => {
@@ -57,7 +57,10 @@ function verifyRequest(body: Record<string, unknown>): 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);
});
@@ -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",
});
});
});
@@ -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<StepUpElevationCleanupPayload>[],