diff --git a/internal/api/handler_coverage_test.go b/internal/api/handler_coverage_test.go index 36295a8f..c811513e 100644 --- a/internal/api/handler_coverage_test.go +++ b/internal/api/handler_coverage_test.go @@ -87,6 +87,7 @@ func TestHandler_validateSecurityContext_Unauthorized(t *testing.T) { func TestHandler_validateSecurityContext_CSRFFailure(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) // Setup authentication to pass (via bearer token), then CSRF to fail mockAuth.On("ValidateSession", mock.Anything, "test-token").Return(&Session{UserID: "user-1"}, nil) @@ -751,7 +752,9 @@ func TestRouter_Handlers_Coverage(t *testing.T) { func TestHandler_listPlans_Error(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) @@ -771,12 +774,14 @@ func TestHandler_listPlans_Error(t *testing.T) { func TestHandler_getPlan_NotFound(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", mock.Anything, "11111111-1111-1111-1111-111111111111").Return(nil, errors.New("not found")) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) handler := &Handler{config: mockStore, auth: mockAuth} @@ -793,6 +798,7 @@ func TestHandler_getPlan_NotFound(t *testing.T) { func TestHandler_deleteUser_CannotDeleteSelf(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) // Use a valid UUID format adminID := "11111111-1111-1111-1111-111111111111" @@ -814,11 +820,12 @@ func TestHandler_deleteUser_CannotDeleteSelf(t *testing.T) { func TestHandler_deleteUser_Error(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockAuth.On("DeleteUser", ctx, "other-user-id").Return(errors.New("delete failed")) + mockAuth.On("DeleteUser", ctx, "22222222-2222-4222-8222-222222222222").Return(errors.New("delete failed")) handler := &Handler{auth: mockAuth} @@ -826,8 +833,9 @@ func TestHandler_deleteUser_Error(t *testing.T) { Headers: map[string]string{"Authorization": "Bearer admin-token"}, } - _, err := handler.deleteUser(ctx, req, "other-user-id") - assert.Error(t, err) + _, err := handler.deleteUser(ctx, req, "22222222-2222-4222-8222-222222222222") + require.Error(t, err) + assert.EqualError(t, err, "delete failed", "the store failure must surface, not an earlier validation error") } // Tests for handler_groups @@ -835,11 +843,12 @@ func TestHandler_deleteUser_Error(t *testing.T) { func TestHandler_deleteGroup_DeleteFailed(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockAuth.On("DeleteGroup", ctx, "group-1").Return(errors.New("delete failed")) + mockAuth.On("DeleteGroup", ctx, "33333333-3333-4333-8333-333333333333").Return(errors.New("delete failed")) handler := &Handler{auth: mockAuth} @@ -847,8 +856,9 @@ func TestHandler_deleteGroup_DeleteFailed(t *testing.T) { Headers: map[string]string{"Authorization": "Bearer admin-token"}, } - _, err := handler.deleteGroup(ctx, req, "group-1") - assert.Error(t, err) + _, err := handler.deleteGroup(ctx, req, "33333333-3333-4333-8333-333333333333") + require.Error(t, err) + assert.EqualError(t, err, "delete failed", "the store failure must surface, not an earlier validation error") } // Tests for forgotPassword @@ -856,6 +866,7 @@ func TestHandler_deleteGroup_DeleteFailed(t *testing.T) { func TestHandler_forgotPassword_SuccessOnError(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) // Even when RequestPasswordReset returns an error, forgotPassword should return success // (to prevent email enumeration) @@ -928,6 +939,7 @@ func TestHandler_createGroup_NoToken(t *testing.T) { func TestHandler_createGroup_InvalidBody(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) @@ -965,7 +977,9 @@ func TestHandler_getGroup_NoToken(t *testing.T) { func TestHandler_deletePlan_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) @@ -1004,7 +1018,9 @@ func TestHandler_deletePlan_NoToken(t *testing.T) { func TestHandler_deletePlan_Error(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "admin-id"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) diff --git a/internal/api/handler_dashboard_test.go b/internal/api/handler_dashboard_test.go index a2ca2184..5063c782 100644 --- a/internal/api/handler_dashboard_test.go +++ b/internal/api/handler_dashboard_test.go @@ -29,8 +29,10 @@ func createMockLambdaRequest(sourceIP string) *events.LambdaFunctionURLRequest { // adminDashboardReq returns (mocked auth with admin session, admin-authed request). // Dashboard handlers are now permission-gated; this short-circuits the gate so // existing tests keep exercising the aggregation logic. -func adminDashboardReq(ctx context.Context) (*MockAuthService, *events.LambdaFunctionURLRequest) { +func adminDashboardReq(t *testing.T, ctx context.Context) (*MockAuthService, *events.LambdaFunctionURLRequest) { + t.Helper() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", @@ -44,7 +46,9 @@ func adminDashboardReq(ctx context.Context) (*MockAuthService, *events.LambdaFun func TestHandler_getDashboardSummary(t *testing.T) { ctx := context.Background() mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // Two rds recs for DISTINCT physical resources (different resource_type) so // they aggregate additively, plus one ec2 rec. Distinct cells are required @@ -69,7 +73,7 @@ func TestHandler_getDashboardSummary(t *testing.T) { // uncapped active set across all accounts via GetActivePurchaseHistory. mockStore.On("GetActivePurchaseHistory", ctx, mock.Anything, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{ auth: mockAuth, scheduler: mockScheduler, @@ -109,7 +113,9 @@ func (s *dashboardOverrideStore) GetAccountServiceOverride(_ context.Context, ac func TestHandler_getDashboardSummary_PerAccountCoverageScalesSavings(t *testing.T) { ctx := context.Background() mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) store := &dashboardOverrideStore{ MockConfigStore: mockStore, overrides: map[string]*config.AccountServiceOverride{ @@ -133,7 +139,7 @@ func TestHandler_getDashboardSummary_PerAccountCoverageScalesSavings(t *testing. Provider: "aws", Service: "rds", Enabled: true, Coverage: 100, }, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{ auth: mockAuth, scheduler: mockScheduler, @@ -157,7 +163,9 @@ func float64Ptr(f float64) *float64 { return &f } func TestHandler_getDashboardSummary_ZeroCoverageInServiceConfigFallsThroughToFull(t *testing.T) { ctx := context.Background() mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // No AccountServiceOverride — only a global ServiceConfig with Coverage=0. store := &dashboardOverrideStore{ MockConfigStore: mockStore, @@ -177,7 +185,7 @@ func TestHandler_getDashboardSummary_ZeroCoverageInServiceConfigFallsThroughToFu Provider: "aws", Service: "rds", Enabled: true, Coverage: 0, }, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{ auth: mockAuth, scheduler: mockScheduler, @@ -416,6 +424,7 @@ func TestSummarizeRecommendationsWithCoverage_DedupesVariantsPerCell(t *testing. func TestHandler_getUpcomingPurchases(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) nextExecDate := time.Now().AddDate(0, 0, 7) planA := config.PurchasePlan{ @@ -462,7 +471,7 @@ func TestHandler_getUpcomingPurchases(t *testing.T) { mockStore.On("GetPendingExecutions", ctx).Return(pending, nil) mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return([]config.PurchasePlan{planA, planB}, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getUpcomingPurchases(ctx, req) @@ -499,6 +508,7 @@ func TestHandler_getUpcomingPurchases(t *testing.T) { func TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) scheduled := time.Now().AddDate(0, 0, 7) plan := config.PurchasePlan{ @@ -537,7 +547,7 @@ func TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID(t *testing.T) { mockStore.On("GetPendingExecutions", ctx).Return(pending, nil) mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return([]config.PurchasePlan{plan}, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getUpcomingPurchases(ctx, req) @@ -555,6 +565,7 @@ func TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID(t *testing.T) { func TestHandler_getUpcomingPurchases_OrphanExecutionSkipped(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) pending := []config.PurchaseExecution{ { @@ -566,7 +577,7 @@ func TestHandler_getUpcomingPurchases_OrphanExecutionSkipped(t *testing.T) { mockStore.On("GetPendingExecutions", ctx).Return(pending, nil) mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return([]config.PurchasePlan{}, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getUpcomingPurchases(ctx, req) @@ -593,7 +604,9 @@ func (m *mockStoreWithPlanAccounts) GetPlanAccounts(_ context.Context, planID st func TestHandler_getUpcomingPurchases_ScopedUser(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) nextExecDate := time.Now().AddDate(0, 0, 7) planA := config.PurchasePlan{ @@ -661,7 +674,9 @@ func TestHandler_getUpcomingPurchases_ScopedUser(t *testing.T) { func TestHandler_getUpcomingPurchases_ScopedUser_SkipsUnattributed(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) nextExecDate := time.Now().AddDate(0, 0, 7) plan := config.PurchasePlan{ @@ -701,6 +716,7 @@ func TestHandler_getPublicInfo(t *testing.T) { t.Run("with auth service and admin exists", func(t *testing.T) { mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("CheckAdminExists", ctx).Return(true, nil) handler := &Handler{ @@ -717,6 +733,7 @@ func TestHandler_getPublicInfo(t *testing.T) { t.Run("with auth service and no admin", func(t *testing.T) { mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("CheckAdminExists", ctx).Return(false, nil) handler := &Handler{ @@ -731,6 +748,7 @@ func TestHandler_getPublicInfo(t *testing.T) { t.Run("auth service check error still returns response", func(t *testing.T) { mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("CheckAdminExists", ctx).Return(false, errors.New("db error")) handler := &Handler{ @@ -755,9 +773,13 @@ func TestHandler_getPublicInfo(t *testing.T) { t.Run("with rate limiting - allowed", func(t *testing.T) { mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockRateLimiter := new(MockRateLimiter) + t.Cleanup(func() { mockRateLimiter.AssertExpectations(t) }) mockAuth.On("CheckAdminExists", ctx).Return(true, nil) - mockRateLimiter.On("AllowWithIP", ctx, "192.168.1.1", "api_general").Return(true, nil) + // No AllowWithIP expectation: getPublicInfo never consults the rate + // limiter, so a configured limiter must not change its answer, and an + // unexpected call would fail the test. handler := &Handler{ auth: mockAuth, @@ -773,6 +795,7 @@ func TestHandler_getPublicInfo(t *testing.T) { // unauthenticated /api/info response, even when a secretsARN is configured. t.Run("no sensitive fields in unauthenticated response (#633)", func(t *testing.T) { mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("CheckAdminExists", ctx).Return(true, nil) handler := &Handler{ @@ -848,6 +871,7 @@ func TestHandler_calculateCommitmentMetrics(t *testing.T) { t.Run("no purchase history", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetActivePurchaseHistory", ctx, mock.AnythingOfType("time.Time"), []string(nil), map[string][]string{"": {"account-123"}}).Return([]config.PurchaseHistoryRecord{}, nil) handler := &Handler{config: mockStore} @@ -864,6 +888,7 @@ func TestHandler_calculateCommitmentMetrics(t *testing.T) { // Issue #72: a store error must be returned, not reported as zeroed KPIs. t.Run("purchase history error is returned, not zeroed", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) storeErr := errors.New("db error") mockStore.On("GetActivePurchaseHistory", ctx, mock.AnythingOfType("time.Time"), []string(nil), map[string][]string{"": {"account-123"}}).Return(nil, storeErr) @@ -876,6 +901,7 @@ func TestHandler_calculateCommitmentMetrics(t *testing.T) { t.Run("with active commitments", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // Create a purchase made 6 months ago with 1-year term (still active) purchaseTime := time.Now().AddDate(0, -6, 0) @@ -904,6 +930,7 @@ func TestHandler_calculateCommitmentMetrics(t *testing.T) { t.Run("with expired commitments", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // Create a purchase made 2 years ago with 1-year term (expired) purchaseTime := time.Now().AddDate(-2, 0, 0) @@ -932,6 +959,7 @@ func TestHandler_calculateCommitmentMetrics(t *testing.T) { t.Run("with purchase made this year", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // Create a purchase made this year purchaseTime := time.Now().AddDate(0, -1, 0) // 1 month ago @@ -963,6 +991,7 @@ func TestHandler_calculateCommitmentMetrics(t *testing.T) { // that isn't "completed". t.Run("failed status row is excluded from committedMonthly", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) purchaseTime := time.Now().AddDate(0, -3, 0) purchases := []config.PurchaseHistoryRecord{ { @@ -1207,7 +1236,9 @@ func TestHandler_getDashboardSummary_CurrentSavingsPopulated(t *testing.T) { } mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockScheduler.On("ListRecommendations", mock.Anything, mock.Anything).Return(recommendations, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{DefaultCoverage: 80.0}, nil) @@ -1216,7 +1247,7 @@ func TestHandler_getDashboardSummary_CurrentSavingsPopulated(t *testing.T) { mockStore.On("GetServiceConfig", ctx, "", mock.Anything).Return(nil, config.ErrNotFound) mockStore.On("GetActivePurchaseHistory", ctx, mock.Anything, mock.Anything, mock.Anything).Return(purchases, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{ auth: mockAuth, scheduler: mockScheduler, @@ -1252,7 +1283,9 @@ func TestHandler_getDashboardSummary_CurrentSavingsJSON(t *testing.T) { } mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockScheduler.On("ListRecommendations", mock.Anything, mock.Anything).Return( []config.RecommendationRecord{{Service: "EC2", Savings: 400.0}}, nil) @@ -1262,7 +1295,7 @@ func TestHandler_getDashboardSummary_CurrentSavingsJSON(t *testing.T) { // with an empty scope) runs. mockStore.On("GetActivePurchaseHistory", ctx, mock.Anything, mock.Anything, mock.Anything).Return(purchases, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{ auth: mockAuth, scheduler: mockScheduler, @@ -1295,7 +1328,9 @@ func TestHandler_getDashboardSummary_CurrentSavingsZeroWhenNoCommitments(t *test // No purchases at all — represents a new account that has recommendations // but has not yet acted on them. mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockScheduler.On("ListRecommendations", mock.Anything, mock.Anything).Return( []config.RecommendationRecord{ @@ -1308,7 +1343,7 @@ func TestHandler_getDashboardSummary_CurrentSavingsZeroWhenNoCommitments(t *test mockStore.On("GetActivePurchaseHistory", ctx, mock.Anything, mock.Anything, mock.Anything).Return( []config.PurchaseHistoryRecord{}, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{ auth: mockAuth, scheduler: mockScheduler, @@ -1368,6 +1403,7 @@ func TestHandler_calculateCurrentCoverage(t *testing.T) { func TestHandleRequest_DashboardSummary_PurchaseHistoryErrorIs500(t *testing.T) { ctx := context.Background() mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) mockScheduler.On("ListRecommendations", mock.Anything, mock.Anything).Return([]config.RecommendationRecord{}, nil) @@ -1404,13 +1440,15 @@ func TestHandleRequest_DashboardSummary_PurchaseHistoryErrorIs500(t *testing.T) func TestGetDashboardSummary_NothingToMeasure_CoverageIsNull(t *testing.T) { ctx := context.Background() mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockScheduler.On("ListRecommendations", mock.Anything, mock.Anything).Return([]config.RecommendationRecord{}, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{DefaultCoverage: 80.0}, nil) mockStore.On("GetActivePurchaseHistory", ctx, mock.Anything, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{auth: mockAuth, scheduler: mockScheduler, config: mockStore} result, err := handler.getDashboardSummary(ctx, req, map[string]string{}) @@ -1427,9 +1465,10 @@ func TestHandler_getDashboardSummary_Errors(t *testing.T) { t.Run("scheduler error", func(t *testing.T) { mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockScheduler.On("ListRecommendations", mock.Anything, mock.Anything).Return(nil, errors.New("scheduler error")) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{auth: mockAuth, scheduler: mockScheduler} _, err := handler.getDashboardSummary(ctx, req, map[string]string{}) @@ -1441,14 +1480,16 @@ func TestHandler_getDashboardSummary_Errors(t *testing.T) { // recommendations; a store error must fail the request, not zero the KPIs. t.Run("purchase history error", func(t *testing.T) { mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) storeErr := errors.New("db error") mockScheduler.On("ListRecommendations", mock.Anything, mock.Anything).Return([]config.RecommendationRecord{}, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{DefaultCoverage: 80.0}, nil) mockStore.On("GetActivePurchaseHistory", ctx, mock.Anything, mock.Anything, mock.Anything).Return(nil, storeErr) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{auth: mockAuth, scheduler: mockScheduler, config: mockStore} result, err := handler.getDashboardSummary(ctx, req, map[string]string{}) @@ -1460,13 +1501,15 @@ func TestHandler_getDashboardSummary_Errors(t *testing.T) { t.Run("nil global config uses default coverage", func(t *testing.T) { mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockScheduler.On("ListRecommendations", mock.Anything, mock.Anything).Return([]config.RecommendationRecord{}, nil) mockStore.On("GetGlobalConfig", ctx).Return(nil, nil) mockStore.On("GetActivePurchaseHistory", ctx, mock.Anything, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{ auth: mockAuth, scheduler: mockScheduler, @@ -1480,7 +1523,9 @@ func TestHandler_getDashboardSummary_Errors(t *testing.T) { t.Run("zero coverage in global config uses default", func(t *testing.T) { mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ DefaultCoverage: 0, @@ -1490,7 +1535,7 @@ func TestHandler_getDashboardSummary_Errors(t *testing.T) { mockStore.On("GetGlobalConfig", ctx).Return(globalCfg, nil) mockStore.On("GetActivePurchaseHistory", ctx, mock.Anything, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{ auth: mockAuth, scheduler: mockScheduler, @@ -1546,6 +1591,7 @@ func TestGetDashboardSummary_ScopedUser_ExplicitOutOfScopeAccountIsIgnored(t *te } mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "viewer-token").Return(&Session{ UserID: scopedUserID, Email: "viewer@example.com", @@ -1610,6 +1656,7 @@ func TestGetDashboardSummary_ScopedUser_ExplicitInScopeAccountStillWorks(t *test } mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "viewer-token").Return(&Session{ UserID: scopedUserID, Email: "viewer@example.com", @@ -1689,6 +1736,7 @@ func TestGetDashboardSummary_ScopedUser_LegacyExternalAccountIDStillMatchesAllow } mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "viewer-token").Return(&Session{ UserID: scopedUserID, Email: "viewer@example.com", @@ -1717,9 +1765,10 @@ func TestHandler_getUpcomingPurchases_Errors(t *testing.T) { t.Run("get pending executions error", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetPendingExecutions", ctx).Return(nil, errors.New("db error")) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} _, err := handler.getUpcomingPurchases(ctx, req) @@ -1729,10 +1778,11 @@ func TestHandler_getUpcomingPurchases_Errors(t *testing.T) { t.Run("list plans error", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{}, nil) mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(nil, errors.New("db error")) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} _, err := handler.getUpcomingPurchases(ctx, req) @@ -1742,10 +1792,11 @@ func TestHandler_getUpcomingPurchases_Errors(t *testing.T) { t.Run("no pending executions yields empty list", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{}, nil) mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return([]config.PurchasePlan{}, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getUpcomingPurchases(ctx, req) @@ -2006,6 +2057,7 @@ func TestHandler_calculateCommitmentMetrics_ProviderFilter(t *testing.T) { func TestHandleRequest_DashboardUpcomingFlagsStepWithoutRecommendations(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) plan := config.PurchasePlan{ ID: "11111111-1111-1111-1111-111111111620", diff --git a/internal/api/handler_dashboard_upcoming_filter_test.go b/internal/api/handler_dashboard_upcoming_filter_test.go index 827fab97..b6d15df7 100644 --- a/internal/api/handler_dashboard_upcoming_filter_test.go +++ b/internal/api/handler_dashboard_upcoming_filter_test.go @@ -60,8 +60,10 @@ func upcomingFilterAccount(id string) config.CloudAccount { // exec-gcp gcp plan, accounts [Analytics] // exec-multi aws plan-level parent, accounts [Production, Staging] // exec-multi-stage aws fan-out child, cloud_account_id = Staging -func upcomingFilterStore() *MockConfigStore { +func upcomingFilterStore(t *testing.T) *MockConfigStore { + t.Helper() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) scheduled := time.Now().AddDate(0, 0, 7) plan := func(id, name, provider, service string) config.PurchasePlan { return config.PurchasePlan{ @@ -109,13 +111,19 @@ func upcomingFilterStore() *MockConfigStore { // upcomingAsViewer calls getUpcomingPurchases as a session restricted to // allowed (nil = admin, unrestricted) with the given query parameters. func upcomingAsViewer(t *testing.T, allowed []string, params map[string]string) (*UpcomingPurchaseResponse, error) { + t.Helper() + return upcomingAsViewerWith(t, upcomingFilterStore(t), allowed, params) +} + +// upcomingAsViewerWith is upcomingAsViewer against the given store. +func upcomingAsViewerWith(t *testing.T, store *MockConfigStore, allowed []string, params map[string]string) (*UpcomingPurchaseResponse, error) { t.Helper() ctx := context.Background() - store := upcomingFilterStore() var mockAuth *MockAuthService + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) var req *events.LambdaFunctionURLRequest if allowed == nil { - mockAuth, req = adminDashboardReq(ctx) + mockAuth, req = adminDashboardReq(t, ctx) } else { mockAuth = new(MockAuthService) mockAuth.On("ValidateSession", ctx, "viewer-token").Return(&Session{UserID: "viewer-1"}, nil) @@ -171,7 +179,9 @@ func TestGetUpcomingPurchases_InvalidFilterIs400(t *testing.T) { "non-uuid account_ids": {"account_ids": "not-a-uuid"}, } { t.Run(name, func(t *testing.T) { - _, err := upcomingAsViewer(t, nil, params) + // A store with no expectations: a bad filter is refused before + // anything is read, and any read would hit an unregistered method. + _, err := upcomingAsViewerWith(t, new(MockConfigStore), nil, params) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok, "want a client error, got %v", err) @@ -241,7 +251,7 @@ func TestGetUpcomingPurchases_OutOfScopeAccount(t *testing.T) { // Through the real router: the query string reaches the handler. func TestHandleRequest_DashboardUpcomingAppliesQueryFilters(t *testing.T) { ctx := context.Background() - handler := &Handler{config: upcomingFilterStore(), corsAllowedOrigin: "*", apiKey: "test-key"} + handler := &Handler{config: upcomingFilterStore(t), corsAllowedOrigin: "*", apiKey: "test-key"} req := &events.LambdaFunctionURLRequest{ Headers: map[string]string{"X-API-Key": "test-key"}, QueryStringParameters: map[string]string{"provider": "aws", "account_ids": upAcctStage}, diff --git a/internal/api/handler_federation_azure_escape_test.go b/internal/api/handler_federation_azure_escape_test.go index fdf02613..7268548e 100644 --- a/internal/api/handler_federation_azure_escape_test.go +++ b/internal/api/handler_federation_azure_escape_test.go @@ -138,6 +138,7 @@ func TestGetFederationIaC_AzureDeployScriptEscapesSessionEmail(t *testing.T) { require.NoError(t, err, "fixture %q must be an address the auth service accepts", email) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "admin-token").Return(&Session{ UserID: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", Email: email, diff --git a/internal/api/handler_federation_test.go b/internal/api/handler_federation_test.go index 4a0814fe..91c74472 100644 --- a/internal/api/handler_federation_test.go +++ b/internal/api/handler_federation_test.go @@ -807,6 +807,7 @@ func TestGetFederationIaC_TfvarsAutoLoadedByTerraform(t *testing.T) { func TestGetFederationIaC_PreservesPlusInSessionEmail(t *testing.T) { t.Setenv("CUDLY_SOURCE_CLOUD", "gcp") mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "admin-token").Return(&Session{ UserID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", Email: "user+tag@example.com", @@ -834,6 +835,7 @@ func TestGetFederationIaC_PreservesPlusInSessionEmail(t *testing.T) { func TestGetFederationIaC_NoSessionEmail_ShipsBundleWithEmptyContact(t *testing.T) { t.Setenv("CUDLY_SOURCE_CLOUD", "gcp") mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "admin-token").Return(&Session{ UserID: "cccccccc-cccc-cccc-cccc-cccccccccccc", Email: "", // empty — admin API key path @@ -1791,6 +1793,7 @@ func TestGetFederationIaC_BundleTfvarsEscapesSessionEmail(t *testing.T) { t.Setenv("CUDLY_SOURCE_CLOUD", "gcp") const email = `"o\"q${x}%{y}"@example.com` mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "admin-token").Return(&Session{ UserID: "dddddddd-dddd-dddd-dddd-dddddddddddd", Email: email, diff --git a/internal/api/handler_forgot_password_ratelimit_test.go b/internal/api/handler_forgot_password_ratelimit_test.go index 638a7dd8..c2a9167c 100644 --- a/internal/api/handler_forgot_password_ratelimit_test.go +++ b/internal/api/handler_forgot_password_ratelimit_test.go @@ -49,6 +49,7 @@ func TestHandler_forgotPassword_PerIPCapAcrossDistinctEmails(t *testing.T) { require.Less(t, limit, 100) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("RequestPasswordReset", mock.Anything, mock.Anything).Return(nil) h := &Handler{auth: mockAuth, rateLimiter: NewInMemoryRateLimiter()} ctx := context.Background() @@ -77,6 +78,7 @@ func TestHandler_forgotPassword_PerIPCapIgnoresForwardedHeaders(t *testing.T) { limit := getDefaultRateLimits()["forgot_password_ip"].MaxAttempts mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("RequestPasswordReset", mock.Anything, mock.Anything).Return(nil) h := &Handler{auth: mockAuth, rateLimiter: NewInMemoryRateLimiter()} @@ -151,6 +153,7 @@ func TestHandler_forgotPassword_PerIPCapConcurrent(t *testing.T) { const callers = 40 mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("RequestPasswordReset", mock.Anything, mock.Anything).Return(nil) h := &Handler{auth: mockAuth, rateLimiter: NewInMemoryRateLimiter()} diff --git a/internal/api/handler_groups_account_binding_test.go b/internal/api/handler_groups_account_binding_test.go index 4ecf2db2..fd69863e 100644 --- a/internal/api/handler_groups_account_binding_test.go +++ b/internal/api/handler_groups_account_binding_test.go @@ -35,6 +35,7 @@ var ( func bindingHandler(t *testing.T, scope []string, accounts []config.CloudAccount, listErr error) (*Handler, *MockAuthService, *int) { t.Helper() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "admin-token"). Return(&Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}, nil) if scope == nil { @@ -259,6 +260,8 @@ func TestGroupGrantByName_SurvivesRenameAndNameReuse(t *testing.T) { newcomer := bindOther newcomer.Name = "prod" scoped := sharedNameHandler(t, stored, []config.CloudAccount{renamed, newcomer}, nil) + scopedAuth := scoped.auth.(*MockAuthService) + t.Cleanup(func() { scopedAuth.AssertExpectations(t) }) assert.Equal(t, 200, sharedNameGET(t, scoped, "/api/accounts/"+renamed.ID).StatusCode) assert.Equal(t, 404, sharedNameGET(t, scoped, "/api/accounts/"+newcomer.ID).StatusCode, diff --git a/internal/api/handler_groups_test.go b/internal/api/handler_groups_test.go index 5021cbee..05ac8806 100644 --- a/internal/api/handler_groups_test.go +++ b/internal/api/handler_groups_test.go @@ -14,6 +14,7 @@ import ( func TestHandler_listGroups_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -47,6 +48,7 @@ func TestHandler_listGroups_Success(t *testing.T) { func TestHandler_createGroup_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -79,6 +81,7 @@ func TestHandler_createGroup_Success(t *testing.T) { func TestHandler_getGroup_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -110,6 +113,7 @@ func TestHandler_getGroup_Success(t *testing.T) { func TestHandler_updateGroup_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -142,6 +146,7 @@ func TestHandler_updateGroup_Success(t *testing.T) { func TestHandler_deleteGroup_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -169,6 +174,7 @@ func TestHandler_deleteGroup_Success(t *testing.T) { func TestHandler_createGroup_InvalidJSON(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) @@ -190,6 +196,7 @@ func TestHandler_createGroup_InvalidJSON(t *testing.T) { func TestHandler_updateGroup_InvalidJSON(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) @@ -211,6 +218,7 @@ func TestHandler_updateGroup_InvalidJSON(t *testing.T) { func TestHandler_deleteGroup_Error(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) diff --git a/internal/api/handler_history_actionable_starvation_test.go b/internal/api/handler_history_actionable_starvation_test.go index 5a277b6a..bdd18261 100644 --- a/internal/api/handler_history_actionable_starvation_test.go +++ b/internal/api/handler_history_actionable_starvation_test.go @@ -43,7 +43,7 @@ func newStarvationStore(ctx context.Context) *MockConfigStore { func runStarvationHistory(t *testing.T, ctx context.Context, store *MockConfigStore) map[string]any { t.Helper() - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: store} result, err := handler.getHistory(ctx, req, map[string]string{}) require.NoError(t, err) @@ -56,6 +56,7 @@ func TestHandler_getHistory_PendingNotStarvedByNewerTerminalRows(t *testing.T) { execs := executionsWithStatus(150, "failed", now, "failed") pending := executionsWithStatus(1, "pending", now.Add(-time.Hour), "pending") store := newStarvationStore(ctx) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(append(execs, pending...), nil) store.On("ListHistoryExecutionsPage", ctx, mock.Anything).Return(append(execs, pending...), nil) @@ -77,6 +78,7 @@ func TestHandler_getHistory_ReturnsMoreThanDefaultLimitPendingRows(t *testing.T) now := time.Now() pending := executionsWithStatus(config.DefaultListLimit+50, "pending", now, "pending") store := newStarvationStore(ctx) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(pending, nil) body := runStarvationHistory(t, ctx, store) @@ -89,6 +91,7 @@ func TestHandler_getHistory_ReturnsMoreThanDefaultLimitPendingRows(t *testing.T) func TestHandler_getHistory_FetchesDisjointClassesWithTheirLimits(t *testing.T) { ctx := context.Background() store := newStarvationStore(ctx) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionsByStatuses", ctx, mock.MatchedBy(isActionableCall), config.MaxListLimit).Return([]config.PurchaseExecution{}, nil).Once() store.On("ListHistoryExecutionsPage", ctx, mock.MatchedBy(func(q config.HistoryExecutionsPageQuery) bool { return isTerminalPage(q) && q.Limit == historyStreamBatch(config.DefaultListLimit) && q.After == nil @@ -117,6 +120,7 @@ func TestHandler_getHistory_ClassListingErrorKeepsOtherClass(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() store := newStarvationStore(ctx) + t.Cleanup(func() { store.AssertExpectations(t) }) actionableRet := mock.Arguments{executionsWithStatus(1, "pending", now, "pending"), nil} terminalRet := mock.Arguments{executionsWithStatus(1, "failed", now, "failed"), nil} if tt.failActive { @@ -143,6 +147,7 @@ func TestHandler_getHistory_ClassListingErrorKeepsOtherClass(t *testing.T) { func TestHandler_getHistory_BothClassListingsFailSetTruncated(t *testing.T) { ctx := context.Background() store := newStarvationStore(ctx) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(nil, errors.New("listing failed")) store.On("ListHistoryExecutionsPage", ctx, mock.Anything).Return(nil, errors.New("listing failed")) @@ -158,6 +163,7 @@ func TestHandler_getHistory_BothClassListingsFailSetTruncated(t *testing.T) { func TestHandler_getHistory_EmptyClassListingsNotTruncated(t *testing.T) { ctx := context.Background() store := newStarvationStore(ctx) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{}, nil) store.On("ListHistoryExecutionsPage", ctx, mock.Anything).Return([]config.PurchaseExecution{}, nil) @@ -185,6 +191,7 @@ func TestHandler_getHistory_TruncatedPerClass(t *testing.T) { rows := append(executionsWithStatus(tt.actionable, "pending", now, "pending"), executionsWithStatus(tt.terminal, "failed", now, "failed")...) store := newStarvationStore(ctx) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(rows, nil) store.On("ListHistoryExecutionsPage", ctx, mock.Anything).Return(rows, nil) @@ -199,11 +206,12 @@ func TestHandler_getHistory_StaleSweepBoundedToDefaultListLimit(t *testing.T) { ctx := context.Background() stale := executionsWithStatus(150, "pending", time.Now().Add(-8*24*time.Hour), "stale") store := newStarvationStore(ctx) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(stale, nil) store.On("ExpireExecutionAtomic", mock.Anything, mock.Anything, mock.Anything).Return(false, nil) // Lambda mode (synchronous sweep) so the call count is final on return. - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: store, isLambda: true} _, err := handler.getHistory(ctx, req, map[string]string{}) require.NoError(t, err) @@ -230,6 +238,7 @@ func TestHandler_getHistory_DueApprovalNotCrowdedOutByFutureRampSteps(t *testing } execs = append(execs, executionsWithStatus(1, "pending", now.Add(-time.Hour), "due")...) store := newStarvationStore(ctx) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(execs, nil) body := runStarvationHistory(t, ctx, store) diff --git a/internal/api/handler_history_actionable_test.go b/internal/api/handler_history_actionable_test.go index 20763ed3..0afc2f52 100644 --- a/internal/api/handler_history_actionable_test.go +++ b/internal/api/handler_history_actionable_test.go @@ -32,6 +32,7 @@ func actionableHistoryRequest(t *testing.T, store *MockConfigStore, scope []stri t.Helper() ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "tok").Return(&Session{UserID: "u-1", Email: "u@example.com"}, nil) mockAuth.On("HasPermissionAPI", mock.Anything, "u-1", mock.Anything, mock.Anything).Return(true, nil) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, "u-1").Return(scope, nil) @@ -86,7 +87,11 @@ func TestHandleRequest_History_ScopedPendingNotCrowdedOutByOtherAccounts(t *test mine := pendingForAccount("mine", actionableDevUUID, now.Add(-time.Hour)) store := new(MockConfigStore) - store.On("GetExecutionsByStatuses", mock.Anything, mock.Anything, mock.Anything).Return(others, nil) + t.Cleanup(func() { store.AssertExpectations(t) }) + // Maybe: the pre-fix unfiltered read, which the handler no longer issues. + // It stays registered so a regression to it is served the crowding rows + // and fails the assertion below instead of a mock panic. + store.On("GetExecutionsByStatuses", mock.Anything, mock.Anything, mock.Anything).Return(others, nil).Maybe() store.On("GetExecutionsByStatusesFiltered", mock.Anything, mock.MatchedBy(func(q config.ExecutionsByStatusesQuery) bool { return isActionableQuery(q) && q.Scope != nil && slices.Contains(q.Scope.AccountIDs, actionableDevUUID) && !slices.Contains(q.Scope.AccountIDs, actionableOtherUUID) @@ -152,6 +157,7 @@ func TestHandleRequest_History_StaleSweepIgnoresFiltersAndScope(t *testing.T) { }) t.Run("not narrowed", func(t *testing.T) { store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionsByStatuses", mock.Anything, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{}, nil) actionableHistoryRequest(t, store, []string{"*"}, map[string]string{}) @@ -182,6 +188,7 @@ func TestHandleRequest_History_PlanPendingNotCrowdedOutByOtherPlans(t *testing.T mine.PlanID = actionablePlanUUID store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionsByStatusesFiltered", mock.Anything, mock.MatchedBy(isPlanQuery(""))).Return(others, nil).Maybe() store.On("GetExecutionsByStatusesFiltered", mock.Anything, mock.MatchedBy(isPlanQuery(actionablePlanUUID))). Return([]config.PurchaseExecution{mine}, nil) diff --git a/internal/api/handler_history_page_test.go b/internal/api/handler_history_page_test.go index 2733275a..6633c344 100644 --- a/internal/api/handler_history_page_test.go +++ b/internal/api/handler_history_page_test.go @@ -111,10 +111,11 @@ func TestHandler_getHistory_PagesThroughEveryRowOnce(t *testing.T) { execs = append(execs, pending) store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return(purchases, nil) store.On("ListHistoryExecutionsPage", ctx, mock.Anything).Return(execs, nil) store.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(execs, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) h := &Handler{auth: mockAuth, config: store} pages := walkHistoryPages(t, h, req, map[string]string{"limit": "4"}) @@ -194,13 +195,14 @@ func TestHandler_getHistory_ScopedPagingStaysInScope(t *testing.T) { hidden[fOther.key.ID] = true store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return(purchases, nil) store.On("ListHistoryExecutionsPage", ctx, mock.Anything).Return(execs, nil) store.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{}, nil) store.ListCloudAccountsFn = func(context.Context, config.CloudAccountFilter) ([]config.CloudAccount, error) { return permsAccountList(), nil } - auth := scopedAuthMock(ctx) + auth := scopedAuthMock(t, ctx) auth.On("GetUser", ctx, mock.Anything).Return(&User{Email: "creator@example.com"}, nil).Maybe() h := &Handler{auth: auth, config: store} @@ -237,8 +239,9 @@ func TestHandler_getHistory_CursorPageOmitsActionableRows(t *testing.T) { purchases = append(purchases, row) } store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return(purchases, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) h := &Handler{auth: mockAuth, config: store, isLambda: true} cursor := encodeHistoryCursor(purchases[0].Key()) @@ -264,9 +267,10 @@ func TestHandler_getHistory_TerminalExecutionsFailureFlagsTruncated(t *testing.T base := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) row, _ := purchaseFixture("p-0", "acct", base, pageUUID(0xa, 0)) store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryPageRow{row}, nil) store.On("ListHistoryExecutionsPage", ctx, mock.Anything).Return(nil, errors.New("db down")) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) h := &Handler{auth: mockAuth, config: store} cursor := encodeHistoryCursor(config.HistoryPageKey{Timestamp: base.Add(time.Hour), Source: config.HistorySourcePurchase, ID: pageUUID(0xa, 9)}) @@ -283,8 +287,9 @@ func TestHandler_getHistory_TerminalExecutionsFailureFlagsTruncated(t *testing.T func TestHandler_getHistory_PurchaseHistoryFailureFailsRequest(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return(nil, errors.New("db down")) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) h := &Handler{auth: mockAuth, config: store} _, err := h.getHistory(ctx, req, map[string]string{}) @@ -375,7 +380,7 @@ func TestParseHistoryFilters_PagingParams(t *testing.T) { func TestHandler_getHistory_InvalidCursorReadsNothing(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) h := &Handler{auth: mockAuth, config: store} _, err := h.getHistory(ctx, req, map[string]string{"cursor": "garbage"}) diff --git a/internal/api/handler_history_plan_filter_test.go b/internal/api/handler_history_plan_filter_test.go index 76328b00..df626b99 100644 --- a/internal/api/handler_history_plan_filter_test.go +++ b/internal/api/handler_history_plan_filter_test.go @@ -51,6 +51,7 @@ func TestGetHistory_PlanIDReachesStoreFilters(t *testing.T) { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) var rowFilter config.PurchaseHistoryFilter mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything). Run(func(args mock.Arguments) { rowFilter = args.Get(1).(config.PurchaseHistoryFilter) }). @@ -61,7 +62,7 @@ func TestGetHistory_PlanIDReachesStoreFilters(t *testing.T) { Run(func(args mock.Arguments) { aggFilter = args.Get(1).(config.HistorySummaryFilter) }). Return(&config.HistorySummaryGroups{}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} params := map[string]string{} if tc.planID != "" { @@ -77,7 +78,7 @@ func TestGetHistory_PlanIDReachesStoreFilters(t *testing.T) { func TestGetHistory_BadPlanIDIs400(t *testing.T) { ctx := context.Background() - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: new(MockConfigStore)} _, err := handler.getHistory(ctx, req, map[string]string{"plan_id": "garbage"}) require.Error(t, err) diff --git a/internal/api/handler_history_scope_provider_test.go b/internal/api/handler_history_scope_provider_test.go index 36f93a10..a29d8c6f 100644 --- a/internal/api/handler_history_scope_provider_test.go +++ b/internal/api/handler_history_scope_provider_test.go @@ -20,11 +20,13 @@ func historyScopeIDs(t *testing.T, scope []string, accounts []config.CloudAccoun t.Helper() ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "tok").Return(&Session{UserID: "u-1", Email: "u@example.com"}, nil) mockAuth.On("HasPermissionAPI", mock.Anything, "u-1", mock.Anything, mock.Anything).Return(true, nil) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, "u-1").Return(scope, nil) store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("ListPurchaseHistoryPage", mock.Anything, mock.Anything, mock.Anything).Return(append([]config.PurchaseHistoryRecord(nil), rows...), nil) store.On("GetExecutionsByStatuses", mock.Anything, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{}, nil) store.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { diff --git a/internal/api/handler_history_summary_test.go b/internal/api/handler_history_summary_test.go index 564d6f8a..8cf797ec 100644 --- a/internal/api/handler_history_summary_test.go +++ b/internal/api/handler_history_summary_test.go @@ -20,6 +20,7 @@ import ( func TestGetHistory_SummaryComesFromScopedAggregate(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{}, nil) mockStore.ListCloudAccountsFn = func(context.Context, config.CloudAccountFilter) ([]config.CloudAccount, error) { @@ -51,7 +52,7 @@ func TestGetHistory_SummaryComesFromScopedAggregate(t *testing.T) { Run(func(args mock.Arguments) { gotFilter = args.Get(1).(config.HistorySummaryFilter) }). Return(groups, nil).Once() - handler := &Handler{auth: scopedAuthMock(ctx), config: mockStore} + handler := &Handler{auth: scopedAuthMock(t, ctx), config: mockStore} result, err := handler.getHistory(ctx, scopedReq(), map[string]string{}) require.NoError(t, err) resp := result.(HistoryResponse) @@ -76,6 +77,7 @@ func TestGetHistory_SummaryComesFromScopedAggregate(t *testing.T) { func TestGetHistory_SummaryFilterMatchesRowFetchFilter(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) var rowFilter config.PurchaseHistoryFilter mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything). Run(func(args mock.Arguments) { rowFilter = args.Get(1).(config.PurchaseHistoryFilter) }). @@ -86,7 +88,7 @@ func TestGetHistory_SummaryFilterMatchesRowFetchFilter(t *testing.T) { Run(func(args mock.Arguments) { aggFilter = args.Get(1).(config.HistorySummaryFilter) }). Return(&config.HistorySummaryGroups{}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} _, err := handler.getHistory(ctx, req, map[string]string{ "provider": "aws", "start": "2026-01-01", "end": "2026-01-31", "limit": "7", @@ -107,6 +109,7 @@ func TestGetHistory_SummaryFilterMatchesRowFetchFilter(t *testing.T) { func TestGetHistory_SummaryFallsBackToPageWhenAggregateFails(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) up := 40.0 mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{ {PurchaseID: "p1", AccountID: "acct", UpfrontCost: &up, EstimatedSavings: 4}, @@ -114,7 +117,7 @@ func TestGetHistory_SummaryFallsBackToPageWhenAggregateFails(t *testing.T) { mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{}, nil) mockStore.On("HistorySummaryAggregate", ctx, mock.Anything).Return(nil, errors.New("db down")) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) require.NoError(t, err) @@ -141,13 +144,14 @@ func TestGetHistory_LambdaSweepRunsAfterSummaryAggregate(t *testing.T) { return func(mock.Arguments) { mu.Lock(); order = append(order, s); mu.Unlock() } } mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{stale}, nil) mockStore.On("HistorySummaryAggregate", ctx, mock.Anything).Run(note("aggregate")). Return(&config.HistorySummaryGroups{Executions: []config.ExecutionSummaryGroup{{Status: "pending", Count: 1}}}, nil) mockStore.On("ExpireExecutionAtomic", mock.Anything, mock.Anything, staleID).Run(note("expire")).Return(true, nil).Once() - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore, isLambda: true} result, err := handler.getHistory(ctx, req, map[string]string{}) require.NoError(t, err) diff --git a/internal/api/handler_history_test.go b/internal/api/handler_history_test.go index f62b109d..2e95b65d 100644 --- a/internal/api/handler_history_test.go +++ b/internal/api/handler_history_test.go @@ -18,8 +18,10 @@ import ( // adminHistoryReq builds an admin-authed request and wires the auth mock so // requirePermission short-circuits. Returns the mocked auth so tests can add // extra expectations. -func adminHistoryReq(ctx context.Context) (*MockAuthService, *events.LambdaFunctionURLRequest) { +func adminHistoryReq(t *testing.T, ctx context.Context) (*MockAuthService, *events.LambdaFunctionURLRequest) { + t.Helper() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", @@ -34,6 +36,7 @@ func adminHistoryReq(ctx context.Context) (*MockAuthService, *events.LambdaFunct func TestHandler_getHistory(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) history := []config.PurchaseHistoryRecord{ {AccountID: "123456789012", PurchaseID: "purchase-1", UpfrontCost: new(float64(100.0)), EstimatedSavings: 10.0}, @@ -45,7 +48,7 @@ func TestHandler_getHistory(t *testing.T) { mockStore.On("ListPurchaseHistoryPage", ctx, config.PurchaseHistoryFilter{ExternalIDsByProvider: map[string][]string{"": {"123456789012"}}, Limit: historyStreamBatch(100)}, noCursor).Return(history, nil) mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} params := map[string]string{ @@ -69,6 +72,7 @@ func TestHandler_getHistory(t *testing.T) { func TestHandler_getHistory_AllAccounts(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) history := []config.PurchaseHistoryRecord{ {AccountID: "111111111111", PurchaseID: "purchase-1", UpfrontCost: new(float64(100.0)), EstimatedSavings: 10.0}, @@ -78,7 +82,7 @@ func TestHandler_getHistory_AllAccounts(t *testing.T) { mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return(history, nil) mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} params := map[string]string{} @@ -101,6 +105,7 @@ func TestHandler_getHistory_AllAccounts(t *testing.T) { func TestHandler_getHistory_IncludesPending(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) completed := []config.PurchaseHistoryRecord{ {AccountID: "acc-1", PurchaseID: "done-1", UpfrontCost: new(float64(500.0)), EstimatedSavings: 50.0}, @@ -127,7 +132,7 @@ func TestHandler_getHistory_IncludesPending(t *testing.T) { mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(pending, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -161,11 +166,12 @@ func TestHandler_getHistory_IncludesPending(t *testing.T) { func TestHandler_getHistory_CustomLimit(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} params := map[string]string{ @@ -236,6 +242,7 @@ func TestHandler_getHistory_ExpireIfStale(t *testing.T) { t.Run("async expire fires for stale pending row; response shows pre-transition status", func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) expired := staleExec("pending") expired.Status = "expired" @@ -252,7 +259,7 @@ func TestHandler_getHistory_ExpireIfStale(t *testing.T) { Run(waitForCall(done)). Return(true, nil).Once() - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -296,6 +303,7 @@ func TestHandler_getHistory_ExpireIfStale(t *testing.T) { t.Run("async expire fires for stale notified row; response shows pre-transition status", func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) expired := staleExec("notified") expired.Status = "expired" @@ -309,7 +317,7 @@ func TestHandler_getHistory_ExpireIfStale(t *testing.T) { Run(waitForCall(done)). Return(true, nil).Once() - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -336,6 +344,7 @@ func TestHandler_getHistory_ExpireIfStale(t *testing.T) { t.Run("async expire error is non-fatal; response and row visibility are unaffected", func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) done := make(chan struct{}) mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) @@ -346,7 +355,7 @@ func TestHandler_getHistory_ExpireIfStale(t *testing.T) { Run(waitForCall(done)). Return(false, errors.New("simulated store failure")).Once() - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} // A Transition error must be non-fatal. No panic, no dropped row — @@ -374,13 +383,14 @@ func TestHandler_getHistory_ExpireIfStale(t *testing.T) { t.Run("fresh row does not trigger TransitionExecutionStatus", func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything). Return([]config.PurchaseExecution{freshExec()}, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -443,7 +453,7 @@ func TestHandler_getHistory_GetIsReadOnly(t *testing.T) { }). Return(false, nil).Maybe() - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} t.Cleanup(func() { mockStore.AssertExpectations(t) }) @@ -523,6 +533,7 @@ func TestHandler_getHistory_ScopedUserSeesEmptyAccountRows(t *testing.T) { Email: "scoped@example.com", } mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "scoped-token").Return(scopedUser, nil) mockAuth.On("HasPermissionAPI", ctx, scopedUserID, "view", "purchases").Return(true, nil) // allowed_accounts is non-empty → user is scoped (not unrestricted). @@ -592,6 +603,7 @@ func TestHandler_getHistory_ScopedUserCannotSeeOtherUsersEmptyAccountRows(t *tes // User A: scoped to one account, different from user B. userASession := &Session{UserID: userAID, Email: "a@example.com"} mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "user-a-token").Return(userASession, nil) mockAuth.On("HasPermissionAPI", ctx, userAID, "view", "purchases").Return(true, nil) mockAuth.On("GetAllowedAccountsAPI", ctx, userAID).Return([]string{"aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa"}, nil) @@ -642,7 +654,7 @@ func TestHandler_expireStaleExecutionsAsync_SystemActorIsNil(t *testing.T) { // goroutine uses context.Background(); use mock.Anything for ctx. mockStore.On("ExpireExecutionAtomic", mock.Anything, mock.Anything, staleID).Run(func(_ mock.Arguments) { close(done) }).Return(true, nil).Once() - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} _, err := handler.getHistory(ctx, req, map[string]string{}) require.NoError(t, err) @@ -660,6 +672,7 @@ func TestHandler_getHistory_PermissionDenied(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "viewer-token").Return(&Session{ UserID: "viewer-1", }, nil) @@ -685,6 +698,7 @@ func TestHandler_getHistory_PermissionDenied(t *testing.T) { func TestHandler_getHistory_IncludesInFlightApprovals(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) completed := []config.PurchaseHistoryRecord{ {AccountID: "acc-1", PurchaseID: "done-1", UpfrontCost: new(float64(500.0)), EstimatedSavings: 50.0}, @@ -714,7 +728,7 @@ func TestHandler_getHistory_IncludesInFlightApprovals(t *testing.T) { mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(inFlight, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approver}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -768,6 +782,7 @@ func TestHandler_getHistory_InProgressRowMapsRecFields(t *testing.T) { t.Run(status, func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) inProgress := []config.PurchaseExecution{ { @@ -786,7 +801,7 @@ func TestHandler_getHistory_InProgressRowMapsRecFields(t *testing.T) { mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(inProgress, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approver}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -821,6 +836,7 @@ func TestHandler_getHistory_InProgressRowMapsRecFields(t *testing.T) { func TestHandler_getHistory_AuditGapCompletedVisible(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) auditGap := []config.PurchaseExecution{ { @@ -839,7 +855,7 @@ func TestHandler_getHistory_AuditGapCompletedVisible(t *testing.T) { mockStore.On("ListHistoryExecutionsPage", ctx, mock.Anything).Return(auditGap, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approver}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -871,6 +887,7 @@ func TestHandler_getHistory_AuditGapCompletedVisible(t *testing.T) { func TestHandler_getHistory_PartiallyCompletedVisible(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) partial := []config.PurchaseExecution{ { @@ -889,7 +906,7 @@ func TestHandler_getHistory_PartiallyCompletedVisible(t *testing.T) { mockStore.On("ListHistoryExecutionsPage", ctx, mock.Anything).Return(partial, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approver}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -918,6 +935,7 @@ func TestHandler_getHistory_PartiallyCompletedVisible(t *testing.T) { func TestHandler_getHistory_CompletedDBRowWithDescriptionStillCounts(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // A genuine completed purchase_history row that happens to carry a // human-readable StatusDescription (IsAuditGap stays false, as it is for @@ -934,10 +952,9 @@ func TestHandler_getHistory_CompletedDBRowWithDescriptionStillCounts(t *testing. } mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return(completed, nil) mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{}, nil) - approver := "ops@example.com" - mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approver}, nil) + // No pending rows, so the approver email (GetGlobalConfig) is never resolved. - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -972,7 +989,7 @@ func TestHandler_getHistory_FilterParams(t *testing.T) { t.Helper() ctx := context.Background() mockStore := new(MockConfigStore) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approver}, nil).Maybe() // resolveHistoryAccountFilter loads cloud_accounts to resolve UUIDs to // their external ids. Default to an empty list (no resolvable external @@ -985,6 +1002,7 @@ func TestHandler_getHistory_FilterParams(t *testing.T) { t.Run("provider filter is pushed to SQL and prunes executions", func(t *testing.T) { mockStore, handler, req, ctx := newHandler(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // SQL path: must be called via ListPurchaseHistoryPage with // provider="aws" and no other filters set. Return only the aws row. @@ -1026,6 +1044,7 @@ func TestHandler_getHistory_FilterParams(t *testing.T) { t.Run("provider=all is treated as no filter (legacy SQL path)", func(t *testing.T) { mockStore, handler, req, ctx := newHandler(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil).Once() mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{}, nil) @@ -1036,6 +1055,7 @@ func TestHandler_getHistory_FilterParams(t *testing.T) { t.Run("account_ids filter is pushed to SQL and prunes executions", func(t *testing.T) { mockStore, handler, req, ctx := newHandler(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) uuidA := "11111111-1111-1111-1111-111111111111" uuidB := "22222222-2222-2222-2222-222222222222" @@ -1135,6 +1155,7 @@ func TestHandler_getHistory_FilterParams(t *testing.T) { t.Run("start/end date filter is pushed to SQL and prunes executions", func(t *testing.T) { mockStore, handler, req, ctx := newHandler(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // Build start/end relative to "now" so the in-range execution's // ScheduledDate stays younger than the approvalExpiryWindow (7 days). @@ -1183,6 +1204,7 @@ func TestHandler_getHistory_FilterParams(t *testing.T) { t.Run("combined provider+account_ids+date filter is applied on both halves", func(t *testing.T) { mockStore, handler, req, ctx := newHandler(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) uuidA := "55555555-5555-5555-5555-555555555555" mockStore.On("ListPurchaseHistoryPage", ctx, @@ -1297,7 +1319,7 @@ func TestHandler_getHistory_FilterValidation(t *testing.T) { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} _, err := handler.getHistory(ctx, req, tc.params) @@ -1323,7 +1345,8 @@ func TestHandler_getHistory_DateRangeBoundary(t *testing.T) { t.Run("range of exactly 366 days is accepted", func(t *testing.T) { mockStore := new(MockConfigStore) - mockAuth, req := adminHistoryReq(ctx) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything). Return([]config.PurchaseHistoryRecord{}, nil) @@ -1336,7 +1359,7 @@ func TestHandler_getHistory_DateRangeBoundary(t *testing.T) { t.Run("range of 367 days is rejected", func(t *testing.T) { mockStore := new(MockConfigStore) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} _, err := handler.getHistory(ctx, req, map[string]string{"start": "2024-01-01", "end": "2025-01-02"}) @@ -1405,6 +1428,7 @@ func TestHandler_getHistory_CreatedByUserEmailResolved(t *testing.T) { t.Run("email resolved from auth service", func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) exec := config.PurchaseExecution{ ExecutionID: "pend-with-creator", @@ -1419,7 +1443,7 @@ func TestHandler_getHistory_CreatedByUserEmailResolved(t *testing.T) { mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{exec}, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) mockAuth.On("GetUser", ctx, creatorID).Return(&User{ID: creatorID, Email: creatorEmail}, nil) handler := &Handler{auth: mockAuth, config: mockStore} @@ -1436,6 +1460,7 @@ func TestHandler_getHistory_CreatedByUserEmailResolved(t *testing.T) { t.Run("lookup failure degrades gracefully", func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) exec := config.PurchaseExecution{ ExecutionID: "pend-bad-user", @@ -1450,7 +1475,7 @@ func TestHandler_getHistory_CreatedByUserEmailResolved(t *testing.T) { mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{exec}, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) mockAuth.On("GetUser", ctx, creatorID).Return(nil, errors.New("user not found")) handler := &Handler{auth: mockAuth, config: mockStore} @@ -1486,6 +1511,7 @@ func TestHandler_getHistory_ApprovalQueueColumnsPopulated(t *testing.T) { t.Run("single-rec pending row carries Account + Payment + MonthlyCost", func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) monthly := 7.5 accountID := "123456789012" pending := []config.PurchaseExecution{ @@ -1517,7 +1543,7 @@ func TestHandler_getHistory_ApprovalQueueColumnsPopulated(t *testing.T) { mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(pending, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -1535,6 +1561,7 @@ func TestHandler_getHistory_ApprovalQueueColumnsPopulated(t *testing.T) { t.Run("multi-rec uniform pending row collapses Account + Payment, sums MonthlyCost", func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) monthlyA := 3.0 monthlyB := 4.5 accountID := "987654321098" @@ -1555,7 +1582,7 @@ func TestHandler_getHistory_ApprovalQueueColumnsPopulated(t *testing.T) { mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(pending, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -1573,6 +1600,7 @@ func TestHandler_getHistory_ApprovalQueueColumnsPopulated(t *testing.T) { t.Run("multi-rec heterogeneous Payment collapses to empty for honest dash fallback", func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) pending := []config.PurchaseExecution{ { ExecutionID: "pend-multi-mixed", @@ -1588,7 +1616,7 @@ func TestHandler_getHistory_ApprovalQueueColumnsPopulated(t *testing.T) { mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(pending, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -1644,7 +1672,7 @@ func TestHandler_getHistory_ApprovalQueueColumnsPopulated(t *testing.T) { Return([]config.PurchaseHistoryRecord{}, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{"account_ids": recAccountID}) @@ -1695,7 +1723,7 @@ func TestHandler_getHistory_ExternalIDOnlyAccount(t *testing.T) { mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseExecution{}, nil) t.Cleanup(func() { mockStore.AssertExpectations(t) }) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{"account_ids": accountBUUID}) @@ -1741,6 +1769,7 @@ func TestMatchesExecution_ExternalIDOnlyPending(t *testing.T) { func TestHandler_getHistory_CompletedExecutionNotDuplicated(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) completed := []config.PurchaseHistoryRecord{ {AccountID: "acc-1", PurchaseID: "ri-commitment-1", UpfrontCost: new(float64(400.0)), EstimatedSavings: 40.0}, @@ -1760,13 +1789,12 @@ func TestHandler_getHistory_CompletedExecutionNotDuplicated(t *testing.T) { Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Region: "us-east-1"}}, }, } - approver := "ops@example.com" mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return(completed, nil) mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(cleanCompletedExec, nil) mockStore.On("ListHistoryExecutionsPage", ctx, mock.Anything).Return(cleanCompletedExec, nil) - mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approver}, nil) + // No pending rows, so the approver email (GetGlobalConfig) is never resolved. - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -1903,7 +1931,8 @@ func TestHandler_getHistory_LimitParsing(t *testing.T) { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) - mockAuth, req := adminHistoryReq(ctx) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} if tc.wantCode != 0 { @@ -2000,7 +2029,7 @@ func TestHandler_getHistory_ExpireIfStale_LambdaGuard(t *testing.T) { mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil) mockStore.On("ExpireExecutionAtomic", mock.Anything, mock.Anything, staleID).Return(true, nil).Once() - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore, isLambda: true} result, err := handler.getHistory(ctx, req, map[string]string{}) @@ -2042,7 +2071,7 @@ func TestHandler_getHistory_ExpireIfStale_LambdaGuard(t *testing.T) { close(swept) }).Return(true, nil).Once() - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore, isLambda: false} done := make(chan struct{}) @@ -2122,6 +2151,7 @@ func TestExpireStaleExecutionsSweep_ReleasesSuppressionsOnlyWhenExpired(t *testi for name, won := range map[string]bool{"flip won": true, "row already moved": false} { t.Run(name, func(t *testing.T) { store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("ExpireExecutionAtomic", mock.Anything, mock.Anything, "stale-1").Return(won, nil) store.On("DeleteSuppressionsByExecutionTx", mock.Anything, mock.Anything, "stale-1").Return(nil).Maybe() h := &Handler{config: store} @@ -2190,6 +2220,7 @@ func TestExecutionToHistoryRow_RedriveUnsafe(t *testing.T) { func TestHandler_getHistory_ProjectsCreatedBySystem(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) approverEmail := "ops@example.com" recs := []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Region: "us-east-1"}} execs := []config.PurchaseExecution{ @@ -2199,7 +2230,7 @@ func TestHandler_getHistory_ProjectsCreatedBySystem(t *testing.T) { mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(execs, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil) - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) result, err := (&Handler{auth: mockAuth, config: mockStore}).getHistory(ctx, req, map[string]string{}) require.NoError(t, err) diff --git a/internal/api/handler_history_truncation_test.go b/internal/api/handler_history_truncation_test.go index 5e58bc43..9bc6190c 100644 --- a/internal/api/handler_history_truncation_test.go +++ b/internal/api/handler_history_truncation_test.go @@ -49,11 +49,12 @@ func runAdminHistory(t *testing.T, completed []config.PurchaseHistoryRecord, exe t.Helper() ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return(completed, nil) mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(execs, nil) mockStore.On("ListHistoryExecutionsPage", ctx, mock.Anything).Return(execs, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil).Maybe() - mockAuth, req := adminHistoryReq(ctx) + mockAuth, req := adminHistoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.getHistory(ctx, req, params) @@ -164,6 +165,7 @@ func TestHandler_getHistory_ScopedUserTruncationLeaksNoCounts(t *testing.T) { execs[len(execs)-1].CreatedByUserID = &own mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything).Return([]config.PurchaseHistoryRecord{}, nil) mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).Return(execs, nil) mockStore.On("ListHistoryExecutionsPage", ctx, mock.Anything).Return(execs, nil) @@ -172,6 +174,7 @@ func TestHandler_getHistory_ScopedUserTruncationLeaksNoCounts(t *testing.T) { return nil, nil } mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "scoped-token").Return(&Session{UserID: scopedUserID, Email: "s@example.com"}, nil) mockAuth.On("HasPermissionAPI", ctx, scopedUserID, "view", "purchases").Return(true, nil) mockAuth.On("GetAllowedAccountsAPI", ctx, scopedUserID).Return([]string{"aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa"}, nil) diff --git a/internal/api/handler_insurance_comparison_test.go b/internal/api/handler_insurance_comparison_test.go index d3799273..e37235a8 100644 --- a/internal/api/handler_insurance_comparison_test.go +++ b/internal/api/handler_insurance_comparison_test.go @@ -267,6 +267,7 @@ func TestInsuranceComparison_DeniedAndScoped(t *testing.T) { p := archera.NewProvider(s, secrets.NewEnvResolver(), &http.Client{Transport: vt}) m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(false, nil) _, err := (&Handler{auth: m, insurance: p}).getInsuranceComparison(sessionPrincipalCtx(), &events.LambdaFunctionURLRequest{}) ce, ok := IsClientError(err) @@ -274,6 +275,7 @@ func TestInsuranceComparison_DeniedAndScoped(t *testing.T) { assert.Equal(t, 403, ce.code) m2 := new(MockAuthService) + t.Cleanup(func() { m2.AssertExpectations(t) }) m2.On("HasAPIKeyPermissionAPI", mock.Anything, "user-key", "view", "recommendations").Return("u2", "key-1", true, nil) m2.On("GetAllowedAccountsAPI", mock.Anything, "u2").Return([]string{"acct-1"}, nil) ctx := contextWithPrincipal(context.Background(), &Principal{Kind: PrincipalUserAPIKey, UserID: "u2", APIKeyID: "key-1"}) diff --git a/internal/api/handler_insurance_test.go b/internal/api/handler_insurance_test.go index 23bcf0bc..f9c69f1f 100644 --- a/internal/api/handler_insurance_test.go +++ b/internal/api/handler_insurance_test.go @@ -72,6 +72,7 @@ func TestInsuranceStatus_UnwiredIsOff(t *testing.T) { func TestInsuranceStatus_DeniedUserGets403(t *testing.T) { m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(false, nil) h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})} _, err := h.getInsuranceStatus(sessionPrincipalCtx(), &events.LambdaFunctionURLRequest{}) @@ -82,6 +83,7 @@ func TestInsuranceStatus_DeniedUserGets403(t *testing.T) { func TestInsuranceStatus_ScopedSessionGets404(t *testing.T) { m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(true, nil) m.On("GetAllowedAccountsAPI", mock.Anything, "u1").Return([]string{"acct-1"}, nil) h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})} @@ -91,6 +93,7 @@ func TestInsuranceStatus_ScopedSessionGets404(t *testing.T) { func TestInsuranceStatus_UnrestrictedSessionAllowed(t *testing.T) { m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(true, nil) m.On("GetAllowedAccountsAPI", mock.Anything, "u1").Return([]string(nil), nil) h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})} @@ -101,6 +104,7 @@ func TestInsuranceStatus_UnrestrictedSessionAllowed(t *testing.T) { func TestInsuranceStatus_ScopedUserAPIKeyGets404(t *testing.T) { m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("HasAPIKeyPermissionAPI", mock.Anything, "user-key", "view", "recommendations").Return("u2", "key-1", true, nil) m.On("GetAllowedAccountsAPI", mock.Anything, "u2").Return([]string{"acct-1"}, nil) h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})} diff --git a/internal/api/handler_inventory_scope_provider_test.go b/internal/api/handler_inventory_scope_provider_test.go index 02bc9c0b..a9483ced 100644 --- a/internal/api/handler_inventory_scope_provider_test.go +++ b/internal/api/handler_inventory_scope_provider_test.go @@ -20,11 +20,13 @@ import ( func inventoryNames(t *testing.T, scope []string, accounts []config.CloudAccount, rows []config.PurchaseHistoryRecord) map[string]string { t.Helper() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "tok").Return(&Session{UserID: "u-1", Email: "u@example.com"}, nil) mockAuth.On("HasPermissionAPI", mock.Anything, "u-1", mock.Anything, mock.Anything).Return(true, nil) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, "u-1").Return(scope, nil) store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetActivePurchaseHistory", mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(append([]config.PurchaseHistoryRecord(nil), rows...), nil) store.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { @@ -142,10 +144,12 @@ func TestHandleRequest_Inventory_SameProviderAmbiguousExternalID(t *testing.T) { func TestHandleRequest_Inventory_AccountListFailureIsAnError(t *testing.T) { t.Parallel() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "tok").Return(&Session{UserID: "u-1", Email: "u@example.com"}, nil) mockAuth.On("HasPermissionAPI", mock.Anything, "u-1", mock.Anything, mock.Anything).Return(true, nil) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, "u-1").Return([]string(nil), nil) store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetActivePurchaseHistory", mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return([]config.PurchaseHistoryRecord{activeRow("r", "aws", "123")}, nil) store.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { diff --git a/internal/api/handler_inventory_test.go b/internal/api/handler_inventory_test.go index a92ebfe4..d4d535c5 100644 --- a/internal/api/handler_inventory_test.go +++ b/internal/api/handler_inventory_test.go @@ -17,8 +17,10 @@ import ( // requirePermission short-circuits. Mirrors adminHistoryReq from // handler_history_test.go — the inventory handler reuses the view:purchases // permission, so the request shape is the same. -func adminInventoryReq(ctx context.Context) (*MockAuthService, *events.LambdaFunctionURLRequest) { +func adminInventoryReq(t *testing.T, ctx context.Context) (*MockAuthService, *events.LambdaFunctionURLRequest) { + t.Helper() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", @@ -36,13 +38,14 @@ func adminInventoryReq(ctx context.Context) (*MockAuthService, *events.LambdaFun func TestHandler_listActiveCommitments_Empty(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetActivePurchaseHistory", ctx, mock.AnythingOfType("time.Time"), []string(nil), map[string][]string(nil)).Return([]config.PurchaseHistoryRecord{}, nil) mockStore.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { return []config.CloudAccount{}, nil } - mockAuth, req := adminInventoryReq(ctx) + mockAuth, req := adminInventoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.listActiveCommitments(ctx, req, map[string]string{}) @@ -62,6 +65,7 @@ func TestHandler_listActiveCommitments_Empty(t *testing.T) { func TestHandler_listActiveCommitments_FiltersExpired(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) now := time.Now() purchases := []config.PurchaseHistoryRecord{ @@ -107,7 +111,7 @@ func TestHandler_listActiveCommitments_FiltersExpired(t *testing.T) { }, nil } - mockAuth, req := adminInventoryReq(ctx) + mockAuth, req := adminInventoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.listActiveCommitments(ctx, req, map[string]string{}) @@ -141,6 +145,7 @@ func TestHandler_listActiveCommitments_FiltersExpired(t *testing.T) { func TestHandler_listActiveCommitments_AccountFilter(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) now := time.Now() purchases := []config.PurchaseHistoryRecord{ @@ -162,7 +167,7 @@ func TestHandler_listActiveCommitments_AccountFilter(t *testing.T) { return []config.CloudAccount{{ID: "acc-1", Name: "Account One"}}, nil } - mockAuth, req := adminInventoryReq(ctx) + mockAuth, req := adminInventoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.listActiveCommitments(ctx, req, map[string]string{"account_id": "acc-1"}) @@ -184,6 +189,7 @@ func TestHandler_listActiveCommitments_AccountFilter(t *testing.T) { func TestHandler_listActiveCommitments_ProviderFilter(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) now := time.Now() purchases := []config.PurchaseHistoryRecord{ @@ -214,7 +220,7 @@ func TestHandler_listActiveCommitments_ProviderFilter(t *testing.T) { return []config.CloudAccount{{ID: "acc-1", Name: "Account One"}}, nil } - mockAuth, req := adminInventoryReq(ctx) + mockAuth, req := adminInventoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.listActiveCommitments(ctx, req, map[string]string{"provider": "aws"}) @@ -233,6 +239,7 @@ func TestHandler_listActiveCommitments_ProviderFilter(t *testing.T) { func TestHandler_listActiveCommitments_SortedByExpiry(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) now := time.Now() // Three purchases with very different term remainders. Listed @@ -275,7 +282,7 @@ func TestHandler_listActiveCommitments_SortedByExpiry(t *testing.T) { return []config.CloudAccount{{ID: "acc-1", Name: "Account One"}}, nil } - mockAuth, req := adminInventoryReq(ctx) + mockAuth, req := adminInventoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.listActiveCommitments(ctx, req, map[string]string{}) @@ -452,7 +459,7 @@ func TestHandler_getCoverageBreakdown_Integration(t *testing.T) { } mockScheduler.On("ListRecommendations", mock.Anything, config.RecommendationFilter{}).Return(recs, nil) - mockAuth, req := adminInventoryReq(ctx) + mockAuth, req := adminInventoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore, scheduler: mockScheduler} result, err := handler.getCoverageBreakdown(ctx, req, map[string]string{}) @@ -547,7 +554,7 @@ func TestHandler_getCoverageBreakdown_ProviderAndAccountChip(t *testing.T) { config.RecommendationFilter{AccountIDs: []string{"acc-1"}}, ).Return(acc1Recs, nil) - mockAuth, req := adminInventoryReq(ctx) + mockAuth, req := adminInventoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore, scheduler: mockScheduler} result, err := handler.getCoverageBreakdown(ctx, req, map[string]string{ @@ -655,7 +662,7 @@ func TestHandler_getCoverageBreakdown_AzureAllUpfrontConsistency(t *testing.T) { // amortized upfront makes Azure 100% covered for compute. mockScheduler.On("ListRecommendations", mock.Anything, config.RecommendationFilter{}).Return([]config.RecommendationRecord{}, nil) - mockAuth, req := adminInventoryReq(ctx) + mockAuth, req := adminInventoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore, scheduler: mockScheduler} result, err := handler.getCoverageBreakdown(ctx, req, map[string]string{}) @@ -754,7 +761,7 @@ func TestHandler_listActiveCommitments_NoTruncationBeyond1000(t *testing.T) { return []config.CloudAccount{}, nil } - mockAuth, req := adminInventoryReq(ctx) + mockAuth, req := adminInventoryReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} result, err := handler.listActiveCommitments(ctx, req, map[string]string{}) diff --git a/internal/api/handler_ladder_test.go b/internal/api/handler_ladder_test.go index 17c1f163..6702b169 100644 --- a/internal/api/handler_ladder_test.go +++ b/internal/api/handler_ladder_test.go @@ -303,6 +303,7 @@ func TestUpsertLadderConfig_OutOfScopeAccountRefused(t *testing.T) { func TestUpsertLadderConfig_InScopeAccountStillAllowed(t *testing.T) { ctx := context.Background() handler, mockStore := ladderScopedHandler(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("UpsertLadderConfig", mock.Anything, mock.AnythingOfType("*config.LadderConfigDB")). Return(&config.LadderConfigDB{ID: "written-row", CloudAccountID: scopedInAccount}, nil) @@ -319,6 +320,7 @@ func TestUpsertLadderConfig_InScopeAccountStillAllowed(t *testing.T) { func TestUpsertLadderConfig_UnrestrictedSessionUnaffected(t *testing.T) { ctx := context.Background() handler, mockStore := scopedHandler(t) // no accounts => grantAdmin => unrestricted + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetCloudAccount", mock.Anything, scopedOutAccount). Return(&config.CloudAccount{ID: scopedOutAccount, Name: "victim-account", Provider: "aws"}, nil) mockStore.On("GetLadderConfig", mock.Anything, mock.Anything, mock.Anything).Return((*config.LadderConfigDB)(nil), nil) diff --git a/internal/api/handler_ladder_timeline_test.go b/internal/api/handler_ladder_timeline_test.go index f544a243..be8f3bc6 100644 --- a/internal/api/handler_ladder_timeline_test.go +++ b/internal/api/handler_ladder_timeline_test.go @@ -86,6 +86,7 @@ func TestLadderTimelineUnavailableIsNotEmpty(t *testing.T) { func TestLadderTimelineUnsupportedProvider(t *testing.T) { h, original := scopedHandler(t, scopedInAccount) + t.Cleanup(func() { original.AssertExpectations(t) }) original.On("GetCloudAccount", mock.Anything, scopedInAccount). Return(&config.CloudAccount{ID: scopedInAccount, Name: "azure", Provider: "azure"}, nil) store := &timelineTestStore{MockConfigStore: original} diff --git a/internal/api/handler_marketplace_attempt_test.go b/internal/api/handler_marketplace_attempt_test.go index 5f73f078..2070079f 100644 --- a/internal/api/handler_marketplace_attempt_test.go +++ b/internal/api/handler_marketplace_attempt_test.go @@ -188,7 +188,7 @@ func newMarketplaceFixture(t *testing.T) *marketplaceFixture { t.Helper() db := newFakeListingDB() authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) authSvc.On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() fake := &fakeMarketplaceAWS{} // The token and schedule must be on the row before AWS sees the request. diff --git a/internal/api/handler_marketplace_compensation_integration_test.go b/internal/api/handler_marketplace_compensation_integration_test.go index 37c488da..fb361a55 100644 --- a/internal/api/handler_marketplace_compensation_integration_test.go +++ b/internal/api/handler_marketplace_compensation_integration_test.go @@ -46,6 +46,7 @@ func TestMarketplaceListCancelFailureKeepsListingRecorded(t *testing.T) { require.NoError(t, store.SavePurchaseHistory(ctx, row)) authSvc := &MockAuthService{} + t.Cleanup(func() { authSvc.AssertExpectations(t) }) authSvc.On("ValidateSession", mock.Anything, "test-token").Return(&Session{UserID: "admin", Email: "admin@test.com"}, nil) authSvc.On("HasPermissionAPI", mock.Anything, "admin", auth.ActionSellAny, auth.ResourcePurchases).Return(true, nil) authSvc.On("GetAllowedAccountsAPI", mock.Anything, "admin").Return([]string{"*"}, nil) @@ -126,8 +127,10 @@ func hostAWSConfig() aws.Config { } } -func newCompensationAuth() *MockAuthService { +func newCompensationAuth(t *testing.T) *MockAuthService { + t.Helper() authSvc := &MockAuthService{} + t.Cleanup(func() { authSvc.AssertExpectations(t) }) authSvc.On("ValidateSession", mock.Anything, "test-token").Return(&Session{UserID: "admin", Email: "admin@test.com"}, nil) authSvc.On("HasPermissionAPI", mock.Anything, "admin", auth.ActionSellAny, auth.ResourcePurchases).Return(true, nil) authSvc.On("GetAllowedAccountsAPI", mock.Anything, "admin").Return([]string{"*"}, nil) @@ -146,7 +149,7 @@ func TestMarketplaceListCancelFailureAfterContextCanceledStillRecordsListing(t * ec2 := &stubMarketplaceEC2{cancelFn: func(_ context.Context, _ string) (ec2svc.MarketplaceListingResult, error) { return ec2svc.MarketplaceListingResult{}, errors.New("RequestLimitExceeded") }} - h := &Handler{config: &cancelOnFirstWriteStore{StoreInterface: store, cancel: cancel}, auth: newCompensationAuth(), + h := &Handler{config: &cancelOnFirstWriteStore{StoreInterface: store, cancel: cancel}, auth: newCompensationAuth(t), marketplaceEC2Factory: func(_ aws.Config) marketplaceEC2Client { return ec2 }} h.awsCfgOnce.Do(func() { h.awsCfg = hostAWSConfig() }) @@ -178,7 +181,7 @@ func TestMarketplaceListCancelFailureRecordsEmptyAWSStateAsPending(t *testing.T) return ec2svc.MarketplaceListingResult{}, errors.New("RequestLimitExceeded") }, } - h := &Handler{config: &failFirstListingWriteStore{StoreInterface: store}, auth: newCompensationAuth(), + h := &Handler{config: &failFirstListingWriteStore{StoreInterface: store}, auth: newCompensationAuth(t), marketplaceEC2Factory: func(_ aws.Config) marketplaceEC2Client { return ec2 }} h.awsCfgOnce.Do(func() { h.awsCfg = hostAWSConfig() }) diff --git a/internal/api/handler_marketplace_credentials_test.go b/internal/api/handler_marketplace_credentials_test.go index 62762431..e99d2cd1 100644 --- a/internal/api/handler_marketplace_credentials_test.go +++ b/internal/api/handler_marketplace_credentials_test.go @@ -50,10 +50,15 @@ func (hostSTSTransport) Do(*http.Request) (*http.Response, error) { func marketplaceAccountHandler(t *testing.T, account *config.CloudAccount, row *config.PurchaseHistoryRecord) (*Handler, *aws.Config) { t.Helper() cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID).Return(row, nil) - cfgStore.On("GetCloudAccount", mock.Anything, "acct-1").Return(account, nil) + // A nil account is a row with no cloud account, which is never looked up; + // an unexpected lookup then hits an unregistered method. + if account != nil { + cfgStore.On("GetCloudAccount", mock.Anything, "acct-1").Return(account, nil) + } cfgStore.On("ClaimMarketplaceListingSlot", mock.Anything, validMarketplacePurchaseID, mock.Anything, mock.Anything).Return(&config.MarketplaceListingClaim{ClientToken: "tok"}, nil).Maybe() cfgStore.On("UpdatePurchaseHistoryListing", mock.Anything, validMarketplacePurchaseID, mock.Anything, mock.Anything).Return(nil).Maybe() cfgStore.On("ReleaseMarketplaceListingClaim", mock.Anything, validMarketplacePurchaseID, mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() @@ -217,8 +222,9 @@ func (d deniedCancelEC2) CancelMarketplaceListing(ctx context.Context, id string func TestMarketplaceList_CompensationAssumeRoleDeniedReturnsFixedMessage(t *testing.T) { cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID).Return(row, nil) cfgStore.On("GetCloudAccount", mock.Anything, "acct-1").Return(roleAccount(), nil) @@ -270,8 +276,9 @@ func TestMarketplaceCancel_RoleARNAccountAssumesRole(t *testing.T) { } func TestLoadMarketplaceAWSConfig_FailsClosed(t *testing.T) { - newHandler := func(account *config.CloudAccount, lookupErr error) *Handler { + newHandler := func(t *testing.T, account *config.CloudAccount, lookupErr error) *Handler { cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) cfgStore.On("GetCloudAccount", mock.Anything, "acct-1").Return(account, lookupErr) h := &Handler{config: cfgStore, credStore: &accountKeyStore{}} h.awsCfgOnce.Do(func() { @@ -285,7 +292,7 @@ func TestLoadMarketplaceAWSConfig_FailsClosed(t *testing.T) { } t.Run("nil account is not host credentials", func(t *testing.T) { - cfg, err := newHandler(nil, nil).loadMarketplaceAWSConfig(context.Background(), standardRow()) + cfg, err := newHandler(t, nil, nil).loadMarketplaceAWSConfig(context.Background(), standardRow()) ce, ok := IsClientError(err) require.True(t, ok, "expected a client error, got %v", err) @@ -295,7 +302,7 @@ func TestLoadMarketplaceAWSConfig_FailsClosed(t *testing.T) { }) t.Run("lookup error is not host credentials", func(t *testing.T) { - cfg, err := newHandler(nil, errors.New("db down")).loadMarketplaceAWSConfig(context.Background(), standardRow()) + cfg, err := newHandler(t, nil, errors.New("db down")).loadMarketplaceAWSConfig(context.Background(), standardRow()) require.Error(t, err) _, isClient := IsClientError(err) @@ -305,7 +312,7 @@ func TestLoadMarketplaceAWSConfig_FailsClosed(t *testing.T) { t.Run("non-aws provider", func(t *testing.T) { account := &config.CloudAccount{ID: "acct-1", Provider: "azure"} - cfg, err := newHandler(account, nil).loadMarketplaceAWSConfig(context.Background(), standardRow()) + cfg, err := newHandler(t, account, nil).loadMarketplaceAWSConfig(context.Background(), standardRow()) ce, ok := IsClientError(err) require.True(t, ok, "expected a client error, got %v", err) @@ -503,6 +510,7 @@ func TestLoadMarketplaceAWSConfig_ResolverErrorNeverFallsBackToHost(t *testing.T for name, store := range stores { t.Run(name, func(t *testing.T) { cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) cfgStore.On("GetCloudAccount", mock.Anything, "acct-1").Return(memberAccount(), nil) h := &Handler{config: cfgStore, credStore: store} h.awsCfgOnce.Do(func() { diff --git a/internal/api/handler_marketplace_poller_test.go b/internal/api/handler_marketplace_poller_test.go index 4ea7fe0d..c0ce131e 100644 --- a/internal/api/handler_marketplace_poller_test.go +++ b/internal/api/handler_marketplace_poller_test.go @@ -59,6 +59,7 @@ func newPollerFixture(t *testing.T, rows ...config.PurchaseHistoryRecord) *polle // its AssertNotCalled checks go through MockConfigStore's call log. func newPollerFixtureWithStore(t *testing.T, store *MockConfigStore, rows ...config.PurchaseHistoryRecord) *pollerFixture { t.Helper() + t.Cleanup(func() { store.AssertExpectations(t) }) f := &pollerFixture{ store: store, aws: &fakeListingStatusReader{statuses: map[string]marketplaceListingStatus{}, errs: map[string]error{}}, @@ -298,6 +299,7 @@ func TestPollMarketplaceListings_FailuresAreCountedAndTheRunGoesOn(t *testing.T) noAccount := listedRow("ri-orphan", "ril-orphan") noAccount.CloudAccountID, noAccount.AccountID = nil, "" f := newPollerFixture(t, listedRow("ri-1", "ril-1"), noAccount, listedRow("ri-2", "ril-2"), listedRow("ri-3", "ril-3")) + t.Cleanup(func() { f.store.AssertExpectations(t) }) f.aws.errs["ril-1"] = errors.New("RequestLimitExceeded") f.aws.statuses["ril-2"] = marketplaceListingStatus{ListingID: "ril-2", State: config.ListingStateActive, CreatedAt: pollerCreateDate} f.store.On("RecordMarketplaceListingActive", mock.Anything, "ri-2", "ril-2", pollerCreateDate).Return(false, errors.New("db down")) @@ -314,6 +316,7 @@ func TestPollMarketplaceListings_FailuresAreCountedAndTheRunGoesOn(t *testing.T) func TestPollMarketplaceListings_ListFailureFailsTheRun(t *testing.T) { f := newPollerFixture(t) + t.Cleanup(func() { f.store.AssertExpectations(t) }) f.store.ExpectedCalls = nil f.store.On("ListMarketplaceListingsToPoll", mock.Anything).Return(nil, errors.New("db down")) diff --git a/internal/api/handler_marketplace_recover_integration_test.go b/internal/api/handler_marketplace_recover_integration_test.go index 3fd1cfa6..8ebd6ad2 100644 --- a/internal/api/handler_marketplace_recover_integration_test.go +++ b/internal/api/handler_marketplace_recover_integration_test.go @@ -110,7 +110,7 @@ func newRecoverRig(t *testing.T) *recoverRig { rig := &recoverRig{store: store, hook: &listingWriteHook{StoreInterface: store}, aws: &lockedMarketplaceAWS{}} rig.row = setupCompensationRow(t, store) rig.ec2 = &stubMarketplaceEC2{createFn: rig.aws.create, cancelFn: rig.aws.cancel, describeFn: rig.aws.describe} - rig.h = &Handler{config: rig.hook, auth: newCompensationAuth(), + rig.h = &Handler{config: rig.hook, auth: newCompensationAuth(t), marketplaceEC2Factory: func(aws.Config) marketplaceEC2Client { return rig.ec2 }, marketplaceFinderFactory: func(aws.Config) marketplaceListingFinder { return rig.aws }} rig.h.awsCfgOnce.Do(func() { rig.h.awsCfg = hostAWSConfig() }) diff --git a/internal/api/handler_marketplace_recover_test.go b/internal/api/handler_marketplace_recover_test.go index 9e7f76e5..9feb6ca3 100644 --- a/internal/api/handler_marketplace_recover_test.go +++ b/internal/api/handler_marketplace_recover_test.go @@ -257,7 +257,7 @@ func TestMarketplaceList_RollbackReleaseUsesDetachedContext(t *testing.T) { defer cancel() cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID).Return(standardRow(), nil) cfgStore.On("ClaimMarketplaceListingSlot", mock.Anything, validMarketplacePurchaseID, mock.Anything, mock.Anything). Return(&config.MarketplaceListingClaim{ClientToken: "tok"}, nil) @@ -286,7 +286,7 @@ func TestMarketplaceCancel_RecordsOnDetachedContext(t *testing.T) { defer cancel() cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() row.ListingID, row.ListingState = "ril-cancel", config.ListingStateActive cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID).Return(row, nil) @@ -313,7 +313,7 @@ func TestMarketplaceCancel_RecordsOnDetachedContext(t *testing.T) { func TestMarketplaceCancel_EmptyStatusRecordedAsCanceled(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() row.ListingID, row.ListingState = "ril-cancel", config.ListingStateActive cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID).Return(row, nil) @@ -333,8 +333,9 @@ func TestMarketplaceCancel_EmptyStatusRecordedAsCanceled(t *testing.T) { // still-active listing is not recorded as canceled. func TestMarketplaceCancel_FailureOnLiveListingIsNotRecorded(t *testing.T) { cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() row.ListingID, row.ListingState = "ril-cancel", config.ListingStateActive cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID).Return(row, nil) @@ -355,8 +356,9 @@ func TestMarketplaceCancel_FailureOnLiveListingIsNotRecorded(t *testing.T) { // is not touched. func TestMarketplaceCancel_PendingRowThatChangedIs409(t *testing.T) { cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() row.ListingState = config.ListingStatePending cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID).Return(row, nil) @@ -377,7 +379,7 @@ func TestMarketplaceCancel_PendingRowThatChangedIs409(t *testing.T) { func TestMarketplaceCancel_FencedRecoveryWriteLosesIs409(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() row.ListingState = config.ListingStatePending seen := config.MarketplaceListingAttempt{State: config.ListingStatePending} @@ -399,7 +401,7 @@ func TestMarketplaceCancel_FencedRecoveryWriteLosesIs409(t *testing.T) { func TestMarketplaceList_UncanceledRecordAfterConcurrentCancelIs409(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID).Return(standardRow(), nil) cfgStore.On("ClaimMarketplaceListingSlot", mock.Anything, validMarketplacePurchaseID, mock.Anything, mock.Anything). Return(&config.MarketplaceListingClaim{ClientToken: "tok"}, nil) diff --git a/internal/api/handler_marketplace_test.go b/internal/api/handler_marketplace_test.go index b94c1c86..8814d7f8 100644 --- a/internal/api/handler_marketplace_test.go +++ b/internal/api/handler_marketplace_test.go @@ -124,7 +124,9 @@ func marketplaceReq() *events.LambdaFunctionURLRequest { } } -func adminSession(authSvc *MockAuthService) { +func adminSession(t *testing.T, authSvc *MockAuthService) { + t.Helper() + t.Cleanup(func() { authSvc.AssertExpectations(t) }) authSvc.On("ValidateSession", mock.Anything, "test-token"). Return(&Session{UserID: "admin", Email: "admin@test.com"}, nil) authSvc.On("HasPermissionAPI", mock.Anything, "admin", auth.ActionSellAny, auth.ResourcePurchases). @@ -139,7 +141,7 @@ func adminSession(authSvc *MockAuthService) { func TestMarketplaceList_ConvertibleRejected(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() row.OfferingClass = "convertible" @@ -196,6 +198,7 @@ func TestMarketplaceList_SellOwnAllowed(t *testing.T) { func TestMarketplaceList_SellOwnDeniedWrongAccount(t *testing.T) { cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) authSvc := &MockAuthService{} authSvc.On("ValidateSession", mock.Anything, "test-token"). @@ -223,8 +226,9 @@ func TestMarketplaceList_SellOwnDeniedWrongAccount(t *testing.T) { func TestMarketplaceList_DuplicateActiveListing409(t *testing.T) { cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() row.ListingState = config.ListingStateActive @@ -244,8 +248,9 @@ func TestMarketplaceList_DuplicateActiveListing409(t *testing.T) { func TestMarketplaceList_AWSClientFaultMapsTo400(t *testing.T) { cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID). Return(standardRow(), nil) cfgStore.On("ClaimMarketplaceListingSlot", mock.Anything, validMarketplacePurchaseID, mock.Anything, mock.Anything). @@ -274,8 +279,9 @@ func TestMarketplaceList_AWSClientFaultMapsTo400(t *testing.T) { func TestMarketplaceList_AWSServerFaultMapsTo502(t *testing.T) { cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID). Return(standardRow(), nil) cfgStore.On("ClaimMarketplaceListingSlot", mock.Anything, validMarketplacePurchaseID, mock.Anything, mock.Anything). @@ -303,8 +309,9 @@ func TestMarketplaceList_AWSServerFaultMapsTo502(t *testing.T) { func TestMarketplaceList_UnknownErrorMapsTo502(t *testing.T) { cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID). Return(standardRow(), nil) cfgStore.On("ClaimMarketplaceListingSlot", mock.Anything, validMarketplacePurchaseID, mock.Anything, mock.Anything). @@ -331,7 +338,7 @@ func TestMarketplaceList_UnknownErrorMapsTo502(t *testing.T) { func TestMarketplaceList_DBFailureCompensatingRollback(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID). Return(standardRow(), nil) cfgStore.On("ClaimMarketplaceListingSlot", mock.Anything, validMarketplacePurchaseID, mock.Anything, mock.Anything). @@ -368,7 +375,7 @@ func TestMarketplaceList_DBFailureAndCancelFailureKeepsListing(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID). Return(standardRow(), nil) cfgStore.On("ClaimMarketplaceListingSlot", mock.Anything, validMarketplacePurchaseID, mock.Anything, mock.Anything). @@ -417,7 +424,7 @@ func TestMarketplaceList_CancelFailureRecordsNonActiveStateAsPending(t *testing. t.Run(tc.name, func(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID). Return(standardRow(), nil) cfgStore.On("ClaimMarketplaceListingSlot", mock.Anything, validMarketplacePurchaseID, mock.Anything, mock.Anything). @@ -456,7 +463,7 @@ func TestMarketplaceList_CancelFailureAfterRequestContextCanceledStillRecords(t ctx, cancel := context.WithCancel(context.Background()) cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID). Return(standardRow(), nil) cfgStore.On("ClaimMarketplaceListingSlot", mock.Anything, validMarketplacePurchaseID, mock.Anything, mock.Anything). @@ -515,7 +522,7 @@ func TestMarketplaceList_DefaultScheduleProrationMath(t *testing.T) { func TestMarketplaceCancel_HappyPath(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() row.ListingState = config.ListingStateActive @@ -539,8 +546,9 @@ func TestMarketplaceCancel_HappyPath(t *testing.T) { func TestMarketplaceCancel_NoActiveListing409(t *testing.T) { cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() // ListingState empty -> not active cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID). @@ -564,7 +572,7 @@ func TestMarketplaceCancel_NoActiveListing409(t *testing.T) { func TestMarketplaceList_ConcurrentClaimLoses409(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID). Return(standardRow(), nil) @@ -591,7 +599,7 @@ func TestMarketplaceList_ConcurrentClaimLoses409(t *testing.T) { func TestMarketplaceList_ClaimErrorMapsToInternal(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) cfgStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID). Return(standardRow(), nil) @@ -702,7 +710,7 @@ func TestResolveMarketplacePriceSchedule_TermExceedsRemainingRejected(t *testing func TestMarketplaceList_EmptyOfferingClassFetchedStandard(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) // Row has no offering_class (simulates pre-migration or externally-created RI). row := standardRow() @@ -740,7 +748,7 @@ func TestMarketplaceList_EmptyOfferingClassFetchedStandard(t *testing.T) { func TestMarketplaceList_EmptyOfferingClassFetchedConvertible(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() row.OfferingClass = "" @@ -851,8 +859,9 @@ func TestMarketplaceListHTTP_UnlistableTermsReturn422(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) authSvc.On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() row := standardRow() tc.mutate(row) @@ -883,8 +892,9 @@ func TestMarketplaceListHTTP_ValidRIStillLists(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { cfgStore := &MockConfigStore{} + t.Cleanup(func() { cfgStore.AssertExpectations(t) }) authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) authSvc.On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() row := standardRow() row.Timestamp = tc.timestamp @@ -918,7 +928,7 @@ func TestMarketplaceList_TermYearsConvertedToMonths(t *testing.T) { t.Run("default schedule price reflects true remaining value", func(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() row.Term = 3 // 3-year RI (stored in years) @@ -998,7 +1008,7 @@ func TestMarketplaceList_TermYearsConvertedToMonths(t *testing.T) { func TestMarketplaceList_InvalidTermReturnsError(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() row.Term = 0 // invalid: purchase_history.term must be 1 or 3 (years) @@ -1019,7 +1029,7 @@ func TestMarketplaceList_InvalidTermReturnsError(t *testing.T) { func TestMarketplaceList_ZeroTimestampReturnsError(t *testing.T) { cfgStore := &MockConfigStore{} authSvc := &MockAuthService{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() row.Timestamp = time.Time{} diff --git a/internal/api/handler_not_found_mapping_test.go b/internal/api/handler_not_found_mapping_test.go index fbe6e335..852deca2 100644 --- a/internal/api/handler_not_found_mapping_test.go +++ b/internal/api/handler_not_found_mapping_test.go @@ -21,8 +21,10 @@ import ( const notFoundTestID = "11111111-1111-1111-1111-111111111111" -func notFoundAdminReq(ctx context.Context, path string) (*MockAuthService, *events.LambdaFunctionURLRequest) { +func notFoundAdminReq(t *testing.T, ctx context.Context, path string) (*MockAuthService, *events.LambdaFunctionURLRequest) { + t.Helper() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", @@ -54,7 +56,7 @@ func requireServerError(t *testing.T, err error) { func TestHandler_getUser_MissingUserIs404(t *testing.T) { ctx := context.Background() - mockAuth, req := notFoundAdminReq(ctx, "/api/users/"+notFoundTestID) + mockAuth, req := notFoundAdminReq(t, ctx, "/api/users/"+notFoundTestID) mockAuth.On("GetUser", ctx, notFoundTestID). Return(nil, fmt.Errorf("%w: %w", auth.ErrUserNotFound, pgx.ErrNoRows)) h := &Handler{auth: mockAuth} @@ -66,7 +68,7 @@ func TestHandler_getUser_MissingUserIs404(t *testing.T) { func TestHandler_getUser_StoreFailureStays500(t *testing.T) { ctx := context.Background() - mockAuth, req := notFoundAdminReq(ctx, "/api/users/"+notFoundTestID) + mockAuth, req := notFoundAdminReq(t, ctx, "/api/users/"+notFoundTestID) mockAuth.On("GetUser", ctx, notFoundTestID).Return(nil, errors.New("connection refused")) h := &Handler{auth: mockAuth} @@ -82,7 +84,7 @@ func TestHandler_getGroup_MissingGroupIs404(t *testing.T) { "nil group": auth.ErrGroupNotFound, } { t.Run(name, func(t *testing.T) { - mockAuth, req := notFoundAdminReq(ctx, "/api/groups/"+notFoundTestID) + mockAuth, req := notFoundAdminReq(t, ctx, "/api/groups/"+notFoundTestID) mockAuth.On("GetGroupAPI", ctx, notFoundTestID).Return(nil, storeErr) h := &Handler{auth: mockAuth} @@ -95,7 +97,7 @@ func TestHandler_getGroup_MissingGroupIs404(t *testing.T) { func TestHandler_getGroup_StoreFailureStays500(t *testing.T) { ctx := context.Background() - mockAuth, req := notFoundAdminReq(ctx, "/api/groups/"+notFoundTestID) + mockAuth, req := notFoundAdminReq(t, ctx, "/api/groups/"+notFoundTestID) mockAuth.On("GetGroupAPI", ctx, notFoundTestID).Return(nil, errors.New("connection refused")) h := &Handler{auth: mockAuth} @@ -116,7 +118,7 @@ func TestHandler_APIKeyDeleteRevoke_MissingOrNotOwnedIs404(t *testing.T) { } for name, serviceErr := range serviceErrs { t.Run("delete "+name, func(t *testing.T) { - mockAuth, req := notFoundAdminReq(ctx, "/api/api-keys/"+notFoundTestID) + mockAuth, req := notFoundAdminReq(t, ctx, "/api/api-keys/"+notFoundTestID) mockAuth.On("DeleteAPIKeyAPI", ctx, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", notFoundTestID).Return(serviceErr("delete")) h := &Handler{auth: mockAuth} @@ -125,7 +127,7 @@ func TestHandler_APIKeyDeleteRevoke_MissingOrNotOwnedIs404(t *testing.T) { requireNotFound(t, err, "API key not found") }) t.Run("revoke "+name, func(t *testing.T) { - mockAuth, req := notFoundAdminReq(ctx, "/api/api-keys/"+notFoundTestID+"/revoke") + mockAuth, req := notFoundAdminReq(t, ctx, "/api/api-keys/"+notFoundTestID+"/revoke") mockAuth.On("RevokeAPIKeyAPI", ctx, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", notFoundTestID).Return(serviceErr("revoke")) h := &Handler{auth: mockAuth} @@ -140,12 +142,12 @@ func TestHandler_APIKeyDeleteRevoke_StoreFailureStays500(t *testing.T) { ctx := context.Background() storeErr := errors.New("failed to get API key: connection refused") - mockAuth, req := notFoundAdminReq(ctx, "/api/api-keys/"+notFoundTestID) + mockAuth, req := notFoundAdminReq(t, ctx, "/api/api-keys/"+notFoundTestID) mockAuth.On("DeleteAPIKeyAPI", ctx, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", notFoundTestID).Return(storeErr) _, err := (&Handler{auth: mockAuth}).deleteAPIKey(ctx, req) requireServerError(t, err) - mockAuth, req = notFoundAdminReq(ctx, "/api/api-keys/"+notFoundTestID+"/revoke") + mockAuth, req = notFoundAdminReq(t, ctx, "/api/api-keys/"+notFoundTestID+"/revoke") mockAuth.On("RevokeAPIKeyAPI", ctx, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", notFoundTestID).Return(storeErr) _, err = (&Handler{auth: mockAuth}).revokeAPIKey(ctx, req) requireServerError(t, err) diff --git a/internal/api/handler_override_audit_integration_test.go b/internal/api/handler_override_audit_integration_test.go index 1d2a6aa0..b6802b63 100644 --- a/internal/api/handler_override_audit_integration_test.go +++ b/internal/api/handler_override_audit_integration_test.go @@ -36,6 +36,7 @@ func newOverrideAuditFixture(ctx context.Context, t *testing.T) *overrideAuditFi acct := &config.CloudAccount{Name: "audit fixture", Provider: "aws", ExternalID: "123456789012", Enabled: true} require.NoError(t, store.CreateCloudAccount(ctx, acct)) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminAccountSession(), nil) mockAuth.grantPermissionsScoped([]auth.Permission{{Action: auth.ActionAdmin, Resource: auth.ResourceAll}}, nil) return &overrideAuditFixture{pg: pg, store: store, h: &Handler{auth: mockAuth, config: store}, acct: acct} diff --git a/internal/api/handler_override_audit_test.go b/internal/api/handler_override_audit_test.go index 94e91eef..f687c47d 100644 --- a/internal/api/handler_override_audit_test.go +++ b/internal/api/handler_override_audit_test.go @@ -28,8 +28,10 @@ func adminAuditActor() config.AuditActor { func TestSaveAccountServiceOverride_WritesThroughAuditedTx(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) setupAdminAuth(ctx, mockAuth) store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) var gotAudit config.AuditContext store.On("SaveAccountServiceOverrideAuditedTx", ctx, mock.Anything, mock.AnythingOfType("*config.AccountServiceOverride"), mock.Anything). Run(func(args mock.Arguments) { gotAudit = args.Get(3).(config.AuditContext) }).Return(nil) @@ -47,8 +49,10 @@ func TestSaveAccountServiceOverride_WritesThroughAuditedTx(t *testing.T) { func TestSaveAccountServiceOverride_AuditFailureFailsTheSave(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) setupAdminAuth(ctx, mockAuth) store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("SaveAccountServiceOverrideAuditedTx", ctx, mock.Anything, mock.Anything, mock.Anything). Return(errors.New("failed to record config audit event")) h := &Handler{auth: mockAuth, config: store} @@ -60,6 +64,7 @@ func TestSaveAccountServiceOverride_AuditFailureFailsTheSave(t *testing.T) { func TestDeleteAccountServiceOverride_WritesThroughAuditedTx(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) setupAdminAuth(ctx, mockAuth) store := new(MockConfigStore) store.On("DeleteAccountServiceOverrideAuditedTx", ctx, mock.Anything, diff --git a/internal/api/handler_override_import_integration_test.go b/internal/api/handler_override_import_integration_test.go index 265238f3..3d6e3dc8 100644 --- a/internal/api/handler_override_import_integration_test.go +++ b/internal/api/handler_override_import_integration_test.go @@ -27,6 +27,7 @@ func TestImportOverrides_StrictIsAllOrNothingPostgres(t *testing.T) { require.NoError(t, store.CreateCloudAccount(ctx, acct)) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminAccountSession(), nil) mockAuth.grantPermissionsScoped([]auth.Permission{{Action: auth.ActionAdmin, Resource: auth.ResourceAll}}, nil) h := &Handler{auth: mockAuth, config: store} diff --git a/internal/api/handler_override_import_test.go b/internal/api/handler_override_import_test.go index d0374f31..53529c7c 100644 --- a/internal/api/handler_override_import_test.go +++ b/internal/api/handler_override_import_test.go @@ -194,6 +194,7 @@ func TestImportOverrides_CSVRejectsNaNCoverage(t *testing.T) { func TestImportOverrides_RequiresUpdateAccounts(t *testing.T) { mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", context.Background(), "admin-token").Return(adminAccountSession(), nil) mockAuth.grantPermissions(nil) h := &Handler{auth: mockAuth, config: newOverrideTransferStore()} diff --git a/internal/api/handler_override_transfer_test.go b/internal/api/handler_override_transfer_test.go index a8e5a8cf..508f17e7 100644 --- a/internal/api/handler_override_transfer_test.go +++ b/internal/api/handler_override_transfer_test.go @@ -140,6 +140,7 @@ func newOverrideTransferStore() *overrideTransferStore { func transferHandler(t *testing.T, store *overrideTransferStore, scope []string) *Handler { t.Helper() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", context.Background(), "admin-token").Return(adminAccountSession(), nil) mockAuth.grantPermissionsScoped([]auth.Permission{{Action: auth.ActionAdmin, Resource: auth.ResourceAll}}, scope) return &Handler{auth: mockAuth, config: store} diff --git a/internal/api/handler_per_account_perms_test.go b/internal/api/handler_per_account_perms_test.go index 98090341..f81785ff 100644 --- a/internal/api/handler_per_account_perms_test.go +++ b/internal/api/handler_per_account_perms_test.go @@ -67,8 +67,10 @@ func permsPtr(s string) *string { return &s } // role-level permission gating (which is covered by other test files). // - GetAllowedAccountsAPI returns [permsAccA] — i.e. the user is restricted // to account A only. -func scopedAuthMock(ctx context.Context) *MockAuthService { +func scopedAuthMock(t *testing.T, ctx context.Context) *MockAuthService { + t.Helper() m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("ValidateSession", ctx, permsScopedToken).Return(&Session{ UserID: permsScopedUserID, Email: "scoped@example.com", @@ -124,6 +126,7 @@ func TestPerAccountPerms_Recommendations_ListFilter(t *testing.T) { } mockSched := new(MockScheduler) + t.Cleanup(func() { mockSched.AssertExpectations(t) }) mockSched.On("ListRecommendations", mock.Anything, mock.Anything).Return([]config.RecommendationRecord{recA, recB}, nil) mockStore := new(MockConfigStore) @@ -132,7 +135,7 @@ func TestPerAccountPerms_Recommendations_ListFilter(t *testing.T) { } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), scheduler: mockSched, config: mockStore, } @@ -175,9 +178,10 @@ func TestPerAccountPerms_Recommendations_ListFilter_AdminSeesAll(t *testing.T) { } mockSched := new(MockScheduler) + t.Cleanup(func() { mockSched.AssertExpectations(t) }) mockSched.On("ListRecommendations", mock.Anything, mock.Anything).Return([]config.RecommendationRecord{recA, recB}, nil) - mockAuth, req := adminDashboardReq(ctx) // reuse adminDashboardReq: admin session + mockAuth, req := adminDashboardReq(t, ctx) // reuse adminDashboardReq: admin session handler := &Handler{ auth: mockAuth, scheduler: mockSched, @@ -222,7 +226,7 @@ func TestPerAccountPerms_RecommendationDetail_CrossAccountRejected(t *testing.T) } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), scheduler: mockSched, config: mockStore, } @@ -254,6 +258,7 @@ func TestPerAccountPerms_RecommendationDetail_AllowedAccountReturns200(t *testin t.Cleanup(func() { mockSched.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { return permsAccountList(), nil } @@ -261,7 +266,7 @@ func TestPerAccountPerms_RecommendationDetail_AllowedAccountReturns200(t *testin Return(&config.RecommendationsFreshness{LastCollectedAt: &now}, nil) handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), scheduler: mockSched, config: mockStore, } @@ -300,6 +305,7 @@ func TestPerAccountPerms_History_ListFilter(t *testing.T) { } mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("ListPurchaseHistoryPage", ctx, mock.Anything, mock.Anything). Return([]config.PurchaseHistoryRecord{rowA, rowB}, nil) mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything). @@ -311,7 +317,7 @@ func TestPerAccountPerms_History_ListFilter(t *testing.T) { } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), config: mockStore, } @@ -349,7 +355,7 @@ func TestPerAccountPerms_HistoryAnalytics_CrossAccountRejected(t *testing.T) { } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), analyticsClient: mockClient, config: mockStore, } @@ -375,6 +381,7 @@ func TestPerAccountPerms_HistoryAnalytics_AllowedAccountSucceeds(t *testing.T) { start := now.Add(-7 * 24 * time.Hour) mockClient := new(MockAnalyticsClient) + t.Cleanup(func() { mockClient.AssertExpectations(t) }) // permsAccA is a known account UUID (no external id in the fixture), so it // is matched on cloud_account_id only — the uuid set carries it, externals // is nil. @@ -387,7 +394,7 @@ func TestPerAccountPerms_HistoryAnalytics_AllowedAccountSucceeds(t *testing.T) { } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), analyticsClient: mockClient, config: mockStore, } @@ -415,6 +422,7 @@ func TestPerAccountPerms_HistoryBreakdown_AllowedAccountSucceeds(t *testing.T) { "ec2": {PurchaseCount: 3, TotalSavings: 150.0}, } mockClient := new(MockAnalyticsClient) + t.Cleanup(func() { mockClient.AssertExpectations(t) }) mockClient.On("QueryBreakdown", ctx, []string{permsAccA}, map[string][]string(nil), mock.Anything, mock.Anything, mock.Anything). Return(expectedData, nil) @@ -424,7 +432,7 @@ func TestPerAccountPerms_HistoryBreakdown_AllowedAccountSucceeds(t *testing.T) { } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), analyticsClient: mockClient, config: mockStore, } @@ -451,7 +459,7 @@ func TestPerAccountPerms_HistoryBreakdown_CrossAccountRejected(t *testing.T) { } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), analyticsClient: mockClient, config: mockStore, } @@ -494,9 +502,11 @@ func TestPerAccountPerms_DashboardSummary_AggregatesAllowedSubsetOnly(t *testing } mockSched := new(MockScheduler) + t.Cleanup(func() { mockSched.AssertExpectations(t) }) mockSched.On("ListRecommendations", mock.Anything, mock.Anything).Return([]config.RecommendationRecord{recA, recB}, nil) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) // resolveCoverageByAccountKey calls config.ResolveAccountConfigsForRecs which // calls GetServiceConfig for each unique (provider, service) pair in the @@ -505,8 +515,9 @@ func TestPerAccountPerms_DashboardSummary_AggregatesAllowedSubsetOnly(t *testing // by resolveCoverageByAccountKey — see issue #201). mockStore.On("GetServiceConfig", ctx, "aws", "ec2").Return((*config.ServiceConfig)(nil), nil) // Guard against future code paths that resolve service configs before filtering: - // stub rds so an unexpected GetServiceConfig call doesn't panic the test. - mockStore.On("GetServiceConfig", ctx, "aws", "rds").Return((*config.ServiceConfig)(nil), nil) + // stub rds (Maybe: the filtered-out rec is never resolved today) so an + // unexpected GetServiceConfig call doesn't panic the test. + mockStore.On("GetServiceConfig", ctx, "aws", "rds").Return((*config.ServiceConfig)(nil), nil).Maybe() // Issue #956: a restricted session with no explicit account filter scopes the // commitment metrics to its allowed_accounts (permsAccA), so the handler calls // GetActivePurchaseHistory scoped to account A — NOT an unscoped all-accounts read. @@ -518,7 +529,7 @@ func TestPerAccountPerms_DashboardSummary_AggregatesAllowedSubsetOnly(t *testing } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), scheduler: mockSched, config: mockStore, } @@ -551,6 +562,7 @@ func TestPerAccountPerms_DashboardSummary_CommitmentMetricsExcludeOtherAccounts( ctx := context.Background() mockSched := new(MockScheduler) + t.Cleanup(func() { mockSched.AssertExpectations(t) }) mockSched.On("ListRecommendations", mock.Anything, mock.Anything).Return([]config.RecommendationRecord{}, nil) purchaseTime := time.Now().AddDate(0, -2, 0) // active 1-year commitment @@ -570,7 +582,7 @@ func TestPerAccountPerms_DashboardSummary_CommitmentMetricsExcludeOtherAccounts( t.Cleanup(func() { mockStore.AssertExpectations(t) }) handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), scheduler: mockSched, config: mockStore, } @@ -599,13 +611,15 @@ func TestPerAccountPerms_ExecutePurchase_CrossAccountRejected403(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) - mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + // The scope check refuses before the global config is read, so no + // GetGlobalConfig expectation. mockStore.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { return permsAccountList(), nil } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), config: mockStore, } @@ -652,13 +666,15 @@ func TestPerAccountPerms_ExecutePurchase_UnattributedRecRejected400(t *testing.T ctx := context.Background() mockStore := new(MockConfigStore) - mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + // The scope check refuses before the global config is read, so no + // GetGlobalConfig expectation. mockStore.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { return permsAccountList(), nil } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), config: mockStore, } @@ -704,6 +720,7 @@ func TestPerAccountPerms_ExecutePurchase_AllowedAccountAccepted(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) mockStore.On("SavePurchaseExecution", ctx, mock.AnythingOfType("*config.PurchaseExecution")).Return(nil) // #644 idempotency lookup: no prior pending row → proceed to create. @@ -718,7 +735,7 @@ func TestPerAccountPerms_ExecutePurchase_AllowedAccountAccepted(t *testing.T) { }) handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), config: mockStore, } @@ -778,6 +795,7 @@ func TestPerAccountPerms_PlannedPurchase_CrossAccountPlanRejected404(t *testing. planID := "ffffffff-ffff-4fff-ffff-ffffffffffff" mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetExecutionByID", ctx, executionID).Return(&config.PurchaseExecution{ ExecutionID: executionID, PlanID: planID, @@ -792,7 +810,7 @@ func TestPerAccountPerms_PlannedPurchase_CrossAccountPlanRejected404(t *testing. } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), config: store, } @@ -854,6 +872,7 @@ func TestPerAccountPerms_CoverageBreakdown_RecsFilteredByAllowedAccounts(t *test } mockSched := new(MockScheduler) + t.Cleanup(func() { mockSched.AssertExpectations(t) }) mockSched.On("ListRecommendations", mock.Anything, config.RecommendationFilter{}). Return([]config.RecommendationRecord{recA, recB}, nil) @@ -869,7 +888,7 @@ func TestPerAccountPerms_CoverageBreakdown_RecsFilteredByAllowedAccounts(t *test t.Cleanup(func() { mockStore.AssertExpectations(t) }) handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), scheduler: mockSched, config: mockStore, } @@ -929,17 +948,19 @@ func TestPerAccountPerms_CoverageBreakdown_AdminSeesAll(t *testing.T) { } mockSched := new(MockScheduler) + t.Cleanup(func() { mockSched.AssertExpectations(t) }) mockSched.On("ListRecommendations", mock.Anything, config.RecommendationFilter{}). Return([]config.RecommendationRecord{recA, recB}, nil) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetActivePurchaseHistory", ctx, mock.AnythingOfType("time.Time"), []string(nil), map[string][]string(nil)). Return([]config.PurchaseHistoryRecord{purchaseA}, nil) mockStore.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { return permsAccountList(), nil } - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{ auth: mockAuth, scheduler: mockSched, @@ -1008,7 +1029,7 @@ func TestPerAccountPerms_InventoryCommitments_ScopedSQLRead(t *testing.T) { t.Cleanup(func() { mockStore.AssertExpectations(t) }) handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), config: mockStore, } @@ -1080,6 +1101,7 @@ func TestPerAccountPerms_PlannedPurchase_AllowedAccountPlanSucceeds(t *testing.T } mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetExecutionByID", ctx, executionID).Return(&config.PurchaseExecution{ ExecutionID: executionID, PlanID: planID, Status: "pending", }, nil) @@ -1095,7 +1117,7 @@ func TestPerAccountPerms_PlannedPurchase_AllowedAccountPlanSucceeds(t *testing.T } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), config: store, } @@ -1131,7 +1153,7 @@ func TestPerAccountPerms_ListConvertibleRIs_ScopedOutsideAllowedReturnsEmpty(t * } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), config: mockStore, // Inject the deployment CloudAccount UUID as permsAccB — outside // the scoped user's allowed set ([permsAccA]). @@ -1160,7 +1182,7 @@ func TestPerAccountPerms_ListConvertibleRIs_AdminSeesAll(t *testing.T) { // Admin auth — ValidateSession returns Role:"admin" so getAllowedAccounts // returns nil (unrestricted) and the scope filter is skipped entirely. - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) // reshapeAccountResolver is NOT set: if the scope check incorrectly runs for // admins it would call h.resolveAWSCloudAccountID which needs STS, causing a @@ -1215,7 +1237,7 @@ func TestPerAccountPerms_GetRIUtilization_ScopedOutsideAllowedReturnsEmpty(t *te } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), config: mockStore, // Deployment account is permsAccB — outside the scoped user's allowed set. reshapeAccountResolver: func(_ context.Context) (string, error) { @@ -1250,7 +1272,7 @@ func TestPerAccountPerms_GetRIUtilization_ScopedWithinAllowedProceedsToAWS(t *te recs := &countingUtilRecs{rows: []recommendations.RIUtilization{{ReservedInstanceID: "ri-own", UtilizationPercent: 40}}} ec2Stub := &countingUtilEC2{instances: []ec2svc.ConvertibleRI{{ReservedInstanceID: "ri-own"}}} handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), config: mockStore, // Deployment account is permsAccA — WITHIN the scoped user's allowed set. reshapeAccountResolver: func(_ context.Context) (string, error) { @@ -1289,7 +1311,7 @@ func TestPerAccountPerms_GetReshapeRecommendations_ScopedOutsideAllowedReturnsEm } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), config: mockStore, // Deployment account is permsAccB — outside the scoped user's allowed set. reshapeAccountResolver: func(_ context.Context) (string, error) { @@ -1325,15 +1347,18 @@ func TestPerAccountPerms_GetReshapeRecommendations_AdminSeesAll(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) - // ListStoredRecommendations is called by the recs lookup closure inside + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + // ListStoredRecommendations backs the recs lookup closure inside // AnalyzeReshapingWithRecs. Return empty so the test finishes without - // surfacing unrelated recs-lookup errors. + // surfacing unrelated recs-lookup errors. Maybe: plumbing only; whether + // the lookup runs depends on the reshape analysis, not on the scope check + // under test, and this fixture's analysis does not reach it. mockStore.On("ListStoredRecommendations", mock.Anything, mock.Anything). - Return([]config.RecommendationRecord(nil), nil) + Return([]config.RecommendationRecord(nil), nil).Maybe() mockStore.On("GetRecommendationsFreshness", mock.Anything). Return(&config.RecommendationsFreshness{}, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{ auth: mockAuth, @@ -1394,13 +1419,14 @@ func TestPerAccountPerms_GetLadderConfigs_ScopedUserSeesOnlyOwnAccount(t *testin cfgB := config.LadderConfigDB{ID: "cfg-b", CloudAccountID: permsAccB, Provider: "aws"} mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetLadderConfigs", ctx).Return([]config.LadderConfigDB{cfgA, cfgB}, nil) mockStore.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { return permsAccountList(), nil } handler := &Handler{ - auth: scopedAuthMock(ctx), + auth: scopedAuthMock(t, ctx), config: mockStore, } @@ -1429,10 +1455,12 @@ func TestPerAccountPerms_GetLadderConfigs_AllowedAccountsLookupError(t *testing. cfgB := config.LadderConfigDB{ID: "cfg-b", CloudAccountID: permsAccB, Provider: "aws"} mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetLadderConfigs", ctx).Return([]config.LadderConfigDB{cfgA, cfgB}, nil) // Scoped auth mock, but GetAllowedAccountsAPI fails mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, permsScopedToken).Return(&Session{ UserID: permsScopedUserID, Email: "scoped@example.com", @@ -1463,9 +1491,10 @@ func TestPerAccountPerms_GetLadderConfigs_AdminSeesAll(t *testing.T) { cfgB := config.LadderConfigDB{ID: "cfg-b", CloudAccountID: permsAccB, Provider: "aws"} mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetLadderConfigs", ctx).Return([]config.LadderConfigDB{cfgA, cfgB}, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) handler := &Handler{auth: mockAuth, config: mockStore} resp, err := handler.getLadderConfigs(ctx, req) diff --git a/internal/api/handler_plans_concurrency_integration_test.go b/internal/api/handler_plans_concurrency_integration_test.go index 928eee6d..08f8e124 100644 --- a/internal/api/handler_plans_concurrency_integration_test.go +++ b/internal/api/handler_plans_concurrency_integration_test.go @@ -120,6 +120,7 @@ func TestCreatePlannedPurchases_MembershipChangesBeforeLock(t *testing.T) { var userID string require.NoError(t, f.pool.QueryRow(ctx, `SELECT id FROM users WHERE email='creator@example.com'`).Scan(&userID)) auth := new(MockAuthService) + t.Cleanup(func() { auth.AssertExpectations(t) }) auth.On("ValidateSession", mock.Anything, "admin-token").Return(&Session{UserID: userID}, nil) auth.grantScoped(accounts[0].ID) f.handler.auth = auth @@ -135,6 +136,7 @@ func TestCreatePlannedPurchases_RoutedDatesAndRollback(t *testing.T) { ctx := context.Background() f := newCreateConcurrencyFixture(ctx, t) auth := f.handler.auth.(*MockAuthService) + t.Cleanup(func() { auth.AssertExpectations(t) }) auth.On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything).Return(nil) first := time.Now().AddDate(0, 0, 7).UTC().Truncate(24 * time.Hour) request := &events.LambdaFunctionURLRequest{Headers: map[string]string{"Authorization": "Bearer admin-token", "X-CSRF-Token": "test-csrf", "Content-Type": "application/json"}, @@ -363,6 +365,7 @@ func TestUpdatePlan_CapPresencePostgres(t *testing.T) { before, err := f.store.GetPurchasePlan(ctx, f.planID) require.NoError(t, err) auth := f.handler.auth.(*MockAuthService) + t.Cleanup(func() { auth.AssertExpectations(t) }) auth.On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything).Return(nil) body := fmt.Sprintf(`{"name":"Concurrent Create Plan","provider":"aws","service":"ec2","term":1,"payment":"no-upfront","target_coverage":80,"ramp_schedule":"weekly-25pct","enabled":true,"auto_purchase":%t,"notification_days_before":2%s}`, tc.auto, tc.field) req := &events.LambdaFunctionURLRequest{ @@ -413,7 +416,9 @@ func TestUpdatePlan_CapNullPreservesConcurrentWinner(t *testing.T) { cap100 := 100.0 plan.MaxStepCommitment = &cap100 require.NoError(t, f.store.UpdatePurchasePlan(ctx, plan)) - f.handler.auth.(*MockAuthService).On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything).Return(nil) + fixtureAuth := f.handler.auth.(*MockAuthService) + t.Cleanup(func() { fixtureAuth.AssertExpectations(t) }) + fixtureAuth.On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything).Return(nil) f.handler.config = concurrentCapStore{StoreInterface: f.store} req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"Authorization": "Bearer admin-token", "Content-Type": "application/json", "X-CSRF-Token": "test-csrf"}, Body: `{"name":"Concurrent Create Plan","ramp_schedule":"weekly-25pct","enabled":false,"auto_purchase":false,"max_step_commitment":null}`, RequestContext: events.LambdaFunctionURLRequestContext{HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{Method: "PUT", Path: "/api/plans/" + f.planID}}} response, err := f.handler.HandleRequest(ctx, req) diff --git a/internal/api/handler_plans_managed_guard_integration_test.go b/internal/api/handler_plans_managed_guard_integration_test.go index 9f8d79cc..ba2757c5 100644 --- a/internal/api/handler_plans_managed_guard_integration_test.go +++ b/internal/api/handler_plans_managed_guard_integration_test.go @@ -86,7 +86,9 @@ func TestManagedPlanRefusalIsHTTP409WithCode(t *testing.T) { ctx := context.Background() f := newCreateConcurrencyFixture(ctx, t) setPlanManaged(ctx, t, f, true) - f.handler.auth.(*MockAuthService).On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything).Return(nil) + fixtureAuth := f.handler.auth.(*MockAuthService) + t.Cleanup(func() { fixtureAuth.AssertExpectations(t) }) + fixtureAuth.On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything).Return(nil) req := managedGuardRequest(`{"notification_days_before":7}`) req.Headers["X-CSRF-Token"] = "test-csrf" req.RequestContext.HTTP.Method = "PATCH" diff --git a/internal/api/handler_plans_ramp_shape_test.go b/internal/api/handler_plans_ramp_shape_test.go index 4d490d87..9c48d777 100644 --- a/internal/api/handler_plans_ramp_shape_test.go +++ b/internal/api/handler_plans_ramp_shape_test.go @@ -19,15 +19,19 @@ func putPlan(t *testing.T, existing *config.PurchasePlan, body string) (*events. t.Helper() ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) - mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "admin-id", Email: "admin@example.com"}, nil) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) + // The admin X-API-Key authenticates the request, so the bearer session is + // never validated. mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) var saved *config.PurchasePlan mockStore.On("GetPurchasePlan", mock.Anything, existing.ID).Return(existing, nil) + // Maybe: a refused update never reaches the store. Callers prove which + // way it went through the returned plan (nil when refused). mockStore.On("UpdatePurchasePlan", mock.Anything, mock.AnythingOfType("*config.PurchasePlan")). - Run(func(args mock.Arguments) { saved = args.Get(1).(*config.PurchasePlan) }).Return(nil) + Run(func(args mock.Arguments) { saved = args.Get(1).(*config.PurchasePlan) }).Return(nil).Maybe() handler := &Handler{config: mockStore, auth: mockAuth, apiKey: "test-key"} resp, err := handler.HandleRequest(ctx, &events.LambdaFunctionURLRequest{ diff --git a/internal/api/handler_plans_services_merge_test.go b/internal/api/handler_plans_services_merge_test.go index 7aa6ae68..b48cea2d 100644 --- a/internal/api/handler_plans_services_merge_test.go +++ b/internal/api/handler_plans_services_merge_test.go @@ -107,6 +107,7 @@ func TestMergeUpdatedPlan_DoesNotMutateExistingServices(t *testing.T) { func TestHandler_updatePlan_RenameKeepsAllServices(t *testing.T) { ctx := context.Background() h, store := spHandler(t) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetPurchasePlan", ctx, spPlanID).Return(multiServicePlan(), nil) var stored *config.PurchasePlan store.On("UpdatePurchasePlan", ctx, mock.AnythingOfType("*config.PurchasePlan")). diff --git a/internal/api/handler_plans_sp_multistep_test.go b/internal/api/handler_plans_sp_multistep_test.go index 3e72a9cb..d05fb664 100644 --- a/internal/api/handler_plans_sp_multistep_test.go +++ b/internal/api/handler_plans_sp_multistep_test.go @@ -22,6 +22,7 @@ func spHandler(t *testing.T) (*Handler, *MockConfigStore) { ctx := context.Background() store := new(MockConfigStore) auth := new(MockAuthService) + t.Cleanup(func() { auth.AssertExpectations(t) }) auth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"}, nil) auth.grantAdmin() return &Handler{config: store, auth: auth}, store @@ -53,12 +54,17 @@ func TestMergeUpdatedPlan_RejectsArmedMultiStepSavingsPlan(t *testing.T) { requireSP400(t, err) } -func patchSP(t *testing.T, existing *config.PurchasePlan, body string) (any, error) { +// patchSP expects the plan write only when writes is true: a refused PATCH +// must not reach UpdatePurchasePlan, and an unexpected call fails the test. +func patchSP(t *testing.T, existing *config.PurchasePlan, body string, writes bool) (any, error) { t.Helper() ctx := context.Background() h, store := spHandler(t) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetPurchasePlan", ctx, spPlanID).Return(existing, nil) - store.On("UpdatePurchasePlan", ctx, mock.AnythingOfType("*config.PurchasePlan")).Return(nil) + if writes { + store.On("UpdatePurchasePlan", ctx, mock.AnythingOfType("*config.PurchasePlan")).Return(nil) + } return h.patchPlan(ctx, spReq(body), spPlanID) } @@ -69,7 +75,7 @@ func spPlan(service string, ramp config.RampSchedule) *config.PurchasePlan { func TestPatchPlan_ArmingMultiStepSavingsPlanIs400(t *testing.T) { weekly := config.RampSchedule{Type: "weekly", PercentPerStep: 25, StepIntervalDays: 7, TotalSteps: 4} - _, err := patchSP(t, spPlan("savingsplans", weekly), `{"enabled":true,"auto_purchase":true}`) + _, err := patchSP(t, spPlan("savingsplans", weekly), `{"enabled":true,"auto_purchase":true}`, false) requireSP400(t, err) } @@ -82,7 +88,7 @@ func TestPatchPlan_SingleStepSavingsPlanAndMultiStepNonSPArmFine(t *testing.T) { "single custom SP (100%)": spPlan("savingsplans", config.RampSchedule{Type: "custom", PercentPerStep: 100, StepIntervalDays: 7, TotalSteps: 1}), } { t.Run(name, func(t *testing.T) { - _, err := patchSP(t, plan, `{"enabled":true,"auto_purchase":true}`) + _, err := patchSP(t, plan, `{"enabled":true,"auto_purchase":true}`, true) require.NoError(t, err) }) } @@ -90,6 +96,6 @@ func TestPatchPlan_SingleStepSavingsPlanAndMultiStepNonSPArmFine(t *testing.T) { func TestPatchPlan_UnarmedMultiStepSavingsPlanIsAllowed(t *testing.T) { weekly := config.RampSchedule{Type: "weekly", PercentPerStep: 25, StepIntervalDays: 7, TotalSteps: 4} - _, err := patchSP(t, spPlan("savingsplans", weekly), `{"name":"renamed"}`) + _, err := patchSP(t, spPlan("savingsplans", weekly), `{"name":"renamed"}`, true) require.NoError(t, err) } diff --git a/internal/api/handler_plans_start_date_test.go b/internal/api/handler_plans_start_date_test.go index c865ef63..f406a382 100644 --- a/internal/api/handler_plans_start_date_test.go +++ b/internal/api/handler_plans_start_date_test.go @@ -19,23 +19,30 @@ const startDateProbePlanID = "11111111-1111-1111-1111-111111111111" // postPlannedPurchaseAt sends POST /api/plans/{id}/purchases through the real // router with the handler clock pinned to now, and returns the response plus // the store mock so callers can check whether any execution row was written. -func postPlannedPurchaseAt(t *testing.T, now time.Time, startDate string) (*events.LambdaFunctionURLResponse, *MockConfigStore) { +// The execution and plan writes are expected only when the date is accepted. +func postPlannedPurchaseAt(t *testing.T, now time.Time, startDate string, accepted bool) (*events.LambdaFunctionURLResponse, *MockConfigStore) { t.Helper() ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - mockAuth.On("ValidateSession", ctx, "test-token").Return(&Session{UserID: "admin-id", Email: "admin@example.com"}, nil) + // The admin X-API-Key authenticates the request, so the bearer session is + // never validated. mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) plan := bareStepAPIPlan() plan.ID = startDateProbePlanID plan.AutoPurchase = false - mockStore.On("GetPurchasePlan", mock.Anything, startDateProbePlanID).Return(plan, nil) - mockStore.On("GetPlanAccounts", mock.Anything, startDateProbePlanID).Return([]config.CloudAccount{{Provider: "azure"}}, nil) - mockStore.On("SavePurchaseExecution", mock.Anything, mock.Anything).Return(nil) - mockStore.On("UpdatePurchasePlan", mock.Anything, mock.Anything).Return(nil) + // Maybe: a malformed date is refused before the plan is read; a + // well-formed past date is refused after it. + mockStore.On("GetPurchasePlan", mock.Anything, startDateProbePlanID).Return(plan, nil).Maybe() + mockStore.On("GetPlanAccounts", mock.Anything, startDateProbePlanID).Return([]config.CloudAccount{{Provider: "azure"}}, nil).Maybe() + if accepted { + mockStore.On("SavePurchaseExecution", mock.Anything, mock.Anything).Return(nil) + mockStore.On("UpdatePurchasePlan", mock.Anything, mock.Anything).Return(nil) + } handler := &Handler{ config: mockStore, @@ -100,7 +107,7 @@ func TestCreatePlannedPurchases_StartDateMustBeInTheFuture(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - resp, store := postPlannedPurchaseAt(t, tc.now, tc.startDate) + resp, store := postPlannedPurchaseAt(t, tc.now, tc.startDate, tc.status == 200) assert.Equal(t, tc.status, resp.StatusCode, "body: %s", resp.Body) if tc.status == 200 { assert.JSONEq(t, `{"created":1}`, resp.Body) diff --git a/internal/api/handler_plans_step_cap_test.go b/internal/api/handler_plans_step_cap_test.go index 123ea146..6704b4fb 100644 --- a/internal/api/handler_plans_step_cap_test.go +++ b/internal/api/handler_plans_step_cap_test.go @@ -57,6 +57,7 @@ func TestUpdatePlan_CapNullKeepsAuthorization(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "cap-token").Return(&Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}, nil) mockAuth.On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything).Return(nil) if status == 403 { diff --git a/internal/api/handler_plans_test.go b/internal/api/handler_plans_test.go index 8802ea53..7d83dbe5 100644 --- a/internal/api/handler_plans_test.go +++ b/internal/api/handler_plans_test.go @@ -19,7 +19,9 @@ import ( func TestHandler_listPlans(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -61,7 +63,9 @@ func TestHandler_listPlans(t *testing.T) { func TestHandler_listPlans_AccountIDsFilter(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -105,7 +109,9 @@ func TestHandler_listPlans_AccountIDsFilter(t *testing.T) { func TestHandler_listPlans_HealthScoreReflectsFailedExecutions(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) @@ -160,6 +166,7 @@ func TestHandler_listPlans_HealthScoreUnknownOnCountsFetchError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"} @@ -199,6 +206,7 @@ func TestHandler_listPlans_HealthUsesExactCountsNotTruncatedPage(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"} @@ -234,6 +242,7 @@ func TestHandler_listPlans_HealthCountsUseTheLookbackWindow(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"} @@ -268,6 +277,7 @@ func TestHandler_listPlans_EmptyListSkipsCountsQuery(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"} @@ -288,7 +298,9 @@ func TestHandler_listPlans_EmptyListSkipsCountsQuery(t *testing.T) { func TestHandler_createPlan(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -330,6 +342,7 @@ func TestHandler_createPlan(t *testing.T) { func TestHandler_createPlan_InvalidBody(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -360,6 +373,7 @@ func TestHandler_createPlan_InvalidBody(t *testing.T) { func TestHandler_createPlan_RejectsEmptyTargetAccounts(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -406,7 +420,9 @@ func TestHandler_createPlan_RejectsEmptyTargetAccounts(t *testing.T) { func TestHandler_createPlan_RollbackDeleteFailureSurfacesOriginalError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -456,6 +472,7 @@ func TestHandler_createPlan_RejectsInvalidTargetAccountUUID(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -482,7 +499,9 @@ func TestHandler_createPlan_RejectsInvalidTargetAccountUUID(t *testing.T) { func TestHandler_getPlan(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -525,6 +544,7 @@ func TestHandler_getPlan_NotFound_MapsTo404(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) @@ -551,7 +571,9 @@ func TestHandler_getPlan_NotFound_MapsTo404(t *testing.T) { func TestHandler_updatePlan(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -597,7 +619,9 @@ func TestHandler_updatePlan(t *testing.T) { func TestHandler_updatePlan_PreservesCompletedRampSteps(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -667,7 +691,9 @@ func TestHandler_updatePlan_PreservesCompletedRampSteps(t *testing.T) { func TestHandler_deletePlan(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -695,6 +721,7 @@ func TestHandler_deletePlan(t *testing.T) { func TestHandler_updatePlan_InvalidBody(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -762,7 +789,9 @@ func createStepsViaTx(ctx context.Context, h *Handler, count int, creator *strin func TestHandler_createPlannedPurchases_StampsCreator(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) const userID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" userSession := &Session{UserID: userID, Email: "u@example.com"} @@ -776,7 +805,8 @@ func TestHandler_createPlannedPurchases_StampsCreator(t *testing.T) { }, } - mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession, nil) + // createStepsViaTx drives the transactional step creation directly, so no + // request session is validated. mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(plan, nil) @@ -813,7 +843,9 @@ func TestHandler_createPlannedPurchases_StampsCreator(t *testing.T) { func TestHandler_createPlannedPurchases_AdminAPIKeyCreatorIsNil(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) apiKeySession := &Session{UserID: apiKeyAdminUserID, Email: "admin-api-key"} @@ -826,7 +858,8 @@ func TestHandler_createPlannedPurchases_AdminAPIKeyCreatorIsNil(t *testing.T) { }, } - mockAuth.On("ValidateSession", ctx, "api-key").Return(apiKeySession, nil) + // createStepsViaTx drives the transactional step creation directly, so no + // request session is validated. mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(plan, nil) @@ -863,11 +896,7 @@ func TestHandler_createPlannedPurchases_MidLoopFailureRollsBack(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) - - adminSession := &Session{ - UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", - Email: "admin@example.com", - } + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) plan := &config.PurchasePlan{ ID: "11111111-1111-1111-1111-111111111111", @@ -878,7 +907,8 @@ func TestHandler_createPlannedPurchases_MidLoopFailureRollsBack(t *testing.T) { }, } - mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + // createStepsViaTx drives the transactional step creation directly, so no + // request session is validated. mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(plan, nil) @@ -947,6 +977,7 @@ func TestHandler_createPlannedPurchases_MidLoopFailureRollsBack(t *testing.T) { func TestHandler_createPlannedPurchases_InvalidCount(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -974,6 +1005,7 @@ func TestHandler_createPlannedPurchases_InvalidCount(t *testing.T) { func TestHandler_createPlannedPurchases_InvalidDate(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1003,6 +1035,7 @@ func TestHandler_createPlannedPurchases_InvalidDate(t *testing.T) { func TestHandler_createPlannedPurchases_InvalidJSON(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1030,7 +1063,9 @@ func TestHandler_createPlannedPurchases_InvalidJSON(t *testing.T) { func TestHandler_createPlannedPurchases_PlanNotFound(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1116,7 +1151,9 @@ func TestCalculateNextExecutionDate(t *testing.T) { func TestHandler_patchPlan_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1161,7 +1198,9 @@ func TestHandler_patchPlan_Success(t *testing.T) { func TestHandler_patchPlan_UpdateMultipleFields(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1225,6 +1264,7 @@ func TestHandler_patchPlan_InvalidUUID(t *testing.T) { func TestHandler_patchPlan_InvalidBody(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1251,7 +1291,9 @@ func TestHandler_patchPlan_InvalidBody(t *testing.T) { func TestHandler_patchPlan_NotFound(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1300,7 +1342,9 @@ func TestHandler_patchPlan_GetPlanDBError_NoPIILeak(t *testing.T) { ) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1346,7 +1390,9 @@ func TestHandler_updatePlan_GetPlanDBError_NoPIILeak(t *testing.T) { ) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1384,7 +1430,9 @@ func TestHandler_updatePlan_GetPlanDBError_NoPIILeak(t *testing.T) { func TestHandler_patchPlan_NilPlan(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1412,7 +1460,9 @@ func TestHandler_patchPlan_NilPlan(t *testing.T) { func TestHandler_patchPlan_EmptyName(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1446,7 +1496,9 @@ func TestHandler_patchPlan_EmptyName(t *testing.T) { func TestHandler_patchPlan_InvalidNotificationDays(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1480,7 +1532,9 @@ func TestHandler_patchPlan_InvalidNotificationDays(t *testing.T) { func TestHandler_patchPlan_NegativeNotificationDays(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1514,17 +1568,25 @@ func TestHandler_patchPlan_NegativeNotificationDays(t *testing.T) { func TestHandler_patchPlan_UpdateError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com", } + // A plan that passes validation once enabled, so the patch reaches the + // store write whose failure this test is about. existingPlan := &config.PurchasePlan{ - ID: "11111111-1111-1111-1111-111111111111", - Name: "Test Plan", - Enabled: false, + ID: "11111111-1111-1111-1111-111111111111", + Name: "Test Plan", + Enabled: false, + NotificationDaysBefore: 3, + Services: map[string]config.ServiceConfig{ + "aws/rds": {Provider: "aws", Service: "rds"}, + }, } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) @@ -1541,7 +1603,11 @@ func TestHandler_patchPlan_UpdateError(t *testing.T) { Body: `{"enabled": true}`, } result, err := handler.patchPlan(ctx, req, "11111111-1111-1111-1111-111111111111") - assert.Error(t, err) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok, "want ClientError, got %T: %v", err, err) + assert.Equal(t, 500, ce.code) + assert.Equal(t, "failed to update plan", ce.message) assert.Nil(t, result) } @@ -1552,6 +1618,7 @@ func TestHandler_createPlan_UnknownAccountReturns404(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{ @@ -1602,6 +1669,7 @@ func TestHandler_createPlan_DBErrorOnCreateReturns500WithLog(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{ @@ -1641,6 +1709,7 @@ func TestHandler_listPlans_StuckRampFetchFailureLeavesHealthUnknown(t *testing.T ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"} @@ -1672,6 +1741,7 @@ func TestHandler_listPlans_StuckRampProducesTheBlockedFactor(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"} @@ -1730,10 +1800,11 @@ func TestHandler_createPlannedPurchases_RefusesAPartlyBoughtStep(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) - session := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "admin-token").Return(session, nil) + // createStepsViaTx drives the transactional step creation directly, so no + // request session is validated. mockAuth.grantAdmin() planID := "11111111-1111-1111-1111-111111111111" @@ -1776,10 +1847,11 @@ func TestHandler_createPlannedPurchases_AllowsAnUnstartedStep(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) - session := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "admin-token").Return(session, nil) + // createStepsViaTx drives the transactional step creation directly, so no + // request session is validated. mockAuth.grantAdmin() planID := "11111111-1111-1111-1111-111111111111" @@ -1819,10 +1891,11 @@ func TestHandler_createPlannedPurchases_FailsClosedWhenTheProbeFails(t *testing. ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) - session := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "admin-token").Return(session, nil) + // createStepsViaTx drives the transactional step creation directly, so no + // request session is validated. mockAuth.grantAdmin() planID := "11111111-1111-1111-1111-111111111111" @@ -1855,6 +1928,7 @@ func TestHandler_planWrites_RefuseZeroTargetCoverage(t *testing.T) { for _, op := range []string{"create", "update"} { t.Run(op, func(t *testing.T) { mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"}, nil) mockAuth.grantAdmin() mockStore := new(MockConfigStore) diff --git a/internal/api/handler_purchase_action_test.go b/internal/api/handler_purchase_action_test.go index 7b54e467..2ebd6662 100644 --- a/internal/api/handler_purchase_action_test.go +++ b/internal/api/handler_purchase_action_test.go @@ -39,6 +39,7 @@ func TestMarketplaceScopeLookupErrors(t *testing.T) { for _, cancel := range []bool{false, true} { for _, accountFailure := range []bool{false, true} { service, store, provider := new(MockAuthService), new(MockConfigStore), new(stubMarketplaceEC2) + t.Cleanup(func() { store.AssertExpectations(t) }) service.On("ValidateSession", mock.Anything, "test-token").Return(&Session{UserID: "owner"}, nil) service.On("HasPermissionAPI", mock.Anything, "owner", auth.ActionSellAny, auth.ResourcePurchases).Return(true, nil) if accountFailure { diff --git a/internal/api/handler_purchases_apikey_scope_test.go b/internal/api/handler_purchases_apikey_scope_test.go index 47b29e41..18b227c9 100644 --- a/internal/api/handler_purchases_apikey_scope_test.go +++ b/internal/api/handler_purchases_apikey_scope_test.go @@ -159,6 +159,7 @@ func ownerHeaders() map[string]string { func serveKeyScope(t *testing.T, rt keyScopeRoute, a *keyScopeAuth, creator string, headers map[string]string) (int, bool) { t.Helper() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) purchaseMgr := new(MockPurchaseManager) store.On("GetExecutionByID", mock.Anything, keyScopeExecID).Return(keyScopeExec(rt.status, creator), nil) store.On("TransitionExecutionStatus", mock.Anything, keyScopeExecID, mock.Anything, mock.Anything, mock.Anything). @@ -247,6 +248,7 @@ func TestPlannedPurchaseRoutes_APIKeyFullScopeProbeErrorRefuses(t *testing.T) { store.On("TransitionExecutionStatus", mock.Anything, keyScopeExecID, mock.Anything, mock.Anything, mock.Anything). Return(nil, errKeyScopeReached).Maybe() m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("HasAPIKeyPermissionAPI", mock.Anything, keyScopeRawKey, tc.entryVerb, auth.ResourcePurchases). Return(keyScopeOwnerID, keyScopeKeyID, true, nil) m.On("GetAllowedAccountsAPI", mock.Anything, keyScopeOwnerID).Return([]string{}, nil) diff --git a/internal/api/handler_purchases_cancel_constraints_test.go b/internal/api/handler_purchases_cancel_constraints_test.go index 778b6eff..c2b8f92c 100644 --- a/internal/api/handler_purchases_cancel_constraints_test.go +++ b/internal/api/handler_purchases_cancel_constraints_test.go @@ -127,6 +127,7 @@ func TestAuthorizeSessionCancel_ConstraintStoreErrorDoesNotFallBack(t *testing.T ctx := context.Background() boom := errors.New("constraint store down") mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("HasPermissionAPI", ctx, revokeConstraintsUser, auth.ActionCancelAny, auth.ResourcePurchases).Return(true, nil) mockAuth.On("HasPermissionForConstraintsAPI", ctx, revokeConstraintsUser, auth.ActionCancelAny, auth.ResourcePurchases, mock.Anything).Return(false, boom) // cancel-own is deliberately unregistered: querying it fails the test. @@ -192,8 +193,10 @@ func cancelRouteExec(status string) *config.PurchaseExecution { // cancelRouteHandler answers authorization from the real matcher for a caller // whose session email is the execution's contact approver. -func cancelRouteHandler(exec *config.PurchaseExecution, perms ...auth.Permission) (*Handler, *MockConfigStore) { +func cancelRouteHandler(t *testing.T, exec *config.PurchaseExecution, perms ...auth.Permission) (*Handler, *MockConfigStore) { + t.Helper() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { return &config.CloudAccount{ID: id, Name: id, ContactEmail: "contact@example.com"}, nil } @@ -208,6 +211,7 @@ func cancelRouteHandler(exec *config.PurchaseExecution, perms ...auth.Permission store.On("SetCancelledBy", mock.Anything, cancelExecID, mock.Anything).Return(nil).Maybe() m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{UserID: revokeConstraintsUser, Email: "contact@example.com"}, nil) m.On("ValidateCSRFToken", mock.Anything, "sess-tok", "").Return(nil).Maybe() m.grantPermissions(perms) @@ -223,7 +227,7 @@ func TestCancelPurchase_ConstrainedCancelAnyIsDeniedWithAndWithoutToken(t *testi for _, token := range []string{"", tokenErrRawToken} { t.Run("token="+token, func(t *testing.T) { t.Parallel() - h, store := cancelRouteHandler(cancelRouteExec("pending"), awsOnly(auth.ActionCancelAny)) + h, store := cancelRouteHandler(t, cancelRouteExec("pending"), awsOnly(auth.ActionCancelAny)) _, err := h.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, token) requireRevokeForbidden(t, err, constraintDenied) store.AssertNotCalled(t, "CancelExecutionAtomic", mock.Anything, mock.Anything, mock.Anything, mock.Anything) @@ -234,7 +238,7 @@ func TestCancelPurchase_ConstrainedCancelAnyIsDeniedWithAndWithoutToken(t *testi func TestCancelPurchase_ContactApproverWithoutCancelVerbStillUsesToken(t *testing.T) { t.Parallel() - h, store := cancelRouteHandler(cancelRouteExec("pending")) + h, store := cancelRouteHandler(t, cancelRouteExec("pending")) res, err := h.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, tokenErrRawToken) require.NoError(t, err) assert.Equal(t, "canceled", res.(map[string]string)["status"]) @@ -243,7 +247,7 @@ func TestCancelPurchase_ContactApproverWithoutCancelVerbStillUsesToken(t *testin func TestCancelPurchase_InScopeCancelAnyStillCancels(t *testing.T) { t.Parallel() - h, _ := cancelRouteHandler(cancelRouteExec("pending"), revokePerm(auth.ActionCancelAny, &auth.PermissionConstraints{Providers: []string{"azure"}})) + h, _ := cancelRouteHandler(t, cancelRouteExec("pending"), revokePerm(auth.ActionCancelAny, &auth.PermissionConstraints{Providers: []string{"azure"}})) res, err := h.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "") require.NoError(t, err) assert.Equal(t, "canceled", res.(map[string]string)["status"]) @@ -260,21 +264,21 @@ func TestRevokeViaEmailToken_SessionBranchEnforcesCancelConstraints(t *testing.T ctx := context.Background() t.Run("constrained out is denied and nothing is written", func(t *testing.T) { t.Parallel() - h, store := cancelRouteHandler(cancelRouteExec("completed"), awsOnly(auth.ActionCancelAny)) + h, store := cancelRouteHandler(t, cancelRouteExec("completed"), awsOnly(auth.ActionCancelAny)) _, err := h.revokeViaEmailToken(ctx, revokeTokenReq(""), cancelExecID, "") requireRevokeForbidden(t, err, constraintDenied) store.AssertNotCalled(t, "TransitionExecutionStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) }) t.Run("constrained out is terminal even with a token", func(t *testing.T) { t.Parallel() - h, store := cancelRouteHandler(cancelRouteExec("completed"), awsOnly(auth.ActionCancelAny)) + h, store := cancelRouteHandler(t, cancelRouteExec("completed"), awsOnly(auth.ActionCancelAny)) _, err := h.revokeViaEmailToken(ctx, revokeTokenReq(tokenErrRawToken), cancelExecID, tokenErrRawToken) requireRevokeForbidden(t, err, constraintDenied) store.AssertNotCalled(t, "TransitionExecutionStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) }) t.Run("constrained in proceeds", func(t *testing.T) { t.Parallel() - h, store := cancelRouteHandler(cancelRouteExec("completed"), revokePerm(auth.ActionCancelAny, &auth.PermissionConstraints{Providers: []string{"azure"}})) + h, store := cancelRouteHandler(t, cancelRouteExec("completed"), revokePerm(auth.ActionCancelAny, &auth.PermissionConstraints{Providers: []string{"azure"}})) _, err := h.revokeViaEmailToken(ctx, revokeTokenReq(""), cancelExecID, "") require.NoError(t, err) store.AssertCalled(t, "TransitionExecutionStatus", mock.Anything, cancelExecID, mock.Anything, "revocation_requested", mock.Anything) @@ -293,7 +297,7 @@ func TestDeletePlannedPurchase_CancelConstraints(t *testing.T) { run := func(creatorID *string, perms ...auth.Permission) (*DeletePlannedPurchaseResponse, *MockConfigStore, error) { exec := cancelRouteExec("pending") exec.CreatedByUserID = creatorID - h, store := cancelRouteHandler(exec, perms...) + h, store := cancelRouteHandler(t, exec, perms...) resp, err := h.deletePlannedPurchase(ctx, deletePlannedReq(), cancelExecID) return resp, store, err } @@ -358,17 +362,20 @@ func TestDeletePlannedPurchase_CancelConstraints(t *testing.T) { // plannedKeyHandler models a delete:purchases user-API-key request whose owner // created the execution and holds cancel-own. keyCancelOwn is what the KEY holds // (probe), keyInScope what its cancel-own Constraints allow for the execution. -func plannedKeyHandler(keyCancelOwn, keyInScope bool) (*Handler, *MockConfigStore) { +func plannedKeyHandler(t *testing.T, keyCancelOwn, keyInScope bool) (*Handler, *MockConfigStore) { + t.Helper() ctx := mock.Anything owner, keyID := revokeConstraintsUser, "key-1" exec := cancelRouteExec("pending") exec.CreatedByUserID = &owner store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionByID", ctx, cancelExecID).Return(exec, nil) store.On("TransitionExecutionStatus", ctx, cancelExecID, mock.Anything, "canceled", mock.Anything). Return(&config.PurchaseExecution{ExecutionID: cancelExecID, Status: "canceled"}, nil).Maybe() store.On("DeleteSuppressionsByExecutionTx", ctx, ctx, cancelExecID).Return(nil).Maybe() m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("HasAPIKeyPermissionAPI", ctx, "user-key-value", auth.ActionDelete, auth.ResourcePurchases).Return(owner, keyID, true, nil) m.On("GetAllowedAccountsAPI", ctx, owner).Return([]string{}, nil) m.On("HasPermissionAPI", ctx, owner, mock.Anything, auth.ResourcePurchases).Return(false, nil).Maybe() @@ -386,21 +393,21 @@ func TestDeletePlannedPurchase_APIKeyCancelOwnFollowsTheKey(t *testing.T) { req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"x-api-key": "user-key-value"}} t.Run("delete-only key keeps working although the owner holds cancel-own", func(t *testing.T) { t.Parallel() - h, _ := plannedKeyHandler(false, false) + h, _ := plannedKeyHandler(t, false, false) resp, err := h.deletePlannedPurchase(context.Background(), req, cancelExecID) require.NoError(t, err) assert.Equal(t, "canceled", resp.Status) }) t.Run("key with constrained cancel-own out of scope is denied", func(t *testing.T) { t.Parallel() - h, store := plannedKeyHandler(true, false) + h, store := plannedKeyHandler(t, true, false) _, err := h.deletePlannedPurchase(context.Background(), req, cancelExecID) requireRevokeForbidden(t, err, constraintDenied) store.AssertNotCalled(t, "TransitionExecutionStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) }) t.Run("key with in-scope cancel-own is allowed", func(t *testing.T) { t.Parallel() - h, _ := plannedKeyHandler(true, true) + h, _ := plannedKeyHandler(t, true, true) _, err := h.deletePlannedPurchase(context.Background(), req, cancelExecID) require.NoError(t, err) }) @@ -414,8 +421,10 @@ func TestDeletePlannedPurchase_ConstraintStoreErrorDoesNotFallBack(t *testing.T) owner := revokeConstraintsUser exec.CreatedByUserID = &owner store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionByID", mock.Anything, cancelExecID).Return(exec, nil) m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{UserID: owner}, nil) m.On("GetAllowedAccountsAPI", mock.Anything, owner).Return([]string{}, nil) m.On("HasPermissionAPI", mock.Anything, owner, auth.ActionCancelAny, auth.ResourcePurchases).Return(true, nil) @@ -435,8 +444,10 @@ func TestDeletePlannedPurchase_APIKeyVerbProbeErrorRefuses(t *testing.T) { exec := cancelRouteExec("pending") exec.CreatedByUserID = &owner store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionByID", mock.Anything, cancelExecID).Return(exec, nil) m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("HasAPIKeyPermissionAPI", mock.Anything, "user-key-value", auth.ActionDelete, auth.ResourcePurchases).Return(owner, keyID, true, nil) m.On("GetAllowedAccountsAPI", mock.Anything, owner).Return([]string{}, nil) m.On("HasPermissionAPI", mock.Anything, owner, mock.Anything, auth.ResourcePurchases).Return(false, nil).Maybe() diff --git a/internal/api/handler_purchases_delay_token_test.go b/internal/api/handler_purchases_delay_token_test.go index 3f35efaf..e172d9fd 100644 --- a/internal/api/handler_purchases_delay_token_test.go +++ b/internal/api/handler_purchases_delay_token_test.go @@ -21,6 +21,7 @@ func delayTokenHandler(t *testing.T, exec *config.PurchaseExecution) *Handler { t.Helper() h := tokenErrHandler(t, exec, nil) store := h.config.(*MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) kept := store.ExpectedCalls[:0] for _, c := range store.ExpectedCalls { if c.Method != "GetGlobalConfig" { @@ -122,6 +123,7 @@ func TestHandleRequest_ApproveViaToken_DelayConfigured_ReplayOfScheduledIs409(t exec := &config.PurchaseExecution{Status: "scheduled", ApprovalToken: config.HashApprovalToken(tokenErrRawToken), ApprovalTokenExpiresAt: &future} h := delayTokenHandler(t, exec) store := h.config.(*MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("TransitionExecutionStatusTokenPinned", mock.Anything, tokenErrExecID, []string{"pending", "notified"}, "scheduled", mock.Anything, tokenErrRawToken). Return(nil, config.ErrExecutionNotInExpectedStatus) diff --git a/internal/api/handler_purchases_finalize_test.go b/internal/api/handler_purchases_finalize_test.go index 4d179831..8edcbbec 100644 --- a/internal/api/handler_purchases_finalize_test.go +++ b/internal/api/handler_purchases_finalize_test.go @@ -110,6 +110,7 @@ func TestFinalizePurchaseStatus_WriteErrorSurfaces(t *testing.T) { // save failure too, not a lost guard, so it gets the same 500. func TestFinalizePurchaseStatus_MissingRowSurfaces(t *testing.T) { store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("SaveExecutionIfStatus", mock.Anything, mock.Anything, []string{"pending"}). Return(config.ErrNotFound).Once() h := &Handler{config: store} @@ -163,6 +164,7 @@ func TestExecutePurchase_EmailFailureNotRecorded_Returns500(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) authSvc := new(MockAuthService) + t.Cleanup(func() { authSvc.AssertExpectations(t) }) authSvc.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"}, nil) authSvc.grantAdminPurchaser() var inserted *config.PurchaseExecution @@ -202,7 +204,7 @@ func TestRetryPurchase_EmailFailureNotRecorded_Returns500(t *testing.T) { CreatedByUserID: &creator, Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100}}, } - h, store, _ := buildSessionRetryHandler(failedExec, &Session{UserID: retryCallerID, Email: "admin@example.com"}, true, false) + h, store, _ := buildSessionRetryHandler(t, failedExec, &Session{UserID: retryCallerID, Email: "admin@example.com"}, true, false) var successor *config.PurchaseExecution store.On("SavePurchaseExecution", mock.Anything, mock.AnythingOfType("*config.PurchaseExecution")). Run(func(args mock.Arguments) { successor = args.Get(1).(*config.PurchaseExecution) }). diff --git a/internal/api/handler_purchases_grace_config_test.go b/internal/api/handler_purchases_grace_config_test.go index 5467bb2f..03eb912a 100644 --- a/internal/api/handler_purchases_grace_config_test.go +++ b/internal/api/handler_purchases_grace_config_test.go @@ -23,7 +23,9 @@ import ( func TestHandler_executePurchase_GlobalConfigErrorFailsBeforePersist(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) session := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(session, nil) @@ -50,7 +52,7 @@ func TestHandler_executePurchase_GlobalConfigErrorFailsBeforePersist(t *testing. } func TestHandler_retryPurchase_GlobalConfigErrorFailsBeforePersist(t *testing.T) { - _, handler, mockConfig := retryDedupeFixture(t) + _, handler, mockConfig := retryDedupeFixture(t, false) for _, c := range mockConfig.ExpectedCalls { if c.Method == "GetGlobalConfig" { c.Unset() diff --git a/internal/api/handler_purchases_notified_test.go b/internal/api/handler_purchases_notified_test.go index 20447376..561e1f35 100644 --- a/internal/api/handler_purchases_notified_test.go +++ b/internal/api/handler_purchases_notified_test.go @@ -17,9 +17,12 @@ import ( const notifiedExecID = "11111111-1111-1111-1111-111111111111" -func notifiedAdminHandler(ctx context.Context) (*Handler, *MockConfigStore, *events.LambdaFunctionURLRequest) { +func notifiedAdminHandler(t *testing.T, ctx context.Context) (*Handler, *MockConfigStore, *events.LambdaFunctionURLRequest) { + t.Helper() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) auth := new(MockAuthService) + t.Cleanup(func() { auth.AssertExpectations(t) }) auth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"}, nil) auth.grantAdmin() req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"Authorization": "Bearer admin-token"}} @@ -28,7 +31,7 @@ func notifiedAdminHandler(ctx context.Context) (*Handler, *MockConfigStore, *eve func TestPausePlannedPurchase_AcceptsNotifiedRow(t *testing.T) { ctx := context.Background() - handler, store, req := notifiedAdminHandler(ctx) + handler, store, req := notifiedAdminHandler(t, ctx) store.On("GetExecutionByID", ctx, notifiedExecID).Return(&config.PurchaseExecution{ExecutionID: notifiedExecID, Status: "notified"}, nil) store.On("TransitionExecutionStatus", ctx, notifiedExecID, []string{"pending", "notified"}, "paused", mock.Anything). Return(&config.PurchaseExecution{ExecutionID: notifiedExecID, Status: "paused"}, nil) @@ -53,7 +56,7 @@ func TestResumePlannedPurchase_PastScheduledDate(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - handler, store, req := notifiedAdminHandler(ctx) + handler, store, req := notifiedAdminHandler(t, ctx) row := &config.PurchaseExecution{ExecutionID: notifiedExecID, PlanID: "plan-1", Status: "pending", Recommendations: tc.recs, ScheduledDate: tc.date} store.On("GetExecutionByID", ctx, notifiedExecID).Return(row, nil).Maybe() store.On("TransitionExecutionStatus", ctx, notifiedExecID, []string{"paused"}, "pending", mock.Anything).Return(row, nil) @@ -80,7 +83,11 @@ func TestResumePlannedPurchase_PastScheduledDate(t *testing.T) { func TestCancelOrRecoverExecution_ReleasesSuppressionsOfNotifiedRow(t *testing.T) { ctx := context.Background() - handler, store, _ := notifiedAdminHandler(ctx) + // cancelOrRecoverExecution is called directly, after authorization, so no + // session is involved: a store-only handler. + store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) + handler := &Handler{config: store} store.On("TransitionExecutionStatus", ctx, notifiedExecID, []string{"pending", "notified", "paused"}, "canceled", mock.Anything). Return(&config.PurchaseExecution{ExecutionID: notifiedExecID, Status: "canceled"}, nil) store.On("DeleteSuppressionsByExecution", ctx, notifiedExecID).Return(nil) @@ -106,7 +113,7 @@ func TestResumePlannedPurchase_RefusesWithoutVetoWindow(t *testing.T) { {"empty row past date, 0 days", nil, time.Now().Add(-time.Hour), false}, } { t.Run(tc.name, func(t *testing.T) { - handler, store, req := notifiedAdminHandler(ctx) + handler, store, req := notifiedAdminHandler(t, ctx) row := &config.PurchaseExecution{ExecutionID: notifiedExecID, PlanID: "plan-1", Status: "paused", Recommendations: tc.recs, ScheduledDate: tc.date} store.On("GetExecutionByID", ctx, notifiedExecID).Return(row, nil) store.On("GetPurchasePlan", ctx, "plan-1").Return(&config.PurchasePlan{ID: "plan-1", NotificationDaysBefore: 0}, nil).Maybe() diff --git a/internal/api/handler_purchases_owner_key_probe_helpers_test.go b/internal/api/handler_purchases_owner_key_probe_helpers_test.go index 33595533..4426bf01 100644 --- a/internal/api/handler_purchases_owner_key_probe_helpers_test.go +++ b/internal/api/handler_purchases_owner_key_probe_helpers_test.go @@ -146,6 +146,7 @@ func TestPurchaseAuthzHelpers_APIKeyVerbProbeErrorRefuses(t *testing.T) { t.Run(tc.name+"/"+failing, func(t *testing.T) { boom := errors.New("key store down") m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) if failing == tc.ownVerb { m.On("HasAPIKeyPermissionForConstraintsAPI", mock.Anything, okpKeyID, okpOwnerID, tc.anyVerb, auth.ResourcePurchases, probe). Return(false, nil) diff --git a/internal/api/handler_purchases_owner_key_probe_test.go b/internal/api/handler_purchases_owner_key_probe_test.go index 43495e7a..449b07b0 100644 --- a/internal/api/handler_purchases_owner_key_probe_test.go +++ b/internal/api/handler_purchases_owner_key_probe_test.go @@ -135,6 +135,7 @@ func okpRequest(method, path, body string, headers map[string]string) *events.La func serveOKPExecute(t *testing.T, a *okpAuth, headers map[string]string) (int, bool) { t.Helper() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("SavePurchaseExecution", mock.Anything, mock.AnythingOfType("*config.PurchaseExecution")).Return(nil).Maybe() store.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{}, nil).Maybe() store.On("GetPendingExecutions", mock.Anything).Return([]config.PurchaseExecution{}, nil).Maybe() @@ -191,6 +192,7 @@ func TestExecuteDirect_FullScopeFollowsTheAPIKey(t *testing.T) { func TestExecuteDirect_APIKeyFullScopeProbeErrorRefuses(t *testing.T) { boom := errors.New("key store down") store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("SavePurchaseExecution", mock.Anything, mock.AnythingOfType("*config.PurchaseExecution")).Return(nil) store.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{}, nil) store.On("GetPendingExecutions", mock.Anything).Return([]config.PurchaseExecution{}, nil).Maybe() @@ -202,6 +204,7 @@ func TestExecuteDirect_APIKeyFullScopeProbeErrorRefuses(t *testing.T) { Return(nil, "", errOKPReached).Maybe() m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("HasAPIKeyPermissionAPI", mock.Anything, okpRawKey, auth.ActionExecute, auth.ResourcePurchases). Return(okpOwnerID, okpKeyID, true, nil) m.On("GetAllowedAccountsAPI", mock.Anything, okpOwnerID).Return([]string{}, nil) diff --git a/internal/api/handler_purchases_pause_resume_constraints_test.go b/internal/api/handler_purchases_pause_resume_constraints_test.go index 62f6d873..dfe54417 100644 --- a/internal/api/handler_purchases_pause_resume_constraints_test.go +++ b/internal/api/handler_purchases_pause_resume_constraints_test.go @@ -35,14 +35,15 @@ var pauseResumeOps = []pauseResumeOp{ }, "paused"}, } -func runPauseResume(op pauseResumeOp, creator *string, recs []config.RecommendationRecord, perms ...auth.Permission) (*StatusResponse, *MockConfigStore, error) { +func runPauseResume(t *testing.T, op pauseResumeOp, creator *string, recs []config.RecommendationRecord, perms ...auth.Permission) (*StatusResponse, *MockConfigStore, error) { + t.Helper() exec := cancelRouteExec(op.status) exec.CreatedByUserID = creator exec.ScheduledDate = time.Now().Add(24 * time.Hour) if recs != nil { exec.Recommendations = recs } - h, store := cancelRouteHandler(exec, append(perms, revokePerm(auth.ActionUpdate, nil))...) + h, store := cancelRouteHandler(t, exec, append(perms, revokePerm(auth.ActionUpdate, nil))...) resp, err := op.run(h) return resp, store, err } @@ -72,7 +73,7 @@ func TestPauseResumePlanned_UpdateAnyConstraintDimensions(t *testing.T) { for dim, c := range denying { t.Run(op.name+"/deny/"+dim, func(t *testing.T) { t.Parallel() - _, store, err := runPauseResume(op, &other, nil, revokePerm(auth.ActionUpdateAny, c)) + _, store, err := runPauseResume(t, op, &other, nil, revokePerm(auth.ActionUpdateAny, c)) requireRevokeForbidden(t, err, constraintDenied) requireNotTransitioned(t, store) }) @@ -80,7 +81,7 @@ func TestPauseResumePlanned_UpdateAnyConstraintDimensions(t *testing.T) { for dim, c := range allowing { t.Run(op.name+"/allow/"+dim, func(t *testing.T) { t.Parallel() - resp, _, err := runPauseResume(op, &other, nil, revokePerm(auth.ActionUpdateAny, c)) + resp, _, err := runPauseResume(t, op, &other, nil, revokePerm(auth.ActionUpdateAny, c)) require.NoError(t, err) require.NotNil(t, resp) }) @@ -96,19 +97,19 @@ func TestPauseResumePlanned_UpdateAnyStrictScope(t *testing.T) { t.Run(op.name+"/every recommendation must be in scope", func(t *testing.T) { t.Parallel() recs := []config.RecommendationRecord{azureRec(), recFor("acct-uuid", "aws", "ec2", "us-east-1")} - _, store, err := runPauseResume(op, &other, recs, azureOnly) + _, store, err := runPauseResume(t, op, &other, recs, azureOnly) requireRevokeForbidden(t, err, constraintDenied) requireNotTransitioned(t, store) }) t.Run(op.name+"/no recommendations is refused by a bounded grant", func(t *testing.T) { t.Parallel() - _, store, err := runPauseResume(op, &other, []config.RecommendationRecord{}, azureOnly) + _, store, err := runPauseResume(t, op, &other, []config.RecommendationRecord{}, azureOnly) requireRevokeForbidden(t, err, constraintDenied) requireNotTransitioned(t, store) }) t.Run(op.name+"/admin is unaffected", func(t *testing.T) { t.Parallel() - resp, _, err := runPauseResume(op, &other, nil, auth.Permission{Action: auth.ActionAdmin, Resource: auth.ResourceAll}) + resp, _, err := runPauseResume(t, op, &other, nil, auth.Permission{Action: auth.ActionAdmin, Resource: auth.ResourceAll}) require.NoError(t, err) assert.NotNil(t, resp) }) @@ -124,13 +125,13 @@ func TestPauseResumePlanned_UpdateAnyDenialFallsBackToCreatorOnly(t *testing.T) for _, op := range pauseResumeOps { t.Run(op.name+"/creator allowed", func(t *testing.T) { t.Parallel() - resp, _, err := runPauseResume(op, &creator, nil, updateOut) + resp, _, err := runPauseResume(t, op, &creator, nil, updateOut) require.NoError(t, err) assert.NotNil(t, resp) }) t.Run(op.name+"/non-creator denied", func(t *testing.T) { t.Parallel() - _, store, err := runPauseResume(op, &other, nil, updateOut) + _, store, err := runPauseResume(t, op, &other, nil, updateOut) requireRevokeForbidden(t, err, constraintDenied) requireNotTransitioned(t, store) }) diff --git a/internal/api/handler_purchases_pause_resume_key_test.go b/internal/api/handler_purchases_pause_resume_key_test.go index 2d4ed8a1..cb94fbe9 100644 --- a/internal/api/handler_purchases_pause_resume_key_test.go +++ b/internal/api/handler_purchases_pause_resume_key_test.go @@ -51,10 +51,12 @@ func TestPauseResumePlanned_UpdateAnyConstraintErrorDoesNotFallBack(t *testing.T exec := cancelRouteExec(op.status) exec.CreatedByUserID = &owner store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionByID", mock.Anything, cancelExecID).Return(exec, nil) store.On("TransitionExecutionStatus", mock.Anything, cancelExecID, mock.Anything, mock.Anything, mock.Anything). Return(nil, errKeyScopeReached).Maybe() m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{UserID: owner}, nil) m.On("GetAllowedAccountsAPI", mock.Anything, owner).Return([]string{}, nil) m.On("HasPermissionAPI", mock.Anything, owner, mock.Anything, auth.ResourcePurchases).Return(true, nil) diff --git a/internal/api/handler_purchases_plan_step_approve_test.go b/internal/api/handler_purchases_plan_step_approve_test.go index 72d3bbab..52ae0412 100644 --- a/internal/api/handler_purchases_plan_step_approve_test.go +++ b/internal/api/handler_purchases_plan_step_approve_test.go @@ -78,22 +78,22 @@ type tokenApproveFixture struct { handler *Handler purchase *MockPurchaseManager req *events.LambdaFunctionURLRequest + wired bool } // newTokenApprove wires an approver who is the account's contact_email and // holds perms (a nil list holds no approve verb). func newTokenApprove(t *testing.T, perms []auth.Permission, plan *config.PurchasePlan) *tokenApproveFixture { t.Helper() - ctx := context.Background() mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { return &config.CloudAccount{ID: id, ContactEmail: stepContact}, nil } mockConfig.GetPurchasePlanFn = func(context.Context, string) (*config.PurchasePlan, error) { return plan, nil } - mockConfig.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.grantPermissions(perms) - mockAuth.On("ValidateSession", ctx, "tok").Return(&Session{UserID: stepUser, Email: stepContact}, nil) mp := new(MockPurchaseManager) // A reached ApproveExecution proves the approval was not refused up front; // it answers with a conflict so the test needs no email or re-fetch wiring. @@ -105,8 +105,18 @@ func newTokenApprove(t *testing.T, perms []auth.Permission, plan *config.Purchas } } +// approve drives the token approval. Only this path resolves the session and +// reads the global config, so their expectations are registered here rather +// than in newTokenApprove, whose callers may test approveConstraintsForSession +// directly. func (f *tokenApproveFixture) approve(exec *config.PurchaseExecution) error { - _, err := f.handler.approveViaToken(context.Background(), f.req, exec, "valid-token") + ctx := context.Background() + if !f.wired { + f.wired = true + f.handler.config.(*MockConfigStore).On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) + f.handler.auth.(*MockAuthService).On("ValidateSession", ctx, "tok").Return(&Session{UserID: stepUser, Email: stepContact}, nil) + } + _, err := f.handler.approveViaToken(ctx, f.req, exec, "valid-token") return err } diff --git a/internal/api/handler_purchases_planned_update_any_test.go b/internal/api/handler_purchases_planned_update_any_test.go index 4506a232..7188ed7b 100644 --- a/internal/api/handler_purchases_planned_update_any_test.go +++ b/internal/api/handler_purchases_planned_update_any_test.go @@ -25,13 +25,14 @@ const plannedOtherUser = "someone-else" // runUpdateAnyDelete drives deletePlannedPurchase for a row created by creator // (nil = legacy NULL creator), optionally replacing its recommendations. -func runUpdateAnyDelete(creator *string, recs []config.RecommendationRecord, perms ...auth.Permission) (*DeletePlannedPurchaseResponse, *MockConfigStore, error) { +func runUpdateAnyDelete(t *testing.T, creator *string, recs []config.RecommendationRecord, perms ...auth.Permission) (*DeletePlannedPurchaseResponse, *MockConfigStore, error) { + t.Helper() exec := cancelRouteExec("pending") exec.CreatedByUserID = creator if recs != nil { exec.Recommendations = recs } - h, store := cancelRouteHandler(exec, perms...) + h, store := cancelRouteHandler(t, exec, perms...) resp, err := h.deletePlannedPurchase(context.Background(), deletePlannedReq(), cancelExecID) return resp, store, err } @@ -67,7 +68,7 @@ func TestDeletePlannedPurchase_UpdateAnyConstraintDimensions(t *testing.T) { for dim, c := range denying { t.Run("deny/"+dim, func(t *testing.T) { t.Parallel() - _, store, err := runUpdateAnyDelete(&other, nil, revokePerm(auth.ActionUpdateAny, c)) + _, store, err := runUpdateAnyDelete(t, &other, nil, revokePerm(auth.ActionUpdateAny, c)) requireRevokeForbidden(t, err, constraintDenied) requireNotCanceled(t, store) }) @@ -75,7 +76,7 @@ func TestDeletePlannedPurchase_UpdateAnyConstraintDimensions(t *testing.T) { for dim, c := range allowing { t.Run("allow/"+dim, func(t *testing.T) { t.Parallel() - resp, _, err := runUpdateAnyDelete(&other, nil, revokePerm(auth.ActionUpdateAny, c)) + resp, _, err := runUpdateAnyDelete(t, &other, nil, revokePerm(auth.ActionUpdateAny, c)) requireCanceled(t, resp, err) }) } @@ -88,26 +89,26 @@ func TestDeletePlannedPurchase_UpdateAnyStrictScopeAndRecommendations(t *testing t.Run("unknown region is refused by a region-bounded grant", func(t *testing.T) { t.Parallel() recs := []config.RecommendationRecord{recFor("acct-1", "azure", "compute", "")} - _, store, err := runUpdateAnyDelete(&other, recs, revokePerm(auth.ActionUpdateAny, &auth.PermissionConstraints{Regions: []string{"westeurope"}})) + _, store, err := runUpdateAnyDelete(t, &other, recs, revokePerm(auth.ActionUpdateAny, &auth.PermissionConstraints{Regions: []string{"westeurope"}})) requireRevokeForbidden(t, err, constraintDenied) requireNotCanceled(t, store) }) t.Run("every recommendation must be in scope", func(t *testing.T) { t.Parallel() recs := []config.RecommendationRecord{azureRec(), recFor("acct-uuid", "aws", "ec2", "us-east-1")} - _, store, err := runUpdateAnyDelete(&other, recs, azureOnly) + _, store, err := runUpdateAnyDelete(t, &other, recs, azureOnly) requireRevokeForbidden(t, err, constraintDenied) requireNotCanceled(t, store) }) t.Run("a row with no recommendations is refused by a bounded grant", func(t *testing.T) { t.Parallel() - _, store, err := runUpdateAnyDelete(&other, []config.RecommendationRecord{}, azureOnly) + _, store, err := runUpdateAnyDelete(t, &other, []config.RecommendationRecord{}, azureOnly) requireRevokeForbidden(t, err, constraintDenied) requireNotCanceled(t, store) }) t.Run("a row with no recommendations is allowed by an unbounded grant", func(t *testing.T) { t.Parallel() - resp, _, err := runUpdateAnyDelete(&other, []config.RecommendationRecord{}, revokePerm(auth.ActionUpdateAny, nil)) + resp, _, err := runUpdateAnyDelete(t, &other, []config.RecommendationRecord{}, revokePerm(auth.ActionUpdateAny, nil)) requireCanceled(t, resp, err) }) } @@ -132,7 +133,7 @@ func TestDeletePlannedPurchase_UpdateAnyFallback(t *testing.T) { for name, tc := range allowed { t.Run("falls back "+name, func(t *testing.T) { t.Parallel() - resp, _, err := runUpdateAnyDelete(tc.creator, nil, tc.perms...) + resp, _, err := runUpdateAnyDelete(t, tc.creator, nil, tc.perms...) requireCanceled(t, resp, err) }) } @@ -148,7 +149,7 @@ func TestDeletePlannedPurchase_UpdateAnyFallback(t *testing.T) { for name, tc := range denied { t.Run("denies "+name, func(t *testing.T) { t.Parallel() - _, store, err := runUpdateAnyDelete(tc.creator, nil, tc.perms...) + _, store, err := runUpdateAnyDelete(t, tc.creator, nil, tc.perms...) requireRevokeForbidden(t, err, constraintDenied) requireNotCanceled(t, store) }) @@ -160,13 +161,13 @@ func TestDeletePlannedPurchase_UpdateAnyAdministratorsGroup(t *testing.T) { other := plannedOtherUser t.Run("unbounded admin still cancels", func(t *testing.T) { t.Parallel() - resp, _, err := runUpdateAnyDelete(&other, nil, auth.Permission{Action: auth.ActionAdmin, Resource: auth.ResourceAll}) + resp, _, err := runUpdateAnyDelete(t, &other, nil, auth.Permission{Action: auth.ActionAdmin, Resource: auth.ResourceAll}) requireCanceled(t, resp, err) }) t.Run("admin bounded to another provider is refused", func(t *testing.T) { t.Parallel() admin := auth.Permission{Action: auth.ActionAdmin, Resource: auth.ResourceAll, Constraints: &auth.PermissionConstraints{Providers: []string{"aws"}}} - _, store, err := runUpdateAnyDelete(&other, nil, admin) + _, store, err := runUpdateAnyDelete(t, &other, nil, admin) requireRevokeForbidden(t, err, constraintDenied) requireNotCanceled(t, store) }) @@ -181,11 +182,13 @@ func TestDeletePlannedPurchase_UpdateAnyConstraintErrorDoesNotFallBack(t *testin exec := cancelRouteExec("pending") exec.CreatedByUserID = &owner store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionByID", mock.Anything, cancelExecID).Return(exec, nil) // Reached only if the error were swallowed; requireNotCanceled reports it. store.On("TransitionExecutionStatus", mock.Anything, cancelExecID, mock.Anything, mock.Anything, mock.Anything). Return(nil, errKeyScopeReached).Maybe() m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{UserID: owner}, nil) m.On("GetAllowedAccountsAPI", mock.Anything, owner).Return([]string{}, nil) m.On("HasPermissionAPI", mock.Anything, owner, mock.Anything, auth.ResourcePurchases).Return(true, nil) diff --git a/internal/api/handler_purchases_retry_dedupe_test.go b/internal/api/handler_purchases_retry_dedupe_test.go index 6bc58e7c..eb932198 100644 --- a/internal/api/handler_purchases_retry_dedupe_test.go +++ b/internal/api/handler_purchases_retry_dedupe_test.go @@ -14,17 +14,21 @@ import ( ) // retryDedupeFixture is a failed execution owned by the retrying caller plus -// a handler whose store accepts the successor insert and the link CAS. -func retryDedupeFixture(t *testing.T) (*config.PurchaseExecution, *Handler, *MockConfigStore) { +// a handler whose store expects the successor insert and the link CAS when +// persists is true. A retry that must fail before any write passes false, so +// an attempted write hits an unregistered method and fails the test. +func retryDedupeFixture(t *testing.T, persists bool) (*config.PurchaseExecution, *Handler, *MockConfigStore) { t.Helper() creator := retryCallerID failed := &config.PurchaseExecution{ ExecutionID: retryExecID, Status: "failed", CreatedByUserID: &creator, CapacityPercent: 100, Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, Count: 1, UpfrontCost: 100}}, } - handler, mockConfig, _ := buildSessionRetryHandler(failed, &Session{UserID: retryCallerID}, false, true) - mockConfig.On("SavePurchaseExecution", mock.Anything, mock.AnythingOfType("*config.PurchaseExecution")).Return(nil) - mockConfig.On("LinkRetryExecutionAtomic", mock.Anything, mock.Anything, failed.ExecutionID, mock.AnythingOfType("string")).Return(true, nil) + handler, mockConfig, _ := buildSessionRetryHandler(t, failed, &Session{UserID: retryCallerID}, false, true) + if persists { + mockConfig.On("SavePurchaseExecution", mock.Anything, mock.AnythingOfType("*config.PurchaseExecution")).Return(nil) + mockConfig.On("LinkRetryExecutionAtomic", mock.Anything, mock.Anything, failed.ExecutionID, mock.AnythingOfType("string")).Return(true, nil) + } return failed, handler, mockConfig } @@ -45,7 +49,7 @@ func liveSubmit(id string, recs []config.RecommendationRecord) config.PurchaseEx // fingerprint, writing no suppressions and sending no approval email. The // real lock and race are covered by TestRetrySuccessorRacesFreshSubmit. func TestHandler_retryPurchase_RefusesIdenticalLiveSubmit(t *testing.T) { - failed, handler, mockConfig := retryDedupeFixture(t) + failed, handler, mockConfig := retryDedupeFixture(t, true) notifier := &recordingEmailNotifier{} handler.emailNotifier = notifier mockConfig.On("ListRecentSubmitsTx", mock.Anything, mock.Anything, retryCallerID, mock.AnythingOfType("time.Time")). @@ -66,7 +70,7 @@ func TestHandler_retryPurchase_RefusesIdenticalLiveSubmit(t *testing.T) { // A recent submit with a different fingerprint does not block the retry. func TestHandler_retryPurchase_AllowsDifferentLiveSubmit(t *testing.T) { - failed, handler, mockConfig := retryDedupeFixture(t) + failed, handler, mockConfig := retryDedupeFixture(t, true) other := append([]config.RecommendationRecord(nil), failed.Recommendations...) other[0].Count = 7 mockConfig.On("ListRecentSubmitsTx", mock.Anything, mock.Anything, retryCallerID, mock.AnythingOfType("time.Time")). @@ -78,7 +82,7 @@ func TestHandler_retryPurchase_AllowsDifferentLiveSubmit(t *testing.T) { // A failure to read the recent submits fails the retry before any write. func TestHandler_retryPurchase_RecentSubmitsErrorFailsRetry(t *testing.T) { - _, handler, mockConfig := retryDedupeFixture(t) + _, handler, mockConfig := retryDedupeFixture(t, false) mockConfig.On("ListRecentSubmitsTx", mock.Anything, mock.Anything, retryCallerID, mock.AnythingOfType("time.Time")). Return(nil, errors.New("db down")) @@ -96,6 +100,7 @@ func TestHandler_retryPurchase_RecentSubmitsErrorFailsRetry(t *testing.T) { // submit, not fall through to an unchecked insert. func TestPersistExecution_RecentSubmitsErrorIsSurfaced(t *testing.T) { mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("ListRecentSubmitsTx", mock.Anything, mock.Anything, "", mock.AnythingOfType("time.Time")). Return(nil, errors.New("db down")) h := &Handler{config: mockConfig} diff --git a/internal/api/handler_purchases_retry_redrive_test.go b/internal/api/handler_purchases_retry_redrive_test.go index a14f33cf..4d973612 100644 --- a/internal/api/handler_purchases_retry_redrive_test.go +++ b/internal/api/handler_purchases_retry_redrive_test.go @@ -87,7 +87,7 @@ func purchasesFiredByRetry(t *testing.T, failed *config.PurchaseExecution, req * // retry-own authorizes the row's creator (issue #907), so RBAC is out of // the way and the re-drive-safety gate is what the assertions see. session := &Session{UserID: retryCallerID, Email: "operator@example.com"} - handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, true) + handler, mockConfig, _ := buildSessionRetryHandler(t, failed, session, false, true) var saved []*config.PurchaseExecution mockConfig.On("SavePurchaseExecution", mock.Anything, mock.AnythingOfType("*config.PurchaseExecution")). diff --git a/internal/api/handler_purchases_revoke_actor_integration_test.go b/internal/api/handler_purchases_revoke_actor_integration_test.go index a5c709e3..fa5e1903 100644 --- a/internal/api/handler_purchases_revoke_actor_integration_test.go +++ b/internal/api/handler_purchases_revoke_actor_integration_test.go @@ -32,7 +32,9 @@ func TestRevokePurchase_RecordsActorUUIDOnRealPostgres(t *testing.T) { })) // The session-authed revoke path enforces CSRF; this request carries no CSRF header. - f.handler.auth.(*MockAuthService).On("ValidateCSRFToken", mock.Anything, "admin-token", "").Return(nil) + fixtureAuth := f.handler.auth.(*MockAuthService) + t.Cleanup(func() { fixtureAuth.AssertExpectations(t) }) + fixtureAuth.On("ValidateCSRFToken", mock.Anything, "admin-token", "").Return(nil) req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"Authorization": "Bearer admin-token"}} _, err := f.handler.revokeViaEmailToken(ctx, req, execID, "") require.NoError(t, err, "revoke must not fail on the transitioned_by UUID column") diff --git a/internal/api/handler_purchases_revoke_constraints_test.go b/internal/api/handler_purchases_revoke_constraints_test.go index 39c87034..0dde8731 100644 --- a/internal/api/handler_purchases_revoke_constraints_test.go +++ b/internal/api/handler_purchases_revoke_constraints_test.go @@ -303,7 +303,9 @@ func TestRevokePurchase_ConstrainedGrantNeverReachesAzure(t *testing.T) { t.Parallel() ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) r := armReservationRecord() acct := "acct-uuid" r.CloudAccountID = &acct @@ -339,7 +341,9 @@ func TestRevokePurchase_ConstrainedGrantNeverCancelsScheduledExecution(t *testin t.Parallel() ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "tok").Return(&Session{UserID: revokeConstraintsUser, Email: "u@example.com"}, nil) mockAuth.grantPermissionsScoped([]auth.Permission{ revokePerm(auth.ActionRevokeAny, &auth.PermissionConstraints{Regions: []string{"eastus"}}), diff --git a/internal/api/handler_purchases_revoke_quote_test.go b/internal/api/handler_purchases_revoke_quote_test.go index b0527fb7..d190504b 100644 --- a/internal/api/handler_purchases_revoke_quote_test.go +++ b/internal/api/handler_purchases_revoke_quote_test.go @@ -97,7 +97,9 @@ func TestHandleRequest_RevokeCalculate_AzureQuote(t *testing.T) { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "tok").Return(revokeAdminSession(), nil) r := armReservationRecord() mockStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, r.PurchaseID).Return(r, nil) diff --git a/internal/api/handler_purchases_revoke_uncertain_test.go b/internal/api/handler_purchases_revoke_uncertain_test.go index ecdfebb5..3d0a7d72 100644 --- a/internal/api/handler_purchases_revoke_uncertain_test.go +++ b/internal/api/handler_purchases_revoke_uncertain_test.go @@ -351,6 +351,7 @@ func TestCallAzureReturn_EnoughTimeOrNotLambda_Proceeds(t *testing.T) { defer cancel() } mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) r := armReservationRecord() mockStore.On("MarkPurchaseRevoked", mock.Anything, r.PurchaseID, mock.AnythingOfType("time.Time"), "direct-api", "", mock.Anything, mock.Anything).Return(nil).Once() diff --git a/internal/api/handler_purchases_run_update_any_test.go b/internal/api/handler_purchases_run_update_any_test.go index 0e3eb4f3..c72a923e 100644 --- a/internal/api/handler_purchases_run_update_any_test.go +++ b/internal/api/handler_purchases_run_update_any_test.go @@ -38,7 +38,7 @@ func runPlanned(t *testing.T, mp *MockPurchaseManager, creator *string, recs []c if recs != nil { exec.Recommendations = recs } - h, _ := cancelRouteHandler(exec, append(perms, unboundedExecute)...) + h, _ := cancelRouteHandler(t, exec, append(perms, unboundedExecute)...) mp.On("RunPlannedPurchaseNow", mock.Anything, cancelExecID, mock.Anything, mock.Anything). Return(&config.PurchaseExecution{ExecutionID: cancelExecID, Status: "completed"}, "", nil).Maybe() h.purchase = mp @@ -154,8 +154,10 @@ func TestRunPlanned_UpdateAnyConstraintErrorDoesNotFallBack(t *testing.T) { exec.CreatedByUserID = &owner exec.Recommendations = []config.RecommendationRecord{runRouteRec()} store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetExecutionByID", mock.Anything, cancelExecID).Return(exec, nil) m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{UserID: owner}, nil) m.On("GetAllowedAccountsAPI", mock.Anything, owner).Return([]string{}, nil) m.On("HasPermissionAPI", mock.Anything, owner, mock.Anything, auth.ResourcePurchases).Return(true, nil) @@ -211,7 +213,12 @@ func TestAuthorizeClaimedRowManagement_WithoutUpdateAny(t *testing.T) { exec.CreatedByUserID = by return exec } - h, _ := cancelRouteHandler(row(&creator), unboundedExecute) + // authorizeClaimedRowManagement is called directly, so no request is + // authenticated and no row is loaded: only the caller's grants matter. + grants := new(MockAuthService) + t.Cleanup(func() { grants.AssertExpectations(t) }) + grants.grantPermissions([]auth.Permission{unboundedExecute}) + h := &Handler{auth: grants} ctx := context.Background() require.NoError(t, h.authorizeClaimedRowManagement(ctx, &Session{UserID: creator}, row(&creator))) @@ -221,6 +228,7 @@ func TestAuthorizeClaimedRowManagement_WithoutUpdateAny(t *testing.T) { boom := errors.New("auth store down") m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("HasPermissionAPI", mock.Anything, creator, auth.ActionUpdateAny, auth.ResourcePurchases).Return(false, boom) err := (&Handler{auth: m}).authorizeClaimedRowManagement(ctx, &Session{UserID: creator}, row(&creator)) require.ErrorIs(t, err, boom) diff --git a/internal/api/handler_purchases_test.go b/internal/api/handler_purchases_test.go index cf4a52e4..4326522b 100644 --- a/internal/api/handler_purchases_test.go +++ b/internal/api/handler_purchases_test.go @@ -52,6 +52,7 @@ func TestHandler_approvePurchase(t *testing.T) { approver := "admin@example.com" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := approvalTestExec(execID, approver, mockConfig) mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockConfig.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{ @@ -59,6 +60,7 @@ func TestHandler_approvePurchase(t *testing.T) { }, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: approver}, nil) @@ -74,6 +76,7 @@ func TestHandler_approvePurchase(t *testing.T) { mockAuth.On("HasPermissionAPI", ctx, "", "approve-own", "purchases").Return(false, nil).Maybe() mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) mockPurchase.On("ApproveExecution", ctx, execID, "valid-token", approver).Return(nil, "", nil) handler := &Handler{purchase: mockPurchase, config: mockConfig, auth: mockAuth} @@ -97,6 +100,7 @@ func TestHandler_cancelPurchase(t *testing.T) { approver := "admin@example.com" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := approvalTestExec(execID, approver, mockConfig) mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockConfig.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{ @@ -104,6 +108,7 @@ func TestHandler_cancelPurchase(t *testing.T) { }, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: approver}, nil) @@ -117,6 +122,7 @@ func TestHandler_cancelPurchase(t *testing.T) { mockAuth.On("HasPermissionAPI", ctx, "", "cancel-own", "purchases").Return(false, nil).Maybe() mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) mockPurchase.On("CancelExecution", ctx, execID, "valid-token", approver).Return(nil) handler := &Handler{purchase: mockPurchase, config: mockConfig, auth: mockAuth} @@ -137,6 +143,7 @@ func TestHandler_approvePurchase_RejectsMismatchedSession(t *testing.T) { approver := "approver@example.com" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := approvalTestExec(execID, approver, mockConfig) mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockConfig.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{ @@ -144,6 +151,7 @@ func TestHandler_approvePurchase_RejectsMismatchedSession(t *testing.T) { }, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() // Session belongs to someone who is NOT the authorized approver. @@ -189,6 +197,7 @@ func TestHandler_approvePurchase_RejectsMissingContactEmail(t *testing.T) { accountID := "acct-no-contact" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "valid-token", @@ -206,6 +215,7 @@ func TestHandler_approvePurchase_RejectsMissingContactEmail(t *testing.T) { }, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: globalNotify}, nil) @@ -236,12 +246,16 @@ func TestHandler_approvePurchase_RejectsMissingSession(t *testing.T) { execID := "12345678-1234-1234-1234-123456789abc" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "valid-token", Status: "pending", } - mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) + // Maybe: the session is required before the execution is loaded (issue + // #435). Registered so a lookup-first regression returns cleanly and is + // caught by the AssertNotCalled below. + mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil).Maybe() handler := &Handler{config: mockConfig} @@ -249,6 +263,7 @@ func TestHandler_approvePurchase_RejectsMissingSession(t *testing.T) { _, err := handler.approvePurchase(ctx, &events.LambdaFunctionURLRequest{}, execID, "valid-token") require.Error(t, err) assert.Contains(t, err.Error(), "sign in") + mockConfig.AssertNotCalled(t, "GetExecutionByID", ctx, execID) } func TestHandler_approvePurchase_AcceptsContactEmailSession(t *testing.T) { @@ -259,6 +274,7 @@ func TestHandler_approvePurchase_AcceptsContactEmailSession(t *testing.T) { accountID := "acct-1" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "valid-token", @@ -276,6 +292,7 @@ func TestHandler_approvePurchase_AcceptsContactEmailSession(t *testing.T) { } mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() // Session email matches the account contact email — global notify is @@ -291,6 +308,7 @@ func TestHandler_approvePurchase_AcceptsContactEmailSession(t *testing.T) { mockAuth.On("HasPermissionAPI", ctx, "", "approve-own", "purchases").Return(false, nil).Maybe() mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) mockPurchase.On("ApproveExecution", ctx, execID, "valid-token", contactEmail).Return(nil, "", nil) handler := &Handler{purchase: mockPurchase, config: mockConfig, auth: mockAuth} @@ -317,6 +335,7 @@ func TestHandler_approvePurchase_SessionApproveAnyChainsToExecute(t *testing.T) adminEmail := "admin@example.com" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "valid-token", @@ -338,6 +357,7 @@ func TestHandler_approvePurchase_SessionApproveAnyChainsToExecute(t *testing.T) mockConfig.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{PurchaseDelayHours: 0}, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: adminEmail}, nil) mockAuth.grantAdminPurchaser() // approvePurchaseViaSession enforces CSRF on the session-authed path (issue #404). @@ -388,6 +408,7 @@ func TestHandleRequest_approvePurchase_SessionFailureStatuses(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "valid-token", @@ -400,11 +421,13 @@ func TestHandleRequest_approvePurchase_SessionFailureStatuses(t *testing.T) { mockConfig.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{PurchaseDelayHours: 0}, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{Email: adminEmail}, nil) mockAuth.grantAdminPurchaser() mockAuth.On("ValidateCSRFToken", mock.Anything, "sess-tok", "").Return(nil) mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) mockPurchase.On("ApproveAndExecute", mock.Anything, execID, adminEmail, (*string)(nil)).Return(tc.final, "", tc.err) handler := &Handler{purchase: mockPurchase, config: mockConfig, auth: mockAuth, corsAllowedOrigin: "*"} @@ -613,6 +636,7 @@ func TestHandler_approveViaToken_GlobalConfigError_FailsClosed(t *testing.T) { contactEmail := "approver@example.com" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := approvalTestExec(execID, contactEmail, mockConfig) mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) // GetGlobalConfig fails on every call. authorizeApprovalAction consumes the @@ -625,6 +649,7 @@ func TestHandler_approveViaToken_GlobalConfigError_FailsClosed(t *testing.T) { mockConfig.On("GetGlobalConfig", ctx).Return(nil, errors.New("db transient error")) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: contactEmail}, nil) @@ -722,6 +747,7 @@ func TestHandler_approvePurchaseViaSession_GlobalConfigError_FailsClosed(t *test adminEmail := "admin@example.com" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "valid-token", @@ -739,6 +765,7 @@ func TestHandler_approvePurchaseViaSession_GlobalConfigError_FailsClosed(t *test mockConfig.On("GetGlobalConfig", ctx).Return(nil, errors.New("db transient error")) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: adminEmail}, nil) mockAuth.grantAdminPurchaser() // approvePurchaseViaSession enforces CSRF. @@ -774,6 +801,7 @@ func TestHandler_approvePurchase_AzureOrphanRejects409(t *testing.T) { execID := "12345678-1234-1234-1234-123456789abc" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "valid-token", @@ -784,6 +812,7 @@ func TestHandler_approvePurchase_AzureOrphanRejects409(t *testing.T) { mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: "admin@example.com"}, nil) // CSRF is validated before the execution lookup (issue #597). mockAuth.On("ValidateCSRFToken", ctx, "sess-tok", "").Return(nil) @@ -816,6 +845,7 @@ func TestHandler_approvePurchase_GCPOrphanRejects409(t *testing.T) { execID := "12345678-1234-1234-1234-123456789abc" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "valid-token", @@ -825,6 +855,7 @@ func TestHandler_approvePurchase_GCPOrphanRejects409(t *testing.T) { mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: "admin@example.com"}, nil) // CSRF is validated before the execution lookup (issue #597). mockAuth.On("ValidateCSRFToken", ctx, "sess-tok", "").Return(nil) @@ -859,6 +890,7 @@ func TestHandler_approvePurchase_AWSOrphanFallsThrough(t *testing.T) { adminEmail := "admin@example.com" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "valid-token", @@ -872,6 +904,7 @@ func TestHandler_approvePurchase_AWSOrphanFallsThrough(t *testing.T) { mockConfig.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: adminEmail}, nil) mockAuth.grantAdminPurchaser() // approvePurchaseViaSession enforces CSRF on the session-authed path (issue #404). @@ -901,6 +934,7 @@ func TestHandler_approvePurchase_NonOrphanUnchanged(t *testing.T) { accountID := "acct-azure-001" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "valid-token", @@ -915,6 +949,7 @@ func TestHandler_approvePurchase_NonOrphanUnchanged(t *testing.T) { mockConfig.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: adminEmail}, nil) mockAuth.grantAdminPurchaser() // approvePurchaseViaSession enforces CSRF on the session-authed path (issue #404). @@ -946,6 +981,7 @@ func TestHandler_approvePurchase_RejectsGlobalNotifyWhenContactSet(t *testing.T) accountID := "acct-1" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "valid-token", @@ -963,6 +999,7 @@ func TestHandler_approvePurchase_RejectsGlobalNotifyWhenContactSet(t *testing.T) } mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: globalNotify}, nil) @@ -1007,6 +1044,7 @@ func TestHandler_approvePurchase_RejectsCreatorWithoutApprovePermission(t *testi creatorEmail := "creator@example.com" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "valid-token", @@ -1016,6 +1054,7 @@ func TestHandler_approvePurchase_RejectsCreatorWithoutApprovePermission(t *testi mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() // Session belongs to the creator themselves but holds no approve verb. @@ -1026,29 +1065,23 @@ func TestHandler_approvePurchase_RejectsCreatorWithoutApprovePermission(t *testi // Four-eyes enforcement: neither approve-any nor approve-own is granted. mockAuth.On("HasPermissionAPI", ctx, creatorUserID, "approve-any", "purchases").Return(false, nil) mockAuth.On("HasPermissionAPI", ctx, creatorUserID, "approve-own", "purchases").Return(false, nil) - // approvePurchase: when token is empty and the session has 403, execution - // falls through to the final approvePurchaseViaSession call (line 506), - // which starts with a CSRF check. Stub it (.Maybe) so the mock doesn't - // panic; the CSRF error returns a 403 before the purchase manager is - // reached, which is equivalent to the authorizeSessionApprove 403 for the - // purpose of this regression guard. - mockAuth.On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything). - Return(errors.New("csrf invalid")).Maybe() + // A valid CSRF token, so the request passes requireSessionBeforeLookup + // and the denial under test comes from the approve-permission check, not + // from a CSRF rejection that would also answer 403. + mockAuth.On("ValidateCSRFToken", ctx, "sess-tok", "csrf-ok").Return(nil) mockPurchase := new(MockPurchaseManager) handler := &Handler{purchase: mockPurchase, config: mockConfig, auth: mockAuth} - // No approval token: the token branch is unavailable, so the 403 from - // authorizeSessionApprove (or CSRF fallback) surfaces to the caller. + // No approval token: the token branch is unavailable, so the denial from + // authorizeSessionApprove surfaces to the caller, answered like a missing + // execution (issue #597). req := &events.LambdaFunctionURLRequest{ - Headers: map[string]string{"authorization": "Bearer sess-tok"}, + Headers: map[string]string{"authorization": "Bearer sess-tok", "x-csrf-token": "csrf-ok"}, } _, err := handler.approvePurchase(ctx, req, execID, "") - require.Error(t, err) - ce, ok := IsClientError(err) - require.True(t, ok, "expected a ClientError, got %T: %v", err, err) - assert.Equal(t, 403, ce.code, "creator without approve permission must be denied 403") + requireConcealedDenial(t, err, "requires approve-any or approve-own") // Purchase manager must never be reached. mockPurchase.AssertNotCalled(t, "ApproveExecution", mock.Anything, mock.Anything, mock.Anything, mock.Anything) mockPurchase.AssertNotCalled(t, "ApproveAndExecute", mock.Anything, mock.Anything, mock.Anything, mock.Anything) @@ -1220,6 +1253,7 @@ func TestHandler_getPlannedPurchases(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1296,7 +1330,9 @@ func TestHandler_getPlannedPurchases(t *testing.T) { func TestHandler_getPlannedPurchases_ErrorGettingExecutions(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1334,6 +1370,7 @@ func TestHandler_getPlannedPurchases_ErrorGettingExecutions(t *testing.T) { func TestHandler_getPlannedPurchases_ScopedUserFiltersByPlanAccount(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) const ( planAllowedID = "11111111-1111-1111-1111-111111111111" @@ -1365,7 +1402,7 @@ func TestHandler_getPlannedPurchases_ScopedUserFiltersByPlanAccount(t *testing.T } } - handler := &Handler{config: mockStore, auth: scopedAuthMock(ctx)} + handler := &Handler{config: mockStore, auth: scopedAuthMock(t, ctx)} result, err := handler.getPlannedPurchases(ctx, scopedReq()) require.NoError(t, err) require.NotNil(t, result) @@ -1384,6 +1421,7 @@ func TestHandler_getPlannedPurchases_PausedStaysVisible(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1463,6 +1501,7 @@ func TestHandler_getPlannedPurchases_SoonestRowsNotTruncated(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1528,7 +1567,9 @@ func TestHandler_getPlannedPurchases_SoonestRowsNotTruncated(t *testing.T) { func TestHandler_pausePlannedPurchase(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1557,7 +1598,9 @@ func TestHandler_pausePlannedPurchase(t *testing.T) { func TestHandler_pausePlannedPurchase_NotFound(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1566,7 +1609,8 @@ func TestHandler_pausePlannedPurchase_NotFound(t *testing.T) { mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"pending", "notified"}, "paused", mock.Anything).Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) + // The row is loaded before the transition, so a missing execution is + // refused there and TransitionExecutionStatus is never reached. mockStore.On("GetExecutionByID", ctx, "99999999-9999-9999-9999-999999999999").Return(nil, config.ErrNotFound) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1584,7 +1628,9 @@ func TestHandler_pausePlannedPurchase_NotFound(t *testing.T) { func TestHandler_resumePlannedPurchase(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1613,7 +1659,9 @@ func TestHandler_resumePlannedPurchase(t *testing.T) { func TestHandler_runPlannedPurchase(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockPurchase := new(MockPurchaseManager) adminSession := &Session{ @@ -1738,7 +1786,9 @@ func TestHandler_runPlannedPurchase_ZeroCommitment(t *testing.T) { func TestHandler_deletePlannedPurchase(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -1772,6 +1822,7 @@ func TestHandler_deletePlannedPurchase_DisablesPlan(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{ @@ -1825,6 +1876,7 @@ func TestHandler_deletePlannedPurchase_AlreadyDisabledPlan(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{ @@ -1874,6 +1926,7 @@ func TestHandler_deletePlannedPurchase_ConflictRetryDisablesPlan(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{ @@ -1927,6 +1980,7 @@ func TestHandler_deletePlannedPurchase_ConflictRetryAlreadyDisabled(t *testing.T ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{ @@ -1978,6 +2032,7 @@ func TestHandler_deletePlannedPurchase_ConflictRetryRunningReturns409(t *testing ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{ @@ -2092,6 +2147,7 @@ func TestHandler_deletePlannedPurchase_BackendErrorReturns5xx(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) adminSession := &Session{ @@ -2130,7 +2186,9 @@ func TestHandler_deletePlannedPurchase_BackendErrorReturns5xx(t *testing.T) { func TestHandler_pausePlannedPurchase_NilExecution(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2139,7 +2197,8 @@ func TestHandler_pausePlannedPurchase_NilExecution(t *testing.T) { mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"pending", "notified"}, "paused", mock.Anything).Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) + // The row is loaded before the transition, so a missing execution is + // refused there and TransitionExecutionStatus is never reached. mockStore.On("GetExecutionByID", ctx, "99999999-9999-9999-9999-999999999999").Return(nil, config.ErrNotFound) handler := &Handler{config: mockStore, auth: mockAuth} @@ -2203,7 +2262,9 @@ func TestHandler_pausePlannedPurchase_IneligibleStatus(t *testing.T) { func TestHandler_resumePlannedPurchase_NilExecution(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2230,8 +2291,11 @@ func TestHandler_resumePlannedPurchase_NilExecution(t *testing.T) { func TestHandler_runPlannedPurchase_NilExecution(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2239,7 +2303,16 @@ func TestHandler_runPlannedPurchase_NilExecution(t *testing.T) { } mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) - mockAuth.grantAdmin() + // execute:purchases is carved out of admin:* (#923): a plain admin is + // refused before the run, so grant the purchaser verbs to reach it. + mockAuth.grantAdminPurchaser() + // The row exists when the constraints are checked and is gone by the time + // the run claims it, so RunPlannedPurchaseNow reports it missing. + mockStore.On("GetExecutionByID", ctx, "99999999-9999-9999-9999-999999999999").Return(&config.PurchaseExecution{ + ExecutionID: "99999999-9999-9999-9999-999999999999", + Status: "pending", + Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 100}}, + }, nil) mockPurchase.On("RunPlannedPurchaseNow", ctx, "99999999-9999-9999-9999-999999999999", "admin@example.com", mock.Anything). Return(nil, "", fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) @@ -2251,7 +2324,8 @@ func TestHandler_runPlannedPurchase_NilExecution(t *testing.T) { }, } result, err := handler.runPlannedPurchase(ctx, req, "99999999-9999-9999-9999-999999999999") - assert.Error(t, err) + require.Error(t, err) + assert.Contains(t, err.Error(), "execution not found") assert.Nil(t, result) } @@ -2293,7 +2367,9 @@ func TestExecuteFailureError(t *testing.T) { func TestHandler_deletePlannedPurchase_NilExecution(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2327,6 +2403,7 @@ func TestHandler_pausePlannedPurchase_ActorStamped(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) const actorID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" @@ -2354,7 +2431,9 @@ func TestHandler_pausePlannedPurchase_ActorStamped(t *testing.T) { func TestHandler_getPlannedPurchases_ErrorGettingPlans(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2478,7 +2557,9 @@ func TestHandler_getPlannedPurchases_ReadOnlyCannotManage(t *testing.T) { func TestHandler_getPurchaseDetails_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2546,7 +2627,9 @@ func TestHandler_getPurchaseDetails_InvalidUUID(t *testing.T) { func TestHandler_getPurchaseDetails_NotFound(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2579,7 +2662,9 @@ func TestHandler_getPurchaseDetails_NotFound(t *testing.T) { func TestHandler_getPurchaseDetails_NilExecution(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2616,7 +2701,9 @@ func TestHandler_getPurchaseDetails_NilExecution(t *testing.T) { func TestHandler_getPurchaseDetails_NotFound_IsClientError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2649,7 +2736,9 @@ func TestHandler_getPurchaseDetails_NotFound_IsClientError(t *testing.T) { func TestHandler_getPurchaseDetails_WithTimestamps(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2697,7 +2786,9 @@ func TestHandler_getPurchaseDetails_WithTimestamps(t *testing.T) { func TestHandler_executePurchase_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2754,6 +2845,7 @@ func TestHandler_executePurchase_Success(t *testing.T) { func TestHandler_executePurchase_InvalidBody(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2780,6 +2872,7 @@ func TestHandler_executePurchase_InvalidBody(t *testing.T) { func TestHandler_executePurchase_EmptyRecommendations(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2806,7 +2899,9 @@ func TestHandler_executePurchase_EmptyRecommendations(t *testing.T) { func TestHandler_executePurchase_NegativeUpfrontCost(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2839,7 +2934,9 @@ func TestHandler_executePurchase_NegativeUpfrontCost(t *testing.T) { func TestHandler_executePurchase_NegativeSavings(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2875,6 +2972,7 @@ func TestHandler_executePurchase_NegativeSavings(t *testing.T) { func TestHandler_executePurchase_TooManyRecommendations(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2912,7 +3010,9 @@ func TestHandler_executePurchase_TooManyRecommendations(t *testing.T) { func TestHandler_executePurchase_ExceedsMaxAmount(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2947,7 +3047,9 @@ func TestHandler_executePurchase_ExceedsMaxAmount(t *testing.T) { func TestHandler_executePurchase_SaveError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -2984,7 +3086,9 @@ func TestHandler_executePurchase_SaveError(t *testing.T) { func TestHandler_pausePlannedPurchase_OutOfScope(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) executionID := "77777777-7777-7777-7777-777777777777" planID := "88888888-8888-8888-8888-888888888888" @@ -3035,7 +3139,9 @@ func TestHandler_pausePlannedPurchase_OutOfScope(t *testing.T) { func TestHandler_approvePurchase_OutOfScope(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) executionID := "aaaaaaaa-1111-1111-1111-111111111111" planID := "bbbbbbbb-1111-1111-1111-111111111111" @@ -3044,8 +3150,10 @@ func TestHandler_approvePurchase_OutOfScope(t *testing.T) { UserID: "operator-1", Email: "operator@example.com", }, nil) // approve-any:purchases -- the RBAC matrix that pre-fix was the ONLY gate - // on this endpoint. - mockAuth.On("HasPermissionAPI", ctx, "operator-1", "approve-any", "purchases").Return(true, nil) + // on this endpoint. Maybe: the account-scope check now runs first and + // refuses this row, so the grant is never consulted; it stays registered + // so the operator still holds the verb should the order ever change. + mockAuth.On("HasPermissionAPI", ctx, "operator-1", "approve-any", "purchases").Return(true, nil).Maybe() mockAuth.On("GetAllowedAccountsAPI", ctx, "operator-1").Return([]string{"Production"}, nil) mockAuth.On("ValidateCSRFToken", ctx, "operator-token", "").Return(nil) @@ -3081,7 +3189,9 @@ func TestHandler_approvePurchase_OutOfScope(t *testing.T) { func TestHandler_retryPurchase_OutOfScope(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) executionID := "cccccccc-1111-1111-1111-111111111111" planID := "dddddddd-1111-1111-1111-111111111111" @@ -3090,8 +3200,10 @@ func TestHandler_retryPurchase_OutOfScope(t *testing.T) { UserID: "operator-1", Email: "operator@example.com", }, nil) // retry-any:purchases -- the RBAC matrix that pre-fix was the ONLY gate - // on this endpoint. - mockAuth.On("HasPermissionAPI", ctx, "operator-1", "retry-any", "purchases").Return(true, nil) + // on this endpoint. Maybe: the account-scope check now runs first and + // refuses this row, so the grant is never consulted; it stays registered + // so the operator still holds the verb should the order ever change. + mockAuth.On("HasPermissionAPI", ctx, "operator-1", "retry-any", "purchases").Return(true, nil).Maybe() mockAuth.On("GetAllowedAccountsAPI", ctx, "operator-1").Return([]string{"Production"}, nil) mockStore.On("GetExecutionByID", ctx, executionID).Return(&config.PurchaseExecution{ @@ -3137,11 +3249,14 @@ const cancelOtherID = "77777777-7777-7777-7777-777777777777" // buildSessionCancelHandler wires the handler with mocks the session-authed // cancel tests share. Token is left empty by callers when invoking // cancelPurchase to drive the new branch. -func buildSessionCancelHandler(exec *config.PurchaseExecution, session *Session, hasAny, hasOwn bool) (*Handler, *MockConfigStore, *MockAuthService) { +func buildSessionCancelHandler(t *testing.T, exec *config.PurchaseExecution, session *Session, hasAny, hasOwn bool) (*Handler, *MockConfigStore, *MockAuthService) { + t.Helper() mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetExecutionByID", mock.Anything, exec.ExecutionID).Return(exec, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "sess-tok").Return(session, nil) mockAuth.allowConstraintChecks() // cancelPurchaseViaSession enforces CSRF on the session-authed path (issue @@ -3184,7 +3299,7 @@ func sessionCancelReq() *events.LambdaFunctionURLRequest { // attribution. func runSessionCancelAllowed(t *testing.T, exec *config.PurchaseExecution, session *Session, hasAny, hasOwn bool) { t.Helper() - handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, hasAny, hasOwn) + handler, mockConfig, mockAuth := buildSessionCancelHandler(t, exec, session, hasAny, hasOwn) // Capture the cancelledBy pointer passed to CancelExecutionAtomic // so we can assert attribution was stamped correctly. @@ -3265,7 +3380,7 @@ func TestHandler_cancelPurchase_Session_CancelOwn_RejectsNonCreator(t *testing.T } session := &Session{UserID: cancelCallerID, Email: "u1@example.com"} - handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, true) + handler, mockConfig, mockAuth := buildSessionCancelHandler(t, exec, session, false, true) _, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "") // The denial answers like a missing execution (issue #597). @@ -3284,7 +3399,7 @@ func TestHandler_cancelPurchase_Session_NoVerb_Rejects(t *testing.T) { } session := &Session{UserID: cancelCallerID, Email: "u1@example.com"} - handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, false) + handler, mockConfig, mockAuth := buildSessionCancelHandler(t, exec, session, false, false) _, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "") // The denial answers like a missing execution (issue #597). @@ -3303,7 +3418,7 @@ func TestHandler_cancelPurchase_Session_RejectsTerminalStatus(t *testing.T) { } session := &Session{UserID: cancelCallerID} - handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, true, false) + handler, mockConfig, mockAuth := buildSessionCancelHandler(t, exec, session, true, false) _, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "") require.Error(t, err) @@ -3333,7 +3448,7 @@ func TestHandler_cancelPurchase_Session_RejectsEachNonCancelableStatus(t *testin } session := &Session{UserID: cancelCallerID} - handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, true, false) + handler, mockConfig, mockAuth := buildSessionCancelHandler(t, exec, session, true, false) _, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "") require.Error(t, err) @@ -3364,7 +3479,7 @@ func TestHandler_cancelPurchase_Session_ScheduledRoutedToRevoke(t *testing.T) { session := &Session{UserID: cancelCallerID, Email: "admin@example.com"} // Caller owns the row; cancel-own would authorize it if status allowed. - handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, true) + handler, mockConfig, mockAuth := buildSessionCancelHandler(t, exec, session, false, true) _, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "") require.Error(t, err) @@ -3417,7 +3532,7 @@ func TestHandler_cancelPurchase_Session_RaceWithApprove(t *testing.T) { session := &Session{UserID: cancelCallerID, Email: "admin@example.com"} // Caller owns the row; cancel-own authorizes it (issue #907). - handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, true) + handler, mockConfig, mockAuth := buildSessionCancelHandler(t, exec, session, false, true) // Simulate concurrent approve winning between IsCancelable check and // the conditional UPDATE inside the tx. mockConfig.On("CancelExecutionAtomic", mock.Anything, mock.Anything, cancelExecID, mock.Anything). @@ -3448,7 +3563,7 @@ func TestHandler_cancelPurchase_Session_LegacyNullCreator_NonAdminRejected(t *te } session := &Session{UserID: cancelCallerID, Email: "u1@example.com"} - handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, true) + handler, mockConfig, mockAuth := buildSessionCancelHandler(t, exec, session, false, true) _, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "") // The denial answers like a missing execution (issue #597). @@ -3461,7 +3576,10 @@ func TestHandler_cancelPurchase_Session_LegacyNullCreator_NonAdminRejected(t *te func TestHandler_cancelPurchase_Session_RejectsMissingSession(t *testing.T) { exec := &config.PurchaseExecution{ExecutionID: cancelExecID, Status: "pending"} mockConfig := new(MockConfigStore) - mockConfig.On("GetExecutionByID", mock.Anything, cancelExecID).Return(exec, nil) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) + // Maybe: registered so a lookup-first regression returns cleanly and is + // caught by the AssertNotCalled below. + mockConfig.On("GetExecutionByID", mock.Anything, cancelExecID).Return(exec, nil).Maybe() handler := &Handler{config: mockConfig, auth: new(MockAuthService)} // No Authorization header → 401 before the execution is loaded (issue #435), @@ -3513,7 +3631,7 @@ func TestHandler_cancelPurchase_DeepLink_AdminBypassesContactEmailGate(t *testin // "Admin" is now an Administrators-group member, i.e. a holder of the // cancel-any permission (issue #907). The contact-email-gate bypass under // test is independent of how that authority is derived. - handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, true, false) + handler, mockConfig, mockAuth := buildSessionCancelHandler(t, exec, session, true, false) // Capture cancelledBy to verify the audit-stamp is passed to the // atomic UPDATE. @@ -3563,7 +3681,7 @@ func TestHandler_cancelPurchase_DeepLink_CancelOwnBypassesContactEmailGate(t *te } session := &Session{UserID: cancelCallerID, Email: "u1@example.com"} - handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false /*hasAny*/, true /*hasOwn*/) + handler, mockConfig, mockAuth := buildSessionCancelHandler(t, exec, session, false /*hasAny*/, true /*hasOwn*/) // CancelExecutionAtomic is called by the session-authed branch. mockConfig.On("CancelExecutionAtomic", mock.Anything, mock.Anything, cancelExecID, mock.Anything). Return(true, "canceled", nil) @@ -3621,6 +3739,7 @@ func TestHandler_cancelPurchase_DeepLink_TransientAuthErrorPropagates(t *testing session := &Session{UserID: cancelCallerID, Email: "u1@example.com"} mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetExecutionByID", mock.Anything, exec.ExecutionID).Return(exec, nil) mockAuth := new(MockAuthService) @@ -3669,7 +3788,7 @@ func TestHandler_cancelPurchase_DeepLink_NonPrivilegedSessionStillHitsContactGat } session := &Session{UserID: cancelCallerID, Email: "u1@example.com"} - handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false /*hasAny*/, false /*hasOwn*/) + handler, mockConfig, mockAuth := buildSessionCancelHandler(t, exec, session, false /*hasAny*/, false /*hasOwn*/) // Token branch fetches the global config to populate the Cc list. mockConfig.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{}, nil) @@ -3705,14 +3824,17 @@ const retryExecID = "88888888-8888-8888-8888-888888888888" const retryCallerID = "99999999-9999-9999-9999-999999999999" const retryOtherID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" -func buildSessionRetryHandler(failed *config.PurchaseExecution, session *Session, hasAny, hasOwn bool) (*Handler, *MockConfigStore, *MockAuthService) { +func buildSessionRetryHandler(t *testing.T, failed *config.PurchaseExecution, session *Session, hasAny, hasOwn bool) (*Handler, *MockConfigStore, *MockAuthService) { + t.Helper() mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetExecutionByID", mock.Anything, failed.ExecutionID).Return(failed, nil) // GetGlobalConfig is consulted for grace-period suppressions; an // empty config is fine (no suppressions written). mockConfig.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{}, nil).Maybe() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "sess-tok").Return(session, nil) // Permission-based for every caller (issue #907): register unconditionally. if session != nil { @@ -3758,7 +3880,7 @@ func sessionRetryReqWithForce() *events.LambdaFunctionURLRequest { // original points at the successor). func runSessionRetryAllowed(t *testing.T, failed *config.PurchaseExecution, session *Session, hasAny, hasOwn bool, req *events.LambdaFunctionURLRequest) (newExec, updatedOriginal *config.PurchaseExecution) { t.Helper() - handler, mockConfig, mockAuth := buildSessionRetryHandler(failed, session, hasAny, hasOwn) + handler, mockConfig, mockAuth := buildSessionRetryHandler(t, failed, session, hasAny, hasOwn) saved := []*config.PurchaseExecution{} mockConfig.On("SavePurchaseExecution", mock.Anything, mock.AnythingOfType("*config.PurchaseExecution")). Run(func(args mock.Arguments) { @@ -3841,7 +3963,7 @@ func TestHandler_retryPurchase_StoresHashOfEmailedToken(t *testing.T) { Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100, CloudAccountID: &accountID}}, } session := &Session{UserID: retryCallerID, Email: "admin@example.com"} - handler, mockConfig, _ := buildSessionRetryHandler(failed, session, true, false) + handler, mockConfig, _ := buildSessionRetryHandler(t, failed, session, true, false) mockConfig.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { return &config.CloudAccount{ID: id, ContactEmail: "contact@acct.example.com"}, nil } @@ -3902,7 +4024,7 @@ func TestHandler_retryPurchase_RetryOwn_RejectsNonCreator(t *testing.T) { } session := &Session{UserID: retryCallerID, Email: "u1@example.com"} - handler, mockConfig, mockAuth := buildSessionRetryHandler(failed, session, false, true) + handler, mockConfig, mockAuth := buildSessionRetryHandler(t, failed, session, false, true) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) assert.Contains(t, err.Error(), "another user's failed purchase") @@ -3920,7 +4042,7 @@ func TestHandler_retryPurchase_NoVerb_Rejects(t *testing.T) { } session := &Session{UserID: retryCallerID, Email: "u1@example.com"} - handler, mockConfig, mockAuth := buildSessionRetryHandler(failed, session, false, false) + handler, mockConfig, mockAuth := buildSessionRetryHandler(t, failed, session, false, false) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) assert.Contains(t, err.Error(), "retry-any or retry-own") @@ -3937,7 +4059,7 @@ func TestHandler_retryPurchase_RejectsNonFailedStatus(t *testing.T) { CreatedByUserID: &creator, } session := &Session{UserID: retryCallerID} - handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, false) + handler, mockConfig, _ := buildSessionRetryHandler(t, failed, session, false, false) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) assert.Contains(t, err.Error(), "cannot be retried") @@ -3952,7 +4074,7 @@ func TestHandler_retryPurchase_LegacyNullCreator_NonAdminRejected(t *testing.T) CreatedByUserID: nil, // pre-migration row } session := &Session{UserID: retryCallerID, Email: "u1@example.com"} - handler, mockConfig, mockAuth := buildSessionRetryHandler(failed, session, false, true) + handler, mockConfig, mockAuth := buildSessionRetryHandler(t, failed, session, false, true) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) assert.Contains(t, err.Error(), "another user's failed purchase") @@ -3970,7 +4092,7 @@ func TestHandler_retryPurchase_PersistentFailure_BlocksWithOpsHint(t *testing.T) } session := &Session{UserID: retryCallerID} // Caller owns the row; retry-own authorizes it (issue #907). - handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, true) + handler, mockConfig, _ := buildSessionRetryHandler(t, failed, session, false, true) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) assert.Contains(t, err.Error(), "operator-fixable") @@ -4012,7 +4134,7 @@ func TestHandler_retryPurchase_Threshold_BlocksAtFive_NoForce(t *testing.T) { } session := &Session{UserID: retryCallerID} // Caller owns the row; retry-own authorizes it (issue #907). - handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, true) + handler, mockConfig, _ := buildSessionRetryHandler(t, failed, session, false, true) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) assert.Contains(t, err.Error(), "force=true") @@ -4069,7 +4191,7 @@ func TestHandler_retryPurchase_AlreadyRetried_Rejects(t *testing.T) { } session := &Session{UserID: retryCallerID} // Caller owns the row; retry-own authorizes it (issue #907). - handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, true) + handler, mockConfig, _ := buildSessionRetryHandler(t, failed, session, false, true) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) assert.Contains(t, err.Error(), "already retried") @@ -4115,7 +4237,7 @@ func TestHandler_retryPurchase_ConcurrentRetryRace_SecondCASLoses(t *testing.T) // racing requests pass the pre-tx already-retried check because // neither has committed yet -- this test's `failed` fixture models // exactly that: RetryExecutionID is still nil when the handler reads it. - handler, mockConfig, mockAuth := buildSessionRetryHandler(failed, session, false, true) + handler, mockConfig, mockAuth := buildSessionRetryHandler(t, failed, session, false, true) // The successor insert always succeeds; only the linkage CAS loses the // race, simulating this request being the second writer. @@ -4151,7 +4273,7 @@ func TestHandler_retryPurchase_RefusesSettledLadderTranche(t *testing.T) { CreatedByUserID: &creator, Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "rds", Term: 1, UpfrontCost: 100}}, } - handler, mockConfig, _ := buildSessionRetryHandler(failed, &Session{UserID: retryCallerID}, false, true) + handler, mockConfig, _ := buildSessionRetryHandler(t, failed, &Session{UserID: retryCallerID}, false, true) mockConfig.On("LockLadderTrancheForRetryTx", mock.Anything, mock.Anything, failed.IdempotencyKey). Return(ladder.TrancheStatusFailed, true, nil) @@ -4170,6 +4292,7 @@ func TestHandler_retryPurchase_RefusesSettledLadderTranche(t *testing.T) { func TestHandler_retryPurchase_RejectsMissingSession(t *testing.T) { failed := &config.PurchaseExecution{ExecutionID: retryExecID, Status: "failed"} mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetExecutionByID", mock.Anything, retryExecID).Return(failed, nil) handler := &Handler{config: mockConfig, auth: new(MockAuthService)} _, err := handler.retryPurchase(context.Background(), &events.LambdaFunctionURLRequest{}, retryExecID) @@ -4218,7 +4341,7 @@ func TestHandler_retryPurchase_AlreadyRetried_RBACBeforeLeak(t *testing.T) { // must hit the 403 from authorizeSessionRetry, NOT the 409 with // successor exposure. session := &Session{UserID: retryCallerID} - handler, _, _ := buildSessionRetryHandler(failed, session, false, false) + handler, _, _ := buildSessionRetryHandler(t, failed, session, false, false) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) ce, ok := IsClientError(err) @@ -4821,8 +4944,11 @@ func TestHandler_executePurchase_DirectExec_FailureStatuses(t *testing.T) { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) @@ -5258,8 +5384,10 @@ const ownUserB = "bbbb2222-2222-2222-2222-222222222222" // creator of P2 // buildManageHandler wires a non-admin "user-token" session for userID with // account access (empty allowed_accounts -> all accessible) and the given // update-any grant. The stored execution is created by creatorID. -func buildManageHandler(userID, creatorID string, hasUpdateAny bool) (*Handler, *MockConfigStore, *MockAuthService) { //nolint:unparam // param intentional for interface consistency/future use +func buildManageHandler(t *testing.T, userID, creatorID string, hasUpdateAny bool) (*Handler, *MockConfigStore, *MockAuthService) { //nolint:unparam // param intentional for interface consistency/future use + t.Helper() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "user-token").Return(&Session{UserID: userID}, nil) mockAuth.On("HasPermissionAPI", mock.Anything, userID, "update", "purchases").Return(true, nil).Maybe() mockAuth.On("HasPermissionAPI", mock.Anything, userID, "execute", "purchases").Return(true, nil).Maybe() @@ -5279,6 +5407,7 @@ func buildManageHandler(userID, creatorID string, hasUpdateAny bool) (*Handler, creator := creatorID exec := &config.PurchaseExecution{ExecutionID: ownExecID, Status: "pending", CreatedByUserID: &creator} mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetExecutionByID", mock.Anything, ownExecID).Return(exec, nil) return &Handler{config: mockConfig, auth: mockAuth}, mockConfig, mockAuth @@ -5287,8 +5416,10 @@ func buildManageHandler(userID, creatorID string, hasUpdateAny bool) (*Handler, // buildCancelOwnHandler wires a Standard-user "user-token" session that holds // cancel-own:purchases but NOT delete:purchases. Used to verify the issue #1400 // fix: creator-scope planned-purchase cancel without the management delete verb. -func buildCancelOwnHandler(userID, creatorID string) (*Handler, *MockConfigStore, *MockAuthService) { //nolint:unparam // userID param intentional for future callers +func buildCancelOwnHandler(t *testing.T, userID, creatorID string) (*Handler, *MockConfigStore, *MockAuthService) { //nolint:unparam // userID param intentional for future callers + t.Helper() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "user-token").Return(&Session{UserID: userID}, nil) // Standard user: cancel-own only -- no delete, no cancel-any, no update-any. mockAuth.On("HasPermissionAPI", mock.Anything, userID, "delete", "purchases").Return(false, nil).Maybe() @@ -5302,6 +5433,7 @@ func buildCancelOwnHandler(userID, creatorID string) (*Handler, *MockConfigStore creator := creatorID exec := &config.PurchaseExecution{ExecutionID: ownExecID, Status: "pending", CreatedByUserID: &creator} mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetExecutionByID", mock.Anything, ownExecID).Return(exec, nil) return &Handler{config: mockConfig, auth: mockAuth}, mockConfig, mockAuth @@ -5317,7 +5449,7 @@ func manageReq() *events.LambdaFunctionURLRequest { // regression: user A pauses a scheduled purchase created by user B -> 403, // and the status transition never runs. func TestHandler_pausePlannedPurchase_NonOwner_Rejected(t *testing.T) { - handler, mockConfig, mockAuth := buildManageHandler(ownUserA, ownUserB, false) + handler, mockConfig, mockAuth := buildManageHandler(t, ownUserA, ownUserB, false) _, err := handler.pausePlannedPurchase(context.Background(), manageReq(), ownExecID) require.Error(t, err) @@ -5330,7 +5462,7 @@ func TestHandler_pausePlannedPurchase_NonOwner_Rejected(t *testing.T) { } func TestHandler_resumePlannedPurchase_NonOwner_Rejected(t *testing.T) { - handler, mockConfig, _ := buildManageHandler(ownUserA, ownUserB, false) + handler, mockConfig, _ := buildManageHandler(t, ownUserA, ownUserB, false) _, err := handler.resumePlannedPurchase(context.Background(), manageReq(), ownExecID) require.Error(t, err) @@ -5341,7 +5473,7 @@ func TestHandler_resumePlannedPurchase_NonOwner_Rejected(t *testing.T) { } func TestHandler_deletePlannedPurchase_NonOwner_Rejected(t *testing.T) { - handler, mockConfig, _ := buildManageHandler(ownUserA, ownUserB, false) + handler, mockConfig, _ := buildManageHandler(t, ownUserA, ownUserB, false) _, err := handler.deletePlannedPurchase(context.Background(), manageReq(), ownExecID) require.Error(t, err) @@ -5355,7 +5487,7 @@ func TestHandler_deletePlannedPurchase_NonOwner_Rejected(t *testing.T) { // Standard user with cancel-own:purchases (but NOT delete:purchases) can cancel // their own planned purchase from the Home page (issue #1400). func TestHandler_deletePlannedPurchase_CancelOwn_Creator_Allowed(t *testing.T) { - handler, mockConfig, _ := buildCancelOwnHandler(ownUserA, ownUserA) + handler, mockConfig, _ := buildCancelOwnHandler(t, ownUserA, ownUserA) canceled := &config.PurchaseExecution{ExecutionID: ownExecID, PlanID: "", Status: "canceled"} mockConfig.On("TransitionExecutionStatus", mock.Anything, ownExecID, []string{"pending", "notified", "paused"}, "canceled", mock.Anything). Return(canceled, nil) @@ -5369,7 +5501,7 @@ func TestHandler_deletePlannedPurchase_CancelOwn_Creator_Allowed(t *testing.T) { // that cancel-own:purchases does not let a Standard user cancel another user's // planned purchase (issue #1400). func TestHandler_deletePlannedPurchase_CancelOwn_NonCreator_Rejected(t *testing.T) { - handler, mockConfig, _ := buildCancelOwnHandler(ownUserA, ownUserB) + handler, mockConfig, _ := buildCancelOwnHandler(t, ownUserA, ownUserB) _, err := handler.deletePlannedPurchase(context.Background(), manageReq(), ownExecID) require.Error(t, err) @@ -5432,7 +5564,7 @@ func TestHandler_deletePlannedPurchase_UserAPIKey_Allowed(t *testing.T) { } func TestHandler_runPlannedPurchase_NonOwner_Rejected(t *testing.T) { - handler, mockConfig, _ := buildManageHandler(ownUserA, ownUserB, false) + handler, mockConfig, _ := buildManageHandler(t, ownUserA, ownUserB, false) _, err := handler.runPlannedPurchase(context.Background(), manageReq(), ownExecID) require.Error(t, err) @@ -5444,7 +5576,7 @@ func TestHandler_runPlannedPurchase_NonOwner_Rejected(t *testing.T) { // TestHandler_pausePlannedPurchase_Owner_Allowed: user A manages their OWN P1. func TestHandler_pausePlannedPurchase_Owner_Allowed(t *testing.T) { - handler, mockConfig, _ := buildManageHandler(ownUserA, ownUserA, false) + handler, mockConfig, _ := buildManageHandler(t, ownUserA, ownUserA, false) mockConfig.On("TransitionExecutionStatus", mock.Anything, ownExecID, []string{"pending", "notified"}, "paused", mock.Anything). Return(&config.PurchaseExecution{ExecutionID: ownExecID, Status: "paused"}, nil) @@ -5457,7 +5589,7 @@ func TestHandler_pausePlannedPurchase_Owner_Allowed(t *testing.T) { // TestHandler_pausePlannedPurchase_UpdateAny_AllowsAny: a privileged user with // update-any:purchases manages P2 created by user B. func TestHandler_pausePlannedPurchase_UpdateAny_AllowsAny(t *testing.T) { - handler, mockConfig, _ := buildManageHandler(ownUserA, ownUserB, true) + handler, mockConfig, _ := buildManageHandler(t, ownUserA, ownUserB, true) mockConfig.On("TransitionExecutionStatus", mock.Anything, ownExecID, []string{"pending", "notified"}, "paused", mock.Anything). Return(&config.PurchaseExecution{ExecutionID: ownExecID, Status: "paused"}, nil) @@ -5480,8 +5612,10 @@ func TestHandler_authorizeExecutionManagement_NilAuth(t *testing.T) { // with a NULL creator is unreachable for a non-update-any user. func TestHandler_authorizeExecutionManagement_LegacyNullCreator(t *testing.T) { mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("HasPermissionAPI", mock.Anything, ownUserA, "update-any", "purchases").Return(false, nil) mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetExecutionByID", mock.Anything, ownExecID). Return(&config.PurchaseExecution{ExecutionID: ownExecID, Status: "pending", CreatedByUserID: nil}, nil) handler := &Handler{config: mockConfig, auth: mockAuth} @@ -5820,6 +5954,7 @@ func TestHandler_revokePurchase_ValidToken(t *testing.T) { mockStore.On("SetCancelledBy", ctx, execID, revokerEmail).Return(nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() // Provide a session for the revoker so authorizeApprovalAction resolves actor. @@ -5862,6 +5997,7 @@ func TestHandler_revokePurchase_InvalidToken(t *testing.T) { } mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) mockStore.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { @@ -5869,6 +6005,7 @@ func TestHandler_revokePurchase_InvalidToken(t *testing.T) { } mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: contactEmail}, nil) @@ -5905,7 +6042,11 @@ func TestHandler_revokePurchase_PendingExecution(t *testing.T) { } mockStore := new(MockConfigStore) - mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + // Maybe: the session is required before the execution is loaded (issue + // #435). Registered so a lookup-first regression returns cleanly and is + // caught by the AssertNotCalled below. + mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil).Maybe() handler := &Handler{config: mockStore} req := &events.LambdaFunctionURLRequest{ @@ -5916,6 +6057,7 @@ func TestHandler_revokePurchase_PendingExecution(t *testing.T) { ce, ok := IsClientError(err) require.True(t, ok, "expected a client error") assert.Equal(t, 401, ce.code) + mockStore.AssertNotCalled(t, "GetExecutionByID", ctx, execID) } // TestHandler_revokePurchase_NotFound verifies 404 when the execution does not exist. @@ -5924,9 +6066,11 @@ func TestHandler_revokePurchase_NotFound(t *testing.T) { execID := "44444444-4444-4444-4444-444444444444" mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetExecutionByID", ctx, execID).Return(nil, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{Email: "admin@example.com"}, nil) // CSRF is validated before the execution lookup (issue #597). mockAuth.On("ValidateCSRFToken", mock.Anything, "sess-tok", "").Return(nil) @@ -5965,6 +6109,7 @@ func TestHandler_revokePurchase_ExpiredToken(t *testing.T) { } mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) mockStore.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { @@ -5972,6 +6117,7 @@ func TestHandler_revokePurchase_ExpiredToken(t *testing.T) { } mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: contactEmail}, nil) @@ -6464,6 +6610,7 @@ func TestRevokePurchase_POSTPerformsRevoke(t *testing.T) { mockStore.On("SetCancelledBy", ctx, execID, revokerEmail).Return(nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: revokerEmail}, nil) @@ -6518,6 +6665,7 @@ func TestRequireDifferentApprover_ModeOff_AllowsSameUser(t *testing.T) { exec := fourEyesExec(&creatorID) mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetGlobalConfig", ctx).Return(fourEyesCfgOff(), nil) handler := &Handler{config: mockConfig} @@ -6536,6 +6684,7 @@ func TestRequireDifferentApprover_ModeOn_DeniesSameUser(t *testing.T) { exec := fourEyesExec(&creatorID) mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetGlobalConfig", ctx).Return(fourEyesCfgOn(), nil) handler := &Handler{config: mockConfig} @@ -6558,6 +6707,7 @@ func TestRequireDifferentApprover_ModeOn_AllowsDifferentUser(t *testing.T) { exec := fourEyesExec(&creatorID) mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetGlobalConfig", ctx).Return(fourEyesCfgOn(), nil) handler := &Handler{config: mockConfig} @@ -6576,6 +6726,7 @@ func TestRequireDifferentApprover_ModeOn_DeniesAdminSelfApprove(t *testing.T) { exec := fourEyesExec(&adminID) mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetGlobalConfig", ctx).Return(fourEyesCfgOn(), nil) handler := &Handler{config: mockConfig} @@ -6597,6 +6748,7 @@ func TestRequireDifferentApprover_ModeOn_NullCreatorDenied(t *testing.T) { exec := fourEyesExec(nil) // NULL creator mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetGlobalConfig", ctx).Return(fourEyesCfgOn(), nil) handler := &Handler{config: mockConfig} @@ -6617,6 +6769,7 @@ func TestRequireDifferentApprover_NilAuth_500(t *testing.T) { exec := fourEyesExec(strptr("some-creator")) mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetGlobalConfig", ctx).Return(fourEyesCfgOn(), nil) handler := &Handler{config: mockConfig} @@ -6649,6 +6802,7 @@ func TestRequireDifferentApprover_EmailTokenPath_ModeOn(t *testing.T) { } mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockConfig.On("GetGlobalConfig", ctx).Return(fourEyesCfgOn(), nil) mockConfig.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { @@ -6658,6 +6812,7 @@ func TestRequireDifferentApprover_EmailTokenPath_ModeOn(t *testing.T) { // Session identifies the user as the CREATOR via UserID. creatorSession := &Session{UserID: creatorID, Email: contactEmail} mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() // Session exists but lacks approve-* permission, so the dispatch falls // through to the token branch. The 4-eyes check then uses the session @@ -6699,6 +6854,7 @@ func TestHandler_approvePurchaseViaSession_FourEyesOn_DeniesSelfApprove(t *testi adminEmail := "admin@example.com" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "valid-token", @@ -6718,6 +6874,7 @@ func TestHandler_approvePurchaseViaSession_FourEyesOn_DeniesSelfApprove(t *testi mockConfig.On("GetGlobalConfig", ctx).Return(fourEyesCfgOn(), nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{UserID: adminID, Email: adminEmail}, nil) mockAuth.grantAdmin() mockAuth.On("ValidateCSRFToken", ctx, "sess-tok", "").Return(nil) @@ -6755,6 +6912,7 @@ func TestHandler_approvePurchaseViaSession_FourEyesOn_DifferentApproverSucceeds( planID := "plan-fourEyes-session" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, PlanID: planID, @@ -6781,6 +6939,7 @@ func TestHandler_approvePurchaseViaSession_FourEyesOn_DifferentApproverSucceeds( mockConfig.On("SavePurchaseExecution", mock.Anything, mock.AnythingOfType("*config.PurchaseExecution")).Return(nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{UserID: approverID, Email: approverEmail}, nil) mockAuth.grantAdminPurchaser() mockAuth.On("ValidateCSRFToken", ctx, "sess-tok", "").Return(nil) @@ -6845,6 +7004,7 @@ func TestRequireDifferentApprover_ModeOn_SystemCreated(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetGlobalConfig", ctx).Return(fourEyesCfgOn(), nil) err := (&Handler{config: mockConfig}).requireDifferentApprover(ctx, tc.session, "exec-1", tc.creator, tc.system) if tc.code == 0 { diff --git a/internal/api/handler_purchases_token_errors_test.go b/internal/api/handler_purchases_token_errors_test.go index c0850bf5..41bf6abe 100644 --- a/internal/api/handler_purchases_token_errors_test.go +++ b/internal/api/handler_purchases_token_errors_test.go @@ -25,25 +25,43 @@ const tokenErrExecID = "12345678-1234-1234-1234-123456789abc" const tokenErrRawToken = "raw-secret-token" func tokenErrHandler(t *testing.T, exec *config.PurchaseExecution, getErr error) *Handler { + t.Helper() + return tokenErrHandlerFor(t, exec, getErr, true) +} + +// tokenErrHandlerFor is tokenErrHandler for a caller that is signedIn or not. +// A caller without a session is refused before the execution is looked up +// (issue #435), so for it neither the session, the row nor the approver +// address is expected, and a lookup would hit an unregistered method. +func tokenErrHandlerFor(t *testing.T, exec *config.PurchaseExecution, getErr error, signedIn bool) *Handler { t.Helper() approver := "admin@example.com" accountID := "acct-1" store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { return &config.CloudAccount{ID: id, ContactEmail: approver}, nil } - if getErr != nil { - store.On("GetExecutionByID", mock.Anything, tokenErrExecID).Return(nil, getErr) - } else { + if exec != nil { exec.ExecutionID = tokenErrExecID exec.Recommendations = []config.RecommendationRecord{{ID: "r1", CloudAccountID: &accountID}} - store.On("GetExecutionByID", mock.Anything, tokenErrExecID).Return(exec, nil) } - store.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{NotificationEmail: &approver}, nil) + if signedIn { + if getErr != nil { + store.On("GetExecutionByID", mock.Anything, tokenErrExecID).Return(nil, getErr) + } else { + store.On("GetExecutionByID", mock.Anything, tokenErrExecID).Return(exec, nil) + // The approver address is resolved only for a row that exists. + store.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{NotificationEmail: &approver}, nil) + } + } auth := new(MockAuthService) + t.Cleanup(func() { auth.AssertExpectations(t) }) auth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() - auth.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{Email: approver}, nil) + if signedIn { + auth.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{Email: approver}, nil) + } // CSRF is validated before the execution lookup (issue #597). auth.On("ValidateCSRFToken", mock.Anything, "sess-tok", "").Return(nil).Maybe() for _, perm := range []string{"approve-any", "approve-own", "cancel-any", "cancel-own", "revoke-any", "revoke-own"} { @@ -114,6 +132,7 @@ func TestHandleRequest_ApproveViaToken_NonPendingExecutionIs409(t *testing.T) { exec := &config.PurchaseExecution{Status: "completed", ApprovalToken: config.HashApprovalToken(tokenErrRawToken), ApprovalTokenExpiresAt: &future} h := tokenErrHandler(t, exec, nil) store := h.config.(*MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("TransitionExecutionStatusTokenPinned", mock.Anything, tokenErrExecID, mock.Anything, "running", mock.Anything, tokenErrRawToken). Return(nil, config.ErrExecutionNotInExpectedStatus) @@ -134,6 +153,7 @@ func TestHandleRequest_CancelViaToken_LostCASIs409(t *testing.T) { exec := &config.PurchaseExecution{Status: "pending", ApprovalToken: config.HashApprovalToken(tokenErrRawToken), ApprovalTokenExpiresAt: &future} h := tokenErrHandler(t, exec, nil) store := h.config.(*MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("CancelExecutionAtomicTokenPinned", mock.Anything, mock.Anything, tokenErrExecID, mock.Anything, tokenErrRawToken). Return(false, "approved", nil) diff --git a/internal/api/handler_purchases_token_oracle_test.go b/internal/api/handler_purchases_token_oracle_test.go index 358b3b9a..2ce37f0f 100644 --- a/internal/api/handler_purchases_token_oracle_test.go +++ b/internal/api/handler_purchases_token_oracle_test.go @@ -41,8 +41,8 @@ func TestHandleRequest_TokenActions_UnauthenticatedCannotTellMissingFromExisting } exec := &config.PurchaseExecution{Status: "pending", ApprovalToken: hash, ApprovalTokenExpiresAt: &future} - existingH := tokenErrHandler(t, exec, nil) - missingH := tokenErrHandler(t, nil, config.ErrNotFound) + existingH := tokenErrHandlerFor(t, exec, nil, false) + missingH := tokenErrHandlerFor(t, nil, config.ErrNotFound, false) for _, h := range []*Handler{existingH, missingH} { h.auth.(*MockAuthService).On("ValidateSession", mock.Anything, "bad-tok").Return(nil, errors.New("invalid session")).Maybe() } @@ -98,11 +98,17 @@ func oracleHandler(t *testing.T, caller oracleCaller, exec *config.PurchaseExecu accountID := "acct-1" creator := "creator-user-id" store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { return &config.CloudAccount{ID: id, Name: id, ContactEmail: "contact@example.com"}, nil } + // The row lookup and the session read are Maybe: whether they run depends + // on the caller and the route. A caller without a session or a CSRF token + // is refused before the lookup (issues #435 and #597), which the tests pin + // by comparing the existing and missing answers and, where it matters, + // with an explicit AssertNotCalled. if exec == nil { - store.On("GetExecutionByID", mock.Anything, tokenErrExecID).Return(nil, config.ErrNotFound) + store.On("GetExecutionByID", mock.Anything, tokenErrExecID).Return(nil, config.ErrNotFound).Maybe() } else { exec.ExecutionID = tokenErrExecID exec.CloudAccountID = &accountID @@ -112,7 +118,7 @@ func oracleHandler(t *testing.T, caller oracleCaller, exec *config.PurchaseExecu exec.CloudAccountID = nil exec.Recommendations = []config.RecommendationRecord{{ID: "r1", Provider: "azure", UpfrontCost: 100}} } - store.On("GetExecutionByID", mock.Anything, tokenErrExecID).Return(exec, nil) + store.On("GetExecutionByID", mock.Anything, tokenErrExecID).Return(exec, nil).Maybe() store.On("TransitionExecutionStatus", mock.Anything, tokenErrExecID, mock.Anything, mock.Anything, mock.Anything). Return(nil, config.ErrExecutionNotInExpectedStatus).Maybe() store.On("CancelExecutionAtomic", mock.Anything, mock.Anything, tokenErrExecID, mock.Anything). @@ -121,7 +127,8 @@ func oracleHandler(t *testing.T, caller oracleCaller, exec *config.PurchaseExecu store.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{}, nil).Maybe() m := new(MockAuthService) - m.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{UserID: "caller-user-id", Email: caller.email}, nil) + t.Cleanup(func() { m.AssertExpectations(t) }) + m.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{UserID: "caller-user-id", Email: caller.email}, nil).Maybe() m.On("ValidateCSRFToken", mock.Anything, "sess-tok", oracleCSRF).Return(nil).Maybe() m.On("ValidateCSRFToken", mock.Anything, "sess-tok", "").Return(errors.New("missing CSRF token")).Maybe() caller.grant(m) diff --git a/internal/api/handler_query_param_validation_test.go b/internal/api/handler_query_param_validation_test.go index 23249e88..667b8af9 100644 --- a/internal/api/handler_query_param_validation_test.go +++ b/internal/api/handler_query_param_validation_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/aws/aws-lambda-go/events" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -33,7 +34,7 @@ func TestHandler_getDashboardSummary_RejectsUnknownProvider(t *testing.T) { t.Run(provider, func(t *testing.T) { mockScheduler := new(MockScheduler) mockStore := new(MockConfigStore) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) h := &Handler{auth: mockAuth, scheduler: mockScheduler, config: mockStore} _, err := h.getDashboardSummary(ctx, req, map[string]string{"provider": provider}) @@ -49,6 +50,7 @@ func TestHandler_getDashboardSummary_AllProviderMeansNoFilter(t *testing.T) { ctx := context.Background() mockScheduler := new(MockScheduler) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockScheduler.On("ListRecommendations", mock.Anything, config.RecommendationFilter{Provider: ""}). Return([]config.RecommendationRecord{{Provider: "aws", Service: "ec2", ResourceType: "m5.large", Savings: 40}}, nil) @@ -61,7 +63,7 @@ func TestHandler_getDashboardSummary_AllProviderMeansNoFilter(t *testing.T) { UpfrontCost: new(float64), }}, nil) - mockAuth, req := adminDashboardReq(ctx) + mockAuth, req := adminDashboardReq(t, ctx) h := &Handler{auth: mockAuth, scheduler: mockScheduler, config: mockStore} result, err := h.getDashboardSummary(ctx, req, map[string]string{"provider": "all"}) @@ -84,14 +86,12 @@ func inventoryRowsForProviderTests() []config.PurchaseHistoryRecord { func TestHandler_inventory_RejectsUnknownProvider(t *testing.T) { ctx := context.Background() - reads := map[string]func(*Handler, map[string]string) error{ - "commitments": func(h *Handler, p map[string]string) error { - _, req := adminInventoryReq(ctx) + reads := map[string]func(*Handler, *events.LambdaFunctionURLRequest, map[string]string) error{ + "commitments": func(h *Handler, req *events.LambdaFunctionURLRequest, p map[string]string) error { _, err := h.listActiveCommitments(ctx, req, p) return err }, - "coverage": func(h *Handler, p map[string]string) error { - _, req := adminInventoryReq(ctx) + "coverage": func(h *Handler, req *events.LambdaFunctionURLRequest, p map[string]string) error { _, err := h.getCoverageBreakdown(ctx, req, p) return err }, @@ -99,10 +99,10 @@ func TestHandler_inventory_RejectsUnknownProvider(t *testing.T) { for name, read := range reads { t.Run(name, func(t *testing.T) { mockStore := new(MockConfigStore) - mockAuth, _ := adminInventoryReq(ctx) + mockAuth, req := adminInventoryReq(t, ctx) h := &Handler{auth: mockAuth, config: mockStore, scheduler: new(MockScheduler)} - err := read(h, map[string]string{"provider": "AWS"}) + err := read(h, req, map[string]string{"provider": "AWS"}) requireBadRequest(t, err) mockStore.AssertNotCalled(t, "GetActivePurchaseHistory", mock.Anything, mock.Anything, mock.Anything, mock.Anything) @@ -113,12 +113,13 @@ func TestHandler_inventory_RejectsUnknownProvider(t *testing.T) { func TestHandler_listActiveCommitments_AllProviderMeansNoFilter(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetActivePurchaseHistory", ctx, mock.AnythingOfType("time.Time"), []string(nil), map[string][]string(nil)). Return(inventoryRowsForProviderTests(), nil) mockStore.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { return []config.CloudAccount{{ID: "acc-1", Name: "Account One"}}, nil } - mockAuth, req := adminInventoryReq(ctx) + mockAuth, req := adminInventoryReq(t, ctx) h := &Handler{auth: mockAuth, config: mockStore} result, err := h.listActiveCommitments(ctx, req, map[string]string{"provider": "all"}) @@ -130,15 +131,17 @@ func TestHandler_listActiveCommitments_AllProviderMeansNoFilter(t *testing.T) { func TestHandler_getCoverageBreakdown_AllProviderMeansNoFilter(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetActivePurchaseHistory", ctx, mock.AnythingOfType("time.Time"), []string(nil), map[string][]string(nil)). Return(inventoryRowsForProviderTests(), nil) mockStore.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { return []config.CloudAccount{{ID: "acc-1", Name: "Account One"}}, nil } mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockScheduler.On("ListRecommendations", mock.Anything, mock.Anything). Return([]config.RecommendationRecord{{Provider: "aws", Service: "ec2", Savings: 20}}, nil) - mockAuth, req := adminInventoryReq(ctx) + mockAuth, req := adminInventoryReq(t, ctx) h := &Handler{auth: mockAuth, config: mockStore, scheduler: mockScheduler} result, err := h.getCoverageBreakdown(ctx, req, map[string]string{"provider": "all"}) @@ -178,6 +181,7 @@ func TestListAccounts_RejectsUnknownEnumParams(t *testing.T) { for name, params := range cases { t.Run(listName+" "+name, func(t *testing.T) { mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) setupAdminAuth(ctx, mockAuth) store := new(MockConfigStore) listed := false @@ -200,6 +204,7 @@ func TestListAccounts_AcceptedFilterValues(t *testing.T) { listWith := func(t *testing.T, params map[string]string) config.CloudAccountFilter { t.Helper() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) setupAdminAuth(ctx, mockAuth) store := new(MockConfigStore) var got config.CloudAccountFilter diff --git a/internal/api/handler_recommendations_page_test.go b/internal/api/handler_recommendations_page_test.go index e5f3ebd0..d87b6544 100644 --- a/internal/api/handler_recommendations_page_test.go +++ b/internal/api/handler_recommendations_page_test.go @@ -41,13 +41,19 @@ func pageTestRecs(n int) []config.RecommendationRecord { return recs } -func pageTestHandler(t *testing.T, recs []config.RecommendationRecord) *Handler { +// pageTestHandler serves recs through the read each request mode uses: +// unpaginated requests read the full list, paginated ones scan it in ID order +// (issue #981). list and scan say which reads the test's requests must make. +func pageTestHandler(t *testing.T, recs []config.RecommendationRecord, list, scan bool) *Handler { t.Helper() ms := new(MockScheduler) - // Unpaginated requests read the full list; paginated ones scan it in - // ID order (issue #981). - ms.On("ListRecommendations", mock.Anything, mock.Anything).Return(recs, nil) - ms.On("ScanRecommendations", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(recs, nil) + t.Cleanup(func() { ms.AssertExpectations(t) }) + if list { + ms.On("ListRecommendations", mock.Anything, mock.Anything).Return(recs, nil) + } + if scan { + ms.On("ScanRecommendations", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(recs, nil) + } return &Handler{scheduler: ms, apiKey: "test-key"} } @@ -75,7 +81,7 @@ func pageTestReq() *events.LambdaFunctionURLRequest { func TestGetRecommendations_PaginationBoundsResponse(t *testing.T) { const total = 8000 recs := pageTestRecs(total) - h := pageTestHandler(t, recs) + h := pageTestHandler(t, recs, false, true) full, err := json.Marshal(buildRecommendationsResponse(append([]config.RecommendationRecord(nil), recs...))) require.NoError(t, err) @@ -128,7 +134,7 @@ func TestGetRecommendations_UnpaginatedShapeUnchanged(t *testing.T) { for i := range recs { wantIDs[i] = recs[i].ID } - h := pageTestHandler(t, recs) + h := pageTestHandler(t, recs, true, false) resp, err := h.getRecommendations(context.Background(), pageTestReq(), map[string]string{}) require.NoError(t, err) @@ -154,7 +160,7 @@ func TestGetRecommendations_UnpaginatedShapeUnchanged(t *testing.T) { func TestGetRecommendations_PaginationRejectsDuplicateIDs(t *testing.T) { recs := pageTestRecs(4) recs[1].ID = recs[2].ID - h := pageTestHandler(t, recs) + h := pageTestHandler(t, recs, true, true) ctx := context.Background() _, err := h.getRecommendations(ctx, pageTestReq(), map[string]string{"limit": "2"}) @@ -168,7 +174,7 @@ func TestGetRecommendations_PaginationRejectsDuplicateIDs(t *testing.T) { func TestGetRecommendations_PaginationPages(t *testing.T) { recs := pageTestRecs(5) // rec-000000 .. rec-000004, stored in reverse - h := pageTestHandler(t, recs) + h := pageTestHandler(t, recs, false, true) ctx := context.Background() first, err := h.getRecommendations(ctx, pageTestReq(), map[string]string{"limit": "2"}) @@ -214,7 +220,7 @@ func TestGetRecommendations_PaginationPages(t *testing.T) { func TestGetRecommendations_CursorWithoutLimitUsesDefaultPageSize(t *testing.T) { recs := pageTestRecs(DefaultRecommendationsPageSize + 3) - h := pageTestHandler(t, recs) + h := pageTestHandler(t, recs, false, true) resp, err := h.getRecommendations(context.Background(), pageTestReq(), map[string]string{ "cursor": encodeRecommendationsCursor("rec-"), @@ -258,7 +264,7 @@ func TestGetRecommendations_PaginationRejectsBadParams(t *testing.T) { } func TestGetRecommendations_PaginationMaxLimitAccepted(t *testing.T) { - h := pageTestHandler(t, pageTestRecs(3)) + h := pageTestHandler(t, pageTestRecs(3), false, true) resp, err := h.getRecommendations(context.Background(), pageTestReq(), map[string]string{ "limit": strconv.Itoa(MaxRecommendationsPageSize), }) @@ -307,7 +313,7 @@ func lambdaPayloadLen(t *testing.T, resp *RecommendationsResponse) (bodyInEnvelo func TestGetRecommendations_PageCutByByteBudget(t *testing.T) { const total = 1500 recs := bigDetailsRecs(total, 1500) // ~10 KB per row in the envelope - h := pageTestHandler(t, recs) + h := pageTestHandler(t, recs, false, true) _, fullSize := lambdaPayloadLen(t, buildRecommendationsResponse(append([]config.RecommendationRecord(nil), recs...))) require.Greater(t, fullSize, 2*lambdaResponseCapBytes, "fixture must be far over the cap") diff --git a/internal/api/handler_recommendations_scan_test.go b/internal/api/handler_recommendations_scan_test.go index 7a43669e..34ade3b3 100644 --- a/internal/api/handler_recommendations_scan_test.go +++ b/internal/api/handler_recommendations_scan_test.go @@ -82,6 +82,7 @@ func scanTestHandler(recs []config.RecommendationRecord, chunk int, scoped bool) func legacyResponse(t *testing.T, recs []config.RecommendationRecord, scoped bool) *RecommendationsResponse { t.Helper() ms := new(MockScheduler) + t.Cleanup(func() { ms.AssertExpectations(t) }) ms.On("ListRecommendations", mock.Anything, mock.Anything). Return(append([]config.RecommendationRecord(nil), recs...), nil) accounts := []string(nil) @@ -100,6 +101,7 @@ func legacyResponse(t *testing.T, recs []config.RecommendationRecord, scoped boo func TestGetRecommendations_PaginatedRequestDoesNotLoadFullList(t *testing.T) { recs := pageTestRecs(5) ms := new(MockScheduler) + t.Cleanup(func() { ms.AssertExpectations(t) }) ms.On("ScanRecommendations", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(recs, nil) h := &Handler{scheduler: ms, apiKey: "test-key"} diff --git a/internal/api/handler_recommendations_test.go b/internal/api/handler_recommendations_test.go index 776e3722..7fc8248c 100644 --- a/internal/api/handler_recommendations_test.go +++ b/internal/api/handler_recommendations_test.go @@ -18,6 +18,7 @@ import ( func TestHandler_getRecommendations(t *testing.T) { ctx := context.Background() mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) // Mock the scheduler to return empty recommendations mockScheduler.On("ListRecommendations", mock.Anything, mock.Anything).Return([]config.RecommendationRecord{}, nil) @@ -124,6 +125,7 @@ func TestHandler_getRecommendationsFreshness(t *testing.T) { t.Run("returns freshness payload", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) now := time.Now().UTC() mockStore.On("GetRecommendationsFreshness", ctx). Return(&config.RecommendationsFreshness{LastCollectedAt: &now}, nil) @@ -144,6 +146,7 @@ func TestHandler_getRecommendationsFreshness(t *testing.T) { // partial sweep must reach it as a structured, sanitized field. t.Run("surfaces last_collection_partial", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) now := time.Now().UTC() partial := &config.RecommendationsCollectionPartial{ Providers: []config.PartialProviderSweep{{ @@ -180,6 +183,7 @@ func TestHandler_getRecommendationsFreshness(t *testing.T) { t.Run("complete collection serializes last_collection_partial as null", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetRecommendationsFreshness", ctx).Return(&config.RecommendationsFreshness{}, nil) handler := &Handler{config: mockStore, apiKey: "test-key"} @@ -197,6 +201,7 @@ func TestHandler_getRecommendationsFreshness(t *testing.T) { t.Run("surfaces store error", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetRecommendationsFreshness", ctx).Return(nil, errors.New("db down")) handler := &Handler{config: mockStore, apiKey: "test-key"} @@ -271,6 +276,7 @@ func TestHandler_getRecommendationDetail(t *testing.T) { t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetRecommendationsFreshness", ctx). Return(&config.RecommendationsFreshness{LastCollectedAt: &now}, nil) @@ -311,6 +317,7 @@ func TestHandler_getRecommendationDetail(t *testing.T) { t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetRecommendationsFreshness", ctx). Return(&config.RecommendationsFreshness{LastCollectedAt: &now}, nil) @@ -339,6 +346,7 @@ func TestHandler_getRecommendationDetail(t *testing.T) { t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetRecommendationsFreshness", ctx). Return(nil, errors.New("db down")) @@ -453,6 +461,7 @@ func TestGetRecommendations_AccountIDFilter(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) if tt.wantStatus == 0 { mockScheduler.On("ListRecommendations", mock.Anything, mock.Anything). Return(tt.mockReturn, nil) diff --git a/internal/api/handler_registrations_approve_test.go b/internal/api/handler_registrations_approve_test.go index d0d35270..82be0aa9 100644 --- a/internal/api/handler_registrations_approve_test.go +++ b/internal/api/handler_registrations_approve_test.go @@ -34,10 +34,13 @@ func pendingRegistrationFixture() *config.AccountRegistration { // newApproveRegistrationHandler wires an admin session and a pending // registration. The pre-#1102 steps are registered with .Maybe() so a // regression to them fails the AssertNotCalled checks instead of panicking. -func newApproveRegistrationHandler(ctx context.Context, approveErr error) (*Handler, *MockConfigStore) { +func newApproveRegistrationHandler(t *testing.T, ctx context.Context, approveErr error) (*Handler, *MockConfigStore) { + t.Helper() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) setupAdminAuth(ctx, mockAuth) store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetAccountRegistration", ctx, approveRegID).Return(pendingRegistrationFixture(), nil) store.On("ApproveAccountRegistration", ctx, mock.Anything, mock.Anything, mock.Anything).Return(approveErr) store.On("TransitionRegistrationStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() @@ -57,7 +60,7 @@ func assertNoPiecemealApproval(t *testing.T, store *MockConfigStore) { func TestApproveRegistration_ApprovesAndCreatesInOneStoreCall(t *testing.T) { ctx := context.Background() - h, store := newApproveRegistrationHandler(ctx, nil) + h, store := newApproveRegistrationHandler(t, ctx, nil) result, err := h.approveRegistration(ctx, adminRequest(approveRegBody), approveRegID) require.NoError(t, err) @@ -94,7 +97,7 @@ func TestApproveRegistration_StoreErrorsMapToResponses(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() - h, store := newApproveRegistrationHandler(ctx, tc.storeErr) + h, store := newApproveRegistrationHandler(t, ctx, tc.storeErr) result, err := h.approveRegistration(ctx, adminRequest(approveRegBody), approveRegID) assert.Nil(t, result) diff --git a/internal/api/handler_registrations_credentials_test.go b/internal/api/handler_registrations_credentials_test.go index a62aa0c7..c810153a 100644 --- a/internal/api/handler_registrations_credentials_test.go +++ b/internal/api/handler_registrations_credentials_test.go @@ -158,6 +158,7 @@ func approveWithCredential(t *testing.T, credType, payload, approvalBody string) t.Helper() ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetAccountRegistration", mock.Anything, regCredRegID).Return(&config.AccountRegistration{ ID: regCredRegID, Status: "pending", Provider: "gcp", ExternalID: "test-project", RegCredentialType: credType, RegCredentialPayload: payload, @@ -165,8 +166,10 @@ func approveWithCredential(t *testing.T, credType, payload, approvalBody string) store.On("ApproveAccountRegistration", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() store.On("TransitionRegistrationStatus", mock.Anything, mock.Anything, "pending", mock.Anything).Return(nil).Maybe() store.On("UpdateAccountRegistration", mock.Anything, mock.Anything).Return(nil).Maybe() + // The router enforces the approve permission; called directly with no + // bearer token, approveRegistration resolves no session, so the auth mock + // carries no expectations (an unexpected auth call would panic). mockAuth := new(MockAuthService) - setupAdminAuth(ctx, mockAuth) creds := &recordingCredStore{} h := &Handler{auth: mockAuth, config: store, credStore: creds} result, err := h.approveRegistration(ctx, &events.LambdaFunctionURLRequest{Body: approvalBody}, regCredRegID) diff --git a/internal/api/handler_registrations_recipients_test.go b/internal/api/handler_registrations_recipients_test.go index f05c40cd..e6fd4cbf 100644 --- a/internal/api/handler_registrations_recipients_test.go +++ b/internal/api/handler_registrations_recipients_test.go @@ -15,6 +15,7 @@ func TestHandler_resolveRegistrationRecipients_AdminsBecomeApprovers(t *testing. globalNotify := "global@cudly.example" mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminGroup := []string{"00000000-0000-5000-8000-000000000001"} mockAuth.On("ListUsersAPI", ctx).Return([]*auth.APIUser{ {Email: "admin-a@example.com", Groups: adminGroup}, @@ -24,6 +25,7 @@ func TestHandler_resolveRegistrationRecipients_AdminsBecomeApprovers(t *testing. }, nil) mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{ NotificationEmail: &globalNotify, }, nil) @@ -43,11 +45,13 @@ func TestHandler_resolveRegistrationRecipients_NoAdminsTriggersBroadcastFallback globalNotify := "global@cudly.example" mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ListUsersAPI", ctx).Return([]*auth.APIUser{ {Email: "user@example.com"}, }, nil) mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{ NotificationEmail: &globalNotify, }, nil) @@ -70,11 +74,13 @@ func TestHandler_resolveRegistrationRecipients_OmitsGlobalWhenAlreadyAdmin(t *te globalNotify := "admin-a@example.com" mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ListUsersAPI", ctx).Return([]*auth.APIUser{ {Email: "admin-a@example.com", Groups: []string{"00000000-0000-5000-8000-000000000001"}}, }, nil) mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockConfig.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{ NotificationEmail: &globalNotify, }, nil) diff --git a/internal/api/handler_registrations_submit_test.go b/internal/api/handler_registrations_submit_test.go index a84fa520..71971117 100644 --- a/internal/api/handler_registrations_submit_test.go +++ b/internal/api/handler_registrations_submit_test.go @@ -41,6 +41,7 @@ func submitRegistrationRequest() *events.LambdaFunctionURLRequest { func TestSubmitRegistration_DuplicatePending_Returns409WithoutEmail(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("CreateAccountRegistration", ctx, mock.Anything). Return(fmt.Errorf("%w: %w", config.ErrDuplicatePendingRegistration, &pgconn.PgError{Code: "23505", ConstraintName: "idx_account_registrations_pending_unique"})).Once() @@ -73,6 +74,7 @@ func TestSubmitRegistration_NonDuplicateErrors_Return500(t *testing.T) { t.Run(name, func(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("CreateAccountRegistration", ctx, mock.Anything).Return(storeErr).Once() notifier := &countingRegistrationNotifier{} h := &Handler{config: store, emailNotifier: notifier} @@ -96,6 +98,7 @@ func TestSubmitRegistration_NonDuplicateErrors_Return500(t *testing.T) { func TestSubmitRegistration_TokenCollision_RetriesWithNewToken(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) var tokens []string collision := fmt.Errorf("%w: %w", config.ErrReferenceTokenCollision, &pgconn.PgError{Code: "23505", ConstraintName: "account_registrations_reference_token_key"}) @@ -117,6 +120,7 @@ func TestSubmitRegistration_TokenCollision_RetriesWithNewToken(t *testing.T) { func TestSubmitRegistration_TokenCollision_GivesUpAs500(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) collision := fmt.Errorf("%w: %w", config.ErrReferenceTokenCollision, &pgconn.PgError{Code: "23505", ConstraintName: "account_registrations_reference_token_key"}) store.On("CreateAccountRegistration", ctx, mock.Anything).Return(collision) @@ -135,6 +139,7 @@ func TestSubmitRegistration_TokenCollision_GivesUpAs500(t *testing.T) { func TestSubmitRegistration_StoresBareContactEmail(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) var stored *config.AccountRegistration store.On("CreateAccountRegistration", ctx, mock.Anything).Run(func(args mock.Arguments) { stored = args.Get(1).(*config.AccountRegistration) diff --git a/internal/api/handler_registrations_token_test.go b/internal/api/handler_registrations_token_test.go index c7978cdd..ad5e4574 100644 --- a/internal/api/handler_registrations_token_test.go +++ b/internal/api/handler_registrations_token_test.go @@ -33,6 +33,7 @@ func regTokenHandler(t *testing.T) (*Handler, *MockConfigStore) { t.Helper() store := new(MockConfigStore) m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("ValidateSession", mock.Anything, regTokenSession). Return(&Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"}, nil) m.grantAdmin() @@ -72,6 +73,7 @@ func assertNoReferenceToken(t *testing.T, body string) { func TestListRegistrations_DoesNotExposeReferenceToken(t *testing.T) { h, store := regTokenHandler(t) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("ListAccountRegistrations", mock.Anything, mock.Anything). Return([]config.AccountRegistration{regTokenFixture()}, nil) @@ -86,6 +88,7 @@ func TestListRegistrations_DoesNotExposeReferenceToken(t *testing.T) { func TestGetRegistration_DoesNotExposeReferenceToken(t *testing.T) { h, store := regTokenHandler(t) + t.Cleanup(func() { store.AssertExpectations(t) }) reg := regTokenFixture() store.On("GetAccountRegistration", mock.Anything, regTokenID).Return(®, nil) diff --git a/internal/api/handler_ri_exchange_azure_test.go b/internal/api/handler_ri_exchange_azure_test.go index 106fb146..097be595 100644 --- a/internal/api/handler_ri_exchange_azure_test.go +++ b/internal/api/handler_ri_exchange_azure_test.go @@ -2338,6 +2338,7 @@ func TestExecuteAzureExchange_FourEyesRefusesDirectExecute(t *testing.T) { h := newAzureExecuteSourceGateHandler(t, opsClient) store, ok := h.config.(*MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) require.True(t, ok) store.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{RequireDifferentApprover: true}, nil) @@ -2365,6 +2366,7 @@ func TestExecuteAzureExchange_FourEyesPolicyLookupErrorFailsClosed(t *testing.T) h := newAzureExecuteSourceGateHandler(t, opsClient) store, ok := h.config.(*MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) require.True(t, ok) store.On("GetGlobalConfig", mock.Anything).Return(nil, errors.New("db down")) diff --git a/internal/api/handler_ri_exchange_history_scope_test.go b/internal/api/handler_ri_exchange_history_scope_test.go index 5e57e25a..0898a30f 100644 --- a/internal/api/handler_ri_exchange_history_scope_test.go +++ b/internal/api/handler_ri_exchange_history_scope_test.go @@ -34,6 +34,7 @@ func runExchangeHistoryScopeCases(t *testing.T, accounts []config.CloudAccount, t.Parallel() ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "tok").Return(&Session{UserID: "uid"}, nil) mockAuth.grantScoped(tc.scope...) store := new(MockConfigStore) @@ -149,7 +150,7 @@ func TestHandler_getRIExchangeHistory_AccountListErrorFailsClosed(t *testing.T) rows := []config.RIExchangeRecord{{ID: "b-1", AccountID: exchangeAcctB}} store.On("GetRIExchangeHistory", ctx, mock.Anything, 500).Return(rows, nil).Maybe() store.On("GetRIExchangeHistoryForAccounts", ctx, mock.Anything, 500, mock.Anything).Return(rows, nil).Maybe() - h, req := scopedExchangeHistoryHandler(ctx, store) + h, req := scopedExchangeHistoryHandler(t, ctx, store) store.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { return nil, errors.New("list failed") } diff --git a/internal/api/handler_ri_exchange_test.go b/internal/api/handler_ri_exchange_test.go index a2a1c730..462cdd73 100644 --- a/internal/api/handler_ri_exchange_test.go +++ b/internal/api/handler_ri_exchange_test.go @@ -345,6 +345,7 @@ func TestApproveRIExchange_DoubleApprove(t *testing.T) { func TestApproveRIExchange_InvalidToken(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) h := &Handler{config: mockStore} ctx := context.Background() id := "550e8400-e29b-41d4-a716-446655440003" @@ -367,7 +368,9 @@ func TestApproveRIExchange_InvalidToken(t *testing.T) { // a pending RI exchange without an email token (issue #300). func TestApproveRIExchange_SessionAdmin(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) h := &Handler{config: mockStore, auth: mockAuth} ctx := context.Background() id := "550e8400-e29b-41d4-a716-446655440010" @@ -395,8 +398,8 @@ func TestApproveRIExchange_SessionAdmin(t *testing.T) { mockStore.On("TransitionRIExchangeStatus", ctx, id, "pending", "processing", mock.Anything). Return(&config.RIExchangeRecord{ID: id, Status: "processing", SourceRIIDs: []string{"ri-123"}, PaymentDue: "100.00"}, nil) - // executeApprovedExchange calls GetGlobalConfig + ReserveApprovedRIExchange - mockStore.On("ReserveApprovedRIExchange", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("500.000000", nil) + // The record carries no region, so execution fails closed at the region + // guard before ReserveApprovedRIExchange: the approval ends in FailRIExchange. mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{ RIExchangeMaxDailyUSD: 1000, RIExchangeMaxPerExchangeUSD: 500, @@ -433,7 +436,9 @@ func TestApproveRIExchange_SessionApproveOwn(t *testing.T) { t.Run("own exchange allowed", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) h := &Handler{config: mockStore, auth: mockAuth} ownerSession := &Session{UserID: ownerID, Email: "owner@example.com"} @@ -456,7 +461,8 @@ func TestApproveRIExchange_SessionApproveOwn(t *testing.T) { mockStore.On("TransitionRIExchangeStatus", ctx, id, "pending", "processing", mock.Anything). Return(&config.RIExchangeRecord{ID: id, Status: "processing", SourceRIIDs: []string{"ri-1"}, PaymentDue: "50.00"}, nil) - mockStore.On("ReserveApprovedRIExchange", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("500.000000", nil) + // The record carries no region, so execution fails closed at the region + // guard before ReserveApprovedRIExchange: the approval ends in FailRIExchange. mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{ RIExchangeMaxDailyUSD: 1000, RIExchangeMaxPerExchangeUSD: 500, @@ -477,7 +483,9 @@ func TestApproveRIExchange_SessionApproveOwn(t *testing.T) { t.Run("other user exchange rejected", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) h := &Handler{config: mockStore, auth: mockAuth} ownerSession := &Session{UserID: ownerID, Email: "owner@example.com"} @@ -520,9 +528,10 @@ func TestApproveRIExchange_SessionScope(t *testing.T) { id := "550e8400-e29b-41d4-a716-446655440019" const accA, accB = "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222" - setup := func(deploymentAccount string, action string) (*Handler, *MockConfigStore) { + setup := func(t *testing.T, deploymentAccount string, action string) (*Handler, *MockConfigStore) { mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "sess").Return(&Session{UserID: "approver-uuid", Email: "approver@example.com"}, nil) mockAuth.On("ValidateCSRFToken", ctx, "sess", "csrf").Return(nil) mockAuth.grantPermissionsScoped([]auth.Permission{{Action: action, Resource: auth.ResourcePurchases}}, []string{accA}) @@ -549,7 +558,7 @@ func TestApproveRIExchange_SessionScope(t *testing.T) { } t.Run("out of scope is not found", func(t *testing.T) { - h, mockStore := setup(accB, auth.ActionApproveAny) + h, mockStore := setup(t, accB, auth.ActionApproveAny) _, err := h.approveRIExchange(ctx, csrfSessionReq(), id, "") require.ErrorIs(t, err, errNotFound) mockStore.AssertNotCalled(t, "GetRIExchangeRecord", ctx, id) @@ -557,21 +566,21 @@ func TestApproveRIExchange_SessionScope(t *testing.T) { }) t.Run("out of scope approve-own is not found", func(t *testing.T) { - h, mockStore := setup(accB, auth.ActionApproveOwn) + h, mockStore := setup(t, accB, auth.ActionApproveOwn) _, err := h.approveRIExchange(ctx, csrfSessionReq(), id, "") require.ErrorIs(t, err, errNotFound) mockStore.AssertNotCalled(t, "TransitionRIExchangeStatus", ctx, id, "pending", "processing", mock.Anything) }) t.Run("out of scope with a valid token falls through to the token flow", func(t *testing.T) { - h, mockStore := setup(accB, auth.ActionApproveAny) + h, mockStore := setup(t, accB, auth.ActionApproveAny) _, err := h.approveRIExchange(ctx, csrfSessionReq(), id, "tok") require.NoError(t, err) mockStore.AssertCalled(t, "TransitionRIExchangeStatus", ctx, id, "pending", "processing", mock.Anything) }) t.Run("scope resolver error fails closed", func(t *testing.T) { - h, mockStore := setup(accA, auth.ActionApproveAny) + h, mockStore := setup(t, accA, auth.ActionApproveAny) h.reshapeAccountResolver = func(context.Context) (string, error) { return "", errors.New("resolver down") } _, err := h.approveRIExchange(ctx, csrfSessionReq(), id, "") require.Error(t, err) @@ -580,7 +589,7 @@ func TestApproveRIExchange_SessionScope(t *testing.T) { }) t.Run("in scope is approved", func(t *testing.T) { - h, mockStore := setup(accA, auth.ActionApproveAny) + h, mockStore := setup(t, accA, auth.ActionApproveAny) _, err := h.approveRIExchange(ctx, csrfSessionReq(), id, "") require.NoError(t, err) mockStore.AssertCalled(t, "TransitionRIExchangeStatus", ctx, id, "pending", "processing", mock.Anything) @@ -601,19 +610,42 @@ func grantRINone(m *MockAuthService, userID string) { m.On("HasPermissionAPI", context.Background(), userID, mock.Anything, auth.ResourcePurchases).Return(false, nil) } +// riApprovePath is how a fourEyesRIExchangeFixture request authenticates, +// which decides the auth calls the approval makes. +type riApprovePath int + +const ( + // riApproveSession: session plus CSRF token, no email token. + riApproveSession riApprovePath = iota + // riApproveTokenWithSession: the email token, with a session that + // attributes the approver. The token authorizes, so no CSRF check runs. + riApproveTokenWithSession + // riApproveTokenNoSession: the email token alone. No session is resolved + // and no permission is consulted. + riApproveTokenNoSession +) + // fourEyesRIExchangeFixture wires a pending exchange created by creatorID and a -// session for approverID holding the given approve grant. -func fourEyesRIExchangeFixture(t *testing.T, creatorID *string, approverID string, grant riApproverGrant, fourEyes bool) (*Handler, *MockConfigStore, string, string) { +// session for approverID holding the given approve grant. path and approved +// say which auth calls and which writes the approval must make; any write a +// refused approval attempts hits an unregistered method and fails the test. +func fourEyesRIExchangeFixture(t *testing.T, creatorID *string, approverID string, grant riApproverGrant, fourEyes bool, path riApprovePath, approved bool) (*Handler, *MockConfigStore, string, string) { t.Helper() ctx := context.Background() id := "550e8400-e29b-41d4-a716-446655440021" token := "four-eyes-token" mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - mockAuth.On("ValidateSession", ctx, "sess").Return(&Session{UserID: approverID, Email: approverID + "@example.com"}, nil) - mockAuth.On("ValidateCSRFToken", ctx, "sess", "csrf").Return(nil) - grant(mockAuth, approverID) + if path != riApproveTokenNoSession { + mockAuth.On("ValidateSession", ctx, "sess").Return(&Session{UserID: approverID, Email: approverID + "@example.com"}, nil) + grant(mockAuth, approverID) + } + if path == riApproveSession { + mockAuth.On("ValidateCSRFToken", ctx, "sess", "csrf").Return(nil) + } mockStore.On("GetRIExchangeRecord", ctx, id).Return(&config.RIExchangeRecord{ ID: id, @@ -628,11 +660,18 @@ func fourEyesRIExchangeFixture(t *testing.T, creatorID *string, approverID strin RIExchangeMaxDailyUSD: 1000, RIExchangeMaxPerExchangeUSD: 500, }, nil) - mockStore.On("TransitionRIExchangeStatus", ctx, id, "pending", "processing", mock.Anything). - Return(&config.RIExchangeRecord{ID: id, Status: "processing", SourceRIIDs: []string{"ri-1"}, PaymentDue: "50.00"}, nil) - mockStore.On("ReserveApprovedRIExchange", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("500.000000", nil) - mockStore.On("FailRIExchange", mock.Anything, id, mock.AnythingOfType("string")).Return(nil) - mockStore.On("StampRIExchangeApprovedBy", mock.Anything, id, mock.Anything).Return(nil) + if approved { + // The claimed record carries no region, so execution fails closed at + // the region guard before reserving the spend ceiling: the approval + // ends in FailRIExchange, and is stamped only when a session named + // the approver. + mockStore.On("TransitionRIExchangeStatus", ctx, id, "pending", "processing", mock.Anything). + Return(&config.RIExchangeRecord{ID: id, Status: "processing", SourceRIIDs: []string{"ri-1"}, PaymentDue: "50.00"}, nil) + mockStore.On("FailRIExchange", mock.Anything, id, mock.AnythingOfType("string")).Return(nil) + if path != riApproveTokenNoSession { + mockStore.On("StampRIExchangeApprovedBy", mock.Anything, id, mock.Anything).Return(nil) + } + } return &Handler{config: mockStore, auth: mockAuth}, mockStore, id, token } @@ -664,57 +703,57 @@ func TestApproveRIExchange_FourEyes(t *testing.T) { } t.Run("session: creator with approve-own denied", func(t *testing.T) { - h, store, id, _ := fourEyesRIExchangeFixture(t, &creator, creator, grantRIApproveOwn, true) + h, store, id, _ := fourEyesRIExchangeFixture(t, &creator, creator, grantRIApproveOwn, true, riApproveSession, false) _, err := h.approveRIExchange(ctx, csrfSessionReq(), id, "") denied(t, store, err, 403) assert.Contains(t, err.Error(), "4-eyes mode") }) t.Run("session: admin creator denied", func(t *testing.T) { - h, store, id, _ := fourEyesRIExchangeFixture(t, &creator, creator, grantRIAdmin, true) + h, store, id, _ := fourEyesRIExchangeFixture(t, &creator, creator, grantRIAdmin, true, riApproveSession, false) _, err := h.approveRIExchange(ctx, csrfSessionReq(), id, "") denied(t, store, err, 403) }) t.Run("session: creator holding the email token still denied", func(t *testing.T) { - h, store, id, token := fourEyesRIExchangeFixture(t, &creator, creator, grantRIApproveOwn, true) + h, store, id, token := fourEyesRIExchangeFixture(t, &creator, creator, grantRIApproveOwn, true, riApproveSession, false) _, err := h.approveRIExchange(ctx, csrfSessionReq(), id, token) denied(t, store, err, 403) }) t.Run("token: creator session without approve rights denied", func(t *testing.T) { - h, store, id, token := fourEyesRIExchangeFixture(t, &creator, creator, grantRINone, true) + h, store, id, token := fourEyesRIExchangeFixture(t, &creator, creator, grantRINone, true, riApproveTokenWithSession, false) _, err := h.approveRIExchange(ctx, sessionReq("sess"), id, token) denied(t, store, err, 403) assert.Contains(t, err.Error(), "4-eyes mode") }) t.Run("token: no session fails closed", func(t *testing.T) { - h, store, id, token := fourEyesRIExchangeFixture(t, &creator, creator, grantRIAdmin, true) + h, store, id, token := fourEyesRIExchangeFixture(t, &creator, creator, grantRIAdmin, true, riApproveTokenNoSession, false) _, err := h.approveRIExchange(ctx, &events.LambdaFunctionURLRequest{}, id, token) denied(t, store, err, 500) }) t.Run("session: different approver allowed", func(t *testing.T) { - h, store, id, _ := fourEyesRIExchangeFixture(t, &creator, "approver-uuid", grantRIAdmin, true) + h, store, id, _ := fourEyesRIExchangeFixture(t, &creator, "approver-uuid", grantRIAdmin, true, riApproveSession, true) _, err := h.approveRIExchange(ctx, csrfSessionReq(), id, "") approved(t, store, err) }) t.Run("token: different approver allowed", func(t *testing.T) { - h, store, id, token := fourEyesRIExchangeFixture(t, &creator, "approver-uuid", grantRINone, true) + h, store, id, token := fourEyesRIExchangeFixture(t, &creator, "approver-uuid", grantRINone, true, riApproveTokenWithSession, true) _, err := h.approveRIExchange(ctx, sessionReq("sess"), id, token) approved(t, store, err) }) t.Run("mode off: creator allowed", func(t *testing.T) { - h, store, id, _ := fourEyesRIExchangeFixture(t, &creator, creator, grantRIApproveOwn, false) + h, store, id, _ := fourEyesRIExchangeFixture(t, &creator, creator, grantRIApproveOwn, false, riApproveSession, true) _, err := h.approveRIExchange(ctx, csrfSessionReq(), id, "") approved(t, store, err) }) t.Run("automated exchange without creator stays approvable", func(t *testing.T) { - h, store, id, _ := fourEyesRIExchangeFixture(t, nil, "approver-uuid", grantRIAdmin, true) + h, store, id, _ := fourEyesRIExchangeFixture(t, nil, "approver-uuid", grantRIAdmin, true, riApproveSession, true) _, err := h.approveRIExchange(ctx, csrfSessionReq(), id, "") approved(t, store, err) }) @@ -723,7 +762,7 @@ func TestApproveRIExchange_FourEyes(t *testing.T) { // branch is the one that runs. Under 4-eyes it must still demand an // identified approver instead of letting an anonymous token holder through. t.Run("automated exchange: token without session fails closed", func(t *testing.T) { - h, store, id, token := fourEyesRIExchangeFixture(t, nil, "approver-uuid", grantRIAdmin, true) + h, store, id, token := fourEyesRIExchangeFixture(t, nil, "approver-uuid", grantRIAdmin, true, riApproveTokenNoSession, false) _, err := h.approveRIExchange(ctx, &events.LambdaFunctionURLRequest{}, id, token) denied(t, store, err, 500) assert.Contains(t, err.Error(), "no session could be resolved") @@ -731,13 +770,13 @@ func TestApproveRIExchange_FourEyes(t *testing.T) { }) t.Run("automated exchange: token with a resolved session allowed", func(t *testing.T) { - h, store, id, token := fourEyesRIExchangeFixture(t, nil, "approver-uuid", grantRINone, true) + h, store, id, token := fourEyesRIExchangeFixture(t, nil, "approver-uuid", grantRINone, true, riApproveTokenWithSession, true) _, err := h.approveRIExchange(ctx, sessionReq("sess"), id, token) approved(t, store, err) }) t.Run("automated exchange: mode off, token without session allowed", func(t *testing.T) { - h, store, id, token := fourEyesRIExchangeFixture(t, nil, "approver-uuid", grantRIAdmin, false) + h, store, id, token := fourEyesRIExchangeFixture(t, nil, "approver-uuid", grantRIAdmin, false, riApproveTokenNoSession, true) _, err := h.approveRIExchange(ctx, &events.LambdaFunctionURLRequest{}, id, token) approved(t, store, err) }) @@ -749,6 +788,7 @@ func TestApproveRIExchange_FourEyes(t *testing.T) { func TestRequireDifferentRIExchangeApprover_ConfigLoadFailsClosed(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetGlobalConfig", ctx).Return(nil, errors.New("db down")) h := &Handler{config: store} err := h.requireDifferentRIExchangeApprover(ctx, nil, &config.RIExchangeRecord{ID: "x"}) @@ -764,7 +804,7 @@ func TestApproveRIExchange_TokenPathAttributesResolvedApprover(t *testing.T) { approver := "11111111-1111-4111-8111-111111111111" t.Run("session resolves", func(t *testing.T) { - h, store, id, token := fourEyesRIExchangeFixture(t, nil, approver, grantRINone, false) + h, store, id, token := fourEyesRIExchangeFixture(t, nil, approver, grantRINone, false, riApproveTokenWithSession, true) _, err := h.approveRIExchange(ctx, sessionReq("sess"), id, token) require.NoError(t, err) store.AssertCalled(t, "TransitionRIExchangeStatus", ctx, id, "pending", "processing", &approver) @@ -772,7 +812,7 @@ func TestApproveRIExchange_TokenPathAttributesResolvedApprover(t *testing.T) { }) t.Run("no session leaves the actor unset", func(t *testing.T) { - h, store, id, token := fourEyesRIExchangeFixture(t, nil, approver, grantRINone, false) + h, store, id, token := fourEyesRIExchangeFixture(t, nil, approver, grantRINone, false, riApproveTokenNoSession, true) _, err := h.approveRIExchange(ctx, &events.LambdaFunctionURLRequest{}, id, token) require.NoError(t, err) store.AssertCalled(t, "TransitionRIExchangeStatus", ctx, id, "pending", "processing", (*string)(nil)) @@ -790,7 +830,9 @@ func TestApproveRIExchange_TokenPathSkipsStampWhenExecutionErrors(t *testing.T) token := "four-eyes-token" mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "sess").Return(&Session{UserID: approver, Email: "approver@example.com"}, nil) grantRINone(mockAuth, approver) mockStore.On("GetRIExchangeRecord", ctx, id).Return(&config.RIExchangeRecord{ @@ -832,6 +874,7 @@ func TestApproveRIExchange_TokenPathSkipsStampWhenExecutionErrors(t *testing.T) // continues to work for non-session callers after the dual-auth refactor (backwards-compat). func TestApproveRIExchange_LegacyTokenStillWorks(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) h := &Handler{config: mockStore} // no auth configured -> tryGetSession returns nil ctx := context.Background() id := "550e8400-e29b-41d4-a716-446655440012" @@ -846,7 +889,8 @@ func TestApproveRIExchange_LegacyTokenStillWorks(t *testing.T) { }, nil) mockStore.On("TransitionRIExchangeStatus", ctx, id, "pending", "processing", mock.Anything). Return(&config.RIExchangeRecord{ID: id, Status: "processing"}, nil) - mockStore.On("ReserveApprovedRIExchange", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("500.000000", nil) + // The record carries no region, so execution fails closed at the region + // guard before ReserveApprovedRIExchange: the approval ends in FailRIExchange. mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{ RIExchangeMaxDailyUSD: 1000, RIExchangeMaxPerExchangeUSD: 500, @@ -968,6 +1012,7 @@ func TestGetReshapeRecommendations_EmptyRegionUsesConfigRegion(t *testing.T) { const cfgRegion = "us-west-2" mockStore := &MockConfigStore{} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // mock.Run captures every invocation's arguments without affecting // the return value. Returning nil/nil from ListStoredRecommendations // means "no recs in this region" which the downstream pipeline @@ -1065,6 +1110,7 @@ func TestGetReshapeRecommendations_AlternativeCurrencyFromRIs(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { mockStore := &MockConfigStore{} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("ListStoredRecommendations", mock.Anything, mock.Anything). Return([]config.RecommendationRecord{ {ID: "rec-1", ResourceType: "c6i.large", Count: 1, MonthlyCost: aws.Float64(40)}, @@ -2368,6 +2414,7 @@ func TestApproveRIExchange_SessionActorStamped(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) t.Cleanup(func() { mockStore.AssertExpectations(t) }) const actorID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" @@ -2623,6 +2670,7 @@ func spyOnAWSExchange(h *Handler) *int { func TestExecuteExchange_FourEyesRefusesDirectExecute(t *testing.T) { ctx := context.Background() store := &MockConfigStore{} + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{RequireDifferentApprover: true}, nil) h := newFourEyesExecuteExchangeHandler(t, store) exchangeCalls := spyOnAWSExchange(h) @@ -2644,6 +2692,7 @@ func TestExecuteExchange_FourEyesRefusesDirectExecute(t *testing.T) { func TestExecuteExchange_FourEyesPolicyLookupErrorFailsClosed(t *testing.T) { ctx := context.Background() store := &MockConfigStore{} + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetGlobalConfig", mock.Anything).Return(nil, errors.New("db down")) h := newFourEyesExecuteExchangeHandler(t, store) exchangeCalls := spyOnAWSExchange(h) @@ -2777,6 +2826,7 @@ func TestConvertToExchangeTypes_ExcludesNonPositiveDuration(t *testing.T) { // UI can show them instead of silently giving them no recommendation. func TestGetReshapeRecommendations_ReportsExcludedRIs(t *testing.T) { mockStore := &MockConfigStore{} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("ListStoredRecommendations", mock.Anything, mock.Anything). Return([]config.RecommendationRecord(nil), nil) h := &Handler{ @@ -2871,6 +2921,7 @@ func TestConvertToExchangeTypes_ExcludesRIWithoutPricing(t *testing.T) { // priced RI in the same account still does. func TestGetReshapeRecommendations_ReportsUnpricedRIs(t *testing.T) { mockStore := &MockConfigStore{} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("ListStoredRecommendations", mock.Anything, mock.Anything). Return([]config.RecommendationRecord(nil), nil) h := &Handler{ diff --git a/internal/api/handler_ri_utilization_scope_test.go b/internal/api/handler_ri_utilization_scope_test.go index 5c96189a..fa4c623c 100644 --- a/internal/api/handler_ri_utilization_scope_test.go +++ b/internal/api/handler_ri_utilization_scope_test.go @@ -114,7 +114,7 @@ func TestGetRIUtilization_ScopedUserDoesNotSeeLinkedMemberRIs(t *testing.T) { ctx := context.Background() ec2 := ownUtilEC2() recs := &countingUtilRecs{rows: payerUtilizationRows()} - h := newUtilizationScopeHandler(scopedAuthMock(ctx), newUtilizationScopeStore(), ec2, recs, permsAccA) + h := newUtilizationScopeHandler(scopedAuthMock(t, ctx), newUtilizationScopeStore(), ec2, recs, permsAccA) resp, err := h.getRIUtilization(ctx, scopedReq()) require.NoError(t, err) @@ -128,6 +128,7 @@ func TestGetRIUtilization_ScopedUserDoesNotSeeLinkedMemberRIs(t *testing.T) { func TestGetRIUtilization_ScopeByAccountNameReachesNothing(t *testing.T) { ctx := context.Background() auth := new(MockAuthService) + t.Cleanup(func() { auth.AssertExpectations(t) }) auth.On("ValidateSession", ctx, permsScopedToken).Return(&Session{UserID: permsScopedUserID}, nil) auth.On("HasPermissionAPI", ctx, permsScopedUserID, mock.Anything, mock.Anything).Return(true, nil) auth.On("GetAllowedAccountsAPI", ctx, permsScopedUserID).Return([]string{permsAccAName}, nil) @@ -144,7 +145,7 @@ func TestGetRIUtilization_ScopeByAccountNameReachesNothing(t *testing.T) { // Unrestricted users keep every row and trigger no EC2 listing. func TestGetRIUtilization_UnrestrictedSeesAllRowsWithoutEC2Call(t *testing.T) { ctx := context.Background() - auth, req := adminDashboardReq(ctx) + auth, req := adminDashboardReq(t, ctx) ec2 := ownUtilEC2() recs := &countingUtilRecs{rows: payerUtilizationRows()} h := newUtilizationScopeHandler(auth, newUtilizationScopeStore(), ec2, recs, permsAccA) @@ -161,7 +162,7 @@ func TestGetRIUtilization_ScopedEC2ListFailureFailsClosed(t *testing.T) { ctx := context.Background() ec2 := &countingUtilEC2{err: errors.New("describe failed")} recs := &countingUtilRecs{rows: payerUtilizationRows()} - h := newUtilizationScopeHandler(scopedAuthMock(ctx), newUtilizationScopeStore(), ec2, recs, permsAccA) + h := newUtilizationScopeHandler(scopedAuthMock(t, ctx), newUtilizationScopeStore(), ec2, recs, permsAccA) resp, err := h.getRIUtilization(ctx, scopedReq()) @@ -175,7 +176,7 @@ func TestGetRIUtilization_ScopedWithNoOwnRIsSeesNothing(t *testing.T) { ctx := context.Background() ec2 := &countingUtilEC2{} recs := &countingUtilRecs{rows: payerUtilizationRows()} - h := newUtilizationScopeHandler(scopedAuthMock(ctx), newUtilizationScopeStore(), ec2, recs, permsAccA) + h := newUtilizationScopeHandler(scopedAuthMock(t, ctx), newUtilizationScopeStore(), ec2, recs, permsAccA) resp, err := h.getRIUtilization(ctx, scopedReq()) require.NoError(t, err) @@ -189,7 +190,7 @@ func TestGetRIUtilization_OutOfScopeDeploymentMakesNoAWSCalls(t *testing.T) { ctx := context.Background() ec2 := ownUtilEC2() recs := &countingUtilRecs{rows: payerUtilizationRows()} - h := newUtilizationScopeHandler(scopedAuthMock(ctx), newUtilizationScopeStore(), ec2, recs, permsAccB) + h := newUtilizationScopeHandler(scopedAuthMock(t, ctx), newUtilizationScopeStore(), ec2, recs, permsAccB) resp, err := h.getRIUtilization(ctx, scopedReq()) require.NoError(t, err) @@ -204,6 +205,7 @@ func TestGetRIUtilization_OutOfScopeDeploymentMakesNoAWSCalls(t *testing.T) { func TestGetRIUtilization_PermissionCheckedBeforeScope(t *testing.T) { ctx := context.Background() auth := new(MockAuthService) + t.Cleanup(func() { auth.AssertExpectations(t) }) auth.On("ValidateSession", ctx, permsScopedToken).Return(&Session{UserID: permsScopedUserID}, nil) auth.On("HasPermissionAPI", ctx, permsScopedUserID, mock.Anything, mock.Anything).Return(false, nil) ec2 := ownUtilEC2() @@ -225,7 +227,7 @@ func TestGetRIUtilization_PermissionCheckedBeforeScope(t *testing.T) { // account's request was served the first account's cached rows. func TestGetRIUtilization_AccountsInSameRegionDoNotShareCache(t *testing.T) { ctx := context.Background() - auth, req := adminDashboardReq(ctx) + auth, req := adminDashboardReq(t, ctx) req.QueryStringParameters = map[string]string{"region": "us-east-1", "lookback_days": "30"} recs := &countingUtilRecs{} h := newUtilizationScopeHandler(auth, newUtilizationScopeStore(), ownUtilEC2(), recs, permsAccA) @@ -261,7 +263,7 @@ func TestGetRIUtilization_AccountsInSameRegionDoNotShareCache(t *testing.T) { // the cache row cannot be keyed (issue #120). func TestGetRIUtilization_AccountResolutionErrorFailsClosed(t *testing.T) { ctx := context.Background() - auth, req := adminDashboardReq(ctx) + auth, req := adminDashboardReq(t, ctx) recs := &countingUtilRecs{rows: payerUtilizationRows()} h := newUtilizationScopeHandler(auth, newUtilizationScopeStore(), ownUtilEC2(), recs, permsAccA) h.reshapeAccountResolver = func(_ context.Context) (string, error) { diff --git a/internal/api/handler_router_test.go b/internal/api/handler_router_test.go index a95d83bb..b232e693 100644 --- a/internal/api/handler_router_test.go +++ b/internal/api/handler_router_test.go @@ -14,6 +14,7 @@ import ( func TestHandler_getGroup_Error(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) @@ -34,6 +35,7 @@ func TestHandler_getGroup_Error(t *testing.T) { func TestHandler_updateGroup_Error(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) @@ -55,6 +57,7 @@ func TestHandler_updateGroup_Error(t *testing.T) { func TestHandler_listGroups_Error(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) @@ -75,6 +78,7 @@ func TestHandler_listGroups_Error(t *testing.T) { func TestHandler_createGroup_Error(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index e0249512..b0ac5521 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -204,6 +204,7 @@ func TestHandler_HandleRequest_CORS_Preflight(t *testing.T) { func TestHandler_HandleRequest_Health(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) // Setup mocks for health checks @@ -335,6 +336,7 @@ func TestHandler_CORS_CustomOrigin(t *testing.T) { func TestHandler_HandleRequest_GetConfig(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ EnabledProviders: []string{"aws"}, @@ -366,21 +368,21 @@ func TestHandler_HandleRequest_GetConfig(t *testing.T) { func TestHandler_HandleRequest_PutConfig(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. A changed default reaches the service configs inside + // the store's atomic update, so the handler never lists them itself. mockStore.On("SaveGlobalConfig", mock.Anything, mock.AnythingOfType("*config.GlobalConfig")).Return(nil) - mockStore.On("ListServiceConfigs", mock.Anything).Return([]config.ServiceConfig{}, nil) // updateConfig calls GetGlobalConfig before save to preserve persisted // values for fields omitted from the request body (PR #308 CR pass-2). mockStore.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{ RecommendationsCacheStaleHours: config.DefaultRecommendationsCacheStaleHours, RecommendationsLookbackDays: config.DefaultRecommendationsLookbackDays, }, nil) - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", mock.Anything, mock.Anything, mock.Anything).Return(nil) handler := &Handler{config: mockStore, auth: mockAuth, apiKey: "test-key"} @@ -408,6 +410,7 @@ func TestHandler_HandleRequest_PutConfig(t *testing.T) { func TestHandler_HandleRequest_GetServiceConfig(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) serviceCfg := &config.ServiceConfig{Provider: "aws", Service: "rds"} mockStore.On("GetServiceConfig", mock.Anything, "aws", "rds").Return(serviceCfg, nil) @@ -434,12 +437,13 @@ func TestHandler_HandleRequest_GetServiceConfig(t *testing.T) { func TestHandler_HandleRequest_PutServiceConfig(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) mockStore.On("GetServiceConfig", mock.Anything, "aws", "rds").Return(nil, nil) mockStore.On("SaveServiceConfig", mock.Anything, mock.AnythingOfType("*config.ServiceConfig")).Return(nil) @@ -470,6 +474,7 @@ func TestHandler_HandleRequest_PutServiceConfig(t *testing.T) { func TestHandler_HandleRequest_GetRecommendations(t *testing.T) { ctx := context.Background() mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) // Mock the scheduler to return empty recommendations mockScheduler.On("ListRecommendations", mock.Anything, mock.Anything).Return([]config.RecommendationRecord{}, nil) @@ -502,13 +507,15 @@ func TestHandler_HandleRequest_RefreshRecommendations(t *testing.T) { t.Setenv("SCHEDULER_LAMBDA_ARN", "") mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) mockScheduler.On("CollectRecommendations", mock.Anything, mock.Anything).Return(&scheduler.CollectResult{Recommendations: 0, TotalSavings: 0}, nil) mockStore.On("GetRecommendationsFreshness", mock.Anything). @@ -653,10 +660,12 @@ func TestRunMarkedCollection_AsyncInvokeSuccess_PayloadCarriesOwnerToken(t *test func TestHandler_HandleRequest_ListPlans(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() plans := []config.PurchasePlan{{ID: "11111111-1111-1111-1111-111111111111"}} @@ -689,14 +698,15 @@ func TestHandler_HandleRequest_ListPlans(t *testing.T) { func TestHandler_HandleRequest_CreatePlan(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) targetAccountID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) mockStore.On("CreatePurchasePlan", mock.Anything, mock.AnythingOfType("*config.PurchasePlan")).Return(nil) mockStore.On("SetPlanAccounts", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("[]string")).Return(nil) @@ -736,10 +746,12 @@ func TestHandler_HandleRequest_CreatePlan(t *testing.T) { func TestHandler_HandleRequest_GetPlan(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() plan := &config.PurchasePlan{ID: "12345678-1234-1234-1234-123456789abc"} @@ -768,12 +780,13 @@ func TestHandler_HandleRequest_GetPlan(t *testing.T) { func TestHandler_HandleRequest_UpdatePlan(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) existingPlan := &config.PurchasePlan{ ID: "12345678-1234-1234-1234-123456789abc", @@ -810,12 +823,13 @@ func TestHandler_HandleRequest_UpdatePlan(t *testing.T) { func TestHandler_HandleRequest_DeletePlan(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) mockStore.On("DeletePurchasePlan", mock.Anything, "12345678-1234-1234-1234-123456789abc").Return(nil) @@ -847,6 +861,7 @@ func TestHandler_HandleRequest_ApprovePurchase(t *testing.T) { accountID := "acct-1" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "token123", @@ -864,6 +879,7 @@ func TestHandler_HandleRequest_ApprovePurchase(t *testing.T) { }, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() mockAuth.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{Email: approver}, nil) @@ -877,6 +893,7 @@ func TestHandler_HandleRequest_ApprovePurchase(t *testing.T) { mockAuth.On("HasPermissionAPI", mock.Anything, "", "approve-own", "purchases").Return(false, nil).Maybe() mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) mockPurchase.On("ApproveExecution", mock.Anything, execID, "token123", approver).Return(nil, "", nil) handler := &Handler{purchase: mockPurchase, config: mockConfig, auth: mockAuth} @@ -936,6 +953,7 @@ func TestHandler_HandleRequest_CancelPurchase(t *testing.T) { accountID := "acct-1" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "token456", @@ -953,6 +971,7 @@ func TestHandler_HandleRequest_CancelPurchase(t *testing.T) { }, nil) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() mockAuth.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{Email: approver}, nil) @@ -966,6 +985,7 @@ func TestHandler_HandleRequest_CancelPurchase(t *testing.T) { mockAuth.On("HasPermissionAPI", mock.Anything, "", "cancel-own", "purchases").Return(false, nil).Maybe() mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) mockPurchase.On("CancelExecution", mock.Anything, execID, "token456", approver).Return(nil) handler := &Handler{purchase: mockPurchase, config: mockConfig, auth: mockAuth} @@ -1054,6 +1074,7 @@ func TestHandler_HandleRequest_CancelPurchase_GETDoesNotCancel(t *testing.T) { func TestHandler_HandleRequest_GetHistory(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) history := []config.PurchaseHistoryRecord{{PurchaseID: "purchase-1"}} mockStore.On("ListPurchaseHistoryPage", mock.Anything, mock.Anything, mock.Anything).Return(history, nil) @@ -1086,6 +1107,7 @@ func TestHandler_HandleRequest_GetHistory(t *testing.T) { func TestHandler_HandleRequest_Error(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetGlobalConfig", mock.Anything).Return(nil, assert.AnError) @@ -1117,7 +1139,9 @@ func TestHandler_HandleRequest_Error(t *testing.T) { func TestHandler_HandleRequest_GetDashboardSummary(t *testing.T) { ctx := context.Background() mockScheduler := new(MockScheduler) + t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) recommendations := []config.RecommendationRecord{ {Service: "rds", Savings: 100.0}, @@ -1167,6 +1191,7 @@ func TestHandler_HandleRequest_GetDashboardSummary(t *testing.T) { func TestHandler_HandleRequest_GetUpcomingPurchases(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) nextExecDate := time.Now().AddDate(0, 0, 7) plans := []config.PurchasePlan{ @@ -1230,10 +1255,12 @@ func TestHandler_HandleRequest_GetUpcomingPurchases(t *testing.T) { func TestHandler_HandleRequest_GetPlannedPurchases(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() scheduledDate := time.Now().AddDate(0, 0, 7) @@ -1270,12 +1297,13 @@ func TestHandler_HandleRequest_GetPlannedPurchases(t *testing.T) { func TestHandler_HandleRequest_PausePlannedPurchase(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) paused := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "paused"} mockStore.On("TransitionExecutionStatus", mock.Anything, "11111111-1111-1111-1111-111111111111", []string{"pending", "notified"}, "paused", mock.Anything).Return(paused, nil) @@ -1305,12 +1333,13 @@ func TestHandler_HandleRequest_PausePlannedPurchase(t *testing.T) { func TestHandler_HandleRequest_ResumePlannedPurchase(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) resumed := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "pending"} mockStore.On("TransitionExecutionStatus", mock.Anything, "11111111-1111-1111-1111-111111111111", []string{"paused"}, "pending", mock.Anything).Return(resumed, nil) @@ -1341,14 +1370,16 @@ func TestHandler_HandleRequest_ResumePlannedPurchase(t *testing.T) { func TestHandler_HandleRequest_RunPlannedPurchase(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) // runPlannedPurchase re-hydrates the execution to enforce the session's // execute:purchases Constraints against its recommendations (issue #60). mockStore.On("GetExecutionByID", mock.Anything, "11111111-1111-1111-1111-111111111111").Return(&config.PurchaseExecution{ @@ -1387,12 +1418,13 @@ func TestHandler_HandleRequest_RunPlannedPurchase(t *testing.T) { func TestHandler_HandleRequest_DeletePlannedPurchase(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) canceled := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "canceled"} mockStore.On("TransitionExecutionStatus", mock.Anything, "11111111-1111-1111-1111-111111111111", []string{"pending", "notified", "paused"}, "canceled", mock.Anything).Return(canceled, nil) @@ -1421,12 +1453,13 @@ func TestHandler_HandleRequest_DeletePlannedPurchase(t *testing.T) { func TestHandler_HandleRequest_CreatePlannedPurchases(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) plan := bareStepAPIPlan() plan.ID = "11111111-1111-1111-1111-111111111111" @@ -1465,10 +1498,12 @@ func TestHandler_HandleRequest_CreatePlannedPurchases(t *testing.T) { func TestHandler_HandleRequest_GetPlan_Error(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() mockStore.On("GetPurchasePlan", mock.Anything, "12345678-1234-1234-1234-123456789abc").Return(nil, assert.AnError) @@ -1497,6 +1532,7 @@ func TestHandler_HandleRequest_GetPlan_Error(t *testing.T) { func TestHandler_HandleRequest_DeleteUser_SelfDeletion(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) // Use valid UUID format for the admin user ID adminUserID := "12345678-1234-1234-1234-123456789abc" @@ -1536,10 +1572,12 @@ func TestHandler_HandleRequest_DeleteUser_SelfDeletion(t *testing.T) { func TestHandler_HandleRequest_ListPlans_Error(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() mockStore.On("ListPurchasePlans", mock.Anything, mock.Anything).Return(nil, assert.AnError) @@ -1572,10 +1610,12 @@ func TestHandler_HandleRequest_ListPlans_Error(t *testing.T) { func TestHandler_HandleRequest_ListPlans_UnassignedFlagged(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() // Simulate the store returning one assigned plan and one zero-account @@ -1633,11 +1673,11 @@ func TestHandler_HandleRequest_ListPlans_UnassignedFlagged(t *testing.T) { func TestHandler_HandleRequest_UpdateConfig_InvalidJSON(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) - adminSession := &Session{UserID: "admin-id", Email: "admin@example.com"} - mockAuth.On("ValidateSession", ctx, "test-token").Return(adminSession, nil) + // The admin X-API-Key authenticates the request, so the bearer session + // is never validated. mockAuth.grantAdmin() - mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) handler := &Handler{auth: mockAuth, apiKey: "test-key"} diff --git a/internal/api/handler_users_test.go b/internal/api/handler_users_test.go index ffe35de6..8b45be53 100644 --- a/internal/api/handler_users_test.go +++ b/internal/api/handler_users_test.go @@ -16,6 +16,7 @@ import ( func TestHandler_listUsers_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -49,6 +50,7 @@ func TestHandler_listUsers_Success(t *testing.T) { func TestHandler_listUsers_NoPermission(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) userSession := &Session{ UserID: "11111111-1111-1111-1111-111111111111", @@ -75,6 +77,7 @@ func TestHandler_listUsers_NoPermission(t *testing.T) { func TestHandler_createUser_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -109,6 +112,7 @@ func TestHandler_createUser_Success(t *testing.T) { func TestHandler_getUser_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -143,6 +147,7 @@ func TestHandler_getUser_Success(t *testing.T) { func TestHandler_updateUser_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -184,6 +189,7 @@ func TestHandler_updateUser_Success(t *testing.T) { func TestHandler_updateUser_Deactivate(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -221,6 +227,7 @@ func TestHandler_updateUser_Deactivate(t *testing.T) { func TestHandler_deleteUser_Success(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -249,6 +256,7 @@ func TestHandler_deleteUser_Success(t *testing.T) { func TestHandler_deleteUser_SelfDeletion(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{ UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", @@ -276,6 +284,7 @@ func TestHandler_deleteUser_SelfDeletion(t *testing.T) { func TestHandler_createUser_InvalidJSON(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) @@ -297,6 +306,7 @@ func TestHandler_createUser_InvalidJSON(t *testing.T) { func TestHandler_updateUser_InvalidJSON(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) diff --git a/internal/api/health_test.go b/internal/api/health_test.go index 60384292..d323185a 100644 --- a/internal/api/health_test.go +++ b/internal/api/health_test.go @@ -22,6 +22,7 @@ type stubCredStore struct{ credentials.CredentialStore } func TestHandler_GetHealth_AllHealthy(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) @@ -71,6 +72,7 @@ func TestHandler_checkCredentialStore_DevKeyInUse(t *testing.T) { func TestHandler_GetHealth_DevKeyNotDisclosed(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) handler := &Handler{ @@ -104,6 +106,7 @@ func TestHandler_checkCredentialStore_NotConfigured(t *testing.T) { func TestHandler_GetHealth_ConfigStoreUnhealthy(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) mockStore.On("GetGlobalConfig", ctx).Return(nil, errors.New("database connection failed")) @@ -127,6 +130,7 @@ func TestHandler_GetHealth_ConfigStoreUnhealthy(t *testing.T) { func TestHandler_GetHealth_AuthServiceUnhealthy(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) @@ -182,6 +186,7 @@ func TestHandler_GetHealth_ConfigStoreNotInitialized(t *testing.T) { func TestHandler_checkConfigStore_Healthy(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{}, nil) @@ -206,6 +211,7 @@ func TestHandler_checkConfigStore_NotInitialized(t *testing.T) { func TestHandler_checkConfigStore_AccessError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetGlobalConfig", mock.Anything).Return(nil, errors.New("connection timeout")) diff --git a/internal/api/ladder_arming_grant_test.go b/internal/api/ladder_arming_grant_test.go index 24b43101..ad63c6fc 100644 --- a/internal/api/ladder_arming_grant_test.go +++ b/internal/api/ladder_arming_grant_test.go @@ -34,9 +34,13 @@ func lgBody(enabled bool, mode, capJSON string) string { lgAcct, enabled, mode, capField, ladderValidRamp) } -func lgCall(caller apgCaller, stored *config.LadderConfigDB, body string) (*MockConfigStore, error) { +func lgCall(t *testing.T, caller apgCaller, stored *config.LadderConfigDB, body string) (*MockConfigStore, error) { + t.Helper() store := new(MockConfigStore) - store.On("GetLadderConfig", mock.Anything, lgAcct, "aws").Return(stored, nil) + t.Cleanup(func() { store.AssertExpectations(t) }) + // Maybe: a body that fails validation is refused before the stored config + // is read; callers assert the outcome through the error and the upsert. + store.On("GetLadderConfig", mock.Anything, lgAcct, "aws").Return(stored, nil).Maybe() store.On("UpsertLadderConfig", mock.Anything, mock.Anything).Return(&config.LadderConfigDB{ID: "cfg-1"}, nil).Maybe() store.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { return &config.CloudAccount{ID: id, Provider: "aws"}, nil @@ -86,7 +90,7 @@ func TestLadderArmingGate_ConfigWrites(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - store, err := lgCall(tc.caller, tc.stored, tc.body) + store, err := lgCall(t, tc.caller, tc.stored, tc.body) if tc.wantRefused { apgRefused(t, err) assert.False(t, apgCalled(store, "UpsertLadderConfig"), "a refused write must not reach the store") @@ -100,6 +104,7 @@ func TestLadderArmingGate_ConfigWrites(t *testing.T) { func TestLadderArmingGate_ConfigLookupErrorFailsClosed(t *testing.T) { store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetLadderConfig", mock.Anything, lgAcct, "aws").Return((*config.LadderConfigDB)(nil), fmt.Errorf("db down")) store.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { return &config.CloudAccount{ID: id, Provider: "aws"}, nil @@ -113,7 +118,7 @@ func TestLadderArmingGate_ConfigLookupErrorFailsClosed(t *testing.T) { func TestLadderArmingGate_CapValidation(t *testing.T) { for _, capJSON := range []string{"0", "-1", "0.0000004", "1.1234567", "1e13", "1e308", `"5"`} { t.Run(capJSON, func(t *testing.T) { - store, err := lgCall(apgCaller{perms: apgAdminPlus}, nil, lgBody(false, "auto_approve", capJSON)) + store, err := lgCall(t, apgCaller{perms: apgAdminPlus}, nil, lgBody(false, "auto_approve", capJSON)) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok, "%v", err) @@ -123,7 +128,7 @@ func TestLadderArmingGate_CapValidation(t *testing.T) { } // Boundary values stay valid. for _, capJSON := range []string{"0.000001", "123.456789", "1e12"} { - _, err := lgCall(apgCaller{perms: apgAdminPlus}, nil, lgBody(false, "auto_approve", capJSON)) + _, err := lgCall(t, apgCaller{perms: apgAdminPlus}, nil, lgBody(false, "auto_approve", capJSON)) require.NoError(t, err, capJSON) } } diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index ed72eeaf..89f97784 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -221,6 +221,7 @@ func TestHandler_extractBearerToken(t *testing.T) { func TestHandler_authenticatePrincipal_BearerToken(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) session := &Session{ UserID: "11111111-1111-1111-1111-111111111111", @@ -256,6 +257,7 @@ func TestHandler_authenticatePrincipal_BearerToken(t *testing.T) { func TestHandler_authenticatePrincipal_BearerTokenWithAPIKey(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) session := &Session{ UserID: "11111111-1111-1111-1111-111111111111", @@ -345,6 +347,7 @@ func TestApproveViaSession_RequiresCSRF(t *testing.T) { adminEmail := "admin@example.com" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "email-tok", @@ -355,7 +358,10 @@ func TestApproveViaSession_RequiresCSRF(t *testing.T) { {ID: "r1", Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 100}, }, } - mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) + // Maybe: CSRF is validated before the execution lookup (issue #597). + // Registered so a lookup-first regression is caught by the + // AssertNotCalled below rather than by a mock panic. + mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil).Maybe() mockAuth := new(MockAuthService) adminSession := &Session{Email: adminEmail} @@ -381,6 +387,7 @@ func TestApproveViaSession_RequiresCSRF(t *testing.T) { require.True(t, ok, "expected a ClientError, got: %v", err) assert.Equal(t, 403, ce.code) assert.Contains(t, ce.Error(), "CSRF validation failed") + mockConfig.AssertNotCalled(t, "GetExecutionByID", ctx, execID) } // TestApproveViaSession_PassesCSRF asserts that a session-authenticated POST @@ -392,6 +399,7 @@ func TestApproveViaSession_PassesCSRF(t *testing.T) { adminEmail := "admin@example.com" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, ApprovalToken: "email-tok", @@ -419,6 +427,7 @@ func TestApproveViaSession_PassesCSRF(t *testing.T) { t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) mockPurchase.On("ApproveAndExecute", ctx, execID, adminEmail, (*string)(nil)).Return(nil, "", nil) handler := &Handler{config: mockConfig, auth: mockAuth, purchase: mockPurchase} @@ -443,11 +452,15 @@ func TestCancelViaSession_RequiresCSRF(t *testing.T) { execID := "55555555-5555-5555-5555-555555555555" mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) exec := &config.PurchaseExecution{ ExecutionID: execID, Status: "pending", } - mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) + // Maybe: CSRF is validated before the execution lookup (issue #597). + // Registered so a lookup-first regression is caught by the + // AssertNotCalled below rather than by a mock panic. + mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil).Maybe() mockAuth := new(MockAuthService) adminSession := &Session{Email: "admin@example.com"} @@ -471,6 +484,7 @@ func TestCancelViaSession_RequiresCSRF(t *testing.T) { require.True(t, ok, "expected a ClientError, got: %v", err) assert.Equal(t, 403, ce.code) assert.Contains(t, ce.Error(), "CSRF validation failed") + mockConfig.AssertNotCalled(t, "GetExecutionByID", ctx, execID) } // TestApproveRIExchangeViaSession_RequiresCSRF is the RI-exchange analog of @@ -629,6 +643,7 @@ func TestApproveRIExchangeViaSession_PassesCSRF(t *testing.T) { creatorID := "creator-uuid" mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) adminSession := &Session{UserID: "admin-uuid", Email: "admin@example.com"} mockAuth.On("ValidateSession", ctx, "sess-tok").Return(adminSession, nil) @@ -650,7 +665,8 @@ func TestApproveRIExchangeViaSession_PassesCSRF(t *testing.T) { }, nil).Once() mockStore.On("TransitionRIExchangeStatus", ctx, id, "pending", "processing", mock.Anything). Return(&config.RIExchangeRecord{ID: id, Status: "processing", SourceRIIDs: []string{"ri-123"}, PaymentDue: "100.00"}, nil) - mockStore.On("ReserveApprovedRIExchange", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("500.000000", nil) + // The record carries no region, so execution fails closed at the region + // guard before ReserveApprovedRIExchange: the approval ends in FailRIExchange. mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{ RIExchangeMaxDailyUSD: 1000, RIExchangeMaxPerExchangeUSD: 500, @@ -682,7 +698,7 @@ func TestTokenApprove_SessionRequiresCSRF(t *testing.T) { execID := "12345678-1234-1234-1234-123456789abc" contactEmail := "contact@example.com" - newHandler := func(mockConfig *MockConfigStore, mockPurchase *MockPurchaseManager) *Handler { + newHandler := func(t *testing.T, mockConfig *MockConfigStore, mockPurchase *MockPurchaseManager) *Handler { accountID := "acct-1" exec := &config.PurchaseExecution{ ExecutionID: execID, @@ -701,6 +717,7 @@ func TestTokenApprove_SessionRequiresCSRF(t *testing.T) { }, nil).Maybe() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: contactEmail}, nil) mockAuth.On("ValidateCSRFToken", ctx, "sess-tok", "").Return(errors.New("missing CSRF token")).Maybe() @@ -713,7 +730,7 @@ func TestTokenApprove_SessionRequiresCSRF(t *testing.T) { t.Run("no CSRF header", func(t *testing.T) { mockConfig, mockPurchase := new(MockConfigStore), new(MockPurchaseManager) - handler := newHandler(mockConfig, mockPurchase) + handler := newHandler(t, mockConfig, mockPurchase) req := &events.LambdaFunctionURLRequest{ Headers: map[string]string{"authorization": "Bearer sess-tok"}, } @@ -725,7 +742,7 @@ func TestTokenApprove_SessionRequiresCSRF(t *testing.T) { t.Run("valid CSRF header", func(t *testing.T) { mockPurchase := new(MockPurchaseManager) - handler := newHandler(new(MockConfigStore), mockPurchase) + handler := newHandler(t, new(MockConfigStore), mockPurchase) mockPurchase.On("ApproveExecution", ctx, execID, "email-token", contactEmail).Return(nil, "", nil) req := &events.LambdaFunctionURLRequest{ Headers: map[string]string{"authorization": "Bearer sess-tok", "x-csrf-token": "csrf-ok"}, diff --git a/internal/api/nullable_purchase_cost_test.go b/internal/api/nullable_purchase_cost_test.go index 619ad95b..3adf153c 100644 --- a/internal/api/nullable_purchase_cost_test.go +++ b/internal/api/nullable_purchase_cost_test.go @@ -80,7 +80,7 @@ func TestCoverageHandlerUnknownUpfrontSameService(t *testing.T) { return []config.CloudAccount{}, nil } scheduler.On("ListRecommendations", mock.Anything, config.RecommendationFilter{}).Return([]config.RecommendationRecord{{Provider: "aws", Service: "ec2", Savings: 100}}, nil) - auth, req := adminInventoryReq(ctx) + auth, req := adminInventoryReq(t, ctx) handler := &Handler{auth: auth, config: store, scheduler: scheduler} result, err := handler.getCoverageBreakdown(ctx, req, map[string]string{}) require.NoError(t, err) @@ -112,7 +112,7 @@ func TestMarketplaceUnknownUpfront(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { store, authSvc, ec2 := &MockConfigStore{}, &MockAuthService{}, &stubMarketplaceEC2{} - adminSession(authSvc) + adminSession(t, authSvc) row := standardRow() row.UpfrontCost = tc.cost store.On("GetPurchaseHistoryByPurchaseID", mock.Anything, validMarketplacePurchaseID).Return(row, nil) diff --git a/internal/api/patch_csrf_test.go b/internal/api/patch_csrf_test.go index c5f91d88..bab81ad6 100644 --- a/internal/api/patch_csrf_test.go +++ b/internal/api/patch_csrf_test.go @@ -62,6 +62,7 @@ func patchPlanCSRFFixture(t *testing.T) patchCSRFFixture { const session = "patch-csrf-admin" store := new(MockConfigStore) m := new(MockAuthService) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("ValidateSession", mock.Anything, session). Return(&Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"}, nil) m.grantAdmin() diff --git a/internal/api/plan_mutation_scope_test.go b/internal/api/plan_mutation_scope_test.go index 06850b9e..da0864f6 100644 --- a/internal/api/plan_mutation_scope_test.go +++ b/internal/api/plan_mutation_scope_test.go @@ -75,6 +75,7 @@ func TestRouterDispatch_DeletePlan_AllAccountsInScopeDeletes(t *testing.T) { func TestRouterDispatch_DeletePlan_UnrestrictedSkipsPlanLookup(t *testing.T) { ctx := context.Background() h, store := scopedHandler(t) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("DeletePurchasePlan", mock.Anything, scopePlanID).Return(nil).Once() _, err := NewRouter(h).Route(ctx, "DELETE", "/api/plans/"+scopePlanID, scopedRequest("")) diff --git a/internal/api/plan_step_no_recs_test.go b/internal/api/plan_step_no_recs_test.go index 922d2775..4a05ed69 100644 --- a/internal/api/plan_step_no_recs_test.go +++ b/internal/api/plan_step_no_recs_test.go @@ -96,6 +96,7 @@ func TestAddPurchasesValidatesLockedPlan(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) plan := bareStepAPIPlan() cap := 100.0 plan.MaxStepCommitment = &cap @@ -128,6 +129,7 @@ func TestAddPurchasesAnchorsPartlyCompletedRamp(t *testing.T) { t.Run(fmt.Sprint(step), func(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) plan := bareStepAPIPlan() plan.AutoPurchase = false plan.RampSchedule.CurrentStep = step diff --git a/internal/api/purchase_currency_test.go b/internal/api/purchase_currency_test.go index fbae84bf..a0e6c161 100644 --- a/internal/api/purchase_currency_test.go +++ b/internal/api/purchase_currency_test.go @@ -114,7 +114,9 @@ func TestEnforcePurchaseConstraints_NonUSDOtherDimensionKeepsItsError(t *testing func TestHandler_executePurchase_NonUSDStoredPriceUnderCapRefused(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) authService := new(MockAuthService) + t.Cleanup(func() { authService.AssertExpectations(t) }) authService.On("ValidateSession", ctx, "eur-cap").Return(&Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "capped@example.com"}, nil) authService.grantPermissions([]auth.Permission{{Action: auth.ActionExecute, Resource: auth.ResourcePurchases, Constraints: &auth.PermissionConstraints{MaxPurchaseAmount: 1000}}}) store.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{}, nil).Maybe() diff --git a/internal/api/purchase_pricing_test.go b/internal/api/purchase_pricing_test.go index 5cfb0b02..494271c0 100644 --- a/internal/api/purchase_pricing_test.go +++ b/internal/api/purchase_pricing_test.go @@ -959,6 +959,7 @@ func emptyIDExecuteHarness(t *testing.T, details string) (*config.PurchaseExecut t.Helper() ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) t.Cleanup(func() { mockAuth.AssertExpectations(t) }) @@ -1040,6 +1041,7 @@ func emptyIDExecuteAgainst(t *testing.T, stored config.RecommendationRecord, rec t.Helper() ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) t.Cleanup(func() { mockAuth.AssertExpectations(t) }) @@ -1132,7 +1134,9 @@ func TestHandler_executePurchase_EmptyIDIgnoresNonDiscriminatorDetails(t *testin func TestRouter_PurchasesExecute_EmptyIDMismatchedTenancyRefused(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "emptyid-token").Return(&Session{UserID: "22222222-2222-3333-4444-555555555555", Email: "emptyid@example.com"}, nil) mockAuth.grantPermissions([]auth.Permission{ {Action: auth.ActionExecute, Resource: auth.ResourcePurchases, Constraints: &auth.PermissionConstraints{MaxPurchaseAmount: 100000}}, @@ -1181,7 +1185,9 @@ func TestAzureCommitmentUnitsRegression(t *testing.T) { func TestAzureCorrectedPriceExceedsOldUnitsCap(t *testing.T) { ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) authService := new(MockAuthService) + t.Cleanup(func() { authService.AssertExpectations(t) }) authService.On("ValidateSession", ctx, "azure-cap").Return(&Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "capped@example.com"}, nil) authService.grantPermissions([]auth.Permission{{Action: auth.ActionExecute, Resource: auth.ResourcePurchases, Constraints: &auth.PermissionConstraints{MaxPurchaseAmount: 1000}}}) store.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{}, nil).Maybe() diff --git a/internal/api/recommendations_background_collection_scope_test.go b/internal/api/recommendations_background_collection_scope_test.go index b449e3c8..ac3a5381 100644 --- a/internal/api/recommendations_background_collection_scope_test.go +++ b/internal/api/recommendations_background_collection_scope_test.go @@ -63,8 +63,10 @@ func recsReadStore() *MockConfigStore { // capturingScheduler records whether each ListRecommendations ctx was allowed // to kick a background collection. -func capturingScheduler(allowed *[]bool) *MockScheduler { +func capturingScheduler(t *testing.T, allowed *[]bool) *MockScheduler { + t.Helper() sched := new(MockScheduler) + t.Cleanup(func() { sched.AssertExpectations(t) }) sched.On("ListRecommendations", mock.Anything, mock.Anything). Run(func(args mock.Arguments) { *allowed = append(*allowed, scheduler.BackgroundCollectionAllowed(args.Get(0).(context.Context))) @@ -96,7 +98,7 @@ func TestRecommendationsRead_BackgroundCollectionOnlyForUnrestrictedReaders(t *t for _, tc := range cases { t.Run(ep.name+"/"+tc.name, func(t *testing.T) { var allowed []bool - h := &Handler{config: recsReadStore(), scheduler: capturingScheduler(&allowed), auth: tc.auth()} + h := &Handler{config: recsReadStore(), scheduler: capturingScheduler(t, &allowed), auth: tc.auth()} req := scopedRequest("") if tc.apiKey { h.apiKey = "test-key" @@ -117,19 +119,22 @@ func TestRecommendationsRead_ScopeLookupErrorNeverAllowsBackgroundCollection(t * for _, ep := range recsReadEndpoints { t.Run(ep.name, func(t *testing.T) { mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, scopedToken). Return(&Session{UserID: scopedUserID}, nil).Maybe() mockAuth.On("HasPermissionAPI", mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(true, nil).Maybe() mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything). Return(nil, errors.New("scope lookup failed")) - var allowed []bool - h := &Handler{config: recsReadStore(), scheduler: capturingScheduler(&allowed), auth: mockAuth} + // No ListRecommendations expectation: a read without an + // established scope would hit an unregistered method. + sched := new(MockScheduler) + h := &Handler{config: recsReadStore(), scheduler: sched, auth: mockAuth} err := ep.call(h, context.Background(), scopedRequest("")) require.Error(t, err) assert.Contains(t, err.Error(), "scope lookup failed") - assert.Empty(t, allowed, "no recommendations read may happen without an established scope") + sched.AssertNotCalled(t, "ListRecommendations", mock.Anything, mock.Anything) }) } } @@ -151,6 +156,7 @@ func TestGetRecommendations_ColdStartMarkerOnlyForUnrestrictedReaders(t *testing for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { store := recsReadStore() + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetRecommendationsFreshness", mock.Anything). Return(&config.RecommendationsFreshness{LastCollectedAt: nil}, nil) store.On("MarkCollectionStarted", mock.Anything).Return("", false, nil).Maybe() @@ -170,7 +176,7 @@ func TestGetRecommendations_ResolvesAccountScopeOnce(t *testing.T) { for _, accounts := range [][]string{nil, {scopedInAccount}} { var allowed []bool mockAuth := scopedAuth(accounts) - h := &Handler{config: recsReadStore(), scheduler: capturingScheduler(&allowed), auth: mockAuth} + h := &Handler{config: recsReadStore(), scheduler: capturingScheduler(t, &allowed), auth: mockAuth} _, err := h.getRecommendations(context.Background(), scopedRequest(""), map[string]string{}) require.NoError(t, err) diff --git a/internal/api/recommendations_next_collection_test.go b/internal/api/recommendations_next_collection_test.go index 5cfbc797..b4dd70d0 100644 --- a/internal/api/recommendations_next_collection_test.go +++ b/internal/api/recommendations_next_collection_test.go @@ -19,6 +19,7 @@ func freshnessJSON(t *testing.T, schedule string, now time.Time) string { t.Helper() ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) collected := now.Add(-3 * time.Hour) mockStore.On("GetRecommendationsFreshness", ctx). Return(&config.RecommendationsFreshness{LastCollectedAt: &collected}, nil) diff --git a/internal/api/refresh_recommendations_scope_test.go b/internal/api/refresh_recommendations_scope_test.go index 2e7715da..551d8bd9 100644 --- a/internal/api/refresh_recommendations_scope_test.go +++ b/internal/api/refresh_recommendations_scope_test.go @@ -163,6 +163,7 @@ func TestRouterDispatch_RefreshRecommendations_ScopeLookupErrorFailsClosed(t *te t.Setenv("SCHEDULER_LAMBDA_ARN", "") lookupErr := errors.New("scope lookup failed") mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, scopedToken). Return(&Session{UserID: scopedUserID}, nil).Maybe() mockAuth.On("HasPermissionAPI", mock.Anything, mock.Anything, mock.Anything, mock.Anything). diff --git a/internal/api/reshape_offered_types_test.go b/internal/api/reshape_offered_types_test.go index f1744fff..adad96a2 100644 --- a/internal/api/reshape_offered_types_test.go +++ b/internal/api/reshape_offered_types_test.go @@ -58,7 +58,8 @@ const reshapeCatalogRegion = "eu-west-3" // 1x m5.4xlarge RI (32 normalized units) at 50% utilization (16 used). On // the legacy ladder that reshapes to 1x m5.2xlarge; with a catalog lacking // m5.2xlarge it must become 2x m5.xlarge. -func newCatalogReshapeHandler(ec2 *fakeReshapeEC2Stub) *Handler { +func newCatalogReshapeHandler(t *testing.T, ec2 *fakeReshapeEC2Stub) *Handler { + t.Helper() ec2.instances = []ec2svc.ConvertibleRI{ { ReservedInstanceID: "ri-1", InstanceType: "m5.4xlarge", InstanceCount: 1, CurrencyCode: "USD", Duration: 31536000, @@ -66,6 +67,7 @@ func newCatalogReshapeHandler(ec2 *fakeReshapeEC2Stub) *Handler { }, } mockStore := &MockConfigStore{} + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("ListStoredRecommendations", mock.Anything, mock.Anything). Return([]config.RecommendationRecord(nil), nil) h := &Handler{ @@ -99,7 +101,7 @@ func reshapeOnce(t *testing.T, h *Handler) *ReshapeRecommendationsResponse { func TestGetReshapeRecommendations_CatalogLimitsTargetToOfferedSizes(t *testing.T) { ec2 := &fakeReshapeEC2Stub{offered: []string{"m5.4xlarge", "m5.xlarge", "c5.large"}} - rec := reshapeOnce(t, newCatalogReshapeHandler(ec2)).Recommendations[0] + rec := reshapeOnce(t, newCatalogReshapeHandler(t, ec2)).Recommendations[0] assert.Equal(t, 1, ec2.offeredCalls, "the reshape handler must query the region's instance type catalog") assert.Equal(t, "m5.xlarge", rec.TargetInstanceType, @@ -109,7 +111,7 @@ func TestGetReshapeRecommendations_CatalogLimitsTargetToOfferedSizes(t *testing. func TestGetReshapeRecommendations_CatalogMissingFamilySurfacesReason(t *testing.T) { ec2 := &fakeReshapeEC2Stub{offered: []string{"c5.large", "c5.xlarge"}} - rec := reshapeOnce(t, newCatalogReshapeHandler(ec2)).Recommendations[0] + rec := reshapeOnce(t, newCatalogReshapeHandler(t, ec2)).Recommendations[0] assert.Empty(t, rec.TargetInstanceType, "an unoffered family must not get a target") assert.Zero(t, rec.TargetCount) @@ -139,7 +141,7 @@ func TestGetReshapeRecommendations_CatalogUnavailableFallsBackWithWarning(t *tes for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { logs := captureWarnings(t) - h := newCatalogReshapeHandler(tc.ec2) + h := newCatalogReshapeHandler(t, tc.ec2) for i := 1; i <= 2; i++ { rec := reshapeOnce(t, h).Recommendations[0] diff --git a/internal/api/retry_carveout_test.go b/internal/api/retry_carveout_test.go index d89e0381..91eb8ee2 100644 --- a/internal/api/retry_carveout_test.go +++ b/internal/api/retry_carveout_test.go @@ -77,6 +77,7 @@ func wireRetryCarveout(t *testing.T, failed *config.PurchaseExecution, mockConfi func TestRetryCarveOut_PlainAdminIsRefused(t *testing.T) { failed := retryCarveoutFailedExec() mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockAuth := new(MockAuthService) h := wireRetryCarveout(t, failed, mockConfig, mockAuth, []auth.Permission{ {Action: auth.ActionAdmin, Resource: auth.ResourceAll}, @@ -118,6 +119,7 @@ func TestRetryCarveOut_PlainAdminIsRefused(t *testing.T) { func TestRetryCarveOut_AdminPurchaserIsAllowed(t *testing.T) { failed := retryCarveoutFailedExec() mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) mockAuth := new(MockAuthService) h := wireRetryCarveout(t, failed, mockConfig, mockAuth, []auth.Permission{ {Action: auth.ActionAdmin, Resource: auth.ResourceAll}, diff --git a/internal/api/ri_exchange_approve_reject_status_test.go b/internal/api/ri_exchange_approve_reject_status_test.go index e07e5383..f11f49f4 100644 --- a/internal/api/ri_exchange_approve_reject_status_test.go +++ b/internal/api/ri_exchange_approve_reject_status_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/LeanerCloud/cloud-commitments-platform/internal/auth" "github.com/LeanerCloud/cloud-commitments-platform/internal/config" ) @@ -75,7 +76,8 @@ type riStatusOpts struct { rateLimited bool // the public approve/cancel rate limit rejects the request } -func riStatusHandler(rec riStatusRecord, o riStatusOpts) *Handler { +func riStatusHandler(t *testing.T, rec riStatusRecord, o riStatusOpts) *Handler { + t.Helper() var record *config.RIExchangeRecord if rec != riRecMissing && rec != riRecMissingNotFound { record = &config.RIExchangeRecord{ @@ -101,6 +103,7 @@ func riStatusHandler(rec riStatusRecord, o riStatusOpts) *Handler { } } store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) switch { case o.dbError: store.On("GetRIExchangeRecord", mock.Anything, riStatusID).Return(nil, errors.New("db down")).Maybe() @@ -119,11 +122,14 @@ func riStatusHandler(rec riStatusRecord, o riStatusOpts) *Handler { store.On("StampRIExchangeApprovedBy", mock.Anything, riStatusID, mock.Anything).Return(nil).Maybe() a := new(MockAuthService) + t.Cleanup(func() { a.AssertExpectations(t) }) a.On("ValidateSession", mock.Anything, "admin-bearer").Return(&Session{UserID: "admin-uuid", Email: "admin@example.com"}, nil).Maybe() a.On("ValidateSession", mock.Anything, mock.Anything).Return(nil, errors.New("invalid session")).Maybe() switch { case o.noApprove: - grantRINone(a, "admin-uuid") + // grantRINone's denial, as a Maybe like every stub here: a caller + // refused on the token or the record is never asked for a permission. + a.On("HasPermissionAPI", mock.Anything, "admin-uuid", mock.Anything, auth.ResourcePurchases).Return(false, nil).Maybe() case o.approveOwn: grantRIApproveOwn(a, "admin-uuid") default: @@ -134,6 +140,7 @@ func riStatusHandler(rec riStatusRecord, o riStatusOpts) *Handler { h := &Handler{config: store, auth: a} if o.rateLimited { rl := new(MockRateLimiter) + t.Cleanup(func() { rl.AssertExpectations(t) }) rl.On("AllowWithIP", mock.Anything, mock.Anything, "approve_cancel_public").Return(false, nil) h.rateLimiter = rl } @@ -184,7 +191,7 @@ type riStatusCase struct { func runRIStatusCases(t *testing.T, action string, cases []riStatusCase) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - h := riStatusHandler(tc.rec, tc.opts) + h := riStatusHandler(t, tc.rec, tc.opts) req := riStatusRequest(action, tc.sess, tc.token) if tc.id != "" { req.RequestContext.HTTP.Path = "/api/ri-exchange/" + action + "/" + tc.id @@ -320,7 +327,7 @@ func TestHandleRequest_RIExchangeTokenRoutes_MissingAnswersLikeWrongToken(t *tes for _, action := range []string{"approve", "reject"} { for _, caller := range callers { do := func(p probe) *events.LambdaFunctionURLResponse { - resp, err := riStatusHandler(p.rec, caller.opts).HandleRequest(context.Background(), riStatusRequest(action, caller.sess, p.token)) + resp, err := riStatusHandler(t, p.rec, caller.opts).HandleRequest(context.Background(), riStatusRequest(action, caller.sess, p.token)) require.NoError(t, err) return resp } @@ -374,7 +381,7 @@ func TestHandleRequest_ApproveRIExchange_ApproveOwnSessionNoOracle(t *testing.T) {"wrong token", "wrong", "invalid approval token"}, } { do := func(p probe) *events.LambdaFunctionURLResponse { - resp, err := riStatusHandler(p.rec, p.opts).HandleRequest(context.Background(), riStatusRequest("approve", riSessCSRF, tc.token)) + resp, err := riStatusHandler(t, p.rec, p.opts).HandleRequest(context.Background(), riStatusRequest("approve", riSessCSRF, tc.token)) require.NoError(t, err) return resp } diff --git a/internal/api/ri_exchange_audit_test.go b/internal/api/ri_exchange_audit_test.go index 7179f27f..be6a0973 100644 --- a/internal/api/ri_exchange_audit_test.go +++ b/internal/api/ri_exchange_audit_test.go @@ -336,6 +336,7 @@ func TestExecuteExchange_OutcomeRecordedAfterRequestCancel(t *testing.T) { // the submit stage are recorded; a request refused by a gate executed nothing. func TestExecuteExchange_GateRefusalWritesNoAuditRow(t *testing.T) { store := newDirectExchangeAuditStore() + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetGlobalConfig", mock.Anything).Return(&config.GlobalConfig{RequireDifferentApprover: true}, nil) h := awsAuditHandler(t, store, func(context.Context, exchange.ExchangeExecuteRequest) (string, *exchange.ExchangeQuoteSummary, error) { t.Fatal("a 4-eyes refusal must not reach AWS") diff --git a/internal/api/router_660_permission_flips_test.go b/internal/api/router_660_permission_flips_test.go index 1d3a758d..e78978a0 100644 --- a/internal/api/router_660_permission_flips_test.go +++ b/internal/api/router_660_permission_flips_test.go @@ -119,6 +119,7 @@ func TestDeletePlan_PermissionGate(t *testing.T) { // requirePlanAccess calls GetAllowedAccountsAPI; stub it to allow all. mockAuth.On("GetAllowedAccountsAPI", ctx, userID).Return([]string{}, nil) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // After the permission gate the handler calls GetPurchasePlan then // DeletePurchasePlan. Seed both so we don't crash on unexpected calls. mockStore.GetPurchasePlanFn = func(_ context.Context, id string) (*config.PurchasePlan, error) { @@ -143,6 +144,7 @@ func TestDeletePlan_PermissionGate(t *testing.T) { // nil without calling GetAllowedAccountsAPI), so do NOT register that expectation. mockAuth := authForAdmin(ctx, t) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.GetPurchasePlanFn = func(_ context.Context, id string) (*config.PurchasePlan, error) { return &config.PurchasePlan{ID: id}, nil } @@ -165,6 +167,7 @@ func TestUpdatePlan_PermissionGate(t *testing.T) { mockAuth := authForUserWith(ctx, t, userID, "update", "plans", true) mockAuth.On("GetAllowedAccountsAPI", ctx, userID).Return([]string{}, nil) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.GetPurchasePlanFn = func(_ context.Context, id string) (*config.PurchasePlan, error) { return &config.PurchasePlan{ID: id, Name: "Old"}, nil } @@ -257,6 +260,7 @@ func TestPausePlannedPurchase_PermissionGate(t *testing.T) { mockAuth.On("HasPermissionAPI", ctx, userID, "update-any", "purchases").Return(false, nil) creator := userID mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // requireExecutionAccess + authorizeExecutionManagement both call // GetExecutionByID; stub a row created by this user. mockStore.On("GetExecutionByID", ctx, execID). @@ -279,6 +283,7 @@ func TestPausePlannedPurchase_PermissionGate(t *testing.T) { mockAuth.On("HasPermissionAPI", ctx, userID, "update-any", "purchases").Return(false, nil) otherCreator := "99999999-9999-9999-9999-999999999999" mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetExecutionByID", ctx, execID). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending", CreatedByUserID: &otherCreator}, nil) @@ -297,6 +302,7 @@ func TestPausePlannedPurchase_PermissionGate(t *testing.T) { mockAuth.On("HasPermissionAPI", ctx, userID, "update-any", "purchases").Return(true, nil) mockAuth.allowConstraintChecks() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // update-any skips the ownership check but its Constraints are // evaluated against the stored row (issue #1098). mockStore.On("GetExecutionByID", ctx, execID). @@ -322,6 +328,7 @@ func TestPausePlannedPurchase_PermissionGate(t *testing.T) { // immediately without calling GetAllowedAccountsAPI or GetExecutionByID). mockAuth := authForAdmin(ctx, t) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetExecutionByID", ctx, execID).Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending"}, nil) mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "notified"}, "paused", mock.Anything). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "paused"}, nil) @@ -352,6 +359,7 @@ func TestDeletePlannedPurchase_PermissionGate(t *testing.T) { mockAuth.On("HasPermissionAPI", ctx, userID, "cancel-own", "purchases").Return(false, nil) creator := userID mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetExecutionByID", ctx, execID). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending", CreatedByUserID: &creator}, nil) mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "notified", "paused"}, "canceled", mock.Anything). @@ -371,6 +379,7 @@ func TestDeletePlannedPurchase_PermissionGate(t *testing.T) { mockAuth.On("HasPermissionAPI", ctx, userID, "cancel-any", "purchases").Return(false, nil) otherCreator := "99999999-9999-9999-9999-999999999999" mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetExecutionByID", ctx, execID). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending", CreatedByUserID: &otherCreator}, nil) @@ -406,6 +415,7 @@ func TestDeletePlannedPurchase_PermissionGate(t *testing.T) { creator := userID mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetExecutionByID", ctx, execID). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending", CreatedByUserID: &creator}, nil) mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "notified", "paused"}, "canceled", mock.Anything). @@ -428,6 +438,7 @@ func TestDeletePlannedPurchase_PermissionGate(t *testing.T) { otherCreator := "99999999-9999-9999-9999-999999999999" mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetExecutionByID", ctx, execID). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending", CreatedByUserID: &otherCreator}, nil) @@ -508,13 +519,20 @@ func TestUpdateRIExchangeConfig_PermissionGate(t *testing.T) { t.Run("user with update:config clears 403 gate", func(t *testing.T) { mockAuth := authForUserWith(ctx, t, userID, "update", "config", true) + // The write path also checks the caller's account scope, the + // constraints on update:config, and (for the auto-mode gate) any + // other grant; this caller holds update:config alone, unrestricted. + mockAuth.On("GetAllowedAccountsAPI", ctx, userID).Return(nil, nil) + mockAuth.allowConstraintChecks() + mockAuth.On("HasPermissionAPI", ctx, userID, mock.Anything, mock.Anything).Return(false, nil).Maybe() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) - mockStore.On("SaveGlobalConfig", ctx, &config.GlobalConfig{}).Return(nil).Maybe() + mockStore.On("SaveGlobalConfig", ctx, mock.AnythingOfType("*config.GlobalConfig")).Return(nil) h := &Handler{auth: mockAuth, config: mockStore} - body := `{"auto_exchange_enabled":false,"mode":"recommend","utilization_threshold":80,"max_payment_per_exchange_usd":"0","max_payment_daily_usd":"0"}` + body := `{"auto_exchange_enabled":false,"mode":"manual","utilization_threshold":80,"max_payment_per_exchange_usd":0,"max_payment_daily_usd":0,"lookback_days":30}` _, err := h.updateRIExchangeConfig(ctx, reqWithBearerAndBody("user-token", body)) - assertNotForbidden(t, err) + require.NoError(t, err, "a caller holding update:config must be able to save the RI exchange config") }) } diff --git a/internal/api/router_authuser_test.go b/internal/api/router_authuser_test.go index 505620e0..a6935160 100644 --- a/internal/api/router_authuser_test.go +++ b/internal/api/router_authuser_test.go @@ -44,8 +44,9 @@ func TestRouterAuthUser_NoCredentials_Rejects(t *testing.T) { func TestRouterAuthUser_InvalidBearerToken_Rejects(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - mockAuth.On("ValidateUserAPIKeyAPI", ctx, ""). - Return(nil, nil, errors.New("empty key")) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) + // No ValidateUserAPIKeyAPI expectation: without an API key header the + // key lookup is skipped, so only the bearer session is validated. mockAuth.On("ValidateSession", ctx, "bad-token"). Return(nil, errors.New("expired")) h := &Handler{auth: mockAuth} @@ -70,6 +71,7 @@ func TestRouterAuthUser_InvalidBearerToken_Rejects(t *testing.T) { func TestRouterAuthUser_ValidUserSession_Accepts(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) userSession := &Session{UserID: "11111111-1111-1111-1111-111111111111"} mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession, nil) mockAuth.On("Logout", mock.Anything, "user-token").Return(nil) @@ -272,6 +274,7 @@ func TestRequireAuth_AdminAPIKey(t *testing.T) { func TestRequireAuth_UserSession(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) userSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession, nil) h := &Handler{auth: mockAuth} diff --git a/internal/api/router_handlers_test.go b/internal/api/router_handlers_test.go index 634c8c2d..9eb23bea 100644 --- a/internal/api/router_handlers_test.go +++ b/internal/api/router_handlers_test.go @@ -30,7 +30,9 @@ func newTestRouter(h *Handler) *Router { func TestRouter_patchPlanHandler(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -70,9 +72,9 @@ func TestRouter_executePurchaseHandler_NoAuth(t *testing.T) { func TestRouter_getPurchaseDetailsHandler_InvalidUUID(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - adminSession := &Session{UserID: "uid"} - mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) - mockAuth.grantAdmin() + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) + // The ID is validated before the session is read, so no session + // expectation: a malformed ID is refused without authenticating. h := &Handler{auth: mockAuth} r := newTestRouter(h) @@ -123,7 +125,9 @@ func TestRouter_executeExchangeHandler_NoAuth(t *testing.T) { func TestRouter_getRIExchangeConfigHandler(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -143,7 +147,9 @@ func TestRouter_getRIExchangeConfigHandler(t *testing.T) { func TestRouter_updateRIExchangeConfigHandler(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -165,7 +171,9 @@ func TestRouter_updateRIExchangeConfigHandler(t *testing.T) { func TestRouter_getRIExchangeHistoryHandler(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -185,6 +193,7 @@ func TestRouter_getRIExchangeHistoryHandler(t *testing.T) { func TestRouter_approveRIExchangeHandler_InvalidToken(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) // approveRIExchange is a public endpoint (no auth required, uses token-based auth) record := &config.RIExchangeRecord{ @@ -207,6 +216,7 @@ func TestRouter_approveRIExchangeHandler_InvalidToken(t *testing.T) { func TestRouter_rejectRIExchangeHandler_InvalidToken(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) record := &config.RIExchangeRecord{ ID: "11111111-1111-1111-1111-111111111111", @@ -227,6 +237,7 @@ func TestRouter_rejectRIExchangeHandler_InvalidToken(t *testing.T) { func TestRouter_listAccountsHandler(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -246,6 +257,7 @@ func TestRouter_listAccountsHandler(t *testing.T) { func TestRouter_createAccountHandler(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -271,6 +283,7 @@ func TestRouter_discoverOrgAccountsHandler(t *testing.T) { // behavior is exercised in handler_accounts_test.go. ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -291,6 +304,7 @@ func TestRouter_discoverOrgAccountsHandler(t *testing.T) { func TestRouter_getAccountHandler(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -310,6 +324,7 @@ func TestRouter_getAccountHandler(t *testing.T) { func TestRouter_saveAccountCredentialsHandler_NoCredStore(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -332,6 +347,7 @@ func TestRouter_saveAccountCredentialsHandler_NoCredStore(t *testing.T) { func TestRouter_testAccountCredentialsHandler(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -352,6 +368,7 @@ func TestRouter_testAccountCredentialsHandler(t *testing.T) { func TestRouter_listAccountServiceOverridesHandler(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -371,6 +388,7 @@ func TestRouter_listAccountServiceOverridesHandler(t *testing.T) { func TestRouter_setPlanAccountsHandler(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -398,7 +416,9 @@ func TestRouter_setPlanAccountsHandler(t *testing.T) { func TestHandler_getRIExchangeConfig_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -427,7 +447,9 @@ func TestHandler_getRIExchangeConfig_NoAuth(t *testing.T) { func TestHandler_getRIExchangeConfig_StoreError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -444,7 +466,9 @@ func TestHandler_getRIExchangeConfig_StoreError(t *testing.T) { func TestHandler_updateRIExchangeConfig_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -464,6 +488,7 @@ func TestHandler_updateRIExchangeConfig_Success(t *testing.T) { func TestHandler_updateRIExchangeConfig_InvalidBody(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -481,6 +506,7 @@ func TestHandler_updateRIExchangeConfig_InvalidBody(t *testing.T) { func TestHandler_updateRIExchangeConfig_ValidationError(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -574,7 +600,9 @@ func TestHandler_getRIExchangeConfigUpdateRequest_Validate(t *testing.T) { func TestHandler_getRIExchangeHistory_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -599,7 +627,9 @@ func TestHandler_getRIExchangeHistory_Success(t *testing.T) { func TestHandler_getRIExchangeHistory_StoreError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -630,6 +660,7 @@ func TestHandler_validateExchangeApproval_NoToken(t *testing.T) { func TestHandler_validateExchangeApproval_RecordNotFound(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetRIExchangeRecord", ctx, "11111111-1111-1111-1111-111111111111").Return(nil, nil) h := &Handler{config: mockStore} @@ -645,6 +676,7 @@ func TestHandler_validateExchangeApproval_RecordNotFound(t *testing.T) { func TestHandler_validateExchangeApproval_NoApprovalToken(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetRIExchangeRecord", ctx, "11111111-1111-1111-1111-111111111111").Return( &config.RIExchangeRecord{ID: "11111111-1111-1111-1111-111111111111", ApprovalToken: ""}, nil) @@ -658,6 +690,7 @@ func TestHandler_validateExchangeApproval_NoApprovalToken(t *testing.T) { func TestHandler_validateExchangeApproval_WrongToken(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetRIExchangeRecord", ctx, "11111111-1111-1111-1111-111111111111").Return( &config.RIExchangeRecord{ID: "11111111-1111-1111-1111-111111111111", ApprovalToken: "correct-token"}, nil) @@ -670,6 +703,7 @@ func TestHandler_validateExchangeApproval_WrongToken(t *testing.T) { func TestHandler_validateExchangeApproval_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetRIExchangeRecord", ctx, "11111111-1111-1111-1111-111111111111").Return( &config.RIExchangeRecord{ID: "11111111-1111-1111-1111-111111111111", ApprovalToken: config.HashApprovalToken("good-token")}, nil) @@ -699,6 +733,7 @@ func TestHandler_testAccountCredentials_NoAuth(t *testing.T) { func TestHandler_testAccountCredentials_NotFound(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -721,6 +756,7 @@ func TestHandler_testAccountCredentials_NotFound(t *testing.T) { func TestHandler_testAccountCredentials_AmbientCredResult(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -748,6 +784,7 @@ func TestHandler_testAccountCredentials_AmbientCredResult(t *testing.T) { func TestHandler_testAccountCredentials_CheckPresence(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -785,6 +822,7 @@ func TestHandler_rejectRIExchange_EmptyToken(t *testing.T) { func TestHandler_rejectRIExchange_ValidTokenAndRecord(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetRIExchangeRecord", ctx, "11111111-1111-1111-1111-111111111111").Return( &config.RIExchangeRecord{ ID: "11111111-1111-1111-1111-111111111111", @@ -812,6 +850,7 @@ func TestHandler_rejectRIExchange_ValidTokenAndRecord(t *testing.T) { func TestHandler_failExchange(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("FailRIExchange", mock.Anything, "11111111-1111-1111-1111-111111111111", "test reason").Return(nil) h := &Handler{config: mockStore} @@ -825,6 +864,7 @@ func TestHandler_failExchange(t *testing.T) { func TestHandler_failExchange_DBError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // Even when DB fails, failExchange returns the failure result (logging the error) mockStore.On("FailRIExchange", mock.Anything, "11111111-1111-1111-1111-111111111111", "reason"). Return(errors.New("db down")) @@ -920,7 +960,9 @@ func TestHandler_docsHandler_OpenAPISpec(t *testing.T) { func TestHandler_getRIExchangeHistory_SinceTime(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) adminSession := &Session{UserID: "uid"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() @@ -1038,6 +1080,7 @@ func TestHandler_getRIExchangeHistory_AccountScope(t *testing.T) { t.Parallel() ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "tok").Return(&Session{UserID: "uid"}, nil) mockAuth.grantScoped(tc.scope...) store := new(MockConfigStore) @@ -1078,8 +1121,10 @@ const ( // scopedExchangeHistoryHandler wires a handler whose session is scoped to the // "u-b" account (STS id 222233334444), with "u-a" (111122223333) also // registered. -func scopedExchangeHistoryHandler(ctx context.Context, store *MockConfigStore) (*Handler, *events.LambdaFunctionURLRequest) { +func scopedExchangeHistoryHandler(t *testing.T, ctx context.Context, store *MockConfigStore) (*Handler, *events.LambdaFunctionURLRequest) { + t.Helper() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, "tok").Return(&Session{UserID: "uid"}, nil) mockAuth.grantScoped("u-b") store.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { @@ -1111,7 +1156,7 @@ func TestHandler_getRIExchangeHistory_ScopedLimitAppliesAfterAccountFilter(t *te } store.On("GetRIExchangeHistoryForAccounts", ctx, mock.Anything, 500, []string{exchangeAcctB, "u-b"}). Return(olderForB, nil).Once() - h, req := scopedExchangeHistoryHandler(ctx, store) + h, req := scopedExchangeHistoryHandler(t, ctx, store) result, err := h.getRIExchangeHistory(ctx, req) require.NoError(t, err) @@ -1131,13 +1176,14 @@ func TestHandler_getRIExchangeHistory_ScopedDropsOutOfScopeStoreRows(t *testing. t.Parallel() ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetRIExchangeHistoryForAccounts", ctx, mock.Anything, 500, mock.Anything). Return([]config.RIExchangeRecord{ {ID: "a-1", AccountID: exchangeAcctA}, {ID: "b-1", AccountID: exchangeAcctB}, {ID: "x-1", AccountID: exchangeAcctOther}, }, nil).Once() - h, req := scopedExchangeHistoryHandler(ctx, store) + h, req := scopedExchangeHistoryHandler(t, ctx, store) result, err := h.getRIExchangeHistory(ctx, req) require.NoError(t, err) @@ -1152,9 +1198,10 @@ func TestHandler_getRIExchangeHistory_ScopedErrors(t *testing.T) { t.Parallel() ctx := context.Background() store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetRIExchangeHistoryForAccounts", ctx, mock.Anything, 500, mock.Anything). Return(nil, errors.New("db error")).Once() - h, req := scopedExchangeHistoryHandler(ctx, store) + h, req := scopedExchangeHistoryHandler(t, ctx, store) _, err := h.getRIExchangeHistory(ctx, req) require.ErrorContains(t, err, "failed to load exchange history: db error") @@ -1163,7 +1210,7 @@ func TestHandler_getRIExchangeHistory_ScopedErrors(t *testing.T) { t.Parallel() ctx := context.Background() store := new(MockConfigStore) - h, req := scopedExchangeHistoryHandler(ctx, store) + h, req := scopedExchangeHistoryHandler(t, ctx, store) store.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { return nil, errors.New("list failed") } diff --git a/internal/api/service_config_scope_test.go b/internal/api/service_config_scope_test.go index 6266d478..24df1b70 100644 --- a/internal/api/service_config_scope_test.go +++ b/internal/api/service_config_scope_test.go @@ -60,6 +60,7 @@ func TestRouterDispatch_UpdateServiceConfig_SameRefusalAsGlobalConfig(t *testing func TestRouterDispatch_UpdateServiceConfig_UnrestrictedAdminSucceeds(t *testing.T) { h, store := scopedHandler(t) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("GetServiceConfig", mock.Anything, "aws", "ec2").Return(nil, nil) store.On("SaveServiceConfig", mock.Anything, mock.Anything).Return(nil) diff --git a/internal/api/session_denial_final_test.go b/internal/api/session_denial_final_test.go index 8339641d..7f587bdd 100644 --- a/internal/api/session_denial_final_test.go +++ b/internal/api/session_denial_final_test.go @@ -26,6 +26,7 @@ var errDenialTestReached = errors.New("mutation reached") func newDenialTestHandler(t *testing.T, store *MockConfigStore, mockPurchase *MockPurchaseManager) (*Handler, context.Context) { t.Helper() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{UserID: denialTestUserID, Email: "denied@example.com"}, nil) mockAuth.On("HasPermissionAPI", mock.Anything, denialTestUserID, mock.Anything, mock.Anything).Return(false, nil) @@ -64,6 +65,7 @@ func requireForbidden(t *testing.T, err error) { func TestApprovePurchase_SessionDenialWithoutTokenIsFinal(t *testing.T) { store, mockPurchase := new(MockConfigStore), new(MockPurchaseManager) + t.Cleanup(func() { store.AssertExpectations(t) }) h, ctx := newDenialTestHandler(t, store, mockPurchase) store.On("GetExecutionByID", mock.Anything, denialTestExecID).Return(&config.PurchaseExecution{ ExecutionID: denialTestExecID, @@ -80,6 +82,7 @@ func TestApprovePurchase_SessionDenialWithoutTokenIsFinal(t *testing.T) { func TestCancelPurchase_SessionDenialWithoutTokenIsFinal(t *testing.T) { store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) h, ctx := newDenialTestHandler(t, store, new(MockPurchaseManager)) store.On("GetExecutionByID", mock.Anything, denialTestExecID).Return(&config.PurchaseExecution{ ExecutionID: denialTestExecID, @@ -94,6 +97,7 @@ func TestCancelPurchase_SessionDenialWithoutTokenIsFinal(t *testing.T) { func TestRevokePurchase_SessionDenialWithoutTokenIsFinal(t *testing.T) { store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) h, ctx := newDenialTestHandler(t, store, new(MockPurchaseManager)) store.On("GetExecutionByID", mock.Anything, denialTestExecID).Return(&config.PurchaseExecution{ ExecutionID: denialTestExecID, @@ -125,6 +129,7 @@ func TestApproveRIExchange_SessionDenialWithoutTokenIsFinal(t *testing.T) { func TestApproveRIExchange_SessionDenialWithTokenUsesToken(t *testing.T) { store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) h, ctx := newDenialTestHandler(t, store, new(MockPurchaseManager)) store.On("GetRIExchangeRecord", mock.Anything, denialTestExecID).Return(&config.RIExchangeRecord{ ID: denialTestExecID, diff --git a/internal/api/tenant_scope_sweep_test.go b/internal/api/tenant_scope_sweep_test.go index 0fe7b301..72022e68 100644 --- a/internal/api/tenant_scope_sweep_test.go +++ b/internal/api/tenant_scope_sweep_test.go @@ -111,6 +111,7 @@ func TestListPlans_UnrestrictedCallerStillSeesUnassignedPlans(t *testing.T) { func TestListPlans_ScopeLookupErrorFailsClosed(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) mockAuth.On("ValidateSession", ctx, scopedToken).Return(&Session{UserID: scopedUserID}, nil) mockAuth.On("HasPermissionAPI", ctx, scopedUserID, "view", "plans").Return(true, nil) mockAuth.On("GetAllowedAccountsAPI", ctx, scopedUserID).Return(nil, errors.New("auth store unavailable")) diff --git a/internal/auth/explicit_zero_cap_test.go b/internal/auth/explicit_zero_cap_test.go index 2491894d..887c01fe 100644 --- a/internal/auth/explicit_zero_cap_test.go +++ b/internal/auth/explicit_zero_cap_test.go @@ -44,6 +44,7 @@ func TestGroupWrite_RejectsExplicitZeroMaxAmount(t *testing.T) { t.Run("update "+raw, func(t *testing.T) { mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) svc := newCeilingService(t, mockStore) stubTargetGroup(ctx, mockStore, &Group{ID: ceilingTargetID, Name: "Team"}) stubActorPermissionsMaybe(ctx, mockStore, adminOnly) @@ -86,6 +87,7 @@ func TestGroupWrite_AbsentNullAndPositiveMaxAmountAreNotRejected(t *testing.T) { for constraints, want := range cases { t.Run(constraints, func(t *testing.T) { mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) svc := newCeilingService(t, mockStore) stubActorPermissions(ctx, mockStore, adminOnly) var saved *Group diff --git a/internal/auth/group_combination_test.go b/internal/auth/group_combination_test.go index c0f12dd7..ba8b579c 100644 --- a/internal/auth/group_combination_test.go +++ b/internal/auth/group_combination_test.go @@ -112,6 +112,7 @@ func TestCheckGroupCombination_MirrorsFrontend(t *testing.T) { func TestCheckGroupCombination_UsesPermissionsNotNames(t *testing.T) { ctx := context.Background() m := new(MockStore) + t.Cleanup(func() { m.AssertExpectations(t) }) svc := createTestService(m, new(MockEmailSender)) m.On("GetGroup", ctx, "a").Return(&Group{ID: "a", Name: "Viewers", Permissions: []Permission{{Action: ActionUpdate, Resource: "plans"}}}, nil) m.On("GetGroup", ctx, "b").Return(&Group{ID: "b", Name: "Administrators", Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}}, nil) @@ -128,6 +129,7 @@ func TestCheckGroupCombination_UsesPermissionsNotNames(t *testing.T) { func TestCheckGroupCombination_StoreErrorFailsClosed(t *testing.T) { ctx := context.Background() m := new(MockStore) + t.Cleanup(func() { m.AssertExpectations(t) }) svc := createTestService(m, new(MockEmailSender)) m.On("GetGroup", ctx, "v1").Return(comboViewOnly, nil).Maybe() m.On("GetGroup", ctx, "w1").Return(nil, errors.New("connection reset")) diff --git a/internal/auth/group_permission_change_test.go b/internal/auth/group_permission_change_test.go index 0078fbf5..4a103113 100644 --- a/internal/auth/group_permission_change_test.go +++ b/internal/auth/group_permission_change_test.go @@ -160,6 +160,7 @@ func TestUpdateGroupAPI_MemberCheckStoreErrorFailsClosed(t *testing.T) { t.Run("listing users fails", func(t *testing.T) { ctx := context.Background() m := new(MockStore) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("GetGroup", ctx, changeTargetID).Return(changeTarget(), nil) m.On("ListUsers", ctx).Return(nil, errors.New("connection reset")) svc := createTestService(m, new(MockEmailSender)) @@ -174,6 +175,7 @@ func TestUpdateGroupAPI_MemberCheckStoreErrorFailsClosed(t *testing.T) { t.Run("loading a member's other group fails", func(t *testing.T) { ctx := context.Background() m := new(MockStore) + t.Cleanup(func() { m.AssertExpectations(t) }) m.On("GetGroup", ctx, changeTargetID).Return(changeTarget(), nil) m.On("GetGroup", ctx, "x1").Return(nil, errors.New("connection reset")) m.On("ListUsers", ctx).Return([]User{{ID: "u1", GroupIDs: []string{changeTargetID, "x1"}}}, nil) diff --git a/internal/auth/negative_purchase_cap_test.go b/internal/auth/negative_purchase_cap_test.go index c9cda9d3..07ed7762 100644 --- a/internal/auth/negative_purchase_cap_test.go +++ b/internal/auth/negative_purchase_cap_test.go @@ -248,6 +248,7 @@ func TestCreateAPIKey_NegativeMaxPurchaseAmount(t *testing.T) { t.Run("negative refused before the key is stored", func(t *testing.T) { mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) err := run(mockStore, -1) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidPermission) @@ -257,6 +258,7 @@ func TestCreateAPIKey_NegativeMaxPurchaseAmount(t *testing.T) { for _, amount := range []float64{0, 100} { mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) require.NoError(t, run(mockStore, amount), "amount %v", amount) mockStore.AssertCalled(t, "CreateAPIKey", ctx, mock.AnythingOfType("*auth.UserAPIKey")) } diff --git a/internal/auth/not_found_sentinels_test.go b/internal/auth/not_found_sentinels_test.go index fdd30a19..e28ca80c 100644 --- a/internal/auth/not_found_sentinels_test.go +++ b/internal/auth/not_found_sentinels_test.go @@ -18,6 +18,7 @@ import ( func TestService_GetUser_MissingUserIsErrUserNotFound(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) mockStore.On("GetUserByID", ctx, "missing").Return(nil, pgx.ErrNoRows).Once() @@ -35,6 +36,7 @@ func TestService_GetGroupAPI_MissingGroupIsErrGroupNotFound(t *testing.T) { t.Run("store returns ErrNoRows", func(t *testing.T) { mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) mockStore.On("GetGroup", ctx, "missing").Return(nil, pgx.ErrNoRows).Once() @@ -46,6 +48,7 @@ func TestService_GetGroupAPI_MissingGroupIsErrGroupNotFound(t *testing.T) { t.Run("store returns nil group", func(t *testing.T) { mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) mockStore.On("GetGroup", ctx, "missing").Return(nil, nil).Once() @@ -65,6 +68,7 @@ func TestService_APIKeyAccess_NotFoundAndNotOwnedSentinels(t *testing.T) { for name, act := range actions { t.Run(name+" missing key", func(t *testing.T) { mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetAPIKeyByID", ctx, "key-1").Return(nil, pgx.ErrNoRows).Once() err := act(&Service{store: mockStore}) @@ -74,6 +78,7 @@ func TestService_APIKeyAccess_NotFoundAndNotOwnedSentinels(t *testing.T) { t.Run(name+" nil key", func(t *testing.T) { mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetAPIKeyByID", ctx, "key-1").Return(nil, nil).Once() err := act(&Service{store: mockStore}) @@ -83,6 +88,7 @@ func TestService_APIKeyAccess_NotFoundAndNotOwnedSentinels(t *testing.T) { t.Run(name+" another user's key", func(t *testing.T) { mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetAPIKeyByID", ctx, "key-1"). Return(&UserAPIKey{ID: "key-1", UserID: "user-456", IsActive: true}, nil) mockStore.On("GetUserByID", ctx, "user-123").Return(&User{ID: "user-123"}, nil) diff --git a/internal/auth/service_api_fields_test.go b/internal/auth/service_api_fields_test.go index 41aebcbe..f675b7a7 100644 --- a/internal/auth/service_api_fields_test.go +++ b/internal/auth/service_api_fields_test.go @@ -85,6 +85,7 @@ func TestAPIAdapters_CreateUser_AllFieldsReachStore(t *testing.T) { requireFieldsCovered(t, req, checks) store := new(MockStore) + t.Cleanup(func() { store.AssertExpectations(t) }) svc := createTestService(store, new(MockEmailSender)) var got *User store.On("GetUserByEmail", ctx, req.Email).Return(nil, nil).Once() @@ -109,6 +110,7 @@ func TestAPIAdapters_UpdateUser_AllFieldsReachStore(t *testing.T) { } run := func(t *testing.T, req APIUpdateUserRequest) *User { store := new(MockStore) + t.Cleanup(func() { store.AssertExpectations(t) }) svc := createTestService(store, new(MockEmailSender)) var got *User store.On("GetUserByID", ctx, "user-123").Return(existing(), nil).Once() @@ -163,6 +165,7 @@ func TestAPIAdapters_CreateGroup_AllFieldsReachStore(t *testing.T) { requireFieldsCovered(t, req, checks) store := new(MockStore) + t.Cleanup(func() { store.AssertExpectations(t) }) svc := createTestService(store, new(MockEmailSender)) var got *Group store.On("CreateGroup", ctx, mock.AnythingOfType("*auth.Group")). @@ -186,6 +189,7 @@ func TestAPIAdapters_UpdateGroup_AllFieldsReachStore(t *testing.T) { } run := func(t *testing.T, req APIUpdateGroupRequest) *Group { store := new(MockStore) + t.Cleanup(func() { store.AssertExpectations(t) }) svc := createTestService(store, new(MockEmailSender)) var got *Group // Twice: UpdateGroupAPI loads the group, then Service.UpdateGroup @@ -248,6 +252,7 @@ func TestAPIAdapters_CreateAPIKey_AllFieldsReachStore(t *testing.T) { requireFieldsCovered(t, req, checks) store := new(MockStore) + t.Cleanup(func() { store.AssertExpectations(t) }) svc := &Service{store: store} store.On("GetUserByID", ctx, "user-1").Return(&User{ ID: "user-1", Email: "test@example.com", Active: true, diff --git a/internal/auth/service_api_test.go b/internal/auth/service_api_test.go index dd40e53e..1f2f243c 100644 --- a/internal/auth/service_api_test.go +++ b/internal/auth/service_api_test.go @@ -443,6 +443,7 @@ func TestService_UpdateGroupAPI(t *testing.T) { // (possibly admin:*) while UpdateGroup still returned success. t.Run("explicit empty permissions list is refused, not silently kept", func(t *testing.T) { mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) service := createTestService(mockStore, mockEmail) diff --git a/internal/auth/service_apikeys_effective_test.go b/internal/auth/service_apikeys_effective_test.go index 18222573..cff5659d 100644 --- a/internal/auth/service_apikeys_effective_test.go +++ b/internal/auth/service_apikeys_effective_test.go @@ -90,11 +90,13 @@ func TestGetAPIKeyEffectivePermissionsAPI_FailsClosed(t *testing.T) { }{ {name: "key lookup error", store: func(t *testing.T) *MockStore { s := new(MockStore) + t.Cleanup(func() { s.AssertExpectations(t) }) s.On("GetAPIKeyByID", mock.Anything, effKeyID).Return((*UserAPIKey)(nil), errors.New("db down")) return s }}, {name: "key not found", store: func(t *testing.T) *MockStore { s := new(MockStore) + t.Cleanup(func() { s.AssertExpectations(t) }) s.On("GetAPIKeyByID", mock.Anything, effKeyID).Return((*UserAPIKey)(nil), nil) return s }}, @@ -125,6 +127,7 @@ func TestGetAPIKeyEffectivePermissionsAPI_FailsClosed(t *testing.T) { }}, {name: "owner group lookup error", store: func(t *testing.T) *MockStore { s := new(MockStore) + t.Cleanup(func() { s.AssertExpectations(t) }) s.On("GetAPIKeyByID", mock.Anything, effKeyID).Return(effKey(view), nil) s.On("GetUserByID", mock.Anything, effOwnerID).Return(effOwner(), nil) s.On("GetGroup", mock.Anything, effGroupID).Return((*Group)(nil), errors.New("db down")) diff --git a/internal/auth/service_apikeys_test.go b/internal/auth/service_apikeys_test.go index d6044240..07910543 100644 --- a/internal/auth/service_apikeys_test.go +++ b/internal/auth/service_apikeys_test.go @@ -1033,6 +1033,7 @@ func TestService_RecordUsageAsync_CountsConcurrentRequests(t *testing.T) { // book its count, and that flusher must still write it. func TestService_RecordUsageAsync_OneFlusherPerKey(t *testing.T) { mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := &Service{store: mockStore} const requests = 200 @@ -1102,6 +1103,7 @@ func TestAPIKeyUsageEntry_ReleaseFlusher(t *testing.T) { // usage would never be written again. func TestService_RecordUsageAsync_PanicReleasesFlusher(t *testing.T) { mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := &Service{store: mockStore} mockStore.On("RecordAPIKeyUsage", mock.Anything, "key-1", int64(1)). diff --git a/internal/auth/service_group_only_authz_test.go b/internal/auth/service_group_only_authz_test.go index 0b093927..f16a1dc3 100644 --- a/internal/auth/service_group_only_authz_test.go +++ b/internal/auth/service_group_only_authz_test.go @@ -133,6 +133,7 @@ func TestGroupOnlyAuthz_LookupErrorDenies(t *testing.T) { func TestCreateUser_RejectsZeroGroups(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) svc := createTestService(mockStore, mockEmail) @@ -154,6 +155,7 @@ func TestCreateUser_RejectsZeroGroups(t *testing.T) { func TestUpdateUser_RejectsZeroGroups(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) svc := createTestService(mockStore, mockEmail) diff --git a/internal/auth/service_mfa_login_token_test.go b/internal/auth/service_mfa_login_token_test.go index 687f977e..ec393316 100644 --- a/internal/auth/service_mfa_login_token_test.go +++ b/internal/auth/service_mfa_login_token_test.go @@ -244,6 +244,7 @@ func TestLoginWithMFAToken_WrongCodeRecordsFailure(t *testing.T) { func TestLoginWithMFAToken_LostConsumeRefused(t *testing.T) { ctx := context.Background() store := new(MockStore) + t.Cleanup(func() { store.AssertExpectations(t) }) svc := createTestService(store, new(MockEmailSender)) user := mfaTokenUnitUser(t) store.On("ClaimMFALoginTokenAttempt", ctx, mock.Anything, MaxMFALoginTokenAttempts). diff --git a/internal/auth/service_mfa_test.go b/internal/auth/service_mfa_test.go index 72de6f4f..369e980e 100644 --- a/internal/auth/service_mfa_test.go +++ b/internal/auth/service_mfa_test.go @@ -228,6 +228,7 @@ func TestMFASetup_HappyPath(t *testing.T) { func TestMFASetup_WrongPassword(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) user := createTestUser(t, "SecurePass@123") @@ -282,6 +283,7 @@ func TestMFAEnable_ExpiredPending(t *testing.T) { func TestMFAEnable_NoPending(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) user := createTestUser(t, "SecurePass@123") @@ -295,6 +297,7 @@ func TestMFAEnable_NoPending(t *testing.T) { func TestMFAEnable_WrongCode(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) secret := "JBSWY3DPEHPK3PXP" @@ -333,6 +336,7 @@ func TestMFADisable_WithTOTP(t *testing.T) { func TestMFADisable_WithRecoveryCode(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) secret := "JBSWY3DPEHPK3PXP" @@ -382,6 +386,7 @@ func TestMFADisable_WithRecoveryCodePassesCodesAsRead(t *testing.T) { func TestMFADisable_WrongPassword(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) user := createTestUser(t, "SecurePass@123") @@ -436,6 +441,7 @@ func TestMFASetupAndEnable_RefusedWhileEnabled(t *testing.T) { func TestMFASetup_WrongPasswordWhileEnabled_ReportsPassword(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) user := createTestUser(t, "SecurePass@123") @@ -449,6 +455,7 @@ func TestMFASetup_WrongPasswordWhileEnabled_ReportsPassword(t *testing.T) { func TestMFARegenerateRecoveryCodes_HappyPath(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) secret := "JBSWY3DPEHPK3PXP" @@ -478,6 +485,7 @@ func TestMFARegenerateRecoveryCodes_HappyPath(t *testing.T) { func TestMFARegenerateRecoveryCodes_WrongTOTP(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) user := createTestUser(t, "SecurePass@123") @@ -494,6 +502,7 @@ func TestMFARegenerateRecoveryCodes_WrongTOTP(t *testing.T) { func TestLogin_WithMFA_RecoveryCode_ConsumedOnce(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) plaintextCode := "ABCD-2345" @@ -508,7 +517,6 @@ func TestLogin_WithMFA_RecoveryCode_ConsumedOnce(t *testing.T) { mockStore.On("GetUserByEmail", ctx, user.Email).Return(user, nil) mockStore.On("ConsumeMFARecoveryCode", ctx, user.ID, []string{hash}, []string{}).Return(nil).Once() mockStore.On("RecordSuccessfulLogin", ctx, user.ID).Return(nil) - mockStore.On("RecordFailedLogin", ctx, user.ID).Return(nil) mockStore.On("CreateSession", ctx, mock.AnythingOfType("*auth.Session")).Return(nil) resp, err := service.Login(ctx, LoginRequest{ @@ -755,6 +763,7 @@ func TestMFADisable_ReplayedTOTPIsRejected(t *testing.T) { func TestMFARegenerateRecoveryCodes_ReplayedTOTPIsRejected(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) secret := "JBSWY3DPEHPK3PXP" @@ -773,6 +782,7 @@ func TestMFARegenerateRecoveryCodes_ReplayedTOTPIsRejected(t *testing.T) { func TestMFARegenerateRecoveryCodes_ClaimFailureFailsClosed(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) secret := "JBSWY3DPEHPK3PXP" @@ -790,6 +800,7 @@ func TestMFARegenerateRecoveryCodes_ClaimFailureFailsClosed(t *testing.T) { func TestMFADisable_ClaimFailureFailsClosed(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) secret := "JBSWY3DPEHPK3PXP" @@ -824,6 +835,7 @@ func TestMFALifecycle_LockedAccountRefusedBeforeCodeCheck(t *testing.T) { for name, call := range calls { t.Run(name, func(t *testing.T) { mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) until := time.Now().Add(time.Minute) expires := time.Now().Add(mfaPendingExpiry) diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go index d1aa67b4..a0e80571 100644 --- a/internal/auth/service_test.go +++ b/internal/auth/service_test.go @@ -433,6 +433,7 @@ func TestService_Login_MFA(t *testing.T) { func TestLogin_WithMFA(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) service := createTestService(mockStore, mockEmail) @@ -475,6 +476,7 @@ func TestLogin_WithMFA(t *testing.T) { func TestLogin_WithMFA_ClaimFailureFailsClosed(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) service := createTestService(mockStore, new(MockEmailSender)) mfaSecret := "JBSWY3DPEHPK3PXP" @@ -497,6 +499,7 @@ func TestLogin_WithMFA_ClaimFailureFailsClosed(t *testing.T) { func TestLogin_WithMFA_InvalidCode(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) service := createTestService(mockStore, mockEmail) @@ -531,6 +534,7 @@ func TestLogin_WithMFA_InvalidCode(t *testing.T) { func TestLogin_WithMFA_MissingCode(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) service := createTestService(mockStore, mockEmail) diff --git a/internal/auth/service_user_lookup_test.go b/internal/auth/service_user_lookup_test.go index 1c8fac3b..37987bb0 100644 --- a/internal/auth/service_user_lookup_test.go +++ b/internal/auth/service_user_lookup_test.go @@ -88,6 +88,7 @@ func lookupCallers() []lookupCaller { func newLookupService(ctx context.Context, t *testing.T, c lookupCaller, user *User, err error) *Service { t.Helper() m := new(MockStore) + t.Cleanup(func() { m.AssertExpectations(t) }) c.setup(ctx, m) m.On("GetUserByID", ctx, lookupUserID).Return(user, err) return createTestService(m, new(MockEmailSender)) diff --git a/internal/email/log_redaction_test.go b/internal/email/log_redaction_test.go index 3a571790..61f9b2dd 100644 --- a/internal/email/log_redaction_test.go +++ b/internal/email/log_redaction_test.go @@ -31,6 +31,7 @@ func captureDebugLogs(t *testing.T) *bytes.Buffer { func TestCreateVerificationRequest_RedactsAddressInLog(t *testing.T) { logs := captureDebugLogs(t) ses := new(MockSESClient) + t.Cleanup(func() { ses.AssertExpectations(t) }) ses.On("CreateEmailIdentity", mock.Anything, mock.AnythingOfType("*sesv2.CreateEmailIdentityInput")). Return(&sesv2.CreateEmailIdentityOutput{}, nil) s := &Sender{sesClient: ses, fromEmail: "noreply@example.com"} diff --git a/internal/email/mute_test.go b/internal/email/mute_test.go index 7ee519c4..48476b57 100644 --- a/internal/email/mute_test.go +++ b/internal/email/mute_test.go @@ -111,7 +111,9 @@ func TestSendPurchaseApprovalRequest_MuteCheckError_FailOpen(t *testing.T) { // so a DB hiccup doesn't permanently block approval notifications). ctx := context.Background() ses := new(MockSESClient) + t.Cleanup(func() { ses.AssertExpectations(t) }) mc := new(mockMuteChecker) + t.Cleanup(func() { mc.AssertExpectations(t) }) mc.On("IsNotificationMuted", mock.Anything, "approver@example.com", string(common.ScopePurchaseApprovals)). Return(false, errors.New("db error")) @@ -199,6 +201,7 @@ func TestSendRIExchangePendingApproval_EmitsScopedUnsubscribeHeaders(t *testing. func TestSendPurchaseApprovalRequest_WithCC_SuppressesListUnsubscribe(t *testing.T) { ctx := context.Background() ses := new(MockSESClient) + t.Cleanup(func() { ses.AssertExpectations(t) }) mc := new(mockMuteChecker) mc.On("IsNotificationMuted", mock.Anything, mock.Anything, mock.Anything). @@ -364,6 +367,7 @@ func TestIsRecipientMuted_NoErrorFollowsStore(t *testing.T) { for _, scope := range []string{string(common.ScopePurchaseApprovals), "new_recommendations"} { for _, stored := range []bool{true, false} { mc := new(mockMuteChecker) + t.Cleanup(func() { mc.AssertExpectations(t) }) mc.On("IsNotificationMuted", mock.Anything, "a@example.com", scope).Return(stored, nil) assert.Equal(t, stored, isRecipientMuted(context.Background(), mc, "a@example.com", scope), "scope=%s stored=%v", scope, stored) } diff --git a/internal/email/sender_test.go b/internal/email/sender_test.go index 575470b2..216a3a37 100644 --- a/internal/email/sender_test.go +++ b/internal/email/sender_test.go @@ -197,11 +197,8 @@ func TestTemplates_NewRecommendations(t *testing.T) { }, } - // Create sender with mock that expects the call - mockSNS := new(MockSNSClient) - mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). - Return(&sns.PublishOutput{MessageId: aws.String("msg-123")}, nil).Once() - + // No SNS client is wired in: the send fails before publishing, which is + // all this test needs to reach the template rendering. sender := &Sender{ snsClient: nil, // We'll test template rendering separately topicARN: "arn:aws:sns:us-east-1:123456789012:topic", @@ -371,6 +368,7 @@ func TestSender_SendNotification_NilClient(t *testing.T) { func TestSender_SendNotification_Error(t *testing.T) { mockSNS := new(MockSNSClient) + t.Cleanup(func() { mockSNS.AssertExpectations(t) }) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). Return(nil, assert.AnError) @@ -419,6 +417,7 @@ func TestSender_SendToEmail_NilClient(t *testing.T) { func TestSender_SendToEmail_Error(t *testing.T) { mockSES := new(MockSESClient) + t.Cleanup(func() { mockSES.AssertExpectations(t) }) // GetAccount is called first - return production mode (not sandbox) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) @@ -776,6 +775,7 @@ func TestNewSenderWithContext_Success(t *testing.T) { // the configured FROM_EMAIL (not a hardcoded literal). func TestSender_SendPurchaseApprovalRequest_Multipart_Issue287(t *testing.T) { mockSES := new(MockSESClient) + t.Cleanup(func() { mockSES.AssertExpectations(t) }) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) @@ -833,6 +833,7 @@ func TestSender_SendPurchaseApprovalRequest_Multipart_Issue287(t *testing.T) { // that haven't been upgraded. func TestSender_SendToEmailWithCCMultipart_FallsBackWhenHTMLEmpty_Issue287(t *testing.T) { mockSES := new(MockSESClient) + t.Cleanup(func() { mockSES.AssertExpectations(t) }) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) var captured *sesv2.SendEmailInput diff --git a/internal/email/templates_test.go b/internal/email/templates_test.go index 49f1a468..d26b7ec4 100644 --- a/internal/email/templates_test.go +++ b/internal/email/templates_test.go @@ -254,6 +254,7 @@ func TestSender_SendUserInviteEmail_NoFromEmail(t *testing.T) { // Test error cases for template functions. func TestSender_SendNewRecommendationsNotification_SNSError(t *testing.T) { mockSNS := new(MockSNSClient) + t.Cleanup(func() { mockSNS.AssertExpectations(t) }) sender := &Sender{ snsClient: mockSNS, topicARN: "arn:aws:sns:us-east-1:123456789:topic", @@ -303,6 +304,7 @@ func TestSender_SendScheduledPurchaseNotification_SESError(t *testing.T) { func TestSender_SendPurchaseConfirmation_SNSError(t *testing.T) { mockSNS := new(MockSNSClient) + t.Cleanup(func() { mockSNS.AssertExpectations(t) }) sender := &Sender{ snsClient: mockSNS, topicARN: "arn:aws:sns:us-east-1:123456789:topic", @@ -321,6 +323,7 @@ func TestSender_SendPurchaseConfirmation_SNSError(t *testing.T) { func TestSender_SendPurchaseFailedNotification_SNSError(t *testing.T) { mockSNS := new(MockSNSClient) + t.Cleanup(func() { mockSNS.AssertExpectations(t) }) sender := &Sender{ snsClient: mockSNS, topicARN: "arn:aws:sns:us-east-1:123456789:topic", @@ -339,6 +342,7 @@ func TestSender_SendPurchaseFailedNotification_SNSError(t *testing.T) { func TestSender_SendPasswordResetEmail_SESError(t *testing.T) { mockSES := new(MockSESClient) + t.Cleanup(func() { mockSES.AssertExpectations(t) }) sender := &Sender{ sesClient: mockSES, fromEmail: "noreply@example.com", @@ -651,7 +655,9 @@ func TestSender_SendRegistrationReceivedNotification_SubjectHeaderInjection(t *t // send, not the SNS broadcast (which is a no-op when no topic is configured). func TestSender_SendPurchaseFailedNotification_RecipientUsesSES(t *testing.T) { mockSNS := new(MockSNSClient) + t.Cleanup(func() { mockSNS.AssertExpectations(t) }) mockSES := new(MockSESClient) + t.Cleanup(func() { mockSES.AssertExpectations(t) }) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) var to []string @@ -660,7 +666,9 @@ func TestSender_SendPurchaseFailedNotification_RecipientUsesSES(t *testing.T) { to = args.Get(1).(*sesv2.SendEmailInput).Destination.ToAddresses }). Return(&sesv2.SendEmailOutput{MessageId: aws.String("msg-714")}, nil) - mockSNS.On("Publish", mock.Anything, mock.Anything).Return(&sns.PublishOutput{}, nil) + // Maybe: registered only so a regressed broadcast returns cleanly and is + // caught by the AssertNotCalled below rather than by a mock panic. + mockSNS.On("Publish", mock.Anything, mock.Anything).Return(&sns.PublishOutput{}, nil).Maybe() sender := NewSenderWithClients(mockSNS, mockSES, SenderConfig{ FromEmail: "noreply@example.com", diff --git a/internal/scheduler/aws_payment_test.go b/internal/scheduler/aws_payment_test.go index 00f9ffa3..6943e040 100644 --- a/internal/scheduler/aws_payment_test.go +++ b/internal/scheduler/aws_payment_test.go @@ -34,6 +34,7 @@ func TestFetchAndConvert_AWSFallbackPaymentOption(t *testing.T) { var mu sync.Mutex var got []string client := new(MockRecommendationsClient) + t.Cleanup(func() { client.AssertExpectations(t) }) client.On("GetAllRecommendations", mock.Anything).Return([]common.Recommendation{}, nil) client.On("GetRecommendations", mock.Anything, mock.MatchedBy(func(p *common.RecommendationParams) bool { mu.Lock() @@ -43,7 +44,7 @@ func TestFetchAndConvert_AWSFallbackPaymentOption(t *testing.T) { })).Return([]common.Recommendation{}, nil) s := &Scheduler{} - _, _, err := s.fetchAndConvert(context.Background(), newRecProvider(client), "aws", nil, "", + _, _, err := s.fetchAndConvert(context.Background(), newRecProvider(t, client), "aws", nil, "", &config.GlobalConfig{DefaultTerm: 1, DefaultPayment: tc.stored, RecommendationsLookbackDays: 30}) require.NoError(t, err) require.NotEmpty(t, got) @@ -81,6 +82,7 @@ func TestFetchAndConvert_AWSFallbackRealClientSavingsPlans(t *testing.T) { t.Run("stored_"+stored, func(t *testing.T) { ce := &spPaymentCE{} prov := new(MockProvider) + t.Cleanup(func() { prov.AssertExpectations(t) }) prov.On("GetRecommendationsClient", mock.Anything).Return(awsrec.NewClientWithAPI(ce, "us-east-1"), nil) // The stub returns nothing, so GetAllRecommendations is empty and // the per-service fallback runs against the real client. diff --git a/internal/scheduler/background_collection_test.go b/internal/scheduler/background_collection_test.go index 8e53697f..4e02cbb3 100644 --- a/internal/scheduler/background_collection_test.go +++ b/internal/scheduler/background_collection_test.go @@ -88,6 +88,7 @@ func assertNoBackgroundCollect(t *testing.T, mark goroutines.Mark, store *MockCo func TestListRecommendations_MarkedStaleCache_KicksRefresh(t *testing.T) { ctx := WithBackgroundCollection(context.Background()) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) old := time.Now().Add(-72 * time.Hour) collected := make(chan struct{}) diff --git a/internal/scheduler/bastion_resolve_test.go b/internal/scheduler/bastion_resolve_test.go index 6b8afb11..c603f92d 100644 --- a/internal/scheduler/bastion_resolve_test.go +++ b/internal/scheduler/bastion_resolve_test.go @@ -94,6 +94,7 @@ func TestScheduler_CollectAWSForAccount_BastionUsesBastionCredentials(t *testing var gotCfg *provider.Config mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockFactory.On("CreateAndValidateProvider", ctx, "aws", mock.Anything). Run(func(args mock.Arguments) { gotCfg = args.Get(2).(*provider.Config) }). Return(nil, errors.New("stop after credential resolution")) @@ -155,6 +156,7 @@ func TestScheduler_CollectAWSForAccount_HostAccountBastionUsesAmbient(t *testing return &config.CloudAccount{ID: id, Provider: "aws", Enabled: true, AWSAuthMode: "role_arn", ExternalID: "111111111111"}, nil } mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockFactory.On("CreateAndValidateProvider", ctx, "aws", mock.Anything).Return(nil, errors.New("stop after credential resolution")) var factoryKey string s := NewScheduler(SchedulerConfig{ diff --git a/internal/scheduler/list_accounts_error_test.go b/internal/scheduler/list_accounts_error_test.go index c56c9009..65a6cef2 100644 --- a/internal/scheduler/list_accounts_error_test.go +++ b/internal/scheduler/list_accounts_error_test.go @@ -37,6 +37,7 @@ func TestCollectRecommendations_ListAccountsErrorFailsClosed(t *testing.T) { t.Setenv(k, v) } store := new(MockConfigStore) + t.Cleanup(func() { store.AssertExpectations(t) }) store.On("ListCloudAccounts", mock.Anything, mock.Anything).Return(nil, listErr) // No expectations: any provider creation means ambient credentials were used. factory := new(MockProviderFactory) diff --git a/internal/scheduler/recommendation_identity_test.go b/internal/scheduler/recommendation_identity_test.go index c3d050d6..2bb94664 100644 --- a/internal/scheduler/recommendation_identity_test.go +++ b/internal/scheduler/recommendation_identity_test.go @@ -38,14 +38,18 @@ func TestRecommendationIdentity_TwoRegisteredAccounts(t *testing.T) { t.Run("provider_account="+reported, func(t *testing.T) { ctx := context.Background() store := &issue235Store{} + t.Cleanup(func() { store.AssertExpectations(t) }) accounts := []config.CloudAccount{ {ID: "11111111-1111-4111-8111-111111111111", Provider: "aws", ExternalID: "111111111111", AWSAuthMode: "role_arn", AWSRoleARN: "arn:aws:iam::111111111111:role/cudly", Enabled: true}, {ID: "22222222-2222-4222-8222-222222222222", Provider: "aws", ExternalID: "222222222222", AWSAuthMode: "role_arn", AWSRoleARN: "arn:aws:iam::222222222222:role/cudly", Enabled: true}, } store.On("ListCloudAccounts", mock.Anything, mock.Anything).Return(accounts, nil) factory := new(MockProviderFactory) + t.Cleanup(func() { factory.AssertExpectations(t) }) prov := new(MockProvider) + t.Cleanup(func() { prov.AssertExpectations(t) }) client := new(MockRecommendationsClient) + t.Cleanup(func() { client.AssertExpectations(t) }) factory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(prov, nil) prov.On("GetRecommendationsClient", mock.Anything).Return(client, nil) client.On("GetAllRecommendations", mock.Anything).Return([]common.Recommendation{{Provider: common.ProviderAWS, Account: reported, Service: common.ServiceEC2, Region: "us-east-1", ResourceType: "m5.large", Count: 1, Term: "1yr", PaymentOption: "all-upfront"}}, nil) diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 2f911c52..9c7dd253 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -214,6 +214,7 @@ func TestNewScheduler(t *testing.T) { func TestScheduler_CollectRecommendations_NoProviders(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) globalCfg := &config.GlobalConfig{ @@ -238,8 +239,10 @@ func TestScheduler_CollectRecommendations_NoProviders(t *testing.T) { func TestScheduler_CollectRecommendations_AWSProvider(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ EnabledProviders: []string{"aws"}, @@ -332,8 +335,10 @@ func TestScheduler_CollectRecommendations_ClearsWithOwnerToken(t *testing.T) { func TestScheduler_CollectRecommendations_AllProviders(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ EnabledProviders: []string{"aws", "azure", "gcp"}, @@ -378,8 +383,10 @@ func TestScheduler_CollectRecommendations_ParallelProviders(t *testing.T) { t.Run("successfulProviders ordering matches config order, not goroutine completion", func(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) // Deliberately non-alphabetical config order so we can distinguish // "by config" from "by goroutine completion" from "alphabetical" @@ -434,8 +441,10 @@ func TestScheduler_CollectRecommendations_ParallelProviders(t *testing.T) { t.Run("ctx cancellation propagates", func(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ EnabledProviders: []string{"aws", "azure", "gcp"}, @@ -470,6 +479,7 @@ func TestScheduler_CollectRecommendations_ParallelProviders(t *testing.T) { func TestScheduler_CollectRecommendations_UnknownProvider(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) mockFactory := new(MockProviderFactory) @@ -506,6 +516,7 @@ func TestScheduler_CollectAWSRecommendations(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ DefaultTerm: 3, @@ -529,6 +540,7 @@ func TestScheduler_CollectAWSRecommendations(t *testing.T) { func TestScheduler_CollectAzureRecommendations_NoAccounts(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ DefaultTerm: 3, @@ -547,6 +559,7 @@ func TestScheduler_CollectAzureRecommendations_NoAccounts(t *testing.T) { func TestScheduler_CollectGCPRecommendations_NoAccounts_Alt(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ DefaultTerm: 3, @@ -565,7 +578,9 @@ func TestScheduler_CollectGCPRecommendations_NoAccounts_Alt(t *testing.T) { func TestScheduler_CollectProviderRecommendations(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ DefaultTerm: 3, @@ -610,8 +625,10 @@ func TestScheduler_CollectProviderRecommendations(t *testing.T) { func TestScheduler_CollectRecommendations_WithNotification(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) // This test verifies that when there are no recommendations, // no email is sent @@ -664,6 +681,7 @@ func TestScheduler_Interface(t *testing.T) { func TestScheduler_CollectRecommendations_ConfigError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) mockStore.On("GetGlobalConfig", ctx).Return(nil, assert.AnError) @@ -927,6 +945,7 @@ func (m *MockRecommendationsClient) GetRecommendationsForService(ctx context.Con func TestScheduler_ListRecommendations(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) now := time.Now().UTC() cached := []config.RecommendationRecord{ @@ -939,16 +958,15 @@ func TestScheduler_ListRecommendations(t *testing.T) { mockStore.On("ListStoredRecommendations", ctx, mock.Anything). Return(cached, nil) mockStore.On("GetServiceConfig", ctx, "aws", mock.Anything).Return(nil, config.ErrNotFound) - // Non-Lambda path resolves the effective stale TTL from the DB config. - mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{ - RecommendationsCacheStaleHours: config.DefaultRecommendationsCacheStaleHours, - }, nil) scheduler := &Scheduler{config: mockStore} recs, err := scheduler.ListRecommendations(ctx, config.RecommendationFilter{}) require.NoError(t, err) assert.Len(t, recs, 2) + // A plain reader may not kick a background refresh, so it never resolves + // the stale TTL from the global config. + mockStore.AssertNotCalled(t, "GetGlobalConfig") } // staleListRecommendations serves a cache older than any TTL through @@ -962,6 +980,7 @@ func staleListRecommendations(t *testing.T, staleHours int) <-chan struct{} { t.Helper() ctx := WithBackgroundCollection(context.Background()) mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) old := time.Now().Add(-72 * time.Hour) mockStore.On("GetRecommendationsFreshness", ctx). @@ -1059,6 +1078,7 @@ func TestScheduler_ListRecommendations_PassesFilterToStore(t *testing.T) { func TestScheduler_ListRecommendations_FreshnessError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetRecommendationsFreshness", ctx).Return(nil, assert.AnError) @@ -1083,6 +1103,7 @@ func TestScheduler_ListRecommendations_LambdaSkipsBackgroundRefresh(t *testing.T ctx := WithBackgroundCollection(context.Background()) require.True(t, BackgroundCollectionAllowed(ctx), "the read must be allowed to collect, or this test is vacuous") mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) // Cache is stale (older than the 1ns TTL set below). old := time.Now().Add(-time.Hour) @@ -1239,6 +1260,7 @@ func TestScheduler_ListRecommendations_ColdStart_NonLambda_KicksBackgroundCollec ctx, cancel := context.WithCancel(WithBackgroundCollection(context.Background())) defer cancel() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) release := make(chan struct{}) collectCtxErr := make(chan error, 1) @@ -1893,8 +1915,11 @@ func TestScheduler_CollectAWSRecommendations_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) + t.Cleanup(func() { mockRecClient.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ DefaultTerm: 3, @@ -1933,7 +1958,9 @@ func TestScheduler_CollectAWSRecommendations_RecClientError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ DefaultTerm: 3, @@ -1958,8 +1985,11 @@ func TestScheduler_CollectAWSRecommendations_GetRecsError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) + t.Cleanup(func() { mockRecClient.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ DefaultTerm: 3, @@ -1998,6 +2028,7 @@ func TestScheduler_CollectAzureRecommendations_AllAccountsFailLoud(t *testing.T) ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ DefaultTerm: 3, @@ -2102,8 +2133,11 @@ func TestScheduler_CollectAWSForAccount_EmptyRoleARNWrongModeFailsLoud(t *testin func TestScheduler_CollectAWSForAccount_SelfAccountRoleARNModeUsesAmbient(t *testing.T) { ctx := context.Background() mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) + t.Cleanup(func() { mockRecClient.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{DefaultTerm: 3, DefaultPayment: "all-upfront"} @@ -2175,6 +2209,7 @@ func TestScheduler_CollectAWSForAccount_RoleARNWithoutARNOnNonHostFailsClosed(t func TestScheduler_CollectGCPRecommendations_NoAccounts(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ DefaultTerm: 3, @@ -2196,10 +2231,15 @@ func TestScheduler_CollectGCPRecommendations_NoAccounts(t *testing.T) { func TestScheduler_CollectRecommendations_WithSuccessfulRecs(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockEmail := new(MockEmailSender) + t.Cleanup(func() { mockEmail.AssertExpectations(t) }) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) + t.Cleanup(func() { mockRecClient.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ EnabledProviders: []string{"aws"}, @@ -2255,7 +2295,9 @@ func TestScheduler_CollectAWSRecommendations_FallbackToFiltered(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) globalCfg := &config.GlobalConfig{ @@ -2380,9 +2422,13 @@ func (s *slowSTSClient) GetCallerIdentity(ctx context.Context, _ *sts.GetCallerI func TestScheduler_CollectAWSRecommendations_AmbientTagging_HappyPath(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) + t.Cleanup(func() { mockRecClient.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{ DefaultTerm: 3, @@ -2440,9 +2486,13 @@ func TestScheduler_CollectAWSRecommendations_AmbientTagging_HappyPath(t *testing func TestScheduler_CollectAWSRecommendations_AmbientTagging_NoRegisteredAccount(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) + t.Cleanup(func() { mockRecClient.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{DefaultTerm: 3, DefaultPayment: "all-upfront"} @@ -2477,9 +2527,13 @@ func TestScheduler_CollectAWSRecommendations_AmbientTagging_NoRegisteredAccount( func TestScheduler_CollectAWSRecommendations_AmbientTagging_STSFailure(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) + t.Cleanup(func() { mockRecClient.AssertExpectations(t) }) globalCfg := &config.GlobalConfig{DefaultTerm: 3, DefaultPayment: "all-upfront"} @@ -2529,6 +2583,7 @@ func TestScheduler_ResolveAmbientHostAccountID_NoSTSClient(t *testing.T) { // it returns "" so the collection falls back to the pre-fix nil tagging. func TestScheduler_ResolveAmbientHostAccountID_StoreError(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetCloudAccountByExternalID", mock.Anything, "aws", "123456789012"). Return(nil, errors.New("db unreachable")) @@ -2569,9 +2624,13 @@ func TestScheduler_CollectAzureRecommendations_AmbientTagging_HappyPath(t *testi ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) + t.Cleanup(func() { mockRecClient.AssertExpectations(t) }) // No enabled Azure accounts — ambient fallback fires. mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) @@ -2625,9 +2684,13 @@ func TestScheduler_CollectAzureRecommendations_AmbientTagging_NoRegisteredAccoun ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) + t.Cleanup(func() { mockRecClient.AssertExpectations(t) }) mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) @@ -2662,9 +2725,13 @@ func TestScheduler_CollectAzureRecommendations_AmbientTagging_StoreError(t *test ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) + t.Cleanup(func() { mockRecClient.AssertExpectations(t) }) mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) @@ -2700,6 +2767,7 @@ func TestScheduler_CollectAzureRecommendations_NoEnvVar_Skips(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockFactory := new(MockProviderFactory) mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) @@ -2724,9 +2792,13 @@ func TestScheduler_CollectGCPRecommendations_AmbientTagging_HappyPath(t *testing ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) + t.Cleanup(func() { mockRecClient.AssertExpectations(t) }) // No enabled GCP accounts — ambient fallback fires. mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) @@ -2778,9 +2850,13 @@ func TestScheduler_CollectGCPRecommendations_AmbientTagging_NoRegisteredAccount( ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) + t.Cleanup(func() { mockRecClient.AssertExpectations(t) }) mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) @@ -2813,9 +2889,13 @@ func TestScheduler_CollectGCPRecommendations_AmbientTagging_StoreError(t *testin ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockFactory := new(MockProviderFactory) + t.Cleanup(func() { mockFactory.AssertExpectations(t) }) mockProvider := new(MockProvider) + t.Cleanup(func() { mockProvider.AssertExpectations(t) }) mockRecClient := new(MockRecommendationsClient) + t.Cleanup(func() { mockRecClient.AssertExpectations(t) }) mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) @@ -2848,6 +2928,7 @@ func TestScheduler_CollectGCPRecommendations_NoEnvVar_Skips(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockFactory := new(MockProviderFactory) mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return([]config.CloudAccount{}, nil) @@ -2877,6 +2958,7 @@ func TestScheduler_ResolveAmbientAccountID_EmptyExternalID(t *testing.T) { // when the store call fails. func TestScheduler_ResolveAmbientAccountID_StoreError(t *testing.T) { mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetCloudAccountByExternalID", mock.Anything, "gcp", "some-project"). Return(nil, errors.New("store down")) sched := &Scheduler{config: mockStore} diff --git a/internal/scheduler/zonal_recs_test.go b/internal/scheduler/zonal_recs_test.go index e9087a51..0e9fa84d 100644 --- a/internal/scheduler/zonal_recs_test.go +++ b/internal/scheduler/zonal_recs_test.go @@ -96,23 +96,27 @@ func assertOnlyMemberZonalDropped(t *testing.T, recs []config.RecommendationReco assert.ElementsMatch(t, []string{"us-east-1a", ""}, azs, "own zonal and member regional kept; member zonal dropped") } -func newRecProvider(client *MockRecommendationsClient) *MockProvider { +func newRecProvider(t *testing.T, client *MockRecommendationsClient) *MockProvider { + t.Helper() prov := new(MockProvider) + t.Cleanup(func() { prov.AssertExpectations(t) }) prov.On("GetRecommendationsClient", mock.Anything).Return(client, nil) return prov } func TestFetchAndConvert_DropsForeignZonalEC2_Primary(t *testing.T) { client := new(MockRecommendationsClient) + t.Cleanup(func() { client.AssertExpectations(t) }) client.On("GetAllRecommendations", mock.Anything).Return(mixedAccountRecs("111111111111"), nil) s := &Scheduler{} - recs, gap, err := s.fetchAndConvert(context.Background(), newRecProvider(client), "aws", nil, "111111111111", nil) + recs, gap, err := s.fetchAndConvert(context.Background(), newRecProvider(t, client), "aws", nil, "111111111111", nil) require.NoError(t, err) assertOnlyMemberZonalDropped(t, recs, gap) } func TestFetchAndConvert_DropsForeignZonalEC2_LookbackFallback(t *testing.T) { client := new(MockRecommendationsClient) + t.Cleanup(func() { client.AssertExpectations(t) }) client.On("GetAllRecommendations", mock.Anything).Return([]common.Recommendation{}, nil) client.On("GetRecommendations", mock.Anything, mock.MatchedBy(func(p *common.RecommendationParams) bool { return p.Service == common.ServiceEC2 @@ -121,7 +125,7 @@ func TestFetchAndConvert_DropsForeignZonalEC2_LookbackFallback(t *testing.T) { return p.Service != common.ServiceEC2 })).Return([]common.Recommendation{}, nil) s := &Scheduler{} - recs, gap, err := s.fetchAndConvert(context.Background(), newRecProvider(client), "aws", nil, "111111111111", + recs, gap, err := s.fetchAndConvert(context.Background(), newRecProvider(t, client), "aws", nil, "111111111111", &config.GlobalConfig{DefaultTerm: 1, DefaultPayment: "no-upfront", RecommendationsLookbackDays: 30}) require.NoError(t, err) assertOnlyMemberZonalDropped(t, recs, gap) @@ -129,9 +133,10 @@ func TestFetchAndConvert_DropsForeignZonalEC2_LookbackFallback(t *testing.T) { func TestFetchAndConvert_NilExpectedAccountKeepsForeignZonal(t *testing.T) { client := new(MockRecommendationsClient) + t.Cleanup(func() { client.AssertExpectations(t) }) client.On("GetAllRecommendations", mock.Anything).Return(mixedAccountRecs("111111111111"), nil) s := &Scheduler{} - recs, _, err := s.fetchAndConvert(context.Background(), newRecProvider(client), "aws", nil, "", nil) + recs, _, err := s.fetchAndConvert(context.Background(), newRecProvider(t, client), "aws", nil, "", nil) require.NoError(t, err) assert.Len(t, recs, 3, "an unknown collecting identity disables the drop") } @@ -140,9 +145,11 @@ func TestFetchAndConvert_NilExpectedAccountKeepsForeignZonal(t *testing.T) { func TestCollectAWSAmbient_PassesHostAccount(t *testing.T) { ctx := context.Background() factory := new(MockProviderFactory) + t.Cleanup(func() { factory.AssertExpectations(t) }) client := new(MockRecommendationsClient) + t.Cleanup(func() { client.AssertExpectations(t) }) client.On("GetAllRecommendations", mock.Anything).Return(mixedAccountRecs("111111111111"), nil) - factory.On("CreateAndValidateProvider", ctx, "aws", (*provider.Config)(nil)).Return(newRecProvider(client), nil) + factory.On("CreateAndValidateProvider", ctx, "aws", (*provider.Config)(nil)).Return(newRecProvider(t, client), nil) s := &Scheduler{providerFactory: factory, stsClient: &fakeSTSClient{accountID: "111111111111"}} recs, gap, err := s.collectAWSAmbient(ctx, nil) require.NoError(t, err) @@ -153,9 +160,11 @@ func TestCollectAWSAmbient_PassesHostAccount(t *testing.T) { func TestCollectAWSForAccount_SelfAccountPassesExternalID(t *testing.T) { ctx := context.Background() factory := new(MockProviderFactory) + t.Cleanup(func() { factory.AssertExpectations(t) }) client := new(MockRecommendationsClient) + t.Cleanup(func() { client.AssertExpectations(t) }) client.On("GetAllRecommendations", mock.Anything).Return(mixedAccountRecs("111111111111"), nil) - factory.On("CreateAndValidateProvider", ctx, "aws", (*provider.Config)(nil)).Return(newRecProvider(client), nil) + factory.On("CreateAndValidateProvider", ctx, "aws", (*provider.Config)(nil)).Return(newRecProvider(t, client), nil) s := &Scheduler{config: new(MockConfigStore), providerFactory: factory, stsClient: &fakeSTSClient{accountID: "111111111111"}} recs, gap, err := s.collectAWSForAccount(ctx, nil, config.CloudAccount{ ID: "self", Provider: "aws", AWSAuthMode: "role_arn", ExternalID: "111111111111", Enabled: true, @@ -169,9 +178,11 @@ func TestCollectAWSForAccount_SelfAccountPassesExternalID(t *testing.T) { func TestCollectAWSForAccount_AssumedRolePassesExternalID(t *testing.T) { ctx := context.Background() factory := new(MockProviderFactory) + t.Cleanup(func() { factory.AssertExpectations(t) }) client := new(MockRecommendationsClient) + t.Cleanup(func() { client.AssertExpectations(t) }) client.On("GetAllRecommendations", mock.Anything).Return(mixedAccountRecs("333333333333"), nil) - factory.On("CreateAndValidateProvider", ctx, "aws", mock.AnythingOfType("*provider.Config")).Return(newRecProvider(client), nil) + factory.On("CreateAndValidateProvider", ctx, "aws", mock.AnythingOfType("*provider.Config")).Return(newRecProvider(t, client), nil) s := &Scheduler{config: new(MockConfigStore), providerFactory: factory, stsClient: &fakeSTSClient{accountID: "111111111111"}} recs, gap, err := s.collectAWSForAccount(ctx, nil, config.CloudAccount{ ID: "member", Provider: "aws", AWSAuthMode: "role_arn", AWSRoleARN: "arn:aws:iam::333333333333:role/cudly", diff --git a/internal/server/adapter_not_found_test.go b/internal/server/adapter_not_found_test.go index da293446..3125135b 100644 --- a/internal/server/adapter_not_found_test.go +++ b/internal/server/adapter_not_found_test.go @@ -17,6 +17,7 @@ func TestAuthServiceAdapter_GetUser_MissingUserIsErrUserNotFound(t *testing.T) { t.Run("store returns ErrNoRows", func(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetUserByID", ctx, "missing").Return(nil, pgx.ErrNoRows) _, err := adapter.GetUser(ctx, "missing") @@ -26,6 +27,7 @@ func TestAuthServiceAdapter_GetUser_MissingUserIsErrUserNotFound(t *testing.T) { t.Run("store returns nil user", func(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) mockStore.On("GetUserByID", ctx, "missing").Return(nil, nil) _, err := adapter.GetUser(ctx, "missing") diff --git a/internal/server/adapter_test.go b/internal/server/adapter_test.go index 0d8d66ab..d3d45a3a 100644 --- a/internal/server/adapter_test.go +++ b/internal/server/adapter_test.go @@ -47,6 +47,7 @@ func createMockAuthService(t *testing.T) (*authServiceAdapter, *auth.MockStore) func TestAuthServiceAdapter_Login(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("GetUserByEmail", ctx, "test@example.com").Return(&auth.User{ @@ -66,6 +67,7 @@ func TestAuthServiceAdapter_Login(t *testing.T) { func TestAuthServiceAdapter_Logout(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() // Token gets hashed internally, so use mock.Anything @@ -77,6 +79,7 @@ func TestAuthServiceAdapter_Logout(t *testing.T) { func TestAuthServiceAdapter_ValidateSession(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() // Token gets hashed; the stored Token must match hashSessionToken("valid-token") @@ -99,6 +102,7 @@ func TestAuthServiceAdapter_ValidateSession(t *testing.T) { func TestAuthServiceAdapter_ValidateSession_Error(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("GetSession", ctx, mock.AnythingOfType("string")).Return(nil, nil) @@ -109,6 +113,7 @@ func TestAuthServiceAdapter_ValidateSession_Error(t *testing.T) { func TestAuthServiceAdapter_CheckAdminExists(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("AdminExists", ctx).Return(true, nil) @@ -120,6 +125,7 @@ func TestAuthServiceAdapter_CheckAdminExists(t *testing.T) { func TestAuthServiceAdapter_RequestPasswordReset(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() // User not found - exercises the adapter code path without needing email sender @@ -132,6 +138,7 @@ func TestAuthServiceAdapter_RequestPasswordReset(t *testing.T) { func TestAuthServiceAdapter_ConfirmPasswordReset(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("GetUserByResetToken", ctx, mock.AnythingOfType("string")).Return(nil, assert.AnError) @@ -145,6 +152,7 @@ func TestAuthServiceAdapter_ConfirmPasswordReset(t *testing.T) { func TestAuthServiceAdapter_GetUser(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("GetUserByID", ctx, "user-1").Return(&auth.User{ @@ -163,6 +171,7 @@ func TestAuthServiceAdapter_GetUser(t *testing.T) { func TestAuthServiceAdapter_GetUser_Error(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("GetUserByID", ctx, "nonexistent").Return(nil, assert.AnError) @@ -173,6 +182,7 @@ func TestAuthServiceAdapter_GetUser_Error(t *testing.T) { func TestAuthServiceAdapter_DeleteUser(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() // Non-admin user: the last-admin guard is skipped after the GetUserByID @@ -188,6 +198,7 @@ func TestAuthServiceAdapter_DeleteUser(t *testing.T) { func TestAuthServiceAdapter_ListUsersAPI(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("ListUsers", ctx).Return([]auth.User{ @@ -202,6 +213,7 @@ func TestAuthServiceAdapter_ListUsersAPI(t *testing.T) { func TestAuthServiceAdapter_ChangePasswordAPI(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("GetUserByID", ctx, "user-1").Return(&auth.User{ @@ -217,6 +229,7 @@ func TestAuthServiceAdapter_ChangePasswordAPI(t *testing.T) { func TestAuthServiceAdapter_DeleteGroup(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() // DeleteGroup loads the group first to refuse system-managed ones (#1629). @@ -229,6 +242,7 @@ func TestAuthServiceAdapter_DeleteGroup(t *testing.T) { func TestAuthServiceAdapter_GetGroupAPI(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("GetGroup", ctx, "group-1").Return(&auth.Group{ @@ -243,6 +257,7 @@ func TestAuthServiceAdapter_GetGroupAPI(t *testing.T) { func TestAuthServiceAdapter_ListGroupsAPI(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("ListGroups", ctx).Return([]auth.Group{ @@ -256,6 +271,7 @@ func TestAuthServiceAdapter_ListGroupsAPI(t *testing.T) { func TestAuthServiceAdapter_ValidateCSRFToken(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() // Token gets hashed; the stored Token must match hashSessionToken("session-token") @@ -280,6 +296,7 @@ func TestAuthServiceAdapter_ValidateCSRFToken(t *testing.T) { func TestAuthServiceAdapter_HasPermissionAPI(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() // Administrators-group member: the group's {admin, *} permission grants @@ -302,6 +319,7 @@ func TestAuthServiceAdapter_HasPermissionAPI(t *testing.T) { func TestAuthServiceAdapter_UpdateUserProfile(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() // User not found - exercises the adapter code path @@ -499,6 +517,7 @@ func TestAuthServiceAdapter_CreateAPIKeyAPI_KeepsPermissionConstraints(t *testin func TestAuthServiceAdapter_ListUserAPIKeysAPI(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("GetUserByID", ctx, "user-1").Return(&auth.User{ @@ -513,6 +532,7 @@ func TestAuthServiceAdapter_ListUserAPIKeysAPI(t *testing.T) { func TestAuthServiceAdapter_DeleteAPIKeyAPI(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("GetUserByID", ctx, "user-1").Return(&auth.User{ @@ -530,6 +550,7 @@ func TestAuthServiceAdapter_DeleteAPIKeyAPI(t *testing.T) { func TestAuthServiceAdapter_RevokeAPIKeyAPI(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("GetUserByID", ctx, "user-1").Return(&auth.User{ @@ -547,6 +568,7 @@ func TestAuthServiceAdapter_RevokeAPIKeyAPI(t *testing.T) { func TestAuthServiceAdapter_ValidateUserAPIKeyAPI(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("GetAPIKeyByHash", ctx, mock.AnythingOfType("string")).Return(nil, assert.AnError) @@ -564,6 +586,7 @@ func TestNewAuthServiceAdapter_NotNil(t *testing.T) { func TestAuthServiceAdapter_SetupAdmin(t *testing.T) { adapter, mockStore := createMockAuthService(t) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) ctx := context.Background() mockStore.On("AdminExists", ctx).Return(false, nil)