diff --git a/clients/passkeys-browser/test/base64url.test.ts b/clients/passkeys-browser/test/base64url.test.ts index 129eaa2..1333590 100644 --- a/clients/passkeys-browser/test/base64url.test.ts +++ b/clients/passkeys-browser/test/base64url.test.ts @@ -47,4 +47,21 @@ describe("base64url", () => { expect(ab).toBeInstanceOf(ArrayBuffer); expect(ab.byteLength).toBe(4); }); + + // StrykerJS (PR #39, @bernata) flagged the input-type dispatch in toUint8Array. + // Encoding a DataView (an ArrayBufferView that is neither Uint8Array nor + // ArrayBuffer) exercises the third branch and kills the mutant that forces the + // `instanceof ArrayBuffer` check true. + it("encodes an ArrayBufferView (DataView) identically to the same bytes", () => { + const buf = new Uint8Array([0xde, 0xad, 0xbe, 0xef]).buffer as ArrayBuffer; + expect(b64u.encode(new DataView(buf))).toBe(b64u.encode(new Uint8Array(buf))); + expect(b64u.encode(new DataView(buf))).toBe("3q2-7w"); + }); + + it("encodes a Uint8Array view that has a non-zero byteOffset", () => { + // Subarray view: byteOffset 1, length 2 — guards the Uint8Array branch + // against being skipped in favour of a buffer-from-scratch reconstruction. + const full = new Uint8Array([0x00, 0xde, 0xad, 0x00]); + expect(b64u.encode(full.subarray(1, 3))).toBe(b64u.encode(new Uint8Array([0xde, 0xad]))); + }); }); diff --git a/clients/passkeys-browser/test/ceremonies.test.ts b/clients/passkeys-browser/test/ceremonies.test.ts index 185806a..d868cef 100644 --- a/clients/passkeys-browser/test/ceremonies.test.ts +++ b/clients/passkeys-browser/test/ceremonies.test.ts @@ -186,3 +186,209 @@ describe("PkAuthCeremonyClient.register (end-to-end with stubbed credentials)", expect(fakeCreds.create).toHaveBeenCalledOnce(); }); }); + +// Additional ceremony coverage driven by the StrykerJS report (PR #39, @bernata): +// the suite previously exercised only the register() happy path, leaving +// authenticate(), the credential create/get helpers, the cancellation branches, +// and the navigator.credentials guard as 0%-covered (every mutant survived). +// These ceremonies ARE unit-testable because CeremonyOptions.credentials injects +// a fake CredentialsContainer — so we drive the full flows here. + +function fakeAssertion(rawId: Uint8Array): PublicKeyCredential { + return fakeCredential(rawId, { + clientDataJSON: new Uint8Array([0x7b]).buffer as ArrayBuffer, + authenticatorData: new Uint8Array([0x55]).buffer as ArrayBuffer, + signature: new Uint8Array([0x99]).buffer as ArrayBuffer, + userHandle: null, + } as unknown as AuthenticatorResponse); +} + +/** Records every request body keyed by the URL suffix, and serves canned responses. */ +function ceremonyFetch(responses: Record) { + const bodies: Record> = {}; + const methods: Record = {}; + const urls: string[] = []; + const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const u = String(url); + urls.push(u); + const key = Object.keys(responses).find((suffix) => u.endsWith(suffix)); + if (!key) throw new Error("unexpected " + u); + methods[key] = init?.method; + if (init?.body) bodies[key] = JSON.parse(String(init.body)); + return new Response(JSON.stringify(responses[key]), { status: 200 }); + }); + return { fetchImpl, bodies, methods, urls }; +} + +describe("PkAuthCeremonyClient.register (start-body contract)", () => { + it("defaults displayName to username and label to null, POSTing both ceremony steps", async () => { + const { fetchImpl, bodies, methods } = ceremonyFetch({ + "/registration/start": { challengeId: "ch-1", publicKey: CREATE_OPTIONS_JSON }, + "/registration/finish": { credential: { credentialId: "cred" } }, + }); + const fakeCreds = { + create: vi.fn(async () => + fakeCredential(new Uint8Array([7]), { + clientDataJSON: new Uint8Array([0]).buffer as ArrayBuffer, + attestationObject: new Uint8Array([0]).buffer as ArrayBuffer, + } as unknown as AuthenticatorResponse), + ), + get: vi.fn(), + } as unknown as CredentialsContainer; + + const client = new PkAuthCeremonyClient( + { apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch }, + { credentials: fakeCreds }, + ); + // No displayName, no label provided. + await client.register({ username: "alice" }); + + // displayName ?? username (kills the `&&` mutant) and label ?? null. + expect(bodies["/registration/start"]).toMatchObject({ + username: "alice", + displayName: "alice", + label: null, + challenge: null, + }); + // label ?? null again on the finish body. + expect(bodies["/registration/finish"]).toMatchObject({ username: "alice", label: null }); + // Both ceremony steps must be POST (kills the "POST" -> "" mutants). + expect(methods["/registration/start"]).toBe("POST"); + expect(methods["/registration/finish"]).toBe("POST"); + }); + + it("passes an explicit displayName and label straight through", async () => { + const { fetchImpl, bodies } = ceremonyFetch({ + "/registration/start": { challengeId: "ch-1", publicKey: CREATE_OPTIONS_JSON }, + "/registration/finish": { credential: { credentialId: "cred" } }, + }); + const fakeCreds = { + create: vi.fn(async () => + fakeCredential(new Uint8Array([7]), { + clientDataJSON: new Uint8Array([0]).buffer as ArrayBuffer, + attestationObject: new Uint8Array([0]).buffer as ArrayBuffer, + } as unknown as AuthenticatorResponse), + ), + get: vi.fn(), + } as unknown as CredentialsContainer; + const client = new PkAuthCeremonyClient( + { apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch }, + { credentials: fakeCreds }, + ); + await client.register({ username: "bob", displayName: "Bobby", label: "yubikey" }); + expect(bodies["/registration/start"]).toMatchObject({ displayName: "Bobby", label: "yubikey" }); + expect(bodies["/registration/finish"]).toMatchObject({ label: "yubikey" }); + }); + + it("rejects when the authenticator returns no credential (create cancelled)", async () => { + const { fetchImpl } = ceremonyFetch({ + "/registration/start": { challengeId: "ch-1", publicKey: CREATE_OPTIONS_JSON }, + }); + const fakeCreds = { create: vi.fn(async () => null), get: vi.fn() } as unknown as CredentialsContainer; + const client = new PkAuthCeremonyClient( + { apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch }, + { credentials: fakeCreds }, + ); + await expect(client.register({ username: "alice" })).rejects.toThrow(/creation was cancelled/); + }); +}); + +describe("PkAuthCeremonyClient.authenticate (end-to-end with stubbed credentials)", () => { + it("walks start -> get -> finish and returns the token", async () => { + const { fetchImpl, bodies, methods, urls } = ceremonyFetch({ + "/authentication/start": { challengeId: "ch-9", publicKey: REQUEST_OPTIONS_JSON }, + "/authentication/finish": { token: "jwt-token" }, + }); + const get = vi.fn(async () => fakeAssertion(new Uint8Array([42]))); + const fakeCreds = { create: vi.fn(), get } as unknown as CredentialsContainer; + const client = new PkAuthCeremonyClient( + { apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch }, + { credentials: fakeCreds }, + ); + + const result = await client.authenticate({ username: "alice" }); + + expect(result.token).toBe("jwt-token"); + expect(get).toHaveBeenCalledOnce(); + expect(bodies["/authentication/start"]).toMatchObject({ username: "alice", challenge: null }); + expect(bodies["/authentication/finish"]).toMatchObject({ challengeId: "ch-9" }); + expect(methods["/authentication/start"]).toBe("POST"); + expect(methods["/authentication/finish"]).toBe("POST"); + expect(urls.some((u) => u.endsWith("/authentication/start"))).toBe(true); + }); + + it("defaults username to null when omitted", async () => { + const { fetchImpl, bodies } = ceremonyFetch({ + "/authentication/start": { challengeId: "ch-9", publicKey: REQUEST_OPTIONS_JSON }, + "/authentication/finish": { token: "t" }, + }); + const fakeCreds = { + create: vi.fn(), + get: vi.fn(async () => fakeAssertion(new Uint8Array([1]))), + } as unknown as CredentialsContainer; + const client = new PkAuthCeremonyClient( + { apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch }, + { credentials: fakeCreds }, + ); + await client.authenticate(); + // username ?? null (kills the `&&` mutant): no username -> explicit null. + expect(bodies["/authentication/start"]).toMatchObject({ username: null }); + }); + + it("requests conditional mediation only when conditional=true", async () => { + const responses = { + "/authentication/start": { challengeId: "ch-9", publicKey: REQUEST_OPTIONS_JSON }, + "/authentication/finish": { token: "t" }, + }; + // conditional = true -> mediation set + const getCond = vi.fn(async () => fakeAssertion(new Uint8Array([1]))); + const a = ceremonyFetch(responses); + await new PkAuthCeremonyClient( + { apiBase: "https://x", fetch: a.fetchImpl as unknown as typeof fetch }, + { credentials: { create: vi.fn(), get: getCond } as unknown as CredentialsContainer }, + ).authenticate({ conditional: true }); + expect( + (getCond.mock.calls[0]![0] as CredentialRequestOptions & { mediation?: string }).mediation, + ).toBe("conditional"); + + // conditional defaults to false -> no mediation + const getPlain = vi.fn(async () => fakeAssertion(new Uint8Array([1]))); + const b = ceremonyFetch(responses); + await new PkAuthCeremonyClient( + { apiBase: "https://x", fetch: b.fetchImpl as unknown as typeof fetch }, + { credentials: { create: vi.fn(), get: getPlain } as unknown as CredentialsContainer }, + ).authenticate(); + expect( + (getPlain.mock.calls[0]![0] as CredentialRequestOptions & { mediation?: string }).mediation, + ).toBeUndefined(); + }); + + it("rejects when the authenticator returns no credential (get cancelled)", async () => { + const { fetchImpl } = ceremonyFetch({ + "/authentication/start": { challengeId: "ch-9", publicKey: REQUEST_OPTIONS_JSON }, + }); + const fakeCreds = { create: vi.fn(), get: vi.fn(async () => null) } as unknown as CredentialsContainer; + const client = new PkAuthCeremonyClient( + { apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch }, + { credentials: fakeCreds }, + ); + await expect(client.authenticate()).rejects.toThrow(/authentication was cancelled/); + }); +}); + +describe("PkAuthCeremonyClient credentials guard", () => { + it("throws a clear error when no credentials are injected and navigator lacks them", async () => { + // jsdom does not implement navigator.credentials, so the fallback guard + // fires. Kills the `if (this.credentials)` and navigator-check mutants. + const { fetchImpl } = ceremonyFetch({ + "/registration/start": { challengeId: "ch-1", publicKey: CREATE_OPTIONS_JSON }, + }); + const client = new PkAuthCeremonyClient({ + apiBase: "https://x", + fetch: fetchImpl as unknown as typeof fetch, + }); + await expect(client.register({ username: "alice" })).rejects.toThrow( + /navigator\.credentials is not available/, + ); + }); +});