Skip to content

Commit 3e8da30

Browse files
authored
Merge pull request #2917 from trycompai/main
[comp] Production Deploy
2 parents 81b60b5 + 0f2c990 commit 3e8da30

2 files changed

Lines changed: 261 additions & 32 deletions

File tree

‎apps/api/src/cloud-security/providers/gcp-security.service.spec.ts‎

Lines changed: 180 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
!/parent\.id%3A/.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+
!/parent\.id%3A/.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+
!/parent\.id%3A/.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

Comments
 (0)