mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
The proof gates now read whether an account has a password from its own row rather than from the per-request user, so the suites that drive them mock that read instead of carrying the hash on the session user.
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// Mirrors require-auth-bearer.test.ts mocking so the api-handler module loads
|
|
// without a live DB / request scope.
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
session: { findUnique: vi.fn() },
|
|
user: { findUnique: vi.fn() },
|
|
webauthnMfaCredential: { count: vi.fn() },
|
|
passkey: { count: vi.fn() },
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/session", () => ({
|
|
getSession: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
|
|
vi.mock("@/lib/auth/audit", () => ({
|
|
auditLog: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
|
|
|
const headersGet = vi.fn<(name: string) => string | null>();
|
|
vi.mock("next/headers", () => ({
|
|
headers: vi.fn(async () => ({ get: headersGet })),
|
|
cookies: vi.fn(async () => ({
|
|
get: () => undefined,
|
|
set: () => {},
|
|
delete: () => {},
|
|
})),
|
|
}));
|
|
|
|
import {
|
|
requireFreshMfa,
|
|
StepUpRequiredError,
|
|
HttpError,
|
|
MFA_STEP_UP_MAX_AGE_SECONDS,
|
|
} from "../api-handler";
|
|
import { getSession } from "@/lib/auth/session";
|
|
import { prisma } from "@/lib/db";
|
|
|
|
const MFA_USER = {
|
|
id: "user-1",
|
|
role: "USER" as const,
|
|
username: "u",
|
|
totpConfirmedAt: new Date("2020-01-01"),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
function mockSession(user: {
|
|
id: string;
|
|
totpConfirmedAt: Date | null;
|
|
passwordHash?: string | null;
|
|
}) {
|
|
// The password hash is read off the stored row, not the session user.
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
passwordHash: user.passwordHash ?? null,
|
|
} as never);
|
|
vi.mocked(getSession).mockResolvedValue({
|
|
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
|
|
user,
|
|
} as never);
|
|
}
|
|
|
|
describe("requireFreshMfa", () => {
|
|
it("passes for a cookie session verified within the window", async () => {
|
|
mockSession(MFA_USER);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: new Date(Date.now() - 60_000), // 1 min ago
|
|
} as never);
|
|
|
|
const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
|
expect(ctx.user.id).toBe("user-1");
|
|
expect(ctx.mfaVerifiedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("rejects a stale verification (outside the window)", async () => {
|
|
mockSession(MFA_USER);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: new Date(Date.now() - 10 * 60_000), // 10 min ago
|
|
} as never);
|
|
|
|
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
|
name: "StepUpRequiredError",
|
|
errorCode: "auth.stepup.required",
|
|
statusCode: 401,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The stale refusal names what clears it, so the browser can open the
|
|
* re-proof dialog instead of printing a sentence (#959): the possession
|
|
* proofs the account holds, never a password, which stamps `reproofAt` only
|
|
* and would leave this gate shut.
|
|
*/
|
|
it("names the possession proofs on a stale refusal", async () => {
|
|
mockSession({ ...MFA_USER, passwordHash: "argon2-hash" });
|
|
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
|
|
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: new Date(Date.now() - 10 * 60_000),
|
|
} as never);
|
|
|
|
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
|
errorCode: "auth.stepup.required",
|
|
extraMeta: { methods: ["totp", "webauthn", "passkey"] },
|
|
});
|
|
});
|
|
|
|
it("offers a passkey-only account its passkey, not its password", async () => {
|
|
mockSession({
|
|
...MFA_USER,
|
|
totpConfirmedAt: null,
|
|
passwordHash: "argon2-hash",
|
|
});
|
|
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
|
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: null,
|
|
} as never);
|
|
|
|
await expect(
|
|
requireFreshMfa(5 * 60, "any-possession"),
|
|
).rejects.toMatchObject({
|
|
errorCode: "auth.stepup.required",
|
|
extraMeta: { methods: ["passkey"] },
|
|
});
|
|
});
|
|
|
|
it("rejects an absent verification stamp", async () => {
|
|
mockSession(MFA_USER);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: null,
|
|
} as never);
|
|
|
|
await expect(requireFreshMfa(5 * 60)).rejects.toBeInstanceOf(
|
|
StepUpRequiredError,
|
|
);
|
|
});
|
|
|
|
it("rejects a user with neither TOTP nor a security key nor a passkey", async () => {
|
|
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
|
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
|
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
|
|
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
|
errorCode: "auth.stepup.mfa_not_enrolled",
|
|
});
|
|
// Never even reaches the session-row read.
|
|
expect(prisma.session.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts a WebAuthn-only account with a fresh stamp", async () => {
|
|
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
|
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: new Date(Date.now() - 60_000),
|
|
} as never);
|
|
|
|
const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
|
expect(ctx.user.id).toBe("user-1");
|
|
});
|
|
|
|
/**
|
|
* The reachability pre-check, and the one route that asks it differently.
|
|
*
|
|
* `requireFreshMfa` refuses `mfa_not_enrolled` when the account holds nothing
|
|
* that could ever produce the stamp, because a gate nobody can clear is a dead
|
|
* end rather than a safeguard. Which credentials count is the `proofSource`
|
|
* parameter, and it changes nothing else: the freshness read below is the same
|
|
* read either way.
|
|
*
|
|
* Since v1.43.3 a primary passkey counts by default: `requireFreshMfa`
|
|
* defaults to `"any-possession"`, so a passkey-only account clears the
|
|
* managed-profile acts with a passkey re-proof. It DOES stamp `mfaVerifiedAt`
|
|
* on login and on re-proof, so it can clear the gate. What stays narrow is
|
|
* `hasSecondFactorEnrolled`: widening THAT would pull every passkey-holding
|
|
* account into `requireFreshMfaIfEnrolled`'s gate on account deletion, the
|
|
* data reset, the password change, the encrypted export and key rotation.
|
|
* The sibling suite `require-fresh-mfa-if-enrolled.test.ts` is what catches
|
|
* that if the two predicates are ever collapsed into one.
|
|
*/
|
|
it("admits a passkey-only account by default, with a fresh stamp", async () => {
|
|
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
|
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
|
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: new Date(Date.now() - 60_000),
|
|
} as never);
|
|
|
|
const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
|
expect(ctx.user.id).toBe("user-1");
|
|
});
|
|
|
|
it("asks a passkey-only account for its passkey by default when the stamp is stale", async () => {
|
|
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
|
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
|
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: null,
|
|
} as never);
|
|
|
|
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
|
errorCode: "auth.stepup.required",
|
|
extraMeta: { methods: ["passkey"] },
|
|
});
|
|
});
|
|
|
|
it("refuses a passkey-only account when only a second factor counts", async () => {
|
|
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
|
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
|
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
|
|
|
await expect(
|
|
requireFreshMfa(5 * 60, "second-factor"),
|
|
).rejects.toMatchObject({
|
|
errorCode: "auth.stepup.mfa_not_enrolled",
|
|
});
|
|
});
|
|
|
|
it("admits that same account under any-possession, with a fresh stamp", async () => {
|
|
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
|
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
|
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: new Date(Date.now() - 60_000),
|
|
} as never);
|
|
|
|
const ctx = await requireFreshMfa(
|
|
MFA_STEP_UP_MAX_AGE_SECONDS,
|
|
"any-possession",
|
|
);
|
|
expect(ctx.user.id).toBe("user-1");
|
|
});
|
|
|
|
it("still enforces the window under any-possession", async () => {
|
|
// The wider proof source decides who may attempt the gate, not who passes
|
|
// it. A stale stamp is refused with the re-provable code, not admitted.
|
|
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
|
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
|
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: new Date(Date.now() - 10 * 60_000),
|
|
} as never);
|
|
|
|
await expect(
|
|
requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS, "any-possession"),
|
|
).rejects.toMatchObject({ errorCode: "auth.stepup.required" });
|
|
});
|
|
|
|
it("refuses an account with no credential at all, under either source", async () => {
|
|
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
|
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
|
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
|
|
|
|
await expect(
|
|
requireFreshMfa(5 * 60, "any-possession"),
|
|
).rejects.toMatchObject({ errorCode: "auth.stepup.mfa_not_enrolled" });
|
|
expect(prisma.session.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects when there is no cookie session — Bearer can never satisfy it", async () => {
|
|
// getSession is cookie-only; a Bearer header is irrelevant and never read.
|
|
headersGet.mockImplementation((n) =>
|
|
n.toLowerCase() === "authorization" ? "Bearer hlk_xyz" : null,
|
|
);
|
|
vi.mocked(getSession).mockResolvedValue(null as never);
|
|
|
|
await expect(requireFreshMfa(5 * 60)).rejects.toBeInstanceOf(HttpError);
|
|
expect(prisma.session.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
});
|