@@ -79,6 +79,34 @@ function sourceRepositoryList(records: unknown[] = []) {
7979 } ;
8080}
8181
82+ function scmInstallationRecord ( overrides : Record < string , unknown > = { } ) {
83+ return {
84+ id : "scminstall_123" ,
85+ type : "scm-installation" ,
86+ url : "https://api.prisma.test/v1/scm-installations/scminstall_123" ,
87+ provider : "github" ,
88+ installationId : 98765 ,
89+ accountId : 111 ,
90+ accountLogin : "prisma" ,
91+ accountType : "organization" ,
92+ suspended : false ,
93+ createdAt : "2026-05-18T00:00:00.000Z" ,
94+ updatedAt : "2026-05-18T00:00:00.000Z" ,
95+ ...overrides ,
96+ } ;
97+ }
98+
99+ function scmRepositoryRecord ( overrides : Record < string , unknown > = { } ) {
100+ return {
101+ id : 999 ,
102+ type : "scm-repository" ,
103+ fullName : "prisma/other" ,
104+ defaultBranch : "main" ,
105+ isPrivate : false ,
106+ ...overrides ,
107+ } ;
108+ }
109+
82110describe ( "real project mode" , ( ) => {
83111 it ( "uses the real API path for project list and sorts by name then id" , async ( ) => {
84112 const readAuthState = mockAuthState ( ) ;
@@ -811,6 +839,132 @@ describe("real project mode", () => {
811839 expect ( post ) . not . toHaveBeenCalled ( ) ;
812840 } ) ;
813841
842+ it ( "guards repeated GitHub App installation pagination cursors" , async ( ) => {
843+ const get = vi . fn ( ) . mockImplementation ( ( pathName : string ) => {
844+ if ( pathName === "/v1/projects" ) {
845+ return mockClient ( ) . GET ( pathName ) ;
846+ }
847+
848+ if ( pathName === "/v1/source-repositories" ) {
849+ return sourceRepositoryList ( ) ;
850+ }
851+
852+ if ( pathName === "/v1/scm-installations" ) {
853+ return {
854+ data : {
855+ data : [ ] ,
856+ pagination : {
857+ nextCursor : "repeat" ,
858+ hasMore : true ,
859+ } ,
860+ } ,
861+ } ;
862+ }
863+
864+ throw new Error ( `Unexpected path ${ pathName } ` ) ;
865+ } ) ;
866+ const post = vi . fn ( ) ;
867+
868+ vi . doMock ( "../src/lib/auth/auth-ops" , ( ) => ( {
869+ readAuthState : mockAuthState ( ) ,
870+ performLogin : vi . fn ( ) ,
871+ performLogout : vi . fn ( ) ,
872+ } ) ) ;
873+ vi . doMock ( "../src/lib/auth/guard" , ( ) => ( {
874+ requireComputeAuth : vi . fn ( ) . mockResolvedValue ( mockClient ( { GET : get , POST : post } ) ) ,
875+ } ) ) ;
876+
877+ const { createTempCwd, createTestCommandContext } = await import ( "./helpers" ) ;
878+ const { runGitConnect } = await import ( "../src/controllers/project" ) ;
879+ const cwd = await createTempCwd ( ) ;
880+ const stateDir = path . join ( cwd , ".state" ) ;
881+ const { context } = await createTestCommandContext ( {
882+ cwd,
883+ stateDir,
884+ env : {
885+ ...process . env ,
886+ PRISMA_CLI_MOCK_FIXTURE_PATH : undefined ,
887+ } ,
888+ } ) ;
889+
890+ await expect ( runGitConnect ( context , "https://github.com/prisma/prisma-cli" , { project : "proj_123" } ) )
891+ . rejects
892+ . toMatchObject ( {
893+ code : "REPO_CONNECTION_FAILED" ,
894+ why : "Pagination cursor did not advance." ,
895+ } ) ;
896+ expect ( post ) . not . toHaveBeenCalled ( ) ;
897+ } ) ;
898+
899+ it ( "guards repeated GitHub repository pagination cursors" , async ( ) => {
900+ const get = vi . fn ( ) . mockImplementation ( ( pathName : string ) => {
901+ if ( pathName === "/v1/projects" ) {
902+ return mockClient ( ) . GET ( pathName ) ;
903+ }
904+
905+ if ( pathName === "/v1/source-repositories" ) {
906+ return sourceRepositoryList ( ) ;
907+ }
908+
909+ if ( pathName === "/v1/scm-installations" ) {
910+ return {
911+ data : {
912+ data : [ scmInstallationRecord ( ) ] ,
913+ pagination : {
914+ nextCursor : null ,
915+ hasMore : false ,
916+ } ,
917+ } ,
918+ } ;
919+ }
920+
921+ if ( pathName === "/v1/scm-installations/{installationId}/repositories" ) {
922+ return {
923+ data : {
924+ data : [ scmRepositoryRecord ( ) ] ,
925+ pagination : {
926+ nextCursor : "repeat" ,
927+ hasMore : true ,
928+ } ,
929+ } ,
930+ } ;
931+ }
932+
933+ throw new Error ( `Unexpected path ${ pathName } ` ) ;
934+ } ) ;
935+ const post = vi . fn ( ) ;
936+
937+ vi . doMock ( "../src/lib/auth/auth-ops" , ( ) => ( {
938+ readAuthState : mockAuthState ( ) ,
939+ performLogin : vi . fn ( ) ,
940+ performLogout : vi . fn ( ) ,
941+ } ) ) ;
942+ vi . doMock ( "../src/lib/auth/guard" , ( ) => ( {
943+ requireComputeAuth : vi . fn ( ) . mockResolvedValue ( mockClient ( { GET : get , POST : post } ) ) ,
944+ } ) ) ;
945+
946+ const { createTempCwd, createTestCommandContext } = await import ( "./helpers" ) ;
947+ const { runGitConnect } = await import ( "../src/controllers/project" ) ;
948+ const cwd = await createTempCwd ( ) ;
949+ const stateDir = path . join ( cwd , ".state" ) ;
950+ const { context } = await createTestCommandContext ( {
951+ cwd,
952+ stateDir,
953+ env : {
954+ ...process . env ,
955+ PRISMA_CLI_MOCK_FIXTURE_PATH : undefined ,
956+ } ,
957+ } ) ;
958+
959+ await expect ( runGitConnect ( context , "https://github.com/prisma/prisma-cli" , { project : "proj_123" } ) )
960+ . rejects
961+ . toMatchObject ( {
962+ code : "REPO_CONNECTION_FAILED" ,
963+ why : "Pagination cursor did not advance." ,
964+ } ) ;
965+ expect ( post ) . not . toHaveBeenCalled ( ) ;
966+ } ) ;
967+
814968 it ( "disconnects a GitHub repository through the source repositories API" , async ( ) => {
815969 const del = vi . fn ( ) . mockResolvedValue ( { } ) ;
816970 const get = vi . fn ( ) . mockImplementation ( ( pathName : string ) => {
0 commit comments