From fac72f3acd47c610b0e9f20086819f9a2519d529 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Thu, 29 Oct 2026 02:12:52 +0000 Subject: [PATCH] test(api,auth,scheduler,server,email): assert mock expectations in the remaining tests Part B of #35: internal/api test files after handler_config_test.go, plus internal/auth, internal/scheduler, internal/server and internal/email. Every test there that registers a required .On() expectation now verifies it, through t.Cleanup(AssertExpectations) at the mock's construction or in the helper that builds it (helpers used only by these files now take t). A runtime audit (testify instrumented to log every On, Maybe, call and AssertExpectations) found 868 tests whose required expectations were never checked; it now finds none, unit and integration alike. Turning the assertions on failed 93 tests on expectations that never fired. Most were stale stubs and are removed or made .Maybe(), each with the reason: an admin X-API-Key request never validates the bearer session, a session or CSRF check now runs before the row lookup (#435, #597), an RI exchange record with no region fails before reserving the spend ceiling, and so on. Where a refusal path should make no call, the call is left unregistered (an unexpected call fails the test) or pinned with AssertNotCalled. Six tests were passing for the wrong reason and now test what they name: - TestHandler_patchPlan_UpdateError failed plan validation, never reaching the UpdatePurchasePlan error it is about. - TestHandler_approvePurchase_RejectsCreatorWithoutApprovePermission was refused by CSRF; with a valid token it now pins the permission denial. - TestHandler_runPlannedPurchase_NilExecution was refused because admin:* does not grant execute; it now reaches RunPlannedPurchaseNow. - TestHandler_deleteUser_Error and TestHandler_deleteGroup_DeleteFailed failed UUID validation before the store error. - TestUpdateRIExchangeConfig_PermissionGate sent a body that no longer decodes; it now saves a valid one. No production code changes. Refs #35 Co-Authored-By: Claude Opus 5.5 --- internal/api/handler_coverage_test.go | 28 +- internal/api/handler_dashboard_test.go | 90 ++++-- .../handler_dashboard_upcoming_filter_test.go | 20 +- .../handler_federation_azure_escape_test.go | 1 + internal/api/handler_federation_test.go | 3 + .../handler_forgot_password_ratelimit_test.go | 3 + .../handler_groups_account_binding_test.go | 3 + internal/api/handler_groups_test.go | 8 + ...dler_history_actionable_starvation_test.go | 13 +- .../api/handler_history_actionable_test.go | 9 +- internal/api/handler_history_page_test.go | 17 +- .../api/handler_history_plan_filter_test.go | 5 +- .../handler_history_scope_provider_test.go | 2 + internal/api/handler_history_summary_test.go | 12 +- internal/api/handler_history_test.go | 103 ++++--- .../api/handler_history_truncation_test.go | 5 +- .../api/handler_insurance_comparison_test.go | 2 + internal/api/handler_insurance_test.go | 4 + .../handler_inventory_scope_provider_test.go | 4 + internal/api/handler_inventory_test.go | 27 +- internal/api/handler_ladder_test.go | 2 + internal/api/handler_ladder_timeline_test.go | 1 + .../api/handler_marketplace_attempt_test.go | 2 +- ...rketplace_compensation_integration_test.go | 9 +- .../handler_marketplace_credentials_test.go | 22 +- .../api/handler_marketplace_poller_test.go | 3 + ...er_marketplace_recover_integration_test.go | 2 +- .../api/handler_marketplace_recover_test.go | 16 +- internal/api/handler_marketplace_test.go | 52 ++-- .../api/handler_not_found_mapping_test.go | 20 +- ...handler_override_audit_integration_test.go | 1 + internal/api/handler_override_audit_test.go | 5 + ...andler_override_import_integration_test.go | 1 + internal/api/handler_override_import_test.go | 1 + .../api/handler_override_transfer_test.go | 1 + .../api/handler_per_account_perms_test.go | 99 +++--- ...dler_plans_concurrency_integration_test.go | 7 +- ...er_plans_managed_guard_integration_test.go | 4 +- internal/api/handler_plans_ramp_shape_test.go | 10 +- .../api/handler_plans_services_merge_test.go | 1 + .../api/handler_plans_sp_multistep_test.go | 16 +- internal/api/handler_plans_start_date_test.go | 23 +- internal/api/handler_plans_step_cap_test.go | 1 + internal/api/handler_plans_test.go | 110 +++++-- internal/api/handler_purchase_action_test.go | 1 + .../handler_purchases_apikey_scope_test.go | 2 + ...ndler_purchases_cancel_constraints_test.go | 35 ++- .../api/handler_purchases_delay_token_test.go | 2 + .../api/handler_purchases_finalize_test.go | 4 +- .../handler_purchases_grace_config_test.go | 4 +- .../api/handler_purchases_notified_test.go | 17 +- ..._purchases_owner_key_probe_helpers_test.go | 1 + .../handler_purchases_owner_key_probe_test.go | 3 + ...purchases_pause_resume_constraints_test.go | 19 +- ...handler_purchases_pause_resume_key_test.go | 2 + ...andler_purchases_plan_step_approve_test.go | 18 +- ...ndler_purchases_planned_update_any_test.go | 27 +- .../handler_purchases_retry_dedupe_test.go | 21 +- .../handler_purchases_retry_redrive_test.go | 2 +- ...purchases_revoke_actor_integration_test.go | 4 +- ...ndler_purchases_revoke_constraints_test.go | 4 + .../handler_purchases_revoke_quote_test.go | 2 + ...handler_purchases_revoke_uncertain_test.go | 1 + .../handler_purchases_run_update_any_test.go | 12 +- internal/api/handler_purchases_test.go | 284 ++++++++++++++---- .../handler_purchases_token_errors_test.go | 32 +- .../handler_purchases_token_oracle_test.go | 17 +- .../handler_query_param_validation_test.go | 27 +- .../api/handler_recommendations_page_test.go | 30 +- .../api/handler_recommendations_scan_test.go | 2 + internal/api/handler_recommendations_test.go | 9 + .../api/handler_registrations_approve_test.go | 9 +- .../handler_registrations_credentials_test.go | 5 +- .../handler_registrations_recipients_test.go | 6 + .../api/handler_registrations_submit_test.go | 5 + .../api/handler_registrations_token_test.go | 3 + .../api/handler_ri_exchange_azure_test.go | 2 + .../handler_ri_exchange_history_scope_test.go | 3 +- internal/api/handler_ri_exchange_test.go | 119 +++++--- .../api/handler_ri_utilization_scope_test.go | 16 +- internal/api/handler_router_test.go | 4 + internal/api/handler_test.go | 140 ++++++--- internal/api/handler_users_test.go | 10 + internal/api/health_test.go | 6 + internal/api/ladder_arming_grant_test.go | 15 +- internal/api/middleware_test.go | 29 +- internal/api/nullable_purchase_cost_test.go | 4 +- internal/api/patch_csrf_test.go | 1 + internal/api/plan_mutation_scope_test.go | 1 + internal/api/plan_step_no_recs_test.go | 2 + internal/api/purchase_currency_test.go | 2 + internal/api/purchase_pricing_test.go | 6 + ...ations_background_collection_scope_test.go | 18 +- .../recommendations_next_collection_test.go | 1 + .../api/refresh_recommendations_scope_test.go | 1 + internal/api/reshape_offered_types_test.go | 10 +- internal/api/retry_carveout_test.go | 2 + .../ri_exchange_approve_reject_status_test.go | 17 +- internal/api/ri_exchange_audit_test.go | 1 + .../api/router_660_permission_flips_test.go | 24 +- internal/api/router_authuser_test.go | 7 +- internal/api/router_handlers_test.go | 63 +++- internal/api/service_config_scope_test.go | 1 + internal/api/session_denial_final_test.go | 5 + internal/api/tenant_scope_sweep_test.go | 1 + internal/auth/explicit_zero_cap_test.go | 2 + internal/auth/group_combination_test.go | 2 + internal/auth/group_permission_change_test.go | 2 + internal/auth/negative_purchase_cap_test.go | 2 + internal/auth/not_found_sentinels_test.go | 6 + internal/auth/service_api_fields_test.go | 5 + internal/auth/service_api_test.go | 1 + .../auth/service_apikeys_effective_test.go | 3 + internal/auth/service_apikeys_test.go | 2 + .../auth/service_group_only_authz_test.go | 2 + internal/auth/service_mfa_login_token_test.go | 1 + internal/auth/service_mfa_test.go | 14 +- internal/auth/service_test.go | 4 + internal/auth/service_user_lookup_test.go | 1 + internal/email/log_redaction_test.go | 1 + internal/email/mute_test.go | 4 + internal/email/sender_test.go | 11 +- internal/email/templates_test.go | 10 +- internal/scheduler/aws_payment_test.go | 4 +- .../scheduler/background_collection_test.go | 1 + internal/scheduler/bastion_resolve_test.go | 2 + .../scheduler/list_accounts_error_test.go | 1 + .../scheduler/recommendation_identity_test.go | 4 + internal/scheduler/scheduler_test.go | 90 +++++- internal/scheduler/zonal_recs_test.go | 25 +- internal/server/adapter_not_found_test.go | 2 + internal/server/adapter_test.go | 23 ++ 132 files changed, 1594 insertions(+), 520 deletions(-) diff --git a/internal/api/handler_coverage_test.go b/internal/api/handler_coverage_test.go index 36295a8f3..c811513e6 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 a2ca21846..5063c7825 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 827fab978..b6d15df7b 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 fdf02613e..7268548e5 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 4a0814fef..91c744721 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 638a7dd8c..c2a9167cf 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 4ecf2db2f..fd69863e7 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 5021cbeed..05ac8806e 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 5a277b6a7..bdd18261e 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 20763ed3c..0afc2f523 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 2733275a7..6633c344a 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 76328b00b..df626b992 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 36f93a104..a29d8c6fa 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 564d6f8a6..8cf797ec2 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 f62b109d2..2e95b65de 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 5e58bc432..9bc6190c8 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 d37992732..e37235a8c 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 23bcf0bc7..f9c69f1f8 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 02bc9c0b0..a9483ced0 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 a92ebfe45..d4d535c58 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 17c1f1635..6702b1699 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 f544a2432..be8f3bc64 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 5f73f078b..2070079fc 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 37c488dad..fb361a55d 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 62762431e..e99d2cd15 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 4ea7fe0de..c0ce131e3 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 3fd1cfa64..8ebd6ad28 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 9e7f76e55..9feb6ca33 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 b94c1c863..8814d7f8f 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 fbe6e3359..852deca2b 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 1d2a6aa0c..b6802b637 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 94e91eef2..f687c47df 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 265238f3d..3d6e3dc8e 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 d0374f310..53529c7cc 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 a8e5a8cff..508f17e7f 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 98090341e..f81785fff 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 928eee6de..08f8e124b 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 9f8d79cc8..ba2757c51 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 4d490d87d..9c48d7778 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 7aa6ae682..b48cea2d5 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 3e72a9cb5..d05fb664e 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 c865ef639..f406a382c 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 123ea1461..6704b4fb1 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 8802ea534..7d83dbe5b 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 7b54e4678..2ebd66623 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 47b29e41c..18b227c9e 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 778b6effa..c2b8f92cf 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 3f35efaf3..e172d9fdb 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 4d179831b..8edcbbec2 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 5467bb2f4..03eb912ab 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 204473766..561e1f357 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 335955338..4426bf01d 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 43495e7aa..449b07b00 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 62f6d873d..dfe54417f 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 2d4ed8a1a..cb94fbe97 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 72d3bbab7..52ae0412a 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 4506a232c..7188ed7ba 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 6bc58e7cd..eb9321987 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 a14f33cf3..4d9736128 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 a5c709e3b..fa5e19036 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 39c870348..0dde87319 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 b0527fb7b..d190504be 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 ecdfebb5f..3d0a7d72c 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 0e3eb4f3f..c72a923e0 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 cf4a52e4c..4326522b1 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 c0850bf59..41bf6abe8 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 358b3b9ac..2ce37f0f9 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 23249e88a..667b8af9c 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 e5f3ebd01..d87b65448 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 7a43669e1..34ade3b3b 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 776e37227..7fc8248c2 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 d0d352701..82be0aa93 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 a62aa0c7c..c810153a4 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 f05c40cd9..e6fd4cbf4 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 a84fa5209..71971117b 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 c7978cdda..ad5e45742 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 106fb1461..097be595b 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 5e57e25a4..0898a30f1 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 a2a1c7305..462cdd73f 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 5c96189ae..fa4c623c7 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 a95d83bb1..b232e6934 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 e02495122..b0ac55210 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 ffe35de66..8b45be530 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 603842927..d323185a5 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 24b431011..ad63c6fcd 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 ed72eeaf7..89f977844 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 619ad95be..3adf153cf 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 c5f91d882..bab81ad60 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 06850b9e4..da0864f67 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 922d2775a..4a05ed692 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 fbae84bf3..a0e6c161c 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 5cfb0b02f..494271c03 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 b449e3c87..ac3a53810 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 5cfbc7971..b4dd70d0a 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 2e7715dac..551d8bd95 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 f1744fff5..adad96a2c 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 d89e0381a..91eb8ee20 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 e07e53831..f11f49f41 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 7179f27f7..be6a0973a 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 1d3a758d6..e78978a01 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 505620e09..a69351605 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 634c8c2d7..9eb23bea0 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 6266d478b..24df1b70e 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 8339641d5..7f587bddc 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 0fe7b301f..72022e688 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 2491894d9..887c01fe7 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 c0f12dd7b..ba8b579c6 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 0078fbf5b..4a103113e 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 c9cda9d3a..07ed7762d 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 fdd30a195..e28ca80c6 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 41aebcbe5..f675b7a78 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 dd40e53ed..1f2f243c7 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 18222573e..cff5659db 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 d6044240e..079105437 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 0b0939278..f16a1dc3a 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 687f977e1..ec3933165 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 72de6f4f5..369e980e8 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 d1aa67b4f..a0e805712 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 1c8fac3b0..37987bb06 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 3a5717909..61f9b2ddd 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 7ee519c42..48476b573 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 575470b23..216a3a376 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 49f1a4685..d26b7ec47 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 00f9ffa30..6943e0401 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 8e53697f5..4e02cbb3b 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 6b8afb116..c603f92d4 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 c56c9009c..65a6cef21 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 c3d050d6f..2bb94664f 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 2f911c52f..9c7dd253f 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 e9087a510..0e9fa84d2 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 da2934464..3125135bc 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 0d8d66aba..d3d45a3a0 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)