@@ -617,6 +617,150 @@ describe('whoami command', () => {
617617 expect ( output ) . not . toContain ( 'secret' )
618618 } )
619619
620+ it . each ( [
621+ { workspaceId : null , status : 401 } ,
622+ { workspaceId : null , status : 403 } ,
623+ { workspaceId : 'ws_1' , status : 401 } ,
624+ { workspaceId : 'ws_1' , status : 403 } ,
625+ ] ) (
626+ 'reports credential rejection with workspace=$workspaceId and HTTP $status' ,
627+ async ( { workspaceId, status } ) => {
628+ mocks . profileFrom . mockReturnValue ( configured ( { workspaceId, output : 'json' } ) )
629+ mocks . request . mockRejectedValue ( new SimApiError ( 'Credential rejected' , status ) )
630+
631+ await whoami ( )
632+
633+ const result = JSON . parse ( vi . mocked ( console . log ) . mock . calls . flat ( ) . join ( '\n' ) )
634+ expect ( result . authenticated ) . toBe ( false )
635+ expect ( result . verification . status ) . toBe ( 'rejected' )
636+ expect ( result . verification . detail ) . toBe ( 'Credential rejected' )
637+ expect ( process . exitCode ) . toBe ( 1 )
638+ }
639+ )
640+
641+ it . each ( [ 0 , 404 , 429 , 502 ] ) (
642+ 'preserves setup guidance without claiming authentication when metadata is unavailable with HTTP %s' ,
643+ async ( status ) => {
644+ mocks . profileFrom . mockReturnValue ( configured ( { workspaceId : null , output : 'json' } ) )
645+ mocks . request . mockRejectedValue ( new SimApiError ( 'Metadata unavailable' , status ) )
646+
647+ await whoami ( )
648+
649+ const result = JSON . parse ( vi . mocked ( console . log ) . mock . calls . flat ( ) . join ( '\n' ) )
650+ expect ( result . authenticated ) . toBeNull ( )
651+ expect ( result . verification . status ) . toBe ( 'no-workspace' )
652+ expect ( result . verification . detail ) . toContain (
653+ 'sim configure --profile default --set-workspace'
654+ )
655+ expect ( process . exitCode ) . toBe ( 2 )
656+ }
657+ )
658+
659+ it ( 'distinguishes accepted credentials from missing workspace configuration' , async ( ) => {
660+ mocks . profileFrom . mockReturnValue ( configured ( { workspaceId : null , output : 'json' } ) )
661+
662+ await whoami ( )
663+
664+ const result = JSON . parse ( vi . mocked ( console . log ) . mock . calls . flat ( ) . join ( '\n' ) )
665+ expect ( result . authenticated ) . toBe ( true )
666+ expect ( result . verification . status ) . toBe ( 'no-workspace' )
667+ expect ( process . exitCode ) . toBe ( 2 )
668+ } )
669+
670+ it ( 'keeps authentication separate from workspace access' , async ( ) => {
671+ mocks . profileFrom . mockReturnValue ( configured ( { output : 'json' } ) )
672+ mocks . request . mockImplementation ( async ( path : string ) => {
673+ if ( path === '/api/v2/meta' ) return { data : { keyType : 'workspace' } }
674+ throw new SimApiError ( 'Workspace not found' , 404 )
675+ } )
676+
677+ await whoami ( )
678+
679+ const result = JSON . parse ( vi . mocked ( console . log ) . mock . calls . flat ( ) . join ( '\n' ) )
680+ expect ( result . authenticated ) . toBe ( true )
681+ expect ( result . verification . status ) . toBe ( 'rejected' )
682+ expect ( process . exitCode ) . toBe ( 1 )
683+ } )
684+
685+ it ( 'leaves authentication unknown when verification is explicitly skipped' , async ( ) => {
686+ mocks . profileFrom . mockReturnValue ( configured ( { output : 'json' } ) )
687+ mocks . request . mockImplementation ( async ( ) => {
688+ throw new Error ( 'Verification must be skipped' )
689+ } )
690+
691+ await whoami ( '--no-verify' )
692+
693+ const result = JSON . parse ( vi . mocked ( console . log ) . mock . calls . flat ( ) . join ( '\n' ) )
694+ expect ( result . authenticated ) . toBeNull ( )
695+ expect ( result . verification . status ) . toBe ( 'disabled' )
696+ expect ( process . exitCode ) . toBeUndefined ( )
697+ } )
698+
699+ it ( 'still verifies a workspace when an older server has no metadata endpoint' , async ( ) => {
700+ mocks . profileFrom . mockReturnValue ( configured ( { output : 'json' } ) )
701+ mocks . request . mockImplementation ( async ( path : string ) => {
702+ if ( path === '/api/v2/meta' ) throw new SimApiError ( 'Not found' , 404 )
703+ return { data : { id : 'ws_1' , name : 'Workspace' , memberCount : 1 } }
704+ } )
705+
706+ await whoami ( )
707+
708+ const result = JSON . parse ( vi . mocked ( console . log ) . mock . calls . flat ( ) . join ( '\n' ) )
709+ expect ( result . authenticated ) . toBe ( true )
710+ expect ( result . verification . status ) . toBe ( 'verified' )
711+ expect ( process . exitCode ) . toBeUndefined ( )
712+ } )
713+
714+ it . each ( [
715+ undefined ,
716+ null ,
717+ { } ,
718+ { data : null } ,
719+ { data : { } } ,
720+ { data : { keyType : 'unsupported' } } ,
721+ ] ) ( 'falls back to workspace verification for malformed metadata %j' , async ( meta ) => {
722+ mocks . profileFrom . mockReturnValue ( configured ( { output : 'json' } ) )
723+ mocks . request . mockImplementation ( async ( path : string ) =>
724+ path === '/api/v2/meta' ? meta : { data : { id : 'ws_1' , name : 'Workspace' , memberCount : 1 } }
725+ )
726+
727+ await whoami ( )
728+
729+ const result = JSON . parse ( vi . mocked ( console . log ) . mock . calls . flat ( ) . join ( '\n' ) )
730+ expect ( result . authenticated ) . toBe ( true )
731+ expect ( result . verification . status ) . toBe ( 'verified' )
732+ expect ( result . verification . keyType ) . toBeNull ( )
733+ expect ( process . exitCode ) . toBeUndefined ( )
734+ } )
735+
736+ it ( 'keeps authentication unknown when malformed metadata is the only possible check' , async ( ) => {
737+ mocks . profileFrom . mockReturnValue ( configured ( { workspaceId : null , output : 'json' } ) )
738+ respond ( { data : { keyType : 'unsupported' } } )
739+
740+ await whoami ( )
741+
742+ const result = JSON . parse ( vi . mocked ( console . log ) . mock . calls . flat ( ) . join ( '\n' ) )
743+ expect ( result . authenticated ) . toBeNull ( )
744+ expect ( result . verification . status ) . toBe ( 'no-workspace' )
745+ expect ( result . verification . keyType ) . toBeNull ( )
746+ expect ( process . exitCode ) . toBe ( 2 )
747+ } )
748+
749+ it ( 'reports a key revoked between the metadata and workspace reads as unauthenticated' , async ( ) => {
750+ mocks . profileFrom . mockReturnValue ( configured ( { output : 'json' } ) )
751+ mocks . request . mockImplementation ( async ( path : string ) => {
752+ if ( path === '/api/v2/meta' ) return { data : { keyType : 'personal' } }
753+ throw new SimApiError ( 'Invalid API key' , 401 )
754+ } )
755+
756+ await whoami ( )
757+
758+ const result = JSON . parse ( vi . mocked ( console . log ) . mock . calls . flat ( ) . join ( '\n' ) )
759+ expect ( result . authenticated ) . toBe ( false )
760+ expect ( result . verification . status ) . toBe ( 'rejected' )
761+ expect ( process . exitCode ) . toBe ( 1 )
762+ } )
763+
620764 it ( 'exits 1 when the API rejects the key, without hiding the resolved settings' , async ( ) => {
621765 mocks . request . mockRejectedValue (
622766 new SimApiError ( 'Invalid API key — run: sim login --profile default' , 401 )
0 commit comments