Skip to content

Commit 3491157

Browse files
committed
test(purchase): regression guard for parallel multi-account execution isolation (closes #210)
Adds TestExecuteMultiAccount_PartialFailure_IsolatesAccounts to pin spec E-2: when account-I has invalid credentials and account-V has valid credentials, account-V's execution must complete with status=completed and its own CommitmentID, while account-I is recorded as failed. Assertions cover: both SavePurchaseExecution calls fire (no silent skip), account-V's record is completed with a purchase, account-I's record is failed with a non-empty error, no cross-account credential material leaks into account-I's error (log-sanitisation guard), and account-V's CommitmentID does not appear anywhere in account-I's record (result-data independence). The base exec CloudAccountID stays nil, confirming per-account copies are used throughout the fan-out. Test-only change; no production code modified.
1 parent c84fd02 commit 3491157

1 file changed

Lines changed: 174 additions & 0 deletions

File tree

internal/purchase/execution_test.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)