@@ -170,7 +170,8 @@ describe('GCPSecurityService — project detection', () => {
170170 // ─── detectProjectsForOrg: org direct + folder-tree projects ──────────
171171
172172 /**
173- * Build a Response-like for the v2/folders endpoint.
173+ * Build a Response-like for the v3/folders endpoint (response shape
174+ * is identical to the legacy v2/folders endpoint).
174175 */
175176 function foldersPage ( opts : {
176177 folders : string [ ] ; // folder IDs (numeric)
@@ -203,11 +204,11 @@ describe('GCPSecurityService — project detection', () => {
203204 const seenFolderIdsQueried : string [ ] = [ ] ;
204205 fetchMock . mockImplementation ( async ( url : string ) => {
205206 // Folder enumeration: top-level folders under the org.
206- if ( url . includes ( 'v2 /folders' ) && url . includes ( 'organizations%2F43356919874' ) ) {
207+ if ( url . includes ( 'v3 /folders' ) && url . includes ( 'organizations%2F43356919874' ) ) {
207208 return foldersPage ( { folders : [ '9724350536' ] } ) ;
208209 }
209210 // Folder enumeration: sub-folders (none in this tree).
210- if ( url . includes ( 'v2 /folders' ) && url . includes ( 'folders%2F9724350536' ) ) {
211+ if ( url . includes ( 'v3 /folders' ) && url . includes ( 'folders%2F9724350536' ) ) {
211212 return foldersPage ( { folders : [ ] } ) ;
212213 }
213214 // Direct org children projects.
@@ -250,13 +251,13 @@ describe('GCPSecurityService — project detection', () => {
250251 // └── folder 3000 (nested)
251252 // └── project deep-prod
252253 fetchMock . mockImplementation ( async ( url : string ) => {
253- if ( url . includes ( 'v2 /folders' ) && url . includes ( 'organizations%2F1000' ) ) {
254+ if ( url . includes ( 'v3 /folders' ) && url . includes ( 'organizations%2F1000' ) ) {
254255 return foldersPage ( { folders : [ '2000' ] } ) ;
255256 }
256- if ( url . includes ( 'v2 /folders' ) && url . includes ( 'folders%2F2000' ) ) {
257+ if ( url . includes ( 'v3 /folders' ) && url . includes ( 'folders%2F2000' ) ) {
257258 return foldersPage ( { folders : [ '3000' ] } ) ;
258259 }
259- if ( url . includes ( 'v2 /folders' ) && url . includes ( 'folders%2F3000' ) ) {
260+ if ( url . includes ( 'v3 /folders' ) && url . includes ( 'folders%2F3000' ) ) {
260261 return foldersPage ( { folders : [ ] } ) ;
261262 }
262263 if ( url . includes ( 'v1/projects' ) && url . includes ( 'parent.id%3A1000' ) ) {
@@ -281,10 +282,10 @@ describe('GCPSecurityService — project detection', () => {
281282
282283 it ( 'dedupes when the same project would appear in both arms' , async ( ) => {
283284 fetchMock . mockImplementation ( async ( url : string ) => {
284- if ( url . includes ( 'v2 /folders' ) && url . includes ( 'organizations%2F123' ) ) {
285+ if ( url . includes ( 'v3 /folders' ) && url . includes ( 'organizations%2F123' ) ) {
285286 return foldersPage ( { folders : [ 'folder-a' ] } ) ;
286287 }
287- if ( url . includes ( 'v2 /folders' ) && url . includes ( 'folders%2Ffolder-a' ) ) {
288+ if ( url . includes ( 'v3 /folders' ) && url . includes ( 'folders%2Ffolder-a' ) ) {
288289 return foldersPage ( { folders : [ ] } ) ;
289290 }
290291 if ( url . includes ( 'v1/projects' ) && url . includes ( 'parent.id%3A123' ) ) {
@@ -302,9 +303,10 @@ describe('GCPSecurityService — project detection', () => {
302303 expect ( result . map ( ( p ) => p . id ) ) . toEqual ( [ 'proj-shared' , 'proj-unique' ] ) ;
303304 } ) ;
304305
305- it ( 'returns empty array when the org has no direct projects and no folders ' , async ( ) => {
306+ it ( 'returns empty array when both arms (and the broad-query fallback) come back empty ' , async ( ) => {
306307 fetchMock . mockImplementation ( async ( url : string ) => {
307- if ( url . includes ( 'v2/folders' ) ) return foldersPage ( { folders : [ ] } ) ;
308+ if ( url . includes ( 'v3/folders' ) ) return foldersPage ( { folders : [ ] } ) ;
309+ // Catches both the direct arm AND the broad-query fallback.
308310 if ( url . includes ( 'v1/projects' ) ) return gcpPage ( { projects : [ ] } ) ;
309311 throw new Error ( `Unexpected URL: ${ url } ` ) ;
310312 } ) ;
@@ -313,13 +315,172 @@ describe('GCPSecurityService — project detection', () => {
313315 expect ( result ) . toEqual ( [ ] ) ;
314316 } ) ;
315317
318+ it ( 'falls back to broad parent.type:folder query when v3/folders returns 403 PERMISSION_DENIED (customer Propper regression)' , async ( ) => {
319+ // Exact production failure mode: v3/folders returns
320+ // `403 PERMISSION_DENIED` for some OAuth grants despite the
321+ // user having org-level folder roles. The fallback MUST fire
322+ // ONLY for true forbiddens — cubic P2 on PR #2916 noted that
323+ // an unconditional fallback on "empty folders" would leak
324+ // cross-org projects for multi-org users whose selected org
325+ // simply has no folders. See the next test for that case.
326+ const broadQueryHits : string [ ] = [ ] ;
327+ fetchMock . mockImplementation ( async ( url : string ) => {
328+ if ( url . includes ( 'v3/folders' ) ) {
329+ // Real 403 PERMISSION_DENIED body that GCP returns.
330+ return {
331+ ok : false ,
332+ status : 403 ,
333+ text : async ( ) =>
334+ JSON . stringify ( {
335+ error : {
336+ code : 403 ,
337+ message : 'The caller does not have permission' ,
338+ status : 'PERMISSION_DENIED' ,
339+ } ,
340+ } ) ,
341+ } ;
342+ }
343+ if (
344+ url . includes ( 'v1/projects' ) &&
345+ url . includes ( 'parent.id%3A43356919874' )
346+ ) {
347+ return gcpPage ( {
348+ projects : [
349+ { projectId : 'direct-1' , name : 'Direct One' , projectNumber : '100' } ,
350+ ] ,
351+ } ) ;
352+ }
353+ if (
354+ url . includes ( 'v1/projects' ) &&
355+ url . includes ( 'parent.type%3Afolder' ) &&
356+ ! / p a r e n t \. i d % 3 A / . test ( url )
357+ ) {
358+ broadQueryHits . push ( url ) ;
359+ return gcpPage ( {
360+ projects : [
361+ { projectId : 'propperai-prod' , name : 'Propper Prod' , projectNumber : '200' } ,
362+ { projectId : 'propperai-demo' , name : 'Propper Demo' , projectNumber : '300' } ,
363+ ] ,
364+ } ) ;
365+ }
366+ throw new Error ( `Unexpected URL: ${ url } ` ) ;
367+ } ) ;
368+
369+ const result = await service . detectProjectsForOrg ( 'token' , '43356919874' ) ;
370+
371+ const ids = result . map ( ( p ) => p . id ) . sort ( ) ;
372+ expect ( ids ) . toEqual ( [ 'direct-1' , 'propperai-demo' , 'propperai-prod' ] ) ;
373+ expect ( broadQueryHits ) . toHaveLength ( 1 ) ;
374+ } ) ;
375+
376+ it ( 'does NOT fall back to the broad query when the org simply has no folders (cubic P2 fix)' , async ( ) => {
377+ // The previous PR (#2916) fired the broad fallback for ANY
378+ // empty enumeration result. That meant a multi-org user whose
379+ // selected org legitimately had no folders would see folder-
380+ // nested projects from OTHER orgs they had IAM access to —
381+ // because the broad query is not org-scoped.
382+ //
383+ // This test locks in the cubic P2 fix: enumeration returns
384+ // empty WITHOUT a 403 → no fallback → folder arm returns []
385+ // and the picker shows only the org's direct children.
386+ const broadQueryHits : string [ ] = [ ] ;
387+ fetchMock . mockImplementation ( async ( url : string ) => {
388+ if ( url . includes ( 'v3/folders' ) ) {
389+ // 200 OK + empty list. NOT a 403. The org just has no
390+ // folders. Common shape for small single-org tenants too.
391+ return foldersPage ( { folders : [ ] } ) ;
392+ }
393+ if (
394+ url . includes ( 'v1/projects' ) &&
395+ url . includes ( 'parent.id%3Aflat-org' )
396+ ) {
397+ return gcpPage ( {
398+ projects : [
399+ {
400+ projectId : 'only-direct' ,
401+ name : 'Only Direct' ,
402+ projectNumber : '900' ,
403+ } ,
404+ ] ,
405+ } ) ;
406+ }
407+ if (
408+ url . includes ( 'v1/projects' ) &&
409+ url . includes ( 'parent.type%3Afolder' ) &&
410+ ! / p a r e n t \. i d % 3 A / . test ( url )
411+ ) {
412+ // Would return cross-org projects if the fallback ever
413+ // fires. Track hits — must be zero.
414+ broadQueryHits . push ( url ) ;
415+ return gcpPage ( {
416+ projects : [
417+ {
418+ projectId : 'should-not-appear' ,
419+ name : 'Other Org Folder Project' ,
420+ projectNumber : '999' ,
421+ } ,
422+ ] ,
423+ } ) ;
424+ }
425+ throw new Error ( `Unexpected URL: ${ url } ` ) ;
426+ } ) ;
427+
428+ const result = await service . detectProjectsForOrg ( 'token' , 'flat-org' ) ;
429+
430+ expect ( result . map ( ( p ) => p . id ) ) . toEqual ( [ 'only-direct' ] ) ;
431+ // The broad fallback must NOT fire. Cubic P2.
432+ expect ( broadQueryHits ) . toHaveLength ( 0 ) ;
433+ } ) ;
434+
435+ it ( 'does NOT fire the broad-query fallback when folder enumeration succeeds (precise scoping preserved)' , async ( ) => {
436+ // Multi-org safety: when v3/folders works as expected, we stick
437+ // with the precise per-folder query so customers in multiple
438+ // orgs do not see projects from orgs other than the one they
439+ // selected (the original cubic P2 concern from PR #2899).
440+ let broadFallbackFired = false ;
441+ fetchMock . mockImplementation ( async ( url : string ) => {
442+ if ( url . includes ( 'v3/folders' ) && url . includes ( 'organizations%2Fhealthy-org' ) ) {
443+ return foldersPage ( { folders : [ 'healthy-folder' ] } ) ;
444+ }
445+ if ( url . includes ( 'v3/folders' ) ) {
446+ return foldersPage ( { folders : [ ] } ) ; // no sub-folders
447+ }
448+ if ( url . includes ( 'parent.id%3Ahealthy-org' ) && ! url . includes ( 'parent.type%3Afolder' ) ) {
449+ return gcpPage ( { projects : [ ] } ) ;
450+ }
451+ if (
452+ url . includes ( 'parent.type%3Afolder' ) &&
453+ url . includes ( 'parent.id%3Ahealthy-folder' )
454+ ) {
455+ return gcpPage ( {
456+ projects : [
457+ { projectId : 'scoped-prod' , name : 'Scoped' , projectNumber : '500' } ,
458+ ] ,
459+ } ) ;
460+ }
461+ // Catch-all: if the broad fallback fires, the test fails.
462+ if (
463+ url . includes ( 'parent.type%3Afolder' ) &&
464+ ! / p a r e n t \. i d % 3 A / . test ( url )
465+ ) {
466+ broadFallbackFired = true ;
467+ return gcpPage ( { projects : [ ] } ) ;
468+ }
469+ throw new Error ( `Unexpected URL: ${ url } ` ) ;
470+ } ) ;
471+
472+ const result = await service . detectProjectsForOrg ( 'token' , 'healthy-org' ) ;
473+ expect ( result . map ( ( p ) => p . id ) ) . toEqual ( [ 'scoped-prod' ] ) ;
474+ expect ( broadFallbackFired ) . toBe ( false ) ;
475+ } ) ;
476+
316477 it ( 'still returns direct-arm projects when the folder arm throws entirely (no-regression guarantee)' , async ( ) => {
317- // If GCP's v2 /folders endpoint throws or returns 4xx, the folder
478+ // If GCP's v3 /folders endpoint throws or returns 4xx, the folder
318479 // arm collapses to [] gracefully — the direct arm still works
319480 // and we are at minimum no worse than prod.
320481 fetchMock . mockImplementation ( async ( url : string ) => {
321- if ( url . includes ( 'v2 /folders' ) ) {
322- throw new Error ( 'simulated v2 /folders network failure' ) ;
482+ if ( url . includes ( 'v3 /folders' ) ) {
483+ throw new Error ( 'simulated v3 /folders network failure' ) ;
323484 }
324485 if ( url . includes ( 'parent.id%3A555' ) ) {
325486 return gcpPage ( {
@@ -342,10 +503,10 @@ describe('GCPSecurityService — project detection', () => {
342503 if ( url . includes ( 'v1/projects' ) && url . includes ( 'parent.id%3A666' ) && ! url . includes ( 'parent.type%3Afolder' ) ) {
343504 throw new Error ( 'simulated direct-arm failure' ) ;
344505 }
345- if ( url . includes ( 'v2 /folders' ) && url . includes ( 'organizations%2F666' ) ) {
506+ if ( url . includes ( 'v3 /folders' ) && url . includes ( 'organizations%2F666' ) ) {
346507 return foldersPage ( { folders : [ 'folder-x' ] } ) ;
347508 }
348- if ( url . includes ( 'v2 /folders' ) ) return foldersPage ( { folders : [ ] } ) ;
509+ if ( url . includes ( 'v3 /folders' ) ) return foldersPage ( { folders : [ ] } ) ;
349510 if ( url . includes ( 'v1/projects' ) && url . includes ( 'parent.id%3Afolder-x' ) ) {
350511 return gcpPage ( {
351512 projects : [
@@ -379,12 +540,12 @@ describe('GCPSecurityService — project detection', () => {
379540
380541 fetchMock . mockImplementation ( ( url : string ) => {
381542 if (
382- url . includes ( 'v2 /folders' ) &&
543+ url . includes ( 'v3 /folders' ) &&
383544 url . includes ( 'organizations%2Fbig-org' )
384545 ) {
385546 return Promise . resolve ( foldersPage ( { folders : folderIds } ) ) ;
386547 }
387- if ( url . includes ( 'v2 /folders' ) ) {
548+ if ( url . includes ( 'v3 /folders' ) ) {
388549 return Promise . resolve ( foldersPage ( { folders : [ ] } ) ) ; // no sub-folders
389550 }
390551 if (
@@ -453,10 +614,10 @@ describe('GCPSecurityService — project detection', () => {
453614 // Two folders, the first one's project list throws. The second
454615 // one should still return its projects.
455616 fetchMock . mockImplementation ( async ( url : string ) => {
456- if ( url . includes ( 'v2 /folders' ) && url . includes ( 'organizations%2F777' ) ) {
617+ if ( url . includes ( 'v3 /folders' ) && url . includes ( 'organizations%2F777' ) ) {
457618 return foldersPage ( { folders : [ 'bad-folder' , 'good-folder' ] } ) ;
458619 }
459- if ( url . includes ( 'v2 /folders' ) ) return foldersPage ( { folders : [ ] } ) ;
620+ if ( url . includes ( 'v3 /folders' ) ) return foldersPage ( { folders : [ ] } ) ;
460621 if ( url . includes ( 'v1/projects' ) && url . includes ( 'parent.id%3A777' ) && ! url . includes ( 'parent.type%3Afolder' ) ) {
461622 return gcpPage ( { projects : [ ] } ) ;
462623 }
0 commit comments