@@ -614,13 +614,33 @@ describe('OAuthController', () => {
614614 config : {
615615 authorizeUrl : 'https://github.com/login/oauth/authorize' ,
616616 tokenUrl : 'https://github.com/login/oauth/access_token' ,
617+ installUrl :
618+ 'https://github.com/apps/comp-ai-compliance/installations/new' ,
617619 } ,
618620 } ,
619621 capabilities : [ ] ,
620622 isActive : true ,
621623 } ;
622624
623- it ( 'blocks the GitHub App connect when the App is not installed' , async ( ) => {
625+ const notInstalledFetch = ( ) =>
626+ jest
627+ . spyOn ( global , 'fetch' )
628+ // 1) token exchange
629+ . mockResolvedValueOnce ( {
630+ ok : true ,
631+ status : 200 ,
632+ json : ( ) => Promise . resolve ( { access_token : 'gh_token' } ) ,
633+ text : ( ) => Promise . resolve ( '' ) ,
634+ } as unknown as Response )
635+ // 2) GET /user/installations -> none
636+ . mockResolvedValueOnce ( {
637+ ok : true ,
638+ status : 200 ,
639+ json : ( ) => Promise . resolve ( { total_count : 0 , installations : [ ] } ) ,
640+ text : ( ) => Promise . resolve ( '' ) ,
641+ } as unknown as Response ) ;
642+
643+ it ( 'redirects to the install URL when the App is not installed (first pass)' , async ( ) => {
624644 const futureDate = new Date ( Date . now ( ) + 600000 ) ;
625645 mockOAuthStateRepository . findByState . mockResolvedValue ( {
626646 state : 'gh_state' ,
@@ -638,31 +658,105 @@ describe('OAuthController', () => {
638658 scopes : [ ] ,
639659 source : 'platform' ,
640660 } ) ;
661+ mockOAuthStateRepository . create . mockResolvedValue ( {
662+ state : 'install_state' ,
663+ } ) ;
641664
642- const fetchSpy = jest
643- . spyOn ( global , 'fetch' )
644- // 1) token exchange
645- . mockResolvedValueOnce ( {
646- ok : true ,
647- status : 200 ,
648- json : ( ) => Promise . resolve ( { access_token : 'gh_token' } ) ,
649- text : ( ) => Promise . resolve ( '' ) ,
650- } as unknown as Response )
651- // 2) GET /user/installations -> none
652- . mockResolvedValueOnce ( {
653- ok : true ,
654- status : 200 ,
655- json : ( ) => Promise . resolve ( { total_count : 0 , installations : [ ] } ) ,
656- text : ( ) => Promise . resolve ( '' ) ,
657- } as unknown as Response ) ;
665+ const fetchSpy = notInstalledFetch ( ) ;
666+
667+ await controller . oauthCallback (
668+ { code : 'auth_code' , state : 'gh_state' } ,
669+ mockRequest ,
670+ mockResponse ,
671+ ) ;
672+
673+ // No installation yet → send them to install, don't finalize.
674+ expect ( mockConnectionService . activateConnection ) . not . toHaveBeenCalled ( ) ;
675+ const redirectUrl = ( mockResponse . redirect as jest . Mock ) . mock . calls [ 0 ] [ 0 ] ;
676+ expect ( redirectUrl ) . toContain (
677+ 'https://github.com/apps/comp-ai-compliance/installations/new' ,
678+ ) ;
679+ expect ( redirectUrl ) . toContain ( 'state=install_state' ) ;
680+
681+ // The install-handoff state must be created BEFORE the original state is
682+ // deleted, so a create failure can't leave the outer catch double-deleting
683+ // an already-removed record (which would hang the request).
684+ const createOrder =
685+ mockOAuthStateRepository . create . mock . invocationCallOrder [ 0 ] ;
686+ const deleteOrder =
687+ mockOAuthStateRepository . delete . mock . invocationCallOrder [ 0 ] ;
688+ expect ( createOrder ) . toBeLessThan ( deleteOrder ) ;
689+
690+ fetchSpy . mockRestore ( ) ;
691+ } ) ;
692+
693+ it ( 'still emits an error redirect if creating the install-handoff state fails' , async ( ) => {
694+ const futureDate = new Date ( Date . now ( ) + 600000 ) ;
695+ mockOAuthStateRepository . findByState . mockResolvedValue ( {
696+ state : 'gh_state' ,
697+ providerSlug : 'github-app' ,
698+ organizationId : 'org_1' ,
699+ userId : 'user_1' ,
700+ codeVerifier : null ,
701+ redirectUrl : null ,
702+ expiresAt : futureDate ,
703+ } ) ;
704+ mockedGetManifest . mockReturnValue ( githubAppManifest as never ) ;
705+ mockOAuthCredentialsService . getCredentials . mockResolvedValue ( {
706+ clientId : 'c' ,
707+ clientSecret : 's' ,
708+ scopes : [ ] ,
709+ source : 'platform' ,
710+ } ) ;
711+ // Creating the install-handoff state fails (e.g. DB error).
712+ mockOAuthStateRepository . create . mockRejectedValue ( new Error ( 'db down' ) ) ;
713+
714+ const fetchSpy = notInstalledFetch ( ) ;
658715
659716 await controller . oauthCallback (
660717 { code : 'auth_code' , state : 'gh_state' } ,
661718 mockRequest ,
662719 mockResponse ,
663720 ) ;
664721
665- // The connection must NOT be activated when the App isn't installed.
722+ // The original state was NOT deleted before the failed create, so the
723+ // outer catch can still clean it up and emit an error redirect (no hang).
724+ expect ( mockOAuthStateRepository . delete ) . toHaveBeenCalledWith ( 'gh_state' ) ;
725+ const errRedirect = ( mockResponse . redirect as jest . Mock ) . mock . calls [ 0 ] [ 0 ] ;
726+ expect ( errRedirect ) . toContain ( 'error=' ) ;
727+ expect ( mockConnectionService . activateConnection ) . not . toHaveBeenCalled ( ) ;
728+
729+ fetchSpy . mockRestore ( ) ;
730+ } ) ;
731+
732+ it ( 'errors (no loop) when still not installed after an install attempt' , async ( ) => {
733+ const futureDate = new Date ( Date . now ( ) + 600000 ) ;
734+ mockOAuthStateRepository . findByState . mockResolvedValue ( {
735+ state : 'gh_state' ,
736+ providerSlug : 'github-app' ,
737+ organizationId : 'org_1' ,
738+ userId : 'user_1' ,
739+ codeVerifier : null ,
740+ redirectUrl : null ,
741+ expiresAt : futureDate ,
742+ } ) ;
743+ mockedGetManifest . mockReturnValue ( githubAppManifest as never ) ;
744+ mockOAuthCredentialsService . getCredentials . mockResolvedValue ( {
745+ clientId : 'c' ,
746+ clientSecret : 's' ,
747+ scopes : [ ] ,
748+ source : 'platform' ,
749+ } ) ;
750+
751+ const fetchSpy = notInstalledFetch ( ) ;
752+
753+ // installation_id present → we already came back from an install attempt.
754+ await controller . oauthCallback (
755+ { code : 'auth_code' , state : 'gh_state' , installation_id : '999' } ,
756+ mockRequest ,
757+ mockResponse ,
758+ ) ;
759+
666760 expect ( mockConnectionService . activateConnection ) . not . toHaveBeenCalled ( ) ;
667761 const redirectUrl = ( mockResponse . redirect as jest . Mock ) . mock . calls [ 0 ] [ 0 ] ;
668762 expect ( redirectUrl ) . toContain ( 'error=github_app_not_installed' ) ;
0 commit comments