@@ -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 ( / c r e a t i o n w a s c a n c e l l e d / ) ;
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 ( / a u t h e n t i c a t i o n w a s c a n c e l l e d / ) ;
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+ / n a v i g a t o r \. c r e d e n t i a l s i s n o t a v a i l a b l e / ,
392+ ) ;
393+ } ) ;
394+ } ) ;
0 commit comments