Files
HealthLog/src/lib/auth/__tests__/passkey.test.ts
T
Marc-André Bombeck e9d28b399e fix(copy): make delete confirmations, sleep-debt labels, and the glucose band honest
Three surfaces where the words no longer matched the code, plus the missing
web control for a preference the server already honours.

Delete confirmations. Four dialogs promised permanence on surfaces that
render an Undo: the measurements and mood bulk bodies, the lab-result body,
and the illness-episode body (which said "cannot be undone" sixty lines
below the code that fires the Undo toast). All four now use the same
sentence the single-row measurement and mood dialogs already used. A sweep
of every other confirmation in the tree found the rest accurate — the
biomarker cascade, the mood-tag purge, and the account purge keep their
warning because those deletes really are unrecoverable.

Sleep debt. v1.19.0 replaced the summed shortfall with a running balance
that credits catch-up sleep; the labels still described the old sum, so the
word contradicted the number for anyone who slept in after a short night.
Corrected in all six locales, plus the places the old semantics had
propagated: the Coach system prompt, the OpenAPI field descriptions the
native client reads, and four module docblocks.

Glucose reference band. `hasDiabetes` selects the tighter reference band and
grounds the Coach, but no web surface set it — a web-only account was pinned
to the non-diabetic scale with no way out. The control goes on Targets,
above the per-metric editor, because it answers the same question that page
answers and the two are ordered: it picks the default band, an override
below beats it. The copy says what it is — a reference band, not a
diagnosis, never inferred from a reading, no clinical action taken on it.

The passkey list was reported as always reading "never used". It does not
reproduce: the column is selected by the list route and stamped on every
verified assertion, both since v1.23.0. Pinned with tests on both halves so
the contract cannot quietly regress.
2026-07-19 12:28:05 +02:00

180 lines
6.2 KiB
TypeScript

