Skip to content

Commit 4dce3a5

Browse files
authored
Merge pull request #41 from codeheadsystems/test/ceremonies-mutation-coverage
test(browser): cover the full ceremony flows (correcting a PR #40 oversight)
2 parents caf6974 + 1b824a8 commit 4dce3a5

2 files changed

Lines changed: 223 additions & 0 deletions

File tree

clients/passkeys-browser/test/base64url.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,21 @@ describe("base64url", () => {
4747
expect(ab).toBeInstanceOf(ArrayBuffer);
4848
expect(ab.byteLength).toBe(4);
4949
});
50+
51+
// StrykerJS (PR #39, @bernata) flagged the input-type dispatch in toUint8Array.
52+
// Encoding a DataView (an ArrayBufferView that is neither Uint8Array nor
53+
// ArrayBuffer) exercises the third branch and kills the mutant that forces the
54+
// `instanceof ArrayBuffer` check true.
55+
it("encodes an ArrayBufferView (DataView) identically to the same bytes", () => {
56+
const buf = new Uint8Array([0xde, 0xad, 0xbe, 0xef]).buffer as ArrayBuffer;
57+
expect(b64u.encode(new DataView(buf))).toBe(b64u.encode(new Uint8Array(buf)));
58+
expect(b64u.encode(new DataView(buf))).toBe("3q2-7w");
59+
});
60+
61+
it("encodes a Uint8Array view that has a non-zero byteOffset", () => {
62+
// Subarray view: byteOffset 1, length 2 — guards the Uint8Array branch
63+
// against being skipped in favour of a buffer-from-scratch reconstruction.
64+
const full = new Uint8Array([0x00, 0xde, 0xad, 0x00]);
65+
expect(b64u.encode(full.subarray(1, 3))).toBe(b64u.encode(new Uint8Array([0xde, 0xad])));
66+
});
5067
});

