From 80d2f43ed2699ab0470d78ced938a6a59f381682 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 26 Jun 2026 17:35:15 +0200 Subject: [PATCH 1/4] test(integration): fix four failing integration tests on main - GetExecutionByID not-found: function returns (nil, nil) per contract; replace assert.Error + nil.Error() panic with require.NoError + assert.Nil - UpsertRecommendations FK violations: seed cloud_accounts rows before calling UpsertRecommendations with non-nil CloudAccountID to satisfy the recommendations.cloud_account_id FK constraint (migration 000030) - OIDC key-rotation race: sign tokB before the swap POST so the RSA operation (~1 ms, CPU-bound) gives the go-oidc cleanup goroutine time to set inflight=nil under the mutex; without this, fast loopback HTTP on Linux CI completes without a goroutine switch and the stale inflight causes Validate to return pre-rotation keys --- .../store_postgres_recommendations_test.go | 13 +++++++++++++ internal/config/store_postgres_test.go | 9 +++++---- internal/server/scheduledauth/validator_test.go | 17 ++++++++++++----- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/internal/config/store_postgres_recommendations_test.go b/internal/config/store_postgres_recommendations_test.go index 28502f40f..9025f21ad 100644 --- a/internal/config/store_postgres_recommendations_test.go +++ b/internal/config/store_postgres_recommendations_test.go @@ -292,6 +292,14 @@ func TestPostgresStore_UpsertRecommendations_AccountScopedEviction(t *testing.T) seedRecommendationCloudAccount(ctx, t, store, acct1, "azure", "sub-1111") seedRecommendationCloudAccount(ctx, t, store, acct2, "azure", "sub-2222") + // Seed cloud_accounts so the FK on recommendations.cloud_account_id is satisfied. + require.NoError(t, store.CreateCloudAccount(ctx, &config.CloudAccount{ + ID: acct1, Name: "Eviction-Test-Acct-1", Enabled: true, Provider: "azure", ExternalID: "eviction-test-acct-1", + })) + require.NoError(t, store.CreateCloudAccount(ctx, &config.CloudAccount{ + ID: acct2, Name: "Eviction-Test-Acct-2", Enabled: true, Provider: "azure", ExternalID: "eviction-test-acct-2", + })) + t0 := time.Now().UTC().Truncate(time.Second) seed := []config.RecommendationRecord{ @@ -349,6 +357,11 @@ func TestPostgresStore_UpsertRecommendations_AmbientAndRegisteredCoexist(t *test registeredAcctID := "33333333-3333-3333-3333-333333333333" seedRecommendationCloudAccount(ctx, t, store, registeredAcctID, "aws", "333333333333") + // Seed cloud_accounts so the FK on recommendations.cloud_account_id is satisfied. + require.NoError(t, store.CreateCloudAccount(ctx, &config.CloudAccount{ + ID: registeredAcctID, Name: "Coexist-Test-Acct", Enabled: true, Provider: "aws", ExternalID: "coexist-test-acct-1", + })) + t0 := time.Now().UTC().Truncate(time.Second) // Seed one ambient row (CloudAccountID nil) + one registered row. diff --git a/internal/config/store_postgres_test.go b/internal/config/store_postgres_test.go index 0ae422030..7d93990c4 100644 --- a/internal/config/store_postgres_test.go +++ b/internal/config/store_postgres_test.go @@ -307,10 +307,11 @@ func TestPostgresStore_PurchaseExecutions(t *testing.T) { }) t.Run("Get execution by ID - not found", func(t *testing.T) { - // Use a valid UUID format that doesn't exist - _, err := store.GetExecutionByID(ctx, "00000000-0000-0000-0000-000000000000") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found") + // GetExecutionByID returns (nil, nil) when no row matches; the + // caller is responsible for distinguishing not-found from error. + exec, err := store.GetExecutionByID(ctx, "00000000-0000-0000-0000-000000000000") + require.NoError(t, err) + assert.Nil(t, exec) }) t.Run("Get execution by plan and date - not found", func(t *testing.T) { diff --git a/internal/server/scheduledauth/validator_test.go b/internal/server/scheduledauth/validator_test.go index d35a61241..5fc464844 100644 --- a/internal/server/scheduledauth/validator_test.go +++ b/internal/server/scheduledauth/validator_test.go @@ -416,10 +416,21 @@ func TestValidate_OIDC_KeyRotation_RefreshOnUnknownKid(t *testing.T) { t.Fatalf("kid A: %v", err) } + // Sign tokB before issuing the swap so that the RSA operation + // (CPU-bound, ~1 ms) gives the go-oidc cleanup goroutine time to + // set inflight=nil under the mutex. Without this, fast loopback + // HTTP on Linux CI completes the swap POST without a goroutine + // switch, leaving the stale inflight visible to the next Validate + // call and causing it to reuse the pre-rotation key set. + tokB := signToken(t, keyB, baseClaims(time.Now(), + testSchedulerSubject, + "https://api.example.com", + "https://accounts.example.com")) + // Swap the JWKS to publish kid B. // // Both the request build and the response status are checked: if the - // /swap handler 5xx's (or — more subtly — returns a non-200 because + // /swap handler 5xx's (or -- more subtly -- returns a non-200 because // the body short-read), the JWKS would silently NOT update. The test // would then fail later at "unknown kid" instead of pointing at the // real cause. Surfacing the swap failure here keeps the diagnostic @@ -441,10 +452,6 @@ func TestValidate_OIDC_KeyRotation_RefreshOnUnknownKid(t *testing.T) { } resp.Body.Close() - tokB := signToken(t, keyB, baseClaims(time.Now(), - testSchedulerSubject, - "https://api.example.com", - "https://accounts.example.com")) if err := v.Validate(context.Background(), "Bearer "+tokB); err != nil { t.Fatalf("kid B (post-rotation): %v", err) } From 0ed4d40de52291e89de2ea7212381211f6593027 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 30 Jun 2026 01:13:29 +0200 Subject: [PATCH 2/4] fix(lint): godot + comment-spelling fixes and node_modules lint exclusion Apply the safe golangci-lint --fix subset (godot, misspell, gocritic, whitespace, unconvert, revive, gofmt, goimports; excluding govet field-alignment) across the module. This drops the golangci-lint issue count from 2718 to 1329 on the current main baseline; the remaining issues are tracked for follow-up batch-fix PRs (no only-new-issues masking). Also exclude frontend/node_modules from linting in .golangci.yml: the flatted npm package bundles a Go file that is not part of this project. Status-spelling note: this PR deliberately does NOT respell the purchase execution status "cancelled" -> "canceled" (comparisons, returned values, persisted SQL, doc comments quoting those values, and the cancelled_by column). That expand-contract rename is owned by #1277; touching it here would diverge from the persisted DB contract on main. Any spelling changes the auto-fixer produced in those spots were reverted. CR fixes applied in the same commit: - gcp_resolver_coverage_test.go: tighten the canceled-context assertion from bare assert.Error to require.Error plus a positive gRPC Canceled code check (the GCP SDK wraps context cancellation in a grpc status error, which errors.Is(context.Canceled) does not traverse). - store_postgres_mock_test.go: return fmt.Errorf("%w: purchase plan %s", ErrNotFound, planID) from the mock so errors.Is(err, ErrNotFound) matches the real store's not-found contract. --- .golangci.yml | 2 + cmd/cleanup-lambda/main.go | 4 +- cmd/configure_test.go | 22 ++--- cmd/helpers.go | 50 +++++----- cmd/lambda/main.go | 4 +- cmd/lambda/main_test.go | 2 +- cmd/main.go | 12 +-- cmd/multi_service.go | 6 +- cmd/multi_service_coverage_test.go | 2 +- cmd/multi_service_csv.go | 21 +++-- cmd/multi_service_csv_test.go | 6 +- cmd/multi_service_engine_versions.go | 38 ++++---- cmd/multi_service_helpers_test.go | 2 +- cmd/multi_service_stats.go | 24 ++--- cmd/multi_service_stats_helpers.go | 24 ++--- cmd/multi_service_stats_test.go | 2 +- cmd/multi_service_test.go | 6 +- cmd/multi_service_test_common_test.go | 20 ++-- cmd/secrets_store.go | 10 +- cmd/server/main.go | 2 +- cmd/validators.go | 16 ++-- internal/accounts/org_discovery_extra_test.go | 8 +- internal/analytics/collector.go | 6 +- internal/analytics/collector_test.go | 14 +-- .../analytics/postgres_analytics_db_test.go | 2 +- .../analytics/postgres_analytics_mock_test.go | 14 +-- internal/analytics/postgres_analytics_test.go | 61 ++++++------ internal/api/db_rate_limiter.go | 18 ++-- .../api/db_rate_limiter_integration_test.go | 2 +- internal/api/exchange_lookup.go | 4 +- internal/api/exchange_lookup_test.go | 22 ++--- internal/api/handler_accounts.go | 10 +- .../api/handler_accounts_external_id_test.go | 4 +- internal/api/handler_accounts_router_test.go | 2 +- internal/api/handler_accounts_test.go | 6 +- internal/api/handler_analytics.go | 6 +- internal/api/handler_analytics_test.go | 6 +- internal/api/handler_apikeys.go | 10 +- internal/api/handler_apikeys_test.go | 2 +- internal/api/handler_auth.go | 6 +- internal/api/handler_auth_test.go | 2 +- internal/api/handler_config.go | 2 +- internal/api/handler_coverage_test.go | 6 +- internal/api/handler_dashboard.go | 16 ++-- internal/api/handler_dashboard_test.go | 10 +- internal/api/handler_federation.go | 10 +- internal/api/handler_groups.go | 10 +- internal/api/handler_history.go | 32 +++---- internal/api/handler_inventory.go | 18 ++-- internal/api/handler_inventory_test.go | 14 +-- .../api/handler_per_account_perms_test.go | 2 +- internal/api/handler_plans.go | 16 ++-- internal/api/handler_plans_test.go | 2 +- internal/api/handler_purchases_revoke.go | 14 +-- internal/api/handler_recommendations.go | 10 +- .../api/handler_recommendations_refresh.go | 2 +- internal/api/handler_recommendations_test.go | 6 +- internal/api/handler_registrations.go | 4 +- .../handler_registrations_recipients_test.go | 2 +- internal/api/handler_ri_exchange.go | 16 ++-- .../handler_ri_exchange_integration_test.go | 4 +- internal/api/handler_router.go | 9 +- internal/api/handler_router_test.go | 4 +- internal/api/handler_security_test.go | 12 +-- internal/api/handler_users.go | 14 +-- internal/api/handler_users_test.go | 2 +- internal/api/health.go | 10 +- internal/api/inmemory_rate_limiter.go | 14 +-- internal/api/middleware.go | 8 +- internal/api/mocks_test.go | 16 ++-- internal/api/rate_limiter.go | 6 +- internal/api/ri_utilization_cache.go | 4 +- internal/api/ri_utilization_cache_test.go | 4 +- internal/api/router.go | 20 ++-- internal/api/router_authuser_test.go | 6 +- internal/api/scoping.go | 8 +- internal/api/types.go | 88 +++++++++--------- internal/api/types_apikeys.go | 6 +- internal/api/validation.go | 24 ++--- internal/auth/interfaces.go | 4 +- internal/auth/service.go | 49 +++++----- internal/auth/service_api.go | 22 ++--- internal/auth/service_api_test.go | 8 +- internal/auth/service_apikeys.go | 16 ++-- internal/auth/service_apikeys_api.go | 18 ++-- internal/auth/service_group.go | 18 ++-- internal/auth/service_helpers.go | 4 +- internal/auth/service_lockout_test.go | 20 ++-- internal/auth/service_mfa.go | 6 +- internal/auth/service_password.go | 22 ++--- internal/auth/service_test.go | 2 +- internal/auth/service_user.go | 16 ++-- internal/auth/service_user_test.go | 8 +- internal/auth/store_postgres.go | 66 ++++++------- internal/auth/test_helpers.go | 14 +-- internal/auth/types.go | 50 +++++----- internal/commitmentopts/probe_test.go | 4 +- internal/commitmentopts/service.go | 5 +- internal/config/constants.go | 58 ++++++------ internal/config/defaults.go | 8 +- internal/config/interfaces.go | 8 +- internal/config/recommendation_overrides.go | 6 +- .../config/recommendation_overrides_test.go | 2 +- internal/config/resolver.go | 4 +- internal/config/resolver_test.go | 2 +- internal/config/store_postgres.go | 80 ++++++++-------- .../config/store_postgres_additional_test.go | 4 +- .../store_postgres_comprehensive_test.go | 2 +- internal/config/store_postgres_db_test.go | 8 +- .../store_postgres_increment_step_test.go | 2 +- internal/config/store_postgres_mock_test.go | 59 ++++++------ .../config/store_postgres_recommendations.go | 4 +- .../store_postgres_recommendations_test.go | 2 +- .../config/store_postgres_registrations.go | 2 +- .../store_postgres_savings_filter_test.go | 2 +- internal/config/store_postgres_test.go | 2 +- internal/config/store_postgres_unit_test.go | 18 ++-- internal/config/types.go | 46 +++++----- internal/config/validation.go | 28 +++--- internal/config/validation_test.go | 2 +- internal/credentials/resolver.go | 6 +- internal/credentials/resolver_test.go | 2 +- internal/database/config.go | 16 ++-- internal/database/connection.go | 36 ++++---- internal/database/connection_test.go | 2 +- internal/database/coverage_extra_test.go | 14 +-- ...053_executions_account_fk_restrict_test.go | 6 +- .../000065_enforce_min_one_admin_test.go | 4 +- .../migrations/ensure_admin_user_test.go | 6 +- .../postgres/migrations/helpers_test.go | 2 +- .../database/postgres/migrations/migrate.go | 29 +++--- .../database/postgres/testhelpers/postgres.go | 10 +- internal/database/security_test.go | 2 +- internal/deploy/coverage_extra_test.go | 12 +-- internal/deploy/profiles.go | 34 +++---- internal/email/coverage_extra_test.go | 34 +++---- internal/email/coverage_test.go | 92 +++++++++---------- internal/email/factory.go | 12 +-- internal/email/factory_test.go | 2 +- internal/email/interfaces.go | 10 +- internal/email/sender.go | 40 ++++---- internal/email/sender_test.go | 16 ++-- internal/email/smtp_sender.go | 30 +++--- internal/email/smtp_sender_test.go | 2 +- internal/email/smtp_server_test.go | 33 +++---- internal/email/template_renderers.go | 2 +- internal/email/template_renderers_test.go | 8 +- internal/email/templates.go | 38 ++++---- internal/email/templates_test.go | 8 +- internal/execution/fanout.go | 8 +- internal/execution/fanout_test.go | 6 +- internal/mocks/email.go | 24 ++--- internal/mocks/secretsmanager.go | 12 +-- internal/mocks/ses.go | 8 +- internal/mocks/sns.go | 8 +- internal/oidc/aws_signer_test.go | 4 +- internal/oidc/factory.go | 2 +- internal/oidc/lambda_issuer.go | 2 +- internal/purchase/approvals.go | 14 +-- internal/purchase/execution.go | 24 ++--- internal/purchase/execution_test.go | 2 +- internal/purchase/finalize_revocations.go | 2 +- internal/purchase/manager.go | 26 +++--- internal/purchase/mocks_test.go | 18 ++-- .../purchase/money_path_regression_test.go | 6 +- internal/purchase/notifications.go | 8 +- internal/purchase/reaper.go | 6 +- internal/reporter/reporter.go | 4 +- internal/runtime/runtime.go | 2 +- internal/scheduler/permission_log_test.go | 2 +- internal/scheduler/scheduler.go | 20 ++-- .../scheduler/scheduler_overrides_test.go | 2 +- internal/scheduler/scheduler_test.go | 48 +++++----- internal/secrets/aws_resolver.go | 14 +-- .../secrets/aws_resolver_coverage_test.go | 32 +++---- internal/secrets/aws_resolver_test.go | 6 +- internal/secrets/azure_resolver.go | 14 +-- .../secrets/azure_resolver_coverage_test.go | 40 ++++---- internal/secrets/azure_resolver_test.go | 8 +- internal/secrets/constructor_error_test.go | 10 +- internal/secrets/env_resolver.go | 10 +- .../secrets/env_resolver_coverage_test.go | 22 ++--- internal/secrets/gcp_resolver.go | 12 +-- .../secrets/gcp_resolver_coverage_test.go | 41 +++++---- internal/secrets/gcp_resolver_test.go | 8 +- internal/secrets/resolver.go | 8 +- internal/secrets/resolver_coverage_test.go | 4 +- internal/server/analytics_collect.go | 6 +- internal/server/app.go | 36 ++++---- internal/server/app_test.go | 4 +- internal/server/handler.go | 18 ++-- internal/server/handler_coverage_test.go | 6 +- internal/server/handler_ri_exchange_test.go | 2 +- internal/server/handler_test.go | 2 +- internal/server/health.go | 12 +-- internal/server/health_test.go | 4 +- internal/server/http.go | 10 +- internal/server/integration_test.go | 6 +- internal/server/interfaces.go | 4 +- internal/server/lambda.go | 14 +-- internal/server/lambda_test.go | 6 +- internal/server/scheduledauth/config.go | 2 +- internal/server/scheduledauth/validator.go | 14 +-- internal/server/test_helpers_test.go | 6 +- internal/testutil/mocks.go | 4 +- internal/testutil/postgres.go | 8 +- internal/testutil/testutil.go | 28 +++--- 207 files changed, 1418 insertions(+), 1401 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 398857762..6fccd1943 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -88,6 +88,7 @@ linters: - third_party$ - builtin$ - examples$ + - node_modules issues: max-issues-per-linter: 0 max-same-issues: 0 @@ -108,3 +109,4 @@ formatters: - third_party$ - builtin$ - examples$ + - node_modules diff --git a/cmd/cleanup-lambda/main.go b/cmd/cleanup-lambda/main.go index 6b7f11377..09584c432 100644 --- a/cmd/cleanup-lambda/main.go +++ b/cmd/cleanup-lambda/main.go @@ -10,12 +10,12 @@ import ( "github.com/aws/aws-lambda-go/lambda" ) -// CleanupEvent represents the input to the cleanup function +// CleanupEvent represents the input to the cleanup function. type CleanupEvent struct { DryRun bool `json:"dryRun,omitempty"` } -// CleanupResult represents the cleanup operation results +// CleanupResult represents the cleanup operation results. type CleanupResult struct { SessionsDeleted int64 `json:"sessionsDeleted"` ExecutionsDeleted int64 `json:"executionsDeleted"` diff --git a/cmd/configure_test.go b/cmd/configure_test.go index b623d89fd..5d58d9faa 100644 --- a/cmd/configure_test.go +++ b/cmd/configure_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -// MockSecretsStore is a mock implementation of SecretsStore for testing +// MockSecretsStore is a mock implementation of SecretsStore for testing. type MockSecretsStore struct { listSecretsFunc func(ctx context.Context, filter string) ([]string, error) updateSecretFunc func(ctx context.Context, secretID string, secretValue string) error @@ -40,7 +40,7 @@ func (m *MockSecretsStore) UpdateSecret(ctx context.Context, secretID string, se return nil } -// TestAzureCredentials_Struct tests the AzureCredentials struct +// TestAzureCredentials_Struct tests the AzureCredentials struct. func TestAzureCredentials_Struct(t *testing.T) { creds := AzureCredentials{ TenantID: "tenant-123", @@ -55,7 +55,7 @@ func TestAzureCredentials_Struct(t *testing.T) { assert.Equal(t, "sub-abc", creds.SubscriptionID) } -// TestAzureConfigOptions_Defaults tests AzureConfigOptions defaults +// TestAzureConfigOptions_Defaults tests AzureConfigOptions defaults. func TestAzureConfigOptions_Defaults(t *testing.T) { opts := AzureConfigOptions{} @@ -68,7 +68,7 @@ func TestAzureConfigOptions_Defaults(t *testing.T) { assert.False(t, opts.Interactive) } -// TestAzureConfigOptions_WithValues tests AzureConfigOptions with values +// TestAzureConfigOptions_WithValues tests AzureConfigOptions with values. func TestAzureConfigOptions_WithValues(t *testing.T) { opts := AzureConfigOptions{ StackName: "my-cudly", @@ -89,7 +89,7 @@ func TestAzureConfigOptions_WithValues(t *testing.T) { assert.True(t, opts.Interactive) } -// TestGCPCredentials_Struct tests the GCPCredentials struct +// TestGCPCredentials_Struct tests the GCPCredentials struct. func TestGCPCredentials_Struct(t *testing.T) { creds := GCPCredentials{ Type: "service_account", @@ -108,7 +108,7 @@ func TestGCPCredentials_Struct(t *testing.T) { assert.Equal(t, "12345678901234567890", creds.ClientID) } -// TestGCPConfigOptions_Defaults tests GCPConfigOptions defaults +// TestGCPConfigOptions_Defaults tests GCPConfigOptions defaults. func TestGCPConfigOptions_Defaults(t *testing.T) { opts := GCPConfigOptions{} @@ -119,7 +119,7 @@ func TestGCPConfigOptions_Defaults(t *testing.T) { assert.False(t, opts.Interactive) } -// TestGCPConfigOptions_WithValues tests GCPConfigOptions with values +// TestGCPConfigOptions_WithValues tests GCPConfigOptions with values. func TestGCPConfigOptions_WithValues(t *testing.T) { opts := GCPConfigOptions{ StackName: "my-cudly", @@ -136,7 +136,7 @@ func TestGCPConfigOptions_WithValues(t *testing.T) { assert.True(t, opts.Interactive) } -// Tests for validateAzureUUID function +// Tests for validateAzureUUID function. func TestValidateAzureUUID(t *testing.T) { tests := []struct { name string @@ -256,7 +256,7 @@ func TestValidateAzureUUID(t *testing.T) { } } -// Tests for validateGCPProjectID function +// Tests for validateGCPProjectID function. func TestValidateGCPProjectID(t *testing.T) { tests := []struct { name string @@ -383,7 +383,7 @@ func TestValidateGCPProjectID(t *testing.T) { } } -// Tests for storeAzureCredentials function +// Tests for storeAzureCredentials function. func TestStoreAzureCredentials(t *testing.T) { tests := []struct { name string @@ -549,7 +549,7 @@ func TestStoreAzureCredentials(t *testing.T) { } } -// Tests for storeGCPCredentials function +// Tests for storeGCPCredentials function. func TestStoreGCPCredentials(t *testing.T) { // private_key validation is presence-only; the key content is not parsed or // validated as a real PEM block by storeGCPCredentials. diff --git a/cmd/helpers.go b/cmd/helpers.go index 6a3a1af93..d3e0272ed 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -18,36 +18,36 @@ import ( "golang.org/x/term" ) -// Constants for purchase processing +// Constants for purchase processing. const ( - // DefaultDuplicateCheckLookbackHours is the default lookback period for checking recent purchases + // DefaultDuplicateCheckLookbackHours is the default lookback period for checking recent purchases. DefaultDuplicateCheckLookbackHours = 24 - // PurchaseDelaySeconds is the delay between consecutive purchases to avoid rate limiting + // PurchaseDelaySeconds is the delay between consecutive purchases to avoid rate limiting. PurchaseDelaySeconds = 2 ) -// AppLogger is a simple logger for application output +// AppLogger is a simple logger for application output. var AppLogger = log.New(os.Stdout, "", 0) -// OrganizationsAPI interface for describing accounts +// OrganizationsAPI interface for describing accounts. type OrganizationsAPI interface { DescribeAccount(ctx context.Context, params *organizations.DescribeAccountInput, optFns ...func(*organizations.Options)) (*organizations.DescribeAccountOutput, error) } -// AccountAliasGetter is an interface for getting account aliases +// AccountAliasGetter is an interface for getting account aliases. type AccountAliasGetter interface { GetAccountAlias(ctx context.Context, accountID string) string } -// AccountAliasCache caches account ID to alias mappings +// AccountAliasCache caches account ID to alias mappings. type AccountAliasCache struct { mu sync.RWMutex cache map[string]string orgClient OrganizationsAPI } -// NewAccountAliasCache creates a new account alias cache +// NewAccountAliasCache creates a new account alias cache. func NewAccountAliasCache(cfg aws.Config) *AccountAliasCache { return &AccountAliasCache{ cache: make(map[string]string), @@ -56,7 +56,7 @@ func NewAccountAliasCache(cfg aws.Config) *AccountAliasCache { } // NewAccountAliasCacheWithClient creates a new account alias cache with a custom client -// This is useful for testing with mocked clients +// This is useful for testing with mocked clients. func NewAccountAliasCacheWithClient(orgClient OrganizationsAPI) *AccountAliasCache { return &AccountAliasCache{ cache: make(map[string]string), @@ -64,7 +64,7 @@ func NewAccountAliasCacheWithClient(orgClient OrganizationsAPI) *AccountAliasCac } } -// GetAccountAlias returns the account alias for an account ID +// GetAccountAlias returns the account alias for an account ID. func (c *AccountAliasCache) GetAccountAlias(ctx context.Context, accountID string) string { if accountID == "" { return "" @@ -104,7 +104,7 @@ func (c *AccountAliasCache) GetAccountAlias(ctx context.Context, accountID strin return accountID } -// CalculateTotalInstances calculates the total instance count across recommendations +// CalculateTotalInstances calculates the total instance count across recommendations. func CalculateTotalInstances(recs []common.Recommendation) int { total := 0 for _, rec := range recs { @@ -223,7 +223,7 @@ func ApplyCoverage(recs []common.Recommendation, coverage float64) []common.Reco // // Pools where CE reports 100% existing coverage but AWS still recommends // new RIs (typical when existing RIs are near expiry) are dropped here — -// the existing coverage is honoured strictly. Use --rebuy-window-days to +// the existing coverage is honored strictly. Use --rebuy-window-days to // surface those replacements before the cliff. // // SPs: @@ -424,7 +424,7 @@ func applyTargetCoverageSP(rec common.Recommendation, targetPct float64) (common // RecommendedUtilization is consulted only as a no-signal guard above (a // zero value means we can't sanity-check the result); the scaling itself // uses targetPct directly rather than a recUtil/target ratio so the flag's - // intent is honoured even when AWS already projects above target. + // intent is honored even when AWS already projects above target. // // If Details isn't a *SavingsPlanDetails (defensive — should always be // for SP recs), log a warning and pass through UNCHANGED — including @@ -467,7 +467,7 @@ func applySizing(recs []common.Recommendation, cfg Config, coverage float64) []c return ApplyCoverage(recs, coverage) } -// ApplyCountOverride overrides the count for all recommendations +// ApplyCountOverride overrides the count for all recommendations. func ApplyCountOverride(recs []common.Recommendation, overrideCount int32) []common.Recommendation { if overrideCount <= 0 { return recs @@ -480,7 +480,7 @@ func ApplyCountOverride(recs []common.Recommendation, overrideCount int32) []com return result } -// ApplyInstanceLimit limits the total number of instances +// ApplyInstanceLimit limits the total number of instances. func ApplyInstanceLimit(recs []common.Recommendation, maxInstances int32) []common.Recommendation { if maxInstances <= 0 { return recs @@ -540,7 +540,7 @@ func CheckAuditLogWritable(path string) error { return f.Close() } -// DuplicateChecker checks for existing commitments to avoid duplicates +// DuplicateChecker checks for existing commitments to avoid duplicates. type DuplicateChecker struct { LookbackHours int // How many hours to look back for recent purchases } @@ -586,7 +586,7 @@ func (d *DuplicateChecker) AdjustRecommendationsForExisting(ctx context.Context, return passed, filtered, nil } -// filterRecentCommitments filters commitments to only recent purchases within the lookback window +// filterRecentCommitments filters commitments to only recent purchases within the lookback window. func (d *DuplicateChecker) filterRecentCommitments(existing []common.Commitment) []common.Commitment { cutoffTime := time.Now().Add(-time.Duration(d.LookbackHours) * time.Hour) recentExisting := make([]common.Commitment, 0) @@ -600,12 +600,12 @@ func (d *DuplicateChecker) filterRecentCommitments(existing []common.Commitment) return recentExisting } -// isRecentActiveCommitment checks if a commitment is active and purchased after the cutoff time +// isRecentActiveCommitment checks if a commitment is active and purchased after the cutoff time. func isRecentActiveCommitment(c common.Commitment, cutoffTime time.Time) bool { return (c.State == "active" || c.State == "payment-pending") && c.StartDate.After(cutoffTime) } -// buildExistingCommitmentsMap builds a map of commitments by resource type, region, and engine +// buildExistingCommitmentsMap builds a map of commitments by resource type, region, and engine. func buildExistingCommitmentsMap(commitments []common.Commitment) map[string]int { existingMap := make(map[string]int) @@ -638,7 +638,7 @@ func adjustRecommendationsAgainstExisting(recs []common.Recommendation, existing return passed, filtered } -// adjustSingleRecommendation adjusts a single recommendation based on existing commitments +// adjustSingleRecommendation adjusts a single recommendation based on existing commitments. func adjustSingleRecommendation(rec common.Recommendation, existingMap map[string]int) common.Recommendation { engine := getEngineFromRecommendation(rec) key := fmt.Sprintf("%s|%s|%s", rec.ResourceType, rec.Region, engine) @@ -664,7 +664,7 @@ func adjustSingleRecommendation(rec common.Recommendation, existingMap map[strin return adjusted } -// getEngineFromRecommendation extracts the engine from recommendation details +// getEngineFromRecommendation extracts the engine from recommendation details. func getEngineFromRecommendation(rec common.Recommendation) string { if rec.Details == nil { return "" @@ -687,7 +687,7 @@ func getEngineFromRecommendation(rec common.Recommendation) string { // engineNameMap maps database engine names to a consistent normalized format. // AWS RIs use: "aurora-postgresql", "aurora-mysql", "mysql", "postgres" -// Cost Explorer uses: "Aurora PostgreSQL", "Aurora MySQL", "MySQL", "PostgreSQL" +// Cost Explorer uses: "Aurora PostgreSQL", "Aurora MySQL", "MySQL", "PostgreSQL". var engineNameMap = map[string]string{ // Cost Explorer format -> normalized "Aurora PostgreSQL": "aurora-postgresql", @@ -714,7 +714,7 @@ var engineNameMap = map[string]string{ "sqlserver-web": "sqlserver", } -// normalizeEngineName normalizes database engine names to a consistent format +// normalizeEngineName normalizes database engine names to a consistent format. func normalizeEngineName(engine string) string { if normalized, ok := engineNameMap[engine]; ok { return normalized @@ -723,12 +723,12 @@ func normalizeEngineName(engine string) string { return strings.ToLower(engine) } -// AdjustRecommendationsForExistingRIs is an alias for AdjustRecommendationsForExisting +// AdjustRecommendationsForExistingRIs is an alias for AdjustRecommendationsForExisting. func (d *DuplicateChecker) AdjustRecommendationsForExistingRIs(ctx context.Context, recs []common.Recommendation, client provider.ServiceClient) ([]common.Recommendation, []common.Recommendation, error) { return d.AdjustRecommendationsForExisting(ctx, recs, client) } -// GetRecommendationDescription returns a human-readable description +// GetRecommendationDescription returns a human-readable description. func GetRecommendationDescription(rec common.Recommendation) string { desc := fmt.Sprintf("%s %s", rec.Service, rec.ResourceType) if rec.Details != nil { diff --git a/cmd/lambda/main.go b/cmd/lambda/main.go index ec3ede3c4..515000d8f 100644 --- a/cmd/lambda/main.go +++ b/cmd/lambda/main.go @@ -17,7 +17,7 @@ import ( "github.com/aws/aws-lambda-go/lambda" ) -// Version is set at build time +// Version is set at build time. var Version = "dev" var ( @@ -56,7 +56,7 @@ func initApp(ctx context.Context) (*server.Application, error) { } // Handler is the main Lambda handler function -// This delegates to Application.HandleLambdaEvent which handles all event types +// This delegates to Application.HandleLambdaEvent which handles all event types. func Handler(ctx context.Context, rawEvent json.RawMessage) (interface{}, error) { // Initialize app on first request (lazy initialization) application, err := initApp(ctx) diff --git a/cmd/lambda/main_test.go b/cmd/lambda/main_test.go index 6dfbd28e6..d54e151d2 100644 --- a/cmd/lambda/main_test.go +++ b/cmd/lambda/main_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -// createTestApp creates a minimal Application for testing with no DB dependency +// createTestApp creates a minimal Application for testing with no DB dependency. func createTestApp() *server.Application { apiHandler := api.NewHandler(api.HandlerConfig{}) return &server.Application{ diff --git a/cmd/main.go b/cmd/main.go index 92a14b9e5..e52d8d033 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -27,11 +27,11 @@ import ( const ( // MaxReasonableInstances is the maximum number of instances that can be processed - // This is a safety limit to prevent accidental large purchases + // This is a safety limit to prevent accidental large purchases. MaxReasonableInstances = 10000 ) -// Config holds all configuration for the RI helper tool +// Config holds all configuration for the RI helper tool. type Config struct { Providers []string Regions []string @@ -173,7 +173,7 @@ func init() { "Default 0 = no filter.") } -// Package-level Config that cobra flags bind to +// Package-level Config that cobra flags bind to. var toolCfg = Config{} // validateFlags is now defined in validators.go @@ -239,7 +239,7 @@ func parseServices(serviceNames []string) []common.ServiceType { return result } -// getAllServices returns all supported services +// getAllServices returns all supported services. func getAllServices() []common.ServiceType { return []common.ServiceType{ common.ServiceRDS, @@ -255,7 +255,7 @@ func getAllServices() []common.ServiceType { } } -// createServiceClient creates the appropriate service client for a service +// createServiceClient creates the appropriate service client for a service. func createServiceClient(service common.ServiceType, cfg aws.Config) provider.ServiceClient { switch service { case common.ServiceRDS: @@ -366,7 +366,7 @@ func generatePurchaseID(rec common.Recommendation, region string, _ int, isDryRu prefix, service, region, instanceType, rec.Count, coveragePct, timestamp, uuidSuffix) } -// sanitizeAccountName converts account name to a filesystem/ID-safe format +// sanitizeAccountName converts account name to a filesystem/ID-safe format. func sanitizeAccountName(accountName string) string { if accountName == "" { return "" diff --git a/cmd/multi_service.go b/cmd/multi_service.go index ae3bab8f8..cd606a6dc 100644 --- a/cmd/multi_service.go +++ b/cmd/multi_service.go @@ -256,7 +256,7 @@ func buildServiceStats(recs []common.Recommendation, results []common.PurchaseRe return stats } -// runToolFromCSV processes recommendations from a CSV input file +// runToolFromCSV processes recommendations from a CSV input file. func runToolFromCSV(ctx context.Context, cfg Config) { // Determine if this is a dry run isDryRun := !cfg.ActualPurchase @@ -368,7 +368,7 @@ func runToolFromCSV(ctx context.Context, cfg Config) { printMultiServiceSummary(recommendations, allResults, serviceStats, isDryRun) } -// filterAndAdjustRecommendations applies filters, coverage, count override, and instance limits to recommendations +// filterAndAdjustRecommendations applies filters, coverage, count override, and instance limits to recommendations. func filterAndAdjustRecommendations(recommendations []common.Recommendation, csvModeCoverage float64, cfg Config) []common.Recommendation { // Query running instances for engine version validation log.Printf("🔍 Querying running RDS instances across all regions to validate engine versions...") @@ -458,7 +458,7 @@ func processService(ctx context.Context, awsCfg aws.Config, recClient provider.R return serviceRecs, serviceResults } -// processPurchaseLoop processes purchases for a single region (used by CSV mode) +// processPurchaseLoop processes purchases for a single region (used by CSV mode). func processPurchaseLoop(ctx context.Context, recs []common.Recommendation, region string, isDryRun bool, serviceClient provider.ServiceClient, cfg Config) []common.PurchaseResult { results := make([]common.PurchaseResult, 0, len(recs)) diff --git a/cmd/multi_service_coverage_test.go b/cmd/multi_service_coverage_test.go index 0ba0d0b78..509c7cbeb 100644 --- a/cmd/multi_service_coverage_test.go +++ b/cmd/multi_service_coverage_test.go @@ -770,7 +770,7 @@ func TestFilterAndAdjustRecommendations_OverrideCountApplied(t *testing.T) { } // TestFetchExistingCoverage_LookbackDays verifies that fetchExistingCoverage -// honours cfg.CoverageLookbackDays (issue #360). The test uses the +// honors cfg.CoverageLookbackDays (issue #360). The test uses the // MockRecommendationsClient which fails the *awsprovider.RecommendationsClientAdapter // type assertion, exercising the non-AWS-provider early-return path. The key // assertions are: diff --git a/cmd/multi_service_csv.go b/cmd/multi_service_csv.go index 29ae14d38..cb8f210cc 100644 --- a/cmd/multi_service_csv.go +++ b/cmd/multi_service_csv.go @@ -2,6 +2,7 @@ package main import ( "encoding/csv" + "errors" "fmt" "io" "log" @@ -13,7 +14,7 @@ import ( "github.com/LeanerCloud/CUDly/providers/aws/recommendations" ) -// determineCSVCoverage determines the coverage percentage to use for CSV mode +// determineCSVCoverage determines the coverage percentage to use for CSV mode. func determineCSVCoverage(cfg Config) float64 { // When using CSV input, default to 100% coverage (use exact numbers from CSV) // unless user explicitly provided a different coverage value @@ -24,7 +25,7 @@ func determineCSVCoverage(cfg Config) float64 { return cfg.Coverage } -// loadRecommendationsFromCSV reads and returns recommendations from a CSV file +// loadRecommendationsFromCSV reads and returns recommendations from a CSV file. func loadRecommendationsFromCSV(csvPath string) ([]common.Recommendation, error) { file, err := os.Open(csvPath) if err != nil { @@ -56,7 +57,7 @@ func loadRecommendationsFromCSV(csvPath string) ([]common.Recommendation, error) return recommendations, nil } -// buildColumnIndexMap creates a map from column names to indices +// buildColumnIndexMap creates a map from column names to indices. func buildColumnIndexMap(header []string) map[string]int { colIdx := make(map[string]int) for i, col := range header { @@ -65,13 +66,13 @@ func buildColumnIndexMap(header []string) map[string]int { return colIdx } -// parseCSVRecords reads and parses all CSV records +// parseCSVRecords reads and parses all CSV records. func parseCSVRecords(reader *csv.Reader, colIdx map[string]int) ([]common.Recommendation, error) { var recommendations []common.Recommendation for { record, err := reader.Read() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { @@ -97,7 +98,7 @@ func parseCSVRecords(reader *csv.Reader, colIdx map[string]int) ([]common.Recomm return recommendations, nil } -// parseCSVRecord parses a single CSV record into a Recommendation +// parseCSVRecord parses a single CSV record into a Recommendation. func parseCSVRecord(record []string, colIdx map[string]int) (common.Recommendation, error) { rec := common.Recommendation{} @@ -154,7 +155,7 @@ func parseCSVRecord(record []string, colIdx map[string]int) (common.Recommendati return rec, nil } -// getCSVField safely retrieves a string field from a CSV record +// getCSVField safely retrieves a string field from a CSV record. func getCSVField(record []string, colIdx map[string]int, fieldName string) string { if idx, ok := colIdx[fieldName]; ok && idx < len(record) { return record[idx] @@ -162,7 +163,7 @@ func getCSVField(record []string, colIdx map[string]int, fieldName string) strin return "" } -// parseCSVInt parses an integer field from a CSV record +// parseCSVInt parses an integer field from a CSV record. func parseCSVInt(record []string, colIdx map[string]int, fieldName string, target *int) error { value := getCSVField(record, colIdx, fieldName) if value == "" { @@ -175,7 +176,7 @@ func parseCSVInt(record []string, colIdx map[string]int, fieldName string, targe return nil } -// parseCSVFloat parses a float field from a CSV record +// parseCSVFloat parses a float field from a CSV record. func parseCSVFloat(record []string, colIdx map[string]int, fieldName string, target *float64) error { value := getCSVField(record, colIdx, fieldName) if value == "" { @@ -188,7 +189,7 @@ func parseCSVFloat(record []string, colIdx map[string]int, fieldName string, tar return nil } -// writeMultiServiceCSVReport writes purchase results to a CSV file +// writeMultiServiceCSVReport writes purchase results to a CSV file. func writeMultiServiceCSVReport(results []common.PurchaseResult, filepath string) error { if len(results) == 0 { return nil diff --git a/cmd/multi_service_csv_test.go b/cmd/multi_service_csv_test.go index 7d6af2772..2f89ca823 100644 --- a/cmd/multi_service_csv_test.go +++ b/cmd/multi_service_csv_test.go @@ -549,7 +549,7 @@ func TestExtractEngine(t *testing.T) { } } -// TestFormatCurrencyOrBlank locks the blank-when-zero behaviour for the +// TestFormatCurrencyOrBlank locks the blank-when-zero behavior for the // UpfrontPayment column. Non-zero renders with two decimals; zero renders // as an empty cell so users can distinguish "no upfront due" from "actual // $0 upfront", consistent with the rest of the optional CSV columns. @@ -570,7 +570,7 @@ func TestFormatCurrencyOrBlank(t *testing.T) { } } -// Tests for loadRecommendationsFromCSV function +// Tests for loadRecommendationsFromCSV function. func TestLoadRecommendationsFromCSV(t *testing.T) { tests := []struct { name string @@ -803,7 +803,7 @@ rds,us-east-1,db.t3.micro,0,0`, } } -// Test loadRecommendationsFromCSV with file errors +// Test loadRecommendationsFromCSV with file errors. func TestLoadRecommendationsFromCSV_FileErrors(t *testing.T) { tests := []struct { name string diff --git a/cmd/multi_service_engine_versions.go b/cmd/multi_service_engine_versions.go index 6edee9c4c..472287e6a 100644 --- a/cmd/multi_service_engine_versions.go +++ b/cmd/multi_service_engine_versions.go @@ -18,7 +18,7 @@ import ( rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" ) -// InstanceEngineVersion stores engine version information for an instance +// InstanceEngineVersion stores engine version information for an instance. type InstanceEngineVersion struct { Engine string EngineVersion string @@ -26,21 +26,21 @@ type InstanceEngineVersion struct { Region string } -// EngineLifecycleInfo stores lifecycle support information for a major engine version +// EngineLifecycleInfo stores lifecycle support information for a major engine version. type EngineLifecycleInfo struct { LifecycleSupportName string LifecycleSupportStartDate time.Time LifecycleSupportEndDate time.Time } -// MajorEngineVersionInfo stores support information for a major engine version +// MajorEngineVersionInfo stores support information for a major engine version. type MajorEngineVersionInfo struct { Engine string MajorEngineVersion string SupportedEngineLifecycles []EngineLifecycleInfo } -// queryRunningInstanceEngineVersions queries all running RDS instances and returns their engine versions +// queryRunningInstanceEngineVersions queries all running RDS instances and returns their engine versions. func queryRunningInstanceEngineVersions(ctx context.Context, cfg Config) (map[string][]InstanceEngineVersion, error) { awsCfg, err := loadValidationAWSConfig(ctx, cfg) if err != nil { @@ -55,7 +55,7 @@ func queryRunningInstanceEngineVersions(ctx context.Context, cfg Config) (map[st return queryRDSInstancesInRegions(ctx, awsCfg, regions) } -// loadValidationAWSConfig loads AWS configuration for validation +// loadValidationAWSConfig loads AWS configuration for validation. func loadValidationAWSConfig(ctx context.Context, cfg Config) (aws.Config, error) { validationProfile := cfg.ValidationProfile if validationProfile == "" { @@ -76,7 +76,7 @@ func loadValidationAWSConfig(ctx context.Context, cfg Config) (aws.Config, error return awsCfg, nil } -// getAWSRegions retrieves all AWS regions +// getAWSRegions retrieves all AWS regions. func getAWSRegions(ctx context.Context, awsCfg aws.Config) ([]ec2types.Region, error) { ec2Client := awsec2.NewFromConfig(awsCfg) regionsOutput, err := ec2Client.DescribeRegions(ctx, &awsec2.DescribeRegionsInput{}) @@ -86,7 +86,7 @@ func getAWSRegions(ctx context.Context, awsCfg aws.Config) ([]ec2types.Region, e return regionsOutput.Regions, nil } -// maxConcurrentRegionQueries limits the number of concurrent AWS API calls across regions +// maxConcurrentRegionQueries limits the number of concurrent AWS API calls across regions. const maxConcurrentRegionQueries = 10 // maxEngineVersionPages caps DescribeDBMajorEngineVersions pagination per engine. @@ -99,7 +99,7 @@ type RDSMajorVersionsClient interface { DescribeDBMajorEngineVersions(ctx context.Context, params *awsrds.DescribeDBMajorEngineVersionsInput, optFns ...func(*awsrds.Options)) (*awsrds.DescribeDBMajorEngineVersionsOutput, error) } -// queryRDSInstancesInRegions queries RDS instances in all regions concurrently +// queryRDSInstancesInRegions queries RDS instances in all regions concurrently. func queryRDSInstancesInRegions(ctx context.Context, awsCfg aws.Config, regions []ec2types.Region) (map[string][]InstanceEngineVersion, error) { instanceVersions := make(map[string][]InstanceEngineVersion) var mu sync.Mutex @@ -128,7 +128,7 @@ func queryRDSInstancesInRegions(ctx context.Context, awsCfg aws.Config, regions return instanceVersions, nil } -// queryRDSInstancesInRegion queries RDS instances in a single region +// queryRDSInstancesInRegion queries RDS instances in a single region. func queryRDSInstancesInRegion(ctx context.Context, awsCfg aws.Config, regionName string, instanceVersions map[string][]InstanceEngineVersion, mu *sync.Mutex) { regionCfg := awsCfg.Copy() regionCfg.Region = regionName @@ -156,7 +156,7 @@ func queryRDSInstancesInRegion(ctx context.Context, awsCfg aws.Config, regionNam } } -// queryRDSInstancesPage queries a single page of RDS instances +// queryRDSInstancesPage queries a single page of RDS instances. func queryRDSInstancesPage(ctx context.Context, rdsClient *awsrds.Client, marker *string, regionName string) (map[string][]InstanceEngineVersion, *string, error) { input := &awsrds.DescribeDBInstancesInput{Marker: marker} output, err := rdsClient.DescribeDBInstances(ctx, input) @@ -186,7 +186,7 @@ func queryRDSInstancesPage(ctx context.Context, rdsClient *awsrds.Client, marker return localVersions, nextMarker, nil } -// queryMajorEngineVersions queries AWS for major engine version lifecycle support information +// queryMajorEngineVersions queries AWS for major engine version lifecycle support information. func queryMajorEngineVersions(ctx context.Context, cfg Config) (map[string]MajorEngineVersionInfo, error) { // Determine which profile to use profile := cfg.ValidationProfile @@ -295,7 +295,7 @@ func parseDBMajorEngineVersion(version rdstypes.DBMajorEngineVersion) MajorEngin } // extractMajorVersion extracts the major version from a full engine version string -// Handles special cases like Aurora MySQL version mapping +// Handles special cases like Aurora MySQL version mapping. func extractMajorVersion(engine, fullVersion string) string { if fullVersion == "" { return "" @@ -314,7 +314,7 @@ func extractMajorVersion(engine, fullVersion string) string { return extractStandardVersion(fullVersion) } -// normalizeEngineNameForVersion normalizes an engine name by removing spaces and hyphens +// normalizeEngineNameForVersion normalizes an engine name by removing spaces and hyphens. func normalizeEngineNameForVersion(engine string) string { normalized := strings.ToLower(engine) normalized = strings.ReplaceAll(normalized, "-", "") @@ -322,7 +322,7 @@ func normalizeEngineNameForVersion(engine string) string { return normalized } -// extractAuroraMySQLVersion extracts the MySQL-compatible version from Aurora MySQL +// extractAuroraMySQLVersion extracts the MySQL-compatible version from Aurora MySQL. func extractAuroraMySQLVersion(fullVersion string) string { // Aurora MySQL 2.x is compatible with MySQL 5.7 if strings.Contains(fullVersion, "mysql_aurora.2.") { @@ -342,7 +342,7 @@ func extractAuroraMySQLVersion(fullVersion string) string { return "" } -// extractStandardVersion extracts major.minor version from a standard version string +// extractStandardVersion extracts major.minor version from a standard version string. func extractStandardVersion(fullVersion string) string { parts := strings.Split(fullVersion, ".") if len(parts) >= 2 { @@ -354,7 +354,7 @@ func extractStandardVersion(fullVersion string) string { return "" } -// extractMajorMinorVersion combines major and minor version parts +// extractMajorMinorVersion combines major and minor version parts. func extractMajorMinorVersion(major, minor string) string { // Filter out non-numeric parts in minor version numericMinor := extractNumericPrefix(minor) @@ -364,7 +364,7 @@ func extractMajorMinorVersion(major, minor string) string { return major } -// extractNumericPrefix extracts the numeric prefix from a string +// extractNumericPrefix extracts the numeric prefix from a string. func extractNumericPrefix(s string) string { numericPrefix := "" for _, ch := range s { @@ -377,7 +377,7 @@ func extractNumericPrefix(s string) string { return numericPrefix } -// isInExtendedSupport checks if a version is currently in extended support based on lifecycle dates +// isInExtendedSupport checks if a version is currently in extended support based on lifecycle dates. func isInExtendedSupport(engine, fullVersion string, versionInfo map[string]MajorEngineVersionInfo) bool { majorVersion := extractMajorVersion(engine, fullVersion) if majorVersion == "" { @@ -411,7 +411,7 @@ func isInExtendedSupport(engine, fullVersion string, versionInfo map[string]Majo } // adjustRecommendationForExcludedVersions reduces the instance count in a recommendation -// by the number of instances running versions in extended support +// by the number of instances running versions in extended support. func adjustRecommendationForExcludedVersions(rec common.Recommendation, instanceVersions map[string][]InstanceEngineVersion, versionInfo map[string]MajorEngineVersionInfo) common.Recommendation { // Check if this instance type has any running instances versions, exists := instanceVersions[rec.ResourceType] diff --git a/cmd/multi_service_helpers_test.go b/cmd/multi_service_helpers_test.go index 2e9ee56c3..1c48f1b06 100644 --- a/cmd/multi_service_helpers_test.go +++ b/cmd/multi_service_helpers_test.go @@ -731,7 +731,7 @@ func TestPopulateAccountNames(t *testing.T) { } // TestPopulateAccountNamesLogic tests the logic of populateAccountNames -// by verifying it populates the AccountName field correctly +// by verifying it populates the AccountName field correctly. func TestPopulateAccountNamesLogic(t *testing.T) { ctx := context.Background() diff --git a/cmd/multi_service_stats.go b/cmd/multi_service_stats.go index ba44abcad..b1941e7c1 100644 --- a/cmd/multi_service_stats.go +++ b/cmd/multi_service_stats.go @@ -4,7 +4,7 @@ import ( "github.com/LeanerCloud/CUDly/pkg/common" ) -// ServiceProcessingStats holds statistics for each service +// ServiceProcessingStats holds statistics for each service. type ServiceProcessingStats struct { Service common.ServiceType RegionsProcessed int @@ -16,7 +16,7 @@ type ServiceProcessingStats struct { TotalEstimatedSavings float64 } -// calculateServiceStats calculates statistics for a service based on recommendations and results +// calculateServiceStats calculates statistics for a service based on recommendations and results. func calculateServiceStats(service common.ServiceType, recs []common.Recommendation, results []common.PurchaseResult) ServiceProcessingStats { stats := ServiceProcessingStats{ Service: service, @@ -43,7 +43,7 @@ func calculateServiceStats(service common.ServiceType, recs []common.Recommendat return stats } -// printServiceSummary prints a summary for a single service +// printServiceSummary prints a summary for a single service. func printServiceSummary(service common.ServiceType, stats ServiceProcessingStats) { AppLogger.Printf("\n📊 %s Summary:\n", getServiceDisplayName(service)) AppLogger.Printf(" Regions processed: %d\n", stats.RegionsProcessed) @@ -55,7 +55,7 @@ func printServiceSummary(service common.ServiceType, stats ServiceProcessingStat } } -// printMultiServiceSummary prints the final summary for all services +// printMultiServiceSummary prints the final summary for all services. func printMultiServiceSummary(allRecommendations []common.Recommendation, allResults []common.PurchaseResult, serviceStats map[common.ServiceType]ServiceProcessingStats, isDryRun bool) { printSummaryHeader(isDryRun) @@ -75,7 +75,7 @@ func printMultiServiceSummary(allRecommendations []common.Recommendation, allRes printFinalMessage(isDryRun, riAggregates.success) } -// riAggregateStats holds aggregated RI statistics +// riAggregateStats holds aggregated RI statistics. type riAggregateStats struct { recommendations int instances int @@ -84,7 +84,7 @@ type riAggregateStats struct { failed int } -// printSummaryHeader prints the summary header with mode indication +// printSummaryHeader prints the summary header with mode indication. func printSummaryHeader(isDryRun bool) { AppLogger.Println("\n🎯 Final Summary:") AppLogger.Println("==========================================") @@ -95,7 +95,7 @@ func printSummaryHeader(isDryRun bool) { } } -// separateAndAggregateStats separates SP from RI stats and aggregates RI totals +// separateAndAggregateStats separates SP from RI stats and aggregates RI totals. func separateAndAggregateStats(serviceStats map[common.ServiceType]ServiceProcessingStats) (ServiceProcessingStats, map[common.ServiceType]ServiceProcessingStats, riAggregateStats) { spStats := ServiceProcessingStats{} riStats := make(map[common.ServiceType]ServiceProcessingStats) @@ -136,7 +136,7 @@ func separateAndAggregateStats(serviceStats map[common.ServiceType]ServiceProces return spStats, riStats, aggregates } -// printReservedInstancesSection prints the RI section with per-service and total stats +// printReservedInstancesSection prints the RI section with per-service and total stats. func printReservedInstancesSection(riStats map[common.ServiceType]ServiceProcessingStats, aggregates riAggregateStats) { if len(riStats) == 0 { return @@ -158,7 +158,7 @@ func printReservedInstancesSection(riStats map[common.ServiceType]ServiceProcess aggregates.savings) } -// printSuccessRate prints the overall success rate if results exist +// printSuccessRate prints the overall success rate if results exist. func printSuccessRate(success, failed int) { totalResults := success + failed if totalResults > 0 { @@ -171,7 +171,7 @@ func printSuccessRate(success, failed int) { // with the web interface (frontend ARCHERA_SIGNUP_URL). const archeraSignupURL = "https://www.archera.ai/cudly" -// printFinalMessage prints the final message based on mode and results +// printFinalMessage prints the final message based on mode and results. func printFinalMessage(isDryRun bool, riSuccess int) { if isDryRun { AppLogger.Println("\n💡 To actually purchase these RIs, run with --purchase flag") @@ -205,7 +205,7 @@ func printArcheraPitch() { AppLogger.Println(" from a fraction of their insurance premiums.") } -// printSavingsPlansSection prints the Savings Plans summary section +// printSavingsPlansSection prints the Savings Plans summary section. func printSavingsPlansSection(allRecommendations []common.Recommendation, spStats ServiceProcessingStats) { AppLogger.Println("\n📊 SAVINGS PLANS:") AppLogger.Println("--------------------------------------------------") @@ -220,7 +220,7 @@ func printSavingsPlansSection(allRecommendations []common.Recommendation, spStat printBestSPOptions(breakdown) } -// printComparisonSection prints the comparison between RIs and Savings Plans +// printComparisonSection prints the comparison between RIs and Savings Plans. func printComparisonSection(allRecommendations []common.Recommendation, riStats map[common.ServiceType]ServiceProcessingStats, riSavings float64) { AppLogger.Println("\n🔄 COMPARISON:") AppLogger.Println("--------------------------------------------------") diff --git a/cmd/multi_service_stats_helpers.go b/cmd/multi_service_stats_helpers.go index f1ff80ffe..7f704b0f1 100644 --- a/cmd/multi_service_stats_helpers.go +++ b/cmd/multi_service_stats_helpers.go @@ -4,7 +4,7 @@ import ( "github.com/LeanerCloud/CUDly/pkg/common" ) -// SPTypeBreakdown holds savings information broken down by Savings Plan type +// SPTypeBreakdown holds savings information broken down by Savings Plan type. type SPTypeBreakdown struct { ComputeSavings float64 EC2InstanceSavings float64 @@ -16,7 +16,7 @@ type SPTypeBreakdown struct { DatabaseCount int } -// categorizeSPRecommendations categorizes Savings Plan recommendations by type +// categorizeSPRecommendations categorizes Savings Plan recommendations by type. func categorizeSPRecommendations(recommendations []common.Recommendation) SPTypeBreakdown { breakdown := SPTypeBreakdown{} @@ -44,7 +44,7 @@ func categorizeSPRecommendations(recommendations []common.Recommendation) SPType return breakdown } -// printSPTypeSummaries prints the summary for each Savings Plan type +// printSPTypeSummaries prints the summary for each Savings Plan type. func printSPTypeSummaries(breakdown SPTypeBreakdown) { if breakdown.ComputeCount > 0 { AppLogger.Printf(" Compute SP | Recs: %3d | Covers: EC2, Fargate, Lambda | $%8.2f/mo\n", @@ -64,7 +64,7 @@ func printSPTypeSummaries(breakdown SPTypeBreakdown) { } } -// printBestSPOptions prints the best Savings Plan options by category +// printBestSPOptions prints the best Savings Plan options by category. func printBestSPOptions(breakdown SPTypeBreakdown) { AppLogger.Println() @@ -88,14 +88,14 @@ func printBestSPOptions(breakdown SPTypeBreakdown) { } } -// SPSavingsByType holds Savings Plan savings categorized by plan type +// SPSavingsByType holds Savings Plan savings categorized by plan type. type SPSavingsByType struct { EC2SPSavings float64 ComputeSPSavings float64 DatabaseSPSavings float64 } -// collectSPSavings collects Savings Plan savings by type +// collectSPSavings collects Savings Plan savings by type. func collectSPSavings(recommendations []common.Recommendation) SPSavingsByType { savings := SPSavingsByType{} @@ -117,13 +117,13 @@ func collectSPSavings(recommendations []common.Recommendation) SPSavingsByType { return savings } -// RISavingsByService holds Reserved Instance savings categorized by service +// RISavingsByService holds Reserved Instance savings categorized by service. type RISavingsByService struct { EC2RISavings float64 DBRISavings float64 } -// collectRISavings collects Reserved Instance savings by service +// collectRISavings collects Reserved Instance savings by service. func collectRISavings(riStats map[common.ServiceType]ServiceProcessingStats) RISavingsByService { savings := RISavingsByService{} @@ -143,7 +143,7 @@ func collectRISavings(riStats map[common.ServiceType]ServiceProcessingStats) RIS return savings } -// ComparisonOptions holds the calculated savings for different purchasing options +// ComparisonOptions holds the calculated savings for different purchasing options. type ComparisonOptions struct { Option1Savings float64 Option2Savings float64 @@ -153,7 +153,7 @@ type ComparisonOptions struct { HasDatabaseSP bool } -// calculateComparisonOptions calculates savings for all comparison options +// calculateComparisonOptions calculates savings for all comparison options. func calculateComparisonOptions(riSavings float64, spSavings SPSavingsByType, risByService RISavingsByService) ComparisonOptions { opts := ComparisonOptions{ Option1Savings: riSavings, @@ -180,7 +180,7 @@ func calculateComparisonOptions(riSavings float64, spSavings SPSavingsByType, ri return opts } -// printComparisonOptions prints all comparison options +// printComparisonOptions prints all comparison options. func printComparisonOptions(opts ComparisonOptions) { // Option 1: All RIs AppLogger.Printf("Option 1 (All RIs):\n") @@ -203,7 +203,7 @@ func printComparisonOptions(opts ComparisonOptions) { } } -// determineBestOption determines and prints the best purchasing option +// determineBestOption determines and prints the best purchasing option. func determineBestOption(opts ComparisonOptions) { if !opts.HasDatabaseSP { // Only 2 options available diff --git a/cmd/multi_service_stats_test.go b/cmd/multi_service_stats_test.go index f654e9cad..c93782e75 100644 --- a/cmd/multi_service_stats_test.go +++ b/cmd/multi_service_stats_test.go @@ -13,7 +13,7 @@ import ( ) // captureAppOutput captures output from AppLogger and returns the captured string. -// Usage: output := captureAppOutput(t, func() { printSomething() }) +// Usage: output := captureAppOutput(t, func() { printSomething() }). func captureAppOutput(t *testing.T, fn func()) string { t.Helper() old := os.Stdout diff --git a/cmd/multi_service_test.go b/cmd/multi_service_test.go index fc28c92e7..bc189da9e 100644 --- a/cmd/multi_service_test.go +++ b/cmd/multi_service_test.go @@ -195,7 +195,7 @@ func TestProcessService_EdgeCases(t *testing.T) { } } -// TestProcessServiceWithMocks tests the processService function using mocks +// TestProcessServiceWithMocks tests the processService function using mocks. func TestProcessServiceWithMocks(t *testing.T) { ctx := context.Background() awsCfg := aws.Config{Region: "us-east-1"} @@ -746,7 +746,7 @@ func generateCSVFilenameTestHelper(service common.ServiceType, payment string, t return serviceStr + "-" + payment + "-" + mode + ".csv" } -// Test types +// Test types. type MultiServiceConfig struct { Services map[common.ServiceType]ServiceConfig PaymentOption string @@ -1740,7 +1740,7 @@ rds,us-east-1,mysql,db.t3.medium,All Upfront,12,5,123456789012 // ==================== Tests for adjustRecommendationForExcludedVersions ==================== -// Helper to create test version info with extended support dates +// Helper to create test version info with extended support dates. func createTestVersionInfo() map[string]MajorEngineVersionInfo { now := time.Now() pastDate := now.AddDate(0, -6, 0) // 6 months ago diff --git a/cmd/multi_service_test_common_test.go b/cmd/multi_service_test_common_test.go index cdf65a9ad..be035bf49 100644 --- a/cmd/multi_service_test_common_test.go +++ b/cmd/multi_service_test_common_test.go @@ -13,7 +13,7 @@ import ( // ==================== Mock Implementations ==================== -// MockEC2Client for testing getAllAWSRegions +// MockEC2Client for testing getAllAWSRegions. type MockEC2Client struct { mock.Mock } @@ -26,7 +26,7 @@ func (m *MockEC2Client) DescribeRegions(ctx context.Context, params *ec2.Describ return args.Get(0).(*ec2.DescribeRegionsOutput), args.Error(1) } -// MockRecommendationsClient for testing +// MockRecommendationsClient for testing. type MockRecommendationsClient struct { mock.Mock } @@ -55,7 +55,7 @@ func (m *MockRecommendationsClient) GetAllRecommendations(ctx context.Context) ( return args.Get(0).([]common.Recommendation), args.Error(1) } -// MockServiceClient implements provider.ServiceClient for testing +// MockServiceClient implements provider.ServiceClient for testing. type MockServiceClient struct { mock.Mock } @@ -114,29 +114,29 @@ func (m *MockServiceClient) GetValidResourceTypes(ctx context.Context) ([]string // ==================== Test Helpers ==================== -// globalVarsSnapshot captures the toolCfg for tests +// globalVarsSnapshot captures the toolCfg for tests. type globalVarsSnapshot struct { cfg Config } -// saveGlobalVars captures current toolCfg state +// saveGlobalVars captures current toolCfg state. func saveGlobalVars() *globalVarsSnapshot { return &globalVarsSnapshot{ cfg: toolCfg, } } -// restoreGlobalVars restores toolCfg state from snapshot +// restoreGlobalVars restores toolCfg state from snapshot. func (s *globalVarsSnapshot) restore() { toolCfg = s.cfg } -// OrganizationsClientAPI is an interface for organizations client operations +// OrganizationsClientAPI is an interface for organizations client operations. type OrganizationsClientAPI interface { DescribeAccount(ctx context.Context, params *organizations.DescribeAccountInput, optFns ...func(*organizations.Options)) (*organizations.DescribeAccountOutput, error) } -// MockOrganizationsClient for testing account alias cache +// MockOrganizationsClient for testing account alias cache. type MockOrganizationsClient struct { mock.Mock } @@ -149,14 +149,14 @@ func (m *MockOrganizationsClient) DescribeAccount(ctx context.Context, params *o return args.Get(0).(*organizations.DescribeAccountOutput), args.Error(1) } -// TestAccountAliasCache is a test-friendly version of AccountAliasCache +// TestAccountAliasCache is a test-friendly version of AccountAliasCache. type TestAccountAliasCache struct { mu sync.RWMutex cache map[string]string orgClient OrganizationsClientAPI } -// GetAccountAlias returns the account alias for an account ID (same logic as production) +// GetAccountAlias returns the account alias for an account ID (same logic as production). func (c *TestAccountAliasCache) GetAccountAlias(ctx context.Context, accountID string) string { if accountID == "" { return "" diff --git a/cmd/secrets_store.go b/cmd/secrets_store.go index 5f02e173f..ff64630e3 100644 --- a/cmd/secrets_store.go +++ b/cmd/secrets_store.go @@ -8,7 +8,7 @@ import ( secretsmgrtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" ) -// SecretsStore interface for storing credentials +// SecretsStore interface for storing credentials. type SecretsStore interface { // ListSecrets returns a list of secret ARNs matching the filter ListSecrets(ctx context.Context, filter string) ([]string, error) @@ -16,19 +16,19 @@ type SecretsStore interface { UpdateSecret(ctx context.Context, secretID string, secretValue string) error } -// AWSSecretsStore implements SecretsStore using AWS Secrets Manager +// AWSSecretsStore implements SecretsStore using AWS Secrets Manager. type AWSSecretsStore struct { client *secretsmanager.Client } -// NewAWSSecretsStore creates a new AWS Secrets Manager store +// NewAWSSecretsStore creates a new AWS Secrets Manager store. func NewAWSSecretsStore(client *secretsmanager.Client) *AWSSecretsStore { return &AWSSecretsStore{ client: client, } } -// ListSecrets lists secrets matching the filter (by name) +// ListSecrets lists secrets matching the filter (by name). func (s *AWSSecretsStore) ListSecrets(ctx context.Context, filter string) ([]string, error) { input := &secretsmanager.ListSecretsInput{ Filters: []secretsmgrtypes.Filter{ @@ -54,7 +54,7 @@ func (s *AWSSecretsStore) ListSecrets(ctx context.Context, filter string) ([]str return arns, nil } -// UpdateSecret updates a secret with the given value +// UpdateSecret updates a secret with the given value. func (s *AWSSecretsStore) UpdateSecret(ctx context.Context, secretID string, secretValue string) error { input := &secretsmanager.UpdateSecretInput{ SecretId: aws.String(secretID), diff --git a/cmd/server/main.go b/cmd/server/main.go index a15b8ef3b..6b41579c0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -100,7 +100,7 @@ func getTaskTimeout() time.Duration { return defaultTimeout } -// determineRuntimeMode determines the runtime mode based on flags and environment +// determineRuntimeMode determines the runtime mode based on flags and environment. func determineRuntimeMode(modeFlag string) string { // If mode is explicitly set, use it if modeFlag != "auto" { diff --git a/cmd/validators.go b/cmd/validators.go index ff1600f90..9277e6836 100644 --- a/cmd/validators.go +++ b/cmd/validators.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/cobra" ) -// validateFlags performs validation on command line flags before execution +// validateFlags performs validation on command line flags before execution. func validateFlags(cmd *cobra.Command, args []string) error { if err := validateNumericRanges(cmd); err != nil { return err @@ -92,7 +92,7 @@ func validateTargetCoverage(cmd *cobra.Command) error { return nil } -// validatePaymentAndTerm validates payment options and term configuration +// validatePaymentAndTerm validates payment options and term configuration. func validatePaymentAndTerm() error { // Validate payment option validPaymentOptions := map[string]bool{ @@ -113,7 +113,7 @@ func validatePaymentAndTerm() error { return warnRDS3YearNoUpfront() } -// warnRDS3YearNoUpfront warns if RDS service is selected with 3-year no-upfront +// warnRDS3YearNoUpfront warns if RDS service is selected with 3-year no-upfront. func warnRDS3YearNoUpfront() error { // In CSV-input mode the payment option comes from each row, not the // --payment flag (which keeps its no-upfront default), so this flag-based @@ -138,7 +138,7 @@ func warnRDS3YearNoUpfront() error { return nil } -// containsService checks if a service exists in the slice +// containsService checks if a service exists in the slice. func containsService(services []common.ServiceType, service common.ServiceType) bool { for _, svc := range services { if svc == service { @@ -148,7 +148,7 @@ func containsService(services []common.ServiceType, service common.ServiceType) return false } -// validateFilePaths validates CSV input/output paths +// validateFilePaths validates CSV input/output paths. func validateFilePaths() error { // Validate CSV output path if provided if toolCfg.CSVOutput != "" { @@ -173,7 +173,7 @@ func validateFilePaths() error { return nil } -// validateFilterFlags validates filter configuration flags +// validateFilterFlags validates filter configuration flags. func validateFilterFlags() error { // Check for region conflicts if err := validateNoConflicts(toolCfg.IncludeRegions, toolCfg.ExcludeRegions, "region"); err != nil { @@ -201,7 +201,7 @@ func validateFilterFlags() error { return nil } -// validateNoConflicts checks that include and exclude lists don't overlap +// validateNoConflicts checks that include and exclude lists don't overlap. func validateNoConflicts(include, exclude []string, itemType string) error { if len(include) == 0 || len(exclude) == 0 { return nil @@ -218,7 +218,7 @@ func validateNoConflicts(include, exclude []string, itemType string) error { return nil } -// validateInstanceTypes performs basic validation on instance type names +// validateInstanceTypes performs basic validation on instance type names. func validateInstanceTypes(instanceTypes []string) error { if len(instanceTypes) == 0 { return nil diff --git a/internal/accounts/org_discovery_extra_test.go b/internal/accounts/org_discovery_extra_test.go index 088262140..200076059 100644 --- a/internal/accounts/org_discovery_extra_test.go +++ b/internal/accounts/org_discovery_extra_test.go @@ -11,16 +11,16 @@ import ( // TestDiscoverOrgAccounts_DelegatesToDiscoverWithClient validates the public // wrapper by constructing a real aws.Config that lacks valid credentials. // The wrapper simply calls discoverWithClient, so when we reach the -// organisations.NewFromConfig step and then try to list accounts, it will +// organizations.NewFromConfig step and then try to list accounts, it will // attempt the call with no credentials. // // Because the test environment has no AWS credentials (and -short is set) // we only verify that the function signature compiles and returns a non-nil -// error (or result) — the actual behaviour is tested in discoverWithClient +// error (or result) — the actual behavior is tested in discoverWithClient // unit tests above. We do this without a network call by passing an empty // aws.Config so the SDK creates a client that will fail immediately on use. // -// We call DiscoverOrgAccounts with a cancelled context so the network dial +// We call DiscoverOrgAccounts with a canceled context so the network dial // is suppressed and the error is deterministic. func TestDiscoverOrgAccounts_CancelledContext(t *testing.T) { if testing.Short() { @@ -33,7 +33,7 @@ func TestDiscoverOrgAccounts_CancelledContext(t *testing.T) { cfg := aws.Config{Region: "us-east-1"} // no credentials result, err := DiscoverOrgAccounts(ctx, cfg) - // With a cancelled context the SDK should return an error via the + // With a canceled context the SDK should return an error via the // paginator's first NextPage call; DiscoverOrgAccounts should wrap it. if err == nil && result != nil { // Acceptable if the SDK returns early-empty rather than an error diff --git a/internal/analytics/collector.go b/internal/analytics/collector.go index aaaff5f1f..6f2a5d684 100644 --- a/internal/analytics/collector.go +++ b/internal/analytics/collector.go @@ -114,9 +114,9 @@ func (c *Collector) Collect(ctx context.Context) error { if err := c.store.BulkInsertSnapshots(ctx, snapshots); err != nil { // Surface context cancellation distinctly so the caller doesn't retry a - // genuinely cancelled run as a transient failure. + // genuinely canceled run as a transient failure. if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return fmt.Errorf("collection cancelled during write: %w", err) + return fmt.Errorf("collection canceled during write: %w", err) } return fmt.Errorf("failed to save snapshots: %w", err) } @@ -135,7 +135,7 @@ func aggregatePurchases(ctx context.Context, purchases []config.PurchaseHistoryR for _, p := range purchases { if err := ctx.Err(); err != nil { - return nil, 0, 0, fmt.Errorf("collection cancelled after %d rows: %w", activePurchases, err) + return nil, 0, 0, fmt.Errorf("collection canceled after %d rows: %w", activePurchases, err) } // H1: a Term <= 0 row would make the amortized-commitment division diff --git a/internal/analytics/collector_test.go b/internal/analytics/collector_test.go index 05ae41645..5c7019925 100644 --- a/internal/analytics/collector_test.go +++ b/internal/analytics/collector_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -// mockAnalyticsStore implements AnalyticsStore for testing +// mockAnalyticsStore implements AnalyticsStore for testing. type mockAnalyticsStore struct { saveSnapshotFunc func(ctx context.Context, snapshot *SavingsSnapshot) error bulkInsertSnapshotsFunc func(ctx context.Context, snapshots []SavingsSnapshot) error @@ -117,7 +117,7 @@ func (m *mockAnalyticsStore) Close() error { return nil } -// mockConfigStore implements config.StoreInterface for testing +// mockConfigStore implements config.StoreInterface for testing. type mockConfigStore struct { getPurchaseHistoryFunc func(ctx context.Context, accountID string, limit int) ([]config.PurchaseHistoryRecord, error) getAllPurchaseHistoryFunc func(ctx context.Context, limit int) ([]config.PurchaseHistoryRecord, error) @@ -446,7 +446,7 @@ func newTestCollector(t *testing.T, store *mockAnalyticsStore, cfgStore *mockCon return collector } -// TestNewCollector tests the NewCollector function +// TestNewCollector tests the NewCollector function. func TestNewCollector(t *testing.T) { t.Run("returns error when analytics store is nil", func(t *testing.T) { collector, err := NewCollector(CollectorConfig{AnalyticsStore: nil}, &mockConfigStore{}) @@ -469,7 +469,7 @@ func TestNewCollector(t *testing.T) { }) } -// TestCollectorCollect tests the Collect method +// TestCollectorCollect tests the Collect method. func TestCollectorCollect(t *testing.T) { t.Run("returns error when GetAllPurchaseHistory fails", func(t *testing.T) { store := &mockAnalyticsStore{} @@ -698,11 +698,11 @@ func TestCollectorCollect(t *testing.T) { err := newTestCollector(t, store, cfgStore).Collect(ctx) require.Error(t, err) assert.ErrorIs(t, err, context.Canceled) - assert.Empty(t, store.savedSnapshots, "no snapshots written on a cancelled run") + assert.Empty(t, store.savedSnapshots, "no snapshots written on a canceled run") }) } -// TestConstants tests the exported constants +// TestConstants tests the exported constants. func TestConstants(t *testing.T) { t.Run("HoursPerYear is correct", func(t *testing.T) { assert.Equal(t, 365*24, HoursPerYear) @@ -714,7 +714,7 @@ func TestConstants(t *testing.T) { }) } -// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace) +// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace). func (m *mockConfigStore) CreateSuppression(_ context.Context, _ *config.PurchaseSuppression) error { return nil } diff --git a/internal/analytics/postgres_analytics_db_test.go b/internal/analytics/postgres_analytics_db_test.go index c76da6ca1..7a218b084 100644 --- a/internal/analytics/postgres_analytics_db_test.go +++ b/internal/analytics/postgres_analytics_db_test.go @@ -38,7 +38,7 @@ func skipIfNoDocker(t *testing.T) { } } -// getMigrationsPath returns the absolute path to migrations directory +// getMigrationsPath returns the absolute path to migrations directory. func getMigrationsPath() string { _, filename, _, _ := runtime.Caller(0) return filepath.Join(filepath.Dir(filename), "..", "database", "postgres", "migrations") diff --git a/internal/analytics/postgres_analytics_mock_test.go b/internal/analytics/postgres_analytics_mock_test.go index 97e41ffcb..ce383d23a 100644 --- a/internal/analytics/postgres_analytics_mock_test.go +++ b/internal/analytics/postgres_analytics_mock_test.go @@ -15,7 +15,7 @@ import ( // For full coverage of PostgresAnalyticsStore, see postgres_analytics_integration_test.go // which requires the 'integration' build tag and a running PostgreSQL instance. -// TestSaveSnapshotMarshalError verifies metadata marshaling error handling +// TestSaveSnapshotMarshalError verifies metadata marshaling error handling. func TestSaveSnapshotMarshalError(t *testing.T) { t.Run("invalid metadata causes marshal error", func(t *testing.T) { // Create snapshot with unmarshallable metadata @@ -68,7 +68,7 @@ func TestAccountFilterClause(t *testing.T) { }) } -// TestPartitionDateCalculation verifies partition date calculation logic +// TestPartitionDateCalculation verifies partition date calculation logic. func TestPartitionDateCalculation(t *testing.T) { t.Run("truncates to first of month", func(t *testing.T) { startDate := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) @@ -113,7 +113,7 @@ func TestPartitionDateCalculation(t *testing.T) { }) } -// TestMetadataHandling verifies metadata JSON handling +// TestMetadataHandling verifies metadata JSON handling. func TestMetadataHandling(t *testing.T) { t.Run("nil metadata produces nil bytes", func(t *testing.T) { var metadata map[string]interface{} = nil @@ -165,7 +165,7 @@ func TestMetadataHandling(t *testing.T) { }) } -// TestUUIDGeneration verifies UUID generation for snapshots +// TestUUIDGeneration verifies UUID generation for snapshots. func TestUUIDGeneration(t *testing.T) { t.Run("empty ID should trigger generation", func(t *testing.T) { snapshot := &SavingsSnapshot{ID: ""} @@ -178,7 +178,7 @@ func TestUUIDGeneration(t *testing.T) { }) } -// TestCommitmentTypeLogic verifies commitment type determination +// TestCommitmentTypeLogic verifies commitment type determination. func TestCommitmentTypeLogic(t *testing.T) { t.Run("SavingsPlans service gets SavingsPlan type", func(t *testing.T) { service := "SavingsPlans" @@ -201,7 +201,7 @@ func TestCommitmentTypeLogic(t *testing.T) { }) } -// TestBulkInsertEmptySlice verifies empty slice handling +// TestBulkInsertEmptySlice verifies empty slice handling. func TestBulkInsertEmptySlice(t *testing.T) { t.Run("empty slice returns early", func(t *testing.T) { snapshots := []SavingsSnapshot{} @@ -220,7 +220,7 @@ func TestBulkInsertEmptySlice(t *testing.T) { }) } -// TestCloseReturnsNil verifies Close behavior +// TestCloseReturnsNil verifies Close behavior. func TestCloseReturnsNil(t *testing.T) { store := NewPostgresAnalyticsStore(nil) err := store.Close() diff --git a/internal/analytics/postgres_analytics_test.go b/internal/analytics/postgres_analytics_test.go index e2f651fdc..aa3cd217b 100644 --- a/internal/analytics/postgres_analytics_test.go +++ b/internal/analytics/postgres_analytics_test.go @@ -47,7 +47,7 @@ var _ AnalyticsStore = (*testablePostgresAnalyticsStore)(nil) // Tests // ===================== -// TestNewPostgresAnalyticsStore tests the constructor +// TestNewPostgresAnalyticsStore tests the constructor. func TestNewPostgresAnalyticsStore(t *testing.T) { t.Run("creates store with database connection", func(t *testing.T) { store := NewPostgresAnalyticsStore(nil) @@ -55,7 +55,7 @@ func TestNewPostgresAnalyticsStore(t *testing.T) { }) } -// TestPostgresAnalyticsStore_Close tests the Close method +// TestPostgresAnalyticsStore_Close tests the Close method. func TestPostgresAnalyticsStore_Close(t *testing.T) { t.Run("returns nil on close", func(t *testing.T) { store := NewPostgresAnalyticsStore(nil) @@ -64,7 +64,7 @@ func TestPostgresAnalyticsStore_Close(t *testing.T) { }) } -// TestSaveSnapshot tests the SaveSnapshot method +// TestSaveSnapshot tests the SaveSnapshot method. func TestSaveSnapshot(t *testing.T) { t.Run("saves snapshot successfully", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -212,7 +212,7 @@ func TestSaveSnapshot(t *testing.T) { }) } -// TestBulkInsertSnapshots tests the BulkInsertSnapshots method +// TestBulkInsertSnapshots tests the BulkInsertSnapshots method. func TestBulkInsertSnapshots(t *testing.T) { t.Run("returns early for empty slice", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -238,7 +238,6 @@ func TestBulkInsertSnapshots(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "failed to acquire connection") }) - } // TestValidateCommitmentType directly exercises the commitment_type guard that @@ -262,7 +261,7 @@ func TestValidateCommitmentType(t *testing.T) { }) } -// TestQuerySavings tests the QuerySavings method +// TestQuerySavings tests the QuerySavings method. func TestQuerySavings(t *testing.T) { t.Run("queries savings successfully", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -494,7 +493,7 @@ func TestQuerySavings(t *testing.T) { }) } -// TestQueryMonthlyTotals tests the QueryMonthlyTotals method +// TestQueryMonthlyTotals tests the QueryMonthlyTotals method. func TestQueryMonthlyTotals(t *testing.T) { t.Run("queries monthly totals successfully", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -562,7 +561,7 @@ func TestQueryMonthlyTotals(t *testing.T) { }) } -// TestQueryByProvider tests the QueryByProvider method +// TestQueryByProvider tests the QueryByProvider method. func TestQueryByProvider(t *testing.T) { t.Run("queries by provider successfully", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -614,7 +613,7 @@ func TestQueryByProvider(t *testing.T) { }) } -// TestQueryByService tests the QueryByService method +// TestQueryByService tests the QueryByService method. func TestQueryByService(t *testing.T) { t.Run("queries by service successfully", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -666,7 +665,7 @@ func TestQueryByService(t *testing.T) { }) } -// TestCreatePartition tests the CreatePartition method +// TestCreatePartition tests the CreatePartition method. func TestCreatePartition(t *testing.T) { t.Run("creates partition successfully", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -706,7 +705,7 @@ func TestCreatePartition(t *testing.T) { }) } -// TestDropOldPartitions tests the DropOldPartitions method +// TestDropOldPartitions tests the DropOldPartitions method. func TestDropOldPartitions(t *testing.T) { t.Run("drops old partitions successfully", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -742,7 +741,7 @@ func TestDropOldPartitions(t *testing.T) { }) } -// TestCreatePartitionsForRange tests the CreatePartitionsForRange method +// TestCreatePartitionsForRange tests the CreatePartitionsForRange method. func TestCreatePartitionsForRange(t *testing.T) { t.Run("creates partitions for range successfully", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -801,7 +800,7 @@ func TestCreatePartitionsForRange(t *testing.T) { }) } -// TestRefreshMaterializedViews tests the RefreshMaterializedViews method +// TestRefreshMaterializedViews tests the RefreshMaterializedViews method. func TestRefreshMaterializedViews(t *testing.T) { t.Run("refreshes materialized views successfully", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -835,7 +834,7 @@ func TestRefreshMaterializedViews(t *testing.T) { }) } -// TestSavingsSnapshot tests the SavingsSnapshot struct +// TestSavingsSnapshot tests the SavingsSnapshot struct. func TestSavingsSnapshot(t *testing.T) { t.Run("creates snapshot with all fields", func(t *testing.T) { now := time.Now() @@ -902,7 +901,7 @@ func TestSavingsSnapshot(t *testing.T) { }) } -// TestQueryRequest tests the QueryRequest struct +// TestQueryRequest tests the QueryRequest struct. func TestQueryRequest(t *testing.T) { t.Run("creates query request with all fields", func(t *testing.T) { start := time.Now().Add(-24 * time.Hour) @@ -937,7 +936,7 @@ func TestQueryRequest(t *testing.T) { }) } -// TestMonthlySummary tests the MonthlySummary struct +// TestMonthlySummary tests the MonthlySummary struct. func TestMonthlySummary(t *testing.T) { t.Run("creates monthly summary with all fields", func(t *testing.T) { month := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) @@ -986,7 +985,7 @@ func TestMonthlySummary(t *testing.T) { }) } -// TestProviderBreakdown tests the ProviderBreakdown struct +// TestProviderBreakdown tests the ProviderBreakdown struct. func TestProviderBreakdown(t *testing.T) { t.Run("creates provider breakdown with all fields", func(t *testing.T) { breakdown := ProviderBreakdown{ @@ -1023,7 +1022,7 @@ func TestProviderBreakdown(t *testing.T) { }) } -// TestServiceBreakdown tests the ServiceBreakdown struct +// TestServiceBreakdown tests the ServiceBreakdown struct. func TestServiceBreakdown(t *testing.T) { t.Run("creates service breakdown with all fields", func(t *testing.T) { breakdown := ServiceBreakdown{ @@ -1060,7 +1059,7 @@ func TestServiceBreakdown(t *testing.T) { }) } -// TestAnalyticsStoreInterface tests that PostgresAnalyticsStore implements AnalyticsStore +// TestAnalyticsStoreInterface tests that PostgresAnalyticsStore implements AnalyticsStore. func TestAnalyticsStoreInterface(t *testing.T) { t.Run("PostgresAnalyticsStore implements AnalyticsStore interface", func(t *testing.T) { // This is a compile-time check that's already in the code, @@ -1069,7 +1068,7 @@ func TestAnalyticsStoreInterface(t *testing.T) { }) } -// TestQuerySavingsRowScanError tests scan error handling +// TestQuerySavingsRowScanError tests scan error handling. func TestQuerySavingsRowScanError(t *testing.T) { t.Run("returns error on row scan failure", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -1102,7 +1101,7 @@ func TestQuerySavingsRowScanError(t *testing.T) { }) } -// TestQueryMonthlyTotalsRowScanError tests scan error handling +// TestQueryMonthlyTotalsRowScanError tests scan error handling. func TestQueryMonthlyTotalsRowScanError(t *testing.T) { t.Run("returns error on row scan failure", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -1125,7 +1124,7 @@ func TestQueryMonthlyTotalsRowScanError(t *testing.T) { }) } -// TestQueryByProviderRowScanError tests scan error handling +// TestQueryByProviderRowScanError tests scan error handling. func TestQueryByProviderRowScanError(t *testing.T) { t.Run("returns error on row scan failure", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -1151,7 +1150,7 @@ func TestQueryByProviderRowScanError(t *testing.T) { }) } -// TestQueryByServiceRowScanError tests scan error handling +// TestQueryByServiceRowScanError tests scan error handling. func TestQueryByServiceRowScanError(t *testing.T) { t.Run("returns error on row scan failure", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -1177,7 +1176,7 @@ func TestQueryByServiceRowScanError(t *testing.T) { }) } -// TestRowsErr tests rows.Err() handling +// TestRowsErr tests rows.Err() handling. func TestRowsErr(t *testing.T) { t.Run("QuerySavings returns rows.Err()", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -1216,7 +1215,7 @@ func TestRowsErr(t *testing.T) { }) } -// TestQueryMonthlyTotalsRowsErr tests rows.Err() handling for monthly totals +// TestQueryMonthlyTotalsRowsErr tests rows.Err() handling for monthly totals. func TestQueryMonthlyTotalsRowsErr(t *testing.T) { t.Run("QueryMonthlyTotals returns rows.Err()", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -1242,7 +1241,7 @@ func TestQueryMonthlyTotalsRowsErr(t *testing.T) { }) } -// TestQueryByProviderRowsErr tests rows.Err() handling for provider query +// TestQueryByProviderRowsErr tests rows.Err() handling for provider query. func TestQueryByProviderRowsErr(t *testing.T) { t.Run("QueryByProvider returns rows.Err()", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -1269,7 +1268,7 @@ func TestQueryByProviderRowsErr(t *testing.T) { }) } -// TestQueryByServiceRowsErr tests rows.Err() handling for service query +// TestQueryByServiceRowsErr tests rows.Err() handling for service query. func TestQueryByServiceRowsErr(t *testing.T) { t.Run("QueryByService returns rows.Err()", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -1296,7 +1295,7 @@ func TestQueryByServiceRowsErr(t *testing.T) { }) } -// Test ErrNoRows handling +// Test ErrNoRows handling. func TestErrNoRowsHandling(t *testing.T) { t.Run("QuerySavings handles empty result gracefully", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -1332,7 +1331,7 @@ func TestErrNoRowsHandling(t *testing.T) { }) } -// Test interface verification for testable store +// Test interface verification for testable store. func TestTestableStoreImplementsInterface(t *testing.T) { t.Run("testablePostgresAnalyticsStore implements AnalyticsStore", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -1344,7 +1343,7 @@ func TestTestableStoreImplementsInterface(t *testing.T) { }) } -// TestClose tests the Close method for testable store +// TestClose tests the Close method for testable store. func TestClose(t *testing.T) { t.Run("testable store Close returns nil", func(t *testing.T) { mock, err := pgxmock.NewPool() @@ -1357,7 +1356,7 @@ func TestClose(t *testing.T) { }) } -// TestNoRows handling +// TestNoRows handling. func Test_NoRowsHandling(t *testing.T) { // Test that pgx.ErrNoRows is handled differently t.Run("pgx.ErrNoRows is a specific error", func(t *testing.T) { diff --git a/internal/api/db_rate_limiter.go b/internal/api/db_rate_limiter.go index 28742eb90..e209ca332 100644 --- a/internal/api/db_rate_limiter.go +++ b/internal/api/db_rate_limiter.go @@ -25,7 +25,7 @@ type DBRateLimiter struct { cleanupInterval time.Duration } -// Verify that DBRateLimiter implements RateLimiterInterface +// Verify that DBRateLimiter implements RateLimiterInterface. var _ RateLimiterInterface = (*DBRateLimiter)(nil) // dbRateLimiterScheduledCleanupInterval is the period between scheduled @@ -50,7 +50,7 @@ func NewDBRateLimiter(pool *pgxpool.Pool) *DBRateLimiter { // // This ensures that perpetually-denied keys (whose count never resets to 1) // are also evicted, preventing unbounded row growth on the rate_limits table -// under sustained abuse (02-M2). The goroutine stops when ctx is cancelled. +// under sustained abuse (02-M2). The goroutine stops when ctx is canceled. // // Call this once at server startup after creating the DBRateLimiter. The // goroutine is lightweight (one blocked timer channel) and safe to call from @@ -70,7 +70,7 @@ func (rl *DBRateLimiter) StartCleanupWorker(ctx context.Context) { }() } -// SetLimit allows customizing rate limits for specific endpoints +// SetLimit allows customizing rate limits for specific endpoints. func (rl *DBRateLimiter) SetLimit(endpoint string, config RateLimitConfig) { rl.limitsMu.Lock() defer rl.limitsMu.Unlock() @@ -91,7 +91,7 @@ func (rl *DBRateLimiter) SetLimit(endpoint string, config RateLimitConfig) { // production — see commit 9fa4170a1's sibling note in // known_issues/05_config_store_postgres.md). // -// Behaviour: each call increments `count` (or resets to 1 if the +// Behavior: each call increments `count` (or resets to 1 if the // window has expired). The returned `count` is then compared to // `config.MaxAttempts` to decide allow/deny. `count` may temporarily // drift past MaxAttempts under sustained over-limit traffic — the @@ -144,7 +144,7 @@ func (rl *DBRateLimiter) Allow(ctx context.Context, key string, endpoint string) } // maybeCleanup triggers cleanup if enough time has passed since the last cleanup -// This prevents spawning too many goroutines when under high load +// This prevents spawning too many goroutines when under high load. func (rl *DBRateLimiter) maybeCleanup() { // Quick check without lock - if cleanup is already running, skip if rl.cleanupRunning.Load() { @@ -174,7 +174,7 @@ func (rl *DBRateLimiter) maybeCleanup() { } // cleanup removes expired rate limit entries from the database -// This is called asynchronously and errors are logged but not returned +// This is called asynchronously and errors are logged but not returned. func (rl *DBRateLimiter) cleanup() { if rl.pool == nil { return @@ -197,19 +197,19 @@ func (rl *DBRateLimiter) cleanup() { } } -// AllowWithIP is a convenience method that formats the key as an IP-based key +// AllowWithIP is a convenience method that formats the key as an IP-based key. func (rl *DBRateLimiter) AllowWithIP(ctx context.Context, ip string, endpoint string) (bool, error) { key := fmt.Sprintf("IP#%s", ip) return rl.Allow(ctx, key, endpoint) } -// AllowWithEmail is a convenience method that formats the key as an email-based key +// AllowWithEmail is a convenience method that formats the key as an email-based key. func (rl *DBRateLimiter) AllowWithEmail(ctx context.Context, email string, endpoint string) (bool, error) { key := fmt.Sprintf("EMAIL#%s", email) return rl.Allow(ctx, key, endpoint) } -// AllowWithUser is a convenience method that formats the key as a user-based key +// AllowWithUser is a convenience method that formats the key as a user-based key. func (rl *DBRateLimiter) AllowWithUser(ctx context.Context, userID string, endpoint string) (bool, error) { key := fmt.Sprintf("USER#%s", userID) return rl.Allow(ctx, key, endpoint) diff --git a/internal/api/db_rate_limiter_integration_test.go b/internal/api/db_rate_limiter_integration_test.go index 48bcf9925..7565a0ff6 100644 --- a/internal/api/db_rate_limiter_integration_test.go +++ b/internal/api/db_rate_limiter_integration_test.go @@ -122,7 +122,7 @@ func TestDBRateLimiter_WindowExpiry_AtomicReset(t *testing.T) { // burst that exceeds MaxAttempts within a single window must produce // some `false` returns. With the always-increment approach we expect // MaxAttempts allows followed by deny(s); count may exceed -// MaxAttempts after the burst, which is documented behaviour. +// MaxAttempts after the burst, which is documented behavior. func TestDBRateLimiter_ExceedsLimitDenies(t *testing.T) { ctx := context.Background() pool, cleanup := setupRateLimitIntegration(ctx, t) diff --git a/internal/api/exchange_lookup.go b/internal/api/exchange_lookup.go index 367180e81..9b7705236 100644 --- a/internal/api/exchange_lookup.go +++ b/internal/api/exchange_lookup.go @@ -29,7 +29,7 @@ type recsLister interface { // - CurrencyCode = currencyCode (propagated; recs don't carry it) // // Where termMonths = rec.Term × 12 (rec.Term is in years, AWS standard -// for RIs / Savings Plans). Term ≤ 0 means we can't amortise upfront, +// for RIs / Savings Plans). Term ≤ 0 means we can't amortize upfront, // so we fall back to MonthlyCost alone — the dollar-units check will // then accept or reject based on monthly recurring vs. source. // @@ -88,7 +88,7 @@ func recommendationToOffering(rec config.RecommendationRecord, currencyCode stri } monthly := monthlyCost / count if rec.Term > 0 { - // rec.Term is in years; canonical AWS RI/SP amortisation uses + // rec.Term is in years; canonical AWS RI/SP amortization uses // 12 months per year regardless of leap years. termMonths := float64(rec.Term * 12) if termMonths > 0 { diff --git a/internal/api/exchange_lookup_test.go b/internal/api/exchange_lookup_test.go index 6a6bd7cc3..7cf94ab03 100644 --- a/internal/api/exchange_lookup_test.go +++ b/internal/api/exchange_lookup_test.go @@ -15,7 +15,7 @@ import ( // failingRoundTripper is an http.RoundTripper that fails every request // with a fixed error. Used to inject an STS GetCallerIdentity failure -// into a Handler's pre-seeded aws.Config without dialling the network +// into a Handler's pre-seeded aws.Config without dialing the network // or relying on real AWS credentials. type failingRoundTripper struct{ err error } @@ -127,11 +127,11 @@ func TestPurchaseRecLookupFromStore_MapsFields(t *testing.T) { Region: "us-east-1", ResourceType: "m6i.large", Term: 1, // 1 year term - UpfrontCost: 120, // 120 / 12 = 10/mo amortised + UpfrontCost: 120, // 120 / 12 = 10/mo amortized MonthlyCost: aws.Float64(20), // + 20/mo recurring = 30 }, { - // Term=0 → no upfront amortisation; effective = MonthlyCost only. + // Term=0 → no upfront amortization; effective = MonthlyCost only. ID: "rec-2", Provider: "aws", Service: "ec2", @@ -157,20 +157,20 @@ func TestPurchaseRecLookupFromStore_MapsFields(t *testing.T) { assert.Equal(t, "c5.xlarge", got[1].InstanceType) assert.InDelta(t, 50.0, got[1].EffectiveMonthlyCost, 0.001, - "Term==0 means upfront cannot be amortised; fall back to MonthlyCost") + "Term==0 means upfront cannot be amortized; fall back to MonthlyCost") assert.InDelta(t, 8.0, got[1].NormalizationFactor, 0.001, "xlarge → NF 8") // Term plumbing: 1y rec → 31_536_000 seconds (AWS canonical RI // duration); Term==0 → TermSeconds==0 (the reshape term-match guard // then falls back to "skip the gate" rather than blocking the rec). assert.Equal(t, int64(365*24*60*60), got[0].TermSeconds, - "1-year rec must serialise to 31_536_000s for the term-match guard") + "1-year rec must serialize to 31_536_000s for the term-match guard") assert.Equal(t, int64(0), got[1].TermSeconds, - "Term==0 rec must not synthesise a fake duration — TermSeconds stays zero") + "Term==0 rec must not synthesize a fake duration — TermSeconds stays zero") } // TestPurchaseRecLookupFromStore_ThreeYearTerm pins the multi-year -// path: rec.Term=3 must serialise to exactly 3 × 31_536_000s so the +// path: rec.Term=3 must serialize to exactly 3 × 31_536_000s so the // reshape term-match guard treats it as 3y rather than rounding it // onto a 1y surface. func TestPurchaseRecLookupFromStore_ThreeYearTerm(t *testing.T) { @@ -188,12 +188,12 @@ func TestPurchaseRecLookupFromStore_ThreeYearTerm(t *testing.T) { require.NoError(t, err) require.Len(t, got, 1) assert.Equal(t, int64(3*365*24*60*60), got[0].TermSeconds, - "3-year rec must serialise to 3 × 31_536_000s for the term-match guard") + "3-year rec must serialize to 3 × 31_536_000s for the term-match guard") } // TestPurchaseRecLookupFromStore_NilMonthlyCost pins the nil-path: when // MonthlyCost is nil (provider didn't expose a monthly breakdown) the lookup -// must not panic and must compute EffectiveMonthlyCost from amortised upfront +// must not panic and must compute EffectiveMonthlyCost from amortized upfront // alone. func TestPurchaseRecLookupFromStore_NilMonthlyCost(t *testing.T) { t.Parallel() @@ -205,7 +205,7 @@ func TestPurchaseRecLookupFromStore_NilMonthlyCost(t *testing.T) { Service: "ec2", Region: "us-east-1", ResourceType: "m5.large", - Term: 1, // 1 year: 120 / 12 = 10/mo amortised + Term: 1, // 1 year: 120 / 12 = 10/mo amortized UpfrontCost: 120, MonthlyCost: nil, // provider API didn't return monthly breakdown }, @@ -215,7 +215,7 @@ func TestPurchaseRecLookupFromStore_NilMonthlyCost(t *testing.T) { got, err := lookup(context.Background(), "us-east-1", "USD") require.NoError(t, err) require.Len(t, got, 1) - // No recurring monthly charge (nil → 0), so effective cost = amortised upfront only. + // No recurring monthly charge (nil → 0), so effective cost = amortized upfront only. assert.InDelta(t, 10.0, got[0].EffectiveMonthlyCost, 0.001, "nil MonthlyCost + 120 upfront / 12mo = 10/mo effective") } diff --git a/internal/api/handler_accounts.go b/internal/api/handler_accounts.go index 4161357b1..a6267a8b6 100644 --- a/internal/api/handler_accounts.go +++ b/internal/api/handler_accounts.go @@ -443,7 +443,7 @@ const ( ) // validateAWSExternalID enforces the issue #128 backend invariants: -// - non-empty (defence-in-depth: the frontend always populates this, +// - non-empty (defense-in-depth: the frontend always populates this, // but a hostile or buggy client posting "" would make AssumeRole // bypass the sts:ExternalId condition entirely if the customer's // trust policy lacks the StringEquals constraint). @@ -618,14 +618,14 @@ func (h *Handler) deleteAccount(ctx context.Context, req *events.LambdaFunctionU // the raw FK error from the eventual DB delete. The list payload // is omitted; the frontend falls back to a generic message. return nil, NewClientErrorWithDetails(409, - fmt.Sprintf("cannot delete account: %d pending purchase(s) must be cancelled first", pendingCount), + fmt.Sprintf("cannot delete account: %d pending purchase(s) must be canceled first", pendingCount), map[string]any{ "pending_count": pendingCount, "reason": "pending_executions", }) } return nil, NewClientErrorWithDetails(409, - fmt.Sprintf("cannot delete account: %d pending purchase(s) must be cancelled first", pendingCount), + fmt.Sprintf("cannot delete account: %d pending purchase(s) must be canceled first", pendingCount), map[string]any{ "pending_count": pendingCount, "pending_execution_ids": execIDs, @@ -644,7 +644,7 @@ func (h *Handler) deleteAccount(ctx context.Context, req *events.LambdaFunctionU var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23503" { return nil, NewClientErrorWithDetails(409, - "cannot delete account: pending purchase(s) must be cancelled first", + "cannot delete account: pending purchase(s) must be canceled first", map[string]any{ "reason": "pending_executions", }) @@ -1074,7 +1074,7 @@ func (h *Handler) saveAccountServiceOverride(ctx context.Context, httpReq *event override := buildServiceOverride(accountID, provider, service, req, existing, now) - // Defence-in-depth: reject invalid (term, payment) combos before persisting. + // Defense-in-depth: reject invalid (term, payment) combos before persisting. // checkCommitmentOptionCombo is permissive when commitmentOpts is nil or // probe data is absent (ErrNoData) — the frontend's hardcoded rules are the // primary gate in those cases. diff --git a/internal/api/handler_accounts_external_id_test.go b/internal/api/handler_accounts_external_id_test.go index a029369c5..b7338ea3f 100644 --- a/internal/api/handler_accounts_external_id_test.go +++ b/internal/api/handler_accounts_external_id_test.go @@ -12,7 +12,7 @@ import ( // TestValidateAWSExternalID covers the issue #128 backend validation // invariants for the AWS sts:ExternalId field on the role_arn auth mode. // The frontend always populates this field (issue #18 / PR #36) but the -// backend is the source of truth — defence-in-depth requires that empty +// backend is the source of truth — defense-in-depth requires that empty // / out-of-range / disallowed-charset values are rejected with 400s on // both create and update. func TestValidateAWSExternalID(t *testing.T) { @@ -246,7 +246,7 @@ func TestCreateAccount_AWSExternalID_BastionNoRoleArnExempt(t *testing.T) { // TestParseArnPartition covers the helper that extracts the partition // segment from an STS GetCallerIdentity ARN (issue #130c). The result // is interpolated into the IAM trust-policy snippet, so any failure to -// recognise a known partition must fall back to "" (which the frontend +// recognize a known partition must fall back to "" (which the frontend // then defaults to "aws"). func TestParseArnPartition(t *testing.T) { cases := []struct { diff --git a/internal/api/handler_accounts_router_test.go b/internal/api/handler_accounts_router_test.go index 380723f31..e297a5df0 100644 --- a/internal/api/handler_accounts_router_test.go +++ b/internal/api/handler_accounts_router_test.go @@ -93,7 +93,7 @@ func TestRouterDispatch_DeleteAccount_RoutesCorrectly(t *testing.T) { // GET /api/accounts/list reaches listAccountsMinimal and is NOT swallowed by // the generic "/api/accounts/" GET prefix route (getAccount), which would treat // "list" as a :id and reject it with a 400 invalid-UUID error. A successful -// (non-error) result proves the more-specific exact-path route won. (#949/#951) +// (non-error) result proves the more-specific exact-path route won. (#949/#951). func TestRouterDispatch_AccountsList_RoutesToMinimalHandler(t *testing.T) { ctx := context.Background() r := setupRouterForDispatch(ctx) diff --git a/internal/api/handler_accounts_test.go b/internal/api/handler_accounts_test.go index 7c2fe5d88..8220869d1 100644 --- a/internal/api/handler_accounts_test.go +++ b/internal/api/handler_accounts_test.go @@ -163,7 +163,7 @@ func TestListAccountsMinimal_StandardUserAllowed_NoSensitiveFields(t *testing.T) assert.Equal(t, full.ExternalID, got[0].ExternalID) assert.Equal(t, full.Provider, got[0].Provider) - // Defence-in-depth: the JSON-serialized summary must not leak any sensitive + // Defense-in-depth: the JSON-serialized summary must not leak any sensitive // field, even by accident (e.g. a future struct-embedding refactor). blob, marshalErr := json.Marshal(got) require.NoError(t, marshalErr) @@ -1198,7 +1198,7 @@ func TestSetPlanAccounts_EmptyServicesSkipsValidation(t *testing.T) { store := setupAdminMock(ctx) // Plan with an empty services map — derived provider set is empty; // the validation block skips and the assignment passes through. - // Pins the defensive behaviour so a future change is conscious. + // Pins the defensive behavior so a future change is conscious. store.GetPurchasePlanFn = func(_ context.Context, _ string) (*config.PurchasePlan, error) { return &config.PurchasePlan{ID: planID209, Name: "no-services"}, nil } @@ -1895,7 +1895,7 @@ func TestUpdateAccount_DuplicateKey_Returns409(t *testing.T) { assert.Contains(t, ce.Error(), "already exists") } -// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace) +// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace). func (m *mockConfigStoreAccounts) CreateSuppression(_ context.Context, _ *config.PurchaseSuppression) error { return nil } diff --git a/internal/api/handler_analytics.go b/internal/api/handler_analytics.go index 514397de9..cf0d9ed96 100644 --- a/internal/api/handler_analytics.go +++ b/internal/api/handler_analytics.go @@ -108,7 +108,7 @@ type BreakdownResponse struct { Data map[string]BreakdownValue `json:"data"` } -// getHistoryAnalytics handles GET /history/analytics +// getHistoryAnalytics handles GET /history/analytics. func (h *Handler) getHistoryAnalytics(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { // Analytics aggregate across purchase history — gate on view:purchases and // scope by allowed_accounts. @@ -119,7 +119,7 @@ func (h *Handler) getHistoryAnalytics(ctx context.Context, req *events.LambdaFun // Analytics is Postgres-backed (api.PostgresAnalyticsClient) and wired // in server.Application.reinitializeAfterConnect, so analyticsClient - // is non-nil whenever the DB is up. The guard stays as defence-in-depth + // is non-nil whenever the DB is up. The guard stays as defense-in-depth // for test builds and misconfigured callers — the frontend treats 503 // as "feature intentionally unavailable" and renders the corresponding // empty-state instead of a generic error. @@ -180,7 +180,7 @@ func (h *Handler) getHistoryAnalytics(ctx context.Context, req *events.LambdaFun }, nil } -// getHistoryBreakdown handles GET /history/breakdown +// getHistoryBreakdown handles GET /history/breakdown. func (h *Handler) getHistoryBreakdown(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { session, err := h.requirePermission(ctx, req, "view", "purchases") if err != nil { diff --git a/internal/api/handler_analytics_test.go b/internal/api/handler_analytics_test.go index 9622b55dc..17106906c 100644 --- a/internal/api/handler_analytics_test.go +++ b/internal/api/handler_analytics_test.go @@ -14,7 +14,7 @@ import ( "github.com/stretchr/testify/require" ) -// MockAnalyticsClient is a mock implementation of AnalyticsClientInterface +// MockAnalyticsClient is a mock implementation of AnalyticsClientInterface. type MockAnalyticsClient struct { mock.Mock } @@ -66,7 +66,7 @@ func TestHandler_getHistoryAnalytics_ExternalIDOnlyAccount(t *testing.T) { mockClient.AssertCalled(t, "QueryHistory", ctx, []string{accountUUID}, map[string][]string{"aws": {accountExternal}}, "", mock.Anything, mock.Anything, "hourly") } -// MockAnalyticsCollector is a mock implementation of AnalyticsCollectorInterface +// MockAnalyticsCollector is a mock implementation of AnalyticsCollectorInterface. type MockAnalyticsCollector struct { mock.Mock } @@ -78,7 +78,7 @@ func (m *MockAnalyticsCollector) Collect(ctx context.Context) error { // adminAnalyticsReq returns (mocked auth with admin session, request with admin token). // All analytics handlers are permission-gated — this short-circuits the gate so the -// existing tests can exercise the analytics-specific behaviour without rewriting auth. +// existing tests can exercise the analytics-specific behavior without rewriting auth. func adminAnalyticsReq(ctx context.Context) (*MockAuthService, *events.LambdaFunctionURLRequest) { mockAuth := new(MockAuthService) mockAuth.On("ValidateSession", ctx, "admin-token").Return(&Session{ diff --git a/internal/api/handler_apikeys.go b/internal/api/handler_apikeys.go index a3f3695db..7116d1101 100644 --- a/internal/api/handler_apikeys.go +++ b/internal/api/handler_apikeys.go @@ -20,7 +20,7 @@ import ( // to the calling user. There is no separate "revoke" action in the permission // model, so revoke reuses "delete". -// listAPIKeys handles GET /api/api-keys +// listAPIKeys handles GET /api/api-keys. func (h *Handler) listAPIKeys(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) { session, err := h.requirePermission(ctx, req, "view", "api-keys") if err != nil { @@ -35,7 +35,7 @@ func (h *Handler) listAPIKeys(ctx context.Context, req *events.LambdaFunctionURL return keys, nil } -// createAPIKey handles POST /api/api-keys +// createAPIKey handles POST /api/api-keys. func (h *Handler) createAPIKey(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) { session, err := h.requirePermission(ctx, req, "create", "api-keys") if err != nil { @@ -69,7 +69,7 @@ func (h *Handler) createAPIKey(ctx context.Context, req *events.LambdaFunctionUR return result, nil } -// deleteAPIKey handles DELETE /api/api-keys/{id} +// deleteAPIKey handles DELETE /api/api-keys/{id}. func (h *Handler) deleteAPIKey(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) { session, err := h.requirePermission(ctx, req, "delete", "api-keys") if err != nil { @@ -88,7 +88,7 @@ func (h *Handler) deleteAPIKey(ctx context.Context, req *events.LambdaFunctionUR return map[string]string{"status": "deleted"}, nil } -// revokeAPIKey handles POST /api/api-keys/{id}/revoke +// revokeAPIKey handles POST /api/api-keys/{id}/revoke. func (h *Handler) revokeAPIKey(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) { // No "revoke" verb in the permission model — reuse "delete". session, err := h.requirePermission(ctx, req, "delete", "api-keys") @@ -130,7 +130,7 @@ func apiKeyIDFromPath(path string, hasTrailingAction bool) (string, error) { return keyID, nil } -// Helper function to format time pointer as string +// Helper function to format time pointer as string. func formatTimePtr(t *time.Time) string { if t == nil { return "" diff --git a/internal/api/handler_apikeys_test.go b/internal/api/handler_apikeys_test.go index 6b7a2bf4f..766821503 100644 --- a/internal/api/handler_apikeys_test.go +++ b/internal/api/handler_apikeys_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" ) -// MockRateLimiter is a mock implementation of RateLimiterInterface +// MockRateLimiter is a mock implementation of RateLimiterInterface. type MockRateLimiter struct { mock.Mock } diff --git a/internal/api/handler_auth.go b/internal/api/handler_auth.go index 75300c1ae..8526b8c70 100644 --- a/internal/api/handler_auth.go +++ b/internal/api/handler_auth.go @@ -43,7 +43,7 @@ func (h *Handler) login(ctx context.Context, req *events.LambdaFunctionURLReques // frontend can detect "MFA required" / "invalid MFA code" // without substring-matching the human message (issue #497). // Both keep the 401 status — the password leg passed, but - // the request is not authorised until the MFA leg is too. + // the request is not authorized until the MFA leg is too. if errors.Is(err, auth.ErrMFARequired) { return nil, NewClientError(401, "mfa_required") } @@ -461,7 +461,7 @@ func decodeChangePasswordRequest(pwdReq ChangePasswordRequest) (current, next st return current, next, nil } -// changePassword handles POST /api/auth/change-password +// changePassword handles POST /api/auth/change-password. func (h *Handler) changePassword(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) { if h.auth == nil { return nil, fmt.Errorf("authentication service not configured") @@ -621,7 +621,7 @@ func (h *Handler) mfaRegenerateRecoveryCodes(ctx context.Context, req *events.La } // redactEmail returns a redacted version of an email address for safe logging. -// e.g. "user@example.com" -> "us***@example.com" +// e.g. "user@example.com" -> "us***@example.com". func redactEmail(email string) string { at := strings.LastIndex(email, "@") if at < 0 { diff --git a/internal/api/handler_auth_test.go b/internal/api/handler_auth_test.go index 30bf53136..8969c6a06 100644 --- a/internal/api/handler_auth_test.go +++ b/internal/api/handler_auth_test.go @@ -1436,7 +1436,7 @@ func TestHandler_getCurrentUserPermissions_UnexpectedPayload(t *testing.T) { // TestHandler_getCurrentUserPermissions_AdminAPIKey guards CR #922 F2: // the AuthUser route admits the stateless admin API key as well as bearer -// sessions, so the handler must honour an X-API-Key-authenticated request +// sessions, so the handler must honor an X-API-Key-authenticated request // instead of forcing a bearer session a second time and returning 401. func TestHandler_getCurrentUserPermissions_AdminAPIKey(t *testing.T) { ctx := context.Background() diff --git a/internal/api/handler_config.go b/internal/api/handler_config.go index 9daa5576f..157a72eeb 100644 --- a/internal/api/handler_config.go +++ b/internal/api/handler_config.go @@ -22,7 +22,7 @@ func sourceCloud() string { return "aws" } -// Configuration handlers +// Configuration handlers. func (h *Handler) getConfig(ctx context.Context, req *events.LambdaFunctionURLRequest) (*ConfigResponse, error) { // Require view:config permission. Every other read handler in the package // pairs the route-level AuthUser gate with this explicit permission check; diff --git a/internal/api/handler_coverage_test.go b/internal/api/handler_coverage_test.go index baa58b648..e2de3a9b0 100644 --- a/internal/api/handler_coverage_test.go +++ b/internal/api/handler_coverage_test.go @@ -48,7 +48,7 @@ func TestHandler_buildResponse_NilBody(t *testing.T) { resp := handler.buildResponse(200, headers, nil, nil) assert.Equal(t, 200, resp.StatusCode) - // Q1 (Phase-2 UX plan): nil-body success serialises as "{}" so the + // Q1 (Phase-2 UX plan): nil-body success serializes as "{}" so the // frontend's response.json() doesn't throw SyntaxError on DELETE and // other empty-response paths. assert.Equal(t, "{}", resp.Body) @@ -635,7 +635,7 @@ func TestRouter_Handlers_Coverage(t *testing.T) { }) } -// Test handler_plans functions +// Test handler_plans functions. func TestHandler_listPlans_Error(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -803,7 +803,7 @@ func TestToAPIPermissions_ReturnsCopy(t *testing.T) { assert.Equal(t, "read", src[0].Action, "original slice must not be mutated") } -// Test NewHandler with API key loaded +// Test NewHandler with API key loaded. func TestNewHandler_WithDependencies(t *testing.T) { mockStore := new(MockConfigStore) mockScheduler := new(MockScheduler) diff --git a/internal/api/handler_dashboard.go b/internal/api/handler_dashboard.go index 225ce356e..0f15eab25 100644 --- a/internal/api/handler_dashboard.go +++ b/internal/api/handler_dashboard.go @@ -34,7 +34,7 @@ func (h *Handler) getDashboardSummary(ctx context.Context, req *events.LambdaFun // without this the commitment KPIs (ActiveCommitments / CommittedMonthly / // CurrentCoverage / YTDSavings) would leak other accounts' data to a scoped // user. Unrestricted / admin sessions resolve to an empty scope and keep the - // all-accounts behaviour. + // all-accounts behavior. if len(accountUUIDs) == 0 && len(accountExternalIDsByProvider) == 0 { accountUUIDs, accountExternalIDsByProvider, err = h.resolveAllowedAccountScope(ctx, session) if err != nil { @@ -131,7 +131,7 @@ func (h *Handler) resolveDashboardAccountScope(ctx context.Context, params map[s // explicit filter). // // Returns (nil, nil, nil) for unrestricted / admin sessions so the caller keeps -// the all-accounts behaviour. A restricted session that matches no account +// the all-accounts behavior. A restricted session that matches no account // resolves to a non-nil-but-empty UUID set (a sentinel that selects no rows), // so a scoped user with zero accessible accounts sees zeroed KPIs rather than // everyone's data. @@ -196,7 +196,7 @@ func (h *Handler) filterDashboardRecommendations(ctx context.Context, session *S // payment) fan-out does not over-report savings; details in the function body. // // Recs without a CloudAccountID and recs whose triple has no entry in the -// map all count at full weight — this matches the pre-#196 behaviour for +// map all count at full weight — this matches the pre-#196 behavior for // un-configured accounts. Zero-coverage configs are excluded from the map // by resolveCoverageByAccountKey (issue #201) so they also fall through to // full weight rather than silently zeroing the headline. @@ -341,14 +341,14 @@ func scaledSavings(rec config.RecommendationRecord, coverageByKey map[string]flo // resolveCoverageByAccountKey returns a map of AccountConfigKey -> resolved // coverage% for every (account, provider, service) triple represented in // recs. Lookup errors degrade gracefully to a nil map (no scaling applied -// → un-overridden behaviour). +// → un-overridden behavior). // // Entries with a resolved coverage of zero are omitted from the map. // ServiceConfig.Coverage is a float64 whose zero-value means "not configured", // so including a zero entry would silently scale that account's savings to $0 // even though the operator never set an explicit coverage cap (issue #201). // When an entry is absent, scaledSavings falls through to full savings, -// matching the pre-#196 behaviour for un-configured accounts. +// matching the pre-#196 behavior for un-configured accounts. func (h *Handler) resolveCoverageByAccountKey(ctx context.Context, recs []config.RecommendationRecord) map[string]float64 { if len(recs) == 0 { return nil @@ -395,7 +395,7 @@ func (h *Handler) resolveTargetCoverage(ctx context.Context) float64 { // getPlannedPurchases (handler_purchases.go) so the dashboard widget and // the Plans page walk the same canonical "what's about to happen" set. // -// The widget previously enumerated plans and synthesised one row per plan +// The widget previously enumerated plans and synthesized one row per plan // from plan.NextExecutionDate. That was wrong because action endpoints // (DELETE /api/purchases/planned/{id}, pause, resume, run) all target // purchase_executions.execution_id, not purchase_plans.id; the Cancel @@ -566,7 +566,7 @@ func commitmentExpiry(p config.PurchaseHistoryRecord) time.Time { // isActiveCommitment reports whether the purchase is active: its term has not // yet expired as of `now` AND its status is one of the successful terminal // states ("" for DB-backed rows where the column is unpersisted, or -// "completed"). Rows synthesised from failed/cancelled/expired executions +// "completed"). Rows synthesized from failed/canceled/expired executions // carry a non-empty status other than "completed" and are excluded so they // do not inflate the committed_monthly KPI. The boundary is strict (After): // a commitment is active right up to the instant its term ends. @@ -700,7 +700,7 @@ func (h *Handler) calculateCommitmentMetrics(ctx context.Context, accountUUIDs [ return activeCommitments, committedMonthly, ytdSavings, savingsByService } -// calculateCurrentCoverage calculates the current coverage percentage +// calculateCurrentCoverage calculates the current coverage percentage. func (h *Handler) calculateCurrentCoverage(potentialSavings, committedMonthly float64) float64 { if potentialSavings == 0 { return 100.0 // No recommendations means 100% coverage diff --git a/internal/api/handler_dashboard_test.go b/internal/api/handler_dashboard_test.go index 71c401d39..c6c2872fb 100644 --- a/internal/api/handler_dashboard_test.go +++ b/internal/api/handler_dashboard_test.go @@ -14,7 +14,7 @@ import ( "github.com/stretchr/testify/require" ) -// createMockLambdaRequest creates a mock Lambda function URL request for testing +// createMockLambdaRequest creates a mock Lambda function URL request for testing. func createMockLambdaRequest(sourceIP string) *events.LambdaFunctionURLRequest { return &events.LambdaFunctionURLRequest{ RequestContext: events.LambdaFunctionURLRequestContext{ @@ -521,10 +521,10 @@ func TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID(t *testing.T) { CreatedByUserID: &creator, }, { - // Legacy / scheduler-tick row: NULL creator. Must serialise as + // Legacy / scheduler-tick row: NULL creator. Must serialize as // no created_by_user_id field (omitempty on the JSON tag) so // the frontend treats it as out-of-reach for non-update-any - // users -- the documented #950 behaviour. + // users -- the documented #950 behavior. ExecutionID: "99998888-7777-6666-5555-444433332222", PlanID: plan.ID, Status: "pending", @@ -1077,7 +1077,7 @@ func TestAggregateActiveCommitmentsPerService(t *testing.T) { }) t.Run("one failed (expired) + one succeeded stays correct", func(t *testing.T) { - // Only the active row should count — the expired row is the "failed" analogue. + // Only the active row should count — the expired row is the "failed" analog. purchases := []config.PurchaseHistoryRecord{ expired("EC2", 999.0), active("EC2", 200.0), @@ -1196,7 +1196,7 @@ func TestHandler_getDashboardSummary_CurrentSavingsJSON(t *testing.T) { // Verify through the ServiceSavings struct that the JSON tag is present and // the value round-trips correctly. We assert on the struct field because // json.Marshal / Unmarshal would be redundant — the tag is on the declared - // type and Go's encoding/json honours it. + // type and Go's encoding/json honors it. require.Contains(t, result.ByService, "EC2") assert.InDelta(t, 120.0, result.ByService["EC2"].CurrentSavings, 0.001, "current_savings field must carry the active purchase's EstimatedSavings") diff --git a/internal/api/handler_federation.go b/internal/api/handler_federation.go index f349e276c..3949d97a1 100644 --- a/internal/api/handler_federation.go +++ b/internal/api/handler_federation.go @@ -384,7 +384,7 @@ func federationIaCParams(q map[string]string) (target, source, format string, er } // shellEscape escapes a string for safe use inside a double-quoted bash argument. -// It escapes characters that have special meaning in double-quoted strings: \, $, `, " +// It escapes characters that have special meaning in double-quoted strings: \, $, `, ". func shellEscape(s string) string { r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", `$`, `\$`) return r.Replace(s) @@ -560,7 +560,7 @@ func buildAzureTemplateReadme(data federationIaCData, format string) string { sb.WriteString("ARM deployment\n") sb.WriteString("================================\n\n") } - sb.WriteString(fmt.Sprintf("Account : %s (%s)\n\n", data.AccountName, data.AccountExternalID)) + fmt.Fprintf(&sb, "Account : %s (%s)\n\n", data.AccountName, data.AccountExternalID) sb.WriteString("The deploy script creates an Azure AD App Registration with a federated\n") sb.WriteString("identity credential bound to CUDly's OIDC issuer, then deploys the role\n") sb.WriteString("assignment template. No certificate or secret is created.\n\n") @@ -693,9 +693,9 @@ func buildBundleReadme(data federationIaCData, target, source string) string { var sb strings.Builder sb.WriteString("CUDly Federation IaC Bundle\n") sb.WriteString("===========================\n\n") - sb.WriteString(fmt.Sprintf("Account : %s (%s)\n", data.AccountName, data.AccountExternalID)) - sb.WriteString(fmt.Sprintf("Target : %s\n", target)) - sb.WriteString(fmt.Sprintf("Source : %s\n\n", source)) + fmt.Fprintf(&sb, "Account : %s (%s)\n", data.AccountName, data.AccountExternalID) + fmt.Fprintf(&sb, "Target : %s\n", target) + fmt.Fprintf(&sb, "Source : %s\n\n", source) switch { case target == "aws" && source == "aws": diff --git a/internal/api/handler_groups.go b/internal/api/handler_groups.go index eb964f0df..0afd26d8e 100644 --- a/internal/api/handler_groups.go +++ b/internal/api/handler_groups.go @@ -11,7 +11,7 @@ import ( // Group management handlers -// listGroups handles GET /api/groups +// listGroups handles GET /api/groups. func (h *Handler) listGroups(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) { if _, err := h.requirePermission(ctx, req, "view", "groups"); err != nil { return nil, err @@ -25,7 +25,7 @@ func (h *Handler) listGroups(ctx context.Context, req *events.LambdaFunctionURLR return map[string]any{"groups": groups}, nil } -// createGroup handles POST /api/groups +// createGroup handles POST /api/groups. func (h *Handler) createGroup(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) { session, err := h.requirePermission(ctx, req, "create", "groups") if err != nil { @@ -55,7 +55,7 @@ func (h *Handler) createGroup(ctx context.Context, req *events.LambdaFunctionURL return group, nil } -// getGroup handles GET /api/groups/{id} +// getGroup handles GET /api/groups/{id}. func (h *Handler) getGroup(ctx context.Context, req *events.LambdaFunctionURLRequest, groupID string) (any, error) { // Validate UUID format to prevent injection attacks if err := validateUUID(groupID); err != nil { @@ -74,7 +74,7 @@ func (h *Handler) getGroup(ctx context.Context, req *events.LambdaFunctionURLReq return group, nil } -// updateGroup handles PUT /api/groups/{id} +// updateGroup handles PUT /api/groups/{id}. func (h *Handler) updateGroup(ctx context.Context, req *events.LambdaFunctionURLRequest, groupID string) (any, error) { // Validate UUID format to prevent injection attacks if err := validateUUID(groupID); err != nil { @@ -98,7 +98,7 @@ func (h *Handler) updateGroup(ctx context.Context, req *events.LambdaFunctionURL return group, nil } -// deleteGroup handles DELETE /api/groups/{id} +// deleteGroup handles DELETE /api/groups/{id}. func (h *Handler) deleteGroup(ctx context.Context, req *events.LambdaFunctionURLRequest, groupID string) (any, error) { // Validate UUID format to prevent injection attacks if err := validateUUID(groupID); err != nil { diff --git a/internal/api/handler_history.go b/internal/api/handler_history.go index fa1344d16..e2c3f79dc 100644 --- a/internal/api/handler_history.go +++ b/internal/api/handler_history.go @@ -14,7 +14,7 @@ import ( "github.com/aws/aws-lambda-go/events" ) -// History handlers +// History handlers. func (h *Handler) getHistory(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { // Purchase history can leak across accounts — gate on view:purchases AND // filter the returned records by the session's allowed_accounts list. @@ -51,7 +51,7 @@ func (h *Handler) getHistory(ctx context.Context, req *events.LambdaFunctionURLR // (purchase_executions) from the completed purchase_history rows, so we // merge after the fact. A failure to list executions must not hide // completed history — log, skip, continue. The same filter set is applied - // here (in-memory against the synthesised row's recommendations and + // here (in-memory against the synthesized row's recommendations and // scheduled_date) so the two halves of the merged response are // consistently scoped (issue #701). // @@ -95,7 +95,7 @@ func (h *Handler) getHistory(ctx context.Context, req *events.LambdaFunctionURLR // states too, rendered with a clear "in progress" badge rather than as a // (misleading) completed row. // -// "completed" is also loaded, but fetchExecutionsAsHistory synthesises a +// "completed" is also loaded, but fetchExecutionsAsHistory synthesizes a // row for it ONLY when the execution carries a non-empty Error — the // audit-gap case where the purchase succeeded but its purchase_history // write failed (issue #621 secondary path). A normal completed execution @@ -104,9 +104,9 @@ func (h *Handler) getHistory(ctx context.Context, req *events.LambdaFunctionURLR // the ExecutionID while a purchase_history row's is the CommitmentID, so // the keys never collide even when both happen to render. // -// "partially_completed" (issue #642) is loaded and ALWAYS synthesised: a +// "partially_completed" (issue #642) is loaded and ALWAYS synthesized: a // partial run committed some recs to purchase_history (those render from the -// DB rows) and failed others. The synthesised execution row carries the +// DB rows) and failed others. The synthesized execution row carries the // partial-failure marker and is flagged IsAuditGap so its execution-level // dollars are excluded from the dashboard totals — the committed dollars are // already counted via the per-rec purchase_history rows that succeeded. @@ -155,7 +155,7 @@ func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyF // Dedup: a normal completed execution is already represented by its // purchase_history rows. Skip it here so it shows exactly once. Only // completed executions carrying an audit-gap Error (history write - // failed after a successful purchase, issue #621) are synthesised — + // failed after a successful purchase, issue #621) are synthesized — // those have no purchase_history row to collide with. if exec.Status == "completed" && exec.Error == "" { continue @@ -192,7 +192,7 @@ func isStaleExecution(exec config.PurchaseExecution) bool { // expireStaleExecutionsAsync fires TransitionExecutionStatus for each stale // execution in a best-effort goroutine that outlives the request context. -// Using context.Background() ensures the transitions are not cancelled when +// Using context.Background() ensures the transitions are not canceled when // the HTTP handler returns. Errors are logged and skipped — a missed // transition leaves the row "pending" until the next History load, which is // better than blocking the read response. @@ -386,7 +386,7 @@ func annotateInFlightOrAuditGapRow(row *config.PurchaseHistoryRecord, exec confi row.StatusDescription = "purchase paused — resume or cancel from the plan" case "partially_completed": // #642: some recs committed, some failed. The committed recs are - // surfaced via their own purchase_history rows; this synthesised row + // surfaced via their own purchase_history rows; this synthesized row // is the audit flag for the failures. Flag IsAuditGap so the dashboard // excludes its execution-level dollars (the committed dollars are // counted on the per-rec purchase_history rows, not here) — same @@ -410,7 +410,7 @@ func annotateInFlightOrAuditGapRow(row *config.PurchaseHistoryRecord, exec confi } } -// annotateCancelled resolves who cancelled the execution: +// annotateCancelled resolves who canceled the execution: // 1. exec.CancelledBy — populated by the session-authed deep-link flow; // exact session-authed click attribution. // 2. approver — the notification inbox that received the cancel token; @@ -606,7 +606,7 @@ const MaxHistoryDateRangeDays = 366 // historyFilters carries the shared filter set used by both halves of the // merged /api/history response: the SQL path (purchase_history rows in -// fetchPurchaseHistory) and the in-memory path (synthesised execution rows +// fetchPurchaseHistory) and the in-memory path (synthesized execution rows // in fetchExecutionsAsHistory). Keeping them in one struct guarantees the // two halves stay scoped consistently — the bug behind issue #701 was that // the executions path ignored the filters the SQL path was supposed to apply. @@ -637,7 +637,7 @@ type historyFilters struct { // ANY) and the in-memory matchesExecution use them so a row/execution that // carries only the external id (cloud_account_id NULL) is still matched // (issue #701/#498). The "" provider key means "provider unknown" and - // matches the external id regardless of provider (legacy behaviour). + // matches the external id regardless of provider (legacy behavior). ExternalIDsByProvider map[string][]string HasDate bool Start time.Time @@ -816,7 +816,7 @@ func (f historyFilters) matchesExecution(exec config.PurchaseExecution) bool { // // The external-id match is provider-scoped: an external number matches only when // it is listed under the execution's own provider, or under the "" key (unknown -// provider, legacy behaviour). This mirrors the SQL (provider = $p AND +// provider, legacy behavior). This mirrors the SQL (provider = $p AND // account_id = ANY(...)) and keeps a reused external number across providers // (aws/123 vs azure/123) from matching the wrong execution. // @@ -928,7 +928,7 @@ func (h *Handler) fetchPurchaseHistory(ctx context.Context, filters historyFilte // is a top-bar chip UUID for current callers (resolved to UUID + external) or a // raw external number for pre-UUID callers (grouped under the "" provider key). // Best-effort: resolution failures leave the UUID-only set in place (no worse -// than the pre-fix behaviour), and the per-record allowed_accounts filter still +// than the pre-fix behavior), and the per-record allowed_accounts filter still // enforces scoping downstream. func (h *Handler) resolveHistoryAccountFilter(ctx context.Context, filters *historyFilters) { uuids, externalsByProvider := h.resolveAccountFilterIDs(ctx, filters.AccountIDs) @@ -1029,14 +1029,14 @@ func summarizePurchaseHistory(purchases []config.PurchaseHistoryRecord) HistoryS continue } summary.TotalCompleted++ - // Audit-gap completed rows (issue #621) are synthesised execution rows + // Audit-gap completed rows (issue #621) are synthesized execution rows // whose purchase_history write failed. Count them as completed (the // money WAS committed and they must stay visible) but exclude their // execution-level dollars: a partially-saved multi-rec execution can - // have BOTH some purchase_history rows AND this synthesised row, and + // have BOTH some purchase_history rows AND this synthesized row, and // adding the full execution total here would double-count the recs that // did save. The dollars are surfaced via the individual purchase_history - // rows that succeeded; the synthesised row is the audit flag, not a + // rows that succeeded; the synthesized row is the audit flag, not a // money source. IsAuditGap is the explicit marker: real purchase_history // rows loaded from the DB always leave it false, so a future change that // annotates completed DB rows can't silently drop them from the totals. diff --git a/internal/api/handler_inventory.go b/internal/api/handler_inventory.go index 8d1f99878..f9bb622f2 100644 --- a/internal/api/handler_inventory.go +++ b/internal/api/handler_inventory.go @@ -70,7 +70,7 @@ func (h *Handler) listActiveCommitments(ctx context.Context, req *events.LambdaF return InventoryCommitmentsResponse{Commitments: commitments}, nil } -// fetchCommitmentRecords reads purchase history from the store, honouring +// fetchCommitmentRecords reads purchase history from the store, honoring // optional `account_id` and `provider` query params the same way // fetchPurchaseHistory does for /api/history. Limit defaults to // MaxListLimit — commitments are a strict subset of purchase history (we @@ -149,7 +149,7 @@ func buildInventoryCommitment(p config.PurchaseHistoryRecord, accountName string // Returns per-provider, per-service coverage breakdowns computed from // two data sources already available in the system: // - Active commitments (purchase history): their effective covered -// monthly spend (recurring MonthlyCost plus amortised upfront — see +// monthly spend (recurring MonthlyCost plus amortized upfront — see // commitmentCoveredMonthly) is the "covered" portion of monthly spend. // - Recommendations (scheduler): their Savings represent the remaining // on-demand gap that could still be committed. @@ -179,7 +179,7 @@ func (h *Handler) getCoverageBreakdown(ctx context.Context, req *events.LambdaFu now := time.Now() // coveredByKey accumulates the effective covered monthly spend by // "provider:service". A commitment's covered monthly is its recurring - // MonthlyCost plus the amortised upfront, so an all-upfront commitment + // MonthlyCost plus the amortized upfront, so an all-upfront commitment // (MonthlyCost nil, UpfrontCost > 0 — typical for Azure RIs) still // registers as covered instead of being silently dropped (issue: Azure // showed $0 coverage while the dashboard reported active commitments). @@ -195,7 +195,7 @@ func (h *Handler) getCoverageBreakdown(ctx context.Context, req *events.LambdaFu // Recommendations represent uncommitted demand that could be purchased. // Their Savings field is the monthly on-demand cost of the uncovered gap. // Scope recs to the account chip the same way fetchCommitmentRecords scopes - // commitments above — otherwise the covered side honours the chip but the + // commitments above — otherwise the covered side honors the chip but the // on-demand side bleeds in other accounts' gaps, producing misleading // per-service coverage (issue #866 follow-up: CR pass on PR #881). recs, err := h.scheduler.ListRecommendations(ctx, buildCoverageRecFilter(params)) @@ -252,8 +252,8 @@ func aggregateOnDemandByKey(recs []config.RecommendationRecord, providerFilter s // commitmentCoveredMonthly returns the effective covered monthly spend of a // single active commitment: its recurring MonthlyCost (when present) plus the -// upfront amortised over the term. This mirrors the canonical effective-monthly -// formula used elsewhere in the codebase (analytics.Collector amortises +// upfront amortized over the term. This mirrors the canonical effective-monthly +// formula used elsewhere in the codebase (analytics.Collector amortizes // UpfrontCost/(Term*MonthsPerYear); exchange_lookup adds MonthlyCost + // UpfrontCost/termMonths) so the Coverage tab and the savings analytics agree // on what "covered" means. @@ -265,11 +265,11 @@ func aggregateOnDemandByKey(recs []config.RecommendationRecord, providerFilter s // commitments are all upfront rendered as $0 / "No usage detected" even though // the dashboard counted the same commitments. A nil MonthlyCost is treated as a // real $0 recurring component (not a fabricated total) and the upfront still -// contributes its amortised share, so the covered figure is never silently 0. +// contributes its amortized share, so the covered figure is never silently 0. // -// Term <= 0 cannot be amortised (division by zero); such a row contributes only +// Term <= 0 cannot be amortized (division by zero); such a row contributes only // its recurring MonthlyCost. The scheduler only writes Term >= 1 rows, so this -// guard matches analytics.Collector's skip-bad-term defence rather than papering +// guard matches analytics.Collector's skip-bad-term defense rather than papering // over real data. func commitmentCoveredMonthly(p config.PurchaseHistoryRecord) float64 { var covered float64 diff --git a/internal/api/handler_inventory_test.go b/internal/api/handler_inventory_test.go index 68d9e2764..7cc81a961 100644 --- a/internal/api/handler_inventory_test.go +++ b/internal/api/handler_inventory_test.go @@ -55,7 +55,7 @@ func TestHandler_listActiveCommitments_Empty(t *testing.T) { // TestHandler_listActiveCommitments_FiltersExpired verifies the term-expiry // predicate drops rows whose timestamp + term has elapsed and keeps the // in-term ones. Same predicate the dashboard aggregate uses; this test -// guards the predicate's behaviour in the inventory-handler context so a +// guards the predicate's behavior in the inventory-handler context so a // future refactor (e.g. moving to days-from-now) trips here too. func TestHandler_listActiveCommitments_FiltersExpired(t *testing.T) { ctx := context.Background() @@ -593,7 +593,7 @@ func TestHandler_getCoverageBreakdown_ProviderAndAccountChip(t *testing.T) { // Root cause: an Azure all-upfront RI carries MonthlyCost == nil (no recurring // charge — see config.PurchaseHistoryRecord.MonthlyCost), and the old Coverage // path summed only non-nil MonthlyCost, silently dropping the row. The covered -// monthly of such a commitment is its amortised upfront (UpfrontCost / term +// monthly of such a commitment is its amortized upfront (UpfrontCost / term // months), so the two surfaces disagreed: the dashboard found the commitment, // Coverage acted as if Azure had none. // @@ -602,7 +602,7 @@ func TestHandler_getCoverageBreakdown_ProviderAndAccountChip(t *testing.T) { // - the dashboard's active-commitment aggregation sees it (non-zero // EstimatedSavings for azure:compute), proving the commitment is "current"; // - the Coverage tab now reports a non-zero covered monthly for Azure equal -// to the amortised upfront, instead of nil / zero coverage. +// to the amortized upfront, instead of nil / zero coverage. // // Pre-fix the Coverage assertion fails (azure section has nil Services and nil // OverallCoveragePct). Post-fix both surfaces agree that Azure has an active, @@ -618,7 +618,7 @@ func TestHandler_getCoverageBreakdown_AzureAllUpfrontConsistency(t *testing.T) { now := time.Now() // Azure all-upfront RI: no recurring monthly charge (MonthlyCost nil), - // $1200 upfront over a 1-year term => $100/mo amortised covered spend. + // $1200 upfront over a 1-year term => $100/mo amortized covered spend. // EstimatedSavings is populated, which is what the dashboard's // "current / committed" figure renders. azureCommitment := config.PurchaseHistoryRecord{ @@ -649,7 +649,7 @@ func TestHandler_getCoverageBreakdown_AzureAllUpfrontConsistency(t *testing.T) { } // No Azure on-demand recommendations: the only signal for Azure is the // covered commitment. Pre-fix this yields nil/zero coverage; post-fix the - // amortised upfront makes Azure 100% covered for compute. + // amortized upfront makes Azure 100% covered for compute. mockScheduler.On("ListRecommendations", ctx, config.RecommendationFilter{}).Return([]config.RecommendationRecord{}, nil) mockAuth, req := adminInventoryReq(ctx) @@ -677,9 +677,9 @@ func TestHandler_getCoverageBreakdown_AzureAllUpfrontConsistency(t *testing.T) { compute := azure.Services[0] assert.Equal(t, "compute", compute.Service) - // $1200 upfront / (1yr * 12mo) = $100/mo amortised covered spend. + // $1200 upfront / (1yr * 12mo) = $100/mo amortized covered spend. assert.InDelta(t, 100.0, compute.CoveredMonthly, 0.001, - "covered monthly = amortised upfront for an all-upfront commitment") + "covered monthly = amortized upfront for an all-upfront commitment") assert.Equal(t, 0.0, compute.OnDemandMonthly) require.NotNil(t, compute.CoveragePct) // 100 covered / (100 covered + 0 on-demand) = 100% — never nil/zero. diff --git a/internal/api/handler_per_account_perms_test.go b/internal/api/handler_per_account_perms_test.go index 1090a9dff..e70c1947b 100644 --- a/internal/api/handler_per_account_perms_test.go +++ b/internal/api/handler_per_account_perms_test.go @@ -691,7 +691,7 @@ func TestPerAccountPerms_ExecutePurchase_UnattributedRecRejected400(t *testing.T // does not block in-scope requests. // // Note: the status in the response is "failed" because no emailNotifier is -// wired in this test. That is the correct behaviour per the existing +// wired in this test. That is the correct behavior per the existing // TestHandler_executePurchase_Success test — "failed" means "saved but email // could not send", not that the scope check blocked it. func TestPerAccountPerms_ExecutePurchase_AllowedAccountAccepted(t *testing.T) { diff --git a/internal/api/handler_plans.go b/internal/api/handler_plans.go index 43a659303..f7817bcd8 100644 --- a/internal/api/handler_plans.go +++ b/internal/api/handler_plans.go @@ -17,7 +17,7 @@ import ( "github.com/jackc/pgx/v5" ) -// Plans handlers +// Plans handlers. func (h *Handler) listPlans(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (*PlansResponse, error) { // Require view:plans permission if _, err := h.requirePermission(ctx, req, "view", "plans"); err != nil { @@ -48,7 +48,7 @@ func (h *Handler) listPlans(ctx context.Context, req *events.LambdaFunctionURLRe return &PlansResponse{Plans: plans}, nil } -// calculateNextExecutionDate calculates the next execution date for a plan +// calculateNextExecutionDate calculates the next execution date for a plan. func calculateNextExecutionDate(plan *config.PurchasePlan, now time.Time) *time.Time { var nextDate time.Time if plan.RampSchedule.Type == "immediate" { @@ -89,7 +89,7 @@ func (h *Handler) createPlan(ctx context.Context, httpReq *events.LambdaFunction // target_accounts is required: a plan must be tied to at least one // cloud_account row. The historical "leave blank to mean all accounts of - // this provider" behaviour created "universal plans" (rows in + // this provider" behavior created "universal plans" (rows in // purchase_plans with no matching plan_accounts row) that were hard to // scope, hard to filter, and hard to govern. Reject early with a clear // 400 so the frontend can surface the error before any DB write. @@ -297,7 +297,7 @@ func (h *Handler) createPlannedPurchases(ctx context.Context, httpReq *events.La // // Issue #950: stamp the session user onto each new execution's // created_by_user_id so the per-row creator-scope ownership gate - // (authorizeExecutionManagement) recognises the actor who scheduled + // (authorizeExecutionManagement) recognizes the actor who scheduled // the purchases as their owner. Without this the rows ship NULL and // are unreachable for pause / resume / run / delete by anyone except // admins / update-any holders, including the user who just clicked @@ -323,7 +323,7 @@ func (h *Handler) createPlannedPurchases(ctx context.Context, httpReq *events.La return &CreatePlannedPurchasesResponse{Created: created}, nil } -// parseCreatePurchasesRequest parses and validates the create purchases request +// parseCreatePurchasesRequest parses and validates the create purchases request. func (h *Handler) parseCreatePurchasesRequest(body string) (*CreatePlannedPurchasesRequest, time.Time, error) { var req CreatePlannedPurchasesRequest if err := json.Unmarshal([]byte(body), &req); err != nil { @@ -373,7 +373,7 @@ func (h *Handler) getPlanForPurchaseCreation(ctx context.Context, planID string) // creator carries the session user's UUID (or nil for the admin-API-key / // non-UUID-session paths) and is stamped onto every inserted row's // created_by_user_id so the issue-#950 ownership gate downstream can -// recognise the actor as the rightful manager. A nil value mirrors the +// recognize the actor as the rightful manager. A nil value mirrors the // migration-000041 fail-closed semantics: legacy / unattributed rows are // reachable only by admin / update-any holders. func (h *Handler) createPurchaseExecutionsTx(ctx context.Context, tx pgx.Tx, plan *config.PurchasePlan, planID string, count int, startDate time.Time, creator *string) (int, error) { @@ -423,7 +423,7 @@ func (h *Handler) updatePlanNextExecutionDateTx(ctx context.Context, tx pgx.Tx, return nil } -// PatchPlanRequest represents a partial update request for plans +// PatchPlanRequest represents a partial update request for plans. type PatchPlanRequest struct { Name *string `json:"name,omitempty"` Enabled *bool `json:"enabled,omitempty"` @@ -457,7 +457,7 @@ func applyPatchFields(plan *config.PurchasePlan, req PatchPlanRequest) error { return nil } -// patchPlan handles partial updates to a plan (PATCH method) +// patchPlan handles partial updates to a plan (PATCH method). func (h *Handler) patchPlan(ctx context.Context, httpReq *events.LambdaFunctionURLRequest, planID string) (any, error) { if err := validateUUID(planID); err != nil { return nil, err diff --git a/internal/api/handler_plans_test.go b/internal/api/handler_plans_test.go index 8cf2366f8..ab81439b7 100644 --- a/internal/api/handler_plans_test.go +++ b/internal/api/handler_plans_test.go @@ -449,7 +449,7 @@ func TestHandler_createPlannedPurchases(t *testing.T) { // guard: every execution row written through POST /api/plans/{id}/purchases // MUST carry the session user's UUID in CreatedByUserID, otherwise the // per-row ownership gate (authorizeExecutionManagement in -// handler_purchases.go) downstream cannot recognise the actor as the +// handler_purchases.go) downstream cannot recognize the actor as the // rightful manager and the user who just scheduled the purchases is // locked out of pause / resume / run / delete until an admin steps in. // diff --git a/internal/api/handler_purchases_revoke.go b/internal/api/handler_purchases_revoke.go index 2f9827fd8..05725f442 100644 --- a/internal/api/handler_purchases_revoke.go +++ b/internal/api/handler_purchases_revoke.go @@ -130,7 +130,7 @@ var revokeMarkRetryBackoffs = []time.Duration{ // // Gmail-style pre-fire delay (issue #291 wave-2): when the ID resolves to a // purchase_execution in status="scheduled" (cloud SDK not yet called), the -// execution is cancelled at zero cost and control returns immediately — no +// execution is canceled at zero cost and control returns immediately — no // provider SDK call is made. This path handles AWS, GCP, and Azure uniformly // since nothing has been committed to any cloud yet. func (h *Handler) revokePurchase(ctx context.Context, req *events.LambdaFunctionURLRequest, purchaseID string) (any, error) { @@ -241,7 +241,7 @@ func (h *Handler) revokeScheduledExecution(ctx context.Context, session *Session // (scheduler lag / backpressure). Returning 410 purely on a past timestamp // would break free-cancel during lag even though the CAS below can still // cancel it before any cloud call. Let CancelScheduledExecutionAtomic be the - // sole arbiter: it returns cancelled=false (-> 410) only when the row has + // sole arbiter: it returns canceled=false (-> 410) only when the row has // actually moved out of "scheduled". if err := h.authorizeSessionRevokeExecution(ctx, session, execution); err != nil { return nil, err @@ -253,7 +253,7 @@ func (h *Handler) revokeScheduledExecution(ctx context.Context, session *Session e := session.Email cancelledBy = &e } - var cancelled bool + var canceled bool var currentStatus string if err := h.config.WithTx(ctx, func(tx pgx.Tx) error { var err error @@ -263,18 +263,18 @@ func (h *Handler) revokeScheduledExecution(ctx context.Context, session *Session // a scheduled row, miscoded as "race lost" -> a misleading 410 even // during the happy path. Issue #290 wave-2: keep the two CAS contracts // distinct so 410 unambiguously means "scheduler already fired". - cancelled, currentStatus, err = h.config.CancelScheduledExecutionAtomic(ctx, tx, execution.ExecutionID, cancelledBy) + canceled, currentStatus, err = h.config.CancelScheduledExecutionAtomic(ctx, tx, execution.ExecutionID, cancelledBy) if err != nil { return err } - if !cancelled { + if !canceled { return nil } return h.config.DeleteSuppressionsByExecutionTx(ctx, tx, execution.ExecutionID) }); err != nil { return nil, fmt.Errorf("cancel scheduled execution %s: %w", execution.ExecutionID, err) } - if !cancelled { + if !canceled { // A concurrent scheduler tick transitioned the row away from "scheduled" // between our SELECT and the CAS UPDATE — the window closed. Return 410 // so the client knows to switch to the completed-purchase revoke path. @@ -283,7 +283,7 @@ func (h *Handler) revokeScheduledExecution(ctx context.Context, session *Session )) } - logging.Infof("revokeScheduledExecution: execution_id=%s cancelled before SDK call (free cancel)", execution.ExecutionID) + logging.Infof("revokeScheduledExecution: execution_id=%s canceled before SDK call (free cancel)", execution.ExecutionID) return map[string]string{ "status": "cancelled", diff --git a/internal/api/handler_recommendations.go b/internal/api/handler_recommendations.go index 943cc6782..684239177 100644 --- a/internal/api/handler_recommendations.go +++ b/internal/api/handler_recommendations.go @@ -13,7 +13,7 @@ import ( ) // parseRecommendationFilter validates all query parameters and builds the DB -// filter. It centralises the parameter-validation logic so getRecommendations +// filter. It centralizes the parameter-validation logic so getRecommendations // stays below the cyclomatic-complexity threshold. // // account_ids filter semantics (issue #211): @@ -80,7 +80,7 @@ func parseRecommendationFilter(params map[string]string) (config.RecommendationF }, nil } -// Recommendations handlers +// Recommendations handlers. func (h *Handler) getRecommendations(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (*RecommendationsResponse, error) { // Require view:recommendations permission session, err := h.requirePermission(ctx, req, "view", "recommendations") @@ -216,7 +216,7 @@ func stampComputeCapacity(rec *config.RecommendationRecord) { return } if compute.VCPU <= 0 || compute.MemoryGB <= 0 { - // Unknown size (converter didn't wire a catalogue lookup): keep + // Unknown size (converter didn't wire a catalog lookup): keep // both absent so the frontend renders "—". return } @@ -259,7 +259,7 @@ func (h *Handler) getRecommendationsFreshness(ctx context.Context, req *events.L // used by handler_accounts.go's account lookup). // // usage_history is intentionally empty in this first pass: the collector -// pipeline does not yet persist time-series utilisation per +// pipeline does not yet persist time-series utilization per // recommendation. Surfacing the missing field as an empty slice (rather // than a 501) keeps the drawer functional today and means the day the // collector starts populating it, the frontend automatically picks it @@ -324,7 +324,7 @@ func (h *Handler) buildRecommendationDetail(ctx context.Context, rec *config.Rec } // confidenceBucketFor mirrors the heuristic that previously lived -// client-side in frontend/src/recommendations.ts. Centralising it on +// client-side in frontend/src/recommendations.ts. Centralizing it on // the server lets future provider-specific tuning land in one place // without a frontend deploy. Thresholds intentionally match the original // shim 1:1 so the drawer label doesn't visibly shift on rollout. diff --git a/internal/api/handler_recommendations_refresh.go b/internal/api/handler_recommendations_refresh.go index 229a6edc4..a1eee7d1f 100644 --- a/internal/api/handler_recommendations_refresh.go +++ b/internal/api/handler_recommendations_refresh.go @@ -133,7 +133,7 @@ func (h *Handler) runMarkedCollection(ctx context.Context) (*config.Recommendati // asyncInvokeSelf fires an InvocationType=Event invoke of the given Lambda // function ARN with the EventBridge-style payload that handleLambdaScheduledEvent -// recognises as a "collect recommendations" job. The call returns immediately; +// recognizes as a "collect recommendations" job. The call returns immediately; // the Lambda runtime delivers the event to the next available container // (which may be this same container's next invocation). func (h *Handler) asyncInvokeSelf(ctx context.Context, functionARN string) error { diff --git a/internal/api/handler_recommendations_test.go b/internal/api/handler_recommendations_test.go index 8fabe8521..ea9335813 100644 --- a/internal/api/handler_recommendations_test.go +++ b/internal/api/handler_recommendations_test.go @@ -358,7 +358,7 @@ func TestGetRecommendations_AccountIDFilter(t *testing.T) { // TestBuildRecommendationsResponse_CapacityFields is the #219 regression // test. It replicates the REAL API shape: VCPU/MemoryGB live nested inside -// the opaque Details blob (a marshalled common.ComputeDetails), exactly as the +// the opaque Details blob (a marshaled common.ComputeDetails), exactly as the // scheduler persists them via common.MarshalServiceDetails — there are NO // top-level vcpu/memory_gb fields on the stored record. The pre-fix code never // decoded Details, so it emitted no top-level vcpu/memory_gb and every @@ -378,7 +378,7 @@ func TestBuildRecommendationsResponse_CapacityFields(t *testing.T) { MemoryGB: memGB, }) require.NoError(t, err) - require.NotEmpty(t, raw, "marshalled compute details must not be empty") + require.NotEmpty(t, raw, "marshaled compute details must not be empty") return raw } @@ -428,7 +428,7 @@ func TestBuildRecommendationsResponse_CapacityFields(t *testing.T) { assert.Equal(t, 8, *compute.VCPU) assert.Equal(t, float64(32), *compute.MemoryGB) - // The serialised JSON must expose them at the TOP LEVEL (not nested under + // The serialized JSON must expose them at the TOP LEVEL (not nested under // details) — this is the exact contract the frontend reads. blob, err := json.Marshal(compute) require.NoError(t, err) diff --git a/internal/api/handler_registrations.go b/internal/api/handler_registrations.go index b9d7a8032..88fdb5910 100644 --- a/internal/api/handler_registrations.go +++ b/internal/api/handler_registrations.go @@ -458,7 +458,7 @@ func generateReferenceToken() (string, error) { // for a new-registration notification email. // // Rules: -// - Every member of the Administrators group is an authorised reviewer. +// - Every member of the Administrators group is an authorized reviewer. // - The first admin email is the To; remaining admins + the global // Settings → General notification email go on Cc. // - When no admin users are configured, falls through to the legacy @@ -497,7 +497,7 @@ func (h *Handler) resolveRegistrationRecipients(ctx context.Context) (to string, } // gatherAdminEmails returns the deduped, insertion-ordered list of emails -// for every authorised reviewer, i.e. every member of the Administrators group +// for every authorized reviewer, i.e. every member of the Administrators group // (the group-membership replacement for the former role == "admin" check; // issue #907). Transport errors are logged and result in an empty return so // registration notifications don't block on auth-store hiccups. diff --git a/internal/api/handler_registrations_recipients_test.go b/internal/api/handler_registrations_recipients_test.go index 35de95b54..facde8f78 100644 --- a/internal/api/handler_registrations_recipients_test.go +++ b/internal/api/handler_registrations_recipients_test.go @@ -35,7 +35,7 @@ func TestHandler_resolveRegistrationRecipients_AdminsBecomeApprovers(t *testing. assert.Equal(t, []string{"admin-b@example.com", globalNotify}, cc, "other admins + global notify go on Cc") assert.Equal(t, []string{"admin-a@example.com", "admin-b@example.com"}, approvers, - "admin role users are the authorised reviewers; non-admins stripped") + "admin role users are the authorized reviewers; non-admins stripped") } func TestHandler_resolveRegistrationRecipients_NoAdminsTriggersBroadcastFallback(t *testing.T) { diff --git a/internal/api/handler_ri_exchange.go b/internal/api/handler_ri_exchange.go index 635dc324a..ec3e117bd 100644 --- a/internal/api/handler_ri_exchange.go +++ b/internal/api/handler_ri_exchange.go @@ -50,7 +50,7 @@ type reshapeRecsClient interface { GetRIUtilization(ctx context.Context, lookbackDays int) ([]recommendations.RIUtilization, error) } -// buildReshapeEC2Client honours the injected factory when set, falling +// buildReshapeEC2Client honors the injected factory when set, falling // back to the direct AWS SDK constructor otherwise. Tests inject a // stub via Handler.reshapeEC2Factory; prod leaves the field nil. func (h *Handler) buildReshapeEC2Client(cfg aws.Config) reshapeEC2Client { @@ -77,7 +77,7 @@ type targetOfferingsEC2Client interface { ListTargetOfferings(ctx context.Context, params ec2svc.ListTargetOfferingsParams) ([]ec2svc.TargetOffering, error) } -// buildTargetOfferingsEC2Client honours the injected factory when set, +// buildTargetOfferingsEC2Client honors the injected factory when set, // falling back to the direct AWS SDK constructor otherwise. func (h *Handler) buildTargetOfferingsEC2Client(cfg aws.Config) targetOfferingsEC2Client { if h.targetOfferingsEC2Factory != nil { @@ -110,7 +110,7 @@ var offeringIDPattern = regexp.MustCompile( // the query so AWS returns all valid target instance types -- the full // menu of what the user can exchange into. // -// GET /api/ri-exchange/target-offerings?source_ri_id=®ion= +// GET /api/ri-exchange/target-offerings?source_ri_id=®ion=. func (h *Handler) listTargetOfferings(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) { if _, err := h.requirePermission(ctx, req, "view", "purchases"); err != nil { return nil, err @@ -332,7 +332,7 @@ func (h *Handler) checkListRIsAccountIDParam(ctx context.Context, params map[str // the running AWS account. // // The optional ?account_id= query parameter narrows the listing to a single -// AWS account so the page honours the Main Header global account filter +// AWS account so the page honors the Main Header global account filter // (issue #871). Convertible RIs are read from the deployment's ambient AWS // credentials, which resolve to exactly one account number; when the chip // selects a different account, none of these RIs belong to it, so we return @@ -1016,7 +1016,7 @@ func (h *Handler) approveRIExchange(ctx context.Context, req *events.LambdaFunct } // Record-level RBAC denied (e.g. approve-own user is not the creator). // If a token is present, preserve legacy token flow; otherwise surface the error. - if !(token != "" && isPermissionDenied(sessErr)) { + if token == "" || !isPermissionDenied(sessErr) { return nil, sessErr } case isPermissionDenied(err): @@ -1048,7 +1048,7 @@ func (h *Handler) approveRIExchangeViaToken(ctx context.Context, id, token strin return nil, fmt.Errorf("failed to transition exchange status: %w", err) } if transitioned == nil { - return nil, NewClientError(409, "exchange already processed, expired, or was cancelled by a newer analysis run") + return nil, NewClientError(409, "exchange already processed, expired, or was canceled by a newer analysis run") } return h.executeApprovedExchange(ctx, id, record) @@ -1081,7 +1081,7 @@ func (h *Handler) approveRIExchangeViaSession(ctx context.Context, req *events.L return nil, fmt.Errorf("failed to transition exchange status: %w", err) } if transitioned == nil { - return nil, NewClientError(409, "exchange already processed, expired, or was cancelled by a newer analysis run") + return nil, NewClientError(409, "exchange already processed, expired, or was canceled by a newer analysis run") } result, execErr := h.executeApprovedExchange(ctx, id, record) @@ -1098,7 +1098,7 @@ func (h *Handler) approveRIExchangeViaSession(ctx context.Context, req *events.L } // fetchAndAuthorizeRIExchange looks up the pending exchange record by id, checks -// that it is in "pending" state, and then verifies that session is authorised to +// that it is in "pending" state, and then verifies that session is authorized to // approve it. Extracted from approveRIExchangeViaSession to keep that function // under the cyclomatic-complexity limit. func (h *Handler) fetchAndAuthorizeRIExchange(ctx context.Context, session *Session, id string) (*config.RIExchangeRecord, error) { diff --git a/internal/api/handler_ri_exchange_integration_test.go b/internal/api/handler_ri_exchange_integration_test.go index 865ddb666..5f0a4fa6f 100644 --- a/internal/api/handler_ri_exchange_integration_test.go +++ b/internal/api/handler_ri_exchange_integration_test.go @@ -36,7 +36,7 @@ func (f *fakeReshapeEC2) ListConvertibleReservedInstances(_ context.Context) ([] } // fakeReshapeRecs is a stub for reshapeRecsClient. Counts calls so the -// test can assert cache-hit behaviour on the second request. +// test can assert cache-hit behavior on the second request. type fakeReshapeRecs struct { utilization []recommendations.RIUtilization calls atomic.Int32 @@ -305,7 +305,7 @@ func TestReshapeRecommendations_Integration_ScopedAccount_FiltersToAccount(t *te accountBID := seedCloudAccount(ctx, t, store, "222222222222", "Tenant B") // Seed one cross-family rec per tenant in the same region. If the - // scope filter is honoured, only Tenant A's c5.large surfaces; + // scope filter is honored, only Tenant A's c5.large surfaces; // otherwise both alternatives leak through (the regression we're // guarding against). require.NoError(t, store.ReplaceRecommendations(ctx, time.Now(), []config.RecommendationRecord{ diff --git a/internal/api/handler_router.go b/internal/api/handler_router.go index d114662de..da561f287 100644 --- a/internal/api/handler_router.go +++ b/internal/api/handler_router.go @@ -8,14 +8,14 @@ import ( ) // routeRequest routes the request to the appropriate handler based on path and method -// This function now delegates to the table-driven router for improved maintainability +// This function now delegates to the table-driven router for improved maintainability. func (h *Handler) routeRequest(ctx context.Context, method, path string, req *events.LambdaFunctionURLRequest) (any, error) { // Create a new router for each handler to avoid shared state in tests r := NewRouter(h) return r.Route(ctx, method, path, req) } -// errNotFound is a sentinel error for 404 responses +// errNotFound is a sentinel error for 404 responses. var errNotFound = ¬FoundError{} type notFoundError struct{} @@ -24,9 +24,10 @@ func (e *notFoundError) Error() string { return "not found" } -// IsNotFoundError checks if the error is a not found error +// IsNotFoundError checks if the error is a not found error. func IsNotFoundError(err error) bool { - _, ok := err.(*notFoundError) + notFoundError := ¬FoundError{} + ok := errors.As(err, ¬FoundError) return ok } diff --git a/internal/api/handler_router_test.go b/internal/api/handler_router_test.go index ffa655d58..b57f6d357 100644 --- a/internal/api/handler_router_test.go +++ b/internal/api/handler_router_test.go @@ -93,7 +93,7 @@ func TestHandler_createGroup_Error(t *testing.T) { assert.Nil(t, result) } -// Tests for notFoundError type +// Tests for notFoundError type. func TestNotFoundError_Error(t *testing.T) { err := ¬FoundError{} assert.Equal(t, "not found", err.Error()) @@ -121,7 +121,7 @@ func TestFormatNotFoundError(t *testing.T) { assert.Contains(t, err.Error(), "not found") } -// Tests for clientError type +// Tests for clientError type. func TestClientError_Error(t *testing.T) { err := &clientError{message: "bad request", code: 400} assert.Equal(t, "bad request", err.Error()) diff --git a/internal/api/handler_security_test.go b/internal/api/handler_security_test.go index bf96f9b4a..6a0039d85 100644 --- a/internal/api/handler_security_test.go +++ b/internal/api/handler_security_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -// TestSetSecurityHeaders verifies that all required security headers are set +// TestSetSecurityHeaders verifies that all required security headers are set. func TestSetSecurityHeaders(t *testing.T) { headers := make(map[string]string) headers = setSecurityHeaders(headers) @@ -26,7 +26,7 @@ func TestSetSecurityHeaders(t *testing.T) { assert.Equal(t, "no-store, no-cache, must-revalidate", headers["Cache-Control"], "Cache-Control should prevent caching") } -// TestSetSecurityHeaders_DoesNotOverwrite verifies headers are set correctly +// TestSetSecurityHeaders_DoesNotOverwrite verifies headers are set correctly. func TestSetSecurityHeaders_DoesNotOverwrite(t *testing.T) { headers := map[string]string{ "Content-Type": "application/json", @@ -40,7 +40,7 @@ func TestSetSecurityHeaders_DoesNotOverwrite(t *testing.T) { assert.NotEmpty(t, headers["X-Content-Type-Options"]) } -// TestHandleRequest_SecurityHeaders verifies all responses include security headers +// TestHandleRequest_SecurityHeaders verifies all responses include security headers. func TestHandleRequest_SecurityHeaders(t *testing.T) { ctx := context.Background() handler := &Handler{corsAllowedOrigin: "https://example.com"} @@ -69,7 +69,7 @@ func TestHandleRequest_SecurityHeaders(t *testing.T) { assert.Equal(t, "no-store, no-cache, must-revalidate", resp.Headers["Cache-Control"]) } -// TestHandleRequest_SecurityHeaders_OPTIONS verifies OPTIONS requests include security headers +// TestHandleRequest_SecurityHeaders_OPTIONS verifies OPTIONS requests include security headers. func TestHandleRequest_SecurityHeaders_OPTIONS(t *testing.T) { ctx := context.Background() handler := &Handler{corsAllowedOrigin: "https://example.com"} @@ -93,7 +93,7 @@ func TestHandleRequest_SecurityHeaders_OPTIONS(t *testing.T) { assert.Equal(t, "max-age=31536000; includeSubDomains", resp.Headers["Strict-Transport-Security"]) } -// TestHandleRequest_SecurityHeaders_ErrorResponse verifies error responses include security headers +// TestHandleRequest_SecurityHeaders_ErrorResponse verifies error responses include security headers. func TestHandleRequest_SecurityHeaders_ErrorResponse(t *testing.T) { ctx := context.Background() handler := &Handler{apiKey: "test-key"} @@ -304,7 +304,7 @@ func TestNonDocsPath_KeepsStrictCSP(t *testing.T) { "non-docs paths must retain the strict default CSP") } -// TestHandleRequest_SecurityHeaders_RequestTooLarge verifies 413 responses include security headers +// TestHandleRequest_SecurityHeaders_RequestTooLarge verifies 413 responses include security headers. func TestHandleRequest_SecurityHeaders_RequestTooLarge(t *testing.T) { ctx := context.Background() handler := &Handler{} diff --git a/internal/api/handler_users.go b/internal/api/handler_users.go index 36befda8a..83e22c938 100644 --- a/internal/api/handler_users.go +++ b/internal/api/handler_users.go @@ -12,7 +12,7 @@ import ( // User management handlers -// listUsers handles GET /api/users +// listUsers handles GET /api/users. func (h *Handler) listUsers(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) { if _, err := h.requirePermission(ctx, req, "view", "users"); err != nil { return nil, err @@ -26,7 +26,7 @@ func (h *Handler) listUsers(ctx context.Context, req *events.LambdaFunctionURLRe return map[string]any{"users": users}, nil } -// createUser handles POST /api/users +// createUser handles POST /api/users. func (h *Handler) createUser(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) { session, err := h.requirePermission(ctx, req, "create", "users") if err != nil { @@ -49,7 +49,7 @@ func (h *Handler) createUser(ctx context.Context, req *events.LambdaFunctionURLR } // Authorization is group-membership-only: a user must belong to at least - // one group (issue #907). The service layer re-validates as defence in + // one group (issue #907). The service layer re-validates as defense in // depth and the DB enforces it via a CHECK constraint. if len(createReq.Groups) == 0 { return nil, NewClientError(400, "at least one group is required") @@ -72,7 +72,7 @@ func (h *Handler) createUser(ctx context.Context, req *events.LambdaFunctionURLR // mapAuthError maps internal/auth sentinel errors to the appropriate // ClientError status code so validation failures surface to the user -// as a 4xx with the real message instead of a generic 500. Unrecognised +// as a 4xx with the real message instead of a generic 500. Unrecognized // errors are returned unchanged for handleRequestError to render as 500. // Used by both /api/users (createUser) and the bootstrap setupAdmin // endpoint — they share the sentinel set. Issue #349. @@ -92,7 +92,7 @@ func mapAuthError(err error) error { return err } -// getUser handles GET /api/users/{id} +// getUser handles GET /api/users/{id}. func (h *Handler) getUser(ctx context.Context, req *events.LambdaFunctionURLRequest, userID string) (any, error) { // Validate UUID format to prevent injection attacks if err := validateUUID(userID); err != nil { @@ -111,7 +111,7 @@ func (h *Handler) getUser(ctx context.Context, req *events.LambdaFunctionURLRequ return user, nil } -// updateUser handles PUT /api/users/{id} +// updateUser handles PUT /api/users/{id}. func (h *Handler) updateUser(ctx context.Context, req *events.LambdaFunctionURLRequest, userID string) (any, error) { // Validate UUID format to prevent injection attacks if err := validateUUID(userID); err != nil { @@ -139,7 +139,7 @@ func (h *Handler) updateUser(ctx context.Context, req *events.LambdaFunctionURLR return user, nil } -// deleteUser handles DELETE /api/users/{id} +// deleteUser handles DELETE /api/users/{id}. func (h *Handler) deleteUser(ctx context.Context, req *events.LambdaFunctionURLRequest, userID string) (any, error) { // Validate UUID format to prevent injection attacks if err := validateUUID(userID); err != nil { diff --git a/internal/api/handler_users_test.go b/internal/api/handler_users_test.go index b170735f6..1ebf53da7 100644 --- a/internal/api/handler_users_test.go +++ b/internal/api/handler_users_test.go @@ -226,7 +226,7 @@ func TestHandler_deleteUser_SelfDeletion(t *testing.T) { assert.Contains(t, err.Error(), "cannot delete your own account") } -// Group management endpoint tests +// Group management endpoint tests. func TestHandler_createUser_InvalidJSON(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) diff --git a/internal/api/health.go b/internal/api/health.go index f112df25f..82cda27cc 100644 --- a/internal/api/health.go +++ b/internal/api/health.go @@ -8,20 +8,20 @@ import ( "github.com/LeanerCloud/CUDly/pkg/logging" ) -// HealthResponse represents the health check response +// HealthResponse represents the health check response. type HealthResponse struct { Status string `json:"status"` Timestamp time.Time `json:"timestamp"` Checks map[string]HealthCheck `json:"checks"` } -// HealthCheck represents a single health check result +// HealthCheck represents a single health check result. type HealthCheck struct { Status string `json:"status"` Message string `json:"message,omitempty"` } -// GetHealth performs comprehensive health checks +// GetHealth performs comprehensive health checks. func (h *Handler) GetHealth(ctx context.Context) (*HealthResponse, error) { response := &HealthResponse{ Status: "healthy", @@ -53,7 +53,7 @@ func (h *Handler) GetHealth(ctx context.Context) (*HealthResponse, error) { return response, nil } -// checkConfigStore checks if the configuration store is accessible +// checkConfigStore checks if the configuration store is accessible. func (h *Handler) checkConfigStore(ctx context.Context) HealthCheck { if h.config == nil { return HealthCheck{ @@ -107,7 +107,7 @@ func (h *Handler) checkCredentialStore() HealthCheck { return HealthCheck{Status: "healthy"} } -// checkAuthService checks if the auth service is accessible +// checkAuthService checks if the auth service is accessible. func (h *Handler) checkAuthService(ctx context.Context) HealthCheck { if h.auth == nil { return HealthCheck{ diff --git a/internal/api/inmemory_rate_limiter.go b/internal/api/inmemory_rate_limiter.go index 7a36f7eef..97654e774 100644 --- a/internal/api/inmemory_rate_limiter.go +++ b/internal/api/inmemory_rate_limiter.go @@ -17,7 +17,7 @@ import ( const inMemoryRateLimitMaxEntries = 500 // InMemoryRateLimiter provides in-memory rate limiting for single-instance deployments (Fargate, ECS) -// This implementation should NOT be used for Lambda (multi-instance) - use DBRateLimiter instead +// This implementation should NOT be used for Lambda (multi-instance) - use DBRateLimiter instead. type InMemoryRateLimiter struct { mu sync.Mutex attempts map[string]*inMemoryRateLimitEntry @@ -29,10 +29,10 @@ type inMemoryRateLimitEntry struct { resetTime time.Time } -// Verify that InMemoryRateLimiter implements RateLimiterInterface +// Verify that InMemoryRateLimiter implements RateLimiterInterface. var _ RateLimiterInterface = (*InMemoryRateLimiter)(nil) -// NewInMemoryRateLimiter creates a new in-memory rate limiter for single-instance deployments +// NewInMemoryRateLimiter creates a new in-memory rate limiter for single-instance deployments. func NewInMemoryRateLimiter() *InMemoryRateLimiter { return &InMemoryRateLimiter{ attempts: make(map[string]*inMemoryRateLimitEntry), @@ -40,7 +40,7 @@ func NewInMemoryRateLimiter() *InMemoryRateLimiter { } } -// SetLimit allows customizing rate limits for specific endpoints +// SetLimit allows customizing rate limits for specific endpoints. func (rl *InMemoryRateLimiter) SetLimit(endpoint string, config RateLimitConfig) { if rl.limits == nil { rl.limits = make(map[string]RateLimitConfig) @@ -133,19 +133,19 @@ func (rl *InMemoryRateLimiter) Allow(ctx context.Context, key string, endpoint s return true, nil } -// AllowWithIP is a convenience method that formats the key as an IP-based key +// AllowWithIP is a convenience method that formats the key as an IP-based key. func (rl *InMemoryRateLimiter) AllowWithIP(ctx context.Context, ip string, endpoint string) (bool, error) { key := fmt.Sprintf("IP#%s", ip) return rl.Allow(ctx, key, endpoint) } -// AllowWithEmail is a convenience method that formats the key as an email-based key +// AllowWithEmail is a convenience method that formats the key as an email-based key. func (rl *InMemoryRateLimiter) AllowWithEmail(ctx context.Context, email string, endpoint string) (bool, error) { key := fmt.Sprintf("EMAIL#%s", email) return rl.Allow(ctx, key, endpoint) } -// AllowWithUser is a convenience method that formats the key as a user-based key +// AllowWithUser is a convenience method that formats the key as a user-based key. func (rl *InMemoryRateLimiter) AllowWithUser(ctx context.Context, userID string, endpoint string) (bool, error) { key := fmt.Sprintf("USER#%s", userID) return rl.Allow(ctx, key, endpoint) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index c89797d9c..79c5df8c9 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -11,7 +11,7 @@ import ( "github.com/aws/aws-lambda-go/events" ) -// isPublicEndpoint returns true for endpoints that don't require authentication +// isPublicEndpoint returns true for endpoints that don't require authentication. func (h *Handler) isPublicEndpoint(path string) bool { publicEndpoints := []string{ "/health", // Root health endpoint (no /api prefix) @@ -43,7 +43,7 @@ func (h *Handler) isPublicEndpoint(path string) bool { return false } -// authenticate checks authentication via admin API key, user API key, or Bearer token +// authenticate checks authentication via admin API key, user API key, or Bearer token. func (h *Handler) authenticate(ctx context.Context, req *events.LambdaFunctionURLRequest) bool { apiKey := extractAPIKey(req) @@ -172,7 +172,7 @@ func (h *Handler) requiresCSRFValidation(method, path string, req *events.Lambda return true } -// validateCSRF validates the CSRF token from the request header +// validateCSRF validates the CSRF token from the request header. func (h *Handler) validateCSRF(ctx context.Context, req *events.LambdaFunctionURLRequest) error { if h.auth == nil { return fmt.Errorf("authentication service not configured") @@ -238,7 +238,7 @@ func logMissingCSRFToken(req *events.LambdaFunctionURLRequest, csrfToken string) // requireAuth verifies the request carries a valid authentication credential // of any kind (admin API key, user API key, or session bearer token). // -// Used as a defence-in-depth check by Router.Route for AuthUser routes: +// Used as a defense-in-depth check by Router.Route for AuthUser routes: // validateSecurity → authenticate already runs before dispatch, but if a // future refactor reorders middleware or a new route bypasses // validateSecurity, this check still rejects unauthenticated requests at diff --git a/internal/api/mocks_test.go b/internal/api/mocks_test.go index 372bb9f09..d3bbad7ed 100644 --- a/internal/api/mocks_test.go +++ b/internal/api/mocks_test.go @@ -13,7 +13,7 @@ import ( var _ credentials.CredentialStore = (*MockCredentialStore)(nil) // compile-time interface check // MockConfigStore is the shared testify mock for config.StoreInterface. -// All Fn-override fields and default behaviours live in internal/mocks. +// All Fn-override fields and default behaviors live in internal/mocks. type MockConfigStore = mocks.MockConfigStore // MockCredentialStore is a simple stub implementing credentials.CredentialStore. @@ -41,7 +41,7 @@ func (m *MockCredentialStore) DecryptPayload(ciphertext string) ([]byte, error) return []byte(ciphertext), nil // no-op: return ciphertext as "decrypted" for tests } -// MockPurchaseManager is a mock implementation of purchase.Manager +// MockPurchaseManager is a mock implementation of purchase.Manager. type MockPurchaseManager struct { mock.Mock } @@ -61,7 +61,7 @@ func (m *MockPurchaseManager) CancelExecution(ctx context.Context, execID, token return args.Error(0) } -// MockScheduler is a mock implementation of scheduler.Scheduler +// MockScheduler is a mock implementation of scheduler.Scheduler. type MockScheduler struct { mock.Mock } @@ -95,7 +95,7 @@ func (m *MockScheduler) GetRecommendationByID(ctx context.Context, id string) (* return rec, hiddenBy, args.Error(2) } -// MockAuthService is a mock implementation of the auth service +// MockAuthService is a mock implementation of the auth service. type MockAuthService struct { mock.Mock } @@ -167,7 +167,7 @@ func (m *MockAuthService) UpdateUserProfile(ctx context.Context, userID string, return args.Error(0) } -// User management mock methods +// User management mock methods. func (m *MockAuthService) CreateUserAPI(ctx context.Context, req interface{}) (interface{}, error) { args := m.Called(ctx, req) return args.Get(0), args.Error(1) @@ -220,7 +220,7 @@ func (m *MockAuthService) MFARegenerateRecoveryCodesAPI(ctx context.Context, use return args.Get(0).([]string), args.Error(1) } -// Group management mock methods +// Group management mock methods. func (m *MockAuthService) CreateGroupAPI(ctx context.Context, req interface{}) (interface{}, error) { args := m.Called(ctx, req) return args.Get(0), args.Error(1) @@ -256,7 +256,7 @@ func (m *MockAuthService) GetUserPermissionsAPI(ctx context.Context, userID stri return args.Get(0), args.Error(1) } -// grantAdmin makes every HasPermissionAPI check succeed, modelling an +// grantAdmin makes every HasPermissionAPI check succeed, modeling an // Administrators-group member. Authorization is group-membership-only after // issue #907, so admin-gated handlers resolve "is admin" / specific permissions // through HasPermissionAPI rather than a Session.Role short-circuit; tests that @@ -282,7 +282,7 @@ func (m *MockAuthService) GetAllowedAccountsAPI(ctx context.Context, userID stri return nil, args.Error(1) } -// API Key management mock methods +// API Key management mock methods. func (m *MockAuthService) CreateAPIKeyAPI(ctx context.Context, userID string, req interface{}) (interface{}, error) { args := m.Called(ctx, userID, req) return args.Get(0), args.Error(1) diff --git a/internal/api/rate_limiter.go b/internal/api/rate_limiter.go index 0fda787f6..a289bfbd9 100644 --- a/internal/api/rate_limiter.go +++ b/internal/api/rate_limiter.go @@ -5,14 +5,14 @@ import ( "time" ) -// RateLimitConfig defines the rate limiting parameters for a specific endpoint/operation +// RateLimitConfig defines the rate limiting parameters for a specific endpoint/operation. type RateLimitConfig struct { MaxAttempts int // Maximum number of attempts allowed WindowSecs int // Time window in seconds Window time.Duration // Computed time window (for convenience) } -// NewRateLimitConfig creates a new RateLimitConfig +// NewRateLimitConfig creates a new RateLimitConfig. func NewRateLimitConfig(maxAttempts int, windowSecs int) RateLimitConfig { return RateLimitConfig{ MaxAttempts: maxAttempts, @@ -21,7 +21,7 @@ func NewRateLimitConfig(maxAttempts int, windowSecs int) RateLimitConfig { } } -// getDefaultRateLimits returns default rate limit configurations +// getDefaultRateLimits returns default rate limit configurations. func getDefaultRateLimits() map[string]RateLimitConfig { return map[string]RateLimitConfig{ "login": NewRateLimitConfig(5, 15*60), // 5 attempts / 15 minutes / IP diff --git a/internal/api/ri_utilization_cache.go b/internal/api/ri_utilization_cache.go index 3631642dd..8bb1d7cc6 100644 --- a/internal/api/ri_utilization_cache.go +++ b/internal/api/ri_utilization_cache.go @@ -65,7 +65,7 @@ type riUtilizationFetcher func(ctx context.Context, lookbackDays int) ([]recomme // revalidate semantics on non-Lambda runtimes. Lambda containers can't // safely run background goroutines (they freeze between invocations) // so on Lambda the cache falls back to synchronous fetch-on-stale — -// today's behaviour. Non-Lambda runtimes get SWR: stale rows are +// today's behavior. Non-Lambda runtimes get SWR: stale rows are // served immediately while a detached goroutine refreshes the row for // the next reader. // @@ -145,7 +145,7 @@ func (c *riUtilizationCache) getOrFetch( // kickBackgroundRefresh runs a single-flighted refetch in a detached // goroutine. sf.Do with the same key collapses concurrent calls to // one in-flight refresh. The refresh uses a fresh context (not the -// caller's) because the caller's ctx may be cancelled when the HTTP +// caller's) because the caller's ctx may be canceled when the HTTP // response completes, which would abort the refresh prematurely. func (c *riUtilizationCache) kickBackgroundRefresh(key, region string, lookbackDays int, fetch riUtilizationFetcher) { go func() { diff --git a/internal/api/ri_utilization_cache_test.go b/internal/api/ri_utilization_cache_test.go index c0aaf811f..5660ead0d 100644 --- a/internal/api/ri_utilization_cache_test.go +++ b/internal/api/ri_utilization_cache_test.go @@ -16,7 +16,7 @@ import ( // fakeRIUtilCacheStore is a minimal in-test implementation of // riUtilizationCacheStore. Keyed by (region, lookbackDays); stores the // raw JSON payload + fetched_at so the cache layer exercises the same -// marshalling path as the real Postgres store. +// marshaling path as the real Postgres store. type fakeRIUtilCacheStore struct { mu sync.Mutex entries map[string]config.RIUtilizationCacheEntry @@ -184,7 +184,7 @@ func TestRIUtilizationCache_StaleOnLambdaBlocksForSyncRefetch(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - // Lambda must return the FRESH data synchronously (today's behaviour). + // Lambda must return the FRESH data synchronously (today's behavior). if len(got) != 1 || got[0].ReservedInstanceID != "ri-fresh" { t.Fatalf("Lambda mode should synchronously refetch; got %+v", got) } diff --git a/internal/api/router.go b/internal/api/router.go index f69a00dc6..0f4a25ce1 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -9,7 +9,7 @@ import ( "github.com/aws/aws-lambda-go/events" ) -// RouteHandler is a function that handles a matched route +// RouteHandler is a function that handles a matched route. type RouteHandler func(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) // AuthLevel controls how Router.Route() enforces authentication. @@ -17,7 +17,7 @@ type RouteHandler func(ctx context.Context, req *events.LambdaFunctionURLRequest // if any registered route leaves it at the zero value. See the const // block below for the AuthAdmin / AuthUser / AuthPublic options. // -// Router.Route enforces these levels itself as a defence-in-depth check, +// Router.Route enforces these levels itself as a defense-in-depth check, // in addition to the validateSecurity → authenticate middleware that runs // earlier in the request pipeline. If middleware ordering ever changes or // a new route bypasses validateSecurity, the router-level enforcement @@ -48,7 +48,7 @@ const ( AuthPublic ) -// Route defines a routing rule +// Route defines a routing rule. type Route struct { // Pattern matching fields ExactPath string // Exact path match (e.g., "/api/health") @@ -66,7 +66,7 @@ type Route struct { Auth AuthLevel } -// Router manages request routing +// Router manages request routing. type Router struct { routes []Route h *Handler @@ -101,7 +101,7 @@ func validateRoutes(routes []Route) { } } -// registerRoutes sets up all application routes +// registerRoutes sets up all application routes. func (r *Router) registerRoutes() { r.routes = []Route{ // Dashboard endpoints — read-only views available to any signed-in @@ -363,7 +363,7 @@ func (r *Router) registerRoutes() { // Route finds and executes the matching route handler. // -// Authentication enforcement is defence-in-depth: validateSecurity → +// Authentication enforcement is defense-in-depth: validateSecurity → // authenticate already runs in the middleware pipeline before dispatch, // but Router.Route also enforces the per-route Auth level so routes stay // protected even if middleware ordering changes or a new code path @@ -387,7 +387,7 @@ func (r *Router) Route(ctx context.Context, method, path string, req *events.Lam // no auth check; relied upon by middleware via isPublicEndpoint default: // authUnset / unknown level — NewRouter should have already - // panicked at startup. Defence in depth: refuse to dispatch. + // panicked at startup. Defense in depth: refuse to dispatch. return nil, NewClientError(500, "internal routing error") } params := r.extractParams(route, path) @@ -397,7 +397,7 @@ func (r *Router) Route(ctx context.Context, method, path string, req *events.Lam return nil, errNotFound } -// matches checks if a route matches the given method and path +// matches checks if a route matches the given method and path. func (r *Router) matches(route Route, method, path string) bool { // Check method (if specified) if route.Method != "" && route.Method != method { @@ -421,7 +421,7 @@ func (r *Router) matches(route Route, method, path string) bool { return route.PathPrefix != "" || route.PathSuffix != "" } -// extractParams extracts path parameters from the route +// extractParams extracts path parameters from the route. func (r *Router) extractParams(route Route, path string) map[string]string { params := make(map[string]string) @@ -808,7 +808,7 @@ func (r *Router) rejectRIExchangeHandler(ctx context.Context, req *events.Lambda return r.h.rejectRIExchange(ctx, params["id"], req.QueryStringParameters["token"]) } -// formatNotFoundError creates a detailed not found error message +// formatNotFoundError creates a detailed not found error message. func formatNotFoundError(method, path string) error { return fmt.Errorf("%w: %s %s", errNotFound, method, path) } diff --git a/internal/api/router_authuser_test.go b/internal/api/router_authuser_test.go index 7e5571a85..ffe8002c6 100644 --- a/internal/api/router_authuser_test.go +++ b/internal/api/router_authuser_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -// These tests cover the defence-in-depth AuthUser enforcement added to +// These tests cover the defense-in-depth AuthUser enforcement added to // Router.Route — see issue #60. Before the fix, AuthUser routes (e.g. // /api/auth/logout, /api/api-keys, /api/federation/iac) fell through the // router with no auth check; only the validateSecurity middleware @@ -37,7 +37,7 @@ func TestRouterAuthUser_NoCredentials_Rejects(t *testing.T) { } // TestRouterAuthUser_InvalidBearerToken_Rejects verifies that an AuthUser -// route returns 401 when the bearer token is not recognised by the auth +// route returns 401 when the bearer token is not recognized by the auth // service. func TestRouterAuthUser_InvalidBearerToken_Rejects(t *testing.T) { ctx := context.Background() @@ -85,7 +85,7 @@ func TestRouterAuthUser_ValidUserSession_Accepts(t *testing.T) { // TestRouterAuthPublic_NoCredentials_Accepts verifies that AuthPublic // routes still dispatch with no credentials — the new switch in -// Router.Route must not regress public-endpoint behaviour. +// Router.Route must not regress public-endpoint behavior. func TestRouterAuthPublic_NoCredentials_Accepts(t *testing.T) { ctx := context.Background() h := &Handler{} diff --git a/internal/api/scoping.go b/internal/api/scoping.go index 57280ec6c..cf479454e 100644 --- a/internal/api/scoping.go +++ b/internal/api/scoping.go @@ -67,7 +67,7 @@ func (h *Handler) canAccessAccountID(ctx context.Context, session *Session, acco // default when we can't attribute the plan to a specific account. // // requirePermission must fire first; the session it returns is what the -// caller passes here. This is the plan-level analogue of requireAccountAccess +// caller passes here. This is the plan-level analog of requireAccountAccess // and is used by the plans/purchases/ri-exchange per-record scoping. func (h *Handler) requirePlanAccess(ctx context.Context, session *Session, planID string) error { allowed, err := h.getAllowedAccounts(ctx, session) @@ -166,7 +166,7 @@ func (h *Handler) requireExecutionAccess(ctx context.Context, session *Session, // // Returns (uuids, nil) when uuids is empty or the account load fails — the // dual-column predicate then degrades to UUID-only matching, no worse than the -// pre-fix behaviour. +// pre-fix behavior. func (h *Handler) resolveAccountFilterIDs(ctx context.Context, uuids []string) (resolvedUUIDs []string, externalIDsByProvider map[string][]string) { if len(uuids) == 0 { return uuids, nil @@ -220,11 +220,11 @@ func addExternalIDForProvider(m map[string][]string, provider, externalID string // callers keep working; it is NOT placed in the uuid set so a raw external // number is never compared against cloud_account_id UUIDs. Its provider is // unknown, so it is grouped under the "" key, which the predicate treats as -// an unconstrained-provider match (legacy behaviour preserved). +// an unconstrained-provider match (legacy behavior preserved). // // Empty input returns nil maps (no account filter). A cloud_accounts load // failure falls back to treating the value as an external id (no worse than the -// pre-fix behaviour); per-record allowed_accounts scoping still applies +// pre-fix behavior); per-record allowed_accounts scoping still applies // downstream. func (h *Handler) resolveSingleAccountFilterIDs(ctx context.Context, accountID string) (uuids []string, externalIDsByProvider map[string][]string) { if accountID == "" { diff --git a/internal/api/types.go b/internal/api/types.go index e88590339..9a53fa546 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -15,7 +15,7 @@ import ( ) // RateLimiterInterface defines the interface for rate limiting implementations -// This allows for both in-memory and database-backed rate limiters +// This allows for both in-memory and database-backed rate limiters. type RateLimiterInterface interface { // Allow checks if a request should be allowed based on rate limits // Returns (allowed bool, error) @@ -31,7 +31,7 @@ type RateLimiterInterface interface { AllowWithUser(ctx context.Context, userID string, endpoint string) (bool, error) } -// HandlerConfig holds configuration for the API handler +// HandlerConfig holds configuration for the API handler. type HandlerConfig struct { ConfigStore config.StoreInterface CredentialStore credentials.CredentialStore @@ -95,7 +95,7 @@ type AnalyticsClientInterface interface { QueryBreakdown(ctx context.Context, accountUUIDs []string, accountExternalIDsByProvider map[string][]string, start, end time.Time, dimension string) (map[string]BreakdownValue, error) } -// AnalyticsCollectorInterface defines the interface for analytics collection +// AnalyticsCollectorInterface defines the interface for analytics collection. type AnalyticsCollectorInterface interface { Collect(ctx context.Context) error } @@ -114,7 +114,7 @@ type AnalyticsSnapshotStoreInterface interface { QueryByService(ctx context.Context, accountUUIDs []string, accountExternalIDsByProvider map[string][]string, provider string, startDate, endDate time.Time) ([]analytics.ServiceBreakdown, error) } -// HistoryDataPoint represents aggregated historical data +// HistoryDataPoint represents aggregated historical data. type HistoryDataPoint struct { Timestamp time.Time `json:"timestamp"` TotalSavings float64 `json:"total_savings"` @@ -125,7 +125,7 @@ type HistoryDataPoint struct { ByProvider map[string]float64 `json:"by_provider,omitempty"` } -// HistorySummaryAnalytics contains aggregated statistics for analytics +// HistorySummaryAnalytics contains aggregated statistics for analytics. type HistorySummaryAnalytics struct { TotalPeriodSavings float64 `json:"total_period_savings"` TotalUpfrontSpent float64 `json:"total_upfront_spent"` @@ -134,7 +134,7 @@ type HistorySummaryAnalytics struct { PeakSavings float64 `json:"peak_savings"` } -// BreakdownValue represents savings breakdown by dimension +// BreakdownValue represents savings breakdown by dimension. type BreakdownValue struct { TotalSavings float64 `json:"total_savings"` TotalUpfront float64 `json:"total_upfront"` @@ -157,7 +157,7 @@ type PurchaseManagerInterface interface { CancelExecution(ctx context.Context, execID, token, actor string) error } -// SchedulerInterface defines scheduler methods used by handler +// SchedulerInterface defines scheduler methods used by handler. type SchedulerInterface interface { CollectRecommendations(ctx context.Context) (*scheduler.CollectResult, error) ListRecommendations(ctx context.Context, filter config.RecommendationFilter) ([]config.RecommendationRecord, error) @@ -170,7 +170,7 @@ type SchedulerInterface interface { } // AuthServiceInterface defines auth service methods used by handler -// Note: This interface uses API-specific types that are converted from auth package types +// Note: This interface uses API-specific types that are converted from auth package types. type AuthServiceInterface interface { Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) Logout(ctx context.Context, token string) error @@ -224,7 +224,7 @@ type AuthServiceInterface interface { ValidateUserAPIKeyAPI(ctx context.Context, apiKey string) (any, any, error) } -// Auth request/response types (to avoid import cycle with auth package) +// Auth request/response types (to avoid import cycle with auth package). type LoginRequest struct { Email string `json:"email"` Password string `json:"password"` @@ -281,13 +281,13 @@ type CreateUserRequest struct { Groups []string `json:"groups,omitempty"` } -// UpdateUserRequest represents a request to update a user +// UpdateUserRequest represents a request to update a user. type UpdateUserRequest struct { Email string `json:"email,omitempty"` Groups []string `json:"groups,omitempty"` } -// Group represents a user group with permissions +// Group represents a user group with permissions. type Group struct { ID string `json:"id"` Name string `json:"name"` @@ -298,14 +298,14 @@ type Group struct { UpdatedAt string `json:"updated_at,omitempty"` } -// Permission represents an action that can be performed on a resource +// Permission represents an action that can be performed on a resource. type Permission struct { Action string `json:"action"` Resource string `json:"resource"` Constraints *PermissionConstraint `json:"constraints,omitempty"` } -// PermissionConstraint limits where a permission applies +// PermissionConstraint limits where a permission applies. type PermissionConstraint struct { Accounts []string `json:"accounts,omitempty"` Providers []string `json:"providers,omitempty"` @@ -314,7 +314,7 @@ type PermissionConstraint struct { MaxAmount float64 `json:"max_amount,omitempty"` } -// CreateGroupRequest represents a request to create a new group +// CreateGroupRequest represents a request to create a new group. type CreateGroupRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` @@ -322,7 +322,7 @@ type CreateGroupRequest struct { AllowedAccounts []string `json:"allowed_accounts,omitempty"` } -// UpdateGroupRequest represents a request to update a group +// UpdateGroupRequest represents a request to update a group. type UpdateGroupRequest struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` @@ -330,13 +330,13 @@ type UpdateGroupRequest struct { AllowedAccounts []string `json:"allowed_accounts,omitempty"` } -// ChangePasswordRequest represents a request to change password +// ChangePasswordRequest represents a request to change password. type ChangePasswordRequest struct { CurrentPassword string `json:"current_password"` NewPassword string `json:"new_password"` } -// ProfileUpdateRequest represents a profile update request +// ProfileUpdateRequest represents a profile update request. type ProfileUpdateRequest struct { Email string `json:"email"` CurrentPassword string `json:"current_password"` @@ -345,7 +345,7 @@ type ProfileUpdateRequest struct { // API Response types for type safety -// ConfigResponse holds the configuration response +// ConfigResponse holds the configuration response. type ConfigResponse struct { Global *config.GlobalConfig `json:"global"` Services []config.ServiceConfig `json:"services"` @@ -353,12 +353,12 @@ type ConfigResponse struct { SourceIdentity *sourceIdentity `json:"source_identity,omitempty"` } -// StatusResponse holds a simple status response +// StatusResponse holds a simple status response. type StatusResponse struct { Status string `json:"status"` } -// RecommendationsSummary holds aggregate statistics for recommendations +// RecommendationsSummary holds aggregate statistics for recommendations. type RecommendationsSummary struct { TotalCount int `json:"total_count"` TotalMonthlySavings float64 `json:"total_monthly_savings"` @@ -366,7 +366,7 @@ type RecommendationsSummary struct { AvgPaybackMonths float64 `json:"avg_payback_months"` } -// RecommendationsResponse holds the recommendations response +// RecommendationsResponse holds the recommendations response. type RecommendationsResponse struct { Recommendations []config.RecommendationRecord `json:"recommendations"` Summary RecommendationsSummary `json:"summary"` @@ -378,7 +378,7 @@ type RecommendationsResponse struct { // always ordered by Timestamp ascending. CPUPct/MemPct are 0..100. // // Empty in the current implementation: the collector pipeline does not -// yet persist time-series utilisation per recommendation. The endpoint +// yet persist time-series utilization per recommendation. The endpoint // returns the empty slice with a non-error status so the frontend can // render a "Usage history not yet available" placeholder rather than a // broken empty chart. See known_issues/28_recommendations_detail_endpoint.md @@ -394,7 +394,7 @@ type UsagePoint struct { // // ConfidenceBucket is "low" | "medium" | "high" — server-side mirror of // the client-side heuristic that previously lived in -// frontend/src/recommendations.ts:confidenceBucketFor. Centralising it +// frontend/src/recommendations.ts:confidenceBucketFor. Centralizing it // server-side lets future provider-specific tuning happen in one place. // // ProvenanceNote is a short human-readable string naming the collector @@ -412,12 +412,12 @@ type RecommendationDetailResponse struct { HiddenBy []string `json:"hidden_by,omitempty"` } -// PlansResponse holds the purchase plans response +// PlansResponse holds the purchase plans response. type PlansResponse struct { Plans []config.PurchasePlan `json:"plans"` } -// CurrentUserResponse holds the current user response +// CurrentUserResponse holds the current user response. type CurrentUserResponse struct { ID string `json:"id"` Email string `json:"email"` @@ -425,7 +425,7 @@ type CurrentUserResponse struct { MFAEnabled bool `json:"mfa_enabled"` } -// AdminExistsResponse holds the admin exists check response +// AdminExistsResponse holds the admin exists check response. type AdminExistsResponse struct { AdminExists bool `json:"admin_exists"` } @@ -452,7 +452,7 @@ type UserPermissionsResponse struct { // before handing to the auth service. // MFASetupRequest begins an MFA enrollment. Current password is -// required as defence-in-depth — a stolen session alone shouldn't +// required as defense-in-depth — a stolen session alone shouldn't // be enough to swap a user's MFA secret. type MFASetupRequest struct { Password string `json:"password"` @@ -500,7 +500,7 @@ type MFARegenerateResponse struct { RecoveryCodes []string `json:"recovery_codes"` } -// EmptyServiceConfigResponse represents an empty service config +// EmptyServiceConfigResponse represents an empty service config. type EmptyServiceConfigResponse struct{} // PublicInfoResponse holds public information about the CUDly instance. @@ -526,7 +526,7 @@ type DeploymentInfoResponse struct { DeploymentAWSAccountID string `json:"deployment_aws_account_id,omitempty"` } -// DashboardSummaryResponse holds the dashboard summary data +// DashboardSummaryResponse holds the dashboard summary data. type DashboardSummaryResponse struct { PotentialMonthlySavings float64 `json:"potential_monthly_savings"` TotalRecommendations int `json:"total_recommendations"` @@ -538,7 +538,7 @@ type DashboardSummaryResponse struct { ByService map[string]ServiceSavings `json:"by_service"` } -// ServiceSavings holds savings data for a service +// ServiceSavings holds savings data for a service. type ServiceSavings struct { PotentialSavings float64 `json:"potential_savings"` CurrentSavings float64 `json:"current_savings"` @@ -619,7 +619,7 @@ type CoverageBreakdownResponse struct { Providers []ProviderCoverageSection `json:"providers"` } -// UpcomingPurchaseResponse holds upcoming purchase data +// UpcomingPurchaseResponse holds upcoming purchase data. type UpcomingPurchaseResponse struct { Purchases []UpcomingPurchase `json:"purchases"` } @@ -659,12 +659,12 @@ type UpcomingPurchase struct { CreatedByUserID *string `json:"created_by_user_id,omitempty"` } -// PlannedPurchasesResponse holds the list of planned purchases +// PlannedPurchasesResponse holds the list of planned purchases. type PlannedPurchasesResponse struct { Purchases []PlannedPurchase `json:"purchases"` } -// PlannedPurchase represents a scheduled purchase from a plan +// PlannedPurchase represents a scheduled purchase from a plan. type PlannedPurchase struct { ID string `json:"id"` PlanID string `json:"plan_id"` @@ -690,7 +690,7 @@ type PlannedPurchase struct { } // PlanRequest represents the API request format for creating/updating plans -// The frontend sends ramp_schedule as a string, which we convert to the proper struct +// The frontend sends ramp_schedule as a string, which we convert to the proper struct. type PlanRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` @@ -717,7 +717,7 @@ type PlanRequest struct { TargetAccounts []string `json:"target_accounts,omitempty"` } -// toPurchasePlan converts a PlanRequest to a config.PurchasePlan +// toPurchasePlan converts a PlanRequest to a config.PurchasePlan. func (r *PlanRequest) toPurchasePlan() *config.PurchasePlan { now := time.Now() plan := &config.PurchasePlan{ @@ -736,7 +736,7 @@ func (r *PlanRequest) toPurchasePlan() *config.PurchasePlan { return plan } -// buildRampSchedule builds the ramp schedule from request parameters +// buildRampSchedule builds the ramp schedule from request parameters. func (r *PlanRequest) buildRampSchedule(now time.Time) config.RampSchedule { if preset, ok := config.PresetRampSchedules[r.RampSchedule]; ok { preset.StartDate = now @@ -753,7 +753,7 @@ func (r *PlanRequest) buildRampSchedule(now time.Time) config.RampSchedule { return schedule } -// buildCustomRampSchedule builds a custom ramp schedule with validated parameters +// buildCustomRampSchedule builds a custom ramp schedule with validated parameters. func (r *PlanRequest) buildCustomRampSchedule(now time.Time) config.RampSchedule { stepPercent := float64(r.CustomStepPercent) if stepPercent <= 0 { @@ -779,7 +779,7 @@ func (r *PlanRequest) buildCustomRampSchedule(now time.Time) config.RampSchedule } } -// buildServiceConfig creates service configuration from request fields +// buildServiceConfig creates service configuration from request fields. func (r *PlanRequest) buildServiceConfig() map[string]config.ServiceConfig { if r.Provider == "" || r.Service == "" { return nil @@ -812,7 +812,7 @@ func (r *PlanRequest) buildServiceConfig() map[string]config.ServiceConfig { } } -// calculateNextExecutionDate determines the next execution date based on ramp schedule +// calculateNextExecutionDate determines the next execution date based on ramp schedule. func (r *PlanRequest) calculateNextExecutionDate(now time.Time, schedule config.RampSchedule) *time.Time { var nextDate time.Time @@ -827,18 +827,18 @@ func (r *PlanRequest) calculateNextExecutionDate(now time.Time, schedule config. return &nextDate } -// CreatePlannedPurchasesRequest represents a request to create planned purchases +// CreatePlannedPurchasesRequest represents a request to create planned purchases. type CreatePlannedPurchasesRequest struct { Count int `json:"count"` StartDate string `json:"start_date"` } -// CreatePlannedPurchasesResponse represents the response after creating planned purchases +// CreatePlannedPurchasesResponse represents the response after creating planned purchases. type CreatePlannedPurchasesResponse struct { Created int `json:"created"` } -// HistoryResponse represents the response from the history API +// HistoryResponse represents the response from the history API. type HistoryResponse struct { Summary HistorySummary `json:"summary"` Purchases []config.PurchaseHistoryRecord `json:"purchases"` @@ -848,14 +848,14 @@ type HistoryResponse struct { // TotalPurchases is the total count of rows (completed + all non-completed // states); the per-state counters break it down so the UI can render // meaningful totals. Dollar totals count completed rows only: pending, -// in-progress, failed, expired, and cancelled rows are all excluded because +// in-progress, failed, expired, and canceled rows are all excluded because // no money was committed for any of those states. type HistorySummary struct { TotalPurchases int `json:"total_purchases"` TotalCompleted int `json:"total_completed"` TotalPending int `json:"total_pending"` // TotalInProgress counts executions that have been approved but whose - // synchronous purchase has not finalised (status approved/running/paused). + // synchronous purchase has not finalized (status approved/running/paused). // Tracked separately from pending and excluded from the dollar totals so an // interrupted approval (issue #621) stays visible without inflating // committed spend/savings. diff --git a/internal/api/types_apikeys.go b/internal/api/types_apikeys.go index c4c78c1e7..840beaa7f 100644 --- a/internal/api/types_apikeys.go +++ b/internal/api/types_apikeys.go @@ -2,21 +2,21 @@ package api import "time" -// CreateAPIKeyRequest represents a request to create a new API key +// CreateAPIKeyRequest represents a request to create a new API key. type CreateAPIKeyRequest struct { Name string `json:"name"` Permissions []Permission `json:"permissions,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` } -// CreateAPIKeyResponse returns the newly created API key (only shown once) +// CreateAPIKeyResponse returns the newly created API key (only shown once). type CreateAPIKeyResponse struct { APIKey string `json:"api_key"` // Full key - only returned on creation KeyID string `json:"key_id"` Info *APIKeyInfo `json:"info"` } -// APIKeyInfo represents public information about an API key +// APIKeyInfo represents public information about an API key. type APIKeyInfo struct { ID string `json:"id"` Name string `json:"name"` diff --git a/internal/api/validation.go b/internal/api/validation.go index 460f2b59d..8a8e1d656 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -15,15 +15,15 @@ import ( "github.com/LeanerCloud/CUDly/internal/config" ) -// Security constants +// Security constants. const ( - // MaxRequestBodySize is the maximum allowed request body size (1MB) + // MaxRequestBodySize is the maximum allowed request body size (1MB). MaxRequestBodySize = 1 * 1024 * 1024 ) // Input validation helpers -// uuidRegex validates UUID format (used for path parameters) +// uuidRegex validates UUID format (used for path parameters). var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) // gcpClientEmailRegex matches a GCP service-account email. @@ -51,7 +51,7 @@ var awsWebIdentityTokenFilePrefixes = []string{ "/var/run/secrets/kubernetes.io/serviceaccount/", } -// validProviders are the allowed provider values +// validProviders are the allowed provider values. var validProviders = map[string]bool{ "": true, // empty is allowed (means all) "all": true, @@ -64,7 +64,7 @@ var validProviders = map[string]bool{ // Uppercase is rejected to prevent stored-XSS via mixed-case surprises and to keep names consistent. var serviceNameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) -// regionNameRegex validates AWS/Azure/GCP region names - requires at least one character +// regionNameRegex validates AWS/Azure/GCP region names - requires at least one character. var regionNameRegex = regexp.MustCompile(`^[a-z0-9-]+$`) // validateGCPClientEmail returns a 400 error when gcp_client_email is non-empty @@ -118,7 +118,7 @@ func validateAWSWebIdentityTokenFile(path string) error { "/var/run/secrets/kubernetes.io/serviceaccount/)") } -// validateProvider checks if a provider value is valid +// validateProvider checks if a provider value is valid. func validateProvider(provider string) error { if !validProviders[provider] { return NewClientError(400, "invalid provider: must be aws, azure, gcp, or all") @@ -324,7 +324,7 @@ func payloadDepth(m map[string]interface{}, current int) int { return max } -// validateServiceName checks if a service name is valid +// validateServiceName checks if a service name is valid. func validateServiceName(service string) error { // Empty is allowed for queries (means all services) if service == "" { @@ -343,7 +343,7 @@ func validateServiceName(service string) error { return nil } -// validateRegion checks if a region name is valid +// validateRegion checks if a region name is valid. func validateRegion(region string) error { // Empty is allowed for queries (means all regions) if region == "" { @@ -362,7 +362,7 @@ func validateRegion(region string) error { return nil } -// validateServicePath checks for path traversal attacks in service paths +// validateServicePath checks for path traversal attacks in service paths. func validateServicePath(service string) error { // Reject path traversal attempts if strings.Contains(service, "..") { @@ -388,7 +388,7 @@ func validateServicePath(service string) error { return nil } -// validateUUID checks if a string is a valid UUID +// validateUUID checks if a string is a valid UUID. func validateUUID(id string) error { if !uuidRegex.MatchString(id) { return NewClientError(400, "invalid ID format: must be a valid UUID") @@ -407,7 +407,7 @@ func validUUIDPtrOrNil(p *string) *string { return p } -// validateContentType checks if the Content-Type header is acceptable for the request +// validateContentType checks if the Content-Type header is acceptable for the request. func validateContentType(req *events.LambdaFunctionURLRequest) error { method := req.RequestContext.HTTP.Method // Only POST/PUT/PATCH with bodies need content-type validation @@ -441,7 +441,7 @@ func validateContentType(req *events.LambdaFunctionURLRequest) error { return NewClientError(400, "unsupported Content-Type: must be application/json") } -// validateRequestBodySize checks if the request body is within allowed limits +// validateRequestBodySize checks if the request body is within allowed limits. func validateRequestBodySize(body string) error { if len(body) > MaxRequestBodySize { return NewClientError(400, fmt.Sprintf("request body too large: maximum size is %d bytes", MaxRequestBodySize)) diff --git a/internal/auth/interfaces.go b/internal/auth/interfaces.go index eff3a8d57..8e84c33b2 100644 --- a/internal/auth/interfaces.go +++ b/internal/auth/interfaces.go @@ -4,7 +4,7 @@ import ( "context" ) -// StoreInterface defines the methods required for auth storage +// StoreInterface defines the methods required for auth storage. type StoreInterface interface { // User operations GetUserByID(ctx context.Context, userID string) (*User, error) @@ -55,7 +55,7 @@ type StoreInterface interface { Ping(ctx context.Context) error } -// EmailSenderInterface defines the methods required for sending emails +// EmailSenderInterface defines the methods required for sending emails. type EmailSenderInterface interface { SendPasswordResetEmail(ctx context.Context, email, resetURL string) error SendWelcomeEmail(ctx context.Context, email, dashboardURL, role string) error diff --git a/internal/auth/service.go b/internal/auth/service.go index 90c91dafe..9da129dbc 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "crypto/subtle" + "errors" "fmt" "net/mail" "strings" @@ -13,9 +14,9 @@ import ( "golang.org/x/sync/singleflight" ) -// Configuration constants +// Configuration constants. const ( - // PasswordResetExpiry is how long password reset tokens are valid + // PasswordResetExpiry is how long password reset tokens are valid. PasswordResetExpiry = 1 * time.Hour // PasswordSetupExpiry is how long an invited user has to set their @@ -24,14 +25,14 @@ const ( // before the recipient acts on them. PasswordSetupExpiry = 7 * 24 * time.Hour - // DefaultSessionDurationHours is the default session duration in hours + // DefaultSessionDurationHours is the default session duration in hours. DefaultSessionDurationHours = 24 // Account lockout settings for brute-force protection - // MaxFailedLoginAttempts is the number of failed attempts before lockout + // MaxFailedLoginAttempts is the number of failed attempts before lockout. MaxFailedLoginAttempts = 5 - // AccountLockoutDuration is how long an account is locked after max failed attempts + // AccountLockoutDuration is how long an account is locked after max failed attempts. AccountLockoutDuration = 15 * time.Minute // genericLoginError is returned for every authentication failure so callers @@ -40,7 +41,7 @@ const ( genericLoginError = "Check your email address and password and try again" ) -// Service handles authentication and authorization +// Service handles authentication and authorization. type Service struct { store StoreInterface emailSender EmailSenderInterface @@ -59,7 +60,7 @@ type Service struct { lastUsedSFG singleflight.Group } -// ServiceConfig holds configuration for the auth service +// ServiceConfig holds configuration for the auth service. type ServiceConfig struct { Store StoreInterface EmailSender EmailSenderInterface @@ -73,7 +74,7 @@ type ServiceConfig struct { CSRFKey []byte } -// NewService creates a new auth service +// NewService creates a new auth service. func NewService(cfg ServiceConfig) *Service { if cfg.SessionDuration == 0 { cfg.SessionDuration = time.Duration(DefaultSessionDurationHours) * time.Hour @@ -127,14 +128,14 @@ func NewService(cfg ServiceConfig) *Service { } } -// notifyPasswordChange calls the password change callback if configured +// notifyPasswordChange calls the password change callback if configured. func (s *Service) notifyPasswordChange(ctx context.Context, userID, newPassword string) { if s.onPasswordChange != nil { s.onPasswordChange(ctx, userID, newPassword) } } -// ensureStore returns an error if the auth store is not initialized +// ensureStore returns an error if the auth store is not initialized. func (s *Service) ensureStore() error { if s.store == nil { return fmt.Errorf("auth store not initialized") @@ -142,7 +143,7 @@ func (s *Service) ensureStore() error { return nil } -// Login authenticates a user and creates a session +// Login authenticates a user and creates a session. func (s *Service) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) { if err := s.ensureStore(); err != nil { return nil, err @@ -173,26 +174,26 @@ func (s *Service) Login(ctx context.Context, req LoginRequest) (*LoginResponse, return s.completeSuccessfulLogin(ctx, user) } -// getUserAndValidateStatus retrieves user and checks if account is active and unlocked +// getUserAndValidateStatus retrieves user and checks if account is active and unlocked. func (s *Service) getUserAndValidateStatus(ctx context.Context, email string) (*User, error) { user, err := s.store.GetUserByEmail(ctx, email) if err != nil || user == nil { // Return the same generic message for both "user not found" and store // errors so callers cannot distinguish a missing account from a DB // failure (issue #416). The caller (Login) runs a dummy bcrypt compare - // after this to equalise response time with the wrong-password path. - return nil, fmt.Errorf(genericLoginError) + // after this to equalize response time with the wrong-password path. + return nil, errors.New(genericLoginError) } if !user.Active { - return nil, fmt.Errorf(genericLoginError) + return nil, errors.New(genericLoginError) } if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) { remainingTime := time.Until(*user.LockedUntil).Round(time.Minute) // Omit user.ID from log to avoid leaking internal identifiers to log logging.Warnf("Login attempt for locked account (locked for %v more)", remainingTime) - return nil, fmt.Errorf(genericLoginError) + return nil, errors.New(genericLoginError) } // NOTE: when LockedUntil is set but the window has already expired, the user falls // through here with FailedLoginAttempts and LockedUntil still set in memory. @@ -225,12 +226,12 @@ func (s *Service) verifyPasswordAndMFA(ctx context.Context, user *User, req Logi // Both branches return the same message as the "user not found" path so the // full login failure surface is uniform (issue #416). if user.PasswordHash == "" { - return fmt.Errorf(genericLoginError) + return errors.New(genericLoginError) } if !s.verifyPassword(req.Password, user.PasswordHash) { s.recordFailedLogin(ctx, user) - return fmt.Errorf(genericLoginError) + return errors.New(genericLoginError) } if user.MFAEnabled { @@ -242,7 +243,7 @@ func (s *Service) verifyPasswordAndMFA(ctx context.Context, user *User, req Logi // Log internally for operator visibility without leaking internal state // to the caller -- a distinct message would confirm the password was correct. logging.Errorf("MFA enabled but secret missing for user %s -- possible data integrity issue", user.ID) - return fmt.Errorf(genericLoginError) + return errors.New(genericLoginError) } // verifyTOTP fails closed on empty or malformed inputs: empty code, empty // secret, and base32-decode errors all return false rather than a match. @@ -269,7 +270,7 @@ func (s *Service) verifyPasswordAndMFA(ctx context.Context, user *User, req Logi return nil } -// completeSuccessfulLogin creates session and updates user login info +// completeSuccessfulLogin creates session and updates user login info. func (s *Service) completeSuccessfulLogin(ctx context.Context, user *User) (*LoginResponse, error) { session, err := s.createSession(ctx, user, "", "") if err != nil { @@ -301,7 +302,7 @@ func (s *Service) completeSuccessfulLogin(ctx context.Context, user *User) (*Log }, nil } -// Logout invalidates a session +// Logout invalidates a session. func (s *Service) Logout(ctx context.Context, token string) error { if err := s.ensureStore(); err != nil { return err @@ -313,7 +314,7 @@ func (s *Service) Logout(ctx context.Context, token string) error { return s.store.DeleteSession(ctx, hashedToken) } -// ValidateSession checks if a session is valid and returns user info +// ValidateSession checks if a session is valid and returns user info. func (s *Service) ValidateSession(ctx context.Context, token string) (*Session, error) { if err := s.ensureStore(); err != nil { return nil, err @@ -377,7 +378,7 @@ func (s *Service) ValidateCSRFToken(ctx context.Context, sessionToken, csrfToken return nil } -// CleanupExpiredSessions removes expired sessions from the store +// CleanupExpiredSessions removes expired sessions from the store. func (s *Service) CleanupExpiredSessions(ctx context.Context) error { if err := s.ensureStore(); err != nil { return err @@ -385,7 +386,7 @@ func (s *Service) CleanupExpiredSessions(ctx context.Context) error { return s.store.CleanupExpiredSessions(ctx) } -// Ping checks the health of the auth store database connection +// Ping checks the health of the auth store database connection. func (s *Service) Ping(ctx context.Context) error { if err := s.ensureStore(); err != nil { return err diff --git a/internal/auth/service_api.go b/internal/auth/service_api.go index de98144b3..df1eca79a 100644 --- a/internal/auth/service_api.go +++ b/internal/auth/service_api.go @@ -40,14 +40,14 @@ type APIGroup struct { UpdatedAt string `json:"updated_at,omitempty"` } -// APIPermission is the permission type for API responses +// APIPermission is the permission type for API responses. type APIPermission struct { Action string `json:"action"` Resource string `json:"resource"` Constraints *APIPermissionConstraint `json:"constraints,omitempty"` } -// APIPermissionConstraint is the permission constraint type for API responses +// APIPermissionConstraint is the permission constraint type for API responses. type APIPermissionConstraint struct { Accounts []string `json:"accounts,omitempty"` Providers []string `json:"providers,omitempty"` @@ -91,7 +91,7 @@ type APIUpdateUserRequest struct { Groups []string `json:"groups,omitempty"` } -// APICreateGroupRequest is the request type for creating groups via API +// APICreateGroupRequest is the request type for creating groups via API. type APICreateGroupRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` @@ -198,7 +198,7 @@ func apiPermissionToPermission(ap APIPermission) Permission { // API adapter methods - these implement the AuthServiceInterface from handler.go // They use any to avoid import cycles with the api package -// CreateUserAPI creates a new user via the API +// CreateUserAPI creates a new user via the API. func (s *Service) CreateUserAPI(ctx context.Context, reqInterface any) (any, error) { req, ok := reqInterface.(APICreateUserRequest) if !ok { @@ -246,7 +246,7 @@ func (s *Service) UpdateUserAPI(ctx context.Context, actorUserID, userID string, return userToAPIUser(user), nil } -// ListUsersAPI returns all users via the API +// ListUsersAPI returns all users via the API. func (s *Service) ListUsersAPI(ctx context.Context) (any, error) { users, err := s.ListUsers(ctx) if err != nil { @@ -259,7 +259,7 @@ func (s *Service) ListUsersAPI(ctx context.Context) (any, error) { return result, nil } -// ChangePasswordAPI changes a user's password via the API +// ChangePasswordAPI changes a user's password via the API. func (s *Service) ChangePasswordAPI(ctx context.Context, userID, currentPassword, newPassword string) error { req := ChangePasswordRequest{ CurrentPassword: currentPassword, @@ -268,7 +268,7 @@ func (s *Service) ChangePasswordAPI(ctx context.Context, userID, currentPassword return s.ChangePassword(ctx, userID, req) } -// CreateGroupAPI creates a new group via the API +// CreateGroupAPI creates a new group via the API. func (s *Service) CreateGroupAPI(ctx context.Context, reqInterface any) (any, error) { req, ok := reqInterface.(APICreateGroupRequest) if !ok { @@ -291,7 +291,7 @@ func (s *Service) CreateGroupAPI(ctx context.Context, reqInterface any) (any, er return groupToAPIGroup(group), nil } -// UpdateGroupAPI updates a group via the API +// UpdateGroupAPI updates a group via the API. func (s *Service) UpdateGroupAPI(ctx context.Context, groupID string, reqInterface any) (any, error) { req, ok := reqInterface.(APIUpdateGroupRequest) if !ok { @@ -329,7 +329,7 @@ func (s *Service) UpdateGroupAPI(ctx context.Context, groupID string, reqInterfa return groupToAPIGroup(group), nil } -// GetGroupAPI returns a group by ID via the API +// GetGroupAPI returns a group by ID via the API. func (s *Service) GetGroupAPI(ctx context.Context, groupID string) (any, error) { group, err := s.GetGroup(ctx, groupID) if err != nil { @@ -341,7 +341,7 @@ func (s *Service) GetGroupAPI(ctx context.Context, groupID string) (any, error) return groupToAPIGroup(group), nil } -// ListGroupsAPI returns all groups via the API +// ListGroupsAPI returns all groups via the API. func (s *Service) ListGroupsAPI(ctx context.Context) (any, error) { groups, err := s.ListGroups(ctx) if err != nil { @@ -354,7 +354,7 @@ func (s *Service) ListGroupsAPI(ctx context.Context) (any, error) { return result, nil } -// HasPermissionAPI checks if a user has a specific permission via the API +// HasPermissionAPI checks if a user has a specific permission via the API. func (s *Service) HasPermissionAPI(ctx context.Context, userID, action, resource string) (bool, error) { return s.HasPermission(ctx, userID, action, resource, nil) } diff --git a/internal/auth/service_api_test.go b/internal/auth/service_api_test.go index 6d00ad7e5..7956accc2 100644 --- a/internal/auth/service_api_test.go +++ b/internal/auth/service_api_test.go @@ -127,7 +127,7 @@ func TestConversionHelpers(t *testing.T) { }) } -// Test API adapter methods +// Test API adapter methods. func TestService_CreateUserAPI(t *testing.T) { ctx := context.Background() @@ -640,7 +640,7 @@ func TestService_HasPermissionAPI(t *testing.T) { func TestUserToAPIUser_EmptyGroups(t *testing.T) { now := time.Now() - t.Run("nil GroupIDs serialises as []", func(t *testing.T) { + t.Run("nil GroupIDs serializes as []", func(t *testing.T) { user := &User{ ID: "user-1", Email: "user@example.com", @@ -661,7 +661,7 @@ func TestUserToAPIUser_EmptyGroups(t *testing.T) { assert.NotContains(t, string(b), `"groups":null`) }) - t.Run("empty-slice GroupIDs serialises as []", func(t *testing.T) { + t.Run("empty-slice GroupIDs serializes as []", func(t *testing.T) { user := &User{ ID: "user-2", Email: "user2@example.com", @@ -699,7 +699,7 @@ func TestUserToAPIUser_EmptyGroups(t *testing.T) { func TestGroupToAPIGroup_EmptyAllowedAccounts(t *testing.T) { now := time.Now() - t.Run("nil AllowedAccounts serialises as []", func(t *testing.T) { + t.Run("nil AllowedAccounts serializes as []", func(t *testing.T) { g := &Group{ ID: "group-1", Name: "Empty", diff --git a/internal/auth/service_apikeys.go b/internal/auth/service_apikeys.go index cae140002..882737ebb 100644 --- a/internal/auth/service_apikeys.go +++ b/internal/auth/service_apikeys.go @@ -15,7 +15,7 @@ import ( ) // CreateAPIKey creates a new user API key with scoped permissions -// Returns the full API key (shown only once), key info, and error +// Returns the full API key (shown only once), key info, and error. func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, permissions []Permission, expiresAt *time.Time) (string, *UserAPIKey, error) { // Validate user exists and is active user, err := s.store.GetUserByID(ctx, userID) @@ -111,7 +111,7 @@ func (s *Service) validateAPIKeyPermissions(ctx context.Context, user *User, per return nil } -// ListUserAPIKeys retrieves all API keys for a user +// ListUserAPIKeys retrieves all API keys for a user. func (s *Service) ListUserAPIKeys(ctx context.Context, userID string) ([]*UserAPIKey, error) { // Validate user exists user, err := s.store.GetUserByID(ctx, userID) @@ -133,7 +133,7 @@ func (s *Service) ListUserAPIKeys(ctx context.Context, userID string) ([]*UserAP return keys, nil } -// GetAPIKeyByHash retrieves an API key by its hash (for authentication) +// GetAPIKeyByHash retrieves an API key by its hash (for authentication). func (s *Service) GetAPIKeyByHash(ctx context.Context, keyHash string) (*UserAPIKey, error) { key, err := s.store.GetAPIKeyByHash(ctx, keyHash) if err != nil { @@ -188,7 +188,7 @@ func (s *Service) authorizeAPIKeyAccess(ctx context.Context, userID, keyID, acti return key, nil } -// RevokeAPIKey deactivates an API key (soft delete) +// RevokeAPIKey deactivates an API key (soft delete). func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error { key, err := s.authorizeAPIKeyAccess(ctx, userID, keyID, "revoke") if err != nil { @@ -206,7 +206,7 @@ func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error return nil } -// DeleteAPIKey permanently deletes an API key +// DeleteAPIKey permanently deletes an API key. func (s *Service) DeleteAPIKey(ctx context.Context, userID, keyID string) error { key, err := s.authorizeAPIKeyAccess(ctx, userID, keyID, "delete") if err != nil { @@ -252,7 +252,7 @@ func (s *Service) lookupAPIKeyUser(ctx context.Context, userID string) (*User, e return user, nil } -// ValidateUserAPIKey validates an API key and returns the key info and associated user +// ValidateUserAPIKey validates an API key and returns the key info and associated user. func (s *Service) ValidateUserAPIKey(ctx context.Context, apiKey string) (*UserAPIKey, *User, error) { hash := sha256.Sum256([]byte(apiKey)) keyHash := base64.RawURLEncoding.EncodeToString(hash[:]) @@ -296,7 +296,7 @@ func (s *Service) ValidateUserAPIKey(ctx context.Context, apiKey string) (*UserA return key, user, nil } -// UpdateLastUsed updates the last used timestamp for an API key atomically +// UpdateLastUsed updates the last used timestamp for an API key atomically. func (s *Service) UpdateLastUsed(ctx context.Context, keyID string) error { return s.store.UpdateAPIKeyLastUsed(ctx, keyID) } @@ -307,7 +307,7 @@ func (s *Service) UpdateLastUsed(ctx context.Context, keyID string) error { // Administrators-group members carry {admin, *}: with no key-specific // permissions their full {admin, *} context is returned, and a scoped admin // key's permissions all pass the HasPermission intersection below, so the -// group-derived path preserves the previous role == admin behaviour without a +// group-derived path preserves the previous role == admin behavior without a // special case. func (s *Service) ComputeEffectivePermissions(ctx context.Context, apiKey *UserAPIKey, user *User) ([]Permission, error) { // Get user's auth context diff --git a/internal/auth/service_apikeys_api.go b/internal/auth/service_apikeys_api.go index a3ebd6181..cc47ce87b 100644 --- a/internal/auth/service_apikeys_api.go +++ b/internal/auth/service_apikeys_api.go @@ -9,14 +9,14 @@ import ( // API wrapper methods for API key operations // These methods return API-friendly types and handle type conversions -// APICreateAPIKeyRequest represents the API request to create an API key +// APICreateAPIKeyRequest represents the API request to create an API key. type APICreateAPIKeyRequest struct { Name string `json:"name"` Permissions []Permission `json:"permissions,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` } -// APIKeyInfo represents public API key information (without sensitive data) +// APIKeyInfo represents public API key information (without sensitive data). type APIKeyInfo struct { ID string `json:"id"` Name string `json:"name"` @@ -28,19 +28,19 @@ type APIKeyInfo struct { IsActive bool `json:"is_active"` } -// APICreateAPIKeyResponse represents the API response for creating an API key +// APICreateAPIKeyResponse represents the API response for creating an API key. type APICreateAPIKeyResponse struct { APIKey string `json:"api_key"` // Full key - only returned once KeyID string `json:"key_id"` Info *APIKeyInfo `json:"info"` } -// APIListAPIKeysResponse represents the API response for listing API keys +// APIListAPIKeysResponse represents the API response for listing API keys. type APIListAPIKeysResponse struct { APIKeys []*APIKeyInfo `json:"api_keys"` } -// CreateAPIKeyAPI creates a new API key and returns API-friendly response +// CreateAPIKeyAPI creates a new API key and returns API-friendly response. func (s *Service) CreateAPIKeyAPI(ctx context.Context, userID string, req any) (any, error) { // Type assert the request createReq, ok := req.(APICreateAPIKeyRequest) @@ -71,7 +71,7 @@ func (s *Service) CreateAPIKeyAPI(ctx context.Context, userID string, req any) ( }, nil } -// ListUserAPIKeysAPI lists all API keys for a user and returns API-friendly response +// ListUserAPIKeysAPI lists all API keys for a user and returns API-friendly response. func (s *Service) ListUserAPIKeysAPI(ctx context.Context, userID string) (any, error) { keys, err := s.ListUserAPIKeys(ctx, userID) if err != nil { @@ -98,18 +98,18 @@ func (s *Service) ListUserAPIKeysAPI(ctx context.Context, userID string) (any, e }, nil } -// DeleteAPIKeyAPI deletes an API key +// DeleteAPIKeyAPI deletes an API key. func (s *Service) DeleteAPIKeyAPI(ctx context.Context, userID, keyID string) error { return s.DeleteAPIKey(ctx, userID, keyID) } -// RevokeAPIKeyAPI revokes an API key +// RevokeAPIKeyAPI revokes an API key. func (s *Service) RevokeAPIKeyAPI(ctx context.Context, userID, keyID string) error { return s.RevokeAPIKey(ctx, userID, keyID) } // ValidateUserAPIKeyAPI validates a user API key and returns the key info and user -// This is the API-facing wrapper for ValidateUserAPIKey +// This is the API-facing wrapper for ValidateUserAPIKey. func (s *Service) ValidateUserAPIKeyAPI(ctx context.Context, apiKey string) (*UserAPIKey, *User, error) { return s.ValidateUserAPIKey(ctx, apiKey) } diff --git a/internal/auth/service_group.go b/internal/auth/service_group.go index a7766b234..9a983ae53 100644 --- a/internal/auth/service_group.go +++ b/internal/auth/service_group.go @@ -10,7 +10,7 @@ import ( "github.com/jackc/pgx/v5" ) -// CreateGroup creates a new permission group +// CreateGroup creates a new permission group. func (s *Service) CreateGroup(ctx context.Context, group *Group, createdBy string) error { now := time.Now() // Generate a plain UUID. The previous "group-" prefix produced @@ -25,22 +25,22 @@ func (s *Service) CreateGroup(ctx context.Context, group *Group, createdBy strin return s.store.CreateGroup(ctx, group) } -// UpdateGroup updates a permission group +// UpdateGroup updates a permission group. func (s *Service) UpdateGroup(ctx context.Context, group *Group) error { return s.store.UpdateGroup(ctx, group) } -// DeleteGroup removes a permission group +// DeleteGroup removes a permission group. func (s *Service) DeleteGroup(ctx context.Context, groupID string) error { return s.store.DeleteGroup(ctx, groupID) } -// GetGroup returns a group by ID +// GetGroup returns a group by ID. func (s *Service) GetGroup(ctx context.Context, groupID string) (*Group, error) { return s.store.GetGroup(ctx, groupID) } -// ListGroups returns all groups +// ListGroups returns all groups. func (s *Service) ListGroups(ctx context.Context) ([]Group, error) { return s.store.ListGroups(ctx) } @@ -149,12 +149,12 @@ func (s *Service) collectGroupsAndAccounts(ctx context.Context, authCtx *AuthCon return nil } -// GetAuthContext is an alias for BuildAuthContext for backward compatibility +// GetAuthContext is an alias for BuildAuthContext for backward compatibility. func (s *Service) GetAuthContext(ctx context.Context, userID string) (*AuthContext, error) { return s.BuildAuthContext(ctx, userID) } -// HasPermission checks if a user has a specific permission +// HasPermission checks if a user has a specific permission. func (s *Service) HasPermission(ctx context.Context, userID, action, resource string, constraints *PermissionConstraints) (bool, error) { permissions, err := s.GetUserPermissions(ctx, userID) if err != nil { @@ -220,7 +220,7 @@ func checkPermissionConstraints(s *Service, perm Permission, constraints *Permis return true } -// matchConstraints checks if permission constraints match request constraints +// matchConstraints checks if permission constraints match request constraints. func (s *Service) matchConstraints(permConstraints, reqConstraints *PermissionConstraints) bool { return s.matchStringListConstraints(permConstraints.AccountIDs, reqConstraints.AccountIDs) && s.matchStringListConstraints(permConstraints.Providers, reqConstraints.Providers) && @@ -249,7 +249,7 @@ func (s *Service) matchStringListConstraints(permList, reqList []string) bool { return true } -// matchPurchaseAmountConstraint checks if requested amount is within permitted limit +// matchPurchaseAmountConstraint checks if requested amount is within permitted limit. func (s *Service) matchPurchaseAmountConstraint(permMax, reqMax float64) bool { if permMax > 0 && reqMax > permMax { return false diff --git a/internal/auth/service_helpers.go b/internal/auth/service_helpers.go index 331e2665b..35eeaad34 100644 --- a/internal/auth/service_helpers.go +++ b/internal/auth/service_helpers.go @@ -63,7 +63,7 @@ func deriveCSRFToken(csrfKey []byte, rawSessionToken string) string { return hex.EncodeToString(mac.Sum(nil)) } -// createSession creates a new session for a user +// createSession creates a new session for a user. func (s *Service) createSession(ctx context.Context, user *User, userAgent, ipAddress string) (*Session, error) { // Generate a cryptographically random session token rawToken, err := generateToken() @@ -126,7 +126,7 @@ func generateToken() (string, error) { return base64.RawURLEncoding.EncodeToString(b), nil } -// containsAny checks if any element from requested is in allowed +// containsAny checks if any element from requested is in allowed. func containsAny(allowed, requested []string) bool { allowedSet := make(map[string]bool) for _, a := range allowed { diff --git a/internal/auth/service_lockout_test.go b/internal/auth/service_lockout_test.go index 85591929b..4e75534ac 100644 --- a/internal/auth/service_lockout_test.go +++ b/internal/auth/service_lockout_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -// TestLogin_AccountLockout_BeforePasswordCheck verifies lockout check happens before password verification +// TestLogin_AccountLockout_BeforePasswordCheck verifies lockout check happens before password verification. func TestLogin_AccountLockout_BeforePasswordCheck(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) @@ -43,7 +43,7 @@ func TestLogin_AccountLockout_BeforePasswordCheck(t *testing.T) { mockStore.AssertNotCalled(t, "UpdateUser", ctx, mock.Anything) } -// TestLogin_AccountLockout_FailedAttempts verifies lockout occurs after max failed attempts +// TestLogin_AccountLockout_FailedAttempts verifies lockout occurs after max failed attempts. func TestLogin_AccountLockout_FailedAttempts(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) @@ -80,7 +80,7 @@ func TestLogin_AccountLockout_FailedAttempts(t *testing.T) { mockStore.AssertExpectations(t) } -// TestLogin_AccountLockout_Duration verifies lockout duration is correct +// TestLogin_AccountLockout_Duration verifies lockout duration is correct. func TestLogin_AccountLockout_Duration(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) @@ -117,7 +117,7 @@ func TestLogin_AccountLockout_Duration(t *testing.T) { mockStore.AssertExpectations(t) } -// TestLogin_AccountLockout_ExpiredLock verifies expired lockouts allow login +// TestLogin_AccountLockout_ExpiredLock verifies expired lockouts allow login. func TestLogin_AccountLockout_ExpiredLock(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) @@ -147,7 +147,7 @@ func TestLogin_AccountLockout_ExpiredLock(t *testing.T) { mockStore.AssertExpectations(t) } -// TestLogin_AccountLockout_ResetOnSuccess verifies successful login resets failed attempts +// TestLogin_AccountLockout_ResetOnSuccess verifies successful login resets failed attempts. func TestLogin_AccountLockout_ResetOnSuccess(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) @@ -183,7 +183,7 @@ func TestLogin_AccountLockout_ResetOnSuccess(t *testing.T) { mockStore.AssertExpectations(t) } -// TestLogin_AccountLockout_IncrementalFailures verifies each failure increments counter +// TestLogin_AccountLockout_IncrementalFailures verifies each failure increments counter. func TestLogin_AccountLockout_IncrementalFailures(t *testing.T) { ctx := context.Background() @@ -228,7 +228,7 @@ func TestLogin_AccountLockout_IncrementalFailures(t *testing.T) { } } -// TestLogin_AccountLockout_MFAFailure verifies MFA failures count toward lockout +// TestLogin_AccountLockout_MFAFailure verifies MFA failures count toward lockout. func TestLogin_AccountLockout_MFAFailure(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) @@ -274,7 +274,7 @@ func TestLogin_AccountLockout_MFAFailure(t *testing.T) { mockStore.AssertExpectations(t) } -// TestLogin_AccountLockout_GenericErrorMessage verifies no information leakage +// TestLogin_AccountLockout_GenericErrorMessage verifies no information leakage. func TestLogin_AccountLockout_GenericErrorMessage(t *testing.T) { ctx := context.Background() mockStore := new(MockStore) @@ -302,7 +302,7 @@ func TestLogin_AccountLockout_GenericErrorMessage(t *testing.T) { mockStore.AssertExpectations(t) } -// TestRecordFailedLogin verifies recordFailedLogin function behavior +// TestRecordFailedLogin verifies recordFailedLogin function behavior. func TestRecordFailedLogin(t *testing.T) { ctx := context.Background() @@ -370,7 +370,7 @@ func TestRecordFailedLogin(t *testing.T) { // Issue #416 added the "store error" scenario: the real Postgres store returns // (nil, pgx.ErrNoRows) for a missing row, not (nil, nil). Both the error path and // the nil-user path now collapse to the same message "Check your email address and password and try again" -// and the Login function runs a dummy bcrypt compare to equalise response timing. +// and the Login function runs a dummy bcrypt compare to equalize response timing. func TestLogin_OWASPEnumerationInvariant(t *testing.T) { const wantMsg = "Check your email address and password and try again" ctx := context.Background() diff --git a/internal/auth/service_mfa.go b/internal/auth/service_mfa.go index 789834328..73077fa6f 100644 --- a/internal/auth/service_mfa.go +++ b/internal/auth/service_mfa.go @@ -86,7 +86,7 @@ func verifyTOTP(secret, code string) bool { return valid == 1 } -// generateTOTP generates a TOTP code for the given counter +// generateTOTP generates a TOTP code for the given counter. func generateTOTP(secret string, counter int64) string { // Decode base32 secret secretBytes, err := base32Decode(secret) @@ -271,7 +271,7 @@ type MFASetupResult struct { } // MFASetup begins an MFA enrollment for a user. The caller must -// re-verify the user's password (defence-in-depth against a session +// re-verify the user's password (defense-in-depth against a session // token being lifted from another tab). Returns the freshly-generated // secret + provisioning URI; persists the secret in the user's // pending fields with a short expiry. Does NOT flip MFAEnabled — @@ -425,7 +425,7 @@ func (s *Service) disableMFAAlreadyOff(ctx context.Context, user *User) error { // MFADisable turns off MFA for a user. Requires both the current // password AND a fresh proof-of-possession (either a TOTP code or -// an unused recovery code). Defence-in-depth: a stolen session +// an unused recovery code). Defense-in-depth: a stolen session // alone shouldn't disable MFA, and a stolen authenticator alone // shouldn't either. // diff --git a/internal/auth/service_password.go b/internal/auth/service_password.go index 4000142a8..f6e036539 100644 --- a/internal/auth/service_password.go +++ b/internal/auth/service_password.go @@ -19,7 +19,7 @@ import ( const bcryptCost = 12 // dummyPasswordHash is a pre-computed bcrypt hash of a random constant string. -// It is used by Login to run a timing-equalising bcrypt.CompareHashAndPassword +// It is used by Login to run a timing-equalizing bcrypt.CompareHashAndPassword // when the requested email does not exist, so an attacker cannot distinguish a // missing account from a wrong-password attempt via response time (issue #416). // The plain-text "dummy" value is intentionally unguessable and never stored. @@ -28,7 +28,7 @@ const bcryptCost = 12 // nolint:gosec -- this is a public sentinel hash, not a credential var dummyPasswordHash = "$2a$12$iAMeexq41AwZ2Dj9oAvGfeVHQxK5ffLPPTNxwPB8bsf7olA730dxO" -// Password validation constants following NIST guidelines +// Password validation constants following NIST guidelines. const ( minPasswordLength = 12 // Minimum password length maxPasswordLength = 128 // Maximum password length to prevent bcrypt DoS @@ -44,7 +44,7 @@ const ( ) // commonPasswords is a list of commonly used weak passwords to reject -// Based on NIST guidelines and common password lists +// Based on NIST guidelines and common password lists. var commonPasswords = []string{ "password", "123456", "qwerty", "admin", "welcome", "letmein", "monkey", "dragon", "master", "login", "abc123", "starwars", @@ -122,7 +122,7 @@ func (s *Service) checkPasswordHistory(newPassword string, currentHash string, p return nil } -// addToPasswordHistory adds a new password hash to the history and maintains the limit +// addToPasswordHistory adds a new password hash to the history and maintains the limit. func addToPasswordHistory(currentHash string, existingHistory []string) []string { // Create new history array with current password at the beginning newHistory := []string{currentHash} @@ -135,7 +135,7 @@ func addToPasswordHistory(currentHash string, existingHistory []string) []string return newHistory } -// validatePassword validates password requirements following NIST guidelines +// validatePassword validates password requirements following NIST guidelines. func (s *Service) validatePassword(password string) error { // Check minimum length if len(password) < minPasswordLength { @@ -166,7 +166,7 @@ func (s *Service) validatePassword(password string) error { return nil } -// validatePasswordComplexity checks that password meets complexity requirements +// validatePasswordComplexity checks that password meets complexity requirements. func (s *Service) validatePasswordComplexity(password string) error { hasUpper, hasLower, hasNumber, hasSpecial := checkCharacterTypes(password) return validateCharacterRequirements(hasUpper, hasLower, hasNumber, hasSpecial) @@ -195,7 +195,7 @@ func validateCharacterRequirements(hasUpper, hasLower, hasNumber, hasSpecial boo return nil } -// checkCommonPasswords verifies password is not in common password list +// checkCommonPasswords verifies password is not in common password list. func (s *Service) checkCommonPasswords(password string) error { lowerPass := strings.ToLower(password) for _, common := range commonPasswords { @@ -206,7 +206,7 @@ func (s *Service) checkCommonPasswords(password string) error { return nil } -// ChangePassword allows a user to change their password +// ChangePassword allows a user to change their password. func (s *Service) ChangePassword(ctx context.Context, userID string, req ChangePasswordRequest) error { user, err := s.store.GetUserByID(ctx, userID) if err != nil { @@ -259,7 +259,7 @@ func (s *Service) ChangePassword(ctx context.Context, userID string, req ChangeP return nil } -// RequestPasswordReset initiates a password reset +// RequestPasswordReset initiates a password reset. func (s *Service) RequestPasswordReset(ctx context.Context, email string) error { user, err := s.store.GetUserByEmail(ctx, email) if err != nil { @@ -328,7 +328,7 @@ func (s *Service) RequestPasswordReset(ctx context.Context, email string) error return nil } -// ConfirmPasswordReset completes a password reset +// ConfirmPasswordReset completes a password reset. func (s *Service) ConfirmPasswordReset(ctx context.Context, req PasswordResetConfirm) error { user, err := s.validateResetToken(ctx, req.Token) if err != nil { @@ -478,7 +478,7 @@ func (s *Service) processPasswordReset(user *User, newPassword string) error { // redactEmail returns a redacted version of an email address safe for debug logs. // Both the local part and the domain are partially masked to reduce PII exposure // for low-entropy addresses (03-L1, see also feedback_pii_in_logs). -// Example: "user@example.com" -> "us***@ex***.com" +// Example: "user@example.com" -> "us***@ex***.com". func redactEmail(email string) string { at := strings.LastIndex(email, "@") if at < 0 { diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go index ac7bf0f1c..3e1691b0d 100644 --- a/internal/auth/service_test.go +++ b/internal/auth/service_test.go @@ -509,7 +509,7 @@ func TestLogin_WithMFA_NoSecret(t *testing.T) { }) } -// Test UpdateUserProfile +// Test UpdateUserProfile. func TestService_ErrorPaths(t *testing.T) { ctx := context.Background() diff --git a/internal/auth/service_user.go b/internal/auth/service_user.go index 28eefb64f..4ea3cae0e 100644 --- a/internal/auth/service_user.go +++ b/internal/auth/service_user.go @@ -31,7 +31,7 @@ func (s *Service) SetupAdmin(ctx context.Context, req SetupAdminRequest) (*Login } if err := s.validatePassword(req.Password); err != nil { - return nil, fmt.Errorf("%w: %v", ErrPasswordPolicy, err) + return nil, fmt.Errorf("%w: %w", ErrPasswordPolicy, err) } passwordHash, err := s.hashPassword(req.Password) @@ -93,7 +93,7 @@ func (s *Service) SetupAdmin(ctx context.Context, req SetupAdminRequest) (*Login // mapStoreCreateUserError maps a Store.CreateUser error into the auth // package's sentinel set so the API handler can surface 4xx instead of -// 500 for known recoverable failures. Defence-in-depth: the validator +// 500 for known recoverable failures. Defense-in-depth: the validator // pre-checks email-in-use, but two callers can race past it and hit the // users_email_key unique constraint. Extracted from CreateUser / // SetupAdmin call sites to keep both functions under gocyclo's @@ -108,7 +108,7 @@ func mapStoreCreateUserError(err error) error { return fmt.Errorf("failed to create user: %w", err) } -// CheckAdminExists returns whether an admin user exists +// CheckAdminExists returns whether an admin user exists. func (s *Service) CheckAdminExists(ctx context.Context) (bool, error) { return s.store.AdminExists(ctx) } @@ -141,7 +141,7 @@ func (s *Service) validateCreateUserRequest(ctx context.Context, req CreateUserR // validatePassword returns specific messages ("must be at least N // characters", "common password", etc.); wrap so the handler can // detect the category while keeping the message detail. - return fmt.Errorf("%w: %v", ErrPasswordPolicy, err) + return fmt.Errorf("%w: %w", ErrPasswordPolicy, err) } return nil } @@ -295,7 +295,7 @@ func (s *Service) loadUser(ctx context.Context, userID string) (*User, error) { // session, never client-supplied). It is used to enforce the self-escalation // guard: a user may not add a group they are not already a member of unless // they hold the manage-users permission. Pass "" for trusted internal callers -// (e.g. the stateless admin API key) that have already been authorised. +// (e.g. the stateless admin API key) that have already been authorized. func (s *Service) UpdateUser(ctx context.Context, actorUserID, userID string, req UpdateUserRequest) (*User, error) { user, err := s.loadUser(ctx, userID) if err != nil { @@ -487,7 +487,7 @@ func (s *Service) GetUser(ctx context.Context, userID string) (*User, error) { return s.store.GetUserByID(ctx, userID) } -// UpdateUserProfile allows a user to update their own email and password +// UpdateUserProfile allows a user to update their own email and password. func (s *Service) UpdateUserProfile(ctx context.Context, userID string, email string, currentPassword string, newPassword string) error { user, err := s.store.GetUserByID(ctx, userID) if err != nil { @@ -577,12 +577,12 @@ func (s *Service) updateUserPassword(user *User, newPassword string) (bool, erro return true, nil } -// ListUsers returns all users (admin only) +// ListUsers returns all users (admin only). func (s *Service) ListUsers(ctx context.Context) ([]User, error) { return s.store.ListUsers(ctx) } -// recordFailedLogin increments failed login attempts and locks the account if necessary +// recordFailedLogin increments failed login attempts and locks the account if necessary. func (s *Service) recordFailedLogin(ctx context.Context, user *User) { user.FailedLoginAttempts++ now := time.Now() diff --git a/internal/auth/service_user_test.go b/internal/auth/service_user_test.go index 7afa7f755..bfde8c913 100644 --- a/internal/auth/service_user_test.go +++ b/internal/auth/service_user_test.go @@ -419,7 +419,7 @@ func TestService_DeleteUser_ConcurrentLastTwoAdmins(t *testing.T) { t.Cleanup(func() { mockStore.AssertExpectations(t) }) - // Use a WaitGroup and a ready channel to maximise concurrency: both + // Use a WaitGroup and a ready channel to maximize concurrency: both // goroutines block at the barrier before calling DeleteUser. ready := make(chan struct{}) errCh := make(chan error, 2) @@ -428,7 +428,7 @@ func TestService_DeleteUser_ConcurrentLastTwoAdmins(t *testing.T) { start := func(userID string) { defer wg.Done() - <-ready // synchronise start + <-ready // synchronize start errCh <- service.DeleteUser(ctx, userID) } @@ -456,7 +456,7 @@ func TestService_DeleteUser_ConcurrentLastTwoAdmins(t *testing.T) { } // TestService_UpdateUser_ConcurrentDeactivateLastTwoAdmins is the deactivation -// analogue of the delete race in issue #919 / CR #921. Two goroutines +// analog of the delete race in issue #919 / CR #921. Two goroutines // simultaneously deactivate the last two active admins. Both read count == 2 // and pass the soft check. The 000065 deferred trigger now counts only *active* // members and serializes via an advisory xact lock, so exactly one commit is @@ -1069,7 +1069,7 @@ func TestService_SetupAdmin_EdgeCases(t *testing.T) { }) } -// Test TOTP functions +// Test TOTP functions. func TestService_UpdateUserProfile(t *testing.T) { ctx := context.Background() diff --git a/internal/auth/store_postgres.go b/internal/auth/store_postgres.go index e8c54ecde..be1e2f1be 100644 --- a/internal/auth/store_postgres.go +++ b/internal/auth/store_postgres.go @@ -15,7 +15,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" ) -// DBConnection defines the interface for database operations needed by PostgresStore +// DBConnection defines the interface for database operations needed by PostgresStore. type DBConnection interface { QueryRow(ctx context.Context, sql string, args ...any) pgx.Row Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) @@ -23,24 +23,24 @@ type DBConnection interface { Ping(ctx context.Context) error } -// PostgresStore implements StoreInterface using PostgreSQL +// PostgresStore implements StoreInterface using PostgreSQL. type PostgresStore struct { db DBConnection } -// NewPostgresStore creates a new PostgreSQL-backed auth store +// NewPostgresStore creates a new PostgreSQL-backed auth store. func NewPostgresStore(db DBConnection) *PostgresStore { return &PostgresStore{db: db} } -// Verify PostgresStore implements StoreInterface +// Verify PostgresStore implements StoreInterface. var _ StoreInterface = (*PostgresStore)(nil) // ========================================== // USER OPERATIONS // ========================================== -// GetUserByID retrieves a user by ID +// GetUserByID retrieves a user by ID. func (s *PostgresStore) GetUserByID(ctx context.Context, userID string) (*User, error) { query := ` SELECT id, email, password_hash, salt, group_ids, active, @@ -55,7 +55,7 @@ func (s *PostgresStore) GetUserByID(ctx context.Context, userID string) (*User, return s.scanUser(s.db.QueryRow(ctx, query, userID)) } -// GetUserByEmail retrieves a user by email +// GetUserByEmail retrieves a user by email. func (s *PostgresStore) GetUserByEmail(ctx context.Context, email string) (*User, error) { query := ` SELECT id, email, password_hash, salt, group_ids, active, @@ -74,7 +74,7 @@ func (s *PostgresStore) GetUserByEmail(ctx context.Context, email string) (*User return user, nil } -// CreateUser creates a new user +// CreateUser creates a new user. func (s *PostgresStore) CreateUser(ctx context.Context, user *User) error { // Generate UUID if not provided if user.ID == "" { @@ -140,7 +140,7 @@ func (s *PostgresStore) CreateUser(ctx context.Context, user *User) error { return nil } -// isDuplicateKeyError checks if the error is a PostgreSQL unique constraint violation (code 23505) +// isDuplicateKeyError checks if the error is a PostgreSQL unique constraint violation (code 23505). func isDuplicateKeyError(err error) bool { var pgErr *pgconn.PgError if errors.As(err, &pgErr) { @@ -185,7 +185,7 @@ func isLastAdminConstraintViolation(err error) bool { return strings.HasPrefix(err.Error(), sentinel) } -// UpdateUser updates an existing user +// UpdateUser updates an existing user. func (s *PostgresStore) UpdateUser(ctx context.Context, user *User) error { user.UpdatedAt = time.Now() @@ -249,7 +249,7 @@ func (s *PostgresStore) UpdateUser(ctx context.Context, user *User) error { return nil } -// DeleteUser deletes a user +// DeleteUser deletes a user. func (s *PostgresStore) DeleteUser(ctx context.Context, userID string) error { query := `DELETE FROM users WHERE id = $1` @@ -265,7 +265,7 @@ func (s *PostgresStore) DeleteUser(ctx context.Context, userID string) error { return nil } -// ListUsers lists all users +// ListUsers lists all users. func (s *PostgresStore) ListUsers(ctx context.Context) ([]User, error) { // LIMIT provides a safety cap against unbounded memory allocation on large installations. // Pagination support should be added if this limit proves insufficient. @@ -438,7 +438,7 @@ func (s *PostgresStore) CreateAdminIfNone(ctx context.Context, user *User) (bool // GROUP OPERATIONS // ========================================== -// GetGroup retrieves a group by ID +// GetGroup retrieves a group by ID. func (s *PostgresStore) GetGroup(ctx context.Context, groupID string) (*Group, error) { query := ` SELECT id, name, description, permissions, allowed_accounts, @@ -450,7 +450,7 @@ func (s *PostgresStore) GetGroup(ctx context.Context, groupID string) (*Group, e return s.scanGroup(s.db.QueryRow(ctx, query, groupID)) } -// CreateGroup creates a new group +// CreateGroup creates a new group. func (s *PostgresStore) CreateGroup(ctx context.Context, group *Group) error { // Generate UUID if not provided if group.ID == "" { @@ -501,7 +501,7 @@ func (s *PostgresStore) CreateGroup(ctx context.Context, group *Group) error { return nil } -// UpdateGroup updates an existing group +// UpdateGroup updates an existing group. func (s *PostgresStore) UpdateGroup(ctx context.Context, group *Group) error { group.UpdatedAt = time.Now() @@ -541,7 +541,7 @@ func (s *PostgresStore) UpdateGroup(ctx context.Context, group *Group) error { return nil } -// DeleteGroup deletes a group +// DeleteGroup deletes a group. func (s *PostgresStore) DeleteGroup(ctx context.Context, groupID string) error { query := `DELETE FROM groups WHERE id = $1` @@ -557,7 +557,7 @@ func (s *PostgresStore) DeleteGroup(ctx context.Context, groupID string) error { return nil } -// ListGroups lists all groups +// ListGroups lists all groups. func (s *PostgresStore) ListGroups(ctx context.Context) ([]Group, error) { // LIMIT provides a safety cap against unbounded memory allocation. // Pagination support should be added if this limit proves insufficient. @@ -594,7 +594,7 @@ func (s *PostgresStore) ListGroups(ctx context.Context) ([]Group, error) { // SESSION OPERATIONS // ========================================== -// CreateSession creates a new session +// CreateSession creates a new session. func (s *PostgresStore) CreateSession(ctx context.Context, session *Session) error { query := ` INSERT INTO sessions ( @@ -621,7 +621,7 @@ func (s *PostgresStore) CreateSession(ctx context.Context, session *Session) err return nil } -// GetSession retrieves a session by token +// GetSession retrieves a session by token. func (s *PostgresStore) GetSession(ctx context.Context, token string) (*Session, error) { query := ` SELECT token, user_id, email, expires_at, created_at, @@ -652,7 +652,7 @@ func (s *PostgresStore) GetSession(ctx context.Context, token string) (*Session, return &session, nil } -// DeleteSession deletes a session +// DeleteSession deletes a session. func (s *PostgresStore) DeleteSession(ctx context.Context, token string) error { query := `DELETE FROM sessions WHERE token = $1` @@ -664,7 +664,7 @@ func (s *PostgresStore) DeleteSession(ctx context.Context, token string) error { return nil } -// DeleteUserSessions deletes all sessions for a user +// DeleteUserSessions deletes all sessions for a user. func (s *PostgresStore) DeleteUserSessions(ctx context.Context, userID string) error { query := `DELETE FROM sessions WHERE user_id = $1` @@ -676,7 +676,7 @@ func (s *PostgresStore) DeleteUserSessions(ctx context.Context, userID string) e return nil } -// CleanupExpiredSessions deletes expired sessions +// CleanupExpiredSessions deletes expired sessions. func (s *PostgresStore) CleanupExpiredSessions(ctx context.Context) error { query := `DELETE FROM sessions WHERE expires_at <= NOW()` @@ -693,7 +693,7 @@ func (s *PostgresStore) CleanupExpiredSessions(ctx context.Context) error { // API KEY OPERATIONS // ========================================== -// CreateAPIKey creates a new API key +// CreateAPIKey creates a new API key. func (s *PostgresStore) CreateAPIKey(ctx context.Context, key *UserAPIKey) error { // Generate UUID if not provided if key.ID == "" { @@ -736,7 +736,7 @@ func (s *PostgresStore) CreateAPIKey(ctx context.Context, key *UserAPIKey) error return nil } -// GetAPIKeyByID retrieves an API key by ID +// GetAPIKeyByID retrieves an API key by ID. func (s *PostgresStore) GetAPIKeyByID(ctx context.Context, keyID string) (*UserAPIKey, error) { query := ` SELECT id, user_id, name, key_prefix, key_hash, permissions, @@ -748,7 +748,7 @@ func (s *PostgresStore) GetAPIKeyByID(ctx context.Context, keyID string) (*UserA return s.scanAPIKey(s.db.QueryRow(ctx, query, keyID)) } -// GetAPIKeyByHash retrieves an API key by hash +// GetAPIKeyByHash retrieves an API key by hash. func (s *PostgresStore) GetAPIKeyByHash(ctx context.Context, keyHash string) (*UserAPIKey, error) { query := ` SELECT id, user_id, name, key_prefix, key_hash, permissions, @@ -761,7 +761,7 @@ func (s *PostgresStore) GetAPIKeyByHash(ctx context.Context, keyHash string) (*U return s.scanAPIKey(s.db.QueryRow(ctx, query, keyHash)) } -// ListAPIKeysByUser lists all API keys for a user +// ListAPIKeysByUser lists all API keys for a user. func (s *PostgresStore) ListAPIKeysByUser(ctx context.Context, userID string) ([]*UserAPIKey, error) { query := ` SELECT id, user_id, name, key_prefix, key_hash, permissions, @@ -792,7 +792,7 @@ func (s *PostgresStore) ListAPIKeysByUser(ctx context.Context, userID string) ([ return keys, rows.Err() } -// UpdateAPIKey updates an API key +// UpdateAPIKey updates an API key. func (s *PostgresStore) UpdateAPIKey(ctx context.Context, key *UserAPIKey) error { // Marshal permissions to JSONB permissionsJSON, err := json.Marshal(key.Permissions) @@ -830,7 +830,7 @@ func (s *PostgresStore) UpdateAPIKey(ctx context.Context, key *UserAPIKey) error return nil } -// UpdateAPIKeyLastUsed atomically updates the last_used_at timestamp for an API key +// UpdateAPIKeyLastUsed atomically updates the last_used_at timestamp for an API key. func (s *PostgresStore) UpdateAPIKeyLastUsed(ctx context.Context, keyID string) error { query := `UPDATE api_keys SET last_used_at = NOW() WHERE id = $1` result, err := s.db.Exec(ctx, query, keyID) @@ -843,7 +843,7 @@ func (s *PostgresStore) UpdateAPIKeyLastUsed(ctx context.Context, keyID string) return nil } -// DeleteAPIKey deletes an API key +// DeleteAPIKey deletes an API key. func (s *PostgresStore) DeleteAPIKey(ctx context.Context, keyID string) error { query := `DELETE FROM api_keys WHERE id = $1` @@ -863,12 +863,12 @@ func (s *PostgresStore) DeleteAPIKey(ctx context.Context, keyID string) error { // HELPER FUNCTIONS // ========================================== -// Scanner interface for both Row and Rows +// Scanner interface for both Row and Rows. type Scanner interface { Scan(dest ...any) error } -// scanUser scans a user from a database row +// scanUser scans a user from a database row. func (s *PostgresStore) scanUser(scanner Scanner) (*User, error) { var user User var groupIDs []string @@ -938,7 +938,7 @@ func (s *PostgresStore) scanUser(scanner Scanner) (*User, error) { return &user, nil } -// scanGroup scans a group from a database row +// scanGroup scans a group from a database row. func (s *PostgresStore) scanGroup(scanner Scanner) (*Group, error) { var group Group var permissionsJSON []byte @@ -980,7 +980,7 @@ func (s *PostgresStore) scanGroup(scanner Scanner) (*Group, error) { return &group, nil } -// scanAPIKey scans an API key from a database row +// scanAPIKey scans an API key from a database row. func (s *PostgresStore) scanAPIKey(scanner Scanner) (*UserAPIKey, error) { var key UserAPIKey var permissionsJSON []byte @@ -1024,7 +1024,7 @@ func (s *PostgresStore) scanAPIKey(scanner Scanner) (*UserAPIKey, error) { return &key, nil } -// Ping checks the database connection health +// Ping checks the database connection health. func (s *PostgresStore) Ping(ctx context.Context) error { return s.db.Ping(ctx) } diff --git a/internal/auth/test_helpers.go b/internal/auth/test_helpers.go index 88cf24cc7..dd913a13c 100644 --- a/internal/auth/test_helpers.go +++ b/internal/auth/test_helpers.go @@ -11,7 +11,7 @@ import ( "golang.org/x/crypto/bcrypt" ) -// MockStore is a mock implementation of the auth store for testing +// MockStore is a mock implementation of the auth store for testing. type MockStore struct { mock.Mock } @@ -165,7 +165,7 @@ func (m *MockStore) CleanupExpiredSessions(ctx context.Context) error { return args.Error(0) } -// API Key operations +// API Key operations. func (m *MockStore) CreateAPIKey(ctx context.Context, key *UserAPIKey) error { args := m.Called(ctx, key) return args.Error(0) @@ -227,7 +227,7 @@ func (m *MockStore) Ping(ctx context.Context) error { return args.Error(0) } -// MockEmailSender is a mock implementation of the email sender for testing +// MockEmailSender is a mock implementation of the email sender for testing. type MockEmailSender struct { mock.Mock } @@ -247,10 +247,10 @@ func (m *MockEmailSender) SendUserInviteEmail(ctx context.Context, email, setupU return args.Error(0) } -// Verify that MockStore implements StoreInterface +// Verify that MockStore implements StoreInterface. var _ StoreInterface = (*MockStore)(nil) -// Verify that MockEmailSender implements EmailSenderInterface +// Verify that MockEmailSender implements EmailSenderInterface. var _ EmailSenderInterface = (*MockEmailSender)(nil) // testCSRFKey is a fixed 32-byte key used across all test services so that @@ -286,7 +286,7 @@ func newTestService() *Service { } } -// createTestService creates a service with mocks for testing +// createTestService creates a service with mocks for testing. func createTestService(mockStore *MockStore, mockEmail *MockEmailSender) *Service { return &Service{ store: mockStore, @@ -298,7 +298,7 @@ func createTestService(mockStore *MockStore, mockEmail *MockEmailSender) *Servic } } -// createTestUser creates a user with hashed password for testing +// createTestUser creates a user with hashed password for testing. func createTestUser(t *testing.T, password string) *User { t.Helper() diff --git a/internal/auth/types.go b/internal/auth/types.go index 4aa2ef100..022396ded 100644 --- a/internal/auth/types.go +++ b/internal/auth/types.go @@ -5,7 +5,7 @@ import ( "time" ) -// User represents a user account +// User represents a user account. type User struct { ID string `json:"id" dynamodbav:"PK"` Email string `json:"email" dynamodbav:"Email"` @@ -41,7 +41,7 @@ type User struct { PasswordHistory []string `json:"-" dynamodbav:"PasswordHistory,omitempty"` } -// Group represents a permission group +// Group represents a permission group. type Group struct { ID string `json:"id" dynamodbav:"PK"` Name string `json:"name" dynamodbav:"Name"` @@ -57,7 +57,7 @@ type Group struct { CreatedBy string `json:"created_by" dynamodbav:"CreatedBy"` } -// Permission defines what actions a group can perform +// Permission defines what actions a group can perform. type Permission struct { // Action: view, purchase, configure, admin Action string `json:"action" dynamodbav:"Action"` @@ -69,7 +69,7 @@ type Permission struct { Constraints *PermissionConstraints `json:"constraints,omitempty" dynamodbav:"Constraints"` } -// PermissionConstraints limit permissions to specific accounts, providers, or services +// PermissionConstraints limit permissions to specific accounts, providers, or services. type PermissionConstraints struct { // AccountIDs limits to specific AWS/Azure/GCP accounts AccountIDs []string `json:"account_ids,omitempty" dynamodbav:"AccountIDs"` @@ -87,7 +87,7 @@ type PermissionConstraints struct { MaxPurchaseAmount float64 `json:"max_purchase_amount,omitempty" dynamodbav:"MaxPurchaseAmount"` } -// UserAPIKey represents a personal API key for a user with scoped permissions +// UserAPIKey represents a personal API key for a user with scoped permissions. type UserAPIKey struct { ID string `json:"id" dynamodbav:"PK"` // UUID string UserID string `json:"user_id" dynamodbav:"UserID"` // User who owns this key @@ -209,7 +209,7 @@ func (ctx *AuthContext) CanAccessAccount(accountID, accountName string) bool { return MatchesAccount(ctx.AllowedAccounts, accountID, accountName) } -// Session represents an active user session +// Session represents an active user session. type Session struct { Token string `json:"token" dynamodbav:"PK"` UserID string `json:"user_id" dynamodbav:"UserID"` @@ -221,14 +221,14 @@ type Session struct { CSRFToken string `json:"csrf_token,omitempty" dynamodbav:"CSRFToken"` } -// LoginRequest represents a login attempt +// LoginRequest represents a login attempt. type LoginRequest struct { Email string `json:"email"` Password string `json:"password"` MFACode string `json:"mfa_code,omitempty"` } -// LoginResponse is returned after successful login +// LoginResponse is returned after successful login. type LoginResponse struct { Token string `json:"token"` ExpiresAt time.Time `json:"expires_at"` @@ -236,7 +236,7 @@ type LoginResponse struct { CSRFToken string `json:"csrf_token,omitempty"` } -// UserInfo is the public user info returned to clients +// UserInfo is the public user info returned to clients. type UserInfo struct { ID string `json:"id"` Email string `json:"email"` @@ -244,12 +244,12 @@ type UserInfo struct { MFAEnabled bool `json:"mfa_enabled"` } -// PasswordResetRequest initiates a password reset +// PasswordResetRequest initiates a password reset. type PasswordResetRequest struct { Email string `json:"email"` } -// PasswordResetConfirm completes a password reset +// PasswordResetConfirm completes a password reset. type PasswordResetConfirm struct { Token string `json:"token"` NewPassword string `json:"new_password"` @@ -279,33 +279,33 @@ type UpdateUserRequest struct { Active *bool `json:"active,omitempty"` } -// ChangePasswordRequest for users changing their own password +// ChangePasswordRequest for users changing their own password. type ChangePasswordRequest struct { CurrentPassword string `json:"current_password"` NewPassword string `json:"new_password"` } -// SetupAdminRequest for first-time admin setup with API key +// SetupAdminRequest for first-time admin setup with API key. type SetupAdminRequest struct { Email string `json:"email"` Password string `json:"password"` } -// CreateAPIKeyRequest for creating a new user API key +// CreateAPIKeyRequest for creating a new user API key. type CreateAPIKeyRequest struct { Name string `json:"name"` Permissions []Permission `json:"permissions,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` } -// CreateAPIKeyResponse returns the newly created API key (only shown once) +// CreateAPIKeyResponse returns the newly created API key (only shown once). type CreateAPIKeyResponse struct { APIKey string `json:"api_key"` // Full key - only returned on creation KeyID string `json:"key_id"` Info *UserAPIKey `json:"info"` } -// Predefined roles +// Predefined roles. const ( RoleAdmin = "admin" RoleUser = "user" @@ -329,7 +329,7 @@ const DefaultPurchaserGroupID = "00000000-0000-5000-8000-000000000007" // the seeded row. const GroupPurchaser = "Purchaser" -// Predefined actions +// Predefined actions. const ( ActionView = "view" ActionCreate = "create" @@ -345,7 +345,7 @@ const ( // * RoleAdmin — implicit via {ActionAdmin, ResourceAll}; covers // both verbs. // * RoleUser — DefaultUserPermissions() adds cancel-own:purchases. - // Allows cancelling pending executions whose created_by_user_id + // Allows canceling pending executions whose created_by_user_id // matches the session user. Legacy rows with NULL creator are // out of reach for non-admins via this verb; admins still cancel // them via cancel-any. @@ -437,7 +437,7 @@ const ( // (pause / resume / run / delete) a SCHEDULED purchase execution // regardless of who created it (issue #950). It complements the base // update:purchases verb every authenticated user already holds: that - // base verb authorises managing only your OWN scheduled purchases + // base verb authorizes managing only your OWN scheduled purchases // (created_by_user_id == session.UserID), while update-any drops the // per-record ownership check. // @@ -482,7 +482,7 @@ const ( ActionRevokeAny = "revoke-any" ) -// Predefined resources +// Predefined resources. const ( ResourceRecommendations = "recommendations" ResourcePlans = "plans" @@ -504,14 +504,14 @@ const ( ResourceAll = "*" ) -// DefaultAdminPermissions returns full admin permissions +// DefaultAdminPermissions returns full admin permissions. func DefaultAdminPermissions() []Permission { return []Permission{ {Action: ActionAdmin, Resource: ResourceAll}, } } -// DefaultUserPermissions returns standard user permissions +// DefaultUserPermissions returns standard user permissions. func DefaultUserPermissions() []Permission { return []Permission{ {Action: ActionView, Resource: ResourceRecommendations}, @@ -535,7 +535,7 @@ func DefaultUserPermissions() []Permission { // pending purchase executions they created themselves (issue #46). // The handler still requires the execution to be in a cancellable // state (pending/notified) and the creator UUID to match the - // session UserID before honouring the request. + // session UserID before honoring the request. {Action: ActionCancelOwn, Resource: ResourcePurchases}, // retry-own:purchases — every authenticated user can retry // failed purchase executions they created themselves (issue #47). @@ -549,7 +549,7 @@ func DefaultUserPermissions() []Permission { // pending purchase executions they created themselves (issue #286). // The handler still requires the execution to be in an approvable // state (pending/notified) and the creator UUID to match the - // session UserID before honouring the request. The legacy email- + // session UserID before honoring the request. The legacy email- // token approve path stays as an escape hatch for non-session // approvers. {Action: ActionApproveOwn, Resource: ResourcePurchases}, @@ -563,7 +563,7 @@ func DefaultUserPermissions() []Permission { } } -// DefaultReadOnlyPermissions returns read-only permissions +// DefaultReadOnlyPermissions returns read-only permissions. func DefaultReadOnlyPermissions() []Permission { return []Permission{ {Action: ActionView, Resource: ResourceRecommendations}, diff --git a/internal/commitmentopts/probe_test.go b/internal/commitmentopts/probe_test.go index 7162c6434..48dbda377 100644 --- a/internal/commitmentopts/probe_test.go +++ b/internal/commitmentopts/probe_test.go @@ -97,7 +97,7 @@ func TestRDSProber_ErrorPropagates(t *testing.T) { } func TestRDSProber_PageCap(t *testing.T) { - // Integration-level check that the RDS prober honours the page cap + // Integration-level check that the RDS prober honors the page cap // when wired through walkPaginated. The cap itself is exercised in // detail by TestWalkPaginated_StopsAtPageCap; this test guards the // wiring (RDS uses Marker rather than NextToken) so a refactor that @@ -352,7 +352,7 @@ func TestDefaultProbers(t *testing.T) { // --------------------------------------------------------------------------- // walkPaginated — the shared pagination helper every prober runs through. -// Testing the helper once covers the page-cap behaviour for all six +// Testing the helper once covers the page-cap behavior for all six // services in lieu of six near-identical Test{Service}Prober_PageCap tests. // The per-prober Probe tests above still exercise the wiring (which token // field each AWS API uses, per-item conversion, optional client-side diff --git a/internal/commitmentopts/service.go b/internal/commitmentopts/service.go index a290f7cf6..ce3a1fda1 100644 --- a/internal/commitmentopts/service.go +++ b/internal/commitmentopts/service.go @@ -2,6 +2,7 @@ package commitmentopts import ( "context" + "errors" "fmt" "sync" @@ -141,14 +142,14 @@ func (s *Service) findAWSAccount(ctx context.Context) (*config.CloudAccount, err // Validate reports whether (provider, service, term, payment) is a legal // combination according to the cached probe data. // -// Fallback behaviour: if no probe data exists (the server has never +// Fallback behavior: if no probe data exists (the server has never // successfully probed) Validate returns true so saves aren't blocked when // we can't verify. The frontend's hardcoded rules are the user-facing // gate; this check is belt-and-braces. func (s *Service) Validate(ctx context.Context, provider, service string, term int, payment string) (bool, error) { opts, err := s.Get(ctx) if err != nil { - if err == ErrNoData { + if errors.Is(err, ErrNoData) { return true, nil } return false, err diff --git a/internal/config/constants.go b/internal/config/constants.go index 56ab21d92..445bcda9a 100644 --- a/internal/config/constants.go +++ b/internal/config/constants.go @@ -3,42 +3,42 @@ package config import "time" -// Default configuration values +// Default configuration values. const ( - // DefaultListLimit is the default number of items returned in list operations + // DefaultListLimit is the default number of items returned in list operations. DefaultListLimit = 100 - // MaxListLimit is the maximum number of items allowed in a single list request + // MaxListLimit is the maximum number of items allowed in a single list request. MaxListLimit = 1000 - // DefaultExecutionTTLDays is how long execution records are kept + // DefaultExecutionTTLDays is how long execution records are kept. DefaultExecutionTTLDays = 30 - // DefaultMaxRecommendationsInEmail is the max recommendations shown in email notifications + // DefaultMaxRecommendationsInEmail is the max recommendations shown in email notifications. DefaultMaxRecommendationsInEmail = 10 - // DefaultPasswordResetExpiry is how long password reset tokens are valid + // DefaultPasswordResetExpiry is how long password reset tokens are valid. DefaultPasswordResetExpiry = 1 * time.Hour ) -// Validation constants +// Validation constants. const ( - // MaxCoverage is the maximum allowed coverage percentage + // MaxCoverage is the maximum allowed coverage percentage. MaxCoverage = 100 - // MinCoverage is the minimum allowed coverage percentage + // MinCoverage is the minimum allowed coverage percentage. MinCoverage = 0 - // MaxPlanNameLength is the maximum length for plan names + // MaxPlanNameLength is the maximum length for plan names. MaxPlanNameLength = 100 - // MaxNotificationDaysBefore is the maximum days before purchase to send notification + // MaxNotificationDaysBefore is the maximum days before purchase to send notification. MaxNotificationDaysBefore = 30 - // MaxStepIntervalDays is the maximum interval between ramp steps + // MaxStepIntervalDays is the maximum interval between ramp steps. MaxStepIntervalDays = 365 - // MaxTotalSteps is the maximum number of ramp steps + // MaxTotalSteps is the maximum number of ramp steps. MaxTotalSteps = 100 // MaxServiceMinCount caps the per-service min-count recommendation @@ -49,51 +49,51 @@ const ( MaxServiceMinCount = 10000 ) -// Default values for new configurations +// Default values for new configurations. const ( - // DefaultCoveragePercent is the default coverage percentage for new configs + // DefaultCoveragePercent is the default coverage percentage for new configs. DefaultCoveragePercent = 80 - // DefaultNotifyDaysBefore is the default days before purchase to send notification + // DefaultNotifyDaysBefore is the default days before purchase to send notification. DefaultNotifyDaysBefore = 7 ) -// Ramp schedule presets +// Ramp schedule presets. const ( - // RampImmediate means all at once + // RampImmediate means all at once. RampImmediate = "immediate" - // RampWeekly25Pct means 25% per week for 4 weeks + // RampWeekly25Pct means 25% per week for 4 weeks. RampWeekly25Pct = "weekly-25pct" - // RampMonthly10Pct means 10% per month for 10 months + // RampMonthly10Pct means 10% per month for 10 months. RampMonthly10Pct = "monthly-10pct" - // Weekly step interval in days + // Weekly step interval in days. WeeklyStepIntervalDays = 7 - // Monthly step interval in days + // Monthly step interval in days. MonthlyStepIntervalDays = 30 ) -// Time constants +// Time constants. const ( - // HoursPerDay is the number of hours in a day + // HoursPerDay is the number of hours in a day. HoursPerDay = 24 - // MinHoursBetweenNotifications is the minimum hours between notification emails + // MinHoursBetweenNotifications is the minimum hours between notification emails. MinHoursBetweenNotifications = 24 ) -// Token constants +// Token constants. const ( - // TokenByteLength is the length of generated tokens in bytes + // TokenByteLength is the length of generated tokens in bytes. TokenByteLength = 32 - // MFATimeStep is the TOTP time step in seconds + // MFATimeStep is the TOTP time step in seconds. MFATimeStep = 30 - // MFADigits is the number of digits in MFA codes + // MFADigits is the number of digits in MFA codes. MFADigits = 6 ) diff --git a/internal/config/defaults.go b/internal/config/defaults.go index ab7c61305..656796fbd 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -379,7 +379,7 @@ var DefaultSettings = []ConfigSetting{ }, } -// GetDefaultValue returns the default value for a given key +// GetDefaultValue returns the default value for a given key. func GetDefaultValue(key string) any { for _, setting := range DefaultSettings { if setting.Key == key { @@ -389,7 +389,7 @@ func GetDefaultValue(key string) any { return nil } -// GetDefaultSetting returns the complete default setting for a given key +// GetDefaultSetting returns the complete default setting for a given key. func GetDefaultSetting(key string) *ConfigSetting { for _, setting := range DefaultSettings { if setting.Key == key { @@ -401,7 +401,7 @@ func GetDefaultSetting(key string) *ConfigSetting { return nil } -// GetDefaultsByCategory returns all default settings for a given category +// GetDefaultsByCategory returns all default settings for a given category. func GetDefaultsByCategory(category string) []ConfigSetting { var result []ConfigSetting for _, setting := range DefaultSettings { @@ -412,7 +412,7 @@ func GetDefaultsByCategory(category string) []ConfigSetting { return result } -// GetAllCategories returns a list of all configuration categories +// GetAllCategories returns a list of all configuration categories. func GetAllCategories() []string { categoryMap := make(map[string]bool) for _, setting := range DefaultSettings { diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go index 0ed2cd7c2..1c1067ef8 100644 --- a/internal/config/interfaces.go +++ b/internal/config/interfaces.go @@ -7,7 +7,7 @@ import ( "github.com/jackc/pgx/v5" ) -// StoreInterface defines the methods required for configuration storage +// StoreInterface defines the methods required for configuration storage. type StoreInterface interface { // Global configuration GetGlobalConfig(ctx context.Context) (*GlobalConfig, error) @@ -56,7 +56,7 @@ type StoreInterface interface { // Distinct from GetExecutionsByStatuses (which is DESC for History's // "newest first" semantics): when the result set exceeds `limit`, an // ORDER-BY-DESC + LIMIT in SQL truncates away the soonest rows, exactly - // the rows this list must surface. Secondary sort by id ASC stabilises + // the rows this list must surface. Secondary sort by id ASC stabilizes // ordering when multiple rows share a scheduled_date. GetPlannedExecutions(ctx context.Context, statuses []string, limit int) ([]PurchaseExecution, error) // GetStaleApprovedExecutions returns executions stuck in the "approved" @@ -97,7 +97,7 @@ type StoreInterface interface { // nil) when zero rows were affected (the execution had already been // approved or otherwise transitioned). Must be called inside a WithTx // block so the suppression cleanup and the status flip commit atomically. - CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (cancelled bool, currentStatus string, err error) + CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (canceled bool, currentStatus string, err error) // CancelScheduledExecutionAtomic atomically flips status from 'scheduled' to // 'cancelled', setting cancelled_by. Used by the Gmail-style pre-fire delay // revoke path (issue #291 wave-2) to cancel a scheduled execution at $0 before @@ -108,7 +108,7 @@ type StoreInterface interface { // a 410 ("window closed") rather than a 409 ("not pending"). Returns // (true, "cancelled", nil) on success and (false, currentStatus, nil) when // zero rows were affected. Must be called inside a WithTx block. - CancelScheduledExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (cancelled bool, currentStatus string, err error) + CancelScheduledExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (canceled bool, currentStatus string, err error) // ListStuckExecutions returns executions in any of the given statuses // whose updated_at is older than the given duration. Used by the // reaper sweep (issue #678) to find rows stuck in approved/running diff --git a/internal/config/recommendation_overrides.go b/internal/config/recommendation_overrides.go index 6d3a63729..d8f4be829 100644 --- a/internal/config/recommendation_overrides.go +++ b/internal/config/recommendation_overrides.go @@ -71,8 +71,8 @@ func (c *globalConfigCache) lookup(ctx context.Context, store AccountConfigReade // the triple as "no filter applies". // // When a per-account override exists but no global ServiceConfig does, the -// override is applied against a synthesised default baseline (Enabled: true) -// so the operator's intent is honoured even when a global row has not been +// override is applied against a synthesized default baseline (Enabled: true) +// so the operator's intent is honored even when a global row has not been // created yet. // // Errors from either lookup are returned alongside the partial map so the @@ -127,7 +127,7 @@ func ResolveAccountConfigsForRecs( } // override may be nil — ResolveServiceConfig returns global unchanged. - // global may be nil — ResolveServiceConfig synthesises a default baseline. + // global may be nil — ResolveServiceConfig synthesizes a default baseline. resolved[key] = ResolveServiceConfig(rec.Provider, rec.Service, global, override) } diff --git a/internal/config/recommendation_overrides_test.go b/internal/config/recommendation_overrides_test.go index 922240a76..060c5892a 100644 --- a/internal/config/recommendation_overrides_test.go +++ b/internal/config/recommendation_overrides_test.go @@ -128,7 +128,7 @@ func TestResolveAccountConfigsForRecs_OverrideWithoutGlobal_OverrideApplied(t *t resolved := got[AccountConfigKey("acct-A", "aws", "rds")] assert.NotNil(t, resolved, "override-without-global entry must be in the map") - assert.False(t, resolved.Enabled, "override Enabled=false applied against synthesised baseline") + assert.False(t, resolved.Enabled, "override Enabled=false applied against synthesized baseline") assert.Equal(t, 70.0, resolved.Coverage, "override Coverage=70 applied") assert.Equal(t, "aws", resolved.Provider, "Provider set from rec") assert.Equal(t, "rds", resolved.Service, "Service set from rec") diff --git a/internal/config/resolver.go b/internal/config/resolver.go index 6e1bafce7..96eb15b7a 100644 --- a/internal/config/resolver.go +++ b/internal/config/resolver.go @@ -6,7 +6,7 @@ package config // replaced wholesale when non-empty in the override. // // If override is nil the global is returned unchanged (no copy is made). -// If global is nil but override is non-nil, a baseline ServiceConfig is synthesised +// If global is nil but override is non-nil, a baseline ServiceConfig is synthesized // with safe defaults (Enabled: true, Provider/Service from the override context via // the provider and service parameters) and the override is merged into it. This // lets a per-account override take effect even when no global ServiceConfig row @@ -21,7 +21,7 @@ func ResolveServiceConfig(provider, service string, global *ServiceConfig, overr baseline := global if baseline == nil { - // No global row — synthesise a safe default so the override can be applied. + // No global row — synthesize a safe default so the override can be applied. // Enabled defaults to true (consistent with "service is on unless told otherwise"). baseline = &ServiceConfig{ Provider: provider, diff --git a/internal/config/resolver_test.go b/internal/config/resolver_test.go index 3037384b0..78ec9d93a 100644 --- a/internal/config/resolver_test.go +++ b/internal/config/resolver_test.go @@ -115,7 +115,7 @@ func TestResolveServiceConfig_NilGlobalWithOverride(t *testing.T) { assert.NotNil(t, result) assert.Equal(t, "aws", result.Provider, "Provider taken from parameters") assert.Equal(t, "rds", result.Service, "Service taken from parameters") - assert.False(t, result.Enabled, "override Enabled=false applied against synthesised baseline") + assert.False(t, result.Enabled, "override Enabled=false applied against synthesized baseline") assert.Equal(t, 60.0, result.Coverage, "override Coverage applied") } diff --git a/internal/config/store_postgres.go b/internal/config/store_postgres.go index 04d832758..6f56d7552 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -26,17 +26,17 @@ type dbConn interface { Begin(ctx context.Context) (pgx.Tx, error) } -// PostgresStore implements StoreInterface using PostgreSQL +// PostgresStore implements StoreInterface using PostgreSQL. type PostgresStore struct { db dbConn } -// NewPostgresStore creates a new PostgreSQL-backed config store +// NewPostgresStore creates a new PostgreSQL-backed config store. func NewPostgresStore(db *database.Connection) *PostgresStore { return &PostgresStore{db: db} } -// Verify PostgresStore implements StoreInterface +// Verify PostgresStore implements StoreInterface. var _ StoreInterface = (*PostgresStore)(nil) // ========================================== @@ -302,7 +302,7 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G // SERVICE CONFIGURATION // ========================================== -// GetServiceConfig retrieves configuration for a specific service +// GetServiceConfig retrieves configuration for a specific service. func (s *PostgresStore) GetServiceConfig(ctx context.Context, provider, service string) (*ServiceConfig, error) { query := ` SELECT provider, service, enabled, term, payment, coverage, ramp_schedule, @@ -333,7 +333,7 @@ func (s *PostgresStore) GetServiceConfig(ctx context.Context, provider, service ) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { return nil, fmt.Errorf("service config not found for %s:%s", provider, service) } return nil, fmt.Errorf("failed to get service config: %w", err) @@ -350,7 +350,7 @@ func (s *PostgresStore) GetServiceConfig(ctx context.Context, provider, service return &config, nil } -// SaveServiceConfig saves configuration for a service +// SaveServiceConfig saves configuration for a service. func (s *PostgresStore) SaveServiceConfig(ctx context.Context, config *ServiceConfig) error { query := ` INSERT INTO service_configs ( @@ -404,7 +404,7 @@ func (s *PostgresStore) SaveServiceConfig(ctx context.Context, config *ServiceCo // realistic upper bound (each cloud has a bounded set of services, so the // total is roughly (providers × service-types × per-service-variants), // which stays under ~150 even with generous provider growth). The cap is -// defence-in-depth against a compromised admin inserting millions of rows +// defense-in-depth against a compromised admin inserting millions of rows // and matches the sibling GetPendingExecutions limit. func (s *PostgresStore) ListServiceConfigs(ctx context.Context) ([]ServiceConfig, error) { query := ` @@ -464,7 +464,7 @@ func (s *PostgresStore) ListServiceConfigs(ctx context.Context) ([]ServiceConfig // PURCHASE PLANS // ========================================== -// CreatePurchasePlan creates a new purchase plan +// CreatePurchasePlan creates a new purchase plan. func (s *PostgresStore) CreatePurchasePlan(ctx context.Context, plan *PurchasePlan) error { // Generate UUID if not provided if plan.ID == "" { @@ -568,7 +568,7 @@ func scanPurchasePlanRow(row pgx.Row) (*PurchasePlan, error) { return &plan, nil } -// GetPurchasePlan retrieves a purchase plan by ID +// GetPurchasePlan retrieves a purchase plan by ID. func (s *PostgresStore) GetPurchasePlan(ctx context.Context, planID string) (*PurchasePlan, error) { query := purchasePlanSelectCols + ` WHERE id = $1` plan, err := scanPurchasePlanRow(s.db.QueryRow(ctx, query, planID)) @@ -586,7 +586,7 @@ func (s *PostgresStore) GetPurchasePlan(ctx context.Context, planID string) (*Pu // callers (overlapping Lambda invocations, multi-tick cron) cannot both read // the same CurrentStep value and both write CurrentStep+1, skipping a step. // Returns nil when the plan no longer exists (deleted between execution and -// progress update) so the caller is not penalised for a race it cannot control. +// progress update) so the caller is not penalized for a race it cannot control. func (s *PostgresStore) IncrementPlanCurrentStep(ctx context.Context, planID string) error { return s.WithTx(ctx, func(tx pgx.Tx) error { row := tx.QueryRow(ctx, purchasePlanSelectCols+` WHERE id = $1 FOR UPDATE`, planID) @@ -696,7 +696,7 @@ func (s *PostgresStore) UpdatePurchasePlanTx(ctx context.Context, tx pgx.Tx, pla return nil } -// DeletePurchasePlan deletes a purchase plan +// DeletePurchasePlan deletes a purchase plan. func (s *PostgresStore) DeletePurchasePlan(ctx context.Context, planID string) error { query := `DELETE FROM purchase_plans WHERE id = $1` @@ -828,7 +828,7 @@ func (s *PostgresStore) ListPurchasePlans(ctx context.Context, filter PurchasePl // PURCHASE EXECUTIONS // ========================================== -// SavePurchaseExecution saves a purchase execution record +// SavePurchaseExecution saves a purchase execution record. func (s *PostgresStore) SavePurchaseExecution(ctx context.Context, execution *PurchaseExecution) error { // Generate the execution ID before we attempt to open a tx so // pre-existing tests (which passed a nil DB and relied on ID @@ -1006,7 +1006,7 @@ func (s *PostgresStore) TransitionExecutionStatus(ctx context.Context, execution return nil, fmt.Errorf("transition %s: probe after zero-row CAS failed: %w", executionID, existErr) } // Wrap ErrExecutionNotInExpectedStatus so callers can use - // errors.Is to recognise CAS rejection (status changed between + // errors.Is to recognize CAS rejection (status changed between // SELECT and UPDATE) as race-lost rather than a real error. return nil, fmt.Errorf("%w: execution %s cannot transition from %q to %q", ErrExecutionNotInExpectedStatus, executionID, existing.Status, toStatus) } @@ -1034,7 +1034,7 @@ func (s *PostgresStore) TransitionExecutionStatus(ctx context.Context, execution // exactly as the old SavePurchaseExecutionTx path did, except now the // status guard is inside the UPDATE rather than checked optimistically // before entering the tx. -func (s *PostgresStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (cancelled bool, currentStatus string, err error) { +func (s *PostgresStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (canceled bool, currentStatus string, err error) { q := ` UPDATE purchase_executions SET status = 'cancelled', @@ -1091,7 +1091,7 @@ func (s *PostgresStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, ex // Returns (true, "cancelled", nil) on success and (false, "", err) on a // real DB error. Must be called inside a WithTx block so the suppression // cleanup commits atomically with the status flip. -func (s *PostgresStore) CancelScheduledExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (cancelled bool, currentStatus string, err error) { +func (s *PostgresStore) CancelScheduledExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (canceled bool, currentStatus string, err error) { q := ` UPDATE purchase_executions SET status = 'cancelled', @@ -1273,7 +1273,7 @@ func (s *PostgresStore) ListStuckExecutions(ctx context.Context, statuses []stri return s.queryExecutions(ctx, query, statuses, intervalArg, MaxListLimit) } -// GetPendingExecutions retrieves all pending purchase executions +// GetPendingExecutions retrieves all pending purchase executions. func (s *PostgresStore) GetPendingExecutions(ctx context.Context) ([]PurchaseExecution, error) { query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, @@ -1354,7 +1354,7 @@ func (s *PostgresStore) GetExecutionByID(ctx context.Context, executionID string return &executions[0], nil } -// GetExecutionByPlanAndDate retrieves execution for a specific plan and date +// GetExecutionByPlanAndDate retrieves execution for a specific plan and date. func (s *PostgresStore) GetExecutionByPlanAndDate(ctx context.Context, planID string, scheduledDate time.Time) (*PurchaseExecution, error) { query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, @@ -1430,7 +1430,7 @@ func (s *PostgresStore) ListPendingExecutionIDsForAccount(ctx context.Context, a return ids, nil } -// queryExecutions is a helper to query and scan purchase executions +// queryExecutions is a helper to query and scan purchase executions. func (s *PostgresStore) queryExecutions(ctx context.Context, query string, args ...any) ([]PurchaseExecution, error) { rows, err := s.db.Query(ctx, query, args...) if err != nil { @@ -1615,7 +1615,7 @@ func (s *PostgresStore) CleanupOldExecutions(ctx context.Context, retentionDays // PURCHASE HISTORY // ========================================== -// SavePurchaseHistory saves a purchase history record +// SavePurchaseHistory saves a purchase history record. func (s *PostgresStore) SavePurchaseHistory(ctx context.Context, record *PurchaseHistoryRecord) error { query := ` INSERT INTO purchase_history ( @@ -1655,7 +1655,7 @@ func (s *PostgresStore) SavePurchaseHistory(ctx context.Context, record *Purchas return nil } -// GetPurchaseHistory retrieves purchase history for an account +// GetPurchaseHistory retrieves purchase history for an account. func (s *PostgresStore) GetPurchaseHistory(ctx context.Context, accountID string, limit int) ([]PurchaseHistoryRecord, error) { query := ` SELECT account_id, purchase_id, timestamp, provider, service, region, @@ -1671,7 +1671,7 @@ func (s *PostgresStore) GetPurchaseHistory(ctx context.Context, accountID string return s.queryPurchaseHistory(ctx, query, accountID, limit) } -// GetAllPurchaseHistory retrieves all purchase history +// GetAllPurchaseHistory retrieves all purchase history. func (s *PostgresStore) GetAllPurchaseHistory(ctx context.Context, limit int) ([]PurchaseHistoryRecord, error) { query := ` SELECT account_id, purchase_id, timestamp, provider, service, region, @@ -2170,7 +2170,7 @@ func (s *PostgresStore) GetPurchaseHistoryInFlight(ctx context.Context) ([]*Purc // RI EXCHANGE HISTORY // ========================================== -// SaveRIExchangeRecord saves an RI exchange record +// SaveRIExchangeRecord saves an RI exchange record. func (s *PostgresStore) SaveRIExchangeRecord(ctx context.Context, record *RIExchangeRecord) error { if record.ID == "" { record.ID = uuid.New().String() @@ -2234,7 +2234,7 @@ func (s *PostgresStore) SaveRIExchangeRecord(ctx context.Context, record *RIExch return nil } -// GetRIExchangeRecord retrieves an RI exchange record by ID +// GetRIExchangeRecord retrieves an RI exchange record by ID. func (s *PostgresStore) GetRIExchangeRecord(ctx context.Context, id string) (*RIExchangeRecord, error) { query := ` SELECT id, account_id, exchange_id, region, source_ri_ids, @@ -2259,7 +2259,7 @@ func (s *PostgresStore) GetRIExchangeRecord(ctx context.Context, id string) (*RI return &records[0], nil } -// GetRIExchangeRecordByToken retrieves an RI exchange record by approval token +// GetRIExchangeRecordByToken retrieves an RI exchange record by approval token. func (s *PostgresStore) GetRIExchangeRecordByToken(ctx context.Context, token string) (*RIExchangeRecord, error) { query := ` SELECT id, account_id, exchange_id, region, source_ri_ids, @@ -2284,7 +2284,7 @@ func (s *PostgresStore) GetRIExchangeRecordByToken(ctx context.Context, token st return &records[0], nil } -// GetRIExchangeHistory retrieves RI exchange history records +// GetRIExchangeHistory retrieves RI exchange history records. func (s *PostgresStore) GetRIExchangeHistory(ctx context.Context, since time.Time, limit int) ([]RIExchangeRecord, error) { query := ` SELECT id, account_id, exchange_id, region, source_ri_ids, @@ -2340,7 +2340,7 @@ func (s *PostgresStore) diagnoseTransitionFailure(ctx context.Context, id, fromS err := s.db.QueryRow(ctx, `SELECT status, (expires_at IS NOT NULL AND expires_at <= NOW()) FROM ri_exchange_history WHERE id = $1`, id, ).Scan(¤tStatus, &expired) - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { return fmt.Errorf("ri exchange record not found: %s", id) } if err != nil { @@ -2352,7 +2352,7 @@ func (s *PostgresStore) diagnoseTransitionFailure(ctx context.Context, id, fromS return fmt.Errorf("ri exchange status transition failed: expected status %q but current status is %q", fromStatus, currentStatus) } -// CompleteRIExchange marks an RI exchange as completed +// CompleteRIExchange marks an RI exchange as completed. func (s *PostgresStore) CompleteRIExchange(ctx context.Context, id string, exchangeID string) error { query := ` UPDATE ri_exchange_history @@ -2393,7 +2393,7 @@ func (s *PostgresStore) StampRIExchangeApprovedBy(ctx context.Context, id string return nil } -// FailRIExchange marks an RI exchange as failed +// FailRIExchange marks an RI exchange as failed. func (s *PostgresStore) FailRIExchange(ctx context.Context, id string, errorMsg string) error { query := ` UPDATE ri_exchange_history @@ -2413,7 +2413,7 @@ func (s *PostgresStore) FailRIExchange(ctx context.Context, id string, errorMsg return nil } -// GetRIExchangeDailySpend returns total payment_due for completed exchanges on a given date (UTC) +// GetRIExchangeDailySpend returns total payment_due for completed exchanges on a given date (UTC). func (s *PostgresStore) GetRIExchangeDailySpend(ctx context.Context, date time.Time) (string, error) { query := ` SELECT COALESCE(SUM(payment_due), 0)::text @@ -2432,7 +2432,7 @@ func (s *PostgresStore) GetRIExchangeDailySpend(ctx context.Context, date time.T return total, nil } -// CancelAllPendingExchanges cancels all pending RI exchange records +// CancelAllPendingExchanges cancels all pending RI exchange records. func (s *PostgresStore) CancelAllPendingExchanges(ctx context.Context) (int64, error) { query := ` UPDATE ri_exchange_history @@ -2448,7 +2448,7 @@ func (s *PostgresStore) CancelAllPendingExchanges(ctx context.Context) (int64, e return result.RowsAffected(), nil } -// GetStaleProcessingExchanges returns processing exchanges older than the given duration +// GetStaleProcessingExchanges returns processing exchanges older than the given duration. func (s *PostgresStore) GetStaleProcessingExchanges(ctx context.Context, olderThan time.Duration) ([]RIExchangeRecord, error) { query := ` SELECT id, account_id, exchange_id, region, source_ri_ids, @@ -2464,7 +2464,7 @@ func (s *PostgresStore) GetStaleProcessingExchanges(ctx context.Context, olderTh return s.queryRIExchangeRecords(ctx, query, fmt.Sprintf("%d seconds", int(olderThan.Seconds()))) } -// queryRIExchangeRecords is a helper to query and scan RI exchange records +// queryRIExchangeRecords is a helper to query and scan RI exchange records. func (s *PostgresStore) queryRIExchangeRecords(ctx context.Context, query string, args ...any) ([]RIExchangeRecord, error) { rows, err := s.db.Query(ctx, query, args...) if err != nil { @@ -2628,7 +2628,7 @@ func (s *PostgresStore) GetCloudAccount(ctx context.Context, id string) (*CloudA &account.CredentialsConfigured, ) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("failed to get cloud account: %w", err) @@ -2678,7 +2678,7 @@ func (s *PostgresStore) GetCloudAccountByExternalID(ctx context.Context, provide &account.CredentialsConfigured, ) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("failed to get cloud account by external id: %w", err) @@ -2757,7 +2757,7 @@ func (s *PostgresStore) DeleteCloudAccount(ctx context.Context, id string) error defer tx.Rollback(ctx) //nolint:errcheck // Reset any linked approved registration first (explicit NULL so we don't - // rely on the FK's ON DELETE SET NULL behaviour). + // rely on the FK's ON DELETE SET NULL behavior). if _, err = tx.Exec(ctx, ` UPDATE account_registrations SET status = 'pending', @@ -2891,7 +2891,7 @@ func (s *PostgresStore) GetAccountCredential(ctx context.Context, accountID, cre accountID, credentialType, ).Scan(&blob) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { return "", nil } return "", fmt.Errorf("failed to get account credential: %w", err) @@ -2945,7 +2945,7 @@ func (s *PostgresStore) GetAccountServiceOverride(ctx context.Context, accountID &o.CreatedAt, &o.UpdatedAt, ) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("failed to get service override: %w", err) @@ -3222,7 +3222,7 @@ func (s *PostgresStore) GetPlanAccounts(ctx context.Context, planID string) ([]C // HELPER FUNCTIONS // ========================================== -// timeFromTTL converts a Unix timestamp (TTL) to a nullable time.Time +// timeFromTTL converts a Unix timestamp (TTL) to a nullable time.Time. func timeFromTTL(ttl int64) any { if ttl == 0 { return nil @@ -3231,12 +3231,12 @@ func timeFromTTL(ttl int64) any { return &t } -// ttlFromTime converts a time.Time to Unix timestamp +// ttlFromTime converts a time.Time to Unix timestamp. func ttlFromTime(t time.Time) int64 { return t.Unix() } -// nullStringFromString converts a string to sql.NullString +// nullStringFromString converts a string to sql.NullString. func nullStringFromString(s string) sql.NullString { if s == "" { return sql.NullString{} diff --git a/internal/config/store_postgres_additional_test.go b/internal/config/store_postgres_additional_test.go index 7cd33a791..98f5725e6 100644 --- a/internal/config/store_postgres_additional_test.go +++ b/internal/config/store_postgres_additional_test.go @@ -13,13 +13,13 @@ import ( "github.com/stretchr/testify/require" ) -// additionalMockStore is a test wrapper for additional coverage tests +// additionalMockStore is a test wrapper for additional coverage tests. type additionalMockStore struct { mock pgxmock.PgxPoolIface } // queryExecutions is the same implementation as PostgresStore.queryExecutions -// for testing purposes +// for testing purposes. func (s *additionalMockStore) queryExecutions(ctx context.Context, query string, args ...interface{}) ([]PurchaseExecution, error) { rows, err := s.mock.Query(ctx, query, args...) if err != nil { diff --git a/internal/config/store_postgres_comprehensive_test.go b/internal/config/store_postgres_comprehensive_test.go index dda071e1f..a2be136fd 100644 --- a/internal/config/store_postgres_comprehensive_test.go +++ b/internal/config/store_postgres_comprehensive_test.go @@ -15,7 +15,7 @@ import ( ) // mockablePostgresStore is a test wrapper that allows direct pgxmock integration -// This mirrors the actual PostgresStore logic for testing +// This mirrors the actual PostgresStore logic for testing. type mockablePostgresStore struct { mock pgxmock.PgxPoolIface } diff --git a/internal/config/store_postgres_db_test.go b/internal/config/store_postgres_db_test.go index 9b2e322cb..a59977987 100644 --- a/internal/config/store_postgres_db_test.go +++ b/internal/config/store_postgres_db_test.go @@ -22,7 +22,7 @@ import ( "github.com/stretchr/testify/require" ) -// getTestMigrationsPath returns the absolute path to migrations directory +// getTestMigrationsPath returns the absolute path to migrations directory. func getTestMigrationsPath() string { _, filename, _, _ := runtime.Caller(0) return filepath.Join(filepath.Dir(filename), "..", "database", "postgres", "migrations") @@ -69,7 +69,7 @@ func setupTestContainerDB(t *testing.T) *database.Connection { return container.DB } -// cleanupTestData deletes all data from test tables +// cleanupTestData deletes all data from test tables. func cleanupTestData(t *testing.T, conn *database.Connection) { t.Helper() ctx := context.Background() @@ -857,7 +857,7 @@ func TestPostgresStoreDB_PurchaseHistory(t *testing.T) { } // TestPostgresStoreDB_QueryExecutions_NullableTimestamps tests the queryExecutions -// helper's handling of all nullable timestamp fields +// helper's handling of all nullable timestamp fields. func TestPostgresStoreDB_QueryExecutions_NullableTimestamps(t *testing.T) { conn := setupTestContainerDB(t) if conn == nil { @@ -924,7 +924,7 @@ func TestPostgresStoreDB_QueryExecutions_NullableTimestamps(t *testing.T) { } // TestPostgresStoreDB_PurchaseHistory_NullStrings tests the queryPurchaseHistory -// helper's handling of nullable string fields (plan_id, plan_name) +// helper's handling of nullable string fields (plan_id, plan_name). func TestPostgresStoreDB_PurchaseHistory_NullStrings(t *testing.T) { conn := setupTestContainerDB(t) if conn == nil { diff --git a/internal/config/store_postgres_increment_step_test.go b/internal/config/store_postgres_increment_step_test.go index 4e3549a89..ac8897b93 100644 --- a/internal/config/store_postgres_increment_step_test.go +++ b/internal/config/store_postgres_increment_step_test.go @@ -151,7 +151,7 @@ func TestPGXMock_IncrementPlanCurrentStep_PlanDeletedMidRace(t *testing.T) { mock.ExpectCommit() // A plan deleted between execution and progress update must not error: the - // caller cannot control that race and should not be penalised for it. + // caller cannot control that race and should not be penalized for it. err := store.IncrementPlanCurrentStep(ctx, "gone") require.NoError(t, err) require.NoError(t, mock.ExpectationsWereMet()) diff --git a/internal/config/store_postgres_mock_test.go b/internal/config/store_postgres_mock_test.go index e7ec404ea..105966513 100644 --- a/internal/config/store_postgres_mock_test.go +++ b/internal/config/store_postgres_mock_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "testing" "time" @@ -15,14 +16,14 @@ import ( "github.com/stretchr/testify/require" ) -// MockDBInterface defines the interface that matches database.Connection methods +// MockDBInterface defines the interface that matches database.Connection methods. type MockDBInterface interface { Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) } -// testablePostgresStore is a test-only wrapper that allows mocking +// testablePostgresStore is a test-only wrapper that allows mocking. type testablePostgresStore struct { mock pgxmock.PgxPoolIface } @@ -49,7 +50,7 @@ func (s *testablePostgresStore) GetGlobalConfig(ctx context.Context) (*GlobalCon ) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { return &GlobalConfig{ EnabledProviders: []string{}, ApprovalRequired: true, @@ -129,7 +130,7 @@ func (s *testablePostgresStore) GetServiceConfig(ctx context.Context, provider, ) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { return nil, errors.New("service config not found") } return nil, err @@ -282,8 +283,8 @@ func (s *testablePostgresStore) GetPurchasePlan(ctx context.Context, planID stri ) if err != nil { - if err == pgx.ErrNoRows { - return nil, errors.New("purchase plan not found") + if errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("%w: purchase plan %s", ErrNotFound, planID) } return nil, err } @@ -309,7 +310,7 @@ func (s *testablePostgresStore) GetPurchasePlan(ctx context.Context, planID stri return &plan, nil } -// TestGetGlobalConfig_NoRows tests that default config is returned when no rows exist +// TestGetGlobalConfig_NoRows tests that default config is returned when no rows exist. func TestGetGlobalConfig_NoRows(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -333,7 +334,7 @@ func TestGetGlobalConfig_NoRows(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestGetGlobalConfig_Success tests successful retrieval of global config +// TestGetGlobalConfig_Success tests successful retrieval of global config. func TestGetGlobalConfig_Success(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -366,7 +367,7 @@ func TestGetGlobalConfig_Success(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestGetGlobalConfig_Error tests error handling +// TestGetGlobalConfig_Error tests error handling. func TestGetGlobalConfig_Error(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -385,7 +386,7 @@ func TestGetGlobalConfig_Error(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestSaveGlobalConfig_Success tests successful save of global config +// TestSaveGlobalConfig_Success tests successful save of global config. func TestSaveGlobalConfig_Success(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -422,7 +423,7 @@ func TestSaveGlobalConfig_Success(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestSaveGlobalConfig_NilEnabledProviders tests that nil EnabledProviders gets converted to empty slice +// TestSaveGlobalConfig_NilEnabledProviders tests that nil EnabledProviders gets converted to empty slice. func TestSaveGlobalConfig_NilEnabledProviders(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -457,7 +458,7 @@ func TestSaveGlobalConfig_NilEnabledProviders(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestSaveGlobalConfig_Error tests error handling +// TestSaveGlobalConfig_Error tests error handling. func TestSaveGlobalConfig_Error(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -488,7 +489,7 @@ func TestSaveGlobalConfig_Error(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestGetServiceConfig_Success tests successful retrieval of service config +// TestGetServiceConfig_Success tests successful retrieval of service config. func TestGetServiceConfig_Success(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -525,7 +526,7 @@ func TestGetServiceConfig_Success(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestGetServiceConfig_NotFound tests service config not found +// TestGetServiceConfig_NotFound tests service config not found. func TestGetServiceConfig_NotFound(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -545,7 +546,7 @@ func TestGetServiceConfig_NotFound(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestGetServiceConfig_Error tests error handling +// TestGetServiceConfig_Error tests error handling. func TestGetServiceConfig_Error(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -565,7 +566,7 @@ func TestGetServiceConfig_Error(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestSaveServiceConfig_Success tests successful save of service config +// TestSaveServiceConfig_Success tests successful save of service config. func TestSaveServiceConfig_Success(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -605,7 +606,7 @@ func TestSaveServiceConfig_Success(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestSaveServiceConfig_Error tests error handling +// TestSaveServiceConfig_Error tests error handling. func TestSaveServiceConfig_Error(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -634,7 +635,7 @@ func TestSaveServiceConfig_Error(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestListServiceConfigs_Success tests successful listing of service configs +// TestListServiceConfigs_Success tests successful listing of service configs. func TestListServiceConfigs_Success(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -665,7 +666,7 @@ func TestListServiceConfigs_Success(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestListServiceConfigs_Empty tests listing when no configs exist +// TestListServiceConfigs_Empty tests listing when no configs exist. func TestListServiceConfigs_Empty(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -690,7 +691,7 @@ func TestListServiceConfigs_Empty(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestListServiceConfigs_Error tests error handling +// TestListServiceConfigs_Error tests error handling. func TestListServiceConfigs_Error(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -709,7 +710,7 @@ func TestListServiceConfigs_Error(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestDeletePurchasePlan_Success tests successful deletion +// TestDeletePurchasePlan_Success tests successful deletion. func TestDeletePurchasePlan_Success(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -727,7 +728,7 @@ func TestDeletePurchasePlan_Success(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestDeletePurchasePlan_NotFound tests deletion of non-existent plan +// TestDeletePurchasePlan_NotFound tests deletion of non-existent plan. func TestDeletePurchasePlan_NotFound(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -746,7 +747,7 @@ func TestDeletePurchasePlan_NotFound(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestDeletePurchasePlan_Error tests error handling +// TestDeletePurchasePlan_Error tests error handling. func TestDeletePurchasePlan_Error(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -765,7 +766,7 @@ func TestDeletePurchasePlan_Error(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestGetPurchasePlan_Success tests successful retrieval of purchase plan +// TestGetPurchasePlan_Success tests successful retrieval of purchase plan. func TestGetPurchasePlan_Success(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -809,7 +810,7 @@ func TestGetPurchasePlan_Success(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestGetPurchasePlan_NotFound tests retrieval of non-existent plan +// TestGetPurchasePlan_NotFound tests retrieval of non-existent plan. func TestGetPurchasePlan_NotFound(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -829,7 +830,7 @@ func TestGetPurchasePlan_NotFound(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestGetPurchasePlan_Error tests error handling +// TestGetPurchasePlan_Error tests error handling. func TestGetPurchasePlan_Error(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -849,7 +850,7 @@ func TestGetPurchasePlan_Error(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestGetPurchasePlan_InvalidJSON tests handling of invalid JSON in services field +// TestGetPurchasePlan_InvalidJSON tests handling of invalid JSON in services field. func TestGetPurchasePlan_InvalidServicesJSON(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -881,7 +882,7 @@ func TestGetPurchasePlan_InvalidServicesJSON(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestGetPurchasePlan_InvalidRampScheduleJSON tests handling of invalid JSON in ramp_schedule field +// TestGetPurchasePlan_InvalidRampScheduleJSON tests handling of invalid JSON in ramp_schedule field. func TestGetPurchasePlan_InvalidRampScheduleJSON(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) @@ -913,7 +914,7 @@ func TestGetPurchasePlan_InvalidRampScheduleJSON(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -// TestGetPurchasePlan_AllNullableTimestampsSet tests when all nullable timestamps are set +// TestGetPurchasePlan_AllNullableTimestampsSet tests when all nullable timestamps are set. func TestGetPurchasePlan_AllNullableTimestampsSet(t *testing.T) { mock, err := pgxmock.NewPool() require.NoError(t, err) diff --git a/internal/config/store_postgres_recommendations.go b/internal/config/store_postgres_recommendations.go index ce3c3a722..452c6bda4 100644 --- a/internal/config/store_postgres_recommendations.go +++ b/internal/config/store_postgres_recommendations.go @@ -84,7 +84,7 @@ func (s *PostgresStore) UpsertRecommendations(ctx context.Context, collectedAt t if len(successfulCollects) > 0 { providers, accountKeys, err := successfulCollectArrays(successfulCollects) if err != nil { - return fmt.Errorf("failed to materialise successful-collect arrays: %w", err) + return fmt.Errorf("failed to materialize successful-collect arrays: %w", err) } if _, err := tx.Exec(ctx, ` DELETE FROM recommendations @@ -443,7 +443,7 @@ func (s *PostgresStore) SetRecommendationsCollectionError(ctx context.Context, e // // Returns true when this caller won the race (rowsAffected == 1) and should // proceed with the async invoke. Returns false when another collection is -// already in flight (rowsAffected == 0), signalling the handler to return +// already in flight (rowsAffected == 0), signaling the handler to return // 409 Conflict. func (s *PostgresStore) MarkCollectionStarted(ctx context.Context) (bool, error) { tag, err := s.db.Exec(ctx, ` diff --git a/internal/config/store_postgres_recommendations_test.go b/internal/config/store_postgres_recommendations_test.go index 9025f21ad..d72951e19 100644 --- a/internal/config/store_postgres_recommendations_test.go +++ b/internal/config/store_postgres_recommendations_test.go @@ -226,7 +226,7 @@ func TestPostgresStore_Freshness_RoundTrip(t *testing.T) { } // TestPostgresStore_UpsertRecommendations_StoresAllTermVariants pins -// the broadened-natural-key behaviour from migration 000032: when +// the broadened-natural-key behavior from migration 000032: when // Azure returns multiple `(term, payment)` variants for the same // (account, provider, service, region, resource_type) SKU, all of // them must round-trip through the cache as distinct rows. Pre-fix diff --git a/internal/config/store_postgres_registrations.go b/internal/config/store_postgres_registrations.go index 9b15db9f3..5d1c6689d 100644 --- a/internal/config/store_postgres_registrations.go +++ b/internal/config/store_postgres_registrations.go @@ -299,7 +299,7 @@ func (s *PostgresStore) scanRegistration(ctx context.Context, query string, arg row := s.db.QueryRow(ctx, query, arg) reg, err := scanRegistrationRow(row) if err != nil { - if err == sql.ErrNoRows || strings.Contains(err.Error(), "no rows") { + if errors.Is(err, sql.ErrNoRows) || strings.Contains(err.Error(), "no rows") { return nil, nil } return nil, fmt.Errorf("failed to get account registration: %w", err) diff --git a/internal/config/store_postgres_savings_filter_test.go b/internal/config/store_postgres_savings_filter_test.go index 13edfc6b3..9322c432e 100644 --- a/internal/config/store_postgres_savings_filter_test.go +++ b/internal/config/store_postgres_savings_filter_test.go @@ -163,7 +163,7 @@ func TestRecEffectiveSavingsPct_Config(t *testing.T) { // TestRecommendationFilter_UnitDistinction is the central regression test for // issue #1089. It asserts that the SAME numeric value "30" produces different -// filter behaviour when interpreted as a dollar amount vs. a percentage: +// filter behavior when interpreted as a dollar amount vs. a percentage: // // rec.Savings = $100/mo, on_demand = $500/mo => effective_pct = 20% // min_savings_usd=30 => passes ($100 >= $30) diff --git a/internal/config/store_postgres_test.go b/internal/config/store_postgres_test.go index 7d93990c4..feccd07e0 100644 --- a/internal/config/store_postgres_test.go +++ b/internal/config/store_postgres_test.go @@ -17,7 +17,7 @@ import ( "github.com/stretchr/testify/require" ) -// getMigrationsPath returns the absolute path to migrations directory +// getMigrationsPath returns the absolute path to migrations directory. func getMigrationsPath() string { _, filename, _, _ := runtime.Caller(0) return filepath.Join(filepath.Dir(filename), "..", "database", "postgres", "migrations") diff --git a/internal/config/store_postgres_unit_test.go b/internal/config/store_postgres_unit_test.go index 3742e2e9e..4577fcc58 100644 --- a/internal/config/store_postgres_unit_test.go +++ b/internal/config/store_postgres_unit_test.go @@ -9,10 +9,10 @@ import ( ) // pf returns a pointer to the given float64 value. Used in test struct -// literals where a *float64 field must be initialised from a constant. +// literals where a *float64 field must be initialized from a constant. func pf(v float64) *float64 { return &v } -// TestTimeFromTTL tests the timeFromTTL helper function +// TestTimeFromTTL tests the timeFromTTL helper function. func TestTimeFromTTL(t *testing.T) { tests := []struct { name string @@ -49,7 +49,7 @@ func TestTimeFromTTL(t *testing.T) { } } -// TestTtlFromTime tests the ttlFromTime helper function +// TestTtlFromTime tests the ttlFromTime helper function. func TestTtlFromTime(t *testing.T) { tests := []struct { name string @@ -86,7 +86,7 @@ func TestTtlFromTime(t *testing.T) { } } -// TestNullStringFromString tests the nullStringFromString helper function +// TestNullStringFromString tests the nullStringFromString helper function. func TestNullStringFromString(t *testing.T) { tests := []struct { name string @@ -124,14 +124,14 @@ func TestNullStringFromString(t *testing.T) { } } -// TestNewPostgresStore tests creating a new PostgresStore +// TestNewPostgresStore tests creating a new PostgresStore. func TestNewPostgresStore(t *testing.T) { // Test that NewPostgresStore returns a non-nil store even with nil db store := NewPostgresStore(nil) assert.NotNil(t, store) } -// TestTimeFromTTLRoundTrip tests that timeFromTTL and ttlFromTime are consistent +// TestTimeFromTTLRoundTrip tests that timeFromTTL and ttlFromTime are consistent. func TestTimeFromTTLRoundTrip(t *testing.T) { // Test round trip conversion originalTime := time.Date(2024, 6, 15, 12, 30, 0, 0, time.UTC) @@ -146,7 +146,7 @@ func TestTimeFromTTLRoundTrip(t *testing.T) { assert.Equal(t, originalTime.Unix(), timePtr.Unix()) } -// TestValidProvidersConstant tests that ValidProviders is properly defined +// TestValidProvidersConstant tests that ValidProviders is properly defined. func TestValidProvidersConstant(t *testing.T) { assert.Contains(t, ValidProviders, "aws") assert.Contains(t, ValidProviders, "azure") @@ -154,7 +154,7 @@ func TestValidProvidersConstant(t *testing.T) { assert.Len(t, ValidProviders, 3) } -// TestValidPaymentOptionsConstant tests that ValidPaymentOptions is properly defined +// TestValidPaymentOptionsConstant tests that ValidPaymentOptions is properly defined. func TestValidPaymentOptionsConstant(t *testing.T) { assert.Contains(t, ValidPaymentOptions, "no-upfront") assert.Contains(t, ValidPaymentOptions, "partial-upfront") @@ -162,7 +162,7 @@ func TestValidPaymentOptionsConstant(t *testing.T) { assert.Len(t, ValidPaymentOptions, 3) } -// TestValidRampScheduleTypesConstant tests that ValidRampScheduleTypes is properly defined +// TestValidRampScheduleTypesConstant tests that ValidRampScheduleTypes is properly defined. func TestValidRampScheduleTypesConstant(t *testing.T) { assert.Contains(t, ValidRampScheduleTypes, "immediate") assert.Contains(t, ValidRampScheduleTypes, "weekly") diff --git a/internal/config/types.go b/internal/config/types.go index 962bcce76..481e9586a 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -9,7 +9,7 @@ import ( "github.com/LeanerCloud/CUDly/pkg/ladder" ) -// GlobalConfig represents the global CUDly configuration +// GlobalConfig represents the global CUDly configuration. type GlobalConfig struct { EnabledProviders []string `json:"enabled_providers" dynamodbav:"enabled_providers"` NotificationEmail *string `json:"notification_email,omitempty" dynamodbav:"notification_email,omitempty"` @@ -25,7 +25,7 @@ type GlobalConfig struct { // GracePeriodDays is a per-provider window (in days) during which // just-purchased capacity is suppressed from the recommendations // list so users don't re-buy the same capacity while the cloud - // provider's utilisation metrics catch up. Keys are provider slugs + // provider's utilization metrics catch up. Keys are provider slugs // ("aws", "azure", "gcp"). Missing keys default to DefaultGracePeriodDays // (7). An explicit 0 disables suppression for that provider. Use // GracePeriodFor to read a specific provider's effective value (it @@ -74,7 +74,7 @@ type GlobalConfig struct { // DefaultGracePeriodDays is the fallback window used when a provider // has no entry in GlobalConfig.GracePeriodDays. A week gives cloud // providers enough time to reflect a fresh commitment in their -// utilisation metrics before we'd re-propose the same capacity. +// utilization metrics before we'd re-propose the same capacity. const DefaultGracePeriodDays = 7 // MaxGracePeriodDays is the ceiling enforced at read time as a safety @@ -148,7 +148,7 @@ func (g *GlobalConfig) GracePeriodFor(provider string) int { return days } -// ServiceConfig represents per-service configuration +// ServiceConfig represents per-service configuration. type ServiceConfig struct { Provider string `json:"provider" dynamodbav:"provider"` Service string `json:"service" dynamodbav:"service"` @@ -172,7 +172,7 @@ type ServiceConfig struct { MinCount int `json:"min_count,omitempty" dynamodbav:"min_count,omitempty"` } -// PurchasePlan represents a saved purchase plan for automated execution +// PurchasePlan represents a saved purchase plan for automated execution. type PurchasePlan struct { ID string `json:"id" dynamodbav:"id"` Name string `json:"name" dynamodbav:"name"` @@ -196,7 +196,7 @@ type PurchasePlan struct { Unassigned bool `json:"unassigned,omitempty" dynamodbav:"unassigned,omitempty"` } -// RampSchedule defines how purchases are spread over time +// RampSchedule defines how purchases are spread over time. type RampSchedule struct { Type string `json:"type" dynamodbav:"type"` // immediate, weekly, monthly, custom PercentPerStep float64 `json:"percent_per_step" dynamodbav:"percent_per_step"` @@ -206,7 +206,7 @@ type RampSchedule struct { StartDate time.Time `json:"start_date" dynamodbav:"start_date"` } -// PresetRampSchedules provides common ramp-up configurations +// PresetRampSchedules provides common ramp-up configurations. var PresetRampSchedules = map[string]RampSchedule{ "immediate": { Type: "immediate", @@ -227,7 +227,7 @@ var PresetRampSchedules = map[string]RampSchedule{ }, } -// GetCurrentCoverage calculates the current effective coverage based on ramp progress +// GetCurrentCoverage calculates the current effective coverage based on ramp progress. func (r *RampSchedule) GetCurrentCoverage(baseCoverage float64) float64 { if r.Type == "immediate" { return baseCoverage @@ -239,7 +239,7 @@ func (r *RampSchedule) GetCurrentCoverage(baseCoverage float64) float64 { return baseCoverage * completedPercent / 100 } -// GetNextPurchaseDate calculates when the next purchase step should occur +// GetNextPurchaseDate calculates when the next purchase step should occur. func (r *RampSchedule) GetNextPurchaseDate() time.Time { if r.StartDate.IsZero() { return time.Now() @@ -247,12 +247,12 @@ func (r *RampSchedule) GetNextPurchaseDate() time.Time { return r.StartDate.AddDate(0, 0, r.CurrentStep*r.StepIntervalDays) } -// IsComplete returns true if all ramp steps are done +// IsComplete returns true if all ramp steps are done. func (r *RampSchedule) IsComplete() bool { return r.CurrentStep >= r.TotalSteps } -// PurchaseExecution represents a single execution of a purchase plan +// PurchaseExecution represents a single execution of a purchase plan. type PurchaseExecution struct { PlanID string `json:"plan_id" dynamodbav:"plan_id"` ExecutionID string `json:"execution_id" dynamodbav:"execution_id"` @@ -346,7 +346,7 @@ type PurchaseExecution struct { // token across a strand-and-re-drive so the provider dedupes and the // commitment is never bought twice. Empty on rows created before // migration 000066 — the derivation falls back to ExecutionID for those - // (identical to the pre-fix behaviour for a single un-retried execution). + // (identical to the pre-fix behavior for a single un-retried execution). IdempotencyKey string `json:"idempotency_key,omitempty" dynamodbav:"idempotency_key,omitempty"` // ScheduledExecutionAt is set by the Gmail-style pre-fire delay path // (issue #291 wave-2) when an approve defers the cloud SDK call. The @@ -369,7 +369,7 @@ func (e *PurchaseExecution) IsCancelable() bool { return e.Status == "pending" || e.Status == "notified" || e.Status == "scheduled" } -// RecommendationRecord stores a recommendation with purchase status +// RecommendationRecord stores a recommendation with purchase status. type RecommendationRecord struct { ID string `json:"id" dynamodbav:"id"` Provider string `json:"provider" dynamodbav:"provider"` @@ -486,7 +486,7 @@ type RecommendationRecord struct { // them at the top level where the frontend already reads them. // // Pointers (not plain int/float64) so "absent / non-compute / unknown - // size" serialises as omitted rather than a misleading 0: the frontend + // size" serializes as omitted rather than a misleading 0: the frontend // renders absent as "—", and a literal 0 would otherwise look like a // real "0 vCPU / 0 GB" capacity. dynamodbav:"-" because they are // derived-on-read, never stored. @@ -648,7 +648,7 @@ func RevocationWindowClosesAtFor(provider string, purchaseTime time.Time) *time. // PurchaseHistoryRecord is the response-layer representation for rows on the // /api/history page. DB-backed rows always describe *completed* purchases; the -// handler additionally synthesises rows for pending executions so users can +// handler additionally synthesizes rows for pending executions so users can // see (and cancel) in-flight approvals. Status is the discriminator — the DB // layer never writes it (tag `dynamodbav:"-"` keeps it out of persistence), // and the API layer populates it as "completed" or "pending" before returning. @@ -695,7 +695,7 @@ type PurchaseHistoryRecord struct { // CreatedByUserID propagates the originating execution's // created_by_user_id so the History UI can decide whether to render // the inline Cancel button (issue #46): a non-admin user only sees - // the button on their own pending rows. Set only for synthesised + // the button on their own pending rows. Set only for synthesized // pending/notified rows (executions); empty on completed history // rows (where the action has already completed). Excluded from DB // persistence. @@ -704,7 +704,7 @@ type PurchaseHistoryRecord struct { // its successor when the user retried it (issue #47). Set only on // the *original* failed row that has been retried; the History UI // renders an inline "Retried as #abc" link to the successor row when - // this is non-empty. Excluded from DB persistence (synthesised from + // this is non-empty. Excluded from DB persistence (synthesized from // purchase_executions). RetryExecutionID string `json:"retry_execution_id,omitempty" dynamodbav:"-"` // RetryAttemptN propagates the originating execution's retry-chain @@ -720,21 +720,21 @@ type PurchaseHistoryRecord struct { // empty otherwise. Excluded from DB persistence (computed at read // time so updates to the persistent-failure map land instantly). OpsHint string `json:"ops_hint,omitempty" dynamodbav:"-"` - // IsAuditGap marks a synthesised "completed" row whose purchase_history + // IsAuditGap marks a synthesized "completed" row whose purchase_history // write failed after a successful purchase (issue #621). Such a row is // reconstructed from the execution so the purchase stays visible, but its // execution-level dollars are excluded from the committed totals: a // partially-saved multi-rec execution can have BOTH some real - // purchase_history rows AND this synthesised row, so adding the full + // purchase_history rows AND this synthesized row, so adding the full // execution total would double-count the recs that did save. The dollars // are surfaced via the individual purchase_history rows that succeeded; // this row is the audit flag, not a money source. Real purchase_history // rows loaded from the DB always leave this false. Excluded from DB - // persistence (set only at read time on synthesised rows). + // persistence (set only at read time on synthesized rows). IsAuditGap bool `json:"is_audit_gap,omitempty" dynamodbav:"-"` // CreatedByUserEmail is the email address of the user who created the // underlying execution, resolved from CreatedByUserID via the auth - // service. Populated only on synthesised execution rows (pending, + // service. Populated only on synthesized execution rows (pending, // notified, failed, expired, cancelled) when a valid user ID is // present; empty for scheduler-driven executions, legacy NULL-creator // rows, and completed purchase_history rows. Excluded from DB @@ -780,7 +780,7 @@ type PurchaseHistoryRecord struct { RevocationInFlight bool `json:"revocation_in_flight,omitempty" dynamodbav:"revocation_in_flight,omitempty"` } -// RIExchangeRecord represents a record in the ri_exchange_history table +// RIExchangeRecord represents a record in the ri_exchange_history table. type RIExchangeRecord struct { ID string `json:"id"` AccountID string `json:"account_id"` @@ -812,7 +812,7 @@ type RIExchangeRecord struct { CloudAccountID *string `json:"cloud_account_id,omitempty"` } -// ConfigSetting represents a configuration setting for the defaults system +// ConfigSetting represents a configuration setting for the defaults system. type ConfigSetting struct { Key string `json:"key"` Value any `json:"value"` diff --git a/internal/config/validation.go b/internal/config/validation.go index 36f63c23a..14a8dc564 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -12,7 +12,7 @@ import ( "github.com/LeanerCloud/CUDly/pkg/ladder" ) -// ValidProviders lists all supported cloud providers +// ValidProviders lists all supported cloud providers. var ValidProviders = []string{"aws", "azure", "gcp"} // ValidPaymentOptions lists the AWS-canonical payment options. Kept for @@ -166,13 +166,13 @@ func crossProviderPaymentAlias(provider, raw string) (string, bool) { return "", false } -// ValidRampScheduleTypes lists all supported ramp schedule types +// ValidRampScheduleTypes lists all supported ramp schedule types. var ValidRampScheduleTypes = []string{"immediate", "weekly", "monthly", "custom"} -// ValidCollectionSchedules lists all valid collection schedule values +// ValidCollectionSchedules lists all valid collection schedule values. var ValidCollectionSchedules = []string{"", "hourly", "daily", "weekly"} -// Validate validates the GlobalConfig +// Validate validates the GlobalConfig. func (c *GlobalConfig) Validate() error { if err := c.validateProviders(); err != nil { return err @@ -272,7 +272,7 @@ func (c *GlobalConfig) validateGracePeriodDays() error { return nil } -// validateProviders checks that all enabled providers are valid +// validateProviders checks that all enabled providers are valid. func (c *GlobalConfig) validateProviders() error { for _, p := range c.EnabledProviders { if !isValidProvider(p) { @@ -282,7 +282,7 @@ func (c *GlobalConfig) validateProviders() error { return nil } -// validateNotificationEmail validates the notification email format if provided +// validateNotificationEmail validates the notification email format if provided. func (c *GlobalConfig) validateNotificationEmail() error { if c.NotificationEmail != nil && *c.NotificationEmail != "" { if _, err := mail.ParseAddress(*c.NotificationEmail); err != nil { @@ -292,7 +292,7 @@ func (c *GlobalConfig) validateNotificationEmail() error { return nil } -// validateTerm validates that the term is 1 or 3 years (or 0 for not set - service-level only) +// validateTerm validates that the term is 1 or 3 years (or 0 for not set - service-level only). func validateTerm(term int) error { if term != 0 && term != 1 && term != 3 { return fmt.Errorf("default term must be 1 or 3 years, got: %d", term) @@ -300,7 +300,7 @@ func validateTerm(term int) error { return nil } -// validateGlobalTerm validates that the term is 1 or 3 years (0 is not allowed for global config) +// validateGlobalTerm validates that the term is 1 or 3 years (0 is not allowed for global config). func validateGlobalTerm(term int) error { if term != 1 && term != 3 { return fmt.Errorf("default term must be 1 or 3 years, got: %d", term) @@ -318,7 +318,7 @@ func validatePaymentOption(payment string) error { return nil } -// validateCoverage validates that coverage is within acceptable range +// validateCoverage validates that coverage is within acceptable range. func validateCoverage(coverage float64) error { if coverage < MinCoverage || coverage > MaxCoverage { return fmt.Errorf("default coverage must be between %d and %d, got: %.2f", MinCoverage, MaxCoverage, coverage) @@ -326,7 +326,7 @@ func validateCoverage(coverage float64) error { return nil } -// Validate validates the ServiceConfig +// Validate validates the ServiceConfig. func (c *ServiceConfig) Validate() error { if err := c.validateProvider(); err != nil { return err @@ -407,7 +407,7 @@ func (c *ServiceConfig) validateConfigCoverage() error { return nil } -// Validate validates the PurchasePlan +// Validate validates the PurchasePlan. func (p *PurchasePlan) Validate() error { // Name is required if p.Name == "" { @@ -440,7 +440,7 @@ func (p *PurchasePlan) Validate() error { return nil } -// Validate validates the RampSchedule +// Validate validates the RampSchedule. func (r *RampSchedule) Validate() error { if r.Type != "" && !isValidRampScheduleType(r.Type) { return fmt.Errorf("invalid ramp schedule type: %s (valid: %s)", r.Type, strings.Join(ValidRampScheduleTypes, ", ")) @@ -530,7 +530,7 @@ func ValidatePaymentOptionEnv(val string) error { return nil } if !isValidPaymentOption(val) { - return fmt.Errorf("value %q is not a recognised payment option (valid: %s)", val, strings.Join(validPaymentOptionsUnion, ", ")) + return fmt.Errorf("value %q is not a recognized payment option (valid: %s)", val, strings.Join(validPaymentOptionsUnion, ", ")) } return nil } @@ -546,7 +546,7 @@ func ValidateRampScheduleEnv(val string) error { return nil } if !isValidRampScheduleType(val) { - return fmt.Errorf("value %q is not a recognised ramp schedule type (valid: %s)", val, strings.Join(ValidRampScheduleTypes, ", ")) + return fmt.Errorf("value %q is not a recognized ramp schedule type (valid: %s)", val, strings.Join(ValidRampScheduleTypes, ", ")) } return nil } diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go index e977f35f9..5dfeab04b 100644 --- a/internal/config/validation_test.go +++ b/internal/config/validation_test.go @@ -978,7 +978,7 @@ func TestIsValidRampScheduleType(t *testing.T) { assert.False(t, isValidRampScheduleType("")) } -// Helper function for creating string pointers in tests +// Helper function for creating string pointers in tests. func stringPtr(s string) *string { return &s } diff --git a/internal/credentials/resolver.go b/internal/credentials/resolver.go index 28555afc6..360697100 100644 --- a/internal/credentials/resolver.go +++ b/internal/credentials/resolver.go @@ -74,7 +74,7 @@ type STSClientFactory func(provider aws.CredentialsProvider) STSClient // AWSResolveOptions holds optional dependencies for the AWS credential // resolver. The bastion path needs both AccountLookup and STSClientFactory to // self-resolve correctly; without them, bastion mode falls back to the -// pre-self-loading behaviour (trusts the caller-supplied STS client) for +// pre-self-loading behavior (trusts the caller-supplied STS client) for // backward compatibility. // // AmbientProvider, when set, is returned for role_arn accounts whose @@ -206,7 +206,7 @@ func resolveRoleARNProvider( // at depth 1 to prevent loops. // // Legacy path: when either option is nil, the resolver falls back to the old -// behaviour and trusts that the caller-supplied stsClient already carries +// behavior and trusts that the caller-supplied stsClient already carries // bastion credentials. This preserves backward compatibility with callers that // have not yet been updated to wire the lookup/factory. func resolveBastionProvider( @@ -238,7 +238,7 @@ func resolveBastionProvider( } // Recursively resolve the bastion's own credentials. Pass empty opts to // guarantee the recursive call cannot trigger bastion mode (already - // guarded above by the AWSAuthMode check, but defence in depth). + // guarded above by the AWSAuthMode check, but defense in depth). bastionCreds, err := ResolveAWSCredentialProviderWithOpts(ctx, bastion, store, stsClient, AWSResolveOptions{}) if err != nil { return nil, fmt.Errorf("credentials: resolve bastion %s creds: %w", bastion.ID, err) diff --git a/internal/credentials/resolver_test.go b/internal/credentials/resolver_test.go index 4256a2422..4b7e7312b 100644 --- a/internal/credentials/resolver_test.go +++ b/internal/credentials/resolver_test.go @@ -362,7 +362,7 @@ func TestResolveBastionProvider_BastionDisabled(t *testing.T) { // TestResolveBastionProvider_LegacyFallback verifies the back-compat path: // when AccountLookup/STSClientFactory are nil, the resolver falls through to -// the old behaviour of trusting the caller-supplied STS client. +// the old behavior of trusting the caller-supplied STS client. func TestResolveBastionProvider_LegacyFallback(t *testing.T) { target := &config.CloudAccount{ ID: "target-acct", diff --git a/internal/database/config.go b/internal/database/config.go index cd66e98c7..a6a470bec 100644 --- a/internal/database/config.go +++ b/internal/database/config.go @@ -7,7 +7,7 @@ import ( "time" ) -// Config holds database configuration +// Config holds database configuration. type Config struct { // Connection details Host string @@ -38,7 +38,7 @@ type Config struct { LogLevel string // error, warn, info, debug } -// LoadFromEnv loads database configuration from environment variables +// LoadFromEnv loads database configuration from environment variables. func LoadFromEnv() (*Config, error) { config := &Config{ // Required fields @@ -78,7 +78,7 @@ func LoadFromEnv() (*Config, error) { return config, nil } -// Validate checks if the configuration is valid +// Validate checks if the configuration is valid. func (c *Config) Validate() error { if err := c.validateRequiredFields(); err != nil { return err @@ -89,7 +89,7 @@ func (c *Config) Validate() error { return c.validatePoolSettings() } -// validateRequiredFields checks that all required configuration fields are set +// validateRequiredFields checks that all required configuration fields are set. func (c *Config) validateRequiredFields() error { if c.Host == "" { return fmt.Errorf("DB_HOST is required") @@ -114,7 +114,7 @@ func (c *Config) validateRequiredFields() error { return nil } -// validateSSLMode checks that SSL mode is valid and warns about insecure production settings +// validateSSLMode checks that SSL mode is valid and warns about insecure production settings. func (c *Config) validateSSLMode() error { validSSLModes := map[string]bool{ "disable": true, @@ -133,7 +133,7 @@ func (c *Config) validateSSLMode() error { return nil } -// validatePoolSettings validates connection pool configuration +// validatePoolSettings validates connection pool configuration. func (c *Config) validatePoolSettings() error { if c.MaxConnections < 1 { return fmt.Errorf("DB_MAX_CONNECTIONS must be at least 1") @@ -164,7 +164,7 @@ func (c *Config) dsn(password string) string { } // DSN generates a PostgreSQL connection string -// If passwordOverride is provided, it's used instead of config.Password +// If passwordOverride is provided, it's used instead of config.Password. func (c *Config) DSN(passwordOverride string) string { password := c.Password if passwordOverride != "" { @@ -173,7 +173,7 @@ func (c *Config) DSN(passwordOverride string) string { return c.dsn(password) } -// RedactedDSN returns a DSN string with the password masked, safe for logging +// RedactedDSN returns a DSN string with the password masked, safe for logging. func (c *Config) RedactedDSN() string { return c.dsn("*****") } diff --git a/internal/database/connection.go b/internal/database/connection.go index daf5c042b..7296ccdac 100644 --- a/internal/database/connection.go +++ b/internal/database/connection.go @@ -17,7 +17,7 @@ import ( "github.com/jackc/pgx/v5/tracelog" ) -// Connection wraps a PostgreSQL connection pool +// Connection wraps a PostgreSQL connection pool. type Connection struct { pool *pgxpool.Pool config *Config @@ -27,14 +27,14 @@ type Connection struct { lockedConns sync.Map // map[int64]*pgxpool.Conn } -// SecretResolver interface for retrieving secrets from cloud providers +// SecretResolver interface for retrieving secrets from cloud providers. type SecretResolver interface { GetSecret(ctx context.Context, secretID string) (string, error) Close() error } // NewConnection creates a new database connection pool -// If secretResolver is provided and config.PasswordSecret is set, password will be retrieved from secret manager +// If secretResolver is provided and config.PasswordSecret is set, password will be retrieved from secret manager. func NewConnection(ctx context.Context, config *Config, secretResolver SecretResolver) (*Connection, error) { // Check if secret resolver is needed but not provided if config.PasswordSecret != "" && secretResolver == nil { @@ -190,7 +190,7 @@ func createConnectionPoolWithRetry(ctx context.Context, poolConfig *pgxpool.Conf return pool, nil } -// buildPoolConfig creates a pgxpool.Config from our Config +// buildPoolConfig creates a pgxpool.Config from our Config. func buildPoolConfig(config *Config, password string) (*pgxpool.Config, error) { // Parse a redacted DSN so that any pgxpool.ParseConfig error never // echoes the plaintext password into the error chain (pgconn.parseConfig @@ -205,7 +205,7 @@ func buildPoolConfig(config *Config, password string) (*pgxpool.Config, error) { } // Overwrite the placeholder with the real password. ConnConfig.Password - // is used by pgx at connect time and is never serialised back to a string. + // is used by pgx at connect time and is never serialized back to a string. poolConfig.ConnConfig.Password = password // Set pool configuration @@ -238,17 +238,17 @@ func buildPoolConfig(config *Config, password string) (*pgxpool.Config, error) { return poolConfig, nil } -// Pool returns the underlying connection pool +// Pool returns the underlying connection pool. func (c *Connection) Pool() *pgxpool.Pool { return c.pool } -// Close closes the connection pool +// Close closes the connection pool. func (c *Connection) Close() { c.pool.Close() } -// HealthCheck verifies the database connection is healthy +// HealthCheck verifies the database connection is healthy. func (c *Connection) HealthCheck(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() @@ -267,42 +267,42 @@ func (c *Connection) HealthCheck(ctx context.Context) error { return nil } -// Stats returns connection pool statistics +// Stats returns connection pool statistics. func (c *Connection) Stats() *pgxpool.Stat { return c.pool.Stat() } -// Acquire gets a connection from the pool +// Acquire gets a connection from the pool. func (c *Connection) Acquire(ctx context.Context) (*pgxpool.Conn, error) { return c.pool.Acquire(ctx) } -// Begin starts a new transaction +// Begin starts a new transaction. func (c *Connection) Begin(ctx context.Context) (pgx.Tx, error) { return c.pool.Begin(ctx) } -// BeginTx starts a new transaction with options +// BeginTx starts a new transaction with options. func (c *Connection) BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) { return c.pool.BeginTx(ctx, txOptions) } -// Query executes a query +// Query executes a query. func (c *Connection) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) { return c.pool.Query(ctx, sql, args...) } -// QueryRow executes a query that returns at most one row +// QueryRow executes a query that returns at most one row. func (c *Connection) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row { return c.pool.QueryRow(ctx, sql, args...) } -// Exec executes a command +// Exec executes a command. func (c *Connection) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { return c.pool.Exec(ctx, sql, args...) } -// Ping checks the database connection +// Ping checks the database connection. func (c *Connection) Ping(ctx context.Context) error { return c.pool.Ping(ctx) } @@ -361,7 +361,7 @@ func (c *Connection) ReleaseAdvisoryLock(ctx context.Context, lockID int64) { } } -// parseLogLevel converts string log level to pgx tracelog level +// parseLogLevel converts string log level to pgx tracelog level. func parseLogLevel(level string) tracelog.LogLevel { switch level { case "debug": @@ -377,7 +377,7 @@ func parseLogLevel(level string) tracelog.LogLevel { } } -// stdLogger implements pgx tracelog.Logger using the logging package +// stdLogger implements pgx tracelog.Logger using the logging package. type stdLogger struct{} // isSensitiveKey reports whether a pgx data-map key should always be redacted. diff --git a/internal/database/connection_test.go b/internal/database/connection_test.go index dab1431c0..97d183cb9 100644 --- a/internal/database/connection_test.go +++ b/internal/database/connection_test.go @@ -167,7 +167,7 @@ func TestBuildPoolConfig(t *testing.T) { }) } -// MockSecretResolver implements SecretResolver for testing +// MockSecretResolver implements SecretResolver for testing. type MockSecretResolver struct { SecretValue string SecretError error diff --git a/internal/database/coverage_extra_test.go b/internal/database/coverage_extra_test.go index fa395321f..0a62a2173 100644 --- a/internal/database/coverage_extra_test.go +++ b/internal/database/coverage_extra_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" ) -// Tests for RedactedDSN +// Tests for RedactedDSN. func TestRedactedDSN(t *testing.T) { cfg := &Config{ Host: "db.example.com", @@ -55,7 +55,7 @@ func TestRedactedDSN_SharesLayoutWithDSN(t *testing.T) { assert.Equal(t, expected, redacted) } -// Tests for extractPasswordFromSecret +// Tests for extractPasswordFromSecret. func TestExtractPasswordFromSecret_JSONWithPassword(t *testing.T) { secret := `{"username":"admin","password":"db-pass-123","host":"db.example.com"}` pwd, err := extractPasswordFromSecret(secret) @@ -92,7 +92,7 @@ func TestExtractPasswordFromSecret_JSONNull(t *testing.T) { assert.Contains(t, err.Error(), "missing 'password' field") } -// Tests for buildPoolConfig overflow protection +// Tests for buildPoolConfig overflow protection. func TestBuildPoolConfig_MaxConnectionsOverflow(t *testing.T) { cfg := &Config{ Host: "localhost", @@ -131,7 +131,7 @@ func TestBuildPoolConfig_MinConnectionsOverflow(t *testing.T) { assert.Contains(t, err.Error(), "MinConnections") } -// Tests for resolvePassword branches +// Tests for resolvePassword branches. func TestResolvePassword_NeitherPasswordNorSecret(t *testing.T) { cfg := &Config{ Password: "", @@ -185,7 +185,7 @@ func TestResolvePassword_SecretResolverFails(t *testing.T) { assert.Contains(t, err.Error(), "failed to retrieve database password from secret manager") } -// Tests for NewConnection when PasswordSecret set without resolver +// Tests for NewConnection when PasswordSecret set without resolver. func TestNewConnection_SecretRequiredButNoResolver(t *testing.T) { cfg := &Config{ Host: "localhost", @@ -209,7 +209,7 @@ func TestNewConnection_SecretRequiredButNoResolver(t *testing.T) { assert.Contains(t, err.Error(), "DB_PASSWORD_SECRET is set but no secret resolver was provided") } -// Tests for Pool() — returns the internal pool (nil when not connected) +// Tests for Pool() — returns the internal pool (nil when not connected). func TestConnection_Pool_ReturnsPool(t *testing.T) { conn := &Connection{ pool: nil, @@ -218,7 +218,7 @@ func TestConnection_Pool_ReturnsPool(t *testing.T) { assert.Nil(t, conn.Pool()) } -// Tests for DSN() +// Tests for DSN(). func TestConfig_DSN(t *testing.T) { cfg := &Config{ Host: "pg.example.com", diff --git a/internal/database/postgres/migrations/000053_executions_account_fk_restrict_test.go b/internal/database/postgres/migrations/000053_executions_account_fk_restrict_test.go index 6a696129c..0bfe66090 100644 --- a/internal/database/postgres/migrations/000053_executions_account_fk_restrict_test.go +++ b/internal/database/postgres/migrations/000053_executions_account_fk_restrict_test.go @@ -55,7 +55,7 @@ func TestMigration_ExecutionsAccountFKRestrict(t *testing.T) { assert.Equal(t, "c", recsDeleteAction, "recommendations FK must stay CASCADE after 000053, got %q", recsDeleteAction) - // Behavioural test: insert an account + a pending execution that + // Behavioral test: insert an account + a pending execution that // references it, then attempt to delete the account. Postgres must // raise a foreign-key-violation (SQLSTATE 23503). _, err = pool.Exec(ctx, ` @@ -93,9 +93,9 @@ func TestMigration_ExecutionsAccountFKRestrict(t *testing.T) { } // TestMigration_ExecutionsAccountFKRestrict_Rollback asserts that the -// 000053 down migration restores the original SET NULL behaviour, so an +// 000053 down migration restores the original SET NULL behavior, so an // emergency rollback re-introduces the (documented) silent-orphan -// behaviour rather than leaving the database in an indeterminate state. +// behavior rather than leaving the database in an indeterminate state. func TestMigration_ExecutionsAccountFKRestrict_Rollback(t *testing.T) { ctx := context.Background() migrationsPath := getMigrationsPath() diff --git a/internal/database/postgres/migrations/000065_enforce_min_one_admin_test.go b/internal/database/postgres/migrations/000065_enforce_min_one_admin_test.go index 1b2e9668a..d12e1b13e 100644 --- a/internal/database/postgres/migrations/000065_enforce_min_one_admin_test.go +++ b/internal/database/postgres/migrations/000065_enforce_min_one_admin_test.go @@ -85,7 +85,7 @@ func TestMigration_EnforceMinOneAdmin_ConcurrentRace(t *testing.T) { require.Equal(t, 2, countActiveAdmins(t, ctx, pool), "test setup: two active admins expected") // Release both goroutines into their COMMIT only after both have - // completed their mutating statement, maximising the overlap the + // completed their mutating statement, maximizing the overlap the // deferred triggers must arbitrate. var writesReady sync.WaitGroup writesReady.Add(2) @@ -137,7 +137,7 @@ func TestMigration_EnforceMinOneAdmin_ConcurrentRace(t *testing.T) { // commits == 1 would be wrong: depending on commit interleaving the advisory // lock can legitimately reject BOTH transactions (commits == 0, two admins // untouched), which is still safe. The bug this guards against is the - // pre-trigger behaviour where both committed and zero admins remained. + // pre-trigger behavior where both committed and zero admins remained. assertRaceSafe := func(t *testing.T, commits, remainingAdmins int, op string) { t.Helper() assert.LessOrEqual(t, commits, 1, diff --git a/internal/database/postgres/migrations/ensure_admin_user_test.go b/internal/database/postgres/migrations/ensure_admin_user_test.go index 18eeffadf..b6c1ae9b8 100644 --- a/internal/database/postgres/migrations/ensure_admin_user_test.go +++ b/internal/database/postgres/migrations/ensure_admin_user_test.go @@ -39,7 +39,7 @@ func queryAdminGroupIDs(t *testing.T, ctx context.Context, pool *pgxpool.Pool, e // TestEnsureAdminUser_GroupAssignment covers the five scenarios from // issue #351: every code path that inserts an admin row must produce // group_ids containing the Administrators group, post-migration -// drift must self-heal on the next boot, and operator-customised +// drift must self-heal on the next boot, and operator-customized // group_ids must be preserved. func TestEnsureAdminUser_GroupAssignment(t *testing.T) { ctx := context.Background() @@ -150,11 +150,11 @@ func TestEnsureAdminUser_GroupAssignment(t *testing.T) { // Second pass: re-run RunMigrations. ensureAdminUser fires // again, but the backfill's WHERE cardinality=0 clause skips - // the customised row. + // the customized row. require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, adminEmail, "")) got := queryAdminGroupIDs(t, ctx, pool, adminEmail) assert.Equal(t, []string{customGroupID}, got, - "operator-customised group_ids (non-empty, no default admin group) must be preserved across boots") + "operator-customized group_ids (non-empty, no default admin group) must be preserved across boots") }) } diff --git a/internal/database/postgres/migrations/helpers_test.go b/internal/database/postgres/migrations/helpers_test.go index 8b7700607..d466cc9e6 100644 --- a/internal/database/postgres/migrations/helpers_test.go +++ b/internal/database/postgres/migrations/helpers_test.go @@ -28,7 +28,7 @@ func getMigrationsPath() string { // Mirrors the helper of the same name in migrate_security_test.go; the // duplication is forced by the package boundary (that file lives in // `package migrations`, while integration tests live in `package -// migrations_test`). Centralising this copy here keeps every integration +// migrations_test`). Centralizing this copy here keeps every integration // test that needs the helper pointed at one definition. func captureStdout(t *testing.T) func() string { t.Helper() diff --git a/internal/database/postgres/migrations/migrate.go b/internal/database/postgres/migrations/migrate.go index 674d222a3..015d3bedd 100644 --- a/internal/database/postgres/migrations/migrate.go +++ b/internal/database/postgres/migrations/migrate.go @@ -2,6 +2,7 @@ package migrations import ( "context" + "errors" "fmt" "log" "net/url" @@ -15,7 +16,7 @@ import ( "golang.org/x/crypto/bcrypt" ) -// bcryptCost matches the cost used in internal/auth/service_password.go +// bcryptCost matches the cost used in internal/auth/service_password.go. const bcryptCost = 12 // defaultAdminGroupID is the fixed UUID of the Administrators group @@ -29,7 +30,7 @@ const defaultAdminGroupID = "00000000-0000-5000-8000-000000000001" // RunMigrations runs database migrations using golang-migrate // adminEmail is optional - if provided, admin user will be created after migrations complete -// adminPassword is optional - if provided, admin is created with hashed password and active=true +// adminPassword is optional - if provided, admin is created with hashed password and active=true. func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath string, adminEmail string, adminPassword string) error { // Create the migrator and run the pre-Up recovery hooks (operator force, // then default-on dirty auto-heal). Kept in a helper so RunMigrations stays @@ -41,13 +42,13 @@ func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath strin defer m.Close() // Run migrations - if err := m.Up(); err != nil && err != migrate.ErrNoChange { + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { return fmt.Errorf("failed to run migrations: %w", err) } // Get current version version, dirty, err := m.Version() - if err != nil && err != migrate.ErrNilVersion { + if err != nil && !errors.Is(err, migrate.ErrNilVersion) { return fmt.Errorf("failed to get migration version: %w", err) } @@ -237,14 +238,14 @@ func ensureAdminUserWithPassword(ctx context.Context, pool *pgxpool.Pool, email // // Post-migration-000057: the `users_min_one_group` CHECK constraint // prevents group_ids from being NULL or zero-length, so this backfill -// is a no-op in normal operation. It remains as defence-in-depth for +// is a no-op in normal operation. It remains as defense-in-depth for // pre-057 schemas (rollback scenarios) and any future drift. The `role` // column was removed by migration 000057 (issue #945) and must not be // referenced here. // // The EXISTS guard on the groups table makes the backfill a no-op // when migration 000024 hasn't yet seeded the Administrators group - -// defence-in-depth, since in practice this function is invoked +// defense-in-depth, since in practice this function is invoked // after RunMigrations -> m.Up() completes. func assignAdminGroupAndWarn(ctx context.Context, pool *pgxpool.Pool, groupID string, adminEmail string) error { res, err := pool.Exec(ctx, ` @@ -372,7 +373,7 @@ func maybeAutoHealDirty(m *migrate.Migrate) error { } version, dirty, err := m.Version() - if err == migrate.ErrNilVersion { + if errors.Is(err, migrate.ErrNilVersion) { // No migrations recorded yet -> nothing to heal. return nil } @@ -412,7 +413,7 @@ func autoHealEnabled() bool { return b } -// RollbackMigrations rolls back N migrations +// RollbackMigrations rolls back N migrations. func RollbackMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath string, steps int) error { if steps <= 0 { return fmt.Errorf("rollback steps must be positive, got %d", steps) @@ -436,12 +437,12 @@ func RollbackMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath log.Printf("Rolling back %d migration(s)...", steps) // Rollback steps - if err := m.Steps(-steps); err != nil && err != migrate.ErrNoChange { + if err := m.Steps(-steps); err != nil && !errors.Is(err, migrate.ErrNoChange) { return fmt.Errorf("failed to rollback migrations: %w", err) } version, dirty, err := m.Version() - if err != nil && err != migrate.ErrNilVersion { + if err != nil && !errors.Is(err, migrate.ErrNilVersion) { return fmt.Errorf("failed to get migration version: %w", err) } @@ -471,7 +472,7 @@ func MigrateToVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath st } defer m.Close() - if err := m.Migrate(version); err != nil && err != migrate.ErrNoChange { + if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) { return fmt.Errorf("failed to migrate to version %d: %w", version, err) } @@ -490,7 +491,7 @@ func MigrateToVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath st return nil } -// GetMigrationVersion returns the current migration version +// GetMigrationVersion returns the current migration version. func GetMigrationVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath string) (uint, bool, error) { dsn := buildMigrateDSN(pool.Config(), "") @@ -504,7 +505,7 @@ func GetMigrationVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath defer m.Close() version, dirty, err := m.Version() - if err != nil && err != migrate.ErrNilVersion { + if err != nil && !errors.Is(err, migrate.ErrNilVersion) { return 0, false, fmt.Errorf("failed to get migration version: %w", err) } @@ -547,7 +548,7 @@ func buildMigrateDSN(config *pgxpool.Config, sslModeOverride string) string { ) } -// ValidateMigrationsPath checks if migrations directory exists +// ValidateMigrationsPath checks if migrations directory exists. func ValidateMigrationsPath(path string) error { info, err := os.Stat(path) if err != nil { diff --git a/internal/database/postgres/testhelpers/postgres.go b/internal/database/postgres/testhelpers/postgres.go index ed3b36c63..71bf95106 100644 --- a/internal/database/postgres/testhelpers/postgres.go +++ b/internal/database/postgres/testhelpers/postgres.go @@ -14,14 +14,14 @@ import ( "github.com/testcontainers/testcontainers-go/wait" ) -// PostgresContainer wraps a testcontainers PostgreSQL instance +// PostgresContainer wraps a testcontainers PostgreSQL instance. type PostgresContainer struct { Container testcontainers.Container Config *database.Config DB *database.Connection } -// SetupPostgresContainer creates and starts a PostgreSQL test container +// SetupPostgresContainer creates and starts a PostgreSQL test container. func SetupPostgresContainer(ctx context.Context, t *testing.T) (*PostgresContainer, error) { t.Helper() @@ -86,7 +86,7 @@ func SetupPostgresContainer(ctx context.Context, t *testing.T) (*PostgresContain }, nil } -// Cleanup terminates the test container and closes database connection +// Cleanup terminates the test container and closes database connection. func (c *PostgresContainer) Cleanup(ctx context.Context) error { if c.DB != nil { c.DB.Close() @@ -97,7 +97,7 @@ func (c *PostgresContainer) Cleanup(ctx context.Context) error { return nil } -// TruncateTables removes all data from tables (useful between tests) +// TruncateTables removes all data from tables (useful between tests). func (c *PostgresContainer) TruncateTables(ctx context.Context, tables ...string) error { for _, table := range tables { // Use pgx.Identifier to safely quote table names and prevent SQL injection @@ -110,7 +110,7 @@ func (c *PostgresContainer) TruncateTables(ctx context.Context, tables ...string return nil } -// ResetDatabase drops and recreates all tables (useful for clean state) +// ResetDatabase drops and recreates all tables (useful for clean state). func (c *PostgresContainer) ResetDatabase(ctx context.Context) error { // Drop all tables query := ` diff --git a/internal/database/security_test.go b/internal/database/security_test.go index 1ba37b4f5..67a3e36f3 100644 --- a/internal/database/security_test.go +++ b/internal/database/security_test.go @@ -124,7 +124,7 @@ func TestBuildPoolConfig_ParseConfigDoesNotExposePassword(t *testing.T) { // TestBuildPoolConfig_ParseError_NoPasswordLeak verifies that when the DSN // contains a structurally invalid piece (beyond what pgx can parse) any error -// returned does not expose the real password. This tests the defence-in-depth +// returned does not expose the real password. This tests the defense-in-depth // goal of issue #444: by passing "REDACTED" to ParseConfig, even an error from // pgx's URI parser only shows "REDACTED", not the real credential. func TestBuildPoolConfig_ParseError_NoPasswordLeak(t *testing.T) { diff --git a/internal/deploy/coverage_extra_test.go b/internal/deploy/coverage_extra_test.go index ff9843ba7..365eb5abb 100644 --- a/internal/deploy/coverage_extra_test.go +++ b/internal/deploy/coverage_extra_test.go @@ -120,7 +120,7 @@ func TestBuildAndUpload_SuccessWithFrontendDirEnv(t *testing.T) { assert.Contains(t, uploaded, "index.html") } -// TestFindFrontendDir_EnvVar_NotFound tests the env var path when package.json is absent +// TestFindFrontendDir_EnvVar_NotFound tests the env var path when package.json is absent. func TestFindFrontendDir_EnvVar_NotFound(t *testing.T) { tmpDir := t.TempDir() // Point env var to dir without package.json @@ -134,7 +134,7 @@ func TestFindFrontendDir_EnvVar_NotFound(t *testing.T) { _ = err } -// TestFindFrontendDir_EnvVar_Found tests the env var path when package.json exists +// TestFindFrontendDir_EnvVar_Found tests the env var path when package.json exists. func TestFindFrontendDir_EnvVar_Found(t *testing.T) { tmpDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{}`), 0644)) @@ -146,7 +146,7 @@ func TestFindFrontendDir_EnvVar_Found(t *testing.T) { assert.NotEmpty(t, dir) } -// TestGetConfigPath_HomeDirError tests GetConfigPath when HOME is unset +// TestGetConfigPath_HomeDirError tests GetConfigPath when HOME is unset. func TestGetConfigPath_HomeError(t *testing.T) { original := os.Getenv("HOME") os.Unsetenv("HOME") @@ -157,7 +157,7 @@ func TestGetConfigPath_HomeError(t *testing.T) { os.Setenv("HOME", original) } -// TestGetConfigDir_HomeError tests GetConfigDir when HOME is unset +// TestGetConfigDir_HomeError tests GetConfigDir when HOME is unset. func TestGetConfigDir_HomeError(t *testing.T) { original := os.Getenv("HOME") os.Unsetenv("HOME") @@ -166,7 +166,7 @@ func TestGetConfigDir_HomeError(t *testing.T) { os.Setenv("HOME", original) } -// TestLoadConfig_ParseError tests LoadConfig when the config file contains invalid YAML +// TestLoadConfig_ParseError tests LoadConfig when the config file contains invalid YAML. func TestLoadConfig_ParseError(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) @@ -182,7 +182,7 @@ func TestLoadConfig_ParseError(t *testing.T) { assert.Contains(t, err.Error(), "failed to parse config file") } -// TestEmptyBucket_DeleteWithErrors covers the per-object error handling branch +// TestEmptyBucket_DeleteWithErrors covers the per-object error handling branch. func TestFrontendService_EmptyBucket_DeleteWithErrors(t *testing.T) { errKey := "locked-file.html" errMsg := "access denied" diff --git a/internal/deploy/profiles.go b/internal/deploy/profiles.go index 400beb008..dddaf430d 100644 --- a/internal/deploy/profiles.go +++ b/internal/deploy/profiles.go @@ -11,7 +11,7 @@ import ( "gopkg.in/yaml.v3" ) -// ProfileConfig holds configuration for a single deployment profile +// ProfileConfig holds configuration for a single deployment profile. type ProfileConfig struct { Provider string `yaml:"provider"` // Cloud provider: aws, azure, gcp ComputePlatform string `yaml:"compute_platform"` // Compute platform: lambda/fargate, container-apps/aks, cloud-run/gke @@ -34,13 +34,13 @@ type ProfileConfig struct { AdminEmail string `yaml:"admin_email,omitempty"` } -// DeploymentConfig holds all deployment profiles +// DeploymentConfig holds all deployment profiles. type DeploymentConfig struct { ActiveProfile string `yaml:"active_profile"` Profiles map[string]ProfileConfig `yaml:"profiles"` } -// GetConfigPath returns the path to the deployment configuration file +// GetConfigPath returns the path to the deployment configuration file. func GetConfigPath() string { homeDir, err := os.UserHomeDir() if err != nil { @@ -50,7 +50,7 @@ func GetConfigPath() string { return filepath.Join(homeDir, ".cudly", "deployment.yaml") } -// GetConfigDir returns the directory containing the deployment configuration +// GetConfigDir returns the directory containing the deployment configuration. func GetConfigDir() string { homeDir, err := os.UserHomeDir() if err != nil { @@ -60,7 +60,7 @@ func GetConfigDir() string { return filepath.Join(homeDir, ".cudly") } -// LoadConfig loads the deployment configuration from disk +// LoadConfig loads the deployment configuration from disk. func LoadConfig() (*DeploymentConfig, error) { configPath := GetConfigPath() @@ -89,7 +89,7 @@ func LoadConfig() (*DeploymentConfig, error) { return &config, nil } -// SaveConfig saves the deployment configuration to disk +// SaveConfig saves the deployment configuration to disk. func SaveConfig(config *DeploymentConfig) error { configDir := GetConfigDir() configPath := GetConfigPath() @@ -111,7 +111,7 @@ func SaveConfig(config *DeploymentConfig) error { return nil } -// InitConfig creates a default configuration file +// InitConfig creates a default configuration file. func InitConfig() error { configPath := GetConfigPath() @@ -144,7 +144,7 @@ func InitConfig() error { return SaveConfig(config) } -// GetActiveProfile returns the active profile configuration +// GetActiveProfile returns the active profile configuration. func (c *DeploymentConfig) GetActiveProfile() (*ProfileConfig, error) { if c.ActiveProfile == "" { return nil, fmt.Errorf("no active profile set") @@ -158,7 +158,7 @@ func (c *DeploymentConfig) GetActiveProfile() (*ProfileConfig, error) { return &profile, nil } -// GetProfile returns a specific profile by name +// GetProfile returns a specific profile by name. func (c *DeploymentConfig) GetProfile(name string) (*ProfileConfig, error) { profile, ok := c.Profiles[name] if !ok { @@ -167,7 +167,7 @@ func (c *DeploymentConfig) GetProfile(name string) (*ProfileConfig, error) { return &profile, nil } -// SetActiveProfile sets the active profile +// SetActiveProfile sets the active profile. func (c *DeploymentConfig) SetActiveProfile(name string) error { if _, ok := c.Profiles[name]; !ok { return fmt.Errorf("profile %q not found", name) @@ -176,7 +176,7 @@ func (c *DeploymentConfig) SetActiveProfile(name string) error { return nil } -// AddProfile adds a new profile to the configuration +// AddProfile adds a new profile to the configuration. func (c *DeploymentConfig) AddProfile(name string, profile ProfileConfig) error { if name == "" { return fmt.Errorf("profile name cannot be empty") @@ -196,7 +196,7 @@ func (c *DeploymentConfig) AddProfile(name string, profile ProfileConfig) error return nil } -// UpdateProfile updates an existing profile +// UpdateProfile updates an existing profile. func (c *DeploymentConfig) UpdateProfile(name string, profile ProfileConfig) error { if _, exists := c.Profiles[name]; !exists { return fmt.Errorf("profile %q does not exist", name) @@ -206,7 +206,7 @@ func (c *DeploymentConfig) UpdateProfile(name string, profile ProfileConfig) err return nil } -// DeleteProfile removes a profile from the configuration +// DeleteProfile removes a profile from the configuration. func (c *DeploymentConfig) DeleteProfile(name string) error { if _, ok := c.Profiles[name]; !ok { return fmt.Errorf("profile %q not found", name) @@ -221,7 +221,7 @@ func (c *DeploymentConfig) DeleteProfile(name string) error { return nil } -// CopyProfile creates a new profile by copying an existing one +// CopyProfile creates a new profile by copying an existing one. func (c *DeploymentConfig) CopyProfile(from, to string) error { if to == "" { return fmt.Errorf("new profile name cannot be empty") @@ -241,7 +241,7 @@ func (c *DeploymentConfig) CopyProfile(from, to string) error { return nil } -// ListProfiles returns a sorted list of profile names +// ListProfiles returns a sorted list of profile names. func (c *DeploymentConfig) ListProfiles() []string { names := make([]string, 0, len(c.Profiles)) for name := range c.Profiles { @@ -251,13 +251,13 @@ func (c *DeploymentConfig) ListProfiles() []string { return names } -// HasProfile checks if a profile exists +// HasProfile checks if a profile exists. func (c *DeploymentConfig) HasProfile(name string) bool { _, ok := c.Profiles[name] return ok } -// ProfileCount returns the number of profiles +// ProfileCount returns the number of profiles. func (c *DeploymentConfig) ProfileCount() int { return len(c.Profiles) } diff --git a/internal/email/coverage_extra_test.go b/internal/email/coverage_extra_test.go index bf9314117..32da23fef 100644 --- a/internal/email/coverage_extra_test.go +++ b/internal/email/coverage_extra_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -// Tests for isSecretManagerReference (replaces the deleted containsColon helper -- 07-L2/L3) +// Tests for isSecretManagerReference (replaces the deleted containsColon helper -- 07-L2/L3). func TestIsSecretManagerReference(t *testing.T) { // AWS ARN assert.True(t, isSecretManagerReference("arn:aws:secretsmanager:us-east-1:123:secret:foo")) @@ -29,7 +29,7 @@ func TestIsSecretManagerReference(t *testing.T) { assert.False(t, isSecretManagerReference("user:password")) } -// Tests for warnIfPlaintext – exercising all branches +// Tests for warnIfPlaintext – exercising all branches. func TestWarnIfPlaintext(t *testing.T) { // Empty value: should be a no-op (no panic) assert.NotPanics(t, func() { warnIfPlaintext("VAR", "") }) @@ -50,7 +50,7 @@ func TestWarnIfPlaintext(t *testing.T) { assert.NotPanics(t, func() { warnIfPlaintext("VAR", "/my/secret/path/that/is/long") }) } -// Tests for renderTemplate error path +// Tests for renderTemplate error path. func TestRenderTemplate_ParseError(t *testing.T) { // A template with an unclosed action will fail to parse _, err := renderTemplate("bad", "{{.Foo", nil) @@ -64,7 +64,7 @@ func TestRenderTemplate_ExecuteError(t *testing.T) { require.Error(t, err) } -// Tests for RenderRIExchangePendingApprovalEmail +// Tests for RenderRIExchangePendingApprovalEmail. func TestRenderRIExchangePendingApprovalEmail(t *testing.T) { data := RIExchangeNotificationData{ DashboardURL: "https://dashboard.example.com", @@ -121,7 +121,7 @@ func TestRenderRIExchangePendingApprovalEmail_NoSkipped(t *testing.T) { assert.Contains(t, result, "r5.xlarge") } -// Tests for RenderRIExchangeCompletedEmail +// Tests for RenderRIExchangeCompletedEmail. func TestRenderRIExchangeCompletedEmail_AutoMode(t *testing.T) { data := RIExchangeNotificationData{ DashboardURL: "https://dashboard.example.com", @@ -200,7 +200,7 @@ func TestRenderRIExchangeCompletedEmail_WithError(t *testing.T) { assert.NotContains(t, result, "ri-fail") } -// Tests for RenderPurchaseApprovalRequestEmail +// Tests for RenderPurchaseApprovalRequestEmail. func TestRenderPurchaseApprovalRequestEmail(t *testing.T) { data := NotificationData{ DashboardURL: "https://dashboard.example.com", @@ -252,7 +252,7 @@ func TestRenderPurchaseApprovalRequestEmail_AuthorizedApprovers(t *testing.T) { body, err := RenderPurchaseApprovalRequestEmail(data) require.NoError(t, err) - assert.Contains(t, body, "Authorised approver(s)") + assert.Contains(t, body, "Authorized approver(s)") assert.Contains(t, body, "contact-a@example.com") assert.Contains(t, body, "contact-b@example.com") assert.Contains(t, body, "Only the inbox(es) listed above can approve") @@ -271,12 +271,12 @@ func TestRenderPurchaseApprovalRequestEmail_NoAuthorizedApprovers(t *testing.T) body, err := RenderPurchaseApprovalRequestEmail(data) require.NoError(t, err) - assert.NotContains(t, body, "Authorised approver") + assert.NotContains(t, body, "Authorized approver") assert.NotContains(t, body, "Only the inbox(es)") } // TestRenderRegistrationReceivedEmail_AdminApprovers pins the new -// "authorised reviewer(s)" block on the registration notification +// "authorized reviewer(s)" block on the registration notification // template: when AdminApprovers is populated the body lists each admin // verbatim and calls out that CC'd recipients can't approve. func TestRenderRegistrationReceivedEmail_AdminApprovers(t *testing.T) { @@ -291,7 +291,7 @@ func TestRenderRegistrationReceivedEmail_AdminApprovers(t *testing.T) { body, err := RenderRegistrationReceivedEmail(data) require.NoError(t, err) - assert.Contains(t, body, "Authorised reviewer(s)") + assert.Contains(t, body, "Authorized reviewer(s)") assert.Contains(t, body, "admin-a@example.com") assert.Contains(t, body, "admin-b@example.com") assert.Contains(t, body, "Only CUDly administrators listed above") @@ -317,7 +317,7 @@ func TestRenderRegistrationReceivedEmail_NoAdminApprovers(t *testing.T) { body, err := RenderRegistrationReceivedEmail(data) require.NoError(t, err) - assert.NotContains(t, body, "Authorised reviewer") + assert.NotContains(t, body, "Authorized reviewer") assert.NotContains(t, body, "Only CUDly administrators") } @@ -387,7 +387,7 @@ func TestSMTPSender_SendPurchaseApprovalRequest_NoRecipient(t *testing.T) { require.ErrorIs(t, err, ErrNoRecipient) } -// Tests for SMTPSender using notifyEmail (not fromEmail) +// Tests for SMTPSender using notifyEmail (not fromEmail). func TestSMTPSender_SendRIExchangePendingApproval_WithNotifyEmail(t *testing.T) { sender := &SMTPSender{ host: "smtp.example.com", @@ -553,7 +553,7 @@ func TestSender_SendPurchaseApprovalRequest_MalformedFromEmail(t *testing.T) { func TestSender_SendPurchaseApprovalRequest_SendsViaSES(t *testing.T) { // Happy path: both FromEmail and RecipientEmail are set, the sender // routes through SES SendEmail (not SNS Publish). This is the - // behavioural contract — approval tokens must target the specific user, + // behavioral contract — approval tokens must target the specific user, // not broadcast to every subscriber of an SNS alerts topic. mockSNS := &mockSNSPublisher{} // must NOT be called mockSES := &mockSESEmailSender{} @@ -579,7 +579,7 @@ func TestSender_SendPurchaseApprovalRequest_SendsViaSES(t *testing.T) { require.Equal(t, "noreply@cudly.example.com", mockSES.lastFrom, "approval email must use configured FROM_EMAIL") } -// Tests for redactEmail edge cases +// Tests for redactEmail edge cases. func TestRedactEmail(t *testing.T) { tests := []struct { input string @@ -601,7 +601,7 @@ func TestRedactEmail(t *testing.T) { } } -// Tests for sanitizeHeader — it strips CR and LF entirely (does not replace with space) +// Tests for sanitizeHeader — it strips CR and LF entirely (does not replace with space). func TestSanitizeHeader(t *testing.T) { assert.Equal(t, "helloworld", sanitizeHeader("hello\r\nworld")) assert.Equal(t, "helloworld", sanitizeHeader("hello\nworld")) @@ -611,7 +611,7 @@ func TestSanitizeHeader(t *testing.T) { assert.Equal(t, "Subject Line", sanitizeHeader("Subject\r Line")) } -// Test smtpAuthenticate nil auth path (covered by unit test without real SMTP) +// Test smtpAuthenticate nil auth path (covered by unit test without real SMTP). func TestSmtpAuthenticate_NilAuth(t *testing.T) { // nil auth → immediate return nil err := smtpAuthenticate(nil, nil) @@ -624,7 +624,7 @@ func TestSmtpAuthenticate_NilAuth(t *testing.T) { // we test the error paths that don't require a live SMTP server via // the SendToEmail no-from-email short-circuit above. -// Tests for SMTPSender.notifyEmail defaults to fromEmail when not set +// Tests for SMTPSender.notifyEmail defaults to fromEmail when not set. func TestSMTPSender_NotifyEmailDefaultsToFromEmail(t *testing.T) { cfg := SMTPConfig{ Host: "smtp.example.com", diff --git a/internal/email/coverage_test.go b/internal/email/coverage_test.go index e143d435c..13f2b09d2 100644 --- a/internal/email/coverage_test.go +++ b/internal/email/coverage_test.go @@ -13,7 +13,7 @@ import ( // Additional coverage tests for internal/email package // These tests target untested code paths and edge cases to increase coverage above 80% -// TestSMTPSender_SendToEmail_WithFromName tests SendToEmail with a from name set +// TestSMTPSender_SendToEmail_WithFromName tests SendToEmail with a from name set. func TestSMTPSender_SendToEmail_WithFromName(t *testing.T) { sender := &SMTPSender{ host: "smtp.example.com", @@ -30,7 +30,7 @@ func TestSMTPSender_SendToEmail_WithFromName(t *testing.T) { require.NoError(t, err) } -// TestSMTPSender_SendToEmail_BuildsMessageCorrectly tests that the message is built correctly +// TestSMTPSender_SendToEmail_BuildsMessageCorrectly tests that the message is built correctly. func TestSMTPSender_SendToEmail_WithFromNameConfigured(t *testing.T) { // This tests the message building path when fromName is set // Since we can't actually send email without a real SMTP server, @@ -48,7 +48,7 @@ func TestSMTPSender_SendToEmail_WithFromNameConfigured(t *testing.T) { require.NoError(t, err) } -// TestSMTPSender_SendPasswordResetEmail_WithFromEmail tests the full path with from email +// TestSMTPSender_SendPasswordResetEmail_WithFromEmail tests the full path with from email. func TestSMTPSender_SendPasswordResetEmail_RenderingSuccess(t *testing.T) { // Tests the rendering success path - error only occurs when trying to send // Since fromEmail is empty, this tests the rendering path and early return @@ -65,7 +65,7 @@ func TestSMTPSender_SendPasswordResetEmail_RenderingSuccess(t *testing.T) { require.NoError(t, err) } -// TestSMTPSender_SendWelcomeEmail_RenderingSuccess tests rendering success path +// TestSMTPSender_SendWelcomeEmail_RenderingSuccess tests rendering success path. func TestSMTPSender_SendWelcomeEmail_RenderingSuccess(t *testing.T) { sender := &SMTPSender{ host: "smtp.example.com", @@ -80,7 +80,7 @@ func TestSMTPSender_SendWelcomeEmail_RenderingSuccess(t *testing.T) { require.NoError(t, err) } -// TestSMTPSender_AllNotificationMethods_NoFromEmail tests all notification methods with empty fromEmail +// TestSMTPSender_AllNotificationMethods_NoFromEmail tests all notification methods with empty fromEmail. func TestSMTPSender_AllNotificationMethods_NoFromEmail(t *testing.T) { sender := &SMTPSender{ host: "smtp.example.com", @@ -125,7 +125,7 @@ func TestSMTPSender_AllNotificationMethods_NoFromEmail(t *testing.T) { require.NoError(t, err) } -// TestSMTPSender_ConfigVariations tests various SMTP configuration scenarios +// TestSMTPSender_ConfigVariations tests various SMTP configuration scenarios. func TestSMTPSender_ConfigVariations(t *testing.T) { tests := []struct { name string @@ -195,7 +195,7 @@ func TestSMTPSender_ConfigVariations(t *testing.T) { } } -// TestRenderFunctions_EdgeCases tests edge cases in template rendering +// TestRenderFunctions_EdgeCases tests edge cases in template rendering. func TestRenderFunctions_EdgeCases(t *testing.T) { t.Run("RenderPasswordResetEmail with empty fields", func(t *testing.T) { result, err := RenderPasswordResetEmail("", "") @@ -258,7 +258,7 @@ func TestRenderFunctions_EdgeCases(t *testing.T) { }) } -// TestRecommendationSummary_AllFields tests recommendation summary with all fields populated +// TestRecommendationSummary_AllFields tests recommendation summary with all fields populated. func TestRecommendationSummary_AllFields(t *testing.T) { summary := RecommendationSummary{ Service: "rds", @@ -312,13 +312,13 @@ func TestRecommendationSummary_AllFields(t *testing.T) { }) } -// TestSender_Implements_SenderInterface verifies interface implementation +// TestSender_Implements_SenderInterface verifies interface implementation. func TestSender_Implements_SenderInterface(t *testing.T) { var sender SenderInterface = &Sender{} assert.NotNil(t, sender) } -// TestSMTPSender_FieldAccess tests that all SMTPSender fields are accessible +// TestSMTPSender_FieldAccess tests that all SMTPSender fields are accessible. func TestSMTPSender_FieldAccess(t *testing.T) { cfg := SMTPConfig{ Host: "smtp.test.com", @@ -342,7 +342,7 @@ func TestSMTPSender_FieldAccess(t *testing.T) { assert.True(t, sender.useTLS) } -// TestNotificationData_AllFields tests NotificationData with all fields +// TestNotificationData_AllFields tests NotificationData with all fields. func TestNotificationData_AllFields(t *testing.T) { recommendations := []RecommendationSummary{ { @@ -384,7 +384,7 @@ func TestNotificationData_AllFields(t *testing.T) { assert.Equal(t, "Annual Savings Plan", data.PlanName) } -// TestPasswordResetData_Fields tests PasswordResetData structure +// TestPasswordResetData_Fields tests PasswordResetData structure. func TestPasswordResetData_AllFields(t *testing.T) { data := PasswordResetData{ Email: "user@example.com", @@ -395,7 +395,7 @@ func TestPasswordResetData_AllFields(t *testing.T) { assert.Equal(t, "https://example.com/reset?token=abc123", data.ResetURL) } -// TestWelcomeUserData_AllFields tests WelcomeUserData structure +// TestWelcomeUserData_AllFields tests WelcomeUserData structure. func TestWelcomeUserData_AllFields(t *testing.T) { data := WelcomeUserData{ Email: "newuser@example.com", @@ -408,7 +408,7 @@ func TestWelcomeUserData_AllFields(t *testing.T) { assert.Equal(t, "operator", data.Role) } -// TestWelcomeUserData_ViewerRole tests WelcomeUserData with viewer role +// TestWelcomeUserData_ViewerRole tests WelcomeUserData with viewer role. func TestWelcomeUserData_ViewerRole(t *testing.T) { data := WelcomeUserData{ Email: "test@example.com", @@ -421,7 +421,7 @@ func TestWelcomeUserData_ViewerRole(t *testing.T) { assert.Equal(t, "viewer", data.Role) } -// TestProviderTypes tests provider type constants +// TestProviderTypes tests provider type constants. func TestProviderTypes_Values(t *testing.T) { assert.Equal(t, ProviderType("aws"), ProviderAWS) assert.Equal(t, ProviderType("gcp"), ProviderGCP) @@ -433,7 +433,7 @@ func TestProviderTypes_Values(t *testing.T) { assert.Equal(t, "azure", string(ProviderAzure)) } -// TestFactoryConfig_AllFields tests all FactoryConfig fields +// TestFactoryConfig_AllFields tests all FactoryConfig fields. func TestFactoryConfig_AllFields(t *testing.T) { cfg := FactoryConfig{ FromEmail: "noreply@example.com", @@ -454,7 +454,7 @@ func TestFactoryConfig_AllFields(t *testing.T) { assert.Equal(t, "azure_pass", cfg.AzureSMTPPassword) } -// TestSMTPSender_TLSBehavior tests TLS configuration behavior +// TestSMTPSender_TLSBehavior tests TLS configuration behavior. func TestSMTPSender_TLSBehavior(t *testing.T) { t.Run("port 587 enables TLS by default", func(t *testing.T) { cfg := SMTPConfig{ @@ -492,7 +492,7 @@ func TestSMTPSender_TLSBehavior(t *testing.T) { }) } -// TestSenderConfig_DefaultValues tests SenderConfig with various default scenarios +// TestSenderConfig_DefaultValues tests SenderConfig with various default scenarios. func TestSenderConfig_DefaultValues(t *testing.T) { cfg := SenderConfig{} @@ -501,7 +501,7 @@ func TestSenderConfig_DefaultValues(t *testing.T) { assert.Empty(t, cfg.EmailAddress) } -// TestTemplateContent_HasRequiredSections tests that templates contain required sections +// TestTemplateContent_HasRequiredSections tests that templates contain required sections. func TestTemplateContent_HasRequiredSections(t *testing.T) { t.Run("newRecommendationsTemplate has key sections", func(t *testing.T) { assert.Contains(t, newRecommendationsTemplate, "CUDly") @@ -552,7 +552,7 @@ func TestTemplateContent_HasRequiredSections(t *testing.T) { }) } -// TestRenderFunctions_MultipleRecommendations tests templates with multiple recommendations +// TestRenderFunctions_MultipleRecommendations tests templates with multiple recommendations. func TestRenderFunctions_MultipleRecommendations(t *testing.T) { data := NotificationData{ DashboardURL: "https://example.com", @@ -585,7 +585,7 @@ func TestRenderFunctions_MultipleRecommendations(t *testing.T) { assert.Contains(t, result, "r5.large.search") } -// TestSMTPSender_MethodsWithNoNetwork tests SMTP methods that should work without network +// TestSMTPSender_MethodsWithNoNetwork tests SMTP methods that should work without network. func TestSMTPSender_MethodsWithNoNetwork(t *testing.T) { sender := &SMTPSender{ host: "nonexistent.example.com", @@ -612,7 +612,7 @@ func TestSMTPSender_MethodsWithNoNetwork(t *testing.T) { assert.NoError(t, sender.SendPurchaseFailedNotification(ctx, data)) } -// TestSMTPSender_SendToEmail_ConnectionFails tests that SendToEmail returns error when connection fails +// TestSMTPSender_SendToEmail_ConnectionFails tests that SendToEmail returns error when connection fails. func TestSMTPSender_SendToEmail_ConnectionFails(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -655,7 +655,7 @@ func TestSMTPSender_SendToEmail_ConnectionFails_NoTLS(t *testing.T) { assert.Contains(t, err.Error(), "SMTP auth over non-TLS connection is refused") } -// TestSMTPSender_SendToEmail_NoAuth tests without authentication +// TestSMTPSender_SendToEmail_NoAuth tests without authentication. func TestSMTPSender_SendToEmail_NoAuth_ConnectionFails(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -675,7 +675,7 @@ func TestSMTPSender_SendToEmail_NoAuth_ConnectionFails(t *testing.T) { assert.Contains(t, err.Error(), "failed to send email via SMTP") } -// TestSMTPSender_AllMethods_ConnectionFails tests all SMTP methods fail with connection error +// TestSMTPSender_AllMethods_ConnectionFails tests all SMTP methods fail with connection error. func TestSMTPSender_AllMethods_ConnectionFails(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -739,7 +739,7 @@ func TestSMTPSender_AllMethods_ConnectionFails(t *testing.T) { }) } -// TestSMTPSender_SendToEmail_WithFromName tests that from name is correctly included +// TestSMTPSender_SendToEmail_WithFromName tests that from name is correctly included. func TestSMTPSender_SendToEmail_MessageBuilding(t *testing.T) { // This test exercises the message building code path // Even though it will fail on send, the message building code is executed @@ -760,7 +760,7 @@ func TestSMTPSender_SendToEmail_MessageBuilding(t *testing.T) { require.Error(t, err) } -// TestSMTPSender_SendToEmail_WithoutFromName tests message building without from name +// TestSMTPSender_SendToEmail_WithoutFromName tests message building without from name. func TestSMTPSender_SendToEmail_MessageBuildingNoName(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -779,7 +779,7 @@ func TestSMTPSender_SendToEmail_MessageBuildingNoName(t *testing.T) { require.Error(t, err) } -// TestSMTPSender_SendToEmail_WithAuth tests the auth path +// TestSMTPSender_SendToEmail_WithAuth tests the auth path. func TestSMTPSender_SendToEmail_WithAuthCredentials(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -798,7 +798,7 @@ func TestSMTPSender_SendToEmail_WithAuthCredentials(t *testing.T) { assert.Contains(t, err.Error(), "failed to send email via SMTP") } -// TestSMTPSender_SendToEmail_NoAuth_NoTLS tests without auth and without TLS +// TestSMTPSender_SendToEmail_NoAuth_NoTLS tests without auth and without TLS. func TestSMTPSender_SendToEmail_NoAuth_NoTLS(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -817,7 +817,7 @@ func TestSMTPSender_SendToEmail_NoAuth_NoTLS(t *testing.T) { assert.Contains(t, err.Error(), "failed to send email via SMTP") } -// TestSMTPSender_SendToEmail_AuthWithOnlyUsername tests with only username (no password) +// TestSMTPSender_SendToEmail_AuthWithOnlyUsername tests with only username (no password). func TestSMTPSender_SendToEmail_AuthWithOnlyUsername(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -835,7 +835,7 @@ func TestSMTPSender_SendToEmail_AuthWithOnlyUsername(t *testing.T) { require.Error(t, err) } -// TestSMTPSender_SendToEmail_AuthWithOnlyPassword tests with only password (no username) +// TestSMTPSender_SendToEmail_AuthWithOnlyPassword tests with only password (no username). func TestSMTPSender_SendToEmail_AuthWithOnlyPassword(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -853,7 +853,7 @@ func TestSMTPSender_SendToEmail_AuthWithOnlyPassword(t *testing.T) { require.Error(t, err) } -// TestSMTPSender_SendToEmail_VariousHosts tests with various host configurations +// TestSMTPSender_SendToEmail_VariousHosts tests with various host configurations. func TestSMTPSender_SendToEmail_VariousHosts(t *testing.T) { // Only use localhost to avoid network timeouts hosts := []struct { @@ -886,7 +886,7 @@ func TestSMTPSender_SendToEmail_VariousHosts(t *testing.T) { } } -// TestSMTPSender_SendMethods_RenderingPaths tests that all send methods properly render templates +// TestSMTPSender_SendMethods_RenderingPaths tests that all send methods properly render templates. func TestSMTPSender_SendMethods_RenderingPaths(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -958,7 +958,7 @@ func TestSMTPSender_SendMethods_RenderingPaths(t *testing.T) { }) } -// TestSMTPSender_SendToEmail_LongSubjectAndBody tests with long content +// TestSMTPSender_SendToEmail_LongSubjectAndBody tests with long content. func TestSMTPSender_SendToEmail_LongSubjectAndBody(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -983,7 +983,7 @@ func TestSMTPSender_SendToEmail_LongSubjectAndBody(t *testing.T) { require.Error(t, err) } -// TestSMTPSender_SendToEmail_SpecialCharacters tests with special characters +// TestSMTPSender_SendToEmail_SpecialCharacters tests with special characters. func TestSMTPSender_SendToEmail_SpecialCharacters(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -1001,7 +1001,7 @@ func TestSMTPSender_SendToEmail_SpecialCharacters(t *testing.T) { require.Error(t, err) } -// TestSMTPSender_Port25NoTLS tests that port 25 without TLS works (fails on connect, but exercises path) +// TestSMTPSender_Port25NoTLS tests that port 25 without TLS works (fails on connect, but exercises path). func TestSMTPSender_Port25NoTLS(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -1020,7 +1020,7 @@ func TestSMTPSender_Port25NoTLS(t *testing.T) { require.Error(t, err) } -// TestSender_VerificationPathWithEmailIdentityError tests the isEmailVerified error path in SendToEmail +// TestSender_VerificationPathWithEmailIdentityError tests the isEmailVerified error path in SendToEmail. func TestSender_SendToEmail_GetEmailIdentityError(t *testing.T) { mockSES := new(MockSESClient) // Return sandbox mode @@ -1045,7 +1045,7 @@ func TestSender_SendToEmail_GetEmailIdentityError(t *testing.T) { assert.Contains(t, err.Error(), "not verified in SES sandbox mode") } -// TestSMTPSenderInterface_Compliance tests that SMTPSender fully implements SenderInterface +// TestSMTPSenderInterface_Compliance tests that SMTPSender fully implements SenderInterface. func TestSMTPSenderInterface_Compliance(t *testing.T) { var _ SenderInterface = (*SMTPSender)(nil) @@ -1066,7 +1066,7 @@ func TestSMTPSenderInterface_Compliance(t *testing.T) { _ = sender.SendWelcomeEmail } -// TestSMTPSender_SendToEmail_MultipleRecipientTypes tests various recipient formats +// TestSMTPSender_SendToEmail_MultipleRecipientTypes tests various recipient formats. func TestSMTPSender_SendToEmail_MultipleRecipientTypes(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -1094,7 +1094,7 @@ func TestSMTPSender_SendToEmail_MultipleRecipientTypes(t *testing.T) { } } -// TestSMTPSender_SendToEmail_EmptySubjectAndBody tests edge case with empty content +// TestSMTPSender_SendToEmail_EmptySubjectAndBody tests edge case with empty content. func TestSMTPSender_SendToEmail_EmptySubjectAndBody(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -1111,7 +1111,7 @@ func TestSMTPSender_SendToEmail_EmptySubjectAndBody(t *testing.T) { require.Error(t, err) // Will fail to connect but message was built } -// TestRenderAllTemplates exercises all template render functions thoroughly +// TestRenderAllTemplates exercises all template render functions thoroughly. func TestRenderAllTemplates_FullCoverage(t *testing.T) { // Test RenderPasswordResetEmail with various inputs t.Run("PasswordReset_simple", func(t *testing.T) { @@ -1214,7 +1214,7 @@ func TestRenderAllTemplates_FullCoverage(t *testing.T) { }) } -// TestSMTPSender_AllNotificationMethods_WithRealData tests all methods with realistic data +// TestSMTPSender_AllNotificationMethods_WithRealData tests all methods with realistic data. func TestSMTPSender_AllNotificationMethods_WithRealData(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -1287,7 +1287,7 @@ func TestSMTPSender_AllNotificationMethods_WithRealData(t *testing.T) { }) } -// TestSender_SendMethods_ErrorPaths tests error paths in Sender template methods +// TestSender_SendMethods_ErrorPaths tests error paths in Sender template methods. func TestSender_SendMethods_ErrorPaths(t *testing.T) { mockSNS := new(MockSNSClient) mockSNS.On("Publish", mock.Anything, mock.Anything).Return(nil, assert.AnError) @@ -1350,7 +1350,7 @@ func TestSender_SendMethods_ErrorPaths(t *testing.T) { }) } -// TestSender_SendToEmail_EmailVerificationCheck tests email verification check path +// TestSender_SendToEmail_EmailVerificationCheck tests email verification check path. func TestSender_SendToEmail_EmailVerificationCheck(t *testing.T) { mockSES := new(MockSESClient) @@ -1373,7 +1373,7 @@ func TestSender_SendToEmail_EmailVerificationCheck(t *testing.T) { assert.Contains(t, err.Error(), "not verified in SES sandbox mode") } -// TestSMTPSender_SendToEmail_AllBranches tests various branch paths in SendToEmail +// TestSMTPSender_SendToEmail_AllBranches tests various branch paths in SendToEmail. func TestSMTPSender_SendToEmail_AllBranches(t *testing.T) { // Test with TLS and auth t.Run("with_tls_and_auth", func(t *testing.T) { @@ -1440,7 +1440,7 @@ func TestSMTPSender_SendToEmail_AllBranches(t *testing.T) { }) } -// TestAllSMTPNotificationMethods_TemplatePaths tests template rendering paths +// TestAllSMTPNotificationMethods_TemplatePaths tests template rendering paths. func TestAllSMTPNotificationMethods_TemplatePaths(t *testing.T) { sender := &SMTPSender{ host: "localhost", @@ -1519,7 +1519,7 @@ func TestAllSMTPNotificationMethods_TemplatePaths(t *testing.T) { }) } -// TestSMTPSender_SendToEmail_EmptyBody tests edge case with empty body +// TestSMTPSender_SendToEmail_EmptyBody tests edge case with empty body. func TestSMTPSender_SendToEmail_EmptyContent(t *testing.T) { sender := &SMTPSender{ host: "localhost", diff --git a/internal/email/factory.go b/internal/email/factory.go index 642c31d90..0e09520d3 100644 --- a/internal/email/factory.go +++ b/internal/email/factory.go @@ -12,7 +12,7 @@ import ( "github.com/LeanerCloud/CUDly/pkg/logging" ) -// ProviderType represents the cloud provider for email services +// ProviderType represents the cloud provider for email services. type ProviderType string const ( @@ -21,7 +21,7 @@ const ( ProviderAzure ProviderType = "azure" ) -// FactoryConfig holds configuration for creating email senders +// FactoryConfig holds configuration for creating email senders. type FactoryConfig struct { // Common configuration FromEmail string @@ -88,7 +88,7 @@ func NewSenderFromEnvironment(ctx context.Context) (SenderInterface, error) { } // isSecretManagerReference reports whether value looks like a secret manager -// reference rather than a plaintext credential (07-L3). Recognised patterns: +// reference rather than a plaintext credential (07-L3). Recognized patterns: // - AWS ARN: starts with "arn:" // - GCP resource: starts with "projects/" // - Azure Key Vault secret URL: contains ".vault.azure.net/" @@ -116,7 +116,7 @@ func warnIfPlaintext(envVar, value string) { } } -// newGCPSenderFromEnv creates a SendGrid-based email sender from environment variables +// newGCPSenderFromEnv creates a SendGrid-based email sender from environment variables. func newGCPSenderFromEnv(ctx context.Context) (SenderInterface, error) { apiKey := os.Getenv("SENDGRID_API_KEY") warnIfPlaintext("SENDGRID_API_KEY", apiKey) @@ -182,7 +182,7 @@ func resolveAzureSMTPCredentials(ctx context.Context) (username, password string return username, password, nil } -// newAzureSenderFromEnv creates an Azure Communication Services email sender from environment variables +// newAzureSenderFromEnv creates an Azure Communication Services email sender from environment variables. func newAzureSenderFromEnv(ctx context.Context) (SenderInterface, error) { username, password, err := resolveAzureSMTPCredentials(ctx) if err != nil { @@ -204,7 +204,7 @@ func newAzureSenderFromEnv(ctx context.Context) (SenderInterface, error) { }) } -// NewSenderWithConfig creates an email sender with explicit configuration +// NewSenderWithConfig creates an email sender with explicit configuration. func NewSenderWithConfig(ctx context.Context, cfg FactoryConfig) (SenderInterface, error) { switch cfg.Provider { case ProviderAWS: diff --git a/internal/email/factory_test.go b/internal/email/factory_test.go index 569d115fd..c2689ed99 100644 --- a/internal/email/factory_test.go +++ b/internal/email/factory_test.go @@ -298,7 +298,7 @@ func TestNewSenderFromEnvironment_EmailEnabled(t *testing.T) { }) } -// Test NewSenderWithConfig +// Test NewSenderWithConfig. func TestNewSenderWithConfig_AWS(t *testing.T) { cfg := FactoryConfig{ Provider: ProviderAWS, diff --git a/internal/email/interfaces.go b/internal/email/interfaces.go index f985ba6ac..72c242df3 100644 --- a/internal/email/interfaces.go +++ b/internal/email/interfaces.go @@ -7,7 +7,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/sns" ) -// SenderInterface defines the methods required for sending emails +// SenderInterface defines the methods required for sending emails. type SenderInterface interface { SendNotification(ctx context.Context, subject, message string) error SendToEmail(ctx context.Context, toEmail, subject, body string) error @@ -31,15 +31,15 @@ type SenderInterface interface { SendRegistrationDecisionNotification(ctx context.Context, toEmail string, data RegistrationDecisionData) error } -// Verify that Sender implements SenderInterface +// Verify that Sender implements SenderInterface. var _ SenderInterface = (*Sender)(nil) -// SNSPublisher defines the interface for SNS publish operations +// SNSPublisher defines the interface for SNS publish operations. type SNSPublisher interface { Publish(ctx context.Context, params *sns.PublishInput, optFns ...func(*sns.Options)) (*sns.PublishOutput, error) } -// SESEmailSender defines the interface for SES send email operations +// SESEmailSender defines the interface for SES send email operations. type SESEmailSender interface { SendEmail(ctx context.Context, params *sesv2.SendEmailInput, optFns ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) GetAccount(ctx context.Context, params *sesv2.GetAccountInput, optFns ...func(*sesv2.Options)) (*sesv2.GetAccountOutput, error) @@ -47,6 +47,6 @@ type SESEmailSender interface { CreateEmailIdentity(ctx context.Context, params *sesv2.CreateEmailIdentityInput, optFns ...func(*sesv2.Options)) (*sesv2.CreateEmailIdentityOutput, error) } -// Ensure concrete types implement interfaces +// Ensure concrete types implement interfaces. var _ SNSPublisher = (*sns.Client)(nil) var _ SESEmailSender = (*sesv2.Client)(nil) diff --git a/internal/email/sender.go b/internal/email/sender.go index 553c8603d..0dcfa2d82 100644 --- a/internal/email/sender.go +++ b/internal/email/sender.go @@ -35,14 +35,14 @@ var ( ErrTokenInBroadcast = errors.New("email: message body contains an approval token; use targeted SES send, not SNS broadcast") ) -// SenderConfig holds configuration for the email sender +// SenderConfig holds configuration for the email sender. type SenderConfig struct { TopicARN string FromEmail string EmailAddress string // Legacy: for SNS notifications } -// Sender handles sending email notifications +// Sender handles sending email notifications. type Sender struct { snsClient SNSPublisher sesClient SESEmailSender @@ -51,7 +51,7 @@ type Sender struct { emailAddress string } -// NewSender creates a new email sender with default context +// NewSender creates a new email sender with default context. func NewSender(cfg SenderConfig) (*Sender, error) { return NewSenderWithContext(context.Background(), cfg) } @@ -79,7 +79,7 @@ func isValidFromEmail(addr string) bool { return true } -// NewSenderWithContext creates a new email sender with the provided context +// NewSenderWithContext creates a new email sender with the provided context. func NewSenderWithContext(ctx context.Context, cfg SenderConfig) (*Sender, error) { awsCfg, err := awsconfig.LoadDefaultConfig(ctx) if err != nil { @@ -95,7 +95,7 @@ func NewSenderWithContext(ctx context.Context, cfg SenderConfig) (*Sender, error }, nil } -// NewSenderWithClients creates a new email sender with custom clients (for testing) +// NewSenderWithClients creates a new email sender with custom clients (for testing). func NewSenderWithClients(snsClient SNSPublisher, sesClient SESEmailSender, cfg SenderConfig) *Sender { return &Sender{ snsClient: snsClient, @@ -154,7 +154,7 @@ func (s *Sender) SendNotification(ctx context.Context, subject, message string) return nil } -// isInSandbox checks if SES is in sandbox mode +// isInSandbox checks if SES is in sandbox mode. func (s *Sender) isInSandbox(ctx context.Context) (bool, error) { if s.sesClient == nil { return false, fmt.Errorf("SES client not initialized") @@ -169,7 +169,7 @@ func (s *Sender) isInSandbox(ctx context.Context) (bool, error) { return !output.ProductionAccessEnabled, nil } -// isEmailVerified checks if an email identity is verified in SES +// isEmailVerified checks if an email identity is verified in SES. func (s *Sender) isEmailVerified(ctx context.Context, email string) (bool, error) { if s.sesClient == nil { return false, fmt.Errorf("SES client not initialized") @@ -186,7 +186,7 @@ func (s *Sender) isEmailVerified(ctx context.Context, email string) (bool, error return output.VerifiedForSendingStatus, nil } -// createVerificationRequest initiates email verification for an email address +// createVerificationRequest initiates email verification for an email address. func (s *Sender) createVerificationRequest(ctx context.Context, email string) error { if s.sesClient == nil { return fmt.Errorf("SES client not initialized") @@ -204,7 +204,7 @@ func (s *Sender) createVerificationRequest(ctx context.Context, email string) er } // SendToEmail sends an email directly to a specific email address via SES -// If SES is in sandbox mode, it will automatically verify the recipient email if needed +// If SES is in sandbox mode, it will automatically verify the recipient email if needed. func (s *Sender) SendToEmail(ctx context.Context, toEmail, subject, body string) error { return s.SendToEmailWithCC(ctx, toEmail, nil, subject, body) } @@ -249,7 +249,7 @@ func (s *Sender) SendToEmailWithCCMultipart(ctx context.Context, toEmail string, } // SendToEmailWithCC sends an email with a primary To recipient plus optional -// Cc recipients. The To recipient is treated as the authorised actor for the +// Cc recipients. The To recipient is treated as the authorized actor for the // message (verified in sandbox mode) and Cc recipients are informed of the // action without carrying the "you must do something" burden. Duplicate // entries across To/Cc are stripped so a single inbox is never addressed @@ -396,7 +396,7 @@ func buildSESSendEmailInput(fromEmail, toEmail string, cc []string, subject, bod // dedupeCCAgainstTo returns cc with the to-address removed (case-insensitive) // and duplicate entries collapsed, preserving input order. Empty strings are -// dropped so a caller can freely pass optional slots without sanitising. +// dropped so a caller can freely pass optional slots without sanitizing. func dedupeCCAgainstTo(to string, cc []string) []string { if len(cc) == 0 { return nil @@ -414,7 +414,7 @@ func dedupeCCAgainstTo(to string, cc []string) []string { return out } -// NotificationData holds data for rendering email templates +// NotificationData holds data for rendering email templates. type NotificationData struct { DashboardURL string ApprovalToken string @@ -439,7 +439,7 @@ type NotificationData struct { RecipientEmail string // CCEmails carries additional recipients (e.g. the global notification // email) for flows where more than one inbox needs visibility into the - // action but only one party is authorised to approve. Empty for single- + // action but only one party is authorized to approve. Empty for single- // recipient flows. Purchase approvals use this to keep the global // notification email informed while directing the approver role at the // account's contact email. @@ -448,7 +448,7 @@ type NotificationData struct { // allowed to click the approve/cancel links. The template prints these // verbatim in the message body so recipients on CC know the action // isn't theirs to take. When empty the template omits the authorisation - // block (legacy broadcast behaviour). + // block (legacy broadcast behavior). AuthorizedApprovers []string // RequestedByName is the human-readable display name (or email-local) of // the user who submitted the purchase. Rendered in the approval-email @@ -486,7 +486,7 @@ type NotificationData struct { RevokeURL string } -// RecommendationSummary is a simplified recommendation for email display +// RecommendationSummary is a simplified recommendation for email display. type RecommendationSummary struct { Service string ResourceType string @@ -508,7 +508,7 @@ type RecommendationSummary struct { AccountLabel string } -// RIExchangeNotificationData holds data for RI exchange email templates +// RIExchangeNotificationData holds data for RI exchange email templates. type RIExchangeNotificationData struct { DashboardURL string Mode string @@ -522,11 +522,11 @@ type RIExchangeNotificationData struct { // SES (not the SNS broadcast topic). Mirrors NotificationData.RecipientEmail. RecipientEmail string // CCEmails carries additional recipients informed of the pending exchanges - // but not the authorised approvers. Deduplicated against RecipientEmail. + // but not the authorized approvers. Deduplicated against RecipientEmail. CCEmails []string } -// RIExchangeItem represents a single exchange in an email notification +// RIExchangeItem represents a single exchange in an email notification. type RIExchangeItem struct { RecordID string ApprovalToken string @@ -540,7 +540,7 @@ type RIExchangeItem struct { Error string } -// SkippedExchange represents an exchange that was skipped +// SkippedExchange represents an exchange that was skipped. type SkippedExchange struct { SourceRIID string SourceInstanceType string @@ -548,7 +548,7 @@ type SkippedExchange struct { } // redactEmail returns a redacted version of an email address for safe logging. -// e.g. "user@example.com" -> "us***@example.com" +// e.g. "user@example.com" -> "us***@example.com". func redactEmail(email string) string { at := strings.LastIndex(email, "@") if at < 0 { diff --git a/internal/email/sender_test.go b/internal/email/sender_test.go index 261b6dc48..d68ebec92 100644 --- a/internal/email/sender_test.go +++ b/internal/email/sender_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" ) -// MockSNSClient is a mock implementation of SNS client +// MockSNSClient is a mock implementation of SNS client. type MockSNSClient struct { mock.Mock } @@ -25,7 +25,7 @@ func (m *MockSNSClient) Publish(ctx context.Context, input *sns.PublishInput, op return args.Get(0).(*sns.PublishOutput), args.Error(1) } -// MockSESClient is a mock implementation of SES client +// MockSESClient is a mock implementation of SES client. type MockSESClient struct { mock.Mock } @@ -62,7 +62,7 @@ func (m *MockSESClient) CreateEmailIdentity(ctx context.Context, input *sesv2.Cr return args.Get(0).(*sesv2.CreateEmailIdentityOutput), args.Error(1) } -// testSender creates a sender with mock clients for testing +// testSender creates a sender with mock clients for testing. type testSender struct { *Sender mockSNS *MockSNSClient @@ -501,7 +501,7 @@ func TestNewSender_Success(t *testing.T) { assert.NotNil(t, sender.sesClient) } -// Test SendToEmail sandbox mode flows +// Test SendToEmail sandbox mode flows. func TestSender_SendToEmail_SandboxModeVerified(t *testing.T) { mockSES := new(MockSESClient) // GetAccount returns sandbox mode (ProductionAccessEnabled = false) @@ -621,7 +621,7 @@ func TestSender_SendToEmail_CreateVerificationError(t *testing.T) { mockSES.AssertExpectations(t) } -// Test isInSandbox directly +// Test isInSandbox directly. func TestSender_isInSandbox_NilClient(t *testing.T) { sender := &Sender{ sesClient: nil, @@ -668,7 +668,7 @@ func TestSender_isInSandbox_SandboxMode(t *testing.T) { mockSES.AssertExpectations(t) } -// Test isEmailVerified directly +// Test isEmailVerified directly. func TestSender_isEmailVerified_NilClient(t *testing.T) { sender := &Sender{ sesClient: nil, @@ -733,7 +733,7 @@ func TestSender_isEmailVerified_NotFound(t *testing.T) { mockSES.AssertExpectations(t) } -// Test createVerificationRequest directly +// Test createVerificationRequest directly. func TestSender_createVerificationRequest_NilClient(t *testing.T) { sender := &Sender{ sesClient: nil, @@ -1006,7 +1006,7 @@ func TestSendScheduledPurchaseNotification_ErrNoRecipientWhenEmpty(t *testing.T) // // Regression test for issue #1015: previously the method called // s.SendNotification which broadcast per-exchange approve/reject tokens to -// every SNS subscriber, allowing unauthorised spend approval. +// every SNS subscriber, allowing unauthorized spend approval. func TestSendRIExchangePendingApproval_UsesSESNotSNS(t *testing.T) { t.Parallel() mockSNS := new(MockSNSClient) diff --git a/internal/email/smtp_sender.go b/internal/email/smtp_sender.go index 03ecb7c93..009031273 100644 --- a/internal/email/smtp_sender.go +++ b/internal/email/smtp_sender.go @@ -14,7 +14,7 @@ import ( "github.com/LeanerCloud/CUDly/pkg/logging" ) -// SMTPConfig holds configuration for SMTP email sender +// SMTPConfig holds configuration for SMTP email sender. type SMTPConfig struct { Host string // SMTP server host (e.g., "smtp.sendgrid.net" or "smtp.azurecomm.net") Port int // SMTP server port (usually 587 for TLS, 465 for SSL) @@ -32,7 +32,7 @@ type SMTPConfig struct { AllowInsecure bool } -// SMTPSender handles sending email via SMTP (works for SendGrid, Azure ACS, and others) +// SMTPSender handles sending email via SMTP (works for SendGrid, Azure ACS, and others). type SMTPSender struct { host string port int @@ -45,7 +45,7 @@ type SMTPSender struct { allowInsecure bool } -// NewSMTPSender creates a new SMTP email sender +// NewSMTPSender creates a new SMTP email sender. func NewSMTPSender(cfg SMTPConfig) (*SMTPSender, error) { if cfg.Host == "" { return nil, fmt.Errorf("SMTP host is required") @@ -85,7 +85,7 @@ func NewSMTPSender(cfg SMTPConfig) (*SMTPSender, error) { // no subscriber list to fan out to. As a result, GCP (SendGrid) and Azure // (ACS SMTP) deployments do not receive broadcast notifications (new-recs, // scheduled-purchase reminders without a recipient email, etc.). Callers that -// need broadcast behaviour on non-AWS deployments must wire their own fan-out +// need broadcast behavior on non-AWS deployments must wire their own fan-out // or configure an SNS-compatible endpoint. Targeted approval emails // (SendPurchaseApprovalRequest, SendScheduledPurchaseNotification) are // unaffected because they use SendToEmailWithCC directly. @@ -99,7 +99,7 @@ func sanitizeHeader(s string) string { return strings.NewReplacer("\r", "", "\n", "").Replace(s) } -// SendToEmail sends an email directly to a specific email address via SMTP +// SendToEmail sends an email directly to a specific email address via SMTP. func (s *SMTPSender) SendToEmail(ctx context.Context, toEmail, subject, body string) error { return s.SendToEmailWithCC(ctx, toEmail, nil, subject, body) } @@ -308,7 +308,7 @@ func smtpSendBody(c *smtp.Client, from string, to []string, msg []byte) error { return w.Close() } -// sendMailTLS sends email using STARTTLS (required for most modern SMTP servers) +// sendMailTLS sends email using STARTTLS (required for most modern SMTP servers). func (s *SMTPSender) sendMailTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte) error { c, err := smtp.Dial(addr) if err != nil { @@ -368,7 +368,7 @@ func (s *SMTPSender) SendUserInviteEmail(ctx context.Context, email, setupURL st ) } -// SendNewRecommendationsNotification sends a notification about new recommendations +// SendNewRecommendationsNotification sends a notification about new recommendations. func (s *SMTPSender) SendNewRecommendationsNotification(ctx context.Context, data NotificationData) error { subject := "New CUDly Recommendations Available" body, err := RenderNewRecommendationsEmail(data) @@ -378,7 +378,7 @@ func (s *SMTPSender) SendNewRecommendationsNotification(ctx context.Context, dat return s.SendToEmail(ctx, s.notifyEmail, subject, body) } -// SendScheduledPurchaseNotification sends a notification about scheduled purchase +// SendScheduledPurchaseNotification sends a notification about scheduled purchase. func (s *SMTPSender) SendScheduledPurchaseNotification(ctx context.Context, data NotificationData) error { subject := fmt.Sprintf("CUDly Purchase Scheduled: %s", data.PlanName) body, err := RenderScheduledPurchaseEmail(data) @@ -388,7 +388,7 @@ func (s *SMTPSender) SendScheduledPurchaseNotification(ctx context.Context, data return s.SendToEmail(ctx, s.notifyEmail, subject, body) } -// SendPurchaseConfirmation sends a confirmation email after successful purchase +// SendPurchaseConfirmation sends a confirmation email after successful purchase. func (s *SMTPSender) SendPurchaseConfirmation(ctx context.Context, data NotificationData) error { subject := "CUDly Purchase Confirmation" body, err := RenderPurchaseConfirmationEmail(data) @@ -398,7 +398,7 @@ func (s *SMTPSender) SendPurchaseConfirmation(ctx context.Context, data Notifica return s.SendToEmail(ctx, s.notifyEmail, subject, body) } -// SendPurchaseFailedNotification sends a notification when a purchase fails +// SendPurchaseFailedNotification sends a notification when a purchase fails. func (s *SMTPSender) SendPurchaseFailedNotification(ctx context.Context, data NotificationData) error { subject := "CUDly Purchase Failed" body, err := RenderPurchaseFailedEmail(data) @@ -408,7 +408,7 @@ func (s *SMTPSender) SendPurchaseFailedNotification(ctx context.Context, data No return s.SendToEmail(ctx, s.notifyEmail, subject, body) } -// SendRIExchangePendingApproval sends an RI exchange approval email via SMTP +// SendRIExchangePendingApproval sends an RI exchange approval email via SMTP. func (s *SMTPSender) SendRIExchangePendingApproval(ctx context.Context, data RIExchangeNotificationData) error { subject := fmt.Sprintf("CUDly - RI Exchange Approval Required (%d exchanges)", len(data.Exchanges)) body, err := RenderRIExchangePendingApprovalEmail(data) @@ -418,7 +418,7 @@ func (s *SMTPSender) SendRIExchangePendingApproval(ctx context.Context, data RIE return s.SendToEmail(ctx, s.notifyEmail, subject, body) } -// SendRIExchangeCompleted sends an RI exchange completion email via SMTP +// SendRIExchangeCompleted sends an RI exchange completion email via SMTP. func (s *SMTPSender) SendRIExchangeCompleted(ctx context.Context, data RIExchangeNotificationData) error { subject := fmt.Sprintf("CUDly - RI Exchanges Completed (%d exchanges)", len(data.Exchanges)) body, err := RenderRIExchangeCompletedEmail(data) @@ -445,7 +445,7 @@ func (s *SMTPSender) SendPurchaseApprovalRequest(ctx context.Context, data Notif } // SendPurchaseScheduledNotification sends the Gmail-style pre-fire delay -// notification email via SMTP. Mirrors the Sender implementation's behaviour. +// notification email via SMTP. Mirrors the Sender implementation's behavior. func (s *SMTPSender) SendPurchaseScheduledNotification(ctx context.Context, data NotificationData) error { body, err := RenderPurchaseScheduledDelayEmail(data) if err != nil { @@ -465,7 +465,7 @@ func (s *SMTPSender) SendPurchaseScheduledNotification(ctx context.Context, data // SendRegistrationReceivedNotification sends an email to CUDly administrators // for a new registration via SMTP. Prefers the caller-resolved // data.RecipientEmail + CCEmails (admin emails + global notify) so the To / -// Cc semantics match the "authorised reviewers" block in the body; falls +// Cc semantics match the "authorized reviewers" block in the body; falls // back to the legacy static s.notifyEmail when the caller didn't resolve // recipients (e.g. no admin users configured yet). func (s *SMTPSender) SendRegistrationReceivedNotification(ctx context.Context, data RegistrationNotificationData) error { @@ -494,5 +494,5 @@ func (s *SMTPSender) SendRegistrationDecisionNotification(ctx context.Context, t return s.SendToEmail(ctx, toEmail, subject, body) } -// Verify that SMTPSender implements SenderInterface +// Verify that SMTPSender implements SenderInterface. var _ SenderInterface = (*SMTPSender)(nil) diff --git a/internal/email/smtp_sender_test.go b/internal/email/smtp_sender_test.go index 461f0c4ca..cd1ebb41f 100644 --- a/internal/email/smtp_sender_test.go +++ b/internal/email/smtp_sender_test.go @@ -249,7 +249,7 @@ func TestSMTPSender_SendPurchaseFailedNotification_NoFromEmail(t *testing.T) { require.NoError(t, err) } -// Test that SMTPSender implements SenderInterface +// Test that SMTPSender implements SenderInterface. func TestSMTPSender_ImplementsInterface(t *testing.T) { var sender SenderInterface = &SMTPSender{} assert.NotNil(t, sender) diff --git a/internal/email/smtp_server_test.go b/internal/email/smtp_server_test.go index 8061f88fc..311c0a8d0 100644 --- a/internal/email/smtp_server_test.go +++ b/internal/email/smtp_server_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -// mockSMTPServer is a simple mock SMTP server for testing +// mockSMTPServer is a simple mock SMTP server for testing. type mockSMTPServer struct { listener net.Listener port int @@ -26,7 +26,7 @@ type mockSMTPServer struct { inData bool } -// newMockSMTPServer creates a new mock SMTP server +// newMockSMTPServer creates a new mock SMTP server. func newMockSMTPServer(t *testing.T, authFail bool) *mockSMTPServer { listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) @@ -42,7 +42,7 @@ func newMockSMTPServer(t *testing.T, authFail bool) *mockSMTPServer { return server } -// start begins accepting connections +// start begins accepting connections. func (s *mockSMTPServer) start(t *testing.T) { s.wg.Add(1) go func() { @@ -130,13 +130,13 @@ func (s *mockSMTPServer) start(t *testing.T) { }() } -// stop closes the server +// stop closes the server. func (s *mockSMTPServer) stop() { s.listener.Close() s.wg.Wait() } -// TestSMTPSender_SendToEmail_WithMockServer tests with a simple mock SMTP server (no TLS) +// TestSMTPSender_SendToEmail_WithMockServer tests with a simple mock SMTP server (no TLS). func TestSMTPSender_SendToEmail_WithMockServer_NoTLS(t *testing.T) { // Create mock server server := newMockSMTPServer(t, false) @@ -250,7 +250,7 @@ func TestSMTPSender_SendToEmail_WithMockServer_AuthFailure(t *testing.T) { assert.Contains(t, err.Error(), "failed to send email via SMTP") } -// TestSMTPSender_SendPasswordResetEmail_WithMockServer tests the full flow +// TestSMTPSender_SendPasswordResetEmail_WithMockServer tests the full flow. func TestSMTPSender_SendPasswordResetEmail_WithMockServer(t *testing.T) { server := newMockSMTPServer(t, false) server.start(t) @@ -272,7 +272,7 @@ func TestSMTPSender_SendPasswordResetEmail_WithMockServer(t *testing.T) { require.NoError(t, err) } -// TestSMTPSender_SendWelcomeEmail_WithMockServer tests welcome email +// TestSMTPSender_SendWelcomeEmail_WithMockServer tests welcome email. func TestSMTPSender_SendWelcomeEmail_WithMockServer(t *testing.T) { server := newMockSMTPServer(t, false) server.start(t) @@ -294,7 +294,7 @@ func TestSMTPSender_SendWelcomeEmail_WithMockServer(t *testing.T) { require.NoError(t, err) } -// TestSMTPSender_SendNewRecommendationsNotification_WithMockServer tests recommendations email +// TestSMTPSender_SendNewRecommendationsNotification_WithMockServer tests recommendations email. func TestSMTPSender_SendNewRecommendationsNotification_WithMockServer(t *testing.T) { server := newMockSMTPServer(t, false) server.start(t) @@ -325,7 +325,7 @@ func TestSMTPSender_SendNewRecommendationsNotification_WithMockServer(t *testing require.NoError(t, err) } -// TestSMTPSender_SendScheduledPurchaseNotification_WithMockServer tests scheduled purchase email +// TestSMTPSender_SendScheduledPurchaseNotification_WithMockServer tests scheduled purchase email. func TestSMTPSender_SendScheduledPurchaseNotification_WithMockServer(t *testing.T) { server := newMockSMTPServer(t, false) server.start(t) @@ -360,7 +360,7 @@ func TestSMTPSender_SendScheduledPurchaseNotification_WithMockServer(t *testing. require.NoError(t, err) } -// TestSMTPSender_SendPurchaseConfirmation_WithMockServer tests purchase confirmation email +// TestSMTPSender_SendPurchaseConfirmation_WithMockServer tests purchase confirmation email. func TestSMTPSender_SendPurchaseConfirmation_WithMockServer(t *testing.T) { server := newMockSMTPServer(t, false) server.start(t) @@ -391,7 +391,7 @@ func TestSMTPSender_SendPurchaseConfirmation_WithMockServer(t *testing.T) { require.NoError(t, err) } -// TestSMTPSender_SendPurchaseFailedNotification_WithMockServer tests purchase failed email +// TestSMTPSender_SendPurchaseFailedNotification_WithMockServer tests purchase failed email. func TestSMTPSender_SendPurchaseFailedNotification_WithMockServer(t *testing.T) { server := newMockSMTPServer(t, false) server.start(t) @@ -420,7 +420,7 @@ func TestSMTPSender_SendPurchaseFailedNotification_WithMockServer(t *testing.T) require.NoError(t, err) } -// TestSMTPSender_SendToEmail_WithMockServer_MultipleRecipients tests multiple recipients +// TestSMTPSender_SendToEmail_WithMockServer_MultipleRecipients tests multiple recipients. func TestSMTPSender_SendToEmail_WithMockServer_MessageContent(t *testing.T) { server := newMockSMTPServer(t, false) server.start(t) @@ -442,7 +442,7 @@ func TestSMTPSender_SendToEmail_WithMockServer_MessageContent(t *testing.T) { require.NoError(t, err) } -// TestSMTPSender_SendToEmail_WithMockServer_LongContent tests long content +// TestSMTPSender_SendToEmail_WithMockServer_LongContent tests long content. func TestSMTPSender_SendToEmail_WithMockServer_LongContent(t *testing.T) { server := newMockSMTPServer(t, false) server.start(t) @@ -526,11 +526,12 @@ func handleFlexSMTPConn(conn net.Conn, behavior string) { return // plaintext conn -> client TLS handshake fails case strings.HasPrefix(cmd, "AUTH"): - if behavior == "auth_fail_535" { + switch behavior { + case "auth_fail_535": fmt.Fprintf(conn, "535 5.7.8 Authentication credentials invalid\r\n") - } else if behavior == "auth_fail_other" { + case "auth_fail_other": fmt.Fprintf(conn, "454 4.7.0 Temporary authentication failure\r\n") - } else { + default: fmt.Fprintf(conn, "235 2.7.0 Authentication successful\r\n") } diff --git a/internal/email/template_renderers.go b/internal/email/template_renderers.go index a72f3d688..3af5c40aa 100644 --- a/internal/email/template_renderers.go +++ b/internal/email/template_renderers.go @@ -16,7 +16,7 @@ var templateFuncs = template.FuncMap{ } // textTemplateFuncs mirrors templateFuncs for text/template so plain-text -// renderers share the same urlquery behaviour without crossing package types. +// renderers share the same urlquery behavior without crossing package types. var textTemplateFuncs = texttemplate.FuncMap{ "urlquery": url.QueryEscape, } diff --git a/internal/email/template_renderers_test.go b/internal/email/template_renderers_test.go index a6827ac6a..fc42a4bb0 100644 --- a/internal/email/template_renderers_test.go +++ b/internal/email/template_renderers_test.go @@ -300,7 +300,7 @@ func TestRenderPurchaseApprovalRequestEmail_NewContextFields_Issue287(t *testing assert.Contains(t, body, "/purchases/cancel/exec-123") // Authorized-approvers block survives. - assert.Contains(t, body, "Authorised approver(s)") + assert.Contains(t, body, "Authorized approver(s)") assert.Contains(t, body, "approver@acme.com") } @@ -440,7 +440,7 @@ func TestRenderPurchaseConfirmationEmail_ArcheraBlock(t *testing.T) { } // Issue #287: when AuthorizedApprovers is empty the HTML omits the -// approver-warning block (legacy broadcast behaviour preserved). +// approver-warning block (legacy broadcast behavior preserved). func TestRenderPurchaseApprovalRequestEmailHTML_NoApprovers(t *testing.T) { data := NotificationData{ DashboardURL: "https://example.com", @@ -450,7 +450,7 @@ func TestRenderPurchaseApprovalRequestEmailHTML_NoApprovers(t *testing.T) { } html, err := RenderPurchaseApprovalRequestEmailHTML(data) require.NoError(t, err) - assert.NotContains(t, html, "Authorised approver") + assert.NotContains(t, html, "Authorized approver") } // TestRenderPasswordResetEmailHTML covers the HTML half of the password @@ -555,7 +555,7 @@ func TestPlainTextTemplates_NoHTMLEscaping(t *testing.T) { }) t.Run("HTML_renderers_still_escape", func(t *testing.T) { - // HTML halves MUST still escape data -- that is the XSS defence. + // HTML halves MUST still escape data -- that is the XSS defense. // A name with a script tag must not survive literally in the HTML body. const xssName = `` data := NotificationData{ diff --git a/internal/email/templates.go b/internal/email/templates.go index ffff21552..9cf23f3f2 100644 --- a/internal/email/templates.go +++ b/internal/email/templates.go @@ -88,7 +88,7 @@ When it makes sense: - You want the deepest discount tier (3-year) but aren't sure the workload will still fit in 18 months, or you want to be covered in case your usage drops. - - You're moving to a new service or region and historical utilisation + - You're moving to a new service or region and historical utilization data is thin. How it works (7-day enrollment window from each purchase): @@ -100,7 +100,7 @@ How it works (7-day enrollment window from each purchase): insurance policy activates and covers any overcommitment from that point forward. 3. Purchase commitments normally through CUDly: Archera tracks - utilisation independently and pays out on shortfalls per your + utilization independently and pays out on shortfalls per your policy. Archera charges an insurance premium for the coverage you select, a @@ -157,7 +157,7 @@ This is an automated message from CUDly. // passwordResetHTMLTemplate renders the same content as the plain-text // password reset template with a styled CTA button and CUDly branding. -// Modelled on purchaseApprovalRequestHTMLTemplate (line 367) — inline styles +// Modeled on purchaseApprovalRequestHTMLTemplate (line 367) — inline styles // because most email clients (Outlook, mobile Gmail) ignore class-based CSS. // Issue #355. const passwordResetHTMLTemplate = ` @@ -339,7 +339,7 @@ View exchange history: This is an automated message from CUDly. ` -// SendNewRecommendationsNotification sends an email about new recommendations +// SendNewRecommendationsNotification sends an email about new recommendations. func (s *Sender) SendNewRecommendationsNotification(ctx context.Context, data NotificationData) error { body, err := RenderNewRecommendationsEmail(data) if err != nil { @@ -370,7 +370,7 @@ func (s *Sender) SendScheduledPurchaseNotification(ctx context.Context, data Not return s.SendToEmailWithCC(ctx, data.RecipientEmail, data.CCEmails, subject, body) } -// SendPurchaseConfirmation sends a confirmation after successful purchases +// SendPurchaseConfirmation sends a confirmation after successful purchases. func (s *Sender) SendPurchaseConfirmation(ctx context.Context, data NotificationData) error { body, err := RenderPurchaseConfirmationEmail(data) if err != nil { @@ -381,7 +381,7 @@ func (s *Sender) SendPurchaseConfirmation(ctx context.Context, data Notification return s.SendNotification(ctx, subject, body) } -// SendPurchaseFailedNotification sends a notification when purchases fail +// SendPurchaseFailedNotification sends a notification when purchases fail. func (s *Sender) SendPurchaseFailedNotification(ctx context.Context, data NotificationData) error { body, err := RenderPurchaseFailedEmail(data) if err != nil { @@ -392,7 +392,7 @@ func (s *Sender) SendPurchaseFailedNotification(ctx context.Context, data Notifi return s.SendNotification(ctx, subject, body) } -// PasswordResetData holds data for password reset emails +// PasswordResetData holds data for password reset emails. type PasswordResetData struct { Email string ResetURL string @@ -408,7 +408,7 @@ func (s *Sender) SendPasswordResetEmail(ctx context.Context, email, resetURL str ) } -// WelcomeUserData holds data for welcome emails +// WelcomeUserData holds data for welcome emails. type WelcomeUserData struct { Email string DashboardURL string @@ -447,7 +447,7 @@ func (s *Sender) SendUserInviteEmail(ctx context.Context, email, setupURL string // SendRIExchangePendingApproval sends an email with RI exchange approval links. // The rendered body contains per-exchange approve/reject links that carry live // tokens; any subscriber of the SNS topic who receives this body can -// approve spend they were never authorised for. This method therefore routes +// approve spend they were never authorized for. This method therefore routes // through targeted SES (SendToEmailWithCC), mirroring the hardened path used // by SendPurchaseApprovalRequest. // @@ -467,7 +467,7 @@ func (s *Sender) SendRIExchangePendingApproval(ctx context.Context, data RIExcha return s.SendToEmailWithCC(ctx, data.RecipientEmail, data.CCEmails, subject, body) } -// SendRIExchangeCompleted sends a notification about completed RI exchanges +// SendRIExchangeCompleted sends a notification about completed RI exchanges. func (s *Sender) SendRIExchangeCompleted(ctx context.Context, data RIExchangeNotificationData) error { body, err := RenderRIExchangeCompletedEmail(data) if err != nil { @@ -483,7 +483,7 @@ const purchaseApprovalRequestTemplate = `CUDly - Purchase Approval Required A direct purchase of {{len .Recommendations}} commitment(s) has been submitted and requires approval. {{if .AuthorizedApprovers}} -Authorised approver(s): +Authorized approver(s): {{range .AuthorizedApprovers}} - {{.}} {{end}} Only the inbox(es) listed above can approve or cancel this purchase. @@ -529,7 +529,7 @@ When it makes sense: - The buyer wants the deepest discount tier (3-year) but isn't sure the workload will still fit in 18 months, or wants to be covered in case usage drops. - - They're moving to a new service or region and historical utilisation + - They're moving to a new service or region and historical utilization data is thin. How it works (within the 7-day enrollment window from each purchase): @@ -541,7 +541,7 @@ How it works (within the 7-day enrollment window from each purchase): insurance policy activates and covers any overcommitment from that point forward. 3. Continue purchasing commitments normally through CUDly: Archera - tracks utilisation independently and pays out on shortfalls per + tracks utilization independently and pays out on shortfalls per the policy. Archera charges an insurance premium for the coverage selected, a @@ -562,7 +562,7 @@ This is an automated message from CUDly. Do not share these links. // purchaseApprovalRequestHTMLTemplate renders the same approval request // as the plain-text template above with inline-styled CTAs and a richer -// summary table. CSS classes are NOT honoured by most email clients +// summary table. CSS classes are NOT honored by most email clients // (Outlook, mobile Gmail) — every visual rule lives in inline `style=""` // attributes on the elements themselves. Issue #287. const purchaseApprovalRequestHTMLTemplate = ` @@ -579,7 +579,7 @@ const purchaseApprovalRequestHTMLTemplate = ` {{if .AuthorizedApprovers}}
-Authorised approver(s): +Authorized approver(s):
    {{range .AuthorizedApprovers}}
  • {{.}}
  • {{end}}
@@ -641,14 +641,14 @@ const purchaseApprovalRequestHTMLTemplate = `

When it makes sense

  • The buyer wants the deepest discount tier (3-year) but isn't sure the workload will still fit in 18 months, or wants to be covered in case usage drops.
  • -
  • They're moving to a new service or region and historical utilisation data is thin.
  • +
  • They're moving to a new service or region and historical utilization data is thin.

How it works (7-day enrollment window from each purchase)

  1. Sign up at Archera: create an account using the link below. The CUDly signup link tells Archera the buyer came from us; CUDly is compensated for the referral, and the link unlocks a dedicated onboarding path.
  2. Archera starts ingesting cost data: once access is granted, the insurance policy activates and covers any overcommitment from that point forward.
  3. -
  4. Continue purchasing commitments normally through CUDly: Archera tracks utilisation independently and pays out on shortfalls per the policy.
  5. +
  6. Continue purchasing commitments normally through CUDly: Archera tracks utilization independently and pays out on shortfalls per the policy.

Archera charges an insurance premium for the coverage selected, a separate fee paid to Archera. The cloud commitment purchased through CUDly is unaffected: same price, same billing.

@@ -729,7 +729,7 @@ func (s *Sender) SendPurchaseApprovalRequest(ctx context.Context, data Notificat } // Both empty and malformed FROM_EMAIL (e.g. "noreply@" when the // subdomain_zone_name tfvar is unset) map to ErrNoFromEmail so the - // handler can report "FROM_EMAIL not configured" — the prior behaviour + // handler can report "FROM_EMAIL not configured" — the prior behavior // handed the bad string to SES and surfaced a BadRequestException stack // trace ("Missing domain") to the user. if !isValidFromEmail(s.fromEmail) { @@ -840,7 +840,7 @@ const registrationReceivedTemplate = `CUDly - New Account Registration A new target account has requested to join your CUDly deployment. {{if .AdminApprovers}} -Authorised reviewer(s): +Authorized reviewer(s): {{range .AdminApprovers}} - {{.}} {{end}} Only CUDly administrators listed above can approve or reject this diff --git a/internal/email/templates_test.go b/internal/email/templates_test.go index 1ba02b4a5..2065e078d 100644 --- a/internal/email/templates_test.go +++ b/internal/email/templates_test.go @@ -158,7 +158,7 @@ func TestSender_SendWelcomeEmail_Success(t *testing.T) { mockSES.AssertExpectations(t) } -// Test template success paths with no recommendations (edge case) +// Test template success paths with no recommendations (edge case). func TestSender_SendNewRecommendationsNotification_EmptyRecommendations(t *testing.T) { mockSNS := new(MockSNSClient) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). @@ -181,7 +181,7 @@ func TestSender_SendNewRecommendationsNotification_EmptyRecommendations(t *testi mockSNS.AssertExpectations(t) } -// Test when topic/from email are empty (early return paths) +// Test when topic/from email are empty (early return paths). func TestSender_SendNewRecommendationsNotification_NoTopic(t *testing.T) { sender := &Sender{ topicARN: "", @@ -269,7 +269,7 @@ func TestSender_SendWelcomeEmail_NoFromEmail(t *testing.T) { require.NoError(t, err) } -// Test error cases for template functions +// Test error cases for template functions. func TestSender_SendNewRecommendationsNotification_SNSError(t *testing.T) { mockSNS := new(MockSNSClient) sender := &Sender{ @@ -389,7 +389,7 @@ func TestSender_SendWelcomeEmail_SESError(t *testing.T) { require.Error(t, err) } -// Test multiple recommendations in templates +// Test multiple recommendations in templates. func TestSender_SendNewRecommendationsNotification_MultipleRecommendations(t *testing.T) { mockSNS := new(MockSNSClient) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). diff --git a/internal/execution/fanout.go b/internal/execution/fanout.go index 6251aa30f..5755d2fd7 100644 --- a/internal/execution/fanout.go +++ b/internal/execution/fanout.go @@ -17,7 +17,7 @@ import ( // returns its value when it's a positive integer, otherwise // DefaultMaxConcurrency. Shared between the purchase manager (which drives // live cloud API calls) and the scheduler (per-account recommendations -// collection) so both honour the same operator-level override. +// collection) so both honor the same operator-level override. func ConcurrencyFromEnv() int { if v := os.Getenv("CUDLY_MAX_ACCOUNT_PARALLELISM"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { @@ -40,7 +40,7 @@ const DefaultMaxConcurrency = 20 // FanOut runs fn concurrently for each accountID in the supplied slice and // collects all results. Cancellation of ctx is respected: inflight goroutines -// see the cancelled context but all launched goroutines are still awaited so +// see the canceled context but all launched goroutines are still awaited so // the caller receives a full result slice (some entries may carry ctx.Err()). // // Concurrency is capped at DefaultMaxConcurrency to avoid overwhelming AWS @@ -70,10 +70,10 @@ func FanOutWithConcurrency[T any]( for i, id := range accountIDs { // Acquire a semaphore slot before launching the goroutine. - // Use select so a cancelled/expired context is not held up by a + // Use select so a canceled/expired context is not held up by a // full semaphore: if ctx is done while waiting for a slot, record // ctx.Err() on the result slot and skip launching the goroutine. - // Without this, a large fan-out on an already-cancelled context + // Without this, a large fan-out on an already-canceled context // (e.g. Lambda deadline exceeded partway through) would block // indefinitely here rather than draining quickly. wg.Add(1) diff --git a/internal/execution/fanout_test.go b/internal/execution/fanout_test.go index 992ec4d99..d9b8105b0 100644 --- a/internal/execution/fanout_test.go +++ b/internal/execution/fanout_test.go @@ -58,7 +58,7 @@ func TestFanOut_Empty(t *testing.T) { func TestFanOut_ContextCancelled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - cancel() // already cancelled + cancel() // already canceled ids := []string{"x"} results := FanOut(ctx, ids, func(ctx context.Context, id string) (string, error) { @@ -88,11 +88,11 @@ func TestPartition(t *testing.T) { // (which would strand the surrounding purchase execution at 'approved' and // terminate the Lambda invocation abnormally — see #669). // TestFanOut_ContextCancelled_BlockedSemaphore asserts that when a context is -// cancelled while goroutines are already occupying all semaphore slots, the +// canceled while goroutines are already occupying all semaphore slots, the // remaining queued items record ctx.Err() immediately rather than blocking // indefinitely on the semaphore (05-H3). // -// Pre-fix behaviour: sem <- struct{}{} was unconditional, so a cancelled +// Pre-fix behavior: sem <- struct{}{} was unconditional, so a canceled // context with maxConcurrency=1 and N>1 ids would block the launch loop on // the second item until the first goroutine released its slot -- a // context-deadline timeout would therefore not be respected at the semaphore diff --git a/internal/mocks/email.go b/internal/mocks/email.go index e927b6bb8..49ac9c6da 100644 --- a/internal/mocks/email.go +++ b/internal/mocks/email.go @@ -7,60 +7,60 @@ import ( "github.com/stretchr/testify/mock" ) -// MockEmailSender is a mock implementation of email.Sender +// MockEmailSender is a mock implementation of email.Sender. type MockEmailSender struct { mock.Mock } -// SendNotification mocks the SendNotification operation +// SendNotification mocks the SendNotification operation. func (m *MockEmailSender) SendNotification(ctx context.Context, subject, message string) error { args := m.Called(ctx, subject, message) return args.Error(0) } -// SendToEmail mocks the SendToEmail operation +// SendToEmail mocks the SendToEmail operation. func (m *MockEmailSender) SendToEmail(ctx context.Context, toEmail, subject, body string) error { args := m.Called(ctx, toEmail, subject, body) return args.Error(0) } -// SendNewRecommendationsNotification mocks the SendNewRecommendationsNotification operation +// SendNewRecommendationsNotification mocks the SendNewRecommendationsNotification operation. func (m *MockEmailSender) SendNewRecommendationsNotification(ctx context.Context, data email.NotificationData) error { args := m.Called(ctx, data) return args.Error(0) } -// SendScheduledPurchaseNotification mocks the SendScheduledPurchaseNotification operation +// SendScheduledPurchaseNotification mocks the SendScheduledPurchaseNotification operation. func (m *MockEmailSender) SendScheduledPurchaseNotification(ctx context.Context, data email.NotificationData) error { args := m.Called(ctx, data) return args.Error(0) } -// SendPurchaseConfirmation mocks the SendPurchaseConfirmation operation +// SendPurchaseConfirmation mocks the SendPurchaseConfirmation operation. func (m *MockEmailSender) SendPurchaseConfirmation(ctx context.Context, data email.NotificationData) error { args := m.Called(ctx, data) return args.Error(0) } -// SendPurchaseFailedNotification mocks the SendPurchaseFailedNotification operation +// SendPurchaseFailedNotification mocks the SendPurchaseFailedNotification operation. func (m *MockEmailSender) SendPurchaseFailedNotification(ctx context.Context, data email.NotificationData) error { args := m.Called(ctx, data) return args.Error(0) } -// SendPasswordResetEmail mocks the SendPasswordResetEmail operation +// SendPasswordResetEmail mocks the SendPasswordResetEmail operation. func (m *MockEmailSender) SendPasswordResetEmail(ctx context.Context, email, resetURL string) error { args := m.Called(ctx, email, resetURL) return args.Error(0) } -// SendWelcomeEmail mocks the SendWelcomeEmail operation +// SendWelcomeEmail mocks the SendWelcomeEmail operation. func (m *MockEmailSender) SendWelcomeEmail(ctx context.Context, email, dashboardURL, role string) error { args := m.Called(ctx, email, dashboardURL, role) return args.Error(0) } -// SendPurchaseApprovalRequest mocks the SendPurchaseApprovalRequest operation +// SendPurchaseApprovalRequest mocks the SendPurchaseApprovalRequest operation. func (m *MockEmailSender) SendPurchaseApprovalRequest(ctx context.Context, data email.NotificationData) error { args := m.Called(ctx, data) return args.Error(0) @@ -76,7 +76,7 @@ func (m *MockEmailSender) SendRegistrationDecisionNotification(ctx context.Conte return args.Error(0) } -// EmailSenderAPI defines the interface for email sender operations +// EmailSenderAPI defines the interface for email sender operations. type EmailSenderAPI interface { SendNotification(ctx context.Context, subject, message string) error SendToEmail(ctx context.Context, toEmail, subject, body string) error @@ -89,5 +89,5 @@ type EmailSenderAPI interface { SendPurchaseApprovalRequest(ctx context.Context, data email.NotificationData) error } -// Ensure MockEmailSender implements EmailSenderAPI +// Ensure MockEmailSender implements EmailSenderAPI. var _ EmailSenderAPI = (*MockEmailSender)(nil) diff --git a/internal/mocks/secretsmanager.go b/internal/mocks/secretsmanager.go index 78719666a..f44b94586 100644 --- a/internal/mocks/secretsmanager.go +++ b/internal/mocks/secretsmanager.go @@ -8,12 +8,12 @@ import ( "github.com/stretchr/testify/mock" ) -// MockSecretsManagerClient is a mock implementation of Secrets Manager client +// MockSecretsManagerClient is a mock implementation of Secrets Manager client. type MockSecretsManagerClient struct { mock.Mock } -// GetSecretValue mocks the GetSecretValue operation +// GetSecretValue mocks the GetSecretValue operation. func (m *MockSecretsManagerClient) GetSecretValue(ctx context.Context, input *secretsmanager.GetSecretValueInput, opts ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { @@ -26,7 +26,7 @@ func (m *MockSecretsManagerClient) GetSecretValue(ctx context.Context, input *se return v, args.Error(1) } -// CreateSecret mocks the CreateSecret operation +// CreateSecret mocks the CreateSecret operation. func (m *MockSecretsManagerClient) CreateSecret(ctx context.Context, input *secretsmanager.CreateSecretInput, opts ...func(*secretsmanager.Options)) (*secretsmanager.CreateSecretOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { @@ -39,7 +39,7 @@ func (m *MockSecretsManagerClient) CreateSecret(ctx context.Context, input *secr return v, args.Error(1) } -// UpdateSecret mocks the UpdateSecret operation +// UpdateSecret mocks the UpdateSecret operation. func (m *MockSecretsManagerClient) UpdateSecret(ctx context.Context, input *secretsmanager.UpdateSecretInput, opts ...func(*secretsmanager.Options)) (*secretsmanager.UpdateSecretOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { @@ -52,12 +52,12 @@ func (m *MockSecretsManagerClient) UpdateSecret(ctx context.Context, input *secr return v, args.Error(1) } -// SecretsManagerAPI defines the interface for Secrets Manager operations used by our code +// SecretsManagerAPI defines the interface for Secrets Manager operations used by our code. type SecretsManagerAPI interface { GetSecretValue(ctx context.Context, input *secretsmanager.GetSecretValueInput, opts ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) CreateSecret(ctx context.Context, input *secretsmanager.CreateSecretInput, opts ...func(*secretsmanager.Options)) (*secretsmanager.CreateSecretOutput, error) UpdateSecret(ctx context.Context, input *secretsmanager.UpdateSecretInput, opts ...func(*secretsmanager.Options)) (*secretsmanager.UpdateSecretOutput, error) } -// Ensure MockSecretsManagerClient implements SecretsManagerAPI +// Ensure MockSecretsManagerClient implements SecretsManagerAPI. var _ SecretsManagerAPI = (*MockSecretsManagerClient)(nil) diff --git a/internal/mocks/ses.go b/internal/mocks/ses.go index 0b94a1c15..93889e534 100644 --- a/internal/mocks/ses.go +++ b/internal/mocks/ses.go @@ -8,12 +8,12 @@ import ( "github.com/stretchr/testify/mock" ) -// MockSESClient is a mock implementation of SES client +// MockSESClient is a mock implementation of SES client. type MockSESClient struct { mock.Mock } -// SendEmail mocks the SendEmail operation +// SendEmail mocks the SendEmail operation. func (m *MockSESClient) SendEmail(ctx context.Context, input *sesv2.SendEmailInput, opts ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { @@ -26,10 +26,10 @@ func (m *MockSESClient) SendEmail(ctx context.Context, input *sesv2.SendEmailInp return v, args.Error(1) } -// SESAPI defines the interface for SES operations used by our code +// SESAPI defines the interface for SES operations used by our code. type SESAPI interface { SendEmail(ctx context.Context, input *sesv2.SendEmailInput, opts ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) } -// Ensure MockSESClient implements SESAPI +// Ensure MockSESClient implements SESAPI. var _ SESAPI = (*MockSESClient)(nil) diff --git a/internal/mocks/sns.go b/internal/mocks/sns.go index 87ce6fe9a..b17b8f97f 100644 --- a/internal/mocks/sns.go +++ b/internal/mocks/sns.go @@ -8,12 +8,12 @@ import ( "github.com/stretchr/testify/mock" ) -// MockSNSClient is a mock implementation of SNS client +// MockSNSClient is a mock implementation of SNS client. type MockSNSClient struct { mock.Mock } -// Publish mocks the Publish operation +// Publish mocks the Publish operation. func (m *MockSNSClient) Publish(ctx context.Context, input *sns.PublishInput, opts ...func(*sns.Options)) (*sns.PublishOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { @@ -26,10 +26,10 @@ func (m *MockSNSClient) Publish(ctx context.Context, input *sns.PublishInput, op return v, args.Error(1) } -// SNSAPI defines the interface for SNS operations used by our code +// SNSAPI defines the interface for SNS operations used by our code. type SNSAPI interface { Publish(ctx context.Context, input *sns.PublishInput, opts ...func(*sns.Options)) (*sns.PublishOutput, error) } -// Ensure MockSNSClient implements SNSAPI +// Ensure MockSNSClient implements SNSAPI. var _ SNSAPI = (*MockSNSClient)(nil) diff --git a/internal/oidc/aws_signer_test.go b/internal/oidc/aws_signer_test.go index 364a5e23d..b6721e7f5 100644 --- a/internal/oidc/aws_signer_test.go +++ b/internal/oidc/aws_signer_test.go @@ -51,7 +51,7 @@ func TestAWSKMSSignerRoundTrip(t *testing.T) { if err != nil { t.Fatalf("public key: %v", err) } - if pub.N.Cmp(key.PublicKey.N) != 0 { + if pub.N.Cmp(key.N) != 0 { t.Fatal("public key modulus mismatch") } @@ -80,7 +80,7 @@ func TestAWSKMSSignerRoundTrip(t *testing.T) { } } -// helpers — unit tests only, kept private +// helpers — unit tests only, kept private. func splitJWS(t *testing.T, jws string) [3]string { t.Helper() var out [3]string diff --git a/internal/oidc/factory.go b/internal/oidc/factory.go index e639e57fa..e49c397c2 100644 --- a/internal/oidc/factory.go +++ b/internal/oidc/factory.go @@ -19,7 +19,7 @@ const ( envAzureKeyName = "CUDLY_SIGNING_KEY_NAME" // GCP: full resource name of the asymmetric key version, e.g. - // projects/.../locations/global/keyRings/.../cryptoKeys/.../cryptoKeyVersions/1 + // projects/.../locations/global/keyRings/.../cryptoKeys/.../cryptoKeyVersions/1. envGCPKeyResource = "CUDLY_SIGNING_KEY_RESOURCE" ) diff --git a/internal/oidc/lambda_issuer.go b/internal/oidc/lambda_issuer.go index 585b8c173..f5c98fdc9 100644 --- a/internal/oidc/lambda_issuer.go +++ b/internal/oidc/lambda_issuer.go @@ -12,7 +12,7 @@ import ( // LambdaFunctionURLClient is the subset of the AWS Lambda API the // issuer-cache primer needs. Exposed as an interface so tests can -// inject a fake without dialling AWS. +// inject a fake without dialing AWS. type LambdaFunctionURLClient interface { GetFunctionUrlConfig(ctx context.Context, params *lambda.GetFunctionUrlConfigInput, optFns ...func(*lambda.Options)) (*lambda.GetFunctionUrlConfigOutput, error) } diff --git a/internal/purchase/approvals.go b/internal/purchase/approvals.go index 0e85cc46a..6cf41fe2d 100644 --- a/internal/purchase/approvals.go +++ b/internal/purchase/approvals.go @@ -193,7 +193,7 @@ func (m *Manager) ApproveAndExecute(ctx context.Context, executionID, actor stri // the approved row. This is the token/email-link cancel analog of // the atomic guard TransitionExecutionStatus provides for ApproveAndExecute. func (m *Manager) CancelExecution(ctx context.Context, executionID, token, actor string) error { - logging.Infof("Cancelling execution: %s", executionID) + logging.Infof("Canceling execution: %s", executionID) if _, err := m.loadCancelableExecution(ctx, executionID, token); err != nil { return err @@ -211,15 +211,15 @@ func (m *Manager) CancelExecution(ctx context.Context, executionID, token, actor // CancelExecutionAtomic flips status only when status IN // ('pending','notified') so a concurrent approve that has already // transitioned the row causes zero rows affected and we surface a 409. - var cancelled bool + var canceled bool var currentStatus string if err := m.config.WithTx(ctx, func(tx pgx.Tx) error { var err error - cancelled, currentStatus, err = m.config.CancelExecutionAtomic(ctx, tx, executionID, cancelledBy) + canceled, currentStatus, err = m.config.CancelExecutionAtomic(ctx, tx, executionID, cancelledBy) if err != nil { return err } - if !cancelled { + if !canceled { // Row already transitioned (concurrent approve/cancel won the // race). Return early without touching suppressions — the other // operation owns the execution state now. @@ -230,11 +230,11 @@ func (m *Manager) CancelExecution(ctx context.Context, executionID, token, actor return fmt.Errorf("failed to cancel execution: %w", err) } - if !cancelled { + if !canceled { return fmt.Errorf("execution %s cannot be cancelled: concurrent operation already transitioned it to %q", executionID, currentStatus) } - logging.Infof("Execution %s cancelled", executionID) + logging.Infof("Execution %s canceled", executionID) return nil } @@ -270,7 +270,7 @@ func (m *Manager) loadCancelableExecution(ctx context.Context, executionID, toke // approved/running/paused/failed/expired execution that the dashboard // user cannot. Restricting to the pre-purchase states is also the // in-flight guard: approved/running rows are mid-execution (the AWS - // commitment is being or has been created), so cancelling them would + // commitment is being or has been created), so canceling them would // leave the DB and the cloud out of sync. if !execution.IsCancelable() { return nil, fmt.Errorf("execution cannot be cancelled, current status: %s", execution.Status) diff --git a/internal/purchase/execution.go b/internal/purchase/execution.go index 5ecb09843..8d7b839e1 100644 --- a/internal/purchase/execution.go +++ b/internal/purchase/execution.go @@ -35,7 +35,7 @@ func (m *Manager) executePurchase(ctx context.Context, exec *config.PurchaseExec // Direct-execute purchases (Opportunities "Purchase" button) arrive // with no associated plan. PlanID is empty and the Postgres UUID // column rejects "" with SQLSTATE 22P02, so skip the plan/accounts - // fetch entirely and synthesise a placeholder plan whose Name is the + // fetch entirely and synthesize a placeholder plan whose Name is the // only field downstream history/notification code reads. By // definition direct-execute purchases target a single account, so // fall straight through to the legacy single-account path. @@ -138,13 +138,13 @@ func anyRecPurchased(recs []config.RecommendationRecord) bool { // multiAccountPartialError is the sentinel returned by executeMultiAccount when // at least one account committed a real purchase while one or more others -// failed (issue #1014). It is the multi-account analogue of partialPurchaseError: +// failed (issue #1014). It is the multi-account analog of partialPurchaseError: // the executor entry points must NOT treat this as a flat failure — the // per-account rows already own their authoritative status (partially_completed / // completed / failed) and real commitments exist, so an SQS/cron caller must ACK // the message (not redeliver) to avoid re-running the fan-out and double-buying // the accounts that already succeeded (which #1012's stable key would otherwise -// dedupe, but the contract should not depend on that second line of defence). +// dedupe, but the contract should not depend on that second line of defense). type multiAccountPartialError struct { committed int errors []string @@ -305,7 +305,7 @@ func (m *Manager) executeForAccount(ctx context.Context, baseExec *config.Purcha // The second return value is the resolved target account's ExternalID — the // provider-appropriate account identifier (AWS account number, Azure // subscription, GCP project) that the caller stamps onto purchase_history -// (#646). It is "" when no target account could be identified, signalling the +// (#646). It is "" when no target account could be identified, signaling the // caller to fall back to the ambient AWS STS identity. // // The account is taken from exec.CloudAccountID when set (plan-with-single- @@ -473,7 +473,7 @@ func (m *Manager) resolveGCPProvider(ctx context.Context, account config.CloudAc // getMaxAccountParallelism is a thin alias over the shared // execution.ConcurrencyFromEnv so the purchase manager and the scheduler -// both honour the same CUDLY_MAX_ACCOUNT_PARALLELISM override. +// both honor the same CUDLY_MAX_ACCOUNT_PARALLELISM override. func getMaxAccountParallelism() int { return execution.ConcurrencyFromEnv() } @@ -580,7 +580,7 @@ func (m *Manager) aggregatePurchaseOutcomes(ctx context.Context, exec *config.Pu } v := r.Value i := v.index - // Defence-in-depth: even with the closure's bounds check, never + // Defense-in-depth: even with the closure's bounds check, never // index past exec.Recommendations here (a future refactor that // mutates the slice between fan-out and aggregation would corrupt // this otherwise). @@ -617,7 +617,7 @@ func (m *Manager) aggregatePurchaseOutcomes(ctx context.Context, exec *config.Pu // recordHistoryAuditGap stamps exec.Error with a note that a successful // purchase's history record could not be saved (issue #621). The execution // keeps a successful status; the marker is what makes the row visible in the -// History view (which synthesises completed executions that carry an Error). +// History view (which synthesizes completed executions that carry an Error). // Appends rather than overwrites so multiple failed history writes within one // execution are all recorded. // historyAuditGapPrefix is the structured prefix stamped on exec.Error by @@ -648,7 +648,7 @@ func recordHistoryAuditGap(exec *config.PurchaseExecution, commitmentID string, // token and the provider dedupes the purchase. It falls back to ExecutionID // only for legacy rows persisted before migration 000066 (IdempotencyKey == ""); // for a single un-retried execution that fallback is identical to the pre-fix -// behaviour, and such legacy rows never gain a retry successor that could +// behavior, and such legacy rows never gain a retry successor that could // diverge (the retry handler seeds the successor's key from the predecessor's // ExecutionID in that case, preserving the match). func idempotencyLineageKey(exec *config.PurchaseExecution) string { @@ -674,7 +674,7 @@ func appendErrNote(existing, note string) string { } // normalizePurchaseSource canonicalizes exec.Source for downstream tag -// stamping. Defence-in-depth: NormalizeSource rejects anything outside +// stamping. Defense-in-depth: NormalizeSource rejects anything outside // the allowed whitelist; an unexpected value (DB tampering, future // code path) is dropped to "" rather than fed onto a cloud commitment // where it would be expensive to retract. @@ -683,7 +683,7 @@ func appendErrNote(existing, note string) string { // failing the rec over a tag-only field would abort a successful cloud // purchase, which is a worse outcome than a missing tag. Input // validation at the API write boundary (exec.Source on save) is the -// correct gate; this fallback is last-resort defence-in-depth. +// correct gate; this fallback is last-resort defense-in-depth. func (m *Manager) normalizePurchaseSource(exec *config.PurchaseExecution) string { source := exec.Source if source == "" { @@ -812,7 +812,7 @@ func (m *Manager) buildPurchaseConfirmationData(exec *config.PurchaseExecution, } // logRecCtxErr emits a diagnostic log line when a per-recommendation context has -// been cancelled or timed out. It distinguishes DeadlineExceeded (the 30s per-rec +// been canceled or timed out. It distinguishes DeadlineExceeded (the 30s per-rec // budget fired) from Canceled (a parent context stopped the execution) so that // CloudWatch filters can tell the two apart without parsing error strings. // It is a no-op when recCtxErr is nil. @@ -958,7 +958,7 @@ func (m *Manager) executeSinglePurchase(ctx context.Context, rec config.Recommen // mapServiceType maps a service string to common.ServiceType. Both the // canonical hyphenated slugs (compute, relational-db, cache, search, // data-warehouse) and the legacy AWS-only slugs (ec2, rds, elasticache, -// opensearch, redshift, memorydb) are recognised; everything else passes +// opensearch, redshift, memorydb) are recognized; everything else passes // through verbatim. Savings Plans slugs are normalised by mapSavingsPlansSlug. func (m *Manager) mapServiceType(service string) common.ServiceType { if svc, ok := mapSavingsPlansSlug(service); ok { diff --git a/internal/purchase/execution_test.go b/internal/purchase/execution_test.go index 6eb8ad387..aee211918 100644 --- a/internal/purchase/execution_test.go +++ b/internal/purchase/execution_test.go @@ -170,7 +170,7 @@ func TestManager_ExecutePurchase_WebSourcePropagates(t *testing.T) { } // TestManager_ExecutePurchase_InvalidSourceFallsBackUntagged verifies the -// NormalizeSource defence-in-depth: a DB row with an unexpected source value +// NormalizeSource defense-in-depth: a DB row with an unexpected source value // proceeds with an empty source (untagged) rather than failing the already- // approved execution or poisoning cloud tags with arbitrary strings. func TestManager_ExecutePurchase_InvalidSourceFallsBackUntagged(t *testing.T) { diff --git a/internal/purchase/finalize_revocations.go b/internal/purchase/finalize_revocations.go index 69e8b5f83..c5696233b 100644 --- a/internal/purchase/finalize_revocations.go +++ b/internal/purchase/finalize_revocations.go @@ -7,7 +7,7 @@ import ( "github.com/LeanerCloud/CUDly/pkg/logging" ) -// FinalizeResult summarises one sweep of FinalizeInFlightRevocations. +// FinalizeResult summarizes one sweep of FinalizeInFlightRevocations. // Returned for the scheduled-task handler to log and surface in CloudWatch. type FinalizeResult struct { // Found is the number of purchase_history rows with revocation_in_flight=true diff --git a/internal/purchase/manager.go b/internal/purchase/manager.go index c6879109c..c9b3f0159 100644 --- a/internal/purchase/manager.go +++ b/internal/purchase/manager.go @@ -17,12 +17,12 @@ import ( "github.com/aws/aws-sdk-go-v2/service/sts" ) -// STSClient interface for AWS STS operations +// STSClient interface for AWS STS operations. type STSClient interface { GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) } -// ManagerConfig holds configuration for the purchase manager +// ManagerConfig holds configuration for the purchase manager. type ManagerConfig struct { ConfigStore config.StoreInterface EmailSender email.SenderInterface @@ -48,7 +48,7 @@ type ManagerConfig struct { OIDCIssuerURL string } -// Manager handles purchase workflow +// Manager handles purchase workflow. type Manager struct { config config.StoreInterface email email.SenderInterface @@ -64,7 +64,7 @@ type Manager struct { oidcIssuerURL string } -// PurchaseDefaults holds default purchase settings +// PurchaseDefaults holds default purchase settings. type PurchaseDefaults struct { Term int Payment string @@ -72,7 +72,7 @@ type PurchaseDefaults struct { RampSchedule string } -// ProcessResult holds the result of processing scheduled purchases +// ProcessResult holds the result of processing scheduled purchases. type ProcessResult struct { Processed int `json:"processed"` Executed int `json:"executed"` @@ -102,12 +102,12 @@ type ProcessResult struct { // single env-configurable threshold. const staleApprovedThreshold = 15 * time.Minute -// NotificationResult holds the result of sending notifications +// NotificationResult holds the result of sending notifications. type NotificationResult struct { Notified int `json:"notified"` } -// NewManager creates a new purchase manager +// NewManager creates a new purchase manager. func NewManager(cfg ManagerConfig) *Manager { factory := cfg.ProviderFactory if factory == nil { @@ -242,7 +242,7 @@ func (m *Manager) executeAndFinalize(ctx context.Context, exec *config.PurchaseE // original execErr as the innermost %w so errors.As/errors.Is can still // reach it from callers (e.g. claimAndRedrive checking ErrAuditLoss). if execErr != nil { - execErr = fmt.Errorf("%w: terminal save failed (%v); original execution error: %w", + execErr = fmt.Errorf("%w: terminal save failed (%w); original execution error: %w", config.ErrAuditLoss, err, execErr) } else { execErr = fmt.Errorf("%w: %w", config.ErrAuditLoss, err) @@ -298,7 +298,7 @@ func allRecsSafeToRedrive(exec *config.PurchaseExecution) bool { func recIsSafeToRedrive(rec config.RecommendationRecord) bool { switch rec.Provider { case "", "aws": - // Empty provider is legacy AWS. All AWS services honour IdempotencyToken. + // Empty provider is legacy AWS. All AWS services honor IdempotencyToken. return true case "azure": // Azure savings-plans uses a timestamp-based alias name and has no @@ -450,7 +450,7 @@ func (m *Manager) safeFail(ctx context.Context, exec *config.PurchaseExecution) // without a stable ExecutionID (legacy rows) also fall through because // idempotencyLineageKey(exec) falls back to "" for them and // DeriveIdempotencyToken("", i) would produce the same token set for every -// such row. These fall through to the original behaviour: the row is atomically +// such row. These fall through to the original behavior: the row is atomically // transitioned to "failed" so it surfaces in History and can be Retry-ed by // an operator after confirming the cloud-side state. // @@ -468,7 +468,7 @@ func (m *Manager) RecoverStrandedApprovals(ctx context.Context) (int, error) { for i := range stranded { exec := &stranded[i] - // Idempotent re-drive path (issue #639): all recs honour + // Idempotent re-drive path (issue #639): all recs honor // opts.IdempotencyToken via DeriveIdempotencyToken(idempotencyLineageKey(exec), i), // so a second in-place call on the same row is a safe no-op on the // provider side. The ExecutionID must be non-empty so the lineage key @@ -498,7 +498,7 @@ func (m *Manager) RecoverStrandedApprovals(ctx context.Context) (int, error) { return recovered, nil } -// ProcessScheduledPurchases checks for and executes scheduled purchases +// ProcessScheduledPurchases checks for and executes scheduled purchases. func (m *Manager) ProcessScheduledPurchases(ctx context.Context) (*ProcessResult, error) { logging.Info("Processing scheduled purchases...") @@ -535,7 +535,7 @@ func (m *Manager) ProcessScheduledPurchases(ctx context.Context) (*ProcessResult // pre-purchase states proceed. The query already filters to // pending/notified, but a row another worker transitioned in the gap // between SELECT and here (approved/running/failed/...) must be skipped. - // The atomic claim below is the real guard; this is defence-in-depth. + // The atomic claim below is the real guard; this is defense-in-depth. if exec.Status != "pending" && exec.Status != "notified" { continue } diff --git a/internal/purchase/mocks_test.go b/internal/purchase/mocks_test.go index 0e18717d8..4da862771 100644 --- a/internal/purchase/mocks_test.go +++ b/internal/purchase/mocks_test.go @@ -14,10 +14,10 @@ import ( // MockConfigStore is the shared testify mock for config.StoreInterface. // All Fn-override fields (GetPlanAccountsFn, SavePurchaseExecutionFn, etc.) -// and default behaviours live in internal/mocks. +// and default behaviors live in internal/mocks. type MockConfigStore = mocks.MockConfigStore -// MockProviderFactory is a mock implementation of ProviderFactoryInterface +// MockProviderFactory is a mock implementation of ProviderFactoryInterface. type MockProviderFactory struct { mock.Mock } @@ -30,7 +30,7 @@ func (m *MockProviderFactory) CreateAndValidateProvider(ctx context.Context, nam return args.Get(0).(provider.Provider), args.Error(1) } -// MockProvider is a mock implementation of provider.Provider +// MockProvider is a mock implementation of provider.Provider. type MockProvider struct { mock.Mock } @@ -100,7 +100,7 @@ func (m *MockProvider) GetRecommendationsClient(ctx context.Context) (provider.R return args.Get(0).(provider.RecommendationsClient), args.Error(1) } -// MockServiceClient is a mock implementation of provider.ServiceClient +// MockServiceClient is a mock implementation of provider.ServiceClient. type MockServiceClient struct { mock.Mock } @@ -157,7 +157,7 @@ func (m *MockServiceClient) GetValidResourceTypes(ctx context.Context) ([]string return args.Get(0).([]string), args.Error(1) } -// MockEmailSender is a mock implementation of email.SenderInterface +// MockEmailSender is a mock implementation of email.SenderInterface. type MockEmailSender struct { mock.Mock } @@ -235,10 +235,10 @@ func (m *MockEmailSender) SendRegistrationDecisionNotification(_ context.Context return nil } -// Verify MockEmailSender implements email.SenderInterface +// Verify MockEmailSender implements email.SenderInterface. var _ email.SenderInterface = (*MockEmailSender)(nil) -// MockSTSClient is a mock implementation of STSClient +// MockSTSClient is a mock implementation of STSClient. type MockSTSClient struct { mock.Mock } @@ -251,11 +251,11 @@ func (m *MockSTSClient) GetCallerIdentity(ctx context.Context, params *sts.GetCa return args.Get(0).(*sts.GetCallerIdentityOutput), args.Error(1) } -// Verify MockSTSClient implements STSClient +// Verify MockSTSClient implements STSClient. var _ STSClient = (*MockSTSClient)(nil) // MockCredentialStore is a stub credentials.CredentialStore used in tests. -// All methods are no-ops; individual tests may override behaviour via fields. +// All methods are no-ops; individual tests may override behavior via fields. type MockCredentialStore struct { LoadRawFn func(ctx context.Context, accountID, credType string) ([]byte, error) } diff --git a/internal/purchase/money_path_regression_test.go b/internal/purchase/money_path_regression_test.go index 5f9d6cc73..eaaaf374b 100644 --- a/internal/purchase/money_path_regression_test.go +++ b/internal/purchase/money_path_regression_test.go @@ -113,7 +113,7 @@ func TestRetryReusesIdempotencyToken(t *testing.T) { // TestLegacyRowFallsBackToExecutionID guards the migration-000066 legacy path: // a row with no IdempotencyKey (NULL column) must derive its token from the -// ExecutionID, identical to the pre-fix behaviour for a single un-retried +// ExecutionID, identical to the pre-fix behavior for a single un-retried // execution, so old in-flight rows keep working. func TestLegacyRowFallsBackToExecutionID(t *testing.T) { legacy := &config.PurchaseExecution{ @@ -228,7 +228,7 @@ func TestSQSRedeliveryDoesNotDoubleExecute(t *testing.T) { // Both deliveries read the row as "pending" from the DB (at-least-once SQS: // pre-fix nothing CASes it to running before the cloud call, so a redelivery // re-reads a still-claimable row). Each GetExecutionByID returns a FRESH - // pending copy, faithfully modelling the real double-delivery scenario. + // pending copy, faithfully modeling the real double-delivery scenario. mockStore.On("GetExecutionByID", ctx, "exec-dup").Return(newPending(), nil).Once() mockStore.On("GetExecutionByID", ctx, "exec-dup").Return(newPending(), nil).Once() @@ -290,7 +290,7 @@ func TestMultiAccountPartialSuccessIsAcked(t *testing.T) { {ID: "acct-ok", Name: "OK", Provider: "aws", ExternalID: "111111111111", AWSAuthMode: "access_keys"}, // acct-bad uses a non-access_keys auth mode with no STS client wired, so // its credential resolution fails deterministically (committed=false) — - // modelling "one account in the fan-out fails" without racing the mock. + // modeling "one account in the fan-out fails" without racing the mock. {ID: "acct-bad", Name: "BAD", Provider: "aws", ExternalID: "222222222222", AWSAuthMode: "role_arn"}, } diff --git a/internal/purchase/notifications.go b/internal/purchase/notifications.go index fa0292b27..9f367c43d 100644 --- a/internal/purchase/notifications.go +++ b/internal/purchase/notifications.go @@ -12,7 +12,7 @@ import ( "github.com/google/uuid" ) -// SendUpcomingPurchaseNotifications sends notifications for upcoming automated purchases +// SendUpcomingPurchaseNotifications sends notifications for upcoming automated purchases. func (m *Manager) SendUpcomingPurchaseNotifications(ctx context.Context) (*NotificationResult, error) { logging.Info("Checking for upcoming purchases to notify...") @@ -35,7 +35,7 @@ func (m *Manager) SendUpcomingPurchaseNotifications(ctx context.Context) (*Notif }, nil } -// shouldNotifyPlan checks if a plan should trigger a notification +// shouldNotifyPlan checks if a plan should trigger a notification. func (m *Manager) shouldNotifyPlan(plan config.PurchasePlan) bool { if !plan.Enabled || !plan.AutoPurchase { return false @@ -61,7 +61,7 @@ func (m *Manager) shouldNotifyPlan(plan config.PurchasePlan) bool { return true } -// sendPlanNotification sends a notification for a plan and returns true if successful +// sendPlanNotification sends a notification for a plan and returns true if successful. func (m *Manager) sendPlanNotification(ctx context.Context, plan *config.PurchasePlan) bool { daysUntil := int(time.Until(*plan.NextExecutionDate).Hours() / config.HoursPerDay) logging.Infof("Sending notification for plan %s (purchase in %d days)", plan.Name, daysUntil) @@ -105,7 +105,7 @@ func (m *Manager) sendPlanNotification(ctx context.Context, plan *config.Purchas return true } -// getOrCreateExecution gets existing execution or creates new one +// getOrCreateExecution gets existing execution or creates new one. func (m *Manager) getOrCreateExecution(ctx context.Context, plan *config.PurchasePlan) (*config.PurchaseExecution, error) { // Check for existing execution for this date to prevent duplicates existing, err := m.config.GetExecutionByPlanAndDate(ctx, plan.ID, *plan.NextExecutionDate) diff --git a/internal/purchase/reaper.go b/internal/purchase/reaper.go index 9aef30b91..ef20d5138 100644 --- a/internal/purchase/reaper.go +++ b/internal/purchase/reaper.go @@ -38,12 +38,12 @@ const failedStatus = "failed" // a real executor. const DefaultReapAfter = 10 * time.Minute -// reapAfterEnvVar is the env var read by ParseReapAfterFromEnv. Centralised +// reapAfterEnvVar is the env var read by ParseReapAfterFromEnv. Centralized // so the wiring code, the tests, and future ops documentation all reference // the same name. const reapAfterEnvVar = "PURCHASE_APPROVED_REAP_AFTER" -// ReapResult summarises one sweep of ReapStuckExecutions. Returned for the +// ReapResult summarizes one sweep of ReapStuckExecutions. Returned for the // scheduled-task handler to log + surface in CloudWatch / metrics. type ReapResult struct { // Found is the number of rows the SELECT returned (i.e. stuck rows the @@ -52,7 +52,7 @@ type ReapResult struct { // Reaped is the number of rows that successfully transitioned to // "failed" via the atomic CAS. Reaped <= Found; the gap is rows the // real executor finished between the SELECT and the CAS (CAS race - // rejected the reap — correct behaviour, not an error). + // rejected the reap — correct behavior, not an error). Reaped int `json:"reaped"` // RaceLost is the number of rows where the CAS rejected the reap // because the row's status changed between the SELECT and the diff --git a/internal/reporter/reporter.go b/internal/reporter/reporter.go index dde5a56c8..5b4c80183 100644 --- a/internal/reporter/reporter.go +++ b/internal/reporter/reporter.go @@ -16,7 +16,7 @@ const ( ) // RenderTable returns a formatted table of recommendations that passed the scorer. -// Columns: Cloud, Account, Region, Service, Type, Term, Count, Est. Cost, Est. Savings, Savings%, Break-even, Commitment +// Columns: Cloud, Account, Region, Service, Type, Term, Count, Est. Cost, Est. Savings, Savings%, Break-even, Commitment. func RenderTable(result scorer.ScoredResult) string { if len(result.Passed) == 0 { return "No recommendations passed the filters.\n" @@ -52,7 +52,7 @@ func RenderTable(result scorer.ScoredResult) string { } // RenderExcluded returns a formatted table of recommendations that were filtered out. -// Columns: Cloud, Account, Region, Service, Type, Term, Savings%, FilterReason +// Columns: Cloud, Account, Region, Service, Type, Term, Savings%, FilterReason. func RenderExcluded(result scorer.ScoredResult) string { if len(result.Filtered) == 0 { return "" diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index ef389948c..eeee628b7 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -11,7 +11,7 @@ import "os" // absent on container images, local dev runs, and the long-running // server deploys (Cloud Run / Container Apps). // -// Callers that need to gate non-Lambda-only behaviour (e.g. +// Callers that need to gate non-Lambda-only behavior (e.g. // background goroutines for stale-while-revalidate) should use this // helper rather than reading the env var directly so the detection // rule stays consistent across call sites. diff --git a/internal/scheduler/permission_log_test.go b/internal/scheduler/permission_log_test.go index 25e7f429f..08fcb6030 100644 --- a/internal/scheduler/permission_log_test.go +++ b/internal/scheduler/permission_log_test.go @@ -15,7 +15,7 @@ import ( // GCP 403 / PermissionDenied must downgrade to WARN so a single // misconfigured account doesn't drown out other log signals; non-GCP // providers and non-permission errors must keep the existing ERROR -// behaviour until analogous predicates are added. +// behavior until analogous predicates are added. func TestIsAccountPermissionError(t *testing.T) { tests := []struct { name string diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index b0300f5ba..5cb587ee9 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -38,7 +38,7 @@ type STSClient interface { GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) } -// SchedulerConfig holds configuration for the scheduler +// SchedulerConfig holds configuration for the scheduler. type SchedulerConfig struct { ConfigStore config.StoreInterface PurchaseManager ManagerInterface @@ -58,7 +58,7 @@ type SchedulerConfig struct { // external_id), the ambient path stamps that account's UUID onto // every rec it returns so the approve modal shows the registered // name instead of `(ambient)`. Optional — when nil, the ambient - // path keeps its pre-fix behaviour (CloudAccountID = nil), which + // path keeps its pre-fix behavior (CloudAccountID = nil), which // preserves the truly-orphan case. STSClient STSClient @@ -83,7 +83,7 @@ type CollectResult struct { FailedProviders map[string]string `json:"failed_providers,omitempty"` } -// ManagerInterface defines the purchase manager methods used by scheduler +// ManagerInterface defines the purchase manager methods used by scheduler. type ManagerInterface interface { ProcessScheduledPurchases(ctx context.Context) (*purchase.ProcessResult, error) SendUpcomingPurchaseNotifications(ctx context.Context) (*purchase.NotificationResult, error) @@ -93,7 +93,7 @@ type ManagerInterface interface { FireScheduledDelayedPurchases(ctx context.Context) (*purchase.FireResult, error) } -// Scheduler handles scheduled tasks +// Scheduler handles scheduled tasks. type Scheduler struct { config config.StoreInterface purchase ManagerInterface @@ -125,7 +125,7 @@ type Scheduler struct { // opportunistic refresh closes the gap when users are active. const defaultCacheTTL = 6 * time.Hour -// NewScheduler creates a new scheduler +// NewScheduler creates a new scheduler. func NewScheduler(cfg SchedulerConfig) *Scheduler { factory := cfg.ProviderFactory if factory == nil { @@ -220,7 +220,7 @@ func (s *Scheduler) CollectRecommendations(ctx context.Context) (*CollectResult, // // Provider-level fan-out under errgroup. Each goroutine returns nil to the // group so a single provider's failure does not cancel siblings — matches - // the previous loop's `continue`-on-error behaviour. Per-provider results + // the previous loop's `continue`-on-error behavior. Per-provider results // are written into a map under a single mutex; the merge then walks // EnabledProviders in config order so successfulProviders ordering is // deterministic regardless of goroutine completion order. After Wait, ctx @@ -460,7 +460,7 @@ func expandSuccessfulCollects(providerName string, accountIDs []string) []config // If no accounts are registered and CUDly runs on AWS, it falls back to // ambient credentials (backward compatibility with single-account setups). // Returns the merged recommendations + the IDs of accounts that succeeded -// (or [""] for the ambient path so the caller can synthesise a nil +// (or [""] for the ambient path so the caller can synthesize a nil // CloudAccountID for eviction). func (s *Scheduler) collectAWSRecommendations(ctx context.Context, globalCfg *config.GlobalConfig) ([]config.RecommendationRecord, []string, error) { accounts := s.enabledAccounts(ctx, "aws") @@ -586,7 +586,7 @@ func fanOutPerAccount( // // Currently dispatches per provider: // - "GCP": gcpprovider.IsPermissionError (HTTP 403 / gRPC PermissionDenied) -// - other providers: false (existing ERROR behaviour preserved until +// - other providers: false (existing ERROR behavior preserved until // analogous predicates are added for AWS/Azure) func isAccountPermissionError(providerLabel string, err error) bool { if err == nil { @@ -658,7 +658,7 @@ func (s *Scheduler) resolveAmbientHostAccountID(ctx context.Context) string { // resolveAmbientHostAccountID: given the host's external identifier (subscription // ID for Azure, project ID for GCP) it checks whether a registered cloud_accounts // row exists for (provider, externalID) and returns its UUID. Returns "" on any -// error or when no row matches, preserving the pre-fix nil-tagging behaviour so +// error or when no row matches, preserving the pre-fix nil-tagging behavior so // truly-orphan deployments are unaffected. All errors are intentionally swallowed // (logged at warn) — this is a best-effort UX improvement on the ambient path // and must not break the collection. @@ -1289,7 +1289,7 @@ func marshalRecDetails(rec common.Recommendation, providerName string) []byte { return blob } -// convertRecommendations converts common.Recommendation slice to config.RecommendationRecord slice +// convertRecommendations converts common.Recommendation slice to config.RecommendationRecord slice. func (s *Scheduler) convertRecommendations(recs []common.Recommendation, providerName string) []config.RecommendationRecord { records := make([]config.RecommendationRecord, 0, len(recs)) diff --git a/internal/scheduler/scheduler_overrides_test.go b/internal/scheduler/scheduler_overrides_test.go index c01cec255..fd9493f5e 100644 --- a/internal/scheduler/scheduler_overrides_test.go +++ b/internal/scheduler/scheduler_overrides_test.go @@ -65,7 +65,7 @@ func (m *mockOverrideStore) GetAccountServiceOverride(_ context.Context, account // the effective stale TTL without panicking on the embedded MockConfigStore. // The returned RecommendationsCacheStaleHours of 24 means ListRecommendations // will use the DB-configured value (24h); the tests in this file exercise -// override/suppression logic, not TTL behaviour. +// override/suppression logic, not TTL behavior. func (m *mockOverrideStore) GetGlobalConfig(_ context.Context) (*config.GlobalConfig, error) { return &config.GlobalConfig{ RecommendationsCacheStaleHours: config.DefaultRecommendationsCacheStaleHours, diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 2a380201d..31b502c39 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -21,7 +21,7 @@ import ( "github.com/stretchr/testify/require" ) -// MockProviderFactory is a mock implementation of ProviderFactoryInterface +// MockProviderFactory is a mock implementation of ProviderFactoryInterface. type MockProviderFactory struct { mock.Mock } @@ -35,10 +35,10 @@ func (m *MockProviderFactory) CreateAndValidateProvider(ctx context.Context, nam } // MockConfigStore is the shared testify mock for config.StoreInterface. -// All default behaviours and Fn-override fields live in internal/mocks. +// All default behaviors and Fn-override fields live in internal/mocks. type MockConfigStore = mocks.MockConfigStore -// MockEmailSender is a mock implementation of email.Sender +// MockEmailSender is a mock implementation of email.Sender. type MockEmailSender struct { mock.Mock } @@ -116,7 +116,7 @@ func (m *MockEmailSender) SendRegistrationDecisionNotification(_ context.Context return nil } -// MockPurchaseManager is a mock implementation of purchase.Manager +// MockPurchaseManager is a mock implementation of purchase.Manager. type MockPurchaseManager struct { mock.Mock } @@ -303,7 +303,7 @@ func TestScheduler_CollectRecommendations_AllProviders(t *testing.T) { // factory returns an error for "azure" and successes for "aws"+"gcp", // the result still includes the successful providers and reports // azure in failedProviders. -// 3. ctx cancellation propagates: a pre-cancelled ctx surfaces as +// 3. ctx cancellation propagates: a pre-canceled ctx surfaces as // context.Canceled (not a "successful but empty" CollectResult). func TestScheduler_CollectRecommendations_ParallelProviders(t *testing.T) { t.Run("successfulProviders ordering matches config order, not goroutine completion", func(t *testing.T) { @@ -372,7 +372,7 @@ func TestScheduler_CollectRecommendations_ParallelProviders(t *testing.T) { EnabledProviders: []string{"aws", "azure", "gcp"}, } // GetGlobalConfig is called pre-fan-out. We need it to succeed so - // we reach the fan-out, where the cancelled ctx is observed. + // we reach the fan-out, where the canceled ctx is observed. mockStore.On("GetGlobalConfig", mock.Anything).Return(globalCfg, nil) mockFactory.On("CreateAndValidateProvider", mock.Anything, mock.Anything, mock.Anything). Return(nil, assert.AnError) @@ -523,7 +523,7 @@ func TestScheduler_CollectProviderRecommendations(t *testing.T) { } } -// Integration-style test for email notification +// Integration-style test for email notification. func TestScheduler_CollectRecommendations_WithNotification(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -561,7 +561,7 @@ func TestScheduler_CollectRecommendations_WithNotification(t *testing.T) { mockEmail.AssertNotCalled(t, "SendNewRecommendationsNotification") } -// Test that verifies the struct implements expected interface +// Test that verifies the struct implements expected interface. func TestScheduler_Interface(t *testing.T) { mockStore := new(MockConfigStore) @@ -577,7 +577,7 @@ func TestScheduler_Interface(t *testing.T) { assert.Equal(t, "https://test.example.com", scheduler.dashboardURL) } -// Test edge cases +// Test edge cases. func TestScheduler_CollectRecommendations_ConfigError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -596,7 +596,7 @@ func TestScheduler_CollectRecommendations_ConfigError(t *testing.T) { assert.Nil(t, result) } -// Helper function tests +// Helper function tests. func TestSchedulerConfigStoreInterface(t *testing.T) { // Verify MockConfigStore implements all required methods store := new(MockConfigStore) @@ -614,7 +614,7 @@ func TestSchedulerConfigStoreInterface(t *testing.T) { store.AssertExpectations(t) } -// Test purchase.Manager integration +// Test purchase.Manager integration. func TestSchedulerWithPurchaseManager(t *testing.T) { mockStore := new(MockConfigStore) mockPurchase := new(MockPurchaseManager) @@ -635,7 +635,7 @@ func TestSchedulerWithPurchaseManager(t *testing.T) { assert.NotNil(t, scheduler.email) } -// MockProvider is a mock implementation of provider.Provider +// MockProvider is a mock implementation of provider.Provider. type MockProvider struct { mock.Mock } @@ -692,7 +692,7 @@ func (m *MockProvider) GetRecommendationsClient(ctx context.Context) (provider.R return args.Get(0).(provider.RecommendationsClient), args.Error(1) } -// MockRecommendationsClient is a mock implementation of provider.RecommendationsClient +// MockRecommendationsClient is a mock implementation of provider.RecommendationsClient. type MockRecommendationsClient struct { mock.Mock } @@ -753,7 +753,7 @@ func TestScheduler_ListRecommendations(t *testing.T) { } // Pin the disable-sentinel contract: when GlobalConfig.RecommendationsCacheStaleHours -// is 0, ListRecommendations must serve from cache (the existing behaviour) without +// is 0, ListRecommendations must serve from cache (the existing behavior) without // kicking off a background refresh — even when the cached row is older than any // hard-coded fallback TTL. The cache-staleness path should treat 0 as "auto-refresh // disabled" rather than "stale immediately". Regression guard for PR #308. @@ -1080,7 +1080,7 @@ func TestScheduler_persistCollection_FullSuccess(t *testing.T) { mockStore.AssertNotCalled(t, "SetRecommendationsCollectionError", mock.Anything, mock.Anything) } -// Test convertRecommendations +// Test convertRecommendations. func TestScheduler_ConvertRecommendations(t *testing.T) { scheduler := &Scheduler{} @@ -1152,7 +1152,7 @@ func TestScheduler_ConvertRecommendations(t *testing.T) { assert.Equal(t, "redis", records[2].Engine) } -// Test convertRecommendations with empty input +// Test convertRecommendations with empty input. func TestScheduler_ConvertRecommendations_Empty(t *testing.T) { scheduler := &Scheduler{} @@ -1355,7 +1355,7 @@ func TestScheduler_ConvertRecommendations_IDDeterminism(t *testing.T) { assert.Equal(t, first[0].ID, second[0].ID, "ID must be deterministic across calls") } -// Test successful AWS recommendations with provider returning data +// Test successful AWS recommendations with provider returning data. func TestScheduler_CollectAWSRecommendations_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -1395,7 +1395,7 @@ func TestScheduler_CollectAWSRecommendations_Success(t *testing.T) { assert.Equal(t, "ec2", recs[0].Service) } -// Test AWS recommendations when GetRecommendationsClient fails +// Test AWS recommendations when GetRecommendationsClient fails. func TestScheduler_CollectAWSRecommendations_RecClientError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -1420,7 +1420,7 @@ func TestScheduler_CollectAWSRecommendations_RecClientError(t *testing.T) { assert.Nil(t, recs) } -// Test AWS recommendations when GetAllRecommendations fails +// Test AWS recommendations when GetAllRecommendations fails. func TestScheduler_CollectAWSRecommendations_GetRecsError(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -1447,7 +1447,7 @@ func TestScheduler_CollectAWSRecommendations_GetRecsError(t *testing.T) { assert.Nil(t, recs) } -// Test successful Azure recommendations +// Test successful Azure recommendations. func TestScheduler_CollectAzureRecommendations_Success(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -1482,7 +1482,7 @@ func TestScheduler_CollectAzureRecommendations_Success(t *testing.T) { _ = recs } -// Test GCP recommendations with no accounts — should skip gracefully +// Test GCP recommendations with no accounts — should skip gracefully. func TestScheduler_CollectGCPRecommendations_NoAccounts(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -1503,7 +1503,7 @@ func TestScheduler_CollectGCPRecommendations_NoAccounts(t *testing.T) { assert.Len(t, recs, 0) } -// Test CollectRecommendations with successful recommendations and email notification +// Test CollectRecommendations with successful recommendations and email notification. func TestScheduler_CollectRecommendations_WithSuccessfulRecs(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -1561,7 +1561,7 @@ func TestScheduler_CollectRecommendations_WithSuccessfulRecs(t *testing.T) { mockEmail.AssertCalled(t, "SendNewRecommendationsNotification", mock.Anything, mock.AnythingOfType("email.NotificationData")) } -// Test AWS recommendations fallback to GetRecommendations when GetAllRecommendations returns empty +// Test AWS recommendations fallback to GetRecommendations when GetAllRecommendations returns empty. func TestScheduler_CollectAWSRecommendations_FallbackToFiltered(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -1622,7 +1622,7 @@ func (f *fakeSTSClient) GetCallerIdentity(ctx context.Context, _ *sts.GetCallerI // slowSTSClient simulates an STS endpoint that hangs longer than the // 3-second deadline applied by resolveAmbientHostAccountID. It blocks -// until ctx is cancelled so the test can verify timeout behaviour. +// until ctx is canceled so the test can verify timeout behavior. type slowSTSClient struct{} func (s *slowSTSClient) GetCallerIdentity(ctx context.Context, _ *sts.GetCallerIdentityInput, _ ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { diff --git a/internal/secrets/aws_resolver.go b/internal/secrets/aws_resolver.go index e472f0506..6b9e166de 100644 --- a/internal/secrets/aws_resolver.go +++ b/internal/secrets/aws_resolver.go @@ -11,13 +11,13 @@ import ( "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" ) -// AWSResolver implements Resolver for AWS Secrets Manager +// AWSResolver implements Resolver for AWS Secrets Manager. type AWSResolver struct { client *secretsmanager.Client region string } -// NewAWSResolver creates a new AWS Secrets Manager resolver +// NewAWSResolver creates a new AWS Secrets Manager resolver. func NewAWSResolver(ctx context.Context, region string) (*AWSResolver, error) { // Load AWS config cfg, err := config.LoadDefaultConfig(ctx, @@ -36,7 +36,7 @@ func NewAWSResolver(ctx context.Context, region string) (*AWSResolver, error) { }, nil } -// GetSecret retrieves a secret from AWS Secrets Manager +// GetSecret retrieves a secret from AWS Secrets Manager. func (r *AWSResolver) GetSecret(ctx context.Context, secretID string) (string, error) { input := &secretsmanager.GetSecretValueInput{ SecretId: aws.String(secretID), @@ -55,7 +55,7 @@ func (r *AWSResolver) GetSecret(ctx context.Context, secretID string) (string, e return "", fmt.Errorf("secret %s has no string value (binary secrets not supported by this resolver)", secretID) } -// PutSecret creates or updates a secret value in AWS Secrets Manager +// PutSecret creates or updates a secret value in AWS Secrets Manager. func (r *AWSResolver) PutSecret(ctx context.Context, secretID string, value string) error { input := &secretsmanager.PutSecretValueInput{ SecretId: aws.String(secretID), @@ -70,7 +70,7 @@ func (r *AWSResolver) PutSecret(ctx context.Context, secretID string, value stri return nil } -// GetSecretJSON retrieves and parses a JSON secret +// GetSecretJSON retrieves and parses a JSON secret. func (r *AWSResolver) GetSecretJSON(ctx context.Context, secretID string) (map[string]any, error) { secretString, err := r.GetSecret(ctx, secretID) if err != nil { @@ -85,7 +85,7 @@ func (r *AWSResolver) GetSecretJSON(ctx context.Context, secretID string) (map[s return result, nil } -// ListSecrets lists secrets in AWS Secrets Manager +// ListSecrets lists secrets in AWS Secrets Manager. func (r *AWSResolver) ListSecrets(ctx context.Context, filter string) ([]string, error) { input := &secretsmanager.ListSecretsInput{} @@ -118,7 +118,7 @@ func (r *AWSResolver) ListSecrets(ctx context.Context, filter string) ([]string, return secrets, nil } -// Close cleans up resources (no-op for AWS) +// Close cleans up resources (no-op for AWS). func (r *AWSResolver) Close() error { return nil } diff --git a/internal/secrets/aws_resolver_coverage_test.go b/internal/secrets/aws_resolver_coverage_test.go index f643bab34..34896c237 100644 --- a/internal/secrets/aws_resolver_coverage_test.go +++ b/internal/secrets/aws_resolver_coverage_test.go @@ -9,7 +9,7 @@ import ( ) // TestAWSResolver_DirectMethods tests the actual AWSResolver methods -// These tests exercise the real code paths but may skip if AWS credentials are unavailable +// These tests exercise the real code paths but may skip if AWS credentials are unavailable. func TestAWSResolver_DirectMethods(t *testing.T) { ctx := context.Background() @@ -25,7 +25,7 @@ func TestAWSResolver_DirectMethods(t *testing.T) { assert.NotNil(t, resolver.client) } -// TestAWSResolver_GetSecret_NonExistent tests getting a non-existent secret +// TestAWSResolver_GetSecret_NonExistent tests getting a non-existent secret. func TestAWSResolver_GetSecret_NonExistent(t *testing.T) { ctx := context.Background() @@ -43,7 +43,7 @@ func TestAWSResolver_GetSecret_NonExistent(t *testing.T) { assert.Contains(t, err.Error(), "failed to get secret") } -// TestAWSResolver_GetSecretJSON_NonExistent tests getting a non-existent JSON secret +// TestAWSResolver_GetSecretJSON_NonExistent tests getting a non-existent JSON secret. func TestAWSResolver_GetSecretJSON_NonExistent(t *testing.T) { ctx := context.Background() @@ -60,7 +60,7 @@ func TestAWSResolver_GetSecretJSON_NonExistent(t *testing.T) { assert.Error(t, err) } -// TestAWSResolver_ListSecrets_WithFilter_Coverage_Direct tests listing secrets with a filter using direct resolver +// TestAWSResolver_ListSecrets_WithFilter_Coverage_Direct tests listing secrets with a filter using direct resolver. func TestAWSResolver_ListSecrets_WithFilter_Coverage_Direct(t *testing.T) { ctx := context.Background() @@ -83,7 +83,7 @@ func TestAWSResolver_ListSecrets_WithFilter_Coverage_Direct(t *testing.T) { } } -// TestAWSResolver_ListSecrets_NoFilter tests listing all secrets +// TestAWSResolver_ListSecrets_NoFilter tests listing all secrets. func TestAWSResolver_ListSecrets_NoFilter(t *testing.T) { ctx := context.Background() @@ -102,7 +102,7 @@ func TestAWSResolver_ListSecrets_NoFilter(t *testing.T) { } } -// TestAWSResolver_Close_Idempotent tests that Close can be called multiple times +// TestAWSResolver_Close_Idempotent tests that Close can be called multiple times. func TestAWSResolver_Close_Idempotent(t *testing.T) { ctx := context.Background() @@ -119,7 +119,7 @@ func TestAWSResolver_Close_Idempotent(t *testing.T) { assert.NoError(t, err2) } -// TestAWSResolver_DifferentRegions tests creating resolvers for different regions +// TestAWSResolver_DifferentRegions tests creating resolvers for different regions. func TestAWSResolver_DifferentRegions(t *testing.T) { ctx := context.Background() @@ -139,7 +139,7 @@ func TestAWSResolver_DifferentRegions(t *testing.T) { } } -// TestAWSResolver_ContextHandling tests context handling in AWS resolver +// TestAWSResolver_ContextHandling tests context handling in AWS resolver. func TestAWSResolver_ContextHandling(t *testing.T) { ctx := context.Background() @@ -149,17 +149,17 @@ func TestAWSResolver_ContextHandling(t *testing.T) { } defer resolver.Close() - // Test with cancelled context - should fail + // Test with canceled context - should fail cancelledCtx, cancel := context.WithCancel(context.Background()) cancel() - // GetSecret with cancelled context + // GetSecret with canceled context _, err = resolver.GetSecret(cancelledCtx, "test-secret") - // Should fail due to cancelled context or other error + // Should fail due to canceled context or other error assert.Error(t, err) } -// TestNewAWSResolver_InvalidRegion tests creation with unusual region values +// TestNewAWSResolver_InvalidRegion tests creation with unusual region values. func TestNewAWSResolver_InvalidRegion(t *testing.T) { ctx := context.Background() @@ -177,7 +177,7 @@ func TestNewAWSResolver_InvalidRegion(t *testing.T) { resolver.Close() } -// TestAWSResolver_SecretWithBinaryData tests getting a secret that might have binary data +// TestAWSResolver_SecretWithBinaryData tests getting a secret that might have binary data. func TestAWSResolver_SecretWithBinaryData(t *testing.T) { ctx := context.Background() @@ -195,7 +195,7 @@ func TestAWSResolver_SecretWithBinaryData(t *testing.T) { assert.Error(t, err) } -// TestAWSResolver_EmptySecretID tests getting a secret with empty ID +// TestAWSResolver_EmptySecretID tests getting a secret with empty ID. func TestAWSResolver_EmptySecretID(t *testing.T) { ctx := context.Background() @@ -210,7 +210,7 @@ func TestAWSResolver_EmptySecretID(t *testing.T) { assert.Error(t, err) } -// TestAWSResolver_SpecialCharactersInSecretID tests secret IDs with special characters +// TestAWSResolver_SpecialCharactersInSecretID tests secret IDs with special characters. func TestAWSResolver_SpecialCharactersInSecretID(t *testing.T) { ctx := context.Background() @@ -237,7 +237,7 @@ func TestAWSResolver_SpecialCharactersInSecretID(t *testing.T) { } } -// TestTestableAWSResolver_GetSecretJSON_RealMethod tests the GetSecretJSON error propagation +// TestTestableAWSResolver_GetSecretJSON_RealMethod tests the GetSecretJSON error propagation. func TestTestableAWSResolver_GetSecretJSON_RealMethod(t *testing.T) { ctx := context.Background() diff --git a/internal/secrets/aws_resolver_test.go b/internal/secrets/aws_resolver_test.go index bb2616d88..62d1cc5bb 100644 --- a/internal/secrets/aws_resolver_test.go +++ b/internal/secrets/aws_resolver_test.go @@ -15,13 +15,13 @@ import ( ) // SecretsManagerAPI defines the interface for AWS Secrets Manager operations -// that we need to mock +// that we need to mock. type SecretsManagerAPI interface { GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) ListSecrets(ctx context.Context, params *secretsmanager.ListSecretsInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.ListSecretsOutput, error) } -// MockSecretsManagerClient is a mock implementation of the Secrets Manager client +// MockSecretsManagerClient is a mock implementation of the Secrets Manager client. type MockSecretsManagerClient struct { mock.Mock } @@ -42,7 +42,7 @@ func (m *MockSecretsManagerClient) ListSecrets(ctx context.Context, params *secr return args.Get(0).(*secretsmanager.ListSecretsOutput), args.Error(1) } -// testableAWSResolver wraps AWSResolver to allow injecting a mock client +// testableAWSResolver wraps AWSResolver to allow injecting a mock client. type testableAWSResolver struct { mockClient SecretsManagerAPI region string diff --git a/internal/secrets/azure_resolver.go b/internal/secrets/azure_resolver.go index 8eb71e1f9..c7aca0e31 100644 --- a/internal/secrets/azure_resolver.go +++ b/internal/secrets/azure_resolver.go @@ -10,13 +10,13 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets" ) -// AzureResolver implements Resolver for Azure Key Vault +// AzureResolver implements Resolver for Azure Key Vault. type AzureResolver struct { client *azsecrets.Client vaultURL string } -// NewAzureResolver creates a new Azure Key Vault resolver +// NewAzureResolver creates a new Azure Key Vault resolver. func NewAzureResolver(ctx context.Context, vaultURL string) (*AzureResolver, error) { // Create a credential using DefaultAzureCredential. // Note: azidentity.NewDefaultAzureCredential does not accept a context parameter, @@ -39,7 +39,7 @@ func NewAzureResolver(ctx context.Context, vaultURL string) (*AzureResolver, err }, nil } -// GetSecret retrieves a secret from Azure Key Vault +// GetSecret retrieves a secret from Azure Key Vault. func (r *AzureResolver) GetSecret(ctx context.Context, secretID string) (string, error) { // Get the latest version of the secret resp, err := r.client.GetSecret(ctx, secretID, "", nil) @@ -54,7 +54,7 @@ func (r *AzureResolver) GetSecret(ctx context.Context, secretID string) (string, return *resp.Value, nil } -// PutSecret creates or updates a secret in Azure Key Vault +// PutSecret creates or updates a secret in Azure Key Vault. func (r *AzureResolver) PutSecret(ctx context.Context, secretID string, value string) error { params := azsecrets.SetSecretParameters{ Value: &value, @@ -68,7 +68,7 @@ func (r *AzureResolver) PutSecret(ctx context.Context, secretID string, value st return nil } -// GetSecretJSON retrieves and parses a JSON secret +// GetSecretJSON retrieves and parses a JSON secret. func (r *AzureResolver) GetSecretJSON(ctx context.Context, secretID string) (map[string]any, error) { secretString, err := r.GetSecret(ctx, secretID) if err != nil { @@ -83,7 +83,7 @@ func (r *AzureResolver) GetSecretJSON(ctx context.Context, secretID string) (map return result, nil } -// ListSecrets lists secrets in Azure Key Vault +// ListSecrets lists secrets in Azure Key Vault. func (r *AzureResolver) ListSecrets(ctx context.Context, filter string) ([]string, error) { secrets := make([]string, 0) @@ -111,7 +111,7 @@ func (r *AzureResolver) ListSecrets(ctx context.Context, filter string) ([]strin return secrets, nil } -// Close cleans up resources (no-op for Azure) +// Close cleans up resources (no-op for Azure). func (r *AzureResolver) Close() error { return nil } diff --git a/internal/secrets/azure_resolver_coverage_test.go b/internal/secrets/azure_resolver_coverage_test.go index 29eec772c..be8fe9e55 100644 --- a/internal/secrets/azure_resolver_coverage_test.go +++ b/internal/secrets/azure_resolver_coverage_test.go @@ -10,7 +10,7 @@ import ( ) // TestAzureResolver_DirectMethods tests the actual AzureResolver methods -// These tests exercise the real code paths but may skip if Azure credentials are unavailable +// These tests exercise the real code paths but may skip if Azure credentials are unavailable. func TestAzureResolver_DirectMethods(t *testing.T) { ctx := context.Background() @@ -26,7 +26,7 @@ func TestAzureResolver_DirectMethods(t *testing.T) { assert.NotNil(t, resolver.client) } -// TestAzureResolver_GetSecret_NonExistent tests getting a non-existent secret +// TestAzureResolver_GetSecret_NonExistent tests getting a non-existent secret. func TestAzureResolver_GetSecret_NonExistent(t *testing.T) { ctx := context.Background() @@ -44,7 +44,7 @@ func TestAzureResolver_GetSecret_NonExistent(t *testing.T) { assert.Contains(t, err.Error(), "failed to get secret") } -// TestAzureResolver_GetSecretJSON_NonExistent tests getting a non-existent JSON secret +// TestAzureResolver_GetSecretJSON_NonExistent tests getting a non-existent JSON secret. func TestAzureResolver_GetSecretJSON_NonExistent(t *testing.T) { ctx := context.Background() @@ -62,7 +62,7 @@ func TestAzureResolver_GetSecretJSON_NonExistent(t *testing.T) { assert.Nil(t, result) } -// TestAzureResolver_ListSecrets tests listing secrets +// TestAzureResolver_ListSecrets tests listing secrets. func TestAzureResolver_ListSecrets(t *testing.T) { ctx := context.Background() @@ -83,7 +83,7 @@ func TestAzureResolver_ListSecrets(t *testing.T) { } } -// TestAzureResolver_ListSecrets_WithFilter_Coverage_Direct tests listing secrets with a filter using direct resolver +// TestAzureResolver_ListSecrets_WithFilter_Coverage_Direct tests listing secrets with a filter using direct resolver. func TestAzureResolver_ListSecrets_WithFilter_Coverage_Direct(t *testing.T) { ctx := context.Background() @@ -103,7 +103,7 @@ func TestAzureResolver_ListSecrets_WithFilter_Coverage_Direct(t *testing.T) { } } -// TestAzureResolver_Close_Idempotent tests that Close can be called multiple times +// TestAzureResolver_Close_Idempotent tests that Close can be called multiple times. func TestAzureResolver_Close_Idempotent(t *testing.T) { ctx := context.Background() @@ -120,7 +120,7 @@ func TestAzureResolver_Close_Idempotent(t *testing.T) { assert.NoError(t, err2) } -// TestAzureResolver_DifferentVaultURLs tests creating resolvers for different vaults +// TestAzureResolver_DifferentVaultURLs tests creating resolvers for different vaults. func TestAzureResolver_DifferentVaultURLs(t *testing.T) { ctx := context.Background() @@ -144,7 +144,7 @@ func TestAzureResolver_DifferentVaultURLs(t *testing.T) { } } -// TestAzureResolver_ContextHandling tests context handling in Azure resolver +// TestAzureResolver_ContextHandling tests context handling in Azure resolver. func TestAzureResolver_ContextHandling(t *testing.T) { ctx := context.Background() @@ -154,17 +154,17 @@ func TestAzureResolver_ContextHandling(t *testing.T) { } defer resolver.Close() - // Test with cancelled context + // Test with canceled context cancelledCtx, cancel := context.WithCancel(context.Background()) cancel() - // GetSecret with cancelled context + // GetSecret with canceled context _, err = resolver.GetSecret(cancelledCtx, "test-secret") // Should fail assert.Error(t, err) } -// TestAzureResolver_EmptySecretID tests getting a secret with empty ID +// TestAzureResolver_EmptySecretID tests getting a secret with empty ID. func TestAzureResolver_EmptySecretID(t *testing.T) { ctx := context.Background() @@ -179,7 +179,7 @@ func TestAzureResolver_EmptySecretID(t *testing.T) { assert.Error(t, err) } -// TestAzureResolver_SpecialCharactersInSecretID tests secret IDs with special characters +// TestAzureResolver_SpecialCharactersInSecretID tests secret IDs with special characters. func TestAzureResolver_SpecialCharactersInSecretID(t *testing.T) { ctx := context.Background() @@ -205,7 +205,7 @@ func TestAzureResolver_SpecialCharactersInSecretID(t *testing.T) { } } -// TestAzureResolver_GetSecretJSON_RealMethod tests the GetSecretJSON error propagation +// TestAzureResolver_GetSecretJSON_RealMethod tests the GetSecretJSON error propagation. func TestAzureResolver_GetSecretJSON_RealMethod(t *testing.T) { ctx := context.Background() @@ -222,7 +222,7 @@ func TestAzureResolver_GetSecretJSON_RealMethod(t *testing.T) { assert.Nil(t, result) } -// TestAzureResolver_VaultURLFormat tests various vault URL formats +// TestAzureResolver_VaultURLFormat tests various vault URL formats. func TestAzureResolver_VaultURLFormat(t *testing.T) { // The Azure resolver stores the vault URL as-is resolver := &AzureResolver{ @@ -234,14 +234,14 @@ func TestAzureResolver_VaultURLFormat(t *testing.T) { assert.Equal(t, "https://my-vault.vault.azure.net/", resolver.vaultURL) } -// TestMockSecretID_VersionMethod tests the Version method of MockSecretID +// TestMockSecretID_VersionMethod tests the Version method of MockSecretID. func TestMockSecretID_VersionMethod(t *testing.T) { id := MockSecretID("https://myvault.vault.azure.net/secrets/my-secret") version := id.Version() assert.Equal(t, "", version) } -// TestMockSecretID_EdgeCases tests edge cases in MockSecretID.Name() +// TestMockSecretID_EdgeCases tests edge cases in MockSecretID.Name(). func TestMockSecretID_EdgeCases(t *testing.T) { tests := []struct { name string @@ -283,7 +283,7 @@ func TestMockSecretID_EdgeCases(t *testing.T) { } } -// TestMockAzureSecretsPager_MultiplePages tests the mock pager with multiple pages +// TestMockAzureSecretsPager_MultiplePages tests the mock pager with multiple pages. func TestMockAzureSecretsPager_MultiplePages(t *testing.T) { pager := &MockAzureSecretsPager{ pages: [][]*azsecrets.SecretItem{ @@ -303,7 +303,7 @@ func TestMockAzureSecretsPager_MultiplePages(t *testing.T) { assert.False(t, pager.More()) } -// TestMockAzureSecretsPager_Error tests the mock pager error handling +// TestMockAzureSecretsPager_Error tests the mock pager error handling. func TestMockAzureSecretsPager_Error(t *testing.T) { pager := &MockAzureSecretsPager{ err: assert.AnError, @@ -316,7 +316,7 @@ func TestMockAzureSecretsPager_Error(t *testing.T) { assert.Error(t, err) } -// TestMockAzureSecretsPager_EmptyPages tests the mock pager with empty pages slice +// TestMockAzureSecretsPager_EmptyPages tests the mock pager with empty pages slice. func TestMockAzureSecretsPager_EmptyPages(t *testing.T) { pager := &MockAzureSecretsPager{ pages: [][]*azsecrets.SecretItem{}, @@ -325,7 +325,7 @@ func TestMockAzureSecretsPager_EmptyPages(t *testing.T) { assert.False(t, pager.More()) } -// TestMockAzureSecretsPager_NoMorePages tests NextPage when there are no more pages +// TestMockAzureSecretsPager_NoMorePages tests NextPage when there are no more pages. func TestMockAzureSecretsPager_NoMorePages(t *testing.T) { pager := &MockAzureSecretsPager{ pages: [][]*azsecrets.SecretItem{{}}, diff --git a/internal/secrets/azure_resolver_test.go b/internal/secrets/azure_resolver_test.go index 69fe2dfa2..c135714d1 100644 --- a/internal/secrets/azure_resolver_test.go +++ b/internal/secrets/azure_resolver_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -// MockSecretID implements azsecrets.ID interface for testing +// MockSecretID implements azsecrets.ID interface for testing. type MockSecretID string func (m MockSecretID) Name() string { @@ -30,7 +30,7 @@ func (m MockSecretID) Version() string { return "" } -// MockAzureSecretsPager simulates the Azure secrets pager +// MockAzureSecretsPager simulates the Azure secrets pager. type MockAzureSecretsPager struct { pages [][]*azsecrets.SecretItem currentPage int @@ -62,7 +62,7 @@ func (m *MockAzureSecretsPager) NextPage(ctx context.Context) (azsecrets.ListSec }, nil } -// MockAzureSecretsClient is a mock implementation of the Azure Key Vault secrets client +// MockAzureSecretsClient is a mock implementation of the Azure Key Vault secrets client. type MockAzureSecretsClient struct { mock.Mock } @@ -77,7 +77,7 @@ func (m *MockAzureSecretsClient) NewListSecretsPager(options *azsecrets.ListSecr return args.Get(0).(*MockAzureSecretsPager) } -// testableAzureResolver wraps AzureResolver to allow injecting a mock client +// testableAzureResolver wraps AzureResolver to allow injecting a mock client. type testableAzureResolver struct { mockClient *MockAzureSecretsClient vaultURL string diff --git a/internal/secrets/constructor_error_test.go b/internal/secrets/constructor_error_test.go index 39476ba53..037e7769f 100644 --- a/internal/secrets/constructor_error_test.go +++ b/internal/secrets/constructor_error_test.go @@ -137,12 +137,12 @@ func TestNewAzureResolver_ConfigError(t *testing.T) { } } -// TestNewAWSResolver_CancelledContext tests constructor with cancelled context +// TestNewAWSResolver_CancelledContext tests constructor with canceled context func TestNewAWSResolver_CancelledContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately - // AWS SDK might still succeed with cancelled context for config loading + // AWS SDK might still succeed with canceled context for config loading resolver, err := NewAWSResolver(ctx, "us-east-1") if err != nil { @@ -153,14 +153,14 @@ func TestNewAWSResolver_CancelledContext(t *testing.T) { } } -// TestNewGCPResolver_CancelledContext tests constructor with cancelled context +// TestNewGCPResolver_CancelledContext tests constructor with canceled context func TestNewGCPResolver_CancelledContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() resolver, err := NewGCPResolver(ctx, "test-project") - // Cancelled context might cause client creation to fail + // Canceled context might cause client creation to fail if err != nil { assert.Nil(t, resolver) } else { @@ -170,7 +170,7 @@ func TestNewGCPResolver_CancelledContext(t *testing.T) { } } -// TestNewAzureResolver_CancelledContext tests constructor with cancelled context +// TestNewAzureResolver_CancelledContext tests constructor with canceled context func TestNewAzureResolver_CancelledContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() diff --git a/internal/secrets/env_resolver.go b/internal/secrets/env_resolver.go index 097877f28..f18432361 100644 --- a/internal/secrets/env_resolver.go +++ b/internal/secrets/env_resolver.go @@ -9,10 +9,10 @@ import ( ) // EnvResolver implements Resolver using environment variables -// This is useful for local development where secrets are stored as env vars +// This is useful for local development where secrets are stored as env vars. type EnvResolver struct{} -// NewEnvResolver creates a new environment variable resolver +// NewEnvResolver creates a new environment variable resolver. func NewEnvResolver() *EnvResolver { return &EnvResolver{} } @@ -36,7 +36,7 @@ func (r *EnvResolver) PutSecret(_ context.Context, _ string, _ string) error { return fmt.Errorf("EnvResolver does not support writing secrets") } -// GetSecretJSON retrieves and parses a JSON secret from environment variable +// GetSecretJSON retrieves and parses a JSON secret from environment variable. func (r *EnvResolver) GetSecretJSON(ctx context.Context, secretID string) (map[string]any, error) { secretString, err := r.GetSecret(ctx, secretID) if err != nil { @@ -51,7 +51,7 @@ func (r *EnvResolver) GetSecretJSON(ctx context.Context, secretID string) (map[s return result, nil } -// ListSecrets lists all environment variables matching the filter (prefix) +// ListSecrets lists all environment variables matching the filter (prefix). func (r *EnvResolver) ListSecrets(ctx context.Context, filter string) ([]string, error) { secrets := make([]string, 0) @@ -74,7 +74,7 @@ func (r *EnvResolver) ListSecrets(ctx context.Context, filter string) ([]string, return secrets, nil } -// Close cleans up resources (no-op for environment variables) +// Close cleans up resources (no-op for environment variables). func (r *EnvResolver) Close() error { return nil } diff --git a/internal/secrets/env_resolver_coverage_test.go b/internal/secrets/env_resolver_coverage_test.go index c117b7d43..09bb0e142 100644 --- a/internal/secrets/env_resolver_coverage_test.go +++ b/internal/secrets/env_resolver_coverage_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -// TestEnvResolver_ListSecrets_MalformedEnvVar tests handling of malformed env vars +// TestEnvResolver_ListSecrets_MalformedEnvVar tests handling of malformed env vars. func TestEnvResolver_ListSecrets_MalformedEnvVar(t *testing.T) { resolver := NewEnvResolver() ctx := context.Background() @@ -26,7 +26,7 @@ func TestEnvResolver_ListSecrets_MalformedEnvVar(t *testing.T) { assert.Contains(t, result, testPrefix+"VALID") } -// TestEnvResolver_ListSecrets_EmptyFilter tests listing all env vars +// TestEnvResolver_ListSecrets_EmptyFilter tests listing all env vars. func TestEnvResolver_ListSecrets_EmptyFilter(t *testing.T) { resolver := NewEnvResolver() ctx := context.Background() @@ -45,7 +45,7 @@ func TestEnvResolver_ListSecrets_EmptyFilter(t *testing.T) { assert.Greater(t, len(result), 1) } -// TestEnvResolver_ListSecrets_ExactMatch tests filter matching behavior +// TestEnvResolver_ListSecrets_ExactMatch tests filter matching behavior. func TestEnvResolver_ListSecrets_ExactMatch(t *testing.T) { resolver := NewEnvResolver() ctx := context.Background() @@ -72,7 +72,7 @@ func TestEnvResolver_ListSecrets_ExactMatch(t *testing.T) { assert.NotContains(t, result, testPrefix+"TWO") } -// TestEnvResolver_GetSecret_SpecialValues tests getting secrets with special values +// TestEnvResolver_GetSecret_SpecialValues tests getting secrets with special values. func TestEnvResolver_GetSecret_SpecialValues(t *testing.T) { resolver := NewEnvResolver() ctx := context.Background() @@ -146,7 +146,7 @@ func TestEnvResolver_GetSecret_SpecialValues(t *testing.T) { } } -// TestEnvResolver_GetSecretJSON_VariousJSONTypes tests parsing various JSON types +// TestEnvResolver_GetSecretJSON_VariousJSONTypes tests parsing various JSON types. func TestEnvResolver_GetSecretJSON_VariousJSONTypes(t *testing.T) { resolver := NewEnvResolver() ctx := context.Background() @@ -277,7 +277,7 @@ func TestEnvResolver_GetSecretJSON_VariousJSONTypes(t *testing.T) { } } -// TestEnvResolver_Close_Multiple tests calling Close multiple times +// TestEnvResolver_Close_Multiple tests calling Close multiple times. func TestEnvResolver_Close_Multiple(t *testing.T) { resolver := NewEnvResolver() @@ -292,7 +292,7 @@ func TestEnvResolver_Close_Multiple(t *testing.T) { assert.NoError(t, err3) } -// TestEnvResolver_ConcurrentAccess tests concurrent access to resolver +// TestEnvResolver_ConcurrentAccess tests concurrent access to resolver. func TestEnvResolver_ConcurrentAccess(t *testing.T) { resolver := NewEnvResolver() ctx := context.Background() @@ -319,7 +319,7 @@ func TestEnvResolver_ConcurrentAccess(t *testing.T) { } } -// TestEnvResolver_ListSecrets_LargeNumberOfVars tests with many env vars +// TestEnvResolver_ListSecrets_LargeNumberOfVars tests with many env vars. func TestEnvResolver_ListSecrets_LargeNumberOfVars(t *testing.T) { resolver := NewEnvResolver() ctx := context.Background() @@ -347,7 +347,7 @@ func TestEnvResolver_ListSecrets_LargeNumberOfVars(t *testing.T) { assert.NotEmpty(t, result) } -// TestEnvResolver_GetSecret_CaseSensitivity tests case sensitivity +// TestEnvResolver_GetSecret_CaseSensitivity tests case sensitivity. func TestEnvResolver_GetSecret_CaseSensitivity(t *testing.T) { resolver := NewEnvResolver() ctx := context.Background() @@ -373,7 +373,7 @@ func TestEnvResolver_GetSecret_CaseSensitivity(t *testing.T) { require.Error(t, err3) } -// TestEnvResolver_ListSecrets_FilterCaseSensitivity tests filter case sensitivity +// TestEnvResolver_ListSecrets_FilterCaseSensitivity tests filter case sensitivity. func TestEnvResolver_ListSecrets_FilterCaseSensitivity(t *testing.T) { resolver := NewEnvResolver() ctx := context.Background() @@ -394,7 +394,7 @@ func TestEnvResolver_ListSecrets_FilterCaseSensitivity(t *testing.T) { assert.Contains(t, lowerResult, "cudly_filter_lower") } -// TestEnvResolver_GetSecretJSON_LargeJSON tests parsing large JSON +// TestEnvResolver_GetSecretJSON_LargeJSON tests parsing large JSON. func TestEnvResolver_GetSecretJSON_LargeJSON(t *testing.T) { resolver := NewEnvResolver() ctx := context.Background() diff --git a/internal/secrets/gcp_resolver.go b/internal/secrets/gcp_resolver.go index 52476f85d..c297bc36f 100644 --- a/internal/secrets/gcp_resolver.go +++ b/internal/secrets/gcp_resolver.go @@ -13,13 +13,13 @@ import ( "google.golang.org/api/iterator" ) -// GCPResolver implements Resolver for GCP Secret Manager +// GCPResolver implements Resolver for GCP Secret Manager. type GCPResolver struct { client *secretmanager.Client projectID string } -// NewGCPResolver creates a new GCP Secret Manager resolver +// NewGCPResolver creates a new GCP Secret Manager resolver. func NewGCPResolver(ctx context.Context, projectID string) (*GCPResolver, error) { // Create Secret Manager client client, err := secretmanager.NewClient(ctx) @@ -33,7 +33,7 @@ func NewGCPResolver(ctx context.Context, projectID string) (*GCPResolver, error) }, nil } -// GetSecret retrieves a secret from GCP Secret Manager +// GetSecret retrieves a secret from GCP Secret Manager. func (r *GCPResolver) GetSecret(ctx context.Context, secretID string) (string, error) { // Build the resource name for the latest version. // secretID may be a short name ("my-secret") or a full resource name @@ -89,7 +89,7 @@ func (r *GCPResolver) PutSecret(ctx context.Context, secretID string, value stri return nil } -// GetSecretJSON retrieves and parses a JSON secret +// GetSecretJSON retrieves and parses a JSON secret. func (r *GCPResolver) GetSecretJSON(ctx context.Context, secretID string) (map[string]any, error) { secretString, err := r.GetSecret(ctx, secretID) if err != nil { @@ -106,7 +106,7 @@ func (r *GCPResolver) GetSecretJSON(ctx context.Context, secretID string) (map[s // ListSecrets lists secrets in GCP Secret Manager whose short name has the given // prefix. The Resolver interface documents GCP as using prefix matching -// (strings.HasPrefix); to honour that contract the filter is applied client-side +// (strings.HasPrefix); to honor that contract the filter is applied client-side // after listing, because GCP's ListSecrets.Filter field accepts an expression // language (not a simple name prefix) and the two semantics are incompatible. // The API request is sent without a Filter so we always get the full list; for @@ -140,7 +140,7 @@ func (r *GCPResolver) ListSecrets(ctx context.Context, filter string) ([]string, return secrets, nil } -// Close cleans up resources +// Close cleans up resources. func (r *GCPResolver) Close() error { return r.client.Close() } diff --git a/internal/secrets/gcp_resolver_coverage_test.go b/internal/secrets/gcp_resolver_coverage_test.go index e19b831aa..87eb49f43 100644 --- a/internal/secrets/gcp_resolver_coverage_test.go +++ b/internal/secrets/gcp_resolver_coverage_test.go @@ -2,14 +2,17 @@ package secrets import ( "context" + "errors" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" ) // TestGCPResolver_DirectMethods tests the actual GCPResolver methods -// These tests exercise the real code paths but may skip if GCP credentials are unavailable +// These tests exercise the real code paths but may skip if GCP credentials are unavailable. func TestGCPResolver_DirectMethods(t *testing.T) { ctx := context.Background() @@ -25,7 +28,7 @@ func TestGCPResolver_DirectMethods(t *testing.T) { assert.NotNil(t, resolver.client) } -// TestGCPResolver_GetSecret_NonExistent tests getting a non-existent secret +// TestGCPResolver_GetSecret_NonExistent tests getting a non-existent secret. func TestGCPResolver_GetSecret_NonExistent(t *testing.T) { ctx := context.Background() @@ -43,7 +46,7 @@ func TestGCPResolver_GetSecret_NonExistent(t *testing.T) { assert.Contains(t, err.Error(), "failed to access secret") } -// TestGCPResolver_GetSecretJSON_NonExistent tests getting a non-existent JSON secret +// TestGCPResolver_GetSecretJSON_NonExistent tests getting a non-existent JSON secret. func TestGCPResolver_GetSecretJSON_NonExistent(t *testing.T) { ctx := context.Background() @@ -61,7 +64,7 @@ func TestGCPResolver_GetSecretJSON_NonExistent(t *testing.T) { assert.Nil(t, result) } -// TestGCPResolver_ListSecrets tests listing secrets +// TestGCPResolver_ListSecrets tests listing secrets. func TestGCPResolver_ListSecrets(t *testing.T) { ctx := context.Background() @@ -82,7 +85,7 @@ func TestGCPResolver_ListSecrets(t *testing.T) { } } -// TestGCPResolver_ListSecrets_WithFilter_Coverage_Direct tests listing secrets with a filter using direct resolver +// TestGCPResolver_ListSecrets_WithFilter_Coverage_Direct tests listing secrets with a filter using direct resolver. func TestGCPResolver_ListSecrets_WithFilter_Coverage_Direct(t *testing.T) { ctx := context.Background() @@ -101,7 +104,7 @@ func TestGCPResolver_ListSecrets_WithFilter_Coverage_Direct(t *testing.T) { } } -// TestGCPResolver_Close_Idempotent tests that Close can be called multiple times +// TestGCPResolver_Close_Idempotent tests that Close can be called multiple times. func TestGCPResolver_Close_Idempotent(t *testing.T) { ctx := context.Background() @@ -120,7 +123,7 @@ func TestGCPResolver_Close_Idempotent(t *testing.T) { _ = resolver.Close() } -// TestGCPResolver_DifferentProjectIDs tests creating resolvers for different projects +// TestGCPResolver_DifferentProjectIDs tests creating resolvers for different projects. func TestGCPResolver_DifferentProjectIDs(t *testing.T) { ctx := context.Background() @@ -140,7 +143,7 @@ func TestGCPResolver_DifferentProjectIDs(t *testing.T) { } } -// TestGCPResolver_ContextHandling tests context handling in GCP resolver +// TestGCPResolver_ContextHandling tests context handling in GCP resolver. func TestGCPResolver_ContextHandling(t *testing.T) { ctx := context.Background() @@ -150,17 +153,23 @@ func TestGCPResolver_ContextHandling(t *testing.T) { } defer resolver.Close() - // Test with cancelled context + // Test with canceled context cancelledCtx, cancel := context.WithCancel(context.Background()) cancel() - // GetSecret with cancelled context + // GetSecret with canceled context _, err = resolver.GetSecret(cancelledCtx, "test-secret") - // Should fail due to cancelled context - assert.Error(t, err) + require.Error(t, err) + // The GCP SDK wraps context cancellation in a gRPC Canceled status error via %w; + // errors.Is does not traverse grpc status errors so we unwrap one level and + // check the gRPC code to confirm this is a genuine cancellation, not an + // auth or missing-secret failure. + st, ok := grpcstatus.FromError(errors.Unwrap(err)) + require.True(t, ok, "expected gRPC status error in chain, got: %v", err) + assert.Equal(t, codes.Canceled, st.Code(), "expected gRPC Canceled code, got: %v", err) } -// TestGCPResolver_EmptySecretID tests getting a secret with empty ID +// TestGCPResolver_EmptySecretID tests getting a secret with empty ID. func TestGCPResolver_EmptySecretID(t *testing.T) { ctx := context.Background() @@ -175,7 +184,7 @@ func TestGCPResolver_EmptySecretID(t *testing.T) { assert.Error(t, err) } -// TestGCPResolver_SpecialCharactersInSecretID tests secret IDs with special characters +// TestGCPResolver_SpecialCharactersInSecretID tests secret IDs with special characters. func TestGCPResolver_SpecialCharactersInSecretID(t *testing.T) { ctx := context.Background() @@ -201,7 +210,7 @@ func TestGCPResolver_SpecialCharactersInSecretID(t *testing.T) { } } -// TestGCPResolver_GetSecretJSON_RealMethod tests the GetSecretJSON error propagation +// TestGCPResolver_GetSecretJSON_RealMethod tests the GetSecretJSON error propagation. func TestGCPResolver_GetSecretJSON_RealMethod(t *testing.T) { ctx := context.Background() @@ -218,7 +227,7 @@ func TestGCPResolver_GetSecretJSON_RealMethod(t *testing.T) { assert.Nil(t, result) } -// TestGCPResolver_ResourceNameFormat verifies the resource name format +// TestGCPResolver_ResourceNameFormat verifies the resource name format. func TestGCPResolver_ResourceNameFormat(t *testing.T) { // The GCP resolver constructs resource names in the format: // projects/{project}/secrets/{secret}/versions/latest diff --git a/internal/secrets/gcp_resolver_test.go b/internal/secrets/gcp_resolver_test.go index 30522ad69..03d4e469a 100644 --- a/internal/secrets/gcp_resolver_test.go +++ b/internal/secrets/gcp_resolver_test.go @@ -15,7 +15,7 @@ import ( "google.golang.org/api/iterator" ) -// MockSecretIterator implements the iterator interface for testing +// MockSecretIterator implements the iterator interface for testing. type MockSecretIterator struct { secrets []*secretmanagerpb.Secret index int @@ -34,7 +34,7 @@ func (m *MockSecretIterator) Next() (*secretmanagerpb.Secret, error) { return secret, nil } -// MockGCPSecretManagerClient is a mock implementation of the GCP Secret Manager client +// MockGCPSecretManagerClient is a mock implementation of the GCP Secret Manager client. type MockGCPSecretManagerClient struct { mock.Mock } @@ -57,7 +57,7 @@ func (m *MockGCPSecretManagerClient) Close() error { return args.Error(0) } -// testableGCPResolver wraps GCPResolver to allow injecting a mock client +// testableGCPResolver wraps GCPResolver to allow injecting a mock client. type testableGCPResolver struct { mockClient *MockGCPSecretManagerClient projectID string @@ -104,7 +104,7 @@ func (r *testableGCPResolver) ListSecrets(ctx context.Context, filter string) ([ for { secret, err := it.Next() - if err == iterator.Done { + if errors.Is(err, iterator.Done) { break } if err != nil { diff --git a/internal/secrets/resolver.go b/internal/secrets/resolver.go index 1a5a87bdd..acf1210ac 100644 --- a/internal/secrets/resolver.go +++ b/internal/secrets/resolver.go @@ -7,7 +7,7 @@ import ( "os" ) -// Resolver defines the interface for retrieving secrets from various secret managers +// Resolver defines the interface for retrieving secrets from various secret managers. type Resolver interface { // GetSecret retrieves a secret value by ID/ARN/name GetSecret(ctx context.Context, secretID string) (string, error) @@ -30,7 +30,7 @@ type Resolver interface { Close() error } -// Config holds secrets resolver configuration +// Config holds secrets resolver configuration. type Config struct { // Provider specifies which secret manager to use // Valid values: "aws", "gcp", "azure", "env" @@ -59,7 +59,7 @@ func LoadConfigFromEnv() *Config { } } -// NewResolver creates a new secret resolver based on the provider +// NewResolver creates a new secret resolver based on the provider. func NewResolver(ctx context.Context, config *Config) (Resolver, error) { if config == nil { return nil, fmt.Errorf("secrets config must not be nil") @@ -86,7 +86,7 @@ func NewResolver(ctx context.Context, config *Config) (Resolver, error) { } } -// Helper function +// Helper function. func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value diff --git a/internal/secrets/resolver_coverage_test.go b/internal/secrets/resolver_coverage_test.go index a49d8f964..f25d48dfb 100644 --- a/internal/secrets/resolver_coverage_test.go +++ b/internal/secrets/resolver_coverage_test.go @@ -293,14 +293,14 @@ func TestNewResolver_MultipleEnvResolvers(t *testing.T) { assert.Equal(t, "value", val2) } -// TestNewResolver_ContextCancellation tests behavior with cancelled context +// TestNewResolver_ContextCancellation tests behavior with canceled context func TestNewResolver_ContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately config := &Config{Provider: "env"} - // EnvResolver should still work with cancelled context + // EnvResolver should still work with canceled context // since it doesn't actually use the context for initialization resolver, err := NewResolver(ctx, config) require.NoError(t, err) diff --git a/internal/server/analytics_collect.go b/internal/server/analytics_collect.go index 255268ee7..04e29cb3a 100644 --- a/internal/server/analytics_collect.go +++ b/internal/server/analytics_collect.go @@ -33,7 +33,7 @@ const ( defaultAnalyticsPartitionsAhead = 3 // analyticsDDLTimeout bounds each long-running partition/retention/refresh - // DDL step. RDS Proxy does not honour a session statement_timeout, so a + // DDL step. RDS Proxy does not honor a session statement_timeout, so a // runaway DDL (e.g. a CONCURRENTLY refresh blocked on a lock) could hang the // whole scheduled run indefinitely; a per-step deadline guarantees the // pipeline makes forward progress or fails fast (06-N3). @@ -148,9 +148,9 @@ func (app *Application) handleCollectAnalytics(ctx context.Context) (map[string] // 2. Collect a snapshot across all tenants. if err := app.AnalyticsCollector.Collect(ctx); err != nil { - // A cancelled context is terminal: stop the pipeline and surface it. + // A canceled context is terminal: stop the pipeline and surface it. if ctx.Err() != nil { - return result, fmt.Errorf("analytics collection cancelled: %w", err) + return result, fmt.Errorf("analytics collection canceled: %w", err) } log.Printf("Warning: analytics collection failed: %v", err) result["status"] = "partial" diff --git a/internal/server/app.go b/internal/server/app.go index 39b7b227c..25cda96e1 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -36,7 +36,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -// Application holds all components of the CUDly server +// Application holds all components of the CUDly server. type Application struct { Config config.StoreInterface API *api.Handler @@ -97,13 +97,13 @@ type Application struct { // migrationsTimeout and runMigrationsFunc are per-instance instead of // package-level variables so that tests can set them on a specific - // Application instance without serialising parallel tests (04-M3). + // Application instance without serializing parallel tests (04-M3). // NewApplicationFromDeps sets them to the package defaults. migrationsTimeout time.Duration runMigrationsFunc func(ctx context.Context, pool *pgxpool.Pool, migrationsPath, adminEmail, adminPassword string) error } -// ApplicationConfig holds all env-based configuration for the application +// ApplicationConfig holds all env-based configuration for the application. type ApplicationConfig struct { Version string NotificationDaysBefore int @@ -138,7 +138,7 @@ type ApplicationConfig struct { Analytics AnalyticsConfig } -// ExternalDeps holds pre-built external dependencies that require infrastructure +// ExternalDeps holds pre-built external dependencies that require infrastructure. type ExternalDeps struct { EmailSender email.SenderInterface ConfigStore config.StoreInterface @@ -158,10 +158,10 @@ type ExternalDeps struct { const defaultMigrationsTimeout = 120 * time.Second // resolveMigrationsTimeout reads CUDLY_MIGRATION_TIMEOUT from the environment. -// It is called once in NewApplicationFromDeps to initialise +// It is called once in NewApplicationFromDeps to initialize // Application.migrationsTimeout. Because the timeout lives on the struct // (not a package-level var), tests can set it on a specific Application -// instance without serialising parallel tests (04-M3). +// instance without serializing parallel tests (04-M3). func resolveMigrationsTimeout() time.Duration { v := os.Getenv("CUDLY_MIGRATION_TIMEOUT") if v == "" { @@ -199,7 +199,7 @@ func (app *Application) snapshotMigrationState() (err error, finishedAt time.Tim // a panic wrapped as an error, or a timeout error -- never a // nil-with-goroutine-still-alive. The goroutine is guaranteed to have exited // before this function returns (the timeout branch waits on <-done after -// cancelling the ctx), so no orphan goroutine survives past this call -- +// canceling the ctx), so no orphan goroutine survives past this call -- // critical on Lambda where goroutines freeze between invocations. // // Using instance fields (not package globals) makes it safe to call @@ -250,7 +250,7 @@ func resolveOIDCIssuerURL(cfg ApplicationConfig) string { return strings.TrimRight(cfg.DashboardURL, "/") } -// LoadApplicationConfig reads all configuration from environment variables +// LoadApplicationConfig reads all configuration from environment variables. func LoadApplicationConfig() ApplicationConfig { version := os.Getenv("VERSION") if version == "" { @@ -405,7 +405,7 @@ func NewApplicationFromDeps(ctx context.Context, cfg ApplicationConfig, deps Ext // Construct the OIDC issuer signer once per deployment. Nil means // the deployment has not opted into the federated flow yet — all // OIDC-dependent paths (handler_oidc.go, purchase manager Azure - // federated credential) fall back to their legacy behaviours. + // federated credential) fall back to their legacy behaviors. signer, signerErr := oidc.NewSignerFromEnv(ctx) if signerErr != nil { log.Printf("oidc signer init failed (federated flow disabled): %v", signerErr) @@ -888,7 +888,7 @@ func buildAdminPasswordSyncCallback(store auth.StoreInterface, resolver secrets. } } -// Close gracefully shuts down the application +// Close gracefully shuts down the application. func (app *Application) Close() error { log.Println("Shutting down CUDly Server...") @@ -903,7 +903,7 @@ func (app *Application) Close() error { } // initConfigStore initializes the configuration store using PostgreSQL -// Connection is deferred (lazy init) until first request to avoid Lambda ENI issues +// Connection is deferred (lazy init) until first request to avoid Lambda ENI issues. func initConfigStore(ctx context.Context) (config.StoreInterface, *database.Config, secrets.Resolver, error) { // Require PostgreSQL configuration if os.Getenv("DB_HOST") == "" { @@ -957,7 +957,7 @@ func getEnvFloat(key string, defaultVal float64) float64 { return defaultVal } -// authServiceAdapter adapts auth.Service to api.AuthServiceInterface +// authServiceAdapter adapts auth.Service to api.AuthServiceInterface. type authServiceAdapter struct { service *auth.Service } @@ -1067,7 +1067,7 @@ func (a *authServiceAdapter) UpdateUserProfile(ctx context.Context, userID strin return a.service.UpdateUserProfile(ctx, userID, email, currentPassword, newPassword) } -// User management methods - delegate to auth service API methods +// User management methods - delegate to auth service API methods. func (a *authServiceAdapter) CreateUserAPI(ctx context.Context, req any) (any, error) { return a.service.CreateUserAPI(ctx, req) } @@ -1105,7 +1105,7 @@ func (a *authServiceAdapter) MFARegenerateRecoveryCodesAPI(ctx context.Context, return a.service.MFARegenerateRecoveryCodesAPI(ctx, userID, code) } -// Group management methods - delegate to auth service API methods +// Group management methods - delegate to auth service API methods. func (a *authServiceAdapter) CreateGroupAPI(ctx context.Context, req any) (any, error) { return a.service.CreateGroupAPI(ctx, req) } @@ -1126,7 +1126,7 @@ func (a *authServiceAdapter) ListGroupsAPI(ctx context.Context) (any, error) { return a.service.ListGroupsAPI(ctx) } -// Permission checking +// Permission checking. func (a *authServiceAdapter) HasPermissionAPI(ctx context.Context, userID, action, resource string) (bool, error) { return a.service.HasPermissionAPI(ctx, userID, action, resource) } @@ -1135,7 +1135,7 @@ func (a *authServiceAdapter) GetUserPermissionsAPI(ctx context.Context, userID s return a.service.GetUserPermissionsAPI(ctx, userID) } -// Account access +// Account access. func (a *authServiceAdapter) GetAllowedAccountsAPI(ctx context.Context, userID string) ([]string, error) { authCtx, err := a.service.BuildAuthContext(ctx, userID) if err != nil { @@ -1144,12 +1144,12 @@ func (a *authServiceAdapter) GetAllowedAccountsAPI(ctx context.Context, userID s return authCtx.AllowedAccounts, nil } -// CSRF validation +// CSRF validation. func (a *authServiceAdapter) ValidateCSRFToken(ctx context.Context, sessionToken, csrfToken string) error { return a.service.ValidateCSRFToken(ctx, sessionToken, csrfToken) } -// API Key management +// API Key management. func (a *authServiceAdapter) CreateAPIKeyAPI(ctx context.Context, userID string, req any) (any, error) { return a.service.CreateAPIKeyAPI(ctx, userID, req) } diff --git a/internal/server/app_test.go b/internal/server/app_test.go index 481918df2..332151688 100644 --- a/internal/server/app_test.go +++ b/internal/server/app_test.go @@ -266,7 +266,7 @@ func TestHandleCollectRecommendations_WithResults(t *testing.T) { testutil.AssertTrue(t, result != nil, "Result should not be nil") } -// noopEmailSender is a minimal email.SenderInterface for unit tests +// noopEmailSender is a minimal email.SenderInterface for unit tests. var _ email.SenderInterface = (*noopEmailSender)(nil) type noopEmailSender struct{} @@ -806,7 +806,7 @@ func TestResolveScheduledTaskSecret_PreferSecretName(t *testing.T) { // TestResolveScheduledTaskSecret_PlaintextOnlyNoResolver verifies the // dev-only path: when no resolver is available, the plaintext value is -// used (expected behaviour for local development). +// used (expected behavior for local development). func TestResolveScheduledTaskSecret_PlaintextOnlyNoResolver(t *testing.T) { ctx := context.Background() diff --git a/internal/server/handler.go b/internal/server/handler.go index a4aa47126..f0da8af3d 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -17,7 +17,7 @@ type TaskLocker interface { ReleaseAdvisoryLock(ctx context.Context, lockID int64) } -// ScheduledTaskType represents different types of scheduled tasks +// ScheduledTaskType represents different types of scheduled tasks. type ScheduledTaskType string const ( @@ -140,7 +140,7 @@ func taskLockID(taskType ScheduledTaskType) int64 { return int64(h.Sum64()) } -// handleCollectRecommendations collects cost optimization recommendations +// handleCollectRecommendations collects cost optimization recommendations. func (app *Application) handleCollectRecommendations(ctx context.Context) (*scheduler.CollectResult, error) { log.Println("Collecting recommendations...") result, err := app.Scheduler.CollectRecommendations(ctx) @@ -152,7 +152,7 @@ func (app *Application) handleCollectRecommendations(ctx context.Context) (*sche return result, nil } -// handleProcessScheduledPurchases processes scheduled purchases +// handleProcessScheduledPurchases processes scheduled purchases. func (app *Application) handleProcessScheduledPurchases(ctx context.Context) (*purchase.ProcessResult, error) { log.Println("Processing scheduled purchases...") result, err := app.Purchase.ProcessScheduledPurchases(ctx) @@ -164,7 +164,7 @@ func (app *Application) handleProcessScheduledPurchases(ctx context.Context) (*p return result, nil } -// handleSendNotifications sends upcoming purchase notifications +// handleSendNotifications sends upcoming purchase notifications. func (app *Application) handleSendNotifications(ctx context.Context) (*purchase.NotificationResult, error) { log.Println("Sending notifications...") result, err := app.Purchase.SendUpcomingPurchaseNotifications(ctx) @@ -176,7 +176,7 @@ func (app *Application) handleSendNotifications(ctx context.Context) (*purchase. return result, nil } -// handleCleanupExpiredRecords cleans up expired sessions and execution records +// handleCleanupExpiredRecords cleans up expired sessions and execution records. func (app *Application) handleCleanupExpiredRecords(ctx context.Context) (map[string]int64, error) { log.Println("Cleaning up expired records...") @@ -266,7 +266,7 @@ func (app *Application) handleFinalizeRevocations(ctx context.Context) (*purchas return result, nil } -// handleRefreshAnalytics refreshes materialized views and analytics data +// handleRefreshAnalytics refreshes materialized views and analytics data. func (app *Application) handleRefreshAnalytics(ctx context.Context) (map[string]any, error) { log.Println("Refreshing analytics...") @@ -298,7 +298,7 @@ func (app *Application) handleRefreshAnalytics(ctx context.Context) (map[string] return result, nil } -// HandleSQSMessage processes an SQS message for async purchase processing +// HandleSQSMessage processes an SQS message for async purchase processing. func (app *Application) HandleSQSMessage(ctx context.Context, body string) error { log.Printf("Processing SQS message (size: %d bytes)", len(body)) if err := app.Purchase.ProcessMessage(ctx, body); err != nil { @@ -309,7 +309,7 @@ func (app *Application) HandleSQSMessage(ctx context.Context, body string) error return nil } -// ScheduledEvent represents a generic scheduled event +// ScheduledEvent represents a generic scheduled event. type ScheduledEvent struct { Source string `json:"source"` DetailType string `json:"detail-type"` @@ -317,7 +317,7 @@ type ScheduledEvent struct { Detail json.RawMessage `json:"detail"` } -// ParseScheduledEvent parses a scheduled event and returns the task type +// ParseScheduledEvent parses a scheduled event and returns the task type. func ParseScheduledEvent(rawEvent json.RawMessage) (ScheduledTaskType, error) { var event ScheduledEvent if err := json.Unmarshal(rawEvent, &event); err != nil { diff --git a/internal/server/handler_coverage_test.go b/internal/server/handler_coverage_test.go index 1c1587154..568d087e2 100644 --- a/internal/server/handler_coverage_test.go +++ b/internal/server/handler_coverage_test.go @@ -315,7 +315,7 @@ func (m *mockConfigStoreForExchangeStale) GetStaleProcessingExchanges(ctx contex return nil, nil } -// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace) +// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace). func (m *mockConfigStoreForExchangeComplete) CreateSuppression(_ context.Context, _ *config.PurchaseSuppression) error { return nil } @@ -338,7 +338,7 @@ func (m *mockConfigStoreForExchangeComplete) WithTx(_ context.Context, fn func(t return fn(nil) } -// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace) +// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace). func (m *mockConfigStoreForExchangeFail) CreateSuppression(_ context.Context, _ *config.PurchaseSuppression) error { return nil } @@ -361,7 +361,7 @@ func (m *mockConfigStoreForExchangeFail) WithTx(_ context.Context, fn func(tx pg return fn(nil) } -// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace) +// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace). func (m *mockConfigStoreForExchangeStale) CreateSuppression(_ context.Context, _ *config.PurchaseSuppression) error { return nil } diff --git a/internal/server/handler_ri_exchange_test.go b/internal/server/handler_ri_exchange_test.go index 9bed6b419..0fe391f93 100644 --- a/internal/server/handler_ri_exchange_test.go +++ b/internal/server/handler_ri_exchange_test.go @@ -910,7 +910,7 @@ func (m *mockExchangeClient) Execute(ctx context.Context, req exchange.ExchangeE return "", nil, errors.New("Execute not mocked") } -// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace) +// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace). func (m *mockConfigStoreForExchange) CreateSuppression(_ context.Context, _ *config.PurchaseSuppression) error { return nil } diff --git a/internal/server/handler_test.go b/internal/server/handler_test.go index ee74ace43..1cc7e475f 100644 --- a/internal/server/handler_test.go +++ b/internal/server/handler_test.go @@ -11,7 +11,7 @@ import ( "github.com/LeanerCloud/CUDly/internal/testutil" ) -// mockTaskLocker implements TaskLocker for testing +// mockTaskLocker implements TaskLocker for testing. type mockTaskLocker struct { acquired bool err error diff --git a/internal/server/health.go b/internal/server/health.go index a09c84dcd..f78ddc5fb 100644 --- a/internal/server/health.go +++ b/internal/server/health.go @@ -9,7 +9,7 @@ import ( "time" ) -// HealthStatus represents the overall health of the application +// HealthStatus represents the overall health of the application. type HealthStatus struct { Status string `json:"status"` Version string `json:"version"` @@ -17,13 +17,13 @@ type HealthStatus struct { Checks map[string]CheckResult `json:"checks"` } -// CheckResult represents the result of a health check +// CheckResult represents the result of a health check. type CheckResult struct { Status string `json:"status"` Message string `json:"message,omitempty"` } -// handleHealthCheck returns the health status of the application +// handleHealthCheck returns the health status of the application. func (app *Application) handleHealthCheck(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() @@ -74,7 +74,7 @@ func (app *Application) handleHealthCheck(w http.ResponseWriter, r *http.Request // disabled = AutoMigrate is off; migrations happen elsewhere (e.g. CI) // pending = AutoMigrate is on but ensureDB hasn't completed yet // failed = last attempt returned an error OR timed out -// healthy = last attempt completed without error +// healthy = last attempt completed without error. func (app *Application) checkMigrations() CheckResult { // No dbConfig means the app isn't using PostgreSQL at all (DynamoDB or // test mode). AutoMigrate off means migrations are handled elsewhere @@ -98,7 +98,7 @@ func (app *Application) checkMigrations() CheckResult { } } -// checkConfigStore checks the health of the configuration store +// checkConfigStore checks the health of the configuration store. func (app *Application) checkConfigStore(ctx context.Context) CheckResult { // Check if config store exists if app.Config == nil { @@ -154,7 +154,7 @@ func setHealthResponseHeaders(w http.ResponseWriter, corsOrigin string) { } } -// checkAuthStore checks the health of the auth store +// checkAuthStore checks the health of the auth store. func (app *Application) checkAuthStore(ctx context.Context) CheckResult { if app.Auth == nil { return CheckResult{ diff --git a/internal/server/health_test.go b/internal/server/health_test.go index 4f92cff2c..daa2ee200 100644 --- a/internal/server/health_test.go +++ b/internal/server/health_test.go @@ -13,7 +13,7 @@ import ( "github.com/LeanerCloud/CUDly/internal/testutil" ) -// mockAuthStoreForHealth implements auth.StoreInterface for health check tests +// mockAuthStoreForHealth implements auth.StoreInterface for health check tests. type mockAuthStoreForHealth struct{} func (m *mockAuthStoreForHealth) GetUserByID(ctx context.Context, userID string) (*auth.User, error) { @@ -128,7 +128,7 @@ func (m *mockAuthStoreForHealth) Ping(ctx context.Context) error { return nil } -// createHealthyAuthService creates an auth service with a mock store for health tests +// createHealthyAuthService creates an auth service with a mock store for health tests. func createHealthyAuthService() *auth.Service { return auth.NewService(auth.ServiceConfig{ Store: &mockAuthStoreForHealth{}, diff --git a/internal/server/http.go b/internal/server/http.go index 0e6f4878e..c61773397 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -121,7 +121,7 @@ func (app *Application) handleOIDCHTTP(w http.ResponseWriter, r *http.Request) { lambdaReq := httpToLambdaRequest(r) resp, handled := app.API.HandleOIDC(ctx, lambdaReq) if !handled { - // Path matched /oidc/ prefix but is not a recognised OIDC endpoint. + // Path matched /oidc/ prefix but is not a recognized OIDC endpoint. http.NotFound(w, r) return } @@ -152,7 +152,7 @@ func securityHeaders(next http.Handler) http.Handler { }) } -// handleHTTPRequest converts standard HTTP requests to Lambda Function URL format +// handleHTTPRequest converts standard HTTP requests to Lambda Function URL format. func (app *Application) handleHTTPRequest(w http.ResponseWriter, r *http.Request) { // Add request timeout to prevent hanging requests ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) @@ -263,7 +263,7 @@ func (app *Application) handleScheduledHTTP(w http.ResponseWriter, r *http.Reque } } -// httpToLambdaRequest converts a standard HTTP request to Lambda Function URL request format +// httpToLambdaRequest converts a standard HTTP request to Lambda Function URL request format. func httpToLambdaRequest(r *http.Request) *events.LambdaFunctionURLRequest { // Read body with size limit to prevent memory exhaustion body := "" @@ -354,12 +354,12 @@ var safeHeaderNames = map[string]bool{ "permissions-policy": true, } -// isSafeHeaderValue checks that a header value doesn't contain CRLF injection characters +// isSafeHeaderValue checks that a header value doesn't contain CRLF injection characters. func isSafeHeaderValue(value string) bool { return !strings.ContainsAny(value, "\r\n") } -// lambdaResponseToHTTP converts a Lambda Function URL response to standard HTTP response +// lambdaResponseToHTTP converts a Lambda Function URL response to standard HTTP response. func lambdaResponseToHTTP(w http.ResponseWriter, lambdaResp *events.LambdaFunctionURLResponse) { // Decode body before writing headers/status to avoid double WriteHeader on error var body []byte diff --git a/internal/server/integration_test.go b/internal/server/integration_test.go index 47e2eabab..fe34f5419 100644 --- a/internal/server/integration_test.go +++ b/internal/server/integration_test.go @@ -45,7 +45,7 @@ func TestServerIntegration(t *testing.T) { t.Logf("PostgreSQL container running at: %s", pgContainer.ConnectionString()) } -// TestHealthCheckIntegration tests the health check endpoint +// TestHealthCheckIntegration tests the health check endpoint. func TestHealthCheckIntegration(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") @@ -73,7 +73,7 @@ func TestHealthCheckIntegration(t *testing.T) { t.Logf("Health check response: %s", w.Body.String()) } -// TestScheduledTaskIntegration tests scheduled task execution +// TestScheduledTaskIntegration tests scheduled task execution. func TestScheduledTaskIntegration(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") @@ -102,7 +102,7 @@ func TestScheduledTaskIntegration(t *testing.T) { t.Logf("Scheduled task completed successfully") } -// TestApplicationLifecycle tests full application startup and shutdown +// TestApplicationLifecycle tests full application startup and shutdown. func TestApplicationLifecycle(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") diff --git a/internal/server/interfaces.go b/internal/server/interfaces.go index da764d463..4a797e6af 100644 --- a/internal/server/interfaces.go +++ b/internal/server/interfaces.go @@ -9,7 +9,7 @@ import ( "github.com/LeanerCloud/CUDly/internal/scheduler" ) -// SchedulerInterface defines the methods required for the scheduler component +// SchedulerInterface defines the methods required for the scheduler component. type SchedulerInterface interface { CollectRecommendations(ctx context.Context) (*scheduler.CollectResult, error) ListRecommendations(ctx context.Context, filter config.RecommendationFilter) ([]config.RecommendationRecord, error) @@ -20,7 +20,7 @@ type SchedulerInterface interface { GetRecommendationByID(ctx context.Context, id string) (rec *config.RecommendationRecord, hiddenBy []string, err error) } -// PurchaseManagerInterface defines the methods required for the purchase manager component +// PurchaseManagerInterface defines the methods required for the purchase manager component. type PurchaseManagerInterface interface { ProcessScheduledPurchases(ctx context.Context) (*purchase.ProcessResult, error) SendUpcomingPurchaseNotifications(ctx context.Context) (*purchase.NotificationResult, error) diff --git a/internal/server/lambda.go b/internal/server/lambda.go index 25ca4b3f2..866edb467 100644 --- a/internal/server/lambda.go +++ b/internal/server/lambda.go @@ -12,7 +12,7 @@ import ( "github.com/aws/aws-lambda-go/lambda" ) -// StartLambdaHandler starts the AWS Lambda handler +// StartLambdaHandler starts the AWS Lambda handler. func StartLambdaHandler(app *Application) { log.Println("Starting Lambda handler mode...") lambda.Start(func(ctx context.Context, rawEvent json.RawMessage) (any, error) { @@ -20,7 +20,7 @@ func StartLambdaHandler(app *Application) { }) } -// HandleLambdaEvent processes any Lambda event type +// HandleLambdaEvent processes any Lambda event type. func (app *Application) HandleLambdaEvent(ctx context.Context, rawEvent json.RawMessage) (any, error) { // Ensure database connection is established (lazy initialization) // Safe to call on every request - mutex guards connection and allows retry on transient failures @@ -40,14 +40,14 @@ func (app *Application) HandleLambdaEvent(ctx context.Context, rawEvent json.Raw case "scheduled": return app.handleLambdaScheduledEvent(ctx, rawEvent) default: - // Return a distinct error instead of silently treating an unrecognised + // Return a distinct error instead of silently treating an unrecognized // payload as a scheduled event. Masking the event shape as "unknown // scheduled task action" makes the real cause hard to diagnose (04-N4). - return nil, fmt.Errorf("unrecognised Lambda event shape (size %d bytes); not an HTTP/SQS/scheduled event", len(rawEvent)) + return nil, fmt.Errorf("unrecognized Lambda event shape (size %d bytes); not an HTTP/SQS/scheduled event", len(rawEvent)) } } -// detectLambdaEventType determines the type of Lambda event +// detectLambdaEventType determines the type of Lambda event. func detectLambdaEventType(rawEvent json.RawMessage) string { // Check for Lambda Function URL / API Gateway event var httpEvent struct { @@ -199,7 +199,7 @@ func isTextContentType(ct string) bool { return false } -// handleLambdaSQSEvent processes SQS messages (for async purchase processing) +// handleLambdaSQSEvent processes SQS messages (for async purchase processing). func (app *Application) handleLambdaSQSEvent(ctx context.Context, rawEvent json.RawMessage) (any, error) { var sqsEvent events.SQSEvent if err := json.Unmarshal(rawEvent, &sqsEvent); err != nil { @@ -223,7 +223,7 @@ func (app *Application) handleLambdaSQSEvent(ctx context.Context, rawEvent json. return map[string]string{"status": "processed"}, nil } -// handleLambdaScheduledEvent processes scheduled/cron events +// handleLambdaScheduledEvent processes scheduled/cron events. func (app *Application) handleLambdaScheduledEvent(ctx context.Context, rawEvent json.RawMessage) (any, error) { taskType, err := ParseScheduledEvent(rawEvent) if err != nil { diff --git a/internal/server/lambda_test.go b/internal/server/lambda_test.go index 0d96e1c31..48a1db8f2 100644 --- a/internal/server/lambda_test.go +++ b/internal/server/lambda_test.go @@ -270,10 +270,10 @@ func TestHandleLambdaScheduledEvent(t *testing.T) { } // TestHandleLambdaEvent_UnknownEventReturnsError is a regression test for -// 04-N4: before the fix, unrecognised payloads were silently routed to +// 04-N4: before the fix, unrecognized payloads were silently routed to // handleLambdaScheduledEvent, which then failed with "unknown scheduled task // action" -- masking the real root cause. The fix returns a distinct error -// so callers (and logs) see "unrecognised Lambda event shape" instead. +// so callers (and logs) see "unrecognized Lambda event shape" instead. func TestHandleLambdaEvent_UnknownEventReturnsError(t *testing.T) { ctx := testutil.TestContext(t) @@ -283,7 +283,7 @@ func TestHandleLambdaEvent_UnknownEventReturnsError(t *testing.T) { _, err := app.HandleLambdaEvent(ctx, json.RawMessage(`{"unknown": "event"}`)) testutil.AssertError(t, err) - testutil.AssertTrue(t, strings.Contains(err.Error(), "unrecognised"), + testutil.AssertTrue(t, strings.Contains(err.Error(), "unrecognized"), "expected 'unrecognised' in error, got: "+err.Error()) } diff --git a/internal/server/scheduledauth/config.go b/internal/server/scheduledauth/config.go index 242f52b3d..043128386 100644 --- a/internal/server/scheduledauth/config.go +++ b/internal/server/scheduledauth/config.go @@ -17,7 +17,7 @@ type EnvMap map[string]string // Get returns the value for key, or "" if absent. func (m EnvMap) Get(key string) string { return m[key] } -// Environment variable names. Centralised so tests and Terraform stay +// Environment variable names. Centralized so tests and Terraform stay // aligned with the Go code. const ( EnvAuthMode = "SCHEDULED_TASK_AUTH_MODE" // "oidc" | "bearer" | "disabled" diff --git a/internal/server/scheduledauth/validator.go b/internal/server/scheduledauth/validator.go index 25a4c7094..6581d3ac7 100644 --- a/internal/server/scheduledauth/validator.go +++ b/internal/server/scheduledauth/validator.go @@ -54,7 +54,7 @@ type Config struct { Issuer string // OIDC issuer; defaults to GoogleIssuer in oidc mode JWKSURL string // OIDC JWKS endpoint; defaults to GoogleJWKSURL in oidc mode Audiences []string // accepted aud claims (must be non-empty in oidc mode) - Subjects []string // accepted sub claims (REQUIRED non-empty in oidc mode — defence in depth) + Subjects []string // accepted sub claims (REQUIRED non-empty in oidc mode — defense in depth) Skew time.Duration Bearer string // shared secret for bearer mode (must be non-empty) } @@ -148,7 +148,7 @@ func configureOIDC(v *Validator, cfg Config) (*Validator, error) { // claim for Cloud Scheduler-signed ID tokens. That is what // SCHEDULED_TASK_OIDC_SUBJECTS must contain. if len(cfg.Subjects) == 0 { - return nil, fmt.Errorf("%w: oidc mode requires SCHEDULED_TASK_OIDC_SUBJECTS (defence in depth)", ErrConfigInvalid) + return nil, fmt.Errorf("%w: oidc mode requires SCHEDULED_TASK_OIDC_SUBJECTS (defense in depth)", ErrConfigInvalid) } auds, err := cleanSet(cfg.Audiences, "SCHEDULED_TASK_OIDC_AUDIENCE") @@ -202,7 +202,7 @@ func cleanSet(in []string, label string) (map[string]struct{}, error) { func validateAbsoluteURL(raw, label string) error { u, err := url.Parse(raw) if err != nil { - return fmt.Errorf("%w: %s must be an absolute URL: %v", ErrConfigInvalid, label, err) + return fmt.Errorf("%w: %s must be an absolute URL: %w", ErrConfigInvalid, label, err) } if u.Scheme == "" || u.Host == "" { return fmt.Errorf("%w: %s must be an absolute URL", ErrConfigInvalid, label) @@ -245,7 +245,7 @@ func (v *Validator) Warmup(ctx context.Context) { ctx, cancel = context.WithTimeout(ctx, warmupTimeout) defer cancel() } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.jwksURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.jwksURL, http.NoBody) if err != nil { log.Printf("scheduledauth: WARN — JWKS warmup request build failed: %v", err) return @@ -339,7 +339,7 @@ func (v *Validator) validateOIDC(ctx context.Context, authz string) error { // and skew-tolerant expiry checks below. idToken, err := v.verifier.Verify(ctx, rawToken) if err != nil { - return fmt.Errorf("%w: %v", ErrUnauthorized, err) + return fmt.Errorf("%w: %w", ErrUnauthorized, err) } // Re-parse the payload to access iat / nbf — IDToken exposes Expiry @@ -347,7 +347,7 @@ func (v *Validator) validateOIDC(ctx context.Context, authz string) error { // nbf is not exposed as a typed field. var c claims if err := idToken.Claims(&c); err != nil { - return fmt.Errorf("%w: malformed claims: %v", ErrUnauthorized, err) + return fmt.Errorf("%w: malformed claims: %w", ErrUnauthorized, err) } if err := v.checkTimestamps(c); err != nil { @@ -361,7 +361,7 @@ func (v *Validator) validateOIDC(ctx context.Context, authz string) error { return fmt.Errorf("%w: audience %v not in allowlist", ErrUnauthorized, idToken.Audience) } - // Subject pinning: required defence-in-depth — any GCP SA in the + // Subject pinning: required defense-in-depth — any GCP SA in the // org could mint a token with our `aud` value, but only the // scheduler SA has our `sub`. if _, ok := v.subjects[idToken.Subject]; !ok { diff --git a/internal/server/test_helpers_test.go b/internal/server/test_helpers_test.go index f1962e4c6..3f4301321 100644 --- a/internal/server/test_helpers_test.go +++ b/internal/server/test_helpers_test.go @@ -9,10 +9,10 @@ import ( "github.com/jackc/pgx/v5" ) -// databaseConfigStub is a type alias used in health tests to simulate pending DB config +// databaseConfigStub is a type alias used in health tests to simulate pending DB config. type databaseConfigStub = database.Config -// mockConfigStoreForHealth implements config.StoreInterface for health check tests +// mockConfigStoreForHealth implements config.StoreInterface for health check tests. type mockConfigStoreForHealth struct{} func (m *mockConfigStoreForHealth) GetGlobalConfig(ctx context.Context) (*config.GlobalConfig, error) { @@ -270,7 +270,7 @@ func (m *mockConfigStoreForHealth) UpsertRIUtilizationCache(_ context.Context, _ return nil } -// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace) +// ── Purchase suppressions (Commit 2 of bulk-purchase-with-grace). func (m *mockConfigStoreForHealth) CreateSuppression(_ context.Context, _ *config.PurchaseSuppression) error { return nil } diff --git a/internal/testutil/mocks.go b/internal/testutil/mocks.go index 0ab05e2c1..472f1706e 100644 --- a/internal/testutil/mocks.go +++ b/internal/testutil/mocks.go @@ -9,7 +9,7 @@ import ( "github.com/LeanerCloud/CUDly/internal/scheduler" ) -// MockScheduler is a mock implementation of server.SchedulerInterface +// MockScheduler is a mock implementation of server.SchedulerInterface. type MockScheduler struct { CollectRecommendationsFunc func(ctx context.Context) (*scheduler.CollectResult, error) ListRecommendationsFunc func(ctx context.Context, filter config.RecommendationFilter) ([]config.RecommendationRecord, error) @@ -37,7 +37,7 @@ func (m *MockScheduler) GetRecommendationByID(ctx context.Context, id string) (* return nil, nil, nil } -// MockPurchaseManager is a mock implementation of server.PurchaseManagerInterface +// MockPurchaseManager is a mock implementation of server.PurchaseManagerInterface. type MockPurchaseManager struct { ProcessScheduledPurchasesFunc func(ctx context.Context) (*purchase.ProcessResult, error) SendUpcomingPurchaseNotificationsFunc func(ctx context.Context) (*purchase.NotificationResult, error) diff --git a/internal/testutil/postgres.go b/internal/testutil/postgres.go index 3372678a3..19670ab4a 100644 --- a/internal/testutil/postgres.go +++ b/internal/testutil/postgres.go @@ -13,7 +13,7 @@ import ( "github.com/testcontainers/testcontainers-go/wait" ) -// PostgresContainer holds the testcontainer for PostgreSQL +// PostgresContainer holds the testcontainer for PostgreSQL. type PostgresContainer struct { Container testcontainers.Container Host string @@ -23,7 +23,7 @@ type PostgresContainer struct { Password string } -// SetupPostgresContainer creates and starts a PostgreSQL testcontainer +// SetupPostgresContainer creates and starts a PostgreSQL testcontainer. func SetupPostgresContainer(ctx context.Context, t *testing.T) (*PostgresContainer, error) { req := testcontainers.ContainerRequest{ Image: "postgres:16-alpine", @@ -74,13 +74,13 @@ func SetupPostgresContainer(ctx context.Context, t *testing.T) (*PostgresContain }, nil } -// ConnectionString returns a PostgreSQL connection string +// ConnectionString returns a PostgreSQL connection string. func (pc *PostgresContainer) ConnectionString() string { return fmt.Sprintf("postgresql://%s:%s@%s:%s/%s?sslmode=disable", pc.Username, pc.Password, pc.Host, pc.Port, pc.Database) } -// Config returns a database configuration for the test container +// Config returns a database configuration for the test container. func (pc *PostgresContainer) Config() map[string]string { return map[string]string{ "DB_HOST": pc.Host, diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 45945ef4d..27a009abf 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -8,14 +8,14 @@ import ( "time" ) -// TestContext creates a context with a reasonable timeout for tests +// TestContext creates a context with a reasonable timeout for tests. func TestContext(t *testing.T) context.Context { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) t.Cleanup(cancel) return ctx } -// SetEnv sets an environment variable for the duration of the test +// SetEnv sets an environment variable for the duration of the test. func SetEnv(t *testing.T, key, value string) { old := os.Getenv(key) os.Setenv(key, value) @@ -28,7 +28,7 @@ func SetEnv(t *testing.T, key, value string) { }) } -// RequireEnv skips the test if the environment variable is not set +// RequireEnv skips the test if the environment variable is not set. func RequireEnv(t *testing.T, key string) string { value := os.Getenv(key) if value == "" { @@ -37,21 +37,21 @@ func RequireEnv(t *testing.T, key string) string { return value } -// SkipIfShort skips the test if running in short mode +// SkipIfShort skips the test if running in short mode. func SkipIfShort(t *testing.T) { if testing.Short() { t.Skip("Skipping test in short mode") } } -// SkipCI skips the test if running in CI environment +// SkipCI skips the test if running in CI environment. func SkipCI(t *testing.T) { if os.Getenv("CI") == "true" { t.Skip("Skipping test in CI environment") } } -// AssertNoError fails the test if err is not nil +// AssertNoError fails the test if err is not nil. func AssertNoError(t *testing.T, err error) { t.Helper() if err != nil { @@ -59,7 +59,7 @@ func AssertNoError(t *testing.T, err error) { } } -// AssertError fails the test if err is nil +// AssertError fails the test if err is nil. func AssertError(t *testing.T, err error) { t.Helper() if err == nil { @@ -67,7 +67,7 @@ func AssertError(t *testing.T, err error) { } } -// AssertEqual fails the test if expected != actual +// AssertEqual fails the test if expected != actual. func AssertEqual(t *testing.T, expected, actual any) { t.Helper() if expected != actual { @@ -75,7 +75,7 @@ func AssertEqual(t *testing.T, expected, actual any) { } } -// AssertNotEqual fails the test if expected == actual +// AssertNotEqual fails the test if expected == actual. func AssertNotEqual(t *testing.T, expected, actual any) { t.Helper() if expected == actual { @@ -83,7 +83,7 @@ func AssertNotEqual(t *testing.T, expected, actual any) { } } -// AssertTrue fails the test if condition is false +// AssertTrue fails the test if condition is false. func AssertTrue(t *testing.T, condition bool, message string) { t.Helper() if !condition { @@ -91,7 +91,7 @@ func AssertTrue(t *testing.T, condition bool, message string) { } } -// AssertFalse fails the test if condition is true +// AssertFalse fails the test if condition is true. func AssertFalse(t *testing.T, condition bool, message string) { t.Helper() if condition { @@ -99,7 +99,7 @@ func AssertFalse(t *testing.T, condition bool, message string) { } } -// AssertContains fails the test if substr is not in str +// AssertContains fails the test if substr is not in str. func AssertContains(t *testing.T, str, substr string) { t.Helper() if !contains(str, substr) { @@ -107,7 +107,7 @@ func AssertContains(t *testing.T, str, substr string) { } } -// AssertNotContains fails the test if substr is in str +// AssertNotContains fails the test if substr is in str. func AssertNotContains(t *testing.T, str, substr string) { t.Helper() if contains(str, substr) { @@ -128,7 +128,7 @@ func indexSubstring(str, substr string) int { return -1 } -// WaitFor waits for a condition to be true, checking every interval +// WaitFor waits for a condition to be true, checking every interval. func WaitFor(t *testing.T, condition func() bool, timeout time.Duration, message string) { t.Helper() deadline := time.Now().Add(timeout) From e605057417fe792fc23c8e86dc7b2d8ce4bfdadf Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 9 Jul 2026 23:20:39 +0200 Subject: [PATCH 3/4] test(integration): fix duplicate cloud-account seeds and not-found contract seedRecommendationCloudAccount already calls CreateCloudAccount; the extra CreateCloudAccount calls added in the previous commit caused cloud_accounts_pkey duplicate-key violations in both TestPostgresStore_UpsertRecommendations_AccountScopedEviction and TestPostgresStore_UpsertRecommendations_AmbientAndRegisteredCoexist. Also fix TestPostgresStore_PurchaseExecutions/Get_execution_by_ID_-_not_found: GetExecutionByID returns ErrNotFound (not nil) when no row matches; the test was incorrectly changed to require.NoError which contradicts the real implementation. --- .../store_postgres_recommendations_test.go | 17 ++++------------- internal/config/store_postgres_test.go | 9 ++++----- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/internal/config/store_postgres_recommendations_test.go b/internal/config/store_postgres_recommendations_test.go index d72951e19..dfd3eb8ac 100644 --- a/internal/config/store_postgres_recommendations_test.go +++ b/internal/config/store_postgres_recommendations_test.go @@ -289,17 +289,11 @@ func TestPostgresStore_UpsertRecommendations_AccountScopedEviction(t *testing.T) // (migration 000030). acct1 := "11111111-1111-1111-1111-111111111111" acct2 := "22222222-2222-2222-2222-222222222222" + // seedRecommendationCloudAccount already creates the cloud_accounts rows + // that recommendations.cloud_account_id FK (migration 000030) requires. seedRecommendationCloudAccount(ctx, t, store, acct1, "azure", "sub-1111") seedRecommendationCloudAccount(ctx, t, store, acct2, "azure", "sub-2222") - // Seed cloud_accounts so the FK on recommendations.cloud_account_id is satisfied. - require.NoError(t, store.CreateCloudAccount(ctx, &config.CloudAccount{ - ID: acct1, Name: "Eviction-Test-Acct-1", Enabled: true, Provider: "azure", ExternalID: "eviction-test-acct-1", - })) - require.NoError(t, store.CreateCloudAccount(ctx, &config.CloudAccount{ - ID: acct2, Name: "Eviction-Test-Acct-2", Enabled: true, Provider: "azure", ExternalID: "eviction-test-acct-2", - })) - t0 := time.Now().UTC().Truncate(time.Second) seed := []config.RecommendationRecord{ @@ -354,14 +348,11 @@ func TestPostgresStore_UpsertRecommendations_AmbientAndRegisteredCoexist(t *test // The registered account must exist in cloud_accounts: // recommendations.cloud_account_id carries an FK (migration 000030). + // seedRecommendationCloudAccount already creates the cloud_accounts row + // that recommendations.cloud_account_id FK (migration 000030) requires. registeredAcctID := "33333333-3333-3333-3333-333333333333" seedRecommendationCloudAccount(ctx, t, store, registeredAcctID, "aws", "333333333333") - // Seed cloud_accounts so the FK on recommendations.cloud_account_id is satisfied. - require.NoError(t, store.CreateCloudAccount(ctx, &config.CloudAccount{ - ID: registeredAcctID, Name: "Coexist-Test-Acct", Enabled: true, Provider: "aws", ExternalID: "coexist-test-acct-1", - })) - t0 := time.Now().UTC().Truncate(time.Second) // Seed one ambient row (CloudAccountID nil) + one registered row. diff --git a/internal/config/store_postgres_test.go b/internal/config/store_postgres_test.go index feccd07e0..cd2808820 100644 --- a/internal/config/store_postgres_test.go +++ b/internal/config/store_postgres_test.go @@ -307,11 +307,10 @@ func TestPostgresStore_PurchaseExecutions(t *testing.T) { }) t.Run("Get execution by ID - not found", func(t *testing.T) { - // GetExecutionByID returns (nil, nil) when no row matches; the - // caller is responsible for distinguishing not-found from error. - exec, err := store.GetExecutionByID(ctx, "00000000-0000-0000-0000-000000000000") - require.NoError(t, err) - assert.Nil(t, exec) + // GetExecutionByID returns ErrNotFound when no row matches. + _, err := store.GetExecutionByID(ctx, "00000000-0000-0000-0000-000000000000") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") }) t.Run("Get execution by plan and date - not found", func(t *testing.T) { From d6ae40e7c365c645e51d01f02cc3c0c1357d28cb Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 10 Jul 2026 15:12:01 +0200 Subject: [PATCH 4/4] fix(lint): clear unparam/unused findings and malformed nolint directive The govulncheck security bumps (Go 1.26.5, aws-sdk s3 v1.97.3, pgx v5.9.2) that this commit originally carried now land via origin/main (commit a840ed1d4 + existing pgx v5.9.2), so after rebasing they are no-ops here; GO-2026-5856, GO-2026-5764 and GO-2026-5004 are all covered on the rebased branch (govulncheck clean across all six modules). This commit's remaining, unique content is the golangci-lint cleanup: - Drop always-nil error / unused return / always-same param across getVersion, checkAuthService, applyUpdateUserRequest, buildMigrateDSN, Manager.executePurchase (unused wasMultiAccount bool), and initConfigStore (always-nil lazy store), updating callers and tests. - Keep getEnvFloat and the two scheduled-task handlers (handleCleanupExpiredRecords, handleRefreshAnalytics) with a documented //nolint:unparam: their signatures are intentionally fixed for symmetry with sibling helpers / the task-dispatch family contract. - Remove dead private code with no call site on main or this branch: canAccessAccountID, planIntersectsAllowed, expireIfStale, duplicatePurchaseResponse, triggerColdStartCollect, and unused test helpers (testSender/newTestSender, customStore/storeWithComplete). Per-account and per-plan scoping remains enforced inline in handler_accounts.go and via isPlanAllowedCached; no gate is removed. - Apply //nolint:unparam (or blank unused params) to the flagged test helpers whose argument is fixed by design. - Fix the malformed nolint on dummyPasswordHash: "// nolint:gosec -- ..." parsed the reason as linter names ("unknown linter"); use the correct "//nolint:gosec // ..." form. --- internal/api/handler_dashboard.go | 17 -------- internal/api/handler_history.go | 19 -------- internal/api/handler_purchases.go | 24 ----------- .../api/handler_recommendations_refresh.go | 43 ------------------- internal/api/handler_version.go | 4 +- internal/api/handler_version_test.go | 6 +-- internal/api/health.go | 4 +- internal/api/health_test.go | 6 +-- internal/api/ri_utilization_cache_test.go | 5 ++- internal/api/router.go | 2 +- .../api/router_660_permission_flips_test.go | 1 + internal/api/scoping.go | 15 ------- internal/auth/service_password.go | 3 +- internal/auth/service_user.go | 11 +++-- .../config/recommendation_overrides_test.go | 1 + .../config/store_postgres_additional_test.go | 1 + .../database/postgres/migrations/migrate.go | 24 +++++------ .../migrations/migrate_security_test.go | 2 +- internal/email/sender_test.go | 24 ----------- internal/email/smtp_server_test.go | 2 +- internal/purchase/coverage_extra_test.go | 16 +++---- internal/purchase/execution.go | 18 ++++---- internal/purchase/execution_test.go | 32 +++++++------- internal/purchase/manager.go | 2 +- .../scheduler/scheduler_overrides_test.go | 2 + internal/server/app.go | 27 +++++++----- internal/server/app_test.go | 5 +-- internal/server/handler.go | 10 +++++ internal/server/handler_coverage_test.go | 27 +----------- 29 files changed, 104 insertions(+), 249 deletions(-) diff --git a/internal/api/handler_dashboard.go b/internal/api/handler_dashboard.go index 0f15eab25..7d2a56e71 100644 --- a/internal/api/handler_dashboard.go +++ b/internal/api/handler_dashboard.go @@ -478,23 +478,6 @@ func upcomingFromExecution(plan *config.PurchasePlan, exec *config.PurchaseExecu } } -// planIntersectsAllowed returns true when any of the plan's associated cloud -// accounts is in the allowed list (matched by ID or display name). Returns -// false when the plan has no account rows — scoped users don't get to see -// unattributed plans. -func (h *Handler) planIntersectsAllowed(ctx context.Context, planID string, allowed []string) (bool, error) { - accounts, err := h.config.GetPlanAccounts(ctx, planID) - if err != nil { - return false, fmt.Errorf("failed to get plan accounts: %w", err) - } - for _, acct := range accounts { - if auth.MatchesAccount(allowed, acct.ID, acct.Name) { - return true, nil - } - } - return false, nil -} - // getPublicInfo returns public information about the CUDly instance (no auth required). // No rate limiting — this is hit by Terraform deployment checks and the frontend on every page load. // Sensitive identifiers (API key secret URL, deployment AWS account ID) are intentionally diff --git a/internal/api/handler_history.go b/internal/api/handler_history.go index e2c3f79dc..121fcd19d 100644 --- a/internal/api/handler_history.go +++ b/internal/api/handler_history.go @@ -243,25 +243,6 @@ func (h *Handler) resolveUserEmails(ctx context.Context, executions []config.Pur return out } -// expireIfStale transitions a pending/notified execution to "expired" when -// its ScheduledDate is older than approvalExpiryWindow. Returns the possibly- -// updated execution. Transition failures are non-fatal — the row still -// renders, just with its original status. -func (h *Handler) expireIfStale(ctx context.Context, exec config.PurchaseExecution) config.PurchaseExecution { - if exec.Status != "pending" && exec.Status != "notified" { - return exec - } - if time.Since(exec.ScheduledDate) < approvalExpiryWindow { - return exec - } - updated, err := h.config.TransitionExecutionStatus(ctx, exec.ExecutionID, []string{"pending", "notified"}, "expired", nil) - if err != nil { - logging.Warnf("history: failed to expire execution %s: %v", exec.ExecutionID, err) - return exec - } - return *updated -} - // resolvePendingApproverEmail returns the notification email the approval // link was sent to (or would have been, if SES failed). Single-tenant // deployments share one value across every pending row, so this is looked up diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index 09a11a762..f229a3c89 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -1785,30 +1785,6 @@ func (h *Handler) findDuplicatePendingExecution(ctx context.Context, creatorID, return nil, nil } -// duplicatePurchaseResponse returns a ready-to-send response body when this -// submit collapses onto an existing pending execution (#644), or nil when it -// is a genuinely new submit that should proceed to create a fresh execution. -// A lookup failure is logged and treated as "not a duplicate" so a transient -// store error never blocks a legitimate purchase. Extracted from executePurchase -// to keep that function under the gocyclo threshold. -func (h *Handler) duplicatePurchaseResponse(ctx context.Context, creator *string, recs []config.RecommendationRecord, capacityPercent int) map[string]any { - creatorID := "" - if creator != nil { - creatorID = *creator - } - key := purchaseIdempotencyKey(creatorID, recs, capacityPercent) - dup, err := h.findDuplicatePendingExecution(ctx, creatorID, key, time.Now()) - if err != nil { - logging.Errorf("idempotency lookup failed, proceeding with new execution: %v", err) - return nil - } - if dup == nil { - return nil - } - logging.Infof("duplicate purchase submit collapsed to existing execution %s", dup.ExecutionID) - return buildDuplicatePurchaseResponse(dup) -} - // buildDuplicatePurchaseResponse returns the executePurchase response body for // a submit that collapsed onto an existing pending execution (#644). It points // at the original row so the client lands on the same approvable execution diff --git a/internal/api/handler_recommendations_refresh.go b/internal/api/handler_recommendations_refresh.go index a1eee7d1f..23baea172 100644 --- a/internal/api/handler_recommendations_refresh.go +++ b/internal/api/handler_recommendations_refresh.go @@ -193,46 +193,3 @@ func (h *Handler) getLambdaInvoker(ctx context.Context) (LambdaInvokerInterface, } return lambda.NewFromConfig(h.awsCfg), nil } - -// triggerColdStartCollect is the GET /api/recommendations cold-start path. -// It is called from ListRecommendations when last_collected_at is nil AND -// last_collection_started_at is nil (no collection running). It fires an -// async self-invoke (Lambda mode) or a synchronous collect (HTTP mode) and -// returns the freshness state after the trigger so the caller can return an -// empty list to the user with the correct "collecting" indicator. -// -// The returned freshness may have LastCollectionStartedAt set (async) or -// LastCollectedAt set (sync). Callers should treat a non-nil -// LastCollectionStartedAt as "collection in progress". -func (h *Handler) triggerColdStartCollect(ctx context.Context) (*config.RecommendationsFreshness, error) { - schedulerARN := os.Getenv("SCHEDULER_LAMBDA_ARN") - if schedulerARN != "" { - // Atomic mark. ok=false means another caller already marked it — we - // MUST NOT trigger a second async invoke or call ClearCollectionStarted - // (that would wipe the other caller's in-flight marker). Returning the - // current freshness lets the caller see the in-flight collection and - // poll for completion. - ok, err := h.config.MarkCollectionStarted(ctx) - if err != nil { - return nil, fmt.Errorf("failed to mark cold-start collection: %w", err) - } - if !ok { - return h.config.GetRecommendationsFreshness(ctx) - } - if invokeErr := h.asyncInvokeSelf(ctx, schedulerARN); invokeErr != nil { - // Roll back ONLY because we own the marker (ok==true above). - if clearErr := h.config.ClearCollectionStarted(ctx); clearErr != nil { - logging.Warnf("coldStartCollect: failed to clear collection started marker: %v", clearErr) - } - return nil, fmt.Errorf("failed to trigger cold-start collect: %w", invokeErr) - } - // Re-read freshness to return the started_at value. - return h.config.GetRecommendationsFreshness(ctx) - } - - // HTTP / non-Lambda mode: synchronous collect. - if _, err := h.scheduler.CollectRecommendations(ctx); err != nil { - return nil, fmt.Errorf("cold-start collect failed: %w", err) - } - return h.config.GetRecommendationsFreshness(ctx) -} diff --git a/internal/api/handler_version.go b/internal/api/handler_version.go index fdc256d34..61d454bd0 100644 --- a/internal/api/handler_version.go +++ b/internal/api/handler_version.go @@ -24,12 +24,12 @@ type VersionResponse struct { // from env avoids an import cycle between this package and main). When the // binary was built without ldflags (e.g. a bare `go run`), the fields fall // back to the same "dev"/"unknown" sentinels main.go declares. -func (h *Handler) getVersion(_ context.Context, _ *events.LambdaFunctionURLRequest) (*VersionResponse, error) { +func (h *Handler) getVersion(_ context.Context, _ *events.LambdaFunctionURLRequest) *VersionResponse { return &VersionResponse{ Version: envOrDefault("VERSION", "dev"), GitSHA: envOrDefault("GIT_SHA", "unknown"), BuildTime: envOrDefault("BUILD_TIME", "unknown"), - }, nil + } } // envOrDefault returns the value of the named env var, or def when it is unset diff --git a/internal/api/handler_version_test.go b/internal/api/handler_version_test.go index 16f74a23e..ebf77cb3f 100644 --- a/internal/api/handler_version_test.go +++ b/internal/api/handler_version_test.go @@ -20,8 +20,7 @@ func TestGetVersion_Defaults(t *testing.T) { t.Setenv("BUILD_TIME", "") h := &Handler{} - resp, err := h.getVersion(context.Background(), &events.LambdaFunctionURLRequest{}) - require.NoError(t, err) + resp := h.getVersion(context.Background(), &events.LambdaFunctionURLRequest{}) require.NotNil(t, resp) assert.Equal(t, "dev", resp.Version) @@ -37,8 +36,7 @@ func TestGetVersion_FromEnv(t *testing.T) { t.Setenv("BUILD_TIME", "2026-06-01T12:00:00Z") h := &Handler{} - resp, err := h.getVersion(context.Background(), &events.LambdaFunctionURLRequest{}) - require.NoError(t, err) + resp := h.getVersion(context.Background(), &events.LambdaFunctionURLRequest{}) assert.Equal(t, "abc1234", resp.Version) assert.Equal(t, "abc1234", resp.GitSHA) diff --git a/internal/api/health.go b/internal/api/health.go index 82cda27cc..753c62a52 100644 --- a/internal/api/health.go +++ b/internal/api/health.go @@ -37,7 +37,7 @@ func (h *Handler) GetHealth(ctx context.Context) (*HealthResponse, error) { } // Check auth service (includes database connection) - authCheck := h.checkAuthService(ctx) + authCheck := h.checkAuthService() response.Checks["auth_service"] = authCheck if authCheck.Status != "healthy" { response.Status = "degraded" @@ -108,7 +108,7 @@ func (h *Handler) checkCredentialStore() HealthCheck { } // checkAuthService checks if the auth service is accessible. -func (h *Handler) checkAuthService(ctx context.Context) HealthCheck { +func (h *Handler) checkAuthService() HealthCheck { if h.auth == nil { return HealthCheck{ Status: "unhealthy", diff --git a/internal/api/health_test.go b/internal/api/health_test.go index 191b174f8..91d5ebd17 100644 --- a/internal/api/health_test.go +++ b/internal/api/health_test.go @@ -187,22 +187,20 @@ func TestHandler_checkConfigStore_AccessError(t *testing.T) { } func TestHandler_checkAuthService_Healthy(t *testing.T) { - ctx := context.Background() mockAuth := new(MockAuthService) handler := &Handler{auth: mockAuth} - check := handler.checkAuthService(ctx) + check := handler.checkAuthService() assert.Equal(t, "healthy", check.Status) assert.Empty(t, check.Message) } func TestHandler_checkAuthService_NotInitialized(t *testing.T) { - ctx := context.Background() handler := &Handler{auth: nil} - check := handler.checkAuthService(ctx) + check := handler.checkAuthService() assert.Equal(t, "unhealthy", check.Status) assert.Equal(t, "Auth service not initialized", check.Message) diff --git a/internal/api/ri_utilization_cache_test.go b/internal/api/ri_utilization_cache_test.go index 5660ead0d..90901c7a6 100644 --- a/internal/api/ri_utilization_cache_test.go +++ b/internal/api/ri_utilization_cache_test.go @@ -59,6 +59,8 @@ func (f *fakeRIUtilCacheStore) UpsertRIUtilizationCache(ctx context.Context, reg // seedStale writes a cache row with fetchedAt set in the past so the // caller can control exactly how "stale" the row is relative to soft // / hard TTLs. +// +//nolint:unparam // test helper: region fixed by design across current callers func (f *fakeRIUtilCacheStore) seedStale(t *testing.T, region string, lookbackDays int, data []recommendations.RIUtilization, age time.Duration) { t.Helper() payload, err := json.Marshal(data) @@ -225,7 +227,8 @@ func TestRIUtilizationCache_SingleflightCollapsesConcurrentRefreshes(t *testing. // Gate the fetcher so concurrent calls all race to enter the // refresh — singleflight should collapse them. release := make(chan struct{}) - fetch := func(ctx context.Context, lookbackDays int) ([]recommendations.RIUtilization, error) { + //nolint:unparam // fetcher signature is fixed by newRIUtilizationCache; this refresh path never errors + fetch := func(_ context.Context, _ int) ([]recommendations.RIUtilization, error) { calls.Add(1) <-release return freshData, nil diff --git a/internal/api/router.go b/internal/api/router.go index 0f4a25ce1..4654103e0 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -735,7 +735,7 @@ func (r *Router) getPublicInfoHandler(ctx context.Context, req *events.LambdaFun } func (r *Router) getVersionHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { - return r.h.getVersion(ctx, req) + return r.h.getVersion(ctx, req), nil } func (r *Router) getDeploymentInfoHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { diff --git a/internal/api/router_660_permission_flips_test.go b/internal/api/router_660_permission_flips_test.go index c56a0a51a..6085ea116 100644 --- a/internal/api/router_660_permission_flips_test.go +++ b/internal/api/router_660_permission_flips_test.go @@ -71,6 +71,7 @@ func reqWithBearer(token string) *events.LambdaFunctionURLRequest { } } +//nolint:unparam // test helper: token fixed by design across current callers func reqWithBearerAndBody(token, body string) *events.LambdaFunctionURLRequest { return &events.LambdaFunctionURLRequest{ Headers: map[string]string{"Authorization": "Bearer " + token}, diff --git a/internal/api/scoping.go b/internal/api/scoping.go index cf479454e..45d8257c0 100644 --- a/internal/api/scoping.go +++ b/internal/api/scoping.go @@ -45,21 +45,6 @@ func (h *Handler) requireAccountAccess(ctx context.Context, session *Session, ac return account, nil } -// canAccessAccountID is the lightweight, no-DB variant of requireAccountAccess -// for callers that already have the account's ID AND name (e.g. when iterating -// a list that was already fetched). Returns true when the session is -// unrestricted or the allowed_accounts list matches. -func (h *Handler) canAccessAccountID(ctx context.Context, session *Session, accountID, accountName string) (bool, error) { - allowed, err := h.getAllowedAccounts(ctx, session) - if err != nil { - return false, fmt.Errorf("failed to get allowed accounts: %w", err) - } - if auth.IsUnrestrictedAccess(allowed) { - return true, nil - } - return auth.MatchesAccount(allowed, accountID, accountName), nil -} - // requirePlanAccess fetches the plan's associated accounts and rejects with // errNotFound when the session's allowed_accounts list doesn't intersect // with any of them. Admin / unrestricted sessions pass through unchanged. diff --git a/internal/auth/service_password.go b/internal/auth/service_password.go index f6e036539..90e71795d 100644 --- a/internal/auth/service_password.go +++ b/internal/auth/service_password.go @@ -25,7 +25,8 @@ const bcryptCost = 12 // The plain-text "dummy" value is intentionally unguessable and never stored. // // Generated once at compile time with cost bcryptCost (12). -// nolint:gosec -- this is a public sentinel hash, not a credential +// +//nolint:gosec // this is a public sentinel hash, not a credential var dummyPasswordHash = "$2a$12$iAMeexq41AwZ2Dj9oAvGfeVHQxK5ffLPPTNxwPB8bsf7olA730dxO" // Password validation constants following NIST guidelines. diff --git a/internal/auth/service_user.go b/internal/auth/service_user.go index 4ea3cae0e..0b38ee4ae 100644 --- a/internal/auth/service_user.go +++ b/internal/auth/service_user.go @@ -307,9 +307,7 @@ func (s *Service) UpdateUser(ctx context.Context, actorUserID, userID string, re priorGroups := append([]string(nil), user.GroupIDs...) priorActive := user.Active - if err := applyUpdateUserRequest(user, req); err != nil { - return nil, err - } + applyUpdateUserRequest(user, req) if req.GroupIDs != nil { if err := s.guardGroupChange(ctx, actorUserID, userID, priorGroups, req.GroupIDs); err != nil { @@ -434,15 +432,16 @@ func addsNewGroup(prior, next []string) bool { return false } -// applyUpdateUserRequest applies the non-nil fields of req to user, validating as needed. -func applyUpdateUserRequest(user *User, req UpdateUserRequest) error { +// applyUpdateUserRequest applies the non-nil fields of req to user. GroupID +// membership changes are validated separately by the caller via +// guardGroupChange; Active is a bool with no per-field validation. +func applyUpdateUserRequest(user *User, req UpdateUserRequest) { if req.GroupIDs != nil { user.GroupIDs = req.GroupIDs } if req.Active != nil { user.Active = *req.Active } - return nil } // DeleteUser removes a user (requires manage-users permission). Refuses to diff --git a/internal/config/recommendation_overrides_test.go b/internal/config/recommendation_overrides_test.go index 060c5892a..3ae5f1e6d 100644 --- a/internal/config/recommendation_overrides_test.go +++ b/internal/config/recommendation_overrides_test.go @@ -34,6 +34,7 @@ func (f *fakeAccountConfigReader) GetAccountServiceOverride(_ context.Context, a return f.overrides[accountID+"|"+provider+"|"+service], nil } +//nolint:unparam // test helper: provider fixed by design across current callers func acctRec(account, provider, service string) RecommendationRecord { return RecommendationRecord{ Provider: provider, diff --git a/internal/config/store_postgres_additional_test.go b/internal/config/store_postgres_additional_test.go index 98f5725e6..78f698e22 100644 --- a/internal/config/store_postgres_additional_test.go +++ b/internal/config/store_postgres_additional_test.go @@ -114,6 +114,7 @@ func (s *additionalMockStore) GetExecutionByPlanAndDate(ctx context.Context, pla return &executions[0], nil } +//nolint:unparam // mock mirrors the production store signature; query fixed by the single test path func (s *additionalMockStore) queryPurchaseHistory(ctx context.Context, query string, args ...interface{}) ([]PurchaseHistoryRecord, error) { rows, err := s.mock.Query(ctx, query, args...) if err != nil { diff --git a/internal/database/postgres/migrations/migrate.go b/internal/database/postgres/migrations/migrate.go index 015d3bedd..d605d1f6f 100644 --- a/internal/database/postgres/migrations/migrate.go +++ b/internal/database/postgres/migrations/migrate.go @@ -87,7 +87,7 @@ func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath strin // masked by the later dirty check. func newMigratorWithRecovery(pool *pgxpool.Pool, migrationsPath string) (*migrate.Migrate, error) { // Get database connection string from pool config (without admin email parameter - RDS Proxy doesn't support options) - dsn := buildMigrateDSN(pool.Config(), "") + dsn := buildMigrateDSN(pool.Config()) m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), @@ -423,7 +423,7 @@ func RollbackMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath return fmt.Errorf("refusing to rollback more than %d migrations at once (requested %d); use multiple calls for safety", maxRollbackSteps, steps) } - dsn := buildMigrateDSN(pool.Config(), "") + dsn := buildMigrateDSN(pool.Config()) m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), @@ -461,7 +461,7 @@ func RollbackMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath // the version just below the migration under test; fixed step counts from // head silently drift every time a newer migration lands. func MigrateToVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath string, version uint) error { - dsn := buildMigrateDSN(pool.Config(), "") + dsn := buildMigrateDSN(pool.Config()) m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), @@ -493,7 +493,7 @@ func MigrateToVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath st // GetMigrationVersion returns the current migration version. func GetMigrationVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath string) (uint, bool, error) { - dsn := buildMigrateDSN(pool.Config(), "") + dsn := buildMigrateDSN(pool.Config()) m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), @@ -513,8 +513,9 @@ func GetMigrationVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath } // buildMigrateDSN builds a connection string for golang-migrate from pgx config. -// sslModeOverride, if non-empty, is used instead of inferring from TLSConfig. -func buildMigrateDSN(config *pgxpool.Config, sslModeOverride string) string { +// sslmode is inferred from the pgx TLSConfig ("require" when TLS is configured, +// "disable" otherwise). +func buildMigrateDSN(config *pgxpool.Config) string { // Extract connection details from pgx config host := config.ConnConfig.Host port := config.ConnConfig.Port @@ -526,13 +527,10 @@ func buildMigrateDSN(config *pgxpool.Config, sslModeOverride string) string { encodedUser := url.QueryEscape(user) encodedPassword := url.QueryEscape(password) - // Use explicit sslmode if provided, otherwise infer from TLS config - sslMode := sslModeOverride - if sslMode == "" { - sslMode = "require" - if config.ConnConfig.TLSConfig == nil { - sslMode = "disable" - } + // Infer sslmode from the TLS config. + sslMode := "require" + if config.ConnConfig.TLSConfig == nil { + sslMode = "disable" } // Build DSN (golang-migrate uses postgres:// format) diff --git a/internal/database/postgres/migrations/migrate_security_test.go b/internal/database/postgres/migrations/migrate_security_test.go index da73373ce..e67a20b27 100644 --- a/internal/database/postgres/migrations/migrate_security_test.go +++ b/internal/database/postgres/migrations/migrate_security_test.go @@ -122,7 +122,7 @@ func TestBuildMigrateDSN_PasswordNotInLogs(t *testing.T) { require.NoError(t, err, "pgxpool.ParseConfig must accept the sentinel DSN") // Call the function under test. - result := buildMigrateDSN(poolCfg, "") + result := buildMigrateDSN(poolCfg) // The sentinel must appear in the returned DSN (proves the function embeds it). assert.Contains(t, result, sentinelPassword, diff --git a/internal/email/sender_test.go b/internal/email/sender_test.go index d68ebec92..767d161cf 100644 --- a/internal/email/sender_test.go +++ b/internal/email/sender_test.go @@ -62,30 +62,6 @@ func (m *MockSESClient) CreateEmailIdentity(ctx context.Context, input *sesv2.Cr return args.Get(0).(*sesv2.CreateEmailIdentityOutput), args.Error(1) } -// testSender creates a sender with mock clients for testing. -type testSender struct { - *Sender - mockSNS *MockSNSClient - mockSES *MockSESClient -} - -func newTestSender(topicARN, fromEmail string) *testSender { - mockSNS := new(MockSNSClient) - mockSES := new(MockSESClient) - - return &testSender{ - Sender: &Sender{ - snsClient: nil, // Will be replaced in tests - sesClient: nil, // Will be replaced in tests - topicARN: topicARN, - fromEmail: fromEmail, - emailAddress: "", - }, - mockSNS: mockSNS, - mockSES: mockSES, - } -} - func TestSenderConfig(t *testing.T) { cfg := SenderConfig{ TopicARN: "arn:aws:sns:us-east-1:123456789012:topic", diff --git a/internal/email/smtp_server_test.go b/internal/email/smtp_server_test.go index 311c0a8d0..3451f4986 100644 --- a/internal/email/smtp_server_test.go +++ b/internal/email/smtp_server_test.go @@ -43,7 +43,7 @@ func newMockSMTPServer(t *testing.T, authFail bool) *mockSMTPServer { } // start begins accepting connections. -func (s *mockSMTPServer) start(t *testing.T) { +func (s *mockSMTPServer) start(_ *testing.T) { s.wg.Add(1) go func() { defer s.wg.Done() diff --git a/internal/purchase/coverage_extra_test.go b/internal/purchase/coverage_extra_test.go index 6032ea9ec..8dc4b7c91 100644 --- a/internal/purchase/coverage_extra_test.go +++ b/internal/purchase/coverage_extra_test.go @@ -607,7 +607,7 @@ func TestManager_ExecuteSinglePurchase_ProviderError(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) assert.Error(t, err) assert.Contains(t, err.Error(), "some purchases failed") assert.Equal(t, "failed to create aws provider: provider unavailable", exec.Recommendations[0].Error) @@ -656,7 +656,7 @@ func TestManager_ExecuteSinglePurchase_ServiceClientError(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) assert.Error(t, err) assert.Contains(t, err.Error(), "some purchases failed") assert.Contains(t, exec.Recommendations[0].Error, "failed to get service client") @@ -708,7 +708,7 @@ func TestManager_ExecuteSinglePurchase_PurchaseNotSuccessful(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) assert.Error(t, err) assert.Contains(t, err.Error(), "some purchases failed") assert.Contains(t, exec.Recommendations[0].Error, "purchase was not successful") @@ -761,7 +761,7 @@ func TestManager_ExecuteSinglePurchase_PurchaseNotSuccessful_WithError(t *testin dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) assert.Error(t, err) assert.Contains(t, exec.Recommendations[0].Error, "capacity limit exceeded") } @@ -817,7 +817,7 @@ func TestManager_ExecuteSinglePurchase_WithEngine(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) assert.True(t, exec.Recommendations[0].Purchased) assert.Equal(t, "ri-engine-001", exec.Recommendations[0].PurchaseID) @@ -875,7 +875,7 @@ func TestManager_SavePurchaseHistory_Error(t *testing.T) { } // Should succeed even though history save failed - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) assert.True(t, exec.Recommendations[0].Purchased) } @@ -1183,7 +1183,7 @@ func TestManager_ExecuteSinglePurchase_DetailsByService(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err = manager.executePurchase(ctx, exec) + err = manager.executePurchase(ctx, exec) require.NoError(t, err, "purchase should not return the regression error 'invalid service details for '") assert.True(t, exec.Recommendations[0].Purchased, "rec should be marked purchased") assert.Empty(t, exec.Recommendations[0].Error, "rec error should be empty") @@ -1321,7 +1321,7 @@ func TestManager_ExecuteSinglePurchase_LegacyEmptyDetails(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err, "legacy empty-Details rec must still purchase cleanly") require.NotNil(t, capturedRec.Details, "rec.Details handed to the cloud client must be non-nil even for legacy rows") tc.assertDetails(t, capturedRec.Details) diff --git a/internal/purchase/execution.go b/internal/purchase/execution.go index 8d7b839e1..99e4c584d 100644 --- a/internal/purchase/execution.go +++ b/internal/purchase/execution.go @@ -27,9 +27,11 @@ import ( // PurchaseExecution record tagged with cloud_account_id. // If no accounts are configured or no credential store is available, it falls back // to single-account execution using ambient credentials. -// executePurchase runs the purchase for a single execution. Returns wasMultiAccount=true when -// fan-out was used (per-account records are already saved; caller should skip root record save). -func (m *Manager) executePurchase(ctx context.Context, exec *config.PurchaseExecution) (wasMultiAccount bool, err error) { +// executePurchase runs the purchase for a single execution. When the plan has +// associated cloud accounts it fans out via executeMultiAccount (which saves its +// own per-account records); otherwise it runs the single-account path. The root +// row is always finalized+saved by the caller regardless of which path ran. +func (m *Manager) executePurchase(ctx context.Context, exec *config.PurchaseExecution) (err error) { logging.Infof("Executing purchase for plan %q, step %d", exec.PlanID, exec.StepNumber) // Direct-execute purchases (Opportunities "Purchase" button) arrive @@ -45,26 +47,26 @@ func (m *Manager) executePurchase(ctx context.Context, exec *config.PurchaseExec } else { plan, err = m.config.GetPurchasePlan(ctx, exec.PlanID) if err != nil { - return false, fmt.Errorf("failed to get plan: %w", err) + return fmt.Errorf("failed to get plan: %w", err) } if plan == nil { - return false, fmt.Errorf("plan not found: %s", exec.PlanID) + return fmt.Errorf("plan not found: %s", exec.PlanID) } // Fan out across plan accounts when accounts are configured. if exec.CloudAccountID == nil { accounts, err := m.config.GetPlanAccounts(ctx, exec.PlanID) if err != nil { - return false, fmt.Errorf("failed to load plan accounts for plan %s: %w", exec.PlanID, err) + return fmt.Errorf("failed to load plan accounts for plan %s: %w", exec.PlanID, err) } if len(accounts) > 0 { - return true, m.executeMultiAccount(ctx, exec, plan, accounts) + return m.executeMultiAccount(ctx, exec, plan, accounts) } } } // Single-account (legacy) path. - return false, m.executeSingleAccount(ctx, exec, plan) + return m.executeSingleAccount(ctx, exec, plan) } // executeSingleAccount runs the legacy single-account purchase path: resolve diff --git a/internal/purchase/execution_test.go b/internal/purchase/execution_test.go index aee211918..39206b040 100644 --- a/internal/purchase/execution_test.go +++ b/internal/purchase/execution_test.go @@ -22,6 +22,8 @@ import ( // lock down the per-rec context.WithTimeout(ctx, max) contract introduced for // issue #683: a bare _, ok := c.Deadline(); return ok matcher would pass even // if executeSinglePurchase silently fell back to the parent's longer deadline. +// +//nolint:unparam // test helper: max fixed to the per-rec timeout under test func hasPerRecDeadline(max time.Duration) func(context.Context) bool { return func(c context.Context) bool { deadline, ok := c.Deadline() @@ -98,7 +100,7 @@ func TestManager_ExecutePurchase(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) // Verify that only selected recommendation was purchased @@ -164,7 +166,7 @@ func TestManager_ExecutePurchase_WebSourcePropagates(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) mockServiceClient.AssertExpectations(t) } @@ -220,7 +222,7 @@ func TestManager_ExecutePurchase_InvalidSourceFallsBackUntagged(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) mockServiceClient.AssertExpectations(t) } @@ -244,7 +246,7 @@ func TestManager_ExecutePurchase_PlanNotFound(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) assert.Error(t, err) assert.Contains(t, err.Error(), "plan not found") @@ -270,7 +272,7 @@ func TestManager_ExecutePurchase_GetPlanError(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to get plan") @@ -308,7 +310,7 @@ func TestManager_ExecutePurchase_NoRecommendations(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) mockStore.AssertExpectations(t) @@ -532,7 +534,7 @@ func TestManager_ExecutePurchase_MultiAccount(t *testing.T) { // assumeRoleSTS is nil → access_keys path, no role assumption needed } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) // The original exec record should be unchanged (fan-out creates per-account copies). @@ -643,7 +645,7 @@ func TestExecuteForAccount_CredentialFailure_MarksFailed(t *testing.T) { credStore: credStore, } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) // Must surface the credential failure — no ambient fallback. require.Error(t, err) @@ -775,7 +777,7 @@ func TestExecuteMultiAccount_PartialFailure_IsolatesAccounts(t *testing.T) { credStore: credStore, } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) // The call must return an error (account-I's failure is aggregated). // This proves the errgroup collected the failure rather than discarding it. @@ -942,7 +944,7 @@ func TestExecuteMultiAccount_RunsAccountsInParallel(t *testing.T) { } start := time.Now() - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) elapsed := time.Since(start) require.NoError(t, err, "both accounts have valid credentials and should succeed") @@ -1061,7 +1063,7 @@ func TestExecutePurchase_SingleAccount_AzureUsesResolvedCreds(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) mockStore.AssertExpectations(t) @@ -1180,7 +1182,7 @@ func TestExecutePurchase_AzureCanonicalServiceTypes(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) mockStore.AssertExpectations(t) @@ -1225,7 +1227,7 @@ func TestExecutePurchase_SingleAccount_CredResolutionError(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.Error(t, err) assert.Contains(t, err.Error(), "credential resolution failed for account "+acctID) assert.Contains(t, err.Error(), "connection refused") @@ -1268,7 +1270,7 @@ func TestExecutePurchase_SingleAccount_AccountNotFound(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.Error(t, err) assert.Contains(t, err.Error(), "credential resolution failed for account "+acctID) assert.Contains(t, err.Error(), "account not found") @@ -1692,7 +1694,7 @@ func TestManager_ExecutePurchase_SingleAccount_StampsTargetAccount(t *testing.T) dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) assert.Equal(t, tc.externalID, stampedAccount, diff --git a/internal/purchase/manager.go b/internal/purchase/manager.go index c9b3f0159..dbd371a8c 100644 --- a/internal/purchase/manager.go +++ b/internal/purchase/manager.go @@ -227,7 +227,7 @@ func isMultiAccountAckable(execErr error) bool { // claimAndExecute claims the root to "running" first (issue #1013), would strand // the root row in "running" until the reaper failed it. func (m *Manager) executeAndFinalize(ctx context.Context, exec *config.PurchaseExecution) error { - _, execErr := m.executePurchase(ctx, exec) + execErr := m.executePurchase(ctx, exec) m.finalizeExecution(exec, execErr) if execErr != nil { logging.Errorf("Failed to execute purchase %s: %v", exec.ExecutionID, execErr) diff --git a/internal/scheduler/scheduler_overrides_test.go b/internal/scheduler/scheduler_overrides_test.go index fd9493f5e..ba2be7793 100644 --- a/internal/scheduler/scheduler_overrides_test.go +++ b/internal/scheduler/scheduler_overrides_test.go @@ -76,6 +76,8 @@ func (m *mockOverrideStore) GetGlobalConfig(_ context.Context) (*config.GlobalCo func boolPtr(b bool) *bool { return &b } // rdsRec returns a rec for the given account/region/engine with sensible defaults. +// +//nolint:unparam // test helper: region fixed by design across current callers func rdsRec(account, region, engine string) config.RecommendationRecord { a := account return config.RecommendationRecord{ diff --git a/internal/server/app.go b/internal/server/app.go index 25cda96e1..bcc390c4b 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -536,8 +536,9 @@ func NewApplication(ctx context.Context, version string) (*Application, error) { log.Printf("CUDly Server initializing, version: %s", cfg.Version) - // Initialize configuration store (PostgreSQL) - configStore, dbConfig, secretResolver, err := initConfigStore(ctx) + // Initialize configuration store (PostgreSQL). The store connects lazily on + // first request, so ConfigStore is wired as nil here (see initConfigStore). + dbConfig, secretResolver, err := initConfigStore(ctx) if err != nil { return nil, fmt.Errorf("failed to initialize config store: %w", err) } @@ -557,7 +558,7 @@ func NewApplication(ctx context.Context, version string) (*Application, error) { deps := ExternalDeps{ EmailSender: emailSender, - ConfigStore: configStore, + ConfigStore: nil, // created lazily on first request (see initConfigStore) DBConfig: dbConfig, SecretResolver: secretResolver, STSClient: stsClient, @@ -904,10 +905,10 @@ func (app *Application) Close() error { // initConfigStore initializes the configuration store using PostgreSQL // Connection is deferred (lazy init) until first request to avoid Lambda ENI issues. -func initConfigStore(ctx context.Context) (config.StoreInterface, *database.Config, secrets.Resolver, error) { +func initConfigStore(ctx context.Context) (*database.Config, secrets.Resolver, error) { // Require PostgreSQL configuration if os.Getenv("DB_HOST") == "" { - return nil, nil, nil, fmt.Errorf("database configuration required: DB_HOST must be set") + return nil, nil, fmt.Errorf("database configuration required: DB_HOST must be set") } log.Println("Preparing PostgreSQL configuration store (lazy initialization)...") @@ -915,20 +916,21 @@ func initConfigStore(ctx context.Context) (config.StoreInterface, *database.Conf // Initialize secret resolver secretResolver, err := secrets.NewResolver(ctx, secrets.LoadConfigFromEnv()) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to create secret resolver: %w", err) + return nil, nil, fmt.Errorf("failed to create secret resolver: %w", err) } // Load database config from environment dbConfig, err := database.LoadFromEnv() if err != nil { - return nil, nil, nil, fmt.Errorf("failed to load database config: %w", err) + return nil, nil, fmt.Errorf("failed to load database config: %w", err) } log.Printf("PostgreSQL config loaded (will connect on first request): %s:%d", dbConfig.Host, dbConfig.Port) - // Return nil for config store - will be created lazily - // This avoids connecting during Lambda init when ENI isn't ready - return nil, dbConfig, secretResolver, nil + // The config store itself is created lazily on first request (not here), so + // callers wire a nil ConfigStore into ExternalDeps. Deferring the connection + // avoids connecting during Lambda init when the ENI isn't ready. + return dbConfig, secretResolver, nil } // Helper functions for environment variable parsing @@ -945,6 +947,11 @@ func getEnvInt(key string, defaultVal int) int { return defaultVal } +// getEnvFloat mirrors getEnvInt for float-valued env vars. defaultVal is kept +// parameterized (rather than inlined) to stay symmetric with getEnvInt even +// though every current caller passes the same coverage default. +// +//nolint:unparam // general-purpose env parser; default kept parameterized for symmetry with getEnvInt func getEnvFloat(key string, defaultVal float64) float64 { if val := os.Getenv(key); val != "" { result, err := strconv.ParseFloat(val, 64) diff --git a/internal/server/app_test.go b/internal/server/app_test.go index 332151688..eb167b053 100644 --- a/internal/server/app_test.go +++ b/internal/server/app_test.go @@ -596,7 +596,7 @@ func TestInitConfigStore(t *testing.T) { t.Run("missing DB_HOST returns error", func(t *testing.T) { testutil.SetEnv(t, "DB_HOST", "") - _, _, _, err := initConfigStore(context.Background()) + _, _, err := initConfigStore(context.Background()) testutil.AssertError(t, err) testutil.AssertContains(t, err.Error(), "DB_HOST must be set") }) @@ -608,9 +608,8 @@ func TestInitConfigStore(t *testing.T) { testutil.SetEnv(t, "SECRET_PROVIDER", "env") testutil.SetEnv(t, "AWS_REGION_CONFIG", "us-east-1") - configStore, dbConfig, resolver, err := initConfigStore(context.Background()) + dbConfig, resolver, err := initConfigStore(context.Background()) testutil.AssertNoError(t, err) - testutil.AssertTrue(t, configStore == nil, "Config store should be nil (lazy init)") testutil.AssertTrue(t, dbConfig != nil, "DB config should not be nil") testutil.AssertTrue(t, resolver != nil, "Secret resolver should not be nil") testutil.AssertEqual(t, "localhost", dbConfig.Host) diff --git a/internal/server/handler.go b/internal/server/handler.go index f0da8af3d..46549cf09 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -177,6 +177,11 @@ func (app *Application) handleSendNotifications(ctx context.Context) (*purchase. } // handleCleanupExpiredRecords cleans up expired sessions and execution records. +// +// contract for the handler family registered in the task dispatch map; error is +// reserved for the failure modes the sibling handlers already surface. +// +//nolint:unparam // scheduled-task handler: (result, error) shape is the shared func (app *Application) handleCleanupExpiredRecords(ctx context.Context) (map[string]int64, error) { log.Println("Cleaning up expired records...") @@ -267,6 +272,11 @@ func (app *Application) handleFinalizeRevocations(ctx context.Context) (*purchas } // handleRefreshAnalytics refreshes materialized views and analytics data. +// +// contract for the handler family registered in the task dispatch map; error is +// reserved for the failure modes the sibling handlers already surface. +// +//nolint:unparam // scheduled-task handler: (result, error) shape is the shared func (app *Application) handleRefreshAnalytics(ctx context.Context) (map[string]any, error) { log.Println("Refreshing analytics...") diff --git a/internal/server/handler_coverage_test.go b/internal/server/handler_coverage_test.go index 568d087e2..db929ed90 100644 --- a/internal/server/handler_coverage_test.go +++ b/internal/server/handler_coverage_test.go @@ -114,33 +114,8 @@ func TestConfigExchangeStoreAdapter_CompleteRIExchange(t *testing.T) { ctx := testutil.TestContext(t) var completedID, completedExchangeID string - store := &mockConfigStoreForExchange{ - mockConfigStoreForHealth: mockConfigStoreForHealth{}, - } - // Override CompleteRIExchange via embedding: use the base mock which returns nil - // Then verify the call reached the underlying store via a custom wrapper. - type customStore struct { - mockConfigStoreForHealth - completeFunc func(ctx context.Context, id, exchangeID string) error - } - cs := &struct { - mockConfigStoreForExchange - completeOverride func(ctx context.Context, id, exchangeID string) error - }{ - mockConfigStoreForExchange: *store, - completeOverride: func(ctx context.Context, id, exID string) error { - completedID = id - completedExchangeID = exID - return nil - }, - } - _ = cs - - // Use a simpler approach: directly test the adapter with the mock store. + // Test the adapter directly with a mock store that records the CompleteRIExchange call. called := false - type storeWithComplete struct { - mockConfigStoreForExchange - } var completeStore config.StoreInterface = &mockConfigStoreForExchangeComplete{ mockConfigStoreForExchange: mockConfigStoreForExchange{}, completeFunc: func(ctx context.Context, id, exID string) error {