/**
* v1.4.43 W13 L-3 — Zod-narrowed `verifyAuthentication` boundary.
*
* Before the fix, the route passed a parsed JSON body through a raw
* `as AuthenticationResponseJSON` cast straight into
* `verifyAuthenticationResponse`. The SimpleWebAuthn verifier owned
* shape validation downstream, but a future refactor reading
* `typedResponse.id` BEFORE calling the verifier would crash on
* `undefined.id`. The Zod schema in front of the verifier closes
* that comprehensively — a malformed body now throws here with a
* structured error.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
authChallenge: {
findUnique: vi.fn(),
deleteMany: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
},
passkey: {
findUnique: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@simplewebauthn/server", () => ({
generateRegistrationOptions: vi.fn(),
verifyRegistrationResponse: vi.fn(),
generateAuthenticationOptions: vi.fn(),
// The verifier should NEVER be invoked on a malformed body — that's
// the whole point of the Zod boundary.
verifyAuthenticationResponse: vi.fn(async () => ({
verified: true,
authenticationInfo: { newCounter: 1 },
})),
}));
import { verifyAuthentication } from "../passkey";
import { prisma } from "@/lib/db";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
const VALID_BODY = {
id: "credId123",
rawId: "credId123",
response: {
clientDataJSON: "AAAA",
authenticatorData: "BBBB",
signature: "CCCC",
},
clientExtensionResults: {},
type: "public-key",
};
beforeEach(() => {
vi.mocked(prisma.authChallenge.findUnique).mockResolvedValue({
id: "ch-1",
challenge: "challenge",
expiresAt: new Date(Date.now() + 60_000),
} as never);
vi.mocked(prisma.authChallenge.delete).mockResolvedValue({} as never);
vi.mocked(prisma.passkey.findUnique).mockResolvedValue({
id: "pk-1",
userId: "user-1",
credentialId: "credId123",
credentialPublicKey: new Uint8Array([1, 2, 3]),
counter: BigInt(0),
transports: ["internal"],
} as never);
vi.mocked(prisma.passkey.update).mockResolvedValue({} as never);
vi.mocked(verifyAuthenticationResponse).mockReset();
vi.mocked(verifyAuthenticationResponse).mockResolvedValue({
verified: true,
authenticationInfo: { newCounter: 1 },
} as never);
});
describe("verifyAuthentication — Zod boundary (L-3)", () => {
it("accepts a well-formed AuthenticationResponseJSON body", async () => {
const out = await verifyAuthentication("ch-1", VALID_BODY);
expect(out.verification.verified).toBe(true);
expect(out.passkey.userId).toBe("user-1");
expect(verifyAuthenticationResponse).toHaveBeenCalledTimes(1);
});
it("rejects a body missing the `id` field before reaching the verifier", async () => {
const malformed = { ...VALID_BODY, id: undefined };
await expect(verifyAuthentication("ch-1", malformed)).rejects.toThrow(
/Malformed passkey authentication response/,
);
expect(verifyAuthenticationResponse).not.toHaveBeenCalled();
});
it("rejects a body with the wrong `type` literal", async () => {
const malformed = { ...VALID_BODY, type: "not-a-public-key" };
await expect(verifyAuthentication("ch-1", malformed)).rejects.toThrow(
/Malformed passkey authentication response/,
);
expect(verifyAuthenticationResponse).not.toHaveBeenCalled();
});
it("rejects a body missing the nested `response.clientDataJSON`", async () => {
const malformed = {
...VALID_BODY,
response: { ...VALID_BODY.response, clientDataJSON: undefined },
};
await expect(verifyAuthentication("ch-1", malformed)).rejects.toThrow(
/Malformed passkey authentication response/,
);
expect(verifyAuthenticationResponse).not.toHaveBeenCalled();
});
it("rejects an empty object as a request body", async () => {
await expect(verifyAuthentication("ch-1", {})).rejects.toThrow(
/Malformed passkey authentication response/,
);
expect(verifyAuthenticationResponse).not.toHaveBeenCalled();
});
it("rejects a null body", async () => {
await expect(verifyAuthentication("ch-1", null)).rejects.toThrow(
/Malformed passkey authentication response/,
);
expect(verifyAuthenticationResponse).not.toHaveBeenCalled();
});
it("accepts a body with extra unknown fields (forward-compatibility)", async () => {
// SimpleWebAuthn's interface is permissive about future
// authenticator-attachment / extension-result additions; the Zod
// schema must not reject unknown keys at the top level.
const extended = { ...VALID_BODY, futureField: "ignored" };
const out = await verifyAuthentication("ch-1", extended);
expect(out.verification.verified).toBe(true);
});
});
/**
* "Last used" is a security-page fact people act on: a passkey that reads
* "never used" invites deletion. The column is only ever written here, on a
* verified assertion, so the stamp and the counter bump must stay one write.
* The read half is pinned in
* `src/app/api/auth/passkeys/__tests__/route.test.ts`.
*/
describe("verifyAuthentication — last-used stamp", () => {
// The file-level `beforeEach` re-arms the mocks but does not clear their
// call history, so the write assertions below need a clean ledger.
beforeEach(() => {
vi.mocked(prisma.passkey.update).mockClear();
});
it("stamps lastUsedAt alongside the counter on a verified assertion", async () => {
const before = Date.now();
await verifyAuthentication("ch-1", VALID_BODY);
expect(prisma.passkey.update).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.passkey.update).mock.calls[0][0];
expect(call.where).toEqual({ id: "pk-1" });
expect(call.data?.counter).toBe(BigInt(1));
const stamped = call.data?.lastUsedAt;
expect(stamped).toBeInstanceOf(Date);
expect((stamped as Date).getTime()).toBeGreaterThanOrEqual(before);
});
it("does not stamp lastUsedAt when the assertion fails", async () => {
vi.mocked(verifyAuthenticationResponse).mockResolvedValue({
verified: false,
} as never);
await verifyAuthentication("ch-1", VALID_BODY);
expect(prisma.passkey.update).not.toHaveBeenCalled();
});
});