@@ -698,3 +698,128 @@ describe('resolveGcpServiceId — Vertex AI grouping', () => {
698698 ) ;
699699 } ) ;
700700} ) ;
701+
702+ // ── SCC findings response helpers ───────────────────────────────────────────
703+ function sccPage (
704+ findings : Array < Record < string , unknown > > ,
705+ nextPageToken ?: string ,
706+ ) : { ok : true ; json : ( ) => Promise < unknown > } {
707+ return {
708+ ok : true ,
709+ json : async ( ) => ( {
710+ listFindingsResults : findings ,
711+ ...( nextPageToken ? { nextPageToken } : { } ) ,
712+ } ) ,
713+ } ;
714+ }
715+
716+ function sccError (
717+ status : number ,
718+ text : string ,
719+ ) : { ok : false ; status : number ; text : ( ) => Promise < string > } {
720+ return { ok : false , status, text : async ( ) => text } ;
721+ }
722+
723+ function sccFinding ( project : string ) : Record < string , unknown > {
724+ return {
725+ finding : {
726+ name : `organizations/1/sources/-/findings/${ project } -1` ,
727+ category : 'PUBLIC_BUCKET_ACL' ,
728+ description : 'desc' ,
729+ severity : 'HIGH' ,
730+ state : 'ACTIVE' ,
731+ resourceName : `//storage.googleapis.com/projects/${ project } /buckets/b` ,
732+ eventTime : '2026-01-01T00:00:00Z' ,
733+ createTime : '2026-01-01T00:00:00Z' ,
734+ } ,
735+ resource : {
736+ type : 'storage.googleapis.com/Bucket' ,
737+ projectDisplayName : project ,
738+ } ,
739+ } ;
740+ }
741+
742+ describe ( 'GCPSecurityService.scanSecurityFindings — all-scopes-failed guard' , ( ) => {
743+ let service : GCPSecurityService ;
744+ let fetchMock : jest . Mock ;
745+ const originalFetch = global . fetch ;
746+
747+ beforeEach ( ( ) => {
748+ fetchMock = jest . fn ( ) ;
749+ // @ts -expect-error replacing global fetch with a mock for these tests
750+ global . fetch = fetchMock ;
751+ service = new GCPSecurityService ( ) ;
752+ } ) ;
753+
754+ afterAll ( ( ) => {
755+ global . fetch = originalFetch ;
756+ } ) ;
757+
758+ // The bug: when the only scope errored, the scan used to swallow the error and
759+ // return [] — which got stored as a fresh "success" run with 0 findings,
760+ // hiding the previous good results. It must now throw so nothing is stored.
761+ it ( 'throws (re-throwing the actionable SCC error) when the only scope errors, instead of returning []' , async ( ) => {
762+ fetchMock . mockResolvedValue (
763+ sccError ( 403 , 'PERMISSION_DENIED: caller lacks securitycenter.findings.list' ) ,
764+ ) ;
765+
766+ await expect (
767+ service . scanSecurityFindings (
768+ { access_token : 'tok' } ,
769+ { project_ids : [ 'wasimil-prod' ] } ,
770+ ) ,
771+ ) . rejects . toThrow ( / P e r m i s s i o n d e n i e d / ) ;
772+ } ) ;
773+
774+ // The exact case from the customer report: project-scoped SCC query on a
775+ // no-org account returns 404 NOT_FOUND (SCC never activated for the project).
776+ it ( 'maps a 404 NOT_FOUND to the actionable SCC_NOT_ACTIVATED error' , async ( ) => {
777+ fetchMock . mockResolvedValue (
778+ sccError ( 404 , '{"error":{"code":404,"message":"Requested entity was not found.","status":"NOT_FOUND"}}' ) ,
779+ ) ;
780+
781+ await expect (
782+ service . scanSecurityFindings (
783+ { access_token : 'tok' } ,
784+ { project_ids : [ 'wasimil-prod' ] } ,
785+ ) ,
786+ ) . rejects . toThrow ( / S C C _ N O T _ A C T I V A T E D / ) ;
787+ } ) ;
788+
789+ it ( 'throws when EVERY configured scope errors' , async ( ) => {
790+ fetchMock . mockResolvedValue ( sccError ( 500 , 'internal error' ) ) ;
791+
792+ await expect (
793+ service . scanSecurityFindings (
794+ { access_token : 'tok' } ,
795+ { project_ids : [ 'p1' , 'p2' ] } ,
796+ ) ,
797+ ) . rejects . toThrow ( ) ;
798+ } ) ;
799+
800+ it ( 'does NOT throw when at least one scope succeeds — returns the surviving scope’s findings' , async ( ) => {
801+ fetchMock . mockImplementation ( async ( url : string ) => {
802+ if ( url . includes ( 'projects/p1' ) ) return sccError ( 403 , 'PERMISSION_DENIED' ) ;
803+ return sccPage ( [ sccFinding ( 'p2' ) ] ) ;
804+ } ) ;
805+
806+ const findings = await service . scanSecurityFindings (
807+ { access_token : 'tok' } ,
808+ { project_ids : [ 'p1' , 'p2' ] } ,
809+ ) ;
810+
811+ expect ( findings ) . toHaveLength ( 1 ) ;
812+ expect ( findings [ 0 ] . resourceId ) . toContain ( 'projects/p2' ) ;
813+ } ) ;
814+
815+ it ( 'does NOT throw when a scope succeeds with zero findings (genuinely clean account)' , async ( ) => {
816+ fetchMock . mockResolvedValue ( sccPage ( [ ] ) ) ;
817+
818+ const findings = await service . scanSecurityFindings (
819+ { access_token : 'tok' } ,
820+ { project_ids : [ 'wasimil-prod' ] } ,
821+ ) ;
822+
823+ expect ( findings ) . toEqual ( [ ] ) ;
824+ } ) ;
825+ } ) ;
0 commit comments