Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions clients/passkeys-browser/test/base64url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])));
});
});
206 changes: 206 additions & 0 deletions clients/passkeys-browser/test/ceremonies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
const bodies: Record<string, Record<string, unknown>> = {};
const methods: Record<string, string | undefined> = {};
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/,
);
});
});