test(auth): retarget the MFA route mocks at the shared gate

The three unit suites that stubbed `requireCookieAuth` / `requireFreshMfa`
directly now stub `requireMfaManagementAuth`, and the password-change suite
stubs the elevation revocation the route gained. Adds the unit checks that need
no database: the elevation TTL is pinned to the cookie path's step-up window so
the two cannot drift apart, the minted value is a prefixed CSPRNG secret whose
plaintext never reaches the row, and a value carrying another credential's
prefix is refused without costing a query.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 13:14:38 +02:00
1 parent 433d983e60
commit ba45a25cb0
5 files changed
+132 -8

No files matched your search

@@ -7,7 +7,7 @@ vi.mock("@/lib/api-handler", async () => {
);
return {
...actual,
requireFreshMfa: vi.fn(),
requireMfaManagementAuth: vi.fn(),
};
});
@@ -39,7 +39,7 @@ vi.mock("@/lib/auth/mfa/verify-factor", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
import { POST } from "../route";
import { requireFreshMfa } from "@/lib/api-handler";
import { requireMfaManagementAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { destroyOtherSessions } from "@/lib/auth/session";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
@@ -54,7 +54,7 @@ function req() {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireFreshMfa).mockResolvedValue({
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
user: { id: "u1" },
session: { id: "sess-current" },
} as never);
@@ -8,7 +8,7 @@ vi.mock("@/lib/auth/mfa-enrollment", () => ({
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireCookieAuth: vi.fn(),
requireMfaManagementAuth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
@@ -33,7 +33,7 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { POST as REGISTER_OPTIONS } from "../options/route";
import { POST as REGISTER_VERIFY } from "../verify/route";
import { requireCookieAuth } from "@/lib/api-handler";
import { requireMfaManagementAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import {
@@ -56,7 +56,9 @@ function verifyRequest(body: Record<string, unknown>): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never);
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
user: USER,
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
});
@@ -24,6 +24,12 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.30.34 — the route drops the account's step-up elevations alongside its
// sessions, so the rotation cannot leave a proof of the old credential standing.
vi.mock("@/lib/auth/step-up", () => ({
revokeStepUpElevations: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
destroyAllSessions: vi.fn().mockResolvedValue(undefined),
+109
View File
@@ -0,0 +1,109 @@
/**
* Unit-level checks on the elevation primitive that need no database.
*
* The behavioural work — binding, single use, expiry, the concurrent race —
* lives in `tests/integration/step-up-elevation.test.ts`, because every one of
* those claims is a claim about what Postgres does with a WHERE clause and a
* mocked client cannot answer it. What is worth pinning here is the pair of
* constants that must not drift apart, and the shape of the value the mint
* hands out.
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
stepUpElevation: {
create: vi.fn().mockResolvedValue({ id: "elevation-1" }),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
findUnique: vi.fn().mockResolvedValue(null),
},
},
}));
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-step-up-unit-32-bytes-minimum-abcdefghijklmnop";
const {
mintStepUpElevation,
redeemStepUpElevation,
STEP_UP_ELEVATION_TTL_SECONDS,
} = await import("@/lib/auth/step-up");
const { MFA_STEP_UP_MAX_AGE_SECONDS } = await import("@/lib/api-handler");
describe("the two step-up windows stay pinned together", () => {
it("matches the cookie path's freshness window exactly", () => {
// If these ever diverge, the looser transport quietly becomes the way in.
// Changing one is a decision; changing one by accident is a hole.
expect(STEP_UP_ELEVATION_TTL_SECONDS).toBe(MFA_STEP_UP_MAX_AGE_SECONDS);
});
});
describe("the minted value", () => {
it("is a prefixed 32-byte CSPRNG secret, and the plaintext never reaches the row", async () => {
const { prisma } = await import("@/lib/db");
const { token } = await mintStepUpElevation({
userId: "u1",
apiTokenId: "t1",
method: "password",
});
expect(token).toMatch(/^hle_[0-9a-f]{64}$/);
const created = vi.mocked(prisma.stepUpElevation.create).mock.calls[0][0];
const stored = (created.data as { tokenHash: string }).tokenHash;
expect(stored).not.toContain(token);
expect(stored).toMatch(/^[0-9a-f]{64}$/);
});
it("differs on every mint", async () => {
const a = await mintStepUpElevation({
userId: "u1",
apiTokenId: "t1",
method: "password",
});
const b = await mintStepUpElevation({
userId: "u1",
apiTokenId: "t1",
method: "password",
});
expect(a.token).not.toBe(b.token);
});
});
describe("redemption rejects a wrong-shaped value before touching the database", () => {
it("refuses a value carrying another credential's prefix", async () => {
const { prisma } = await import("@/lib/db");
vi.mocked(prisma.stepUpElevation.updateMany).mockClear();
// `hls_` is the session cookie secret and `hlk_` an access token. Neither
// is an elevation, and neither should cost a query to find that out.
for (const value of [
`hls_${"a".repeat(64)}`,
`hlk_${"a".repeat(64)}`,
"",
]) {
const result = await redeemStepUpElevation({
rawToken: value,
userId: "u1",
apiTokenId: "t1",
});
expect(result).toEqual({ ok: false, reason: "malformed" });
}
expect(prisma.stepUpElevation.updateMany).not.toHaveBeenCalled();
});
});
describe("the elevation header name is stable", () => {
it("is the lowercase form the gate reads", async () => {
const { STEP_UP_ELEVATION_HEADER } = await import("@/lib/api-handler");
// Header lookups in the gate go through `headers().get()`, which is
// case-insensitive; the constant is lowercase so a direct Map-backed test
// jar matches it too.
expect(STEP_UP_ELEVATION_HEADER).toBe("x-step-up");
expect(STEP_UP_ELEVATION_HEADER).toBe(
STEP_UP_ELEVATION_HEADER.toLowerCase(),
);
});
});
+9 -2
View File
@@ -129,10 +129,17 @@ async function mintElevation(
return { status: res.status, body: (await res.json()) as never };
}
/**
* Some handlers in the set declare no request parameter (`apiHandler(async () =>
* …)`), so their wrapped type takes none. The runtime always passes one; this
* keeps the call sites honest without loosening the handlers themselves.
*/
type RouteFn = (request: NextRequest) => Promise<Response>;
/** GET /api/auth/me/mfa — the cheapest route in the elevation-accepting set. */
async function callMfaStatus(): Promise<Response> {
const { GET } = await import("@/app/api/auth/me/mfa/route");
return await GET(req("/api/auth/me/mfa"));
return await (GET as unknown as RouteFn)(req("/api/auth/me/mfa"));
}
async function auditReasons(action: string): Promise<string[]> {
@@ -338,7 +345,7 @@ describe("E5 — an elevation does not satisfy requireAdmin", () => {
useElevation(elevation);
const { GET } = await import("@/app/api/admin/users/route");
const res = await GET(req("/api/admin/users"));
const res = await (GET as unknown as RouteFn)(req("/api/admin/users"));
// Cookie-only, and the elevation is not a cookie. The account being a
// genuine admin is what makes this test worth having: the refusal is the