@@ -660,3 +660,177 @@ func TestExecuteForAccount_CredentialFailure_MarksFailed(t *testing.T) {
660660 })
661661 }
662662}
663+
664+ // TestExecuteMultiAccount_PartialFailure_IsolatesAccounts is the regression
665+ // guard for spec E-2: when one account (account-I) has invalid credentials and
666+ // another (account-V) has valid credentials, account-V's execution must
667+ // complete successfully and its record must be independent of account-I's failure.
668+ //
669+ // What this test pins:
670+ // - Two SavePurchaseExecution calls fire (one per account — no record is skipped).
671+ // - account-V's record: Status=="completed", PurchaseID set (purchase went through).
672+ // - account-I's record: Status=="failed", Error non-empty.
673+ // - account-I's error text does NOT contain account-V's credential material
674+ // (cross-account data isolation / log-sanitisation guard).
675+ // - account-V's CommitmentID does NOT appear in account-I's error text
676+ // (result_data independence).
677+ // - The overall executePurchase call returns an error (aggregated from I) —
678+ // proving the errgroup collected I's failure — while V's record was still
679+ // saved as "completed" (proving the errgroup did NOT short-circuit on I).
680+ func TestExecuteMultiAccount_PartialFailure_IsolatesAccounts (t * testing.T ) {
681+ ctx := context .Background ()
682+ mockStore := new (MockConfigStore )
683+ mockEmail := new (MockEmailSender )
684+ mockFactory := new (MockProviderFactory )
685+ mockProviderInst := new (MockProvider )
686+ mockServiceClient := new (MockServiceClient )
687+
688+ // account-V: valid mock credentials (access_keys path, LoadRaw returns key JSON).
689+ // account-I: invalid mock credentials (LoadRaw returns nil → "no credentials" resolver error).
690+ const (
691+ accountVID = "vvvvvvvv-0000-0000-0000-000000000001"
692+ accountIID = "iiiiiiii-0000-0000-0000-000000000002"
693+ accountVKey = "AKIAIOSFODNN7EXAMPLE" // sentinel — must not appear in I's error
694+ commitmentV = "ri-valid-001" // V's CommitmentID — must not appear in I's error
695+ )
696+
697+ accounts := []config.CloudAccount {
698+ {ID : accountVID , Name : "Valid" , Provider : "aws" , ExternalID : "111111111111" , AWSAuthMode : "access_keys" },
699+ {ID : accountIID , Name : "Invalid" , Provider : "aws" , ExternalID : "222222222222" , AWSAuthMode : "access_keys" },
700+ }
701+
702+ rec := config.RecommendationRecord {
703+ Provider : "aws" ,
704+ Service : "ec2" ,
705+ ResourceType : "m5.large" ,
706+ Region : "us-east-1" ,
707+ Count : 1 ,
708+ Savings : 75.0 ,
709+ UpfrontCost : 300.0 ,
710+ Selected : true ,
711+ }
712+
713+ exec := & config.PurchaseExecution {
714+ ExecutionID : "exec-partial" ,
715+ PlanID : "plan-partial" ,
716+ Status : "pending" ,
717+ Recommendations : []config.RecommendationRecord {rec },
718+ }
719+
720+ plan := & config.PurchasePlan {
721+ ID : "plan-partial" ,
722+ Name : "Partial Failure Plan" ,
723+ RampSchedule : config.RampSchedule {CurrentStep : 0 , TotalSteps : 1 },
724+ }
725+
726+ mockStore .On ("GetPurchasePlan" , ctx , "plan-partial" ).Return (plan , nil )
727+ mockStore .GetPlanAccountsFn = func (_ context.Context , _ string ) ([]config.CloudAccount , error ) {
728+ return accounts , nil
729+ }
730+
731+ // Collect saved execution records concurrency-safely (goroutine fan-out).
732+ var savedExecs []* config.PurchaseExecution
733+ var mu sync.Mutex
734+ mockStore .SavePurchaseExecutionFn = func (_ context.Context , e * config.PurchaseExecution ) error {
735+ mu .Lock ()
736+ // Copy the record before appending — executeForAccount reuses the acctExec
737+ // struct value but Recommendations slice is already deep-copied; capture
738+ // the status and error which are set before Save is called.
739+ saved := * e
740+ savedExecs = append (savedExecs , & saved )
741+ mu .Unlock ()
742+ return nil
743+ }
744+
745+ // Only account-V reaches the provider; account-I fails at credential resolution.
746+ // Use .Once() so testify fails if the factory is called for account-I as well.
747+ mockFactory .On ("CreateAndValidateProvider" , ctx , "aws" , mock .Anything ).Return (mockProviderInst , nil ).Once ()
748+ mockProviderInst .On ("GetServiceClient" , ctx , common .ServiceEC2 , "us-east-1" ).Return (mockServiceClient , nil ).Once ()
749+ mockServiceClient .On ("PurchaseCommitment" , ctx ,
750+ mock .AnythingOfType ("common.Recommendation" ),
751+ mock .AnythingOfType ("common.PurchaseOptions" ),
752+ ).Return (common.PurchaseResult {Success : true , CommitmentID : commitmentV }, nil ).Once ()
753+
754+ // Only account-V triggers history + notification.
755+ mockStore .On ("SavePurchaseHistory" , ctx , mock .AnythingOfType ("*config.PurchaseHistoryRecord" )).Return (nil ).Once ()
756+ mockEmail .On ("SendPurchaseConfirmation" , ctx , mock .AnythingOfType ("email.NotificationData" )).Return (nil ).Once ()
757+
758+ // Credential store: V gets valid key JSON; I gets nil (no credentials stored).
759+ credStore := & MockCredentialStore {
760+ LoadRawFn : func (_ context.Context , accountID , _ string ) ([]byte , error ) {
761+ if accountID == accountVID {
762+ return []byte (`{"access_key_id":"` + accountVKey + `","secret_access_key":"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}` ), nil
763+ }
764+ // account-I: no credentials stored → resolver returns error.
765+ return nil , nil
766+ },
767+ }
768+
769+ manager := & Manager {
770+ config : mockStore ,
771+ email : mockEmail ,
772+ providerFactory : mockFactory ,
773+ credStore : credStore ,
774+ }
775+
776+ _ , err := manager .executePurchase (ctx , exec )
777+
778+ // The call must return an error (account-I's failure is aggregated).
779+ // This proves the errgroup collected the failure rather than discarding it.
780+ require .Error (t , err , "executePurchase must surface account-I's credential failure" )
781+
782+ // Both per-account records must have been saved (no silent skip).
783+ require .Len (t , savedExecs , 2 , "exactly two SavePurchaseExecution calls expected — one per account" )
784+
785+ // Build a map keyed by cloud_account_id for order-independent lookup.
786+ byAccount := make (map [string ]* config.PurchaseExecution , 2 )
787+ for _ , e := range savedExecs {
788+ require .NotNil (t , e .CloudAccountID , "every saved record must have cloud_account_id set" )
789+ byAccount [* e .CloudAccountID ] = e
790+ }
791+
792+ // --- account-V assertions (success path must be unaffected by I's failure) ---
793+ recordV , ok := byAccount [accountVID ]
794+ require .True (t , ok , "account-V's execution record must be saved" )
795+ assert .Equal (t , "completed" , recordV .Status , "account-V must complete successfully" )
796+ assert .Empty (t , recordV .Error , "account-V's record must carry no error message" )
797+
798+ // Verify the purchase went through: at least one recommendation must be marked Purchased.
799+ purchasedV := false
800+ for _ , r := range recordV .Recommendations {
801+ if r .Purchased {
802+ purchasedV = true
803+ assert .Equal (t , commitmentV , r .PurchaseID , "account-V's commitment ID must match the mock return value" )
804+ }
805+ }
806+ assert .True (t , purchasedV , "account-V must have at least one purchased recommendation" )
807+
808+ // --- account-I assertions (failure path must be correctly recorded) ---
809+ recordI , ok := byAccount [accountIID ]
810+ require .True (t , ok , "account-I's execution record must be saved" )
811+ assert .Equal (t , "failed" , recordI .Status , "account-I must be marked failed" )
812+ assert .NotEmpty (t , recordI .Error , "account-I's record must carry an error message" )
813+
814+ // Log-sanitisation guard: account-I's error must not contain account-V's
815+ // credential material (cross-account data leak would be a security regression).
816+ assert .NotContains (t , recordI .Error , accountVKey ,
817+ "account-I's error must not contain account-V's access key (cross-account credential leak)" )
818+
819+ // Result-data independence: account-V's CommitmentID must not bleed into
820+ // account-I's error or recommendation error fields.
821+ assert .NotContains (t , recordI .Error , commitmentV ,
822+ "account-I's error must not reference account-V's commitment ID" )
823+ for _ , r := range recordI .Recommendations {
824+ assert .NotContains (t , r .Error , commitmentV ,
825+ "account-I's recommendation errors must not reference account-V's commitment ID" )
826+ }
827+
828+ // The base exec record must remain untagged (fan-out creates per-account copies).
829+ assert .Nil (t , exec .CloudAccountID , "base execution record must have nil cloud_account_id after fan-out" )
830+
831+ mockStore .AssertExpectations (t )
832+ mockEmail .AssertExpectations (t )
833+ mockFactory .AssertExpectations (t )
834+ mockProviderInst .AssertExpectations (t )
835+ mockServiceClient .AssertExpectations (t )
836+ }
0 commit comments