@@ -134,6 +134,7 @@ describe("authenticated", () => {
134134 afterEach ( ( ) => vi . resetAllMocks ( ) ) ;
135135 beforeEach ( ( ) => {
136136 vi . spyOn ( persona , "getAccount" ) . mockResolvedValue ( undefined ) ; // eslint-disable-line unicorn/no-useless-undefined
137+ vi . spyOn ( panda , "getCards" ) . mockResolvedValue ( [ ] ) ;
137138 } ) ;
138139
139140 it ( "returns 404 card not found" , async ( ) => {
@@ -532,6 +533,232 @@ describe("authenticated", () => {
532533 expect ( captureException ) . not . toHaveBeenCalled ( ) ;
533534 } ) ;
534535
536+ it ( "returns 409 card limit reached when panda rejects with the max cards error" , async ( ) => {
537+ const credentialId = "card-limit-reached" ;
538+ await database . insert ( credentials ) . values ( {
539+ id : credentialId ,
540+ publicKey : new Uint8Array ( ) ,
541+ account : padHex ( "0x4042" , { size : 20 } ) ,
542+ factory : inject ( "ExaAccountFactory" ) ,
543+ pandaId : credentialId ,
544+ } ) ;
545+
546+ vi . spyOn ( panda , "getApplicationStatus" ) . mockResolvedValueOnce ( { id : "pandaId" , applicationStatus : "approved" } ) ;
547+ const createCard = vi
548+ . spyOn ( panda , "createCard" )
549+ . mockRejectedValueOnce (
550+ new ServiceError (
551+ "Panda" ,
552+ 400 ,
553+ '{"message":"User has reached the maximum number of cards allowed: 3","error":"BadRequestError","statusCode":400}' ,
554+ ) ,
555+ ) ;
556+
557+ const response = await appClient . index . $post ( { header : { "test-credential-id" : credentialId } } ) ;
558+
559+ expect ( response . status ) . toBe ( 409 ) ;
560+ await expect ( response . json ( ) ) . resolves . toStrictEqual ( { code : "card limit reached" } ) ;
561+ expect ( createCard ) . toHaveBeenCalledOnce ( ) ;
562+ expect ( captureException ) . toHaveBeenCalledExactlyOnceWith ( expect . any ( ServiceError ) as ServiceError , {
563+ level : "warning" ,
564+ fingerprint : [ "card-limit-reached" ] ,
565+ extra : { credentialId, pandaId : credentialId } ,
566+ } ) ;
567+ const persisted = await database . query . cards . findFirst ( { where : eq ( cards . credentialId , credentialId ) } ) ;
568+ expect ( persisted ) . toBeUndefined ( ) ;
569+ } ) ;
570+
571+ it ( "throws when createCard fails with an unrelated 400 error" , async ( ) => {
572+ const credentialId = "card-bad-request" ;
573+ await database . insert ( credentials ) . values ( {
574+ id : credentialId ,
575+ publicKey : new Uint8Array ( ) ,
576+ account : padHex ( "0x4043" , { size : 20 } ) ,
577+ factory : inject ( "ExaAccountFactory" ) ,
578+ pandaId : credentialId ,
579+ } ) ;
580+
581+ vi . spyOn ( panda , "getApplicationStatus" ) . mockResolvedValueOnce ( { id : "pandaId" , applicationStatus : "approved" } ) ;
582+ const createCard = vi
583+ . spyOn ( panda , "createCard" )
584+ . mockRejectedValueOnce (
585+ new ServiceError ( "Panda" , 400 , '{"message":"Invalid request","error":"BadRequestError","statusCode":400}' ) ,
586+ ) ;
587+
588+ const response = await appClient . index . $post ( { header : { "test-credential-id" : credentialId } } ) ;
589+
590+ expect ( response . status ) . toBe ( 500 ) ;
591+ expect ( createCard ) . toHaveBeenCalledOnce ( ) ;
592+ expect ( captureException ) . not . toHaveBeenCalled ( ) ;
593+ } ) ;
594+
595+ it ( "adopts an existing active panda card instead of creating a duplicate" , async ( ) => {
596+ const credentialId = "orphan-adopt" ;
597+ const orphanId = "00000000-0000-4000-8000-0000000000aa" ;
598+ await database . insert ( credentials ) . values ( {
599+ id : credentialId ,
600+ publicKey : new Uint8Array ( ) ,
601+ account : padHex ( "0x4051" , { size : 20 } ) ,
602+ factory : inject ( "ExaAccountFactory" ) ,
603+ pandaId : credentialId ,
604+ } ) ;
605+
606+ vi . spyOn ( panda , "getApplicationStatus" ) . mockResolvedValueOnce ( { id : "pandaId" , applicationStatus : "approved" } ) ;
607+ vi . spyOn ( panda , "getCards" ) . mockResolvedValueOnce ( [
608+ { id : orphanId , status : "active" , last4 : "4242" , expirationMonth : "9" , expirationYear : "2029" } ,
609+ ] ) ;
610+ const createCard = vi . spyOn ( panda , "createCard" ) ;
611+ const getAccount = vi . spyOn ( persona , "getAccount" ) ;
612+
613+ const response = await appClient . index . $post ( { header : { "test-credential-id" : credentialId } } ) ;
614+
615+ expect ( response . status ) . toBe ( 200 ) ;
616+ await expect ( response . json ( ) ) . resolves . toStrictEqual ( {
617+ status : "ACTIVE" ,
618+ lastFour : "4242" ,
619+ cardId : orphanId ,
620+ productId : SIGNATURE_PRODUCT_ID ,
621+ } ) ;
622+ expect ( createCard ) . not . toHaveBeenCalled ( ) ;
623+ expect ( getAccount ) . not . toHaveBeenCalled ( ) ;
624+ const adopted = await database . query . cards . findFirst ( {
625+ columns : { id : true , status : true , lastFour : true , productId : true } ,
626+ where : eq ( cards . credentialId , credentialId ) ,
627+ } ) ;
628+ expect ( adopted ) . toStrictEqual ( {
629+ id : orphanId ,
630+ status : "ACTIVE" ,
631+ lastFour : "4242" ,
632+ productId : SIGNATURE_PRODUCT_ID ,
633+ } ) ;
634+ expect ( captureException ) . toHaveBeenCalledExactlyOnceWith ( expect . any ( Error ) as Error , {
635+ level : "warning" ,
636+ fingerprint : [ "orphan-card-adopted" ] ,
637+ extra : { credentialId, pandaId : credentialId , cardId : orphanId } ,
638+ } ) ;
639+ } ) ;
640+
641+ it ( "adopts only the first active card when panda has multiple orphans" , async ( ) => {
642+ const credentialId = "orphan-multi" ;
643+ const first = "00000000-0000-4000-8000-0000000000c1" ;
644+ const second = "00000000-0000-4000-8000-0000000000c2" ;
645+ await database . insert ( credentials ) . values ( {
646+ id : credentialId ,
647+ publicKey : new Uint8Array ( ) ,
648+ account : padHex ( "0x4053" , { size : 20 } ) ,
649+ factory : inject ( "ExaAccountFactory" ) ,
650+ pandaId : credentialId ,
651+ } ) ;
652+
653+ vi . spyOn ( panda , "getApplicationStatus" ) . mockResolvedValueOnce ( { id : "pandaId" , applicationStatus : "approved" } ) ;
654+ vi . spyOn ( panda , "getCards" ) . mockResolvedValueOnce ( [
655+ { id : first , status : "active" , last4 : "4444" , expirationMonth : "9" , expirationYear : "2029" } ,
656+ { id : second , status : "active" , last4 : "5555" , expirationMonth : "9" , expirationYear : "2029" } ,
657+ {
658+ id : "00000000-0000-4000-8000-0000000000c3" ,
659+ status : "canceled" ,
660+ last4 : "6666" ,
661+ expirationMonth : "9" ,
662+ expirationYear : "2029" ,
663+ } ,
664+ ] ) ;
665+ const createCard = vi . spyOn ( panda , "createCard" ) ;
666+
667+ const response = await appClient . index . $post ( { header : { "test-credential-id" : credentialId } } ) ;
668+
669+ expect ( response . status ) . toBe ( 200 ) ;
670+ await expect ( response . json ( ) ) . resolves . toStrictEqual ( {
671+ status : "ACTIVE" ,
672+ lastFour : "4444" ,
673+ cardId : first ,
674+ productId : SIGNATURE_PRODUCT_ID ,
675+ } ) ;
676+ expect ( createCard ) . not . toHaveBeenCalled ( ) ;
677+ const persisted = await database . query . cards . findMany ( {
678+ columns : { id : true } ,
679+ where : eq ( cards . credentialId , credentialId ) ,
680+ } ) ;
681+ expect ( persisted ) . toStrictEqual ( [ { id : first } ] ) ;
682+ expect ( captureException ) . toHaveBeenCalledExactlyOnceWith ( expect . any ( Error ) as Error , {
683+ level : "warning" ,
684+ fingerprint : [ "orphan-card-adopted" ] ,
685+ extra : { credentialId, pandaId : credentialId , cardId : first } ,
686+ } ) ;
687+ } ) ;
688+
689+ it ( "creates a new card when panda has only non-active cards" , async ( ) => {
690+ const credentialId = "orphan-nonactive" ;
691+ const createdId = "00000000-0000-4000-8000-0000000000bb" ;
692+ await database . insert ( credentials ) . values ( {
693+ id : credentialId ,
694+ publicKey : new Uint8Array ( ) ,
695+ account : padHex ( "0x4052" , { size : 20 } ) ,
696+ factory : inject ( "ExaAccountFactory" ) ,
697+ pandaId : credentialId ,
698+ } ) ;
699+
700+ vi . spyOn ( panda , "getApplicationStatus" ) . mockResolvedValueOnce ( { id : "pandaId" , applicationStatus : "approved" } ) ;
701+ vi . spyOn ( panda , "getCards" ) . mockResolvedValueOnce ( [
702+ {
703+ id : "00000000-0000-4000-8000-0000000000b1" ,
704+ status : "canceled" ,
705+ last4 : "1111" ,
706+ expirationMonth : "9" ,
707+ expirationYear : "2029" ,
708+ } ,
709+ {
710+ id : "00000000-0000-4000-8000-0000000000b2" ,
711+ status : "locked" ,
712+ last4 : "2222" ,
713+ expirationMonth : "9" ,
714+ expirationYear : "2029" ,
715+ } ,
716+ ] ) ;
717+ const createCard = vi
718+ . spyOn ( panda , "createCard" )
719+ . mockResolvedValueOnce ( { ...cardTemplate , id : createdId , last4 : "3333" } ) ;
720+
721+ const response = await appClient . index . $post ( { header : { "test-credential-id" : credentialId } } ) ;
722+
723+ expect ( response . status ) . toBe ( 200 ) ;
724+ await expect ( response . json ( ) ) . resolves . toStrictEqual ( {
725+ status : "ACTIVE" ,
726+ lastFour : "3333" ,
727+ cardId : createdId ,
728+ productId : SIGNATURE_PRODUCT_ID ,
729+ } ) ;
730+ expect ( createCard ) . toHaveBeenCalledOnce ( ) ;
731+ expect ( captureException ) . not . toHaveBeenCalled ( ) ;
732+ const created = await database . query . cards . findFirst ( {
733+ columns : { id : true } ,
734+ where : eq ( cards . credentialId , credentialId ) ,
735+ } ) ;
736+ expect ( created ) . toStrictEqual ( { id : createdId } ) ;
737+ } ) ;
738+
739+ it ( "throws and does not create a card when getCards fails" , async ( ) => {
740+ const credentialId = "orphan-list-fail" ;
741+ await database . insert ( credentials ) . values ( {
742+ id : credentialId ,
743+ publicKey : new Uint8Array ( ) ,
744+ account : padHex ( "0x4054" , { size : 20 } ) ,
745+ factory : inject ( "ExaAccountFactory" ) ,
746+ pandaId : credentialId ,
747+ } ) ;
748+
749+ vi . spyOn ( panda , "getApplicationStatus" ) . mockResolvedValueOnce ( { id : "pandaId" , applicationStatus : "approved" } ) ;
750+ vi . spyOn ( panda , "getCards" ) . mockRejectedValueOnce ( new ServiceError ( "Panda" , 500 , "internal error" ) ) ;
751+ const createCard = vi . spyOn ( panda , "createCard" ) ;
752+
753+ const response = await appClient . index . $post ( { header : { "test-credential-id" : credentialId } } ) ;
754+
755+ expect ( response . status ) . toBe ( 500 ) ;
756+ expect ( createCard ) . not . toHaveBeenCalled ( ) ;
757+ expect ( captureException ) . not . toHaveBeenCalled ( ) ;
758+ const persisted = await database . query . cards . findFirst ( { where : eq ( cards . credentialId , credentialId ) } ) ;
759+ expect ( persisted ) . toBeUndefined ( ) ;
760+ } ) ;
761+
535762 it ( "returns 403 no panda when getApplicationStatus reports user not found" , async ( ) => {
536763 const credentialId = "stale-panda-id" ;
537764 await database . insert ( credentials ) . values ( {
0 commit comments