clients/passkeys-browser/test/ceremonies.test.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,209 @@ describe("PkAuthCeremonyClient.register (end-to-end with stubbed credentials)",
186186
expect(fakeCreds.create).toHaveBeenCalledOnce();
187187
});
188188
});
189+
190+
// Additional ceremony coverage driven by the StrykerJS report (PR #39, @bernata):
191+
// the suite previously exercised only the register() happy path, leaving
192+
// authenticate(), the credential create/get helpers, the cancellation branches,
193+
// and the navigator.credentials guard as 0%-covered (every mutant survived).
194+
// These ceremonies ARE unit-testable because CeremonyOptions.credentials injects
195+
// a fake CredentialsContainer — so we drive the full flows here.
196+
197+
function fakeAssertion(rawId: Uint8Array): PublicKeyCredential {
198+
return fakeCredential(rawId, {
199+
clientDataJSON: new Uint8Array([0x7b]).buffer as ArrayBuffer,
200+
authenticatorData: new Uint8Array([0x55]).buffer as ArrayBuffer,
201+
signature: new Uint8Array([0x99]).buffer as ArrayBuffer,
202+
userHandle: null,
203+
} as unknown as AuthenticatorResponse);
204+
}
205+
206+
/** Records every request body keyed by the URL suffix, and serves canned responses. */
207+
function ceremonyFetch(responses: Record<string, unknown>) {
208+
const bodies: Record<string, Record<string, unknown>> = {};
209+
const methods: Record<string, string | undefined> = {};
210+
const urls: string[] = [];
211+
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
212+
const u = String(url);
213+
urls.push(u);
214+
const key = Object.keys(responses).find((suffix) => u.endsWith(suffix));
215+
if (!key) throw new Error("unexpected " + u);
216+
methods[key] = init?.method;
217+
if (init?.body) bodies[key] = JSON.parse(String(init.body));
218+
return new Response(JSON.stringify(responses[key]), { status: 200 });
219+
});
220+
return { fetchImpl, bodies, methods, urls };
221+
}
222+
223+
describe("PkAuthCeremonyClient.register (start-body contract)", () => {
224+
it("defaults displayName to username and label to null, POSTing both ceremony steps", async () => {
225+
const { fetchImpl, bodies, methods } = ceremonyFetch({
226+
"/registration/start": { challengeId: "ch-1", publicKey: CREATE_OPTIONS_JSON },
227+
"/registration/finish": { credential: { credentialId: "cred" } },
228+
});
229+
const fakeCreds = {
230+
create: vi.fn(async () =>
231+
fakeCredential(new Uint8Array([7]), {
232+
clientDataJSON: new Uint8Array([0]).buffer as ArrayBuffer,
233+
attestationObject: new Uint8Array([0]).buffer as ArrayBuffer,
234+
} as unknown as AuthenticatorResponse),
235+
),
236+
get: vi.fn(),
237+
} as unknown as CredentialsContainer;
238+
239+
const client = new PkAuthCeremonyClient(
240+
{ apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch },
241+
{ credentials: fakeCreds },
242+
);
243+
// No displayName, no label provided.
244+
await client.register({ username: "alice" });
245+
246+
// displayName ?? username (kills the `&&` mutant) and label ?? null.
247+
expect(bodies["/registration/start"]).toMatchObject({
248+
username: "alice",
249+
displayName: "alice",
250+
label: null,
251+
challenge: null,
252+
});
253+
// label ?? null again on the finish body.
254+
expect(bodies["/registration/finish"]).toMatchObject({ username: "alice", label: null });
255+
// Both ceremony steps must be POST (kills the "POST" -> "" mutants).
256+
expect(methods["/registration/start"]).toBe("POST");
257+
expect(methods["/registration/finish"]).toBe("POST");
258+
});
259+
260+
it("passes an explicit displayName and label straight through", async () => {
261+
const { fetchImpl, bodies } = ceremonyFetch({
262+
"/registration/start": { challengeId: "ch-1", publicKey: CREATE_OPTIONS_JSON },
263+
"/registration/finish": { credential: { credentialId: "cred" } },
264+
});
265+
const fakeCreds = {
266+
create: vi.fn(async () =>
267+
fakeCredential(new Uint8Array([7]), {
268+
clientDataJSON: new Uint8Array([0]).buffer as ArrayBuffer,
269+
attestationObject: new Uint8Array([0]).buffer as ArrayBuffer,
270+
} as unknown as AuthenticatorResponse),
271+
),
272+
get: vi.fn(),
273+
} as unknown as CredentialsContainer;
274+
const client = new PkAuthCeremonyClient(
275+
{ apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch },
276+
{ credentials: fakeCreds },
277+
);
278+
await client.register({ username: "bob", displayName: "Bobby", label: "yubikey" });
279+
expect(bodies["/registration/start"]).toMatchObject({ displayName: "Bobby", label: "yubikey" });
280+
expect(bodies["/registration/finish"]).toMatchObject({ label: "yubikey" });
281+
});
282+
283+
it("rejects when the authenticator returns no credential (create cancelled)", async () => {
284+
const { fetchImpl } = ceremonyFetch({
285+
"/registration/start": { challengeId: "ch-1", publicKey: CREATE_OPTIONS_JSON },
286+
});
287+
const fakeCreds = { create: vi.fn(async () => null), get: vi.fn() } as unknown as CredentialsContainer;
288+
const client = new PkAuthCeremonyClient(
289+
{ apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch },
290+
{ credentials: fakeCreds },
291+
);
292+
await expect(client.register({ username: "alice" })).rejects.toThrow(/creation was cancelled/);
293+
});
294+
});
295+
296+
describe("PkAuthCeremonyClient.authenticate (end-to-end with stubbed credentials)", () => {
297+
it("walks start -> get -> finish and returns the token", async () => {
298+
const { fetchImpl, bodies, methods, urls } = ceremonyFetch({
299+
"/authentication/start": { challengeId: "ch-9", publicKey: REQUEST_OPTIONS_JSON },
300+
"/authentication/finish": { token: "jwt-token" },
301+
});
302+
const get = vi.fn(async () => fakeAssertion(new Uint8Array([42])));
303+
const fakeCreds = { create: vi.fn(), get } as unknown as CredentialsContainer;
304+
const client = new PkAuthCeremonyClient(
305+
{ apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch },
306+
{ credentials: fakeCreds },
307+
);
308+
309+
const result = await client.authenticate({ username: "alice" });
310+
311+
expect(result.token).toBe("jwt-token");
312+
expect(get).toHaveBeenCalledOnce();
313+
expect(bodies["/authentication/start"]).toMatchObject({ username: "alice", challenge: null });
314+
expect(bodies["/authentication/finish"]).toMatchObject({ challengeId: "ch-9" });
315+
expect(methods["/authentication/start"]).toBe("POST");
316+
expect(methods["/authentication/finish"]).toBe("POST");
317+
expect(urls.some((u) => u.endsWith("/authentication/start"))).toBe(true);
318+
});
319+
320+
it("defaults username to null when omitted", async () => {
321+
const { fetchImpl, bodies } = ceremonyFetch({
322+
"/authentication/start": { challengeId: "ch-9", publicKey: REQUEST_OPTIONS_JSON },
323+
"/authentication/finish": { token: "t" },
324+
});
325+
const fakeCreds = {
326+
create: vi.fn(),
327+
get: vi.fn(async () => fakeAssertion(new Uint8Array([1]))),
328+
} as unknown as CredentialsContainer;
329+
const client = new PkAuthCeremonyClient(
330+
{ apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch },
331+
{ credentials: fakeCreds },
332+
);
333+
await client.authenticate();
334+
// username ?? null (kills the `&&` mutant): no username -> explicit null.
335+
expect(bodies["/authentication/start"]).toMatchObject({ username: null });
336+
});
337+
338+
it("requests conditional mediation only when conditional=true", async () => {
339+
const responses = {
340+
"/authentication/start": { challengeId: "ch-9", publicKey: REQUEST_OPTIONS_JSON },
341+
"/authentication/finish": { token: "t" },
342+
};
343+
// conditional = true -> mediation set
344+
const getCond = vi.fn(async () => fakeAssertion(new Uint8Array([1])));
345+
const a = ceremonyFetch(responses);
346+
await new PkAuthCeremonyClient(
347+
{ apiBase: "https://x", fetch: a.fetchImpl as unknown as typeof fetch },
348+
{ credentials: { create: vi.fn(), get: getCond } as unknown as CredentialsContainer },
349+
).authenticate({ conditional: true });
350+
expect(
351+
(getCond.mock.calls[0]![0] as CredentialRequestOptions & { mediation?: string }).mediation,
352+
).toBe("conditional");
353+
354+
// conditional defaults to false -> no mediation
355+
const getPlain = vi.fn(async () => fakeAssertion(new Uint8Array([1])));
356+
const b = ceremonyFetch(responses);
357+
await new PkAuthCeremonyClient(
358+
{ apiBase: "https://x", fetch: b.fetchImpl as unknown as typeof fetch },
359+
{ credentials: { create: vi.fn(), get: getPlain } as unknown as CredentialsContainer },
360+
).authenticate();
361+
expect(
362+
(getPlain.mock.calls[0]![0] as CredentialRequestOptions & { mediation?: string }).mediation,
363+
).toBeUndefined();
364+
});
365+
366+
it("rejects when the authenticator returns no credential (get cancelled)", async () => {
367+
const { fetchImpl } = ceremonyFetch({
368+
"/authentication/start": { challengeId: "ch-9", publicKey: REQUEST_OPTIONS_JSON },
369+
});
370+
const fakeCreds = { create: vi.fn(), get: vi.fn(async () => null) } as unknown as CredentialsContainer;
371+
const client = new PkAuthCeremonyClient(
372+
{ apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch },
373+
{ credentials: fakeCreds },
374+
);
375+
await expect(client.authenticate()).rejects.toThrow(/authentication was cancelled/);
376+
});
377+
});
378+
379+
describe("PkAuthCeremonyClient credentials guard", () => {
380+
it("throws a clear error when no credentials are injected and navigator lacks them", async () => {
381+
// jsdom does not implement navigator.credentials, so the fallback guard
382+
// fires. Kills the `if (this.credentials)` and navigator-check mutants.
383+
const { fetchImpl } = ceremonyFetch({
384+
"/registration/start": { challengeId: "ch-1", publicKey: CREATE_OPTIONS_JSON },
385+
});
386+
const client = new PkAuthCeremonyClient({
387+
apiBase: "https://x",
388+
fetch: fetchImpl as unknown as typeof fetch,
389+
});
390+
await expect(client.register({ username: "alice" })).rejects.toThrow(
391+
/navigator\.credentials is not available/,
392+
);
393+
});
394+
});

0 commit comments

Comments
 (0)