diff --git a/.golangci.yml b/.golangci.yml index 398857762..89b875da2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -84,6 +84,10 @@ linters: - linters: - all path: .*_gen\.go + - linters: + - revive + text: "avoid meaningless package names" + path: internal/api/ paths: - third_party$ - builtin$ diff --git a/ci_cd_sanity_tests/cmd/azure_sanity/main.go b/ci_cd_sanity_tests/cmd/azure_sanity/main.go index 44ea9d45d..bb0980847 100644 --- a/ci_cd_sanity_tests/cmd/azure_sanity/main.go +++ b/ci_cd_sanity_tests/cmd/azure_sanity/main.go @@ -11,6 +11,10 @@ import ( ) func main() { + os.Exit(run()) +} + +func run() int { var ( subID = flag.String("subscription-id", "", "Azure subscription ID (or set AZURE_SUBSCRIPTION_ID)") expectedTenant = flag.String("expected-tenant", "", "Expected Azure tenant ID (optional)") @@ -31,18 +35,19 @@ func main() { }) if err != nil { fmt.Fprintf(os.Stderr, "azure sanity run failed: %v\n", err) - os.Exit(2) + return 2 } if err := rep.WriteJSON(*outPath); err != nil { fmt.Fprintf(os.Stderr, "write report failed: %v\n", err) - os.Exit(2) + return 2 } if rep.HasFailures() { fmt.Fprintf(os.Stderr, "azure sanity: FAIL (see %s)\n", *outPath) - os.Exit(1) + return 1 } fmt.Printf("azure sanity: PASS (see %s)\n", *outPath) + return 0 } diff --git a/ci_cd_sanity_tests/cmd/ri-exchange/main.go b/ci_cd_sanity_tests/cmd/ri-exchange/main.go index a4a9cfee7..e484d4d45 100644 --- a/ci_cd_sanity_tests/cmd/ri-exchange/main.go +++ b/ci_cd_sanity_tests/cmd/ri-exchange/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "flag" "fmt" + "math" "os" "strings" "time" @@ -13,17 +14,16 @@ import ( ) type Output struct { - Mode string `json:"mode"` // dry-run | execute - Region string `json:"region"` - AccountChk string `json:"expected_account,omitempty"` - - ReservedIDs []string `json:"reserved_instance_ids"` + Quote any `json:"quote"` + Mode string `json:"mode"` + Region string `json:"region"` + AccountChk string `json:"expected_account,omitempty"` TargetOfferingID string `json:"target_offering_id"` - TargetCount int32 `json:"target_count"` MaxPaymentDueUSD string `json:"max_payment_due_usd,omitempty"` ExchangeID string `json:"exchange_id,omitempty"` - Quote any `json:"quote"` Error string `json:"error,omitempty"` + ReservedIDs []string `json:"reserved_instance_ids"` + TargetCount int32 `json:"target_count"` } func parseIDs(s string) []string { @@ -38,115 +38,158 @@ func parseIDs(s string) []string { } func main() { - var ( - region = flag.String("region", "us-east-1", "AWS region") - expectedAccount = flag.String("expected-account", "", "Safety check: expected AWS account ID (optional)") - - riIDsCSV = flag.String("ri-ids", "", "Comma-separated Convertible Reserved Instance IDs to exchange (required)") - targetOffering = flag.String("target-offering-id", "", "Target RI offering ID (required)") - targetCount = flag.Int("target-count", 1, "Target instance count (default 1)") - - // Execution gating - execute = flag.Bool("execute", false, "Actually execute the exchange (default false = quote only)") - ack = flag.String("ack", "", "Must be 'YES' to execute (safety)") - maxPaymentDue = flag.String("max-payment-due-usd", "", "Max allowed paymentDue from quote (required for execute). Example: 5.00") - - outPath = flag.String("out", "ri_exchange_result.json", "Output JSON path") - timeoutSec = flag.Int("timeout-sec", 180, "Timeout seconds") - ) - flag.Parse() + os.Exit(run()) +} - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeoutSec)*time.Second) - defer cancel() +// runArgs holds the parsed CLI arguments for run(). +type runArgs struct { + region string + expectedAccount string + targetOffering string + ack string + maxPaymentDue string + outPath string + ids []string + timeoutSec int + targetCount32 int32 + execute bool +} + +// parseArgs parses CLI flags and validates required inputs. +// Returns (args, exit code) where exit code 0 means success. +func parseArgs() (args runArgs, code int) { + region := flag.String("region", "us-east-1", "AWS region") + expectedAccount := flag.String("expected-account", "", "Safety check: expected AWS account ID (optional)") + riIDsCSV := flag.String("ri-ids", "", "Comma-separated Convertible Reserved Instance IDs to exchange (required)") + targetOffering := flag.String("target-offering-id", "", "Target RI offering ID (required)") + targetCount := flag.Int("target-count", 1, "Target instance count (default 1)") + execute := flag.Bool("execute", false, "Actually execute the exchange (default false = quote only)") + ack := flag.String("ack", "", "Must be 'YES' to execute (safety)") + maxPaymentDue := flag.String("max-payment-due-usd", "", "Max allowed paymentDue from quote (required for execute). Example: 5.00") + outPath := flag.String("out", "ri_exchange_result.json", "Output JSON path") + timeoutSec := flag.Int("timeout-sec", 180, "Timeout seconds") + flag.Parse() ids := parseIDs(*riIDsCSV) if len(ids) == 0 { fmt.Fprintln(os.Stderr, "ERROR: --ri-ids is required (comma-separated)") - os.Exit(2) + return runArgs{}, 2 } if strings.TrimSpace(*targetOffering) == "" { fmt.Fprintln(os.Stderr, "ERROR: --target-offering-id is required") - os.Exit(2) + return runArgs{}, 2 } + tcVal := *targetCount + if tcVal < 0 || tcVal > math.MaxInt32 { + fmt.Fprintf(os.Stderr, "ERROR: --target-count value %d out of range [0, %d]\n", tcVal, math.MaxInt32) + return runArgs{}, 2 + } + return runArgs{ + region: *region, + expectedAccount: *expectedAccount, + ids: ids, + targetOffering: *targetOffering, + targetCount32: int32(tcVal), + execute: *execute, + ack: *ack, + maxPaymentDue: *maxPaymentDue, + outPath: *outPath, + timeoutSec: *timeoutSec, + }, 0 +} - o := Output{ - Region: *region, - AccountChk: *expectedAccount, - ReservedIDs: ids, - TargetOfferingID: *targetOffering, - TargetCount: int32(*targetCount), - } - - if !*execute { - o.Mode = "dry-run" - q, err := exchange.GetExchangeQuote(ctx, exchange.ExchangeQuoteRequest{ - Region: *region, - ExpectedAccount: *expectedAccount, - ReservedIDs: ids, - TargetOfferingID: *targetOffering, - TargetCount: int32(*targetCount), - DryRun: false, // IAMCheckOnly: false = real quote, true = only verify IAM permissions - }) - if err != nil { - o.Error = err.Error() - o.Quote = q - writeOrExit(o, *outPath) - fmt.Fprintf(os.Stderr, "quote: FAIL (see %s)\n", *outPath) - os.Exit(1) - } +// runQuote handles the dry-run (quote-only) path. +func runQuote(ctx context.Context, a *runArgs, o *Output) int { + o.Mode = "dry-run" + q, err := exchange.GetExchangeQuote(ctx, &exchange.QuoteRequest{ + Region: a.region, + ExpectedAccount: a.expectedAccount, + ReservedIDs: a.ids, + TargetOfferingID: a.targetOffering, + TargetCount: a.targetCount32, + DryRun: false, // IAMCheckOnly: false = real quote, true = only verify IAM permissions + }) + if err != nil { + o.Error = err.Error() o.Quote = q - writeOrExit(o, *outPath) + writeOrExit(*o, a.outPath) + fmt.Fprintf(os.Stderr, "quote: FAIL (see %s)\n", a.outPath) + return 1 + } + o.Quote = q + writeOrExit(*o, a.outPath) + if !q.IsValidExchange { + fmt.Fprintf(os.Stderr, "quote: INVALID (%s) (see %s)\n", q.ValidationFailureReason, a.outPath) + return 1 + } + fmt.Printf("quote: OK (valid=%v, paymentDue=%s %s) (see %s)\n", q.IsValidExchange, q.PaymentDueRaw, q.CurrencyCode, a.outPath) + return 0 +} - if !q.IsValidExchange { - fmt.Fprintf(os.Stderr, "quote: INVALID (%s) (see %s)\n", q.ValidationFailureReason, *outPath) - os.Exit(1) - } - fmt.Printf("quote: OK (valid=%v, paymentDue=%s %s) (see %s)\n", q.IsValidExchange, q.PaymentDueRaw, q.CurrencyCode, *outPath) - os.Exit(0) +func run() int { + a, code := parseArgs() + if code != 0 { + return code + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(a.timeoutSec)*time.Second) + defer cancel() + + o := Output{ + Region: a.region, + AccountChk: a.expectedAccount, + ReservedIDs: a.ids, + TargetOfferingID: a.targetOffering, + TargetCount: a.targetCount32, + } + + if !a.execute { + return runQuote(ctx, &a, &o) } // Execute path o.Mode = "execute" - if strings.TrimSpace(*ack) != "YES" { + if strings.TrimSpace(a.ack) != "YES" { o.Error = "refusing to execute: pass --ack YES" - writeOrExit(o, *outPath) - fmt.Fprintf(os.Stderr, "execute: REFUSED (see %s)\n", *outPath) - os.Exit(2) + writeOrExit(o, a.outPath) + fmt.Fprintf(os.Stderr, "execute: REFUSED (see %s)\n", a.outPath) + return 2 } - if strings.TrimSpace(*maxPaymentDue) == "" { + if strings.TrimSpace(a.maxPaymentDue) == "" { o.Error = "refusing to execute: --max-payment-due-usd is required as a safety cap" - writeOrExit(o, *outPath) - fmt.Fprintf(os.Stderr, "execute: REFUSED (see %s)\n", *outPath) - os.Exit(2) + writeOrExit(o, a.outPath) + fmt.Fprintf(os.Stderr, "execute: REFUSED (see %s)\n", a.outPath) + return 2 } - maxRat, err := exchange.ParseDecimalRat(*maxPaymentDue) + maxRat, err := exchange.ParseDecimalRat(a.maxPaymentDue) if err != nil { o.Error = err.Error() - writeOrExit(o, *outPath) - fmt.Fprintf(os.Stderr, "execute: BAD INPUT (see %s)\n", *outPath) - os.Exit(2) + writeOrExit(o, a.outPath) + fmt.Fprintf(os.Stderr, "execute: BAD INPUT (see %s)\n", a.outPath) + return 2 } o.MaxPaymentDueUSD = maxRat.FloatString(2) - exID, q, err := exchange.ExecuteExchange(ctx, exchange.ExchangeExecuteRequest{ - Region: *region, - ExpectedAccount: *expectedAccount, - ReservedIDs: ids, - TargetOfferingID: *targetOffering, - TargetCount: int32(*targetCount), + exID, q, err := exchange.ExecuteExchange(ctx, &exchange.ExecuteRequest{ + Region: a.region, + ExpectedAccount: a.expectedAccount, + ReservedIDs: a.ids, + TargetOfferingID: a.targetOffering, + TargetCount: a.targetCount32, MaxPaymentDueUSD: maxRat, }) o.Quote = q if err != nil { o.Error = err.Error() - writeOrExit(o, *outPath) - fmt.Fprintf(os.Stderr, "execute: FAIL (see %s)\n", *outPath) - os.Exit(1) + writeOrExit(o, a.outPath) + fmt.Fprintf(os.Stderr, "execute: FAIL (see %s)\n", a.outPath) + return 1 } o.ExchangeID = exID - writeOrExit(o, *outPath) - fmt.Printf("execute: OK exchangeId=%s (see %s)\n", exID, *outPath) + writeOrExit(o, a.outPath) + fmt.Printf("execute: OK exchangeId=%s (see %s)\n", exID, a.outPath) + return 0 } func write(v any, path string) error { diff --git a/ci_cd_sanity_tests/cmd/sanity/main.go b/ci_cd_sanity_tests/cmd/sanity/main.go index 8c9afc69f..7de3a18c8 100644 --- a/ci_cd_sanity_tests/cmd/sanity/main.go +++ b/ci_cd_sanity_tests/cmd/sanity/main.go @@ -4,6 +4,7 @@ import ( "context" "flag" "fmt" + "math" "os" "time" @@ -11,6 +12,10 @@ import ( ) func main() { + os.Exit(run()) +} + +func run() int { var ( region = flag.String("region", "us-east-1", "AWS region for sanity checks") expectedAccount = flag.String("expected-account", "", "Expected AWS Account ID (optional)") @@ -22,25 +27,31 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() + maxListVal := *maxList + if maxListVal < 0 || maxListVal > math.MaxInt32 { + fmt.Fprintf(os.Stderr, "-max-list value %d out of range [0, %d]\n", maxListVal, math.MaxInt32) + return 2 + } rep, err := aws.Run(ctx, aws.Options{ Region: *region, ExpectedAccount: *expectedAccount, - MaxList: int32(*maxList), + MaxList: int32(maxListVal), }) if err != nil { fmt.Fprintf(os.Stderr, "sanity run failed: %v\n", err) - os.Exit(2) + return 2 } if err := rep.WriteJSON(*outPath); err != nil { fmt.Fprintf(os.Stderr, "write report failed: %v\n", err) - os.Exit(2) + return 2 } if rep.HasFailures() { fmt.Fprintf(os.Stderr, "sanity checks: FAIL (see %s)\n", *outPath) - os.Exit(1) + return 1 } fmt.Printf("sanity checks: PASS (see %s)\n", *outPath) + return 0 } diff --git a/ci_cd_sanity_tests/pkg/sanity/aws/aws.go b/ci_cd_sanity_tests/pkg/sanity/aws/aws.go index 3fb243b20..8e2c2c8ba 100644 --- a/ci_cd_sanity_tests/pkg/sanity/aws/aws.go +++ b/ci_cd_sanity_tests/pkg/sanity/aws/aws.go @@ -20,8 +20,8 @@ type Options struct { MaxList int32 // used for EC2; RDS will clamp to valid range } -func checkIdentity(ctx context.Context, cfg aws.Config, expectedAccount string) (map[string]string, error) { - out, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) +func checkIdentity(ctx context.Context, cfg *aws.Config, expectedAccount string) (map[string]string, error) { + out, err := sts.NewFromConfig(*cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) if err != nil { return nil, err } @@ -36,19 +36,19 @@ func checkIdentity(ctx context.Context, cfg aws.Config, expectedAccount string) return d, nil } -func checkRegions(ctx context.Context, cfg aws.Config) (map[string]string, error) { - out, err := ec2.NewFromConfig(cfg).DescribeRegions(ctx, &ec2.DescribeRegionsInput{}) +func checkRegions(ctx context.Context, cfg *aws.Config) (map[string]string, error) { + out, err := ec2.NewFromConfig(*cfg).DescribeRegions(ctx, &ec2.DescribeRegionsInput{}) if err != nil { return nil, err } return map[string]string{"regions_count": fmt.Sprintf("%d", len(out.Regions))}, nil } -func checkInstances(ctx context.Context, cfg aws.Config, maxList int32) (map[string]string, error) { +func checkInstances(ctx context.Context, cfg *aws.Config, maxList int32) (map[string]string, error) { if maxList <= 0 { maxList = 5 } - out, err := ec2.NewFromConfig(cfg).DescribeInstances(ctx, &ec2.DescribeInstancesInput{ + out, err := ec2.NewFromConfig(*cfg).DescribeInstances(ctx, &ec2.DescribeInstancesInput{ MaxResults: aws.Int32(maxList), }) if err != nil { @@ -61,16 +61,16 @@ func checkInstances(ctx context.Context, cfg aws.Config, maxList int32) (map[str return map[string]string{"instances_seen": fmt.Sprintf("%d", instances)}, nil } -func checkRDS(ctx context.Context, cfg aws.Config, maxList int32) (map[string]string, error) { - max := maxList - if max < 20 { - max = 20 +func checkRDS(ctx context.Context, cfg *aws.Config, maxList int32) (map[string]string, error) { + maxRec := maxList + if maxRec < 20 { + maxRec = 20 } - if max > 100 { - max = 100 + if maxRec > 100 { + maxRec = 100 } - out, err := rds.NewFromConfig(cfg).DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{ - MaxRecords: aws.Int32(max), + out, err := rds.NewFromConfig(*cfg).DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{ + MaxRecords: aws.Int32(maxRec), }) if err != nil { return nil, err @@ -109,20 +109,20 @@ func Run(ctx context.Context, opts Options) (*report.Report, error) { cr.Message = e.Error() } cr.Details = details - rep.Add(cr) + rep.Add(&cr) } runCheck("sts:GetCallerIdentity", func() (map[string]string, error) { - return checkIdentity(ctx, cfg, opts.ExpectedAccount) + return checkIdentity(ctx, &cfg, opts.ExpectedAccount) }) runCheck("ec2:DescribeRegions", func() (map[string]string, error) { - return checkRegions(ctx, cfg) + return checkRegions(ctx, &cfg) }) runCheck("ec2:DescribeInstances (sample)", func() (map[string]string, error) { - return checkInstances(ctx, cfg, opts.MaxList) + return checkInstances(ctx, &cfg, opts.MaxList) }) runCheck("rds:DescribeDBInstances (sample)", func() (map[string]string, error) { - return checkRDS(ctx, cfg, opts.MaxList) + return checkRDS(ctx, &cfg, opts.MaxList) }) rep.EndedAt = time.Now().UTC() diff --git a/ci_cd_sanity_tests/pkg/sanity/azure/azure.go b/ci_cd_sanity_tests/pkg/sanity/azure/azure.go index c60e176b9..6b445f5b3 100644 --- a/ci_cd_sanity_tests/pkg/sanity/azure/azure.go +++ b/ci_cd_sanity_tests/pkg/sanity/azure/azure.go @@ -30,11 +30,11 @@ type azAccountShow struct { } `json:"user"` } -func truncate(s string, max int) string { - if len(s) <= max { +func truncate(s string, limit int) string { + if len(s) <= limit { return s } - return s[:max] + "...(truncated)" + return s[:limit] + "...(truncated)" } // validateAccountExpectations parses "az account show" JSON output and checks @@ -127,24 +127,25 @@ func Run(ctx context.Context, opts Options) (*report.Report, error) { // Ensure subscription context (read-only) _, cr := runCmd("azure:account:set", "account", "set", "--subscription", opts.SubscriptionID) - rep.Add(cr) + rep.Add(&cr) // Read-only identity/subscription info (only call once; reuse output) accountOut, cr := runCmd("azure:account:show", "account", "show", "-o", "json") - rep.Add(cr) + rep.Add(&cr) if opts.ExpectedSubID != "" || opts.ExpectedTenantID != "" { - rep.Add(validateAccountExpectations(opts, accountOut)) + expectCr := validateAccountExpectations(opts, accountOut) + rep.Add(&expectCr) } // Read-only lists (sample) _, cr = runCmd("azure:group:list(sample)", "group", "list", "--query", "[0:10].{name:name, location:location}", "-o", "json") - rep.Add(cr) + rep.Add(&cr) _, cr = runCmd("azure:vm:list(sample)", "vm", "list", "--query", "[0:10].{name:name, resourceGroup:resourceGroup, location:location}", "-o", "json") - rep.Add(cr) + rep.Add(&cr) rep.EndedAt = time.Now().UTC() return rep, nil diff --git a/ci_cd_sanity_tests/pkg/sanity/azure/azure_test.go b/ci_cd_sanity_tests/pkg/sanity/azure/azure_test.go index f1464bc0e..3dc964b88 100644 --- a/ci_cd_sanity_tests/pkg/sanity/azure/azure_test.go +++ b/ci_cd_sanity_tests/pkg/sanity/azure/azure_test.go @@ -9,12 +9,12 @@ import ( "github.com/stretchr/testify/require" ) -func accountJSON(id, tenantID, name, state string) []byte { +func accountJSON(id, tenantID string) []byte { b, err := json.Marshal(azAccountShow{ ID: id, TenantID: tenantID, - Name: name, - State: state, + Name: "My Sub", + State: "Enabled", }) if err != nil { panic(err) @@ -25,16 +25,16 @@ func accountJSON(id, tenantID, name, state string) []byte { func TestValidateAccountExpectations(t *testing.T) { tests := []struct { name string + wantStatus report.Status + wantMsgPart string opts Options accountOut []byte - wantStatus report.Status - wantMsgPart string // substring expected in Message when non-empty }{ { name: "valid json, no expectations", opts: Options{}, accountOut: accountJSON( - "sub-123", "tenant-456", "My Sub", "Enabled", + "sub-123", "tenant-456", ), wantStatus: report.StatusPass, }, @@ -45,7 +45,7 @@ func TestValidateAccountExpectations(t *testing.T) { ExpectedTenantID: "tenant-456", }, accountOut: accountJSON( - "sub-123", "tenant-456", "My Sub", "Enabled", + "sub-123", "tenant-456", ), wantStatus: report.StatusPass, }, @@ -55,7 +55,7 @@ func TestValidateAccountExpectations(t *testing.T) { ExpectedSubID: "sub-expected", }, accountOut: accountJSON( - "sub-actual", "tenant-456", "My Sub", "Enabled", + "sub-actual", "tenant-456", ), wantStatus: report.StatusFail, wantMsgPart: "unexpected subscription", @@ -66,7 +66,7 @@ func TestValidateAccountExpectations(t *testing.T) { ExpectedTenantID: "tenant-expected", }, accountOut: accountJSON( - "sub-123", "tenant-actual", "My Sub", "Enabled", + "sub-123", "tenant-actual", ), wantStatus: report.StatusFail, wantMsgPart: "unexpected tenant", diff --git a/ci_cd_sanity_tests/pkg/sanity/report/report.go b/ci_cd_sanity_tests/pkg/sanity/report/report.go index 03d576297..7ecb99d1f 100644 --- a/ci_cd_sanity_tests/pkg/sanity/report/report.go +++ b/ci_cd_sanity_tests/pkg/sanity/report/report.go @@ -15,12 +15,12 @@ const ( ) type CheckResult struct { + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + Details map[string]string `json:"details,omitempty"` Name string `json:"name"` Status Status `json:"status"` Message string `json:"message,omitempty"` - Details map[string]string `json:"details,omitempty"` - StartedAt time.Time `json:"started_at"` - EndedAt time.Time `json:"ended_at"` } type Report struct { @@ -32,8 +32,8 @@ type Report struct { Results []CheckResult `json:"results"` } -func (r *Report) Add(res CheckResult) { - r.Results = append(r.Results, res) +func (r *Report) Add(res *CheckResult) { + r.Results = append(r.Results, *res) } func (r *Report) HasFailures() bool { 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_azure.go b/cmd/configure_azure.go index 7449ea7e0..6af2eed37 100644 --- a/cmd/configure_azure.go +++ b/cmd/configure_azure.go @@ -21,10 +21,10 @@ import ( "golang.org/x/term" ) -// azureUUIDRegex validates Azure UUIDs (subscription IDs, tenant IDs, client IDs) +// azureUUIDRegex validates Azure UUIDs (subscription IDs, tenant IDs, client IDs). var azureUUIDRegex = 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}$`) -// validateAzureUUID validates an Azure UUID to prevent command injection +// validateAzureUUID validates an Azure UUID to prevent command injection. func validateAzureUUID(uuid, fieldName string) error { if !azureUUIDRegex.MatchString(uuid) { return fmt.Errorf("invalid %s format: must be a valid UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)", fieldName) @@ -38,27 +38,27 @@ func validateAzureUUID(uuid, fieldName string) error { // is still valid input. io.EOF with no data, or any other error, is returned. func readTrimmedLine(reader *bufio.Reader) (string, error) { input, err := reader.ReadString('\n') - if err != nil && !(errors.Is(err, io.EOF) && input != "") { + if err != nil && (!errors.Is(err, io.EOF) || input == "") { return "", err } return strings.TrimSpace(input), nil } -// AzureCredentials holds the Azure Service Principal credentials +// AzureCredentials holds the Azure Service Principal credentials. type AzureCredentials struct { TenantID string `json:"tenant_id"` ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` + ClientSecret string `json:"client_secret"` //nolint:gosec // G117: intentional credential field SubscriptionID string `json:"subscription_id"` } -// AzureConfigOptions holds configuration for the Azure config command +// AzureConfigOptions holds configuration for the Azure config command. type AzureConfigOptions struct { StackName string Profile string TenantID string ClientID string - ClientSecret string + ClientSecret string //nolint:gosec // G117: intentional credential field in config struct SubscriptionID string Interactive bool SkipSetup bool @@ -111,7 +111,7 @@ func validateAzureCredentialFields(creds AzureCredentials) error { return validateAzureUUID(creds.SubscriptionID, "Subscription ID") } -// storeAzureCredentials stores Azure credentials in the secrets store +// storeAzureCredentials stores Azure credentials in the secrets store. func storeAzureCredentials(ctx context.Context, store SecretsStore, stackName string, creds AzureCredentials) error { if err := validateAzureCredentialFields(creds); err != nil { return err @@ -187,7 +187,7 @@ func runConfigureAzure(cmd *cobra.Command, args []string) error { return nil } -// loadAWSConfigForAzure loads AWS configuration with optional profile +// loadAWSConfigForAzure loads AWS configuration with optional profile. func loadAWSConfigForAzure(ctx context.Context) (aws.Config, error) { var opts []func(*awsconfig.LoadOptions) error if azureOpts.Profile != "" { @@ -202,7 +202,7 @@ func loadAWSConfigForAzure(ctx context.Context) (aws.Config, error) { return cfg, nil } -// collectAzureCredentials collects Azure credentials interactively or from flags +// collectAzureCredentials collects Azure credentials interactively or from flags. func collectAzureCredentials(reader *bufio.Reader) (AzureCredentials, error) { creds := AzureCredentials{ TenantID: azureOpts.TenantID, @@ -226,7 +226,7 @@ func collectAzureCredentials(reader *bufio.Reader) (AzureCredentials, error) { return creds, nil } -// promptForAzureCredentialFields prompts for missing credential fields +// promptForAzureCredentialFields prompts for missing credential fields. func promptForAzureCredentialFields(reader *bufio.Reader, creds *AzureCredentials) error { if creds.TenantID == "" { fmt.Print("Azure Tenant ID: ") @@ -248,7 +248,7 @@ func promptForAzureCredentialFields(reader *bufio.Reader, creds *AzureCredential if creds.ClientSecret == "" { fmt.Print("Client Secret (password): ") - secret, err := term.ReadPassword(int(syscall.Stdin)) + secret, err := term.ReadPassword(syscall.Stdin) //nolint:unconvert // int(syscall.Stdin) was flagged; syscall.Stdin is already int on all platforms if err != nil { return fmt.Errorf("failed to read secret: %w", err) } @@ -268,7 +268,7 @@ func promptForAzureCredentialFields(reader *bufio.Reader, creds *AzureCredential return nil } -// runAzureSetupCommands runs the Azure CLI commands interactively +// runAzureSetupCommands runs the Azure CLI commands interactively. func runAzureSetupCommands(reader *bufio.Reader) error { fmt.Println("Step 1: Azure Login") fmt.Println("-------------------") @@ -341,7 +341,7 @@ func createAzureServicePrincipal(reader *bufio.Reader, subscriptionID string) er if choice == "r" || choice == "run" || choice == "" { fmt.Println() fmt.Println(strings.Repeat("-", 60)) - cmd := exec.Command("az", "ad", "sp", "create-for-rbac", + cmd := exec.Command("az", "ad", "sp", "create-for-rbac", //nolint:gosec,noctx // G204: subscriptionID is UUID-validated above "--name", "CUDly", "--role", "Reservations Administrator", "--scopes", fmt.Sprintf("/subscriptions/%s", subscriptionID)) @@ -355,7 +355,7 @@ func createAzureServicePrincipal(reader *bufio.Reader, subscriptionID string) er if readErr != nil { return fmt.Errorf("failed to read response: %w", readErr) } - if strings.ToLower(response) != "y" { + if !strings.EqualFold(response, "y") { return fmt.Errorf("failed to create service principal: %w", err) } } @@ -368,7 +368,7 @@ func createAzureServicePrincipal(reader *bufio.Reader, subscriptionID string) er // promptAndRunExplicitCommand shows a command and asks to run or skip. // Takes explicit program and args to avoid command injection via string splitting. -func promptAndRunExplicitCommand(reader *bufio.Reader, name, displayCmd string, program string, args ...string) error { +func promptAndRunExplicitCommand(reader *bufio.Reader, name, displayCmd, program string, args ...string) error { fmt.Printf("Command: %s\n", displayCmd) fmt.Println() fmt.Printf("[R]un, [S]kip? ") @@ -396,12 +396,12 @@ func promptAndRunExplicitCommand(reader *bufio.Reader, name, displayCmd string, // is consumed from one consistent buffered stream (a fresh // bufio.NewReader(os.Stdin) here would drop input already buffered by the // caller's reader, breaking piped input after earlier prompts). -func executeExplicitCommand(reader *bufio.Reader, displayCmd string, program string, args ...string) error { +func executeExplicitCommand(reader *bufio.Reader, displayCmd, program string, args ...string) error { fmt.Println() fmt.Printf("Executing: %s\n", displayCmd) fmt.Println(strings.Repeat("-", 60)) - cmd := exec.Command(program, args...) + cmd := exec.Command(program, args...) //nolint:noctx // interactive CLI subprocess; caller has no cancellable context cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin @@ -416,7 +416,7 @@ func executeExplicitCommand(reader *bufio.Reader, displayCmd string, program str if readErr != nil { return fmt.Errorf("failed to read response: %w", readErr) } - if strings.ToLower(response) != "y" { + if !strings.EqualFold(response, "y") { return fmt.Errorf("command failed: %w", err) } } diff --git a/cmd/configure_gcp.go b/cmd/configure_gcp.go index 1cd4ca010..2c2de14df 100644 --- a/cmd/configure_gcp.go +++ b/cmd/configure_gcp.go @@ -18,10 +18,10 @@ import ( "github.com/spf13/cobra" ) -// gcpProjectIDRegex validates GCP project IDs (lowercase letters, digits, hyphens, 6-30 chars) +// gcpProjectIDRegex validates GCP project IDs (lowercase letters, digits, hyphens, 6-30 chars). var gcpProjectIDRegex = regexp.MustCompile(`^[a-z][a-z0-9-]{4,28}[a-z0-9]$`) -// validateGCPProjectID validates a GCP project ID to prevent command injection +// validateGCPProjectID validates a GCP project ID to prevent command injection. func validateGCPProjectID(projectID string) error { if !gcpProjectIDRegex.MatchString(projectID) { return fmt.Errorf("invalid GCP project ID format: must be 6-30 lowercase letters, digits, or hyphens, starting with a letter") @@ -29,12 +29,12 @@ func validateGCPProjectID(projectID string) error { return nil } -// GCPCredentials holds the GCP Service Account credentials +// GCPCredentials holds the GCP Service Account credentials. type GCPCredentials struct { Type string `json:"type"` ProjectID string `json:"project_id"` PrivateKeyID string `json:"private_key_id"` - PrivateKey string `json:"private_key"` + PrivateKey string `json:"private_key"` //nolint:gosec // G117: intentional credential field in service account struct ClientEmail string `json:"client_email"` ClientID string `json:"client_id,omitempty"` AuthURI string `json:"auth_uri,omitempty"` @@ -43,7 +43,7 @@ type GCPCredentials struct { ClientX509CertURL string `json:"client_x509_cert_url,omitempty"` } -// GCPConfigOptions holds configuration for the GCP config command +// GCPConfigOptions holds configuration for the GCP config command. type GCPConfigOptions struct { StackName string Profile string @@ -81,8 +81,8 @@ func init() { configureGCPCmd.Flags().BoolVar(&gcpOpts.SkipSetup, "skip-setup", false, "Skip GCP CLI setup commands (gcloud login, create service account)") } -// storeGCPCredentials stores GCP credentials in the secrets store -func storeGCPCredentials(ctx context.Context, store SecretsStore, stackName string, credsJSON string) error { +// storeGCPCredentials stores GCP credentials in the secrets store. +func storeGCPCredentials(ctx context.Context, store SecretsStore, stackName, credsJSON string) error { // Validate that we have valid JSON var creds GCPCredentials if err := json.Unmarshal([]byte(credsJSON), &creds); err != nil { @@ -161,7 +161,7 @@ func runConfigureGCP(cmd *cobra.Command, args []string) error { return nil } -// getGCPCredentialsFilePath determines the credentials file path from options or user input +// getGCPCredentialsFilePath determines the credentials file path from options or user input. func getGCPCredentialsFilePath(reader *bufio.Reader) (string, error) { var credsFile string @@ -191,7 +191,7 @@ func getGCPCredentialsFilePath(reader *bufio.Reader) (string, error) { return credsFile, nil } -// loadAWSConfigForGCP loads AWS configuration with optional profile +// loadAWSConfigForGCP loads AWS configuration with optional profile. func loadAWSConfigForGCP(ctx context.Context) (aws.Config, error) { var opts []func(*awsconfig.LoadOptions) error if gcpOpts.Profile != "" { @@ -206,17 +206,17 @@ func loadAWSConfigForGCP(ctx context.Context) (aws.Config, error) { return cfg, nil } -// loadAndUpdateGCPCredentials loads, parses, and optionally updates GCP credentials +// loadAndUpdateGCPCredentials loads, parses, and optionally updates GCP credentials. func loadAndUpdateGCPCredentials(credsFile string) (GCPCredentials, []byte, error) { expandedPath := expandHomeDirectory(credsFile) - credsData, err := os.ReadFile(expandedPath) + credsData, err := os.ReadFile(expandedPath) //nolint:gosec // G703: user-provided path is intended; no untrusted input if err != nil { return GCPCredentials{}, nil, fmt.Errorf("failed to read credentials file: %w", err) } var creds GCPCredentials - if err := json.Unmarshal(credsData, &creds); err != nil { + if err = json.Unmarshal(credsData, &creds); err != nil { //nolint:govet // shadow: reassign outer err (safe, err was nil here) return GCPCredentials{}, nil, fmt.Errorf("failed to parse credentials file: %w", err) } @@ -231,7 +231,7 @@ func loadAndUpdateGCPCredentials(credsFile string) (GCPCredentials, []byte, erro return creds, credsData, nil } -// expandHomeDirectory expands ~ to the user's home directory +// expandHomeDirectory expands ~ to the user's home directory. func expandHomeDirectory(path string) string { if !strings.HasPrefix(path, "~/") { return path @@ -245,8 +245,8 @@ func expandHomeDirectory(path string) string { return strings.Replace(path, "~", home, 1) } -// printGCPConfigurationSuccess prints success message with credentials info -func printGCPConfigurationSuccess(creds GCPCredentials) { +// printGCPConfigurationSuccess prints success message with credentials info. +func printGCPConfigurationSuccess(creds GCPCredentials) { //nolint:gocritic // hugeParam: GCPCredentials (160 bytes) passed by value; callers don't hold a pointer log.Printf("GCP credentials stored successfully in Secrets Manager") fmt.Println("\nGCP configuration complete!") fmt.Printf("Service Account: %s\n", creds.ClientEmail) @@ -254,7 +254,7 @@ func printGCPConfigurationSuccess(creds GCPCredentials) { fmt.Println("\nCUDly can now manage GCP Committed Use Discounts.") } -// runGCPSetupCommands runs the GCP CLI commands interactively +// runGCPSetupCommands runs the GCP CLI commands interactively. func runGCPSetupCommands(reader *bufio.Reader) (string, error) { fmt.Println("Step 1: GCP Login") fmt.Println("-----------------") @@ -282,17 +282,17 @@ func runGCPSetupCommands(reader *bufio.Reader) (string, error) { } // Validate project ID to prevent command injection - if err := validateGCPProjectID(projectID); err != nil { + if err = validateGCPProjectID(projectID); err != nil { //nolint:gocritic,govet // shadow: reuse outer err (safe; was nil here) return "", err } // Set the project - use exec.Command with arguments instead of shell fmt.Println() fmt.Println("Setting project...") - cmd := exec.Command("gcloud", "config", "set", "project", projectID) + cmd := exec.Command("gcloud", "config", "set", "project", projectID) //nolint:gosec,noctx // G702: projectID is validated above; interactive CLI subprocess cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err = cmd.Run(); err != nil { //nolint:govet // shadow: reuse outer err (safe) return "", fmt.Errorf("failed to set project: %w", err) } @@ -305,7 +305,7 @@ func runGCPSetupCommands(reader *bufio.Reader) (string, error) { saName := "cudly-service-account" createSaDisplay := fmt.Sprintf(`gcloud iam service-accounts create %s --display-name="CUDly Service Account" --description="Service account for CUDly commitment management"`, saName) - if err := promptAndRunGCPCommand(reader, "Create Service Account", createSaDisplay, + if err = promptAndRunGCPCommand(reader, "Create Service Account", createSaDisplay, //nolint:gocritic,govet // shadow: reuse outer err "gcloud", "iam", "service-accounts", "create", saName, "--display-name=CUDly Service Account", "--description=Service account for CUDly commitment management"); err != nil { @@ -323,7 +323,7 @@ func runGCPSetupCommands(reader *bufio.Reader) (string, error) { // Grant Compute Admin role for commitment management grantRoleDisplay := fmt.Sprintf(`gcloud projects add-iam-policy-binding %s --member="serviceAccount:%s" --role="roles/compute.admin"`, projectID, saEmail) - if err := promptAndRunGCPCommand(reader, "Grant Compute Admin Role", grantRoleDisplay, + if err = promptAndRunGCPCommand(reader, "Grant Compute Admin Role", grantRoleDisplay, //nolint:gocritic,gosec,govet // shadow: reuse outer err; G702: projectID/saEmail validated "gcloud", "projects", "add-iam-policy-binding", projectID, fmt.Sprintf("--member=serviceAccount:%s", saEmail), "--role=roles/compute.admin"); err != nil { @@ -374,7 +374,7 @@ func readRequiredInputLine(reader *bufio.Reader, prompt, fieldName string) (stri // promptAndRunGCPCommand shows a command and asks to run or skip. // Takes explicit program and args to avoid command injection via string splitting. -func promptAndRunGCPCommand(reader *bufio.Reader, name, displayCmd string, program string, args ...string) error { +func promptAndRunGCPCommand(reader *bufio.Reader, name, displayCmd, program string, args ...string) error { //nolint:unparam fmt.Printf("Command: %s\n", displayCmd) fmt.Println() fmt.Printf("[R]un, [S]kip? ") @@ -402,12 +402,12 @@ func promptAndRunGCPCommand(reader *bufio.Reader, name, displayCmd string, progr // is consumed from one consistent buffered stream (a fresh // bufio.NewReader(os.Stdin) here would drop input already buffered by the // caller's reader, breaking piped input after earlier prompts). -func executeGCPCommand(reader *bufio.Reader, displayCmd string, program string, args ...string) error { +func executeGCPCommand(reader *bufio.Reader, displayCmd, program string, args ...string) error { fmt.Println() fmt.Printf("Executing: %s\n", displayCmd) fmt.Println(strings.Repeat("-", 60)) - cmd := exec.Command(program, args...) + cmd := exec.Command(program, args...) //nolint:gosec,noctx // interactive CLI subprocess; caller has no cancellable context cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin @@ -422,7 +422,7 @@ func executeGCPCommand(reader *bufio.Reader, displayCmd string, program string, if readErr != nil { return fmt.Errorf("failed to read response: %w", readErr) } - if strings.ToLower(response) != "y" { + if !strings.EqualFold(response, "y") { return fmt.Errorf("command failed: %w", err) } } diff --git a/cmd/configure_test.go b/cmd/configure_test.go index b623d89fd..662b5a88a 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,16 +383,16 @@ func TestValidateGCPProjectID(t *testing.T) { } } -// Tests for storeAzureCredentials function +// Tests for storeAzureCredentials function. func TestStoreAzureCredentials(t *testing.T) { tests := []struct { + mockSetup func(*MockSecretsStore) + validateStore func(*testing.T, *MockSecretsStore) + creds AzureCredentials name string stackName string - creds AzureCredentials - mockSetup func(*MockSecretsStore) - wantErr bool wantErrMsg string - validateStore func(*testing.T, *MockSecretsStore) + wantErr bool }{ { name: "Successfully store valid credentials", @@ -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. @@ -565,13 +565,13 @@ func TestStoreGCPCredentials(t *testing.T) { }` tests := []struct { + mockSetup func(*MockSecretsStore) + validateStore func(*testing.T, *MockSecretsStore) name string stackName string credsJSON string - mockSetup func(*MockSecretsStore) - wantErr bool wantErrMsg string - validateStore func(*testing.T, *MockSecretsStore) + wantErr bool }{ { name: "Successfully store valid GCP credentials", diff --git a/cmd/helpers.go b/cmd/helpers.go index 6a3a1af93..0984f3de7 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -18,37 +18,37 @@ 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 + cache map[string]string + mu sync.RWMutex } -// NewAccountAliasCache creates a new account alias cache -func NewAccountAliasCache(cfg aws.Config) *AccountAliasCache { +// NewAccountAliasCache creates a new account alias cache. +func NewAccountAliasCache(cfg aws.Config) *AccountAliasCache { //nolint:gocritic return &AccountAliasCache{ cache: make(map[string]string), orgClient: organizations.NewFromConfig(cfg), @@ -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,10 +104,10 @@ 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 { + for _, rec := range recs { //nolint:gocritic total += rec.Count } return total @@ -131,7 +131,7 @@ func ApplyCoverage(recs []common.Recommendation, coverage float64) []common.Reco ratio := coverage / 100.0 result := make([]common.Recommendation, 0, len(recs)) - for _, rec := range recs { + for _, rec := range recs { //nolint:gocritic adjusted := rec // For Savings Plans, reduce the hourly commitment instead of count. @@ -143,8 +143,8 @@ func ApplyCoverage(recs []common.Recommendation, coverage float64) []common.Reco if common.IsSavingsPlan(rec.Service) { if details, ok := rec.Details.(*common.SavingsPlanDetails); ok { newDetails := *details // Copy the struct - newDetails.HourlyCommitment = newDetails.HourlyCommitment * ratio - adjusted = common.ScaleRecommendationCosts(adjusted, ratio) + newDetails.HourlyCommitment *= ratio + adjusted = common.ScaleRecommendationCosts(&adjusted, ratio) adjusted.Details = &newDetails } else { AppLogger.Printf("WARNING: SP recommendation for service %q has unexpected Details type %T; passing through unscaled\n", rec.Service, rec.Details) @@ -165,7 +165,7 @@ func ApplyCoverage(recs []common.Recommendation, coverage float64) []common.Reco newCount := int(float64(rec.Count) * ratio) if newCount > 0 { sizedRatio := float64(newCount) / float64(rec.Count) - adjusted = common.ScaleRecommendationCosts(adjusted, sizedRatio) + adjusted = common.ScaleRecommendationCosts(&adjusted, sizedRatio) adjusted.Count = newCount result = append(result, adjusted) } @@ -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: @@ -251,7 +251,7 @@ func ApplyTargetCoverage(recs []common.Recommendation, targetPct float64) []comm var skipped int unsupportedSeen := make(map[common.CommitmentType]bool) - for _, rec := range recs { + for _, rec := range recs { //nolint:gocritic adjusted, kept, missingSignal := applyTargetCoverageOne(rec, targetPct, unsupportedSeen) if missingSignal { skipped++ @@ -278,7 +278,7 @@ func ApplyTargetCoverage(recs []common.Recommendation, targetPct float64) []comm // // Split out of ApplyTargetCoverage to keep that function under gocyclo's // complexity threshold. -func applyTargetCoverageOne(rec common.Recommendation, targetPct float64, unsupportedSeen map[common.CommitmentType]bool) (common.Recommendation, bool, bool) { +func applyTargetCoverageOne(rec common.Recommendation, targetPct float64, unsupportedSeen map[common.CommitmentType]bool) (common.Recommendation, bool, bool) { //nolint:gocritic switch { case common.IsSavingsPlan(rec.Service): adjusted, ok := applyTargetCoverageSP(rec, targetPct) @@ -311,7 +311,7 @@ func applyTargetCoverageOne(rec common.Recommendation, targetPct float64, unsupp // (adjusted, true) on success, (rec, false) when the rec should be passed // through unscaled (no signal) or dropped (target unreachable). Caller // distinguishes the two via rec.AverageInstancesUsedPerHour. -func applyTargetCoverageRI(rec common.Recommendation, targetPct float64) (common.Recommendation, bool) { +func applyTargetCoverageRI(rec common.Recommendation, targetPct float64) (common.Recommendation, bool) { //nolint:gocritic if rec.AverageInstancesUsedPerHour <= 0 { // No signal — caller will pass through and count in the summary. return rec, false @@ -382,7 +382,7 @@ func applyTargetCoverageRI(rec common.Recommendation, targetPct float64) (common } else { ratio = float64(nTarget) } - adjusted := common.ScaleRecommendationCosts(rec, ratio) + adjusted := common.ScaleRecommendationCosts(&rec, ratio) adjusted.Count = nTarget // Projection metrics. ProjectedCoverage is TOTAL coverage (existing + @@ -405,7 +405,7 @@ func applyTargetCoverageRI(rec common.Recommendation, targetPct float64) (common // applyTargetCoverageSP is the SP branch of ApplyTargetCoverage. Returns // (adjusted, true) when the rec is kept, (rec, false) when it should be // skipped (caller passes through unscaled and counts in the skip summary). -func applyTargetCoverageSP(rec common.Recommendation, targetPct float64) (common.Recommendation, bool) { +func applyTargetCoverageSP(rec common.Recommendation, targetPct float64) (common.Recommendation, bool) { //nolint:gocritic if rec.RecommendedUtilization <= 0 { return rec, false } @@ -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 @@ -438,8 +438,8 @@ func applyTargetCoverageSP(rec common.Recommendation, targetPct float64) (common } ratio := targetPct / 100.0 newDetails := *details // copy - newDetails.HourlyCommitment = newDetails.HourlyCommitment * ratio - adjusted := common.ScaleRecommendationCosts(rec, ratio) + newDetails.HourlyCommitment *= ratio + adjusted := common.ScaleRecommendationCosts(&rec, ratio) adjusted.Details = &newDetails // Shrinking commitment raises projected utilization by 1/ratio // (used is fixed = orig_commit * RecUtil, bought is orig_commit * ratio). @@ -460,27 +460,27 @@ func applyTargetCoverageSP(rec common.Recommendation, targetPct float64) (common // (the main path passes cfg.Coverage; the CSV path passes csvModeCoverage, // which substitutes the default 80% with 100% so CSV-driven counts aren't // silently dropped). -func applySizing(recs []common.Recommendation, cfg Config, coverage float64) []common.Recommendation { +func applySizing(recs []common.Recommendation, cfg Config, coverage float64) []common.Recommendation { //nolint:gocritic if cfg.TargetCoverage > 0 { return ApplyTargetCoverage(recs, cfg.TargetCoverage) } 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 } result := make([]common.Recommendation, len(recs)) - for i, rec := range recs { + for i, rec := range recs { //nolint:gocritic result[i] = rec result[i].Count = int(overrideCount) } 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 @@ -489,7 +489,7 @@ func ApplyInstanceLimit(recs []common.Recommendation, maxInstances int32) []comm result := make([]common.Recommendation, 0) remaining := int(maxInstances) - for _, rec := range recs { + for _, rec := range recs { //nolint:gocritic if remaining <= 0 { break } @@ -512,7 +512,7 @@ func ConfirmPurchase(totalInstances int, totalSavings float64, skipConfirmation return true } - if !term.IsTerminal(int(os.Stdin.Fd())) { + if !term.IsTerminal(int(os.Stdin.Fd())) { //nolint:gosec // G115: uintptr->int; fd is always a small non-negative value log.Printf("stdin is not a terminal and --yes was not set; skipping purchase") return false } @@ -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 } @@ -559,7 +559,7 @@ func NewDuplicateChecker(hours int) *DuplicateChecker { // This checks for recently purchased RIs (within LookbackHours) to avoid duplicate purchases. // Note: This is designed to prevent re-purchasing something you just bought, not to prevent // purchasing RIs in other accounts that happen to have the same characteristics. -func (d *DuplicateChecker) AdjustRecommendationsForExisting(ctx context.Context, recs []common.Recommendation, client provider.ServiceClient) ([]common.Recommendation, []common.Recommendation, error) { +func (d *DuplicateChecker) AdjustRecommendationsForExisting(ctx context.Context, recs []common.Recommendation, client provider.ServiceClient) ([]common.Recommendation, []common.Recommendation, error) { //nolint:gocritic existing, err := client.GetExistingCommitments(ctx) if err != nil { return recs, nil, err @@ -586,12 +586,12 @@ 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) - for _, c := range existing { + for _, c := range existing { //nolint:gocritic if isRecentActiveCommitment(c, cutoffTime) { recentExisting = append(recentExisting, c) } @@ -600,16 +600,16 @@ func (d *DuplicateChecker) filterRecentCommitments(existing []common.Commitment) return recentExisting } -// isRecentActiveCommitment checks if a commitment is active and purchased after the cutoff time -func isRecentActiveCommitment(c common.Commitment, cutoffTime time.Time) bool { +// isRecentActiveCommitment checks if a commitment is active and purchased after the cutoff time. +func isRecentActiveCommitment(c common.Commitment, cutoffTime time.Time) bool { //nolint:gocritic 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) - for _, c := range commitments { + for _, c := range commitments { //nolint:gocritic normalizedEngine := normalizeEngineName(c.Engine) key := fmt.Sprintf("%s|%s|%s", c.ResourceType, c.Region, normalizedEngine) existingMap[key] += c.Count @@ -622,11 +622,11 @@ func buildExistingCommitmentsMap(commitments []common.Commitment) map[string]int // adjustRecommendationsAgainstExisting adjusts recommendations based on existing commitments. // Returns (passed, filtered) where filtered contains recs whose count was reduced to zero. -func adjustRecommendationsAgainstExisting(recs []common.Recommendation, existingMap map[string]int) ([]common.Recommendation, []common.Recommendation) { +func adjustRecommendationsAgainstExisting(recs []common.Recommendation, existingMap map[string]int) ([]common.Recommendation, []common.Recommendation) { //nolint:gocritic passed := make([]common.Recommendation, 0, len(recs)) filtered := make([]common.Recommendation, 0) - for _, rec := range recs { + for _, rec := range recs { //nolint:gocritic adjusted := adjustSingleRecommendation(rec, existingMap) if adjusted.Count > 0 { passed = append(passed, adjusted) @@ -638,8 +638,8 @@ func adjustRecommendationsAgainstExisting(recs []common.Recommendation, existing return passed, filtered } -// adjustSingleRecommendation adjusts a single recommendation based on existing commitments -func adjustSingleRecommendation(rec common.Recommendation, existingMap map[string]int) common.Recommendation { +// adjustSingleRecommendation adjusts a single recommendation based on existing commitments. +func adjustSingleRecommendation(rec common.Recommendation, existingMap map[string]int) common.Recommendation { //nolint:gocritic engine := getEngineFromRecommendation(rec) key := fmt.Sprintf("%s|%s|%s", rec.ResourceType, rec.Region, engine) existingCount := existingMap[key] @@ -664,8 +664,8 @@ func adjustSingleRecommendation(rec common.Recommendation, existingMap map[strin return adjusted } -// getEngineFromRecommendation extracts the engine from recommendation details -func getEngineFromRecommendation(rec common.Recommendation) string { +// getEngineFromRecommendation extracts the engine from recommendation details. +func getEngineFromRecommendation(rec common.Recommendation) string { //nolint:gocritic 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,13 +723,13 @@ func normalizeEngineName(engine string) string { return strings.ToLower(engine) } -// AdjustRecommendationsForExistingRIs is an alias for AdjustRecommendationsForExisting -func (d *DuplicateChecker) AdjustRecommendationsForExistingRIs(ctx context.Context, recs []common.Recommendation, client provider.ServiceClient) ([]common.Recommendation, []common.Recommendation, error) { +// AdjustRecommendationsForExistingRIs is an alias for AdjustRecommendationsForExisting. +func (d *DuplicateChecker) AdjustRecommendationsForExistingRIs(ctx context.Context, recs []common.Recommendation, client provider.ServiceClient) ([]common.Recommendation, []common.Recommendation, error) { //nolint:gocritic return d.AdjustRecommendationsForExisting(ctx, recs, client) } -// GetRecommendationDescription returns a human-readable description -func GetRecommendationDescription(rec common.Recommendation) string { +// GetRecommendationDescription returns a human-readable description. +func GetRecommendationDescription(rec common.Recommendation) string { //nolint:gocritic desc := fmt.Sprintf("%s %s", rec.Service, rec.ResourceType) if rec.Details != nil { desc += " " + rec.Details.GetDetailDescription() diff --git a/cmd/lambda/main.go b/cmd/lambda/main.go index ec3ede3c4..796036cca 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 ( @@ -55,8 +55,8 @@ func initApp(ctx context.Context) (*server.Application, error) { return app, nil } -// Handler is the main Lambda handler function -// This delegates to Application.HandleLambdaEvent which handles all event types +// Handler is the main Lambda handler function. +// 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..e5522512c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -27,50 +27,43 @@ 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 - Services []string - Coverage float64 + CSVOutput string + CSVInput string + PaymentOption string + Profile string + ValidationProfile string + // Purchase pipeline settings + AuditLog string + IdempotencyWindow string + Providers []string + Regions []string + Services []string + IncludeRegions []string + ExcludeRegions []string + IncludeInstanceTypes []string + ExcludeInstanceTypes []string + IncludeEngines []string + ExcludeEngines []string + IncludeAccounts []string + ExcludeAccounts []string + // Savings Plans specific filters + IncludeSPTypes []string + ExcludeSPTypes []string + Coverage float64 // TargetCoverage, when > 0, switches sizing from --coverage's // rec.Count-scaling to under-buy against historical hourly usage: // each rec is sized to floor(avg * TargetCoverage/100), leaving // (100-TargetCoverage)% of historical demand on-demand. Mutually // exclusive with --coverage (target wins, with an info log). See // cmd/helpers.go: ApplyTargetCoverage. - TargetCoverage float64 - ActualPurchase bool - CSVOutput string - CSVInput string - AllServices bool - PaymentOption string - TermYears int - IncludeRegions []string - ExcludeRegions []string - IncludeInstanceTypes []string - ExcludeInstanceTypes []string - IncludeEngines []string - ExcludeEngines []string - IncludeAccounts []string - ExcludeAccounts []string - SkipConfirmation bool - MaxInstances int32 - OverrideCount int32 - Profile string - ValidationProfile string - IncludeExtendedSupport bool - // Savings Plans specific filters - IncludeSPTypes []string - ExcludeSPTypes []string - // Purchase pipeline settings - AuditLog string - DryRun bool - IdempotencyWindow string + TargetCoverage float64 + TermYears int MinSavingsPct float64 MaxBreakEvenMonths int MinCount int @@ -93,7 +86,14 @@ type Config struct { // can't approximate target=80% without buying enough RIs to hit // 100% coverage). Zero (default) keeps all pools. SPs and recs // without a per-hour signal pass through unfiltered. - MinPoolSize float64 + MinPoolSize float64 + MaxInstances int32 + OverrideCount int32 + ActualPurchase bool + AllServices bool + SkipConfirmation bool + IncludeExtendedSupport bool + DryRun bool } func main() { @@ -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,8 +255,8 @@ func getAllServices() []common.ServiceType { } } -// createServiceClient creates the appropriate service client for a service -func createServiceClient(service common.ServiceType, cfg aws.Config) provider.ServiceClient { +// createServiceClient creates the appropriate service client for a service. +func createServiceClient(service common.ServiceType, cfg aws.Config) provider.ServiceClient { //nolint:gocritic switch service { case common.ServiceRDS: return rds.NewClient(cfg) @@ -288,7 +288,7 @@ func createServiceClient(service common.ServiceType, cfg aws.Config) provider.Se // cfg.TargetCoverage when set (>0), else cfg.Coverage. Use this when // emitting human-facing labels (purchase IDs, audit-log fields) so the label // reflects the value that drove the sizing, not the unused default. -func effectiveSizingPct(cfg Config) float64 { +func effectiveSizingPct(cfg Config) float64 { //nolint:gocritic if cfg.TargetCoverage > 0 { return cfg.TargetCoverage } @@ -327,7 +327,7 @@ func extractEngineLabel(details interface{}) string { // sizingPct is the percentage that actually drove the sizing decision (see // effectiveSizingPct); it appears in the ID as e.g. "80pct" purely for human // readability and audit traceability. -func generatePurchaseID(rec common.Recommendation, region string, _ int, isDryRun bool, sizingPct float64) string { +func generatePurchaseID(rec common.Recommendation, region string, _ int, isDryRun bool, sizingPct float64) string { //nolint:gocritic // Generate a short UUID suffix (first 8 characters) for uniqueness uuidSuffix := uuid.New().String()[:8] timestamp := time.Now().Format("20060102-150405") @@ -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/main_test.go b/cmd/main_test.go index b9f1979b1..96e05f20c 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -402,9 +402,9 @@ func TestGeneratePurchaseIDComprehensive(t *testing.T) { tests := []struct { name string region string - rec common.Recommendation expectedContains []string expectedNotContains []string + rec common.Recommendation isDryRun bool }{ { diff --git a/cmd/multi_service.go b/cmd/multi_service.go index ae3bab8f8..82ab0dcfd 100644 --- a/cmd/multi_service.go +++ b/cmd/multi_service.go @@ -35,7 +35,7 @@ import ( // The lookback window is cfg.CoverageLookbackDays (default 30, matching the // CE UI default). Operators reconciling against the AWS console coverage // report should match this value to the report's own time window. -func fetchExistingCoverage(ctx context.Context, awsCfg aws.Config, recClient provider.RecommendationsClient, cfg Config) recommendations.PoolCoverageMap { +func fetchExistingCoverage(ctx context.Context, awsCfg aws.Config, recClient provider.RecommendationsClient, cfg Config) recommendations.PoolCoverageMap { //nolint:gocritic if cfg.TargetCoverage <= 0 { return nil } @@ -74,7 +74,7 @@ var shutdownRequested atomic.Bool // runToolMultiService is the main entry point for processing multiple services. // It runs a two-phase pipeline: (1) fetch+filter all recommendations, then // (2) score, display, confirm, and purchase. -func runToolMultiService(ctx context.Context, cfg Config) { +func runToolMultiService(ctx context.Context, cfg Config) { //nolint:gocritic if cfg.CSVInput != "" { runToolFromCSV(ctx, cfg) return @@ -96,7 +96,7 @@ func runToolMultiService(ctx context.Context, cfg Config) { // Verify audit log is writable before making any cloud API calls. if err := CheckAuditLogWritable(cfg.AuditLog); err != nil { - log.Fatalf("Cannot write audit log: %v", err) + log.Fatalf("Cannot write audit log: %v", err) //nolint:gocritic } printRunMode(isDryRun) @@ -135,7 +135,7 @@ func runToolMultiService(ctx context.Context, cfg Config) { if !isDryRun { totalInstances, totalSavings := sumPassedRecs(scoredResult.Passed) if !ConfirmPurchase(totalInstances, totalSavings, cfg.SkipConfirmation) { - AppLogger.Printf("\n❌ Purchase cancelled.\n") + AppLogger.Printf("\n❌ Purchase canceled.\n") return } } @@ -155,7 +155,7 @@ func runToolMultiService(ctx context.Context, cfg Config) { } // loadAWSConfig builds an aws.Config from the tool config. -func loadAWSConfig(ctx context.Context, cfg Config) (aws.Config, error) { +func loadAWSConfig(ctx context.Context, cfg Config) (aws.Config, error) { //nolint:gocritic var opts []func(*awsconfig.LoadOptions) error opts = append(opts, awsconfig.WithRegion("us-east-1")) if cfg.Profile != "" { @@ -165,7 +165,7 @@ func loadAWSConfig(ctx context.Context, cfg Config) (aws.Config, error) { } // scoreAndDisplay runs the scorer on recs and prints the scored table and summary. -func scoreAndDisplay(recs []common.Recommendation, cfg Config) scorer.ScoredResult { +func scoreAndDisplay(recs []common.Recommendation, cfg Config) scorer.ScoredResult { //nolint:gocritic scorerCfg := scorer.Config{ MinSavingsPct: cfg.MinSavingsPct, MaxBreakEvenMonths: cfg.MaxBreakEvenMonths, @@ -179,10 +179,10 @@ func scoreAndDisplay(recs []common.Recommendation, cfg Config) scorer.ScoredResu } // sumPassedRecs returns total instance count and total estimated savings for passed recs. -func sumPassedRecs(recs []common.Recommendation) (int, float64) { +func sumPassedRecs(recs []common.Recommendation) (int, float64) { //nolint:gocritic total := 0 savings := 0.0 - for _, r := range recs { + for _, r := range recs { //nolint:gocritic total += r.Count savings += r.EstimatedSavings } @@ -190,17 +190,19 @@ func sumPassedRecs(recs []common.Recommendation) (int, float64) { } // executePurchasePipeline purchases each rec in the passed list (or dry-runs) and writes audit records. -func executePurchasePipeline(ctx context.Context, awsCfg aws.Config, recs []common.Recommendation, isDryRun bool, runID string, cfg Config) []common.PurchaseResult { +func executePurchasePipeline(ctx context.Context, awsCfg aws.Config, recs []common.Recommendation, isDryRun bool, runID string, cfg Config) []common.PurchaseResult { //nolint:gocritic results := make([]common.PurchaseResult, 0, len(recs)) - for i, rec := range recs { + for i, rec := range recs { //nolint:gocritic if shutdownRequested.Load() { log.Printf("Shutdown requested — skipping %d remaining recommendations", len(recs)-i) break } result, status := purchaseSingleRec(ctx, awsCfg, rec, i+1, isDryRun, cfg) results = append(results, result) - auditRec := common.NewAuditRecord(runID, rec, result, status, isDryRun, common.PurchaseSourceCLI) - if err := common.WriteAuditRecord(auditRec, cfg.AuditLog); err != nil { + auditRec, auditErr := common.NewAuditRecord(runID, &rec, &result, status, isDryRun, common.PurchaseSourceCLI) + if auditErr != nil { + log.Printf("Warning: failed to build audit record: %v", auditErr) + } else if err := common.WriteAuditRecord(&auditRec, cfg.AuditLog); err != nil { log.Printf("Warning: failed to write audit record: %v", err) } if !isDryRun && i < len(recs)-1 && os.Getenv("DISABLE_PURCHASE_DELAY") != "true" { @@ -211,7 +213,7 @@ func executePurchasePipeline(ctx context.Context, awsCfg aws.Config, recs []comm } // purchaseSingleRec executes or dry-runs a single purchase and returns the result + audit status. -func purchaseSingleRec(ctx context.Context, awsCfg aws.Config, rec common.Recommendation, index int, isDryRun bool, cfg Config) (common.PurchaseResult, string) { +func purchaseSingleRec(ctx context.Context, awsCfg aws.Config, rec common.Recommendation, index int, isDryRun bool, cfg Config) (common.PurchaseResult, string) { //nolint:gocritic AppLogger.Printf(" [%d] %s %s %s (count=%d)\n", index, rec.Service, rec.Region, rec.ResourceType, rec.Count) if isDryRun { result := createDryRunResult(rec, rec.Region, index, cfg) @@ -243,7 +245,7 @@ func purchaseSingleRec(ctx context.Context, awsCfg aws.Config, rec common.Recomm func buildServiceStats(recs []common.Recommendation, results []common.PurchaseResult) map[common.ServiceType]ServiceProcessingStats { byService := make(map[common.ServiceType][]common.Recommendation) resultsByService := make(map[common.ServiceType][]common.PurchaseResult) - for i, rec := range recs { + for i, rec := range recs { //nolint:gocritic byService[rec.Service] = append(byService[rec.Service], rec) if i < len(results) { resultsByService[rec.Service] = append(resultsByService[rec.Service], results[i]) @@ -256,18 +258,18 @@ func buildServiceStats(recs []common.Recommendation, results []common.PurchaseRe return stats } -// runToolFromCSV processes recommendations from a CSV input file -func runToolFromCSV(ctx context.Context, cfg Config) { +// runToolFromCSV processes recommendations from a CSV input file. +func runToolFromCSV(ctx context.Context, cfg Config) { //nolint:gocritic // Determine if this is a dry run isDryRun := !cfg.ActualPurchase printRunMode(isDryRun) - csvModeCoverage := determineCSVCoverage(cfg) + csvModeCoverage := determineCSVCoverage(&cfg) AppLogger.Printf("📄 Reading recommendations from CSV: %s\n", cfg.CSVInput) // Read recommendations from CSV - recommendations, err := loadRecommendationsFromCSV(cfg.CSVInput) + recommendations, err := loadRecommendationsFromCSV(cfg.CSVInput) //nolint:gocritic if err != nil { log.Fatalf("Failed to read CSV file: %v", err) } @@ -368,8 +370,8 @@ func runToolFromCSV(ctx context.Context, cfg Config) { printMultiServiceSummary(recommendations, allResults, serviceStats, isDryRun) } -// filterAndAdjustRecommendations applies filters, coverage, count override, and instance limits to recommendations -func filterAndAdjustRecommendations(recommendations []common.Recommendation, csvModeCoverage float64, cfg Config) []common.Recommendation { +// filterAndAdjustRecommendations applies filters, coverage, count override, and instance limits to recommendations. +func filterAndAdjustRecommendations(recommendations []common.Recommendation, csvModeCoverage float64, cfg Config) []common.Recommendation { //nolint:gocritic // Query running instances for engine version validation log.Printf("🔍 Querying running RDS instances across all regions to validate engine versions...") instanceVersions, err := queryRunningInstanceEngineVersions(context.Background(), cfg) @@ -432,7 +434,7 @@ func filterAndAdjustRecommendations(recommendations []common.Recommendation, csv // processService processes a single service and returns recommendations and results. // Used by legacy callers; new code should use fetchAllRecs + executePurchasePipeline. -func processService(ctx context.Context, awsCfg aws.Config, recClient provider.RecommendationsClient, accountCache *AccountAliasCache, service common.ServiceType, isDryRun bool, cfg Config, engineData engineVersionData) ([]common.Recommendation, []common.PurchaseResult) { +func processService(ctx context.Context, awsCfg aws.Config, recClient provider.RecommendationsClient, accountCache *AccountAliasCache, service common.ServiceType, isDryRun bool, cfg Config, engineData engineVersionData) ([]common.Recommendation, []common.PurchaseResult) { //nolint:gocritic,unparam // engineData is nil in legacy callers; parameterised for engine-version callers regionsToProcess, err := determineRegionsForService(ctx, awsCfg, recClient, service, cfg.Regions) if err != nil { log.Printf("❌ Failed to determine regions: %v", err) @@ -458,11 +460,11 @@ 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) -func processPurchaseLoop(ctx context.Context, recs []common.Recommendation, region string, isDryRun bool, serviceClient provider.ServiceClient, cfg Config) []common.PurchaseResult { +// 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 { //nolint:gocritic results := make([]common.PurchaseResult, 0, len(recs)) - for j, rec := range recs { + for j, rec := range recs { //nolint:gocritic AppLogger.Printf(" [%d/%d] Processing: %s %s\n", j+1, len(recs), rec.Service, rec.ResourceType) AppLogger.Printf(" 💳 Purchasing %d instances\n", rec.Count) @@ -474,12 +476,12 @@ func processPurchaseLoop(ctx context.Context, recs []common.Recommendation, regi if j == 0 { totalInstances := CalculateTotalInstances(recs) totalSavings := 0.0 - for _, r := range recs { + for _, r := range recs { //nolint:gocritic totalSavings += r.EstimatedSavings } if !ConfirmPurchase(totalInstances, totalSavings, cfg.SkipConfirmation) { - // User cancelled - return cancelled results for all + // User canceled - return canceled results for all return createCancelledResults(recs, region, cfg) } } diff --git a/cmd/multi_service_coverage_test.go b/cmd/multi_service_coverage_test.go index 0ba0d0b78..8ec3d4220 100644 --- a/cmd/multi_service_coverage_test.go +++ b/cmd/multi_service_coverage_test.go @@ -272,9 +272,9 @@ func TestAdjustRecommendationForExcludedVersions_AdditionalCases(t *testing.T) { } tests := []struct { + instanceVersions map[string][]InstanceEngineVersion name string rec common.Recommendation - instanceVersions map[string][]InstanceEngineVersion expectedCount int }{ { @@ -370,10 +370,10 @@ func TestValidateFlags_Coverage(t *testing.T) { defer func() { toolCfg = origCfg }() tests := []struct { - name string setupCfg func() - expectError bool + name string errorMsg string + expectError bool }{ { name: "Valid configuration", @@ -592,7 +592,7 @@ func TestProcessService_GetRegionsError(t *testing.T) { IncludeSPTypes: toolCfg.IncludeSPTypes, ExcludeSPTypes: toolCfg.ExcludeSPTypes, } - mockClient.On("GetRecommendations", ctx, params).Return([]common.Recommendation{}, nil) + mockClient.On("GetRecommendations", ctx, ¶ms).Return([]common.Recommendation{}, nil) recs, results := processService(ctx, awsCfg, mockClient, accountCache, common.ServiceRDS, true, toolCfg, engineVersionData{}) @@ -628,7 +628,7 @@ func TestProcessService_GetRecommendationsError(t *testing.T) { IncludeSPTypes: toolCfg.IncludeSPTypes, ExcludeSPTypes: toolCfg.ExcludeSPTypes, } - mockClient.On("GetRecommendations", ctx, params).Return([]common.Recommendation(nil), errors.New("API error")) + mockClient.On("GetRecommendations", ctx, ¶ms).Return([]common.Recommendation(nil), errors.New("API error")) recs, results := processService(ctx, awsCfg, mockClient, accountCache, common.ServiceEC2, true, toolCfg, engineVersionData{}) @@ -670,7 +670,7 @@ func TestProcessService_AllRecommendationsFilteredOut(t *testing.T) { {ResourceType: "db.t3.small", Count: 5, Region: "us-east-1", EstimatedSavings: 100}, {ResourceType: "db.t3.medium", Count: 3, Region: "us-east-1", EstimatedSavings: 200}, } - mockClient.On("GetRecommendations", ctx, params).Return(mockRecs, nil) + mockClient.On("GetRecommendations", ctx, ¶ms).Return(mockRecs, nil) recs, results := processService(ctx, awsCfg, mockClient, accountCache, common.ServiceRDS, true, toolCfg, engineVersionData{}) @@ -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: @@ -798,7 +798,7 @@ func TestFetchExistingCoverage_LookbackDays(t *testing.T) { }) t.Run("custom lookback stored in Config", func(t *testing.T) { - cfg := Config{TargetCoverage: 80, CoverageLookbackDays: 60, Regions: []string{"us-east-1"}} + cfg := Config{CoverageLookbackDays: 60} // CoverageLookbackDays field value is preserved in the struct. assert.Equal(t, 60, cfg.CoverageLookbackDays) }) diff --git a/cmd/multi_service_csv.go b/cmd/multi_service_csv.go index 29ae14d38..5c81b7541 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,8 +14,8 @@ import ( "github.com/LeanerCloud/CUDly/providers/aws/recommendations" ) -// determineCSVCoverage determines the coverage percentage to use for CSV mode -func determineCSVCoverage(cfg Config) float64 { +// 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 if cfg.Coverage == 80.0 { @@ -24,15 +25,15 @@ 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 { return nil, fmt.Errorf("failed to open CSV file: %w", err) } defer func() { - if err := file.Close(); err != nil { - log.Printf("Warning: failed to close CSV file %s: %v", csvPath, err) + if closeErr := file.Close(); closeErr != nil { + log.Printf("Warning: failed to close CSV file %s: %v", csvPath, closeErr) } }() @@ -48,15 +49,15 @@ func loadRecommendationsFromCSV(csvPath string) ([]common.Recommendation, error) colIdx := buildColumnIndexMap(header) // Parse all records - recommendations, err := parseCSVRecords(reader, colIdx) + recs, err := parseCSVRecords(reader, colIdx) if err != nil { return nil, err } - return recommendations, nil + return recs, 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 + var recs []common.Recommendation for { record, err := reader.Read() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { @@ -91,13 +92,13 @@ func parseCSVRecords(reader *csv.Reader, colIdx map[string]int) ([]common.Recomm return nil, err } - recommendations = append(recommendations, rec) + recs = append(recs, rec) } - return recommendations, nil + return recs, 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 @@ -199,8 +200,8 @@ func writeMultiServiceCSVReport(results []common.PurchaseResult, filepath string return fmt.Errorf("failed to create CSV file: %w", err) } defer func() { - if err := file.Close(); err != nil { - log.Printf("Warning: failed to close CSV file %s: %v", filepath, err) + if closeErr := file.Close(); closeErr != nil { + log.Printf("Warning: failed to close CSV file %s: %v", filepath, closeErr) } }() @@ -244,8 +245,9 @@ func writeMultiServiceCSVReport(results []common.PurchaseResult, filepath string return sorted[i].Recommendation.CommitmentCost > sorted[j].Recommendation.CommitmentCost }) - for _, r := range sorted { - rec := r.Recommendation + for i := range sorted { + r := &sorted[i] + rec := &r.Recommendation errStr := "" if r.Error != nil { errStr = r.Error.Error() @@ -277,8 +279,8 @@ func writeMultiServiceCSVReport(results []common.PurchaseResult, filepath string formatExistingCoverage(rec), formatPercentOrBlank(rec.ProjectedCoverage), } - if err := writer.Write(row); err != nil { - return fmt.Errorf("failed to write CSV row: %w", err) + if writeErr := writer.Write(row); writeErr != nil { + return fmt.Errorf("failed to write CSV row: %w", writeErr) } } @@ -306,7 +308,8 @@ func buildTotalRow(results []common.PurchaseResult) []string { var totalCount int var totalNU, totalUpfront, totalRecurring, totalSavings float64 hasRecurring := false - for _, r := range results { + for i := range results { + r := &results[i] totalCount += r.Recommendation.Count totalNU += float64(r.Recommendation.Count) * recommendations.RDSInstanceNUFromType(r.Recommendation.ResourceType) totalUpfront += r.Recommendation.CommitmentCost @@ -351,7 +354,7 @@ func formatIntOrBlank(v int) string { // instance type doesn't follow the RDS three-part naming. Useful for // grouping rows in the CSV by family-NU bucket so operators can see at // a glance which recs belong to the same size-flex family. -func extractRDSFamily(rec common.Recommendation) string { +func extractRDSFamily(rec *common.Recommendation) string { if rec.Service != common.ServiceRDS && rec.Service != common.ServiceRelationalDB { return "" } @@ -364,7 +367,7 @@ func extractRDSFamily(rec common.Recommendation) string { // into a single rec at one size — without this column, operators have // to compute NU by hand to verify the bundling. Renders blank for // non-RDS rows and for sizes not in the standard NU scale. -func formatNormalizedUnitsOrBlank(rec common.Recommendation) string { +func formatNormalizedUnitsOrBlank(rec *common.Recommendation) string { if rec.Service != common.ServiceRDS && rec.Service != common.ServiceRelationalDB { return "" } @@ -384,7 +387,7 @@ func formatNormalizedUnitsOrBlank(rec common.Recommendation) string { // // Both value and pointer Details are accepted to mirror extractEngine // (parser path stores pointers; CSV-loader path constructs values). -func extractDeployment(rec common.Recommendation) string { +func extractDeployment(rec *common.Recommendation) string { switch details := rec.Details.(type) { case *common.DatabaseDetails: if details != nil { @@ -406,7 +409,7 @@ func extractDeployment(rec common.Recommendation) string { // path constructs the value forms; the dispatch in generatePurchaseID does // the same trick. Without the pointer cases the column silently blanks // every row coming from the live parser path. -func extractEngine(rec common.Recommendation) string { +func extractEngine(rec *common.Recommendation) string { switch details := rec.Details.(type) { case *common.DatabaseDetails: if details != nil { @@ -443,7 +446,7 @@ func extractEngine(rec common.Recommendation) string { // Previously both the no-data and the genuine-zero cases rendered as a // blank cell, conflating "we don't know" with "definitely zero" and // making it impossible to spot pools where the CE signal was missing. -func formatExistingCoverage(rec common.Recommendation) string { +func formatExistingCoverage(rec *common.Recommendation) string { if !rec.ExistingCoverageKnown { return "n/a" } @@ -493,7 +496,7 @@ func formatAvgInstancesOrBlank(v float64) string { // next to Instances so operators can read "you have X running, Y are // already covered, this rec adds N more" without doing the arithmetic. // Blank when either signal is zero (we can't compute a meaningful value). -func formatCoveredInstancesOrBlank(rec common.Recommendation) string { +func formatCoveredInstancesOrBlank(rec *common.Recommendation) string { if rec.AverageInstancesUsedPerHour <= 0 || rec.ExistingCoveragePct <= 0 { return "" } diff --git a/cmd/multi_service_csv_test.go b/cmd/multi_service_csv_test.go index 7d6af2772..93578fa49 100644 --- a/cmd/multi_service_csv_test.go +++ b/cmd/multi_service_csv_test.go @@ -59,7 +59,7 @@ func TestDetermineCSVCoverage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := determineCSVCoverage(tt.cfg) + result := determineCSVCoverage(&tt.cfg) assert.Equal(t, tt.expected, result) }) } @@ -68,8 +68,8 @@ func TestDetermineCSVCoverage(t *testing.T) { func TestWriteMultiServiceCSVReport(t *testing.T) { tests := []struct { name string - results []common.PurchaseResult filename string + results []common.PurchaseResult wantErr bool }{ { @@ -400,17 +400,17 @@ func TestWriteMultiServiceCSVReport_SortAndTotal(t *testing.T) { func TestFormatExistingCoverage(t *testing.T) { tests := []struct { name string - rec common.Recommendation want string + rec common.Recommendation }{ - {"unknown (CE no data)", common.Recommendation{}, "n/a"}, - {"known zero coverage", common.Recommendation{ExistingCoverageKnown: true}, "0.0"}, - {"known partial coverage", common.Recommendation{ExistingCoverageKnown: true, ExistingCoveragePct: 37.74}, "37.7"}, - {"known full coverage", common.Recommendation{ExistingCoverageKnown: true, ExistingCoveragePct: 100.0}, "100.0"}, + {name: "unknown (CE no data)", rec: common.Recommendation{}, want: "n/a"}, + {name: "known zero coverage", rec: common.Recommendation{ExistingCoverageKnown: true}, want: "0.0"}, + {name: "known partial coverage", rec: common.Recommendation{ExistingCoverageKnown: true, ExistingCoveragePct: 37.74}, want: "37.7"}, + {name: "known full coverage", rec: common.Recommendation{ExistingCoverageKnown: true, ExistingCoveragePct: 100.0}, want: "100.0"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, formatExistingCoverage(tt.rec)) + assert.Equal(t, tt.want, formatExistingCoverage(&tt.rec)) }) } } @@ -444,21 +444,21 @@ func TestFormatRecurringMonthlyOrBlank(t *testing.T) { func TestExtractRDSFamily(t *testing.T) { tests := []struct { name string - rec common.Recommendation want string + rec common.Recommendation }{ - {"RDS db.r7g.large", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.large"}, "db.r7g"}, - {"RDS db.t4g.medium", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.t4g.medium"}, "db.t4g"}, - {"RelationalDB alias", common.Recommendation{Service: common.ServiceRelationalDB, ResourceType: "db.m5.xlarge"}, "db.m5"}, + {name: "RDS db.r7g.large", rec: common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.large"}, want: "db.r7g"}, + {name: "RDS db.t4g.medium", rec: common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.t4g.medium"}, want: "db.t4g"}, + {name: "RelationalDB alias", rec: common.Recommendation{Service: common.ServiceRelationalDB, ResourceType: "db.m5.xlarge"}, want: "db.m5"}, // Non-RDS services blank even when ResourceType looks RDS-shaped. - {"EC2 ignored", common.Recommendation{Service: common.ServiceEC2, ResourceType: "m5.large"}, ""}, - {"ElastiCache ignored", common.Recommendation{Service: common.ServiceElastiCache, ResourceType: "cache.t3.micro"}, ""}, + {name: "EC2 ignored", rec: common.Recommendation{Service: common.ServiceEC2, ResourceType: "m5.large"}, want: ""}, + {name: "ElastiCache ignored", rec: common.Recommendation{Service: common.ServiceElastiCache, ResourceType: "cache.t3.micro"}, want: ""}, // Malformed RDS type. - {"RDS bare type", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g"}, ""}, + {name: "RDS bare type", rec: common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g"}, want: ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, extractRDSFamily(tt.rec)) + assert.Equal(t, tt.want, extractRDSFamily(&tt.rec)) }) } } @@ -469,25 +469,25 @@ func TestExtractRDSFamily(t *testing.T) { func TestFormatNormalizedUnitsOrBlank(t *testing.T) { tests := []struct { name string - rec common.Recommendation want string + rec common.Recommendation }{ // 15 × db.r7g.large = 15 × 4 NU = 60 NU - {"r7g.large × 15", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.large", Count: 15}, "60"}, + {name: "r7g.large × 15", rec: common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.large", Count: 15}, want: "60"}, // 3 × db.t4g.medium = 3 × 2 NU = 6 NU - {"t4g.medium × 3", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.t4g.medium", Count: 3}, "6"}, + {name: "t4g.medium × 3", rec: common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.t4g.medium", Count: 3}, want: "6"}, // Fractional NU survives via %g (db.t4g.micro = 0.5 NU) - {"t4g.micro × 3", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.t4g.micro", Count: 3}, "1.5"}, + {name: "t4g.micro × 3", rec: common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.t4g.micro", Count: 3}, want: "1.5"}, // Non-RDS service → blank - {"EC2 row blank", common.Recommendation{Service: common.ServiceEC2, ResourceType: "m5.large", Count: 5}, ""}, + {name: "EC2 row blank", rec: common.Recommendation{Service: common.ServiceEC2, ResourceType: "m5.large", Count: 5}, want: ""}, // Zero count → blank - {"zero count blank", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.large", Count: 0}, ""}, + {name: "zero count blank", rec: common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.large", Count: 0}, want: ""}, // Unknown size → blank - {"unknown size blank", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.bogus", Count: 5}, ""}, + {name: "unknown size blank", rec: common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.bogus", Count: 5}, want: ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, formatNormalizedUnitsOrBlank(tt.rec)) + assert.Equal(t, tt.want, formatNormalizedUnitsOrBlank(&tt.rec)) }) } } @@ -500,22 +500,22 @@ func TestFormatNormalizedUnitsOrBlank(t *testing.T) { func TestExtractDeployment(t *testing.T) { tests := []struct { name string - rec common.Recommendation want string + rec common.Recommendation }{ - {"*DatabaseDetails Single-AZ", common.Recommendation{Details: &common.DatabaseDetails{AZConfig: "single-az"}}, "single-az"}, - {"*DatabaseDetails Multi-AZ", common.Recommendation{Details: &common.DatabaseDetails{AZConfig: "multi-az"}}, "multi-az"}, - {"DatabaseDetails (value) Multi-AZ", common.Recommendation{Details: common.DatabaseDetails{AZConfig: "multi-az"}}, "multi-az"}, - {"DatabaseDetails empty AZConfig", common.Recommendation{Details: &common.DatabaseDetails{Engine: "mysql"}}, ""}, + {name: "*DatabaseDetails Single-AZ", rec: common.Recommendation{Details: &common.DatabaseDetails{AZConfig: "single-az"}}, want: "single-az"}, + {name: "*DatabaseDetails Multi-AZ", rec: common.Recommendation{Details: &common.DatabaseDetails{AZConfig: "multi-az"}}, want: "multi-az"}, + {name: "DatabaseDetails (value) Multi-AZ", rec: common.Recommendation{Details: common.DatabaseDetails{AZConfig: "multi-az"}}, want: "multi-az"}, + {name: "DatabaseDetails empty AZConfig", rec: common.Recommendation{Details: &common.DatabaseDetails{Engine: "mysql"}}, want: ""}, // Non-RDS Details → blank (column is RDS-only data). - {"CacheDetails -> empty", common.Recommendation{Details: &common.CacheDetails{Engine: "redis"}}, ""}, - {"ComputeDetails -> empty", common.Recommendation{Details: &common.ComputeDetails{Platform: "Linux/UNIX"}}, ""}, - {"nil Details -> empty", common.Recommendation{}, ""}, - {"nil *DatabaseDetails -> empty", common.Recommendation{Details: (*common.DatabaseDetails)(nil)}, ""}, + {name: "CacheDetails -> empty", rec: common.Recommendation{Details: &common.CacheDetails{Engine: "redis"}}, want: ""}, + {name: "ComputeDetails -> empty", rec: common.Recommendation{Details: &common.ComputeDetails{Platform: "Linux/UNIX"}}, want: ""}, + {name: "nil Details -> empty", rec: common.Recommendation{}, want: ""}, + {name: "nil *DatabaseDetails -> empty", rec: common.Recommendation{Details: (*common.DatabaseDetails)(nil)}, want: ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, extractDeployment(tt.rec)) + assert.Equal(t, tt.want, extractDeployment(&tt.rec)) }) } } @@ -526,42 +526,42 @@ func TestExtractDeployment(t *testing.T) { func TestExtractEngine(t *testing.T) { tests := []struct { name string - rec common.Recommendation want string + rec common.Recommendation }{ // Pointer forms — what the live parser actually emits. - {"*DatabaseDetails -> Engine", common.Recommendation{Details: &common.DatabaseDetails{Engine: "aurora-postgresql"}}, "aurora-postgresql"}, - {"*CacheDetails -> Engine", common.Recommendation{Details: &common.CacheDetails{Engine: "redis"}}, "redis"}, - {"*ComputeDetails -> Platform", common.Recommendation{Details: &common.ComputeDetails{Platform: "Linux/UNIX"}}, "Linux/UNIX"}, + {name: "*DatabaseDetails -> Engine", rec: common.Recommendation{Details: &common.DatabaseDetails{Engine: "aurora-postgresql"}}, want: "aurora-postgresql"}, + {name: "*CacheDetails -> Engine", rec: common.Recommendation{Details: &common.CacheDetails{Engine: "redis"}}, want: "redis"}, + {name: "*ComputeDetails -> Platform", rec: common.Recommendation{Details: &common.ComputeDetails{Platform: "Linux/UNIX"}}, want: "Linux/UNIX"}, // Value forms — what the CSV-loader path constructs. - {"DatabaseDetails (value) -> Engine", common.Recommendation{Details: common.DatabaseDetails{Engine: "mysql"}}, "mysql"}, - {"CacheDetails (value) -> Engine", common.Recommendation{Details: common.CacheDetails{Engine: "memcached"}}, "memcached"}, - {"ComputeDetails (value) -> Platform", common.Recommendation{Details: common.ComputeDetails{Platform: "Windows"}}, "Windows"}, + {name: "DatabaseDetails (value) -> Engine", rec: common.Recommendation{Details: common.DatabaseDetails{Engine: "mysql"}}, want: "mysql"}, + {name: "CacheDetails (value) -> Engine", rec: common.Recommendation{Details: common.CacheDetails{Engine: "memcached"}}, want: "memcached"}, + {name: "ComputeDetails (value) -> Platform", rec: common.Recommendation{Details: common.ComputeDetails{Platform: "Windows"}}, want: "Windows"}, // Fallbacks. - {"nil Details -> empty", common.Recommendation{}, ""}, - {"SavingsPlanDetails -> empty", common.Recommendation{Details: &common.SavingsPlanDetails{HourlyCommitment: 1.0}}, ""}, - {"nil *DatabaseDetails -> empty", common.Recommendation{Details: (*common.DatabaseDetails)(nil)}, ""}, + {name: "nil Details -> empty", rec: common.Recommendation{}, want: ""}, + {name: "SavingsPlanDetails -> empty", rec: common.Recommendation{Details: &common.SavingsPlanDetails{HourlyCommitment: 1.0}}, want: ""}, + {name: "nil *DatabaseDetails -> empty", rec: common.Recommendation{Details: (*common.DatabaseDetails)(nil)}, want: ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, extractEngine(tt.rec)) + assert.Equal(t, tt.want, extractEngine(&tt.rec)) }) } } -// 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. func TestFormatCurrencyOrBlank(t *testing.T) { tests := []struct { name string - in float64 want string + in float64 }{ - {"non-zero renders with two decimals", 1234.56, "1234.56"}, - {"integer value gets .00", 700, "700.00"}, - {"zero blanks the cell", 0, ""}, + {name: "non-zero renders with two decimals", in: 1234.56, want: "1234.56"}, + {name: "integer value gets .00", in: 700, want: "700.00"}, + {name: "zero blanks the cell", in: 0, want: ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -570,14 +570,14 @@ func TestFormatCurrencyOrBlank(t *testing.T) { } } -// Tests for loadRecommendationsFromCSV function +// Tests for loadRecommendationsFromCSV function. func TestLoadRecommendationsFromCSV(t *testing.T) { tests := []struct { + validate func(t *testing.T, recs []common.Recommendation) name string csvContent string - wantErr bool errContains string - validate func(t *testing.T, recs []common.Recommendation) + wantErr bool }{ { name: "Valid CSV with all fields", @@ -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..63ac0d7df 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,22 +26,22 @@ 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 + LifecycleSupportName string } -// 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 -func queryRunningInstanceEngineVersions(ctx context.Context, cfg Config) (map[string][]InstanceEngineVersion, error) { +// queryRunningInstanceEngineVersions queries all running RDS instances and returns their engine versions. +func queryRunningInstanceEngineVersions(ctx context.Context, cfg Config) (map[string][]InstanceEngineVersion, error) { //nolint:gocritic awsCfg, err := loadValidationAWSConfig(ctx, cfg) if err != nil { return nil, err @@ -55,8 +55,8 @@ func queryRunningInstanceEngineVersions(ctx context.Context, cfg Config) (map[st return queryRDSInstancesInRegions(ctx, awsCfg, regions) } -// loadValidationAWSConfig loads AWS configuration for validation -func loadValidationAWSConfig(ctx context.Context, cfg Config) (aws.Config, error) { +// loadValidationAWSConfig loads AWS configuration for validation. +func loadValidationAWSConfig(ctx context.Context, cfg Config) (aws.Config, error) { //nolint:gocritic validationProfile := cfg.ValidationProfile if validationProfile == "" { validationProfile = cfg.Profile @@ -76,8 +76,8 @@ func loadValidationAWSConfig(ctx context.Context, cfg Config) (aws.Config, error return awsCfg, nil } -// getAWSRegions retrieves all AWS regions -func getAWSRegions(ctx context.Context, awsCfg aws.Config) ([]ec2types.Region, error) { +// getAWSRegions retrieves all AWS regions. +func getAWSRegions(ctx context.Context, awsCfg aws.Config) ([]ec2types.Region, error) { //nolint:gocritic ec2Client := awsec2.NewFromConfig(awsCfg) regionsOutput, err := ec2Client.DescribeRegions(ctx, &awsec2.DescribeRegionsInput{}) if err != nil { @@ -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,8 +99,8 @@ 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 -func queryRDSInstancesInRegions(ctx context.Context, awsCfg aws.Config, regions []ec2types.Region) (map[string][]InstanceEngineVersion, error) { +// queryRDSInstancesInRegions queries RDS instances in all regions concurrently. +func queryRDSInstancesInRegions(ctx context.Context, awsCfg aws.Config, regions []ec2types.Region) (map[string][]InstanceEngineVersion, error) { //nolint:gocritic instanceVersions := make(map[string][]InstanceEngineVersion) var mu sync.Mutex var wg sync.WaitGroup @@ -128,8 +128,8 @@ func queryRDSInstancesInRegions(ctx context.Context, awsCfg aws.Config, regions return instanceVersions, nil } -// 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) { +// 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) { //nolint:gocritic regionCfg := awsCfg.Copy() regionCfg.Region = regionName rdsClient := awsrds.NewFromConfig(regionCfg) @@ -156,8 +156,8 @@ func queryRDSInstancesInRegion(ctx context.Context, awsCfg aws.Config, regionNam } } -// 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) { +// 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) { //nolint:gocritic input := &awsrds.DescribeDBInstancesInput{Marker: marker} output, err := rdsClient.DescribeDBInstances(ctx, input) if err != nil { @@ -165,7 +165,7 @@ func queryRDSInstancesPage(ctx context.Context, rdsClient *awsrds.Client, marker } localVersions := make(map[string][]InstanceEngineVersion) - for _, dbInstance := range output.DBInstances { + for _, dbInstance := range output.DBInstances { //nolint:gocritic instanceClass := aws.ToString(dbInstance.DBInstanceClass) engine := aws.ToString(dbInstance.Engine) engineVersion := aws.ToString(dbInstance.EngineVersion) @@ -186,8 +186,8 @@ func queryRDSInstancesPage(ctx context.Context, rdsClient *awsrds.Client, marker return localVersions, nextMarker, nil } -// queryMajorEngineVersions queries AWS for major engine version lifecycle support information -func queryMajorEngineVersions(ctx context.Context, cfg Config) (map[string]MajorEngineVersionInfo, error) { +// queryMajorEngineVersions queries AWS for major engine version lifecycle support information. +func queryMajorEngineVersions(ctx context.Context, cfg Config) (map[string]MajorEngineVersionInfo, error) { //nolint:gocritic // Determine which profile to use profile := cfg.ValidationProfile if profile == "" { @@ -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,8 +411,8 @@ 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 -func adjustRecommendationForExcludedVersions(rec common.Recommendation, instanceVersions map[string][]InstanceEngineVersion, versionInfo map[string]MajorEngineVersionInfo) common.Recommendation { +// 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 { //nolint:gocritic // Check if this instance type has any running instances versions, exists := instanceVersions[rec.ResourceType] if !exists { diff --git a/cmd/multi_service_engine_versions_paginate_test.go b/cmd/multi_service_engine_versions_paginate_test.go index cb68aa097..4f64077b2 100644 --- a/cmd/multi_service_engine_versions_paginate_test.go +++ b/cmd/multi_service_engine_versions_paginate_test.go @@ -45,9 +45,9 @@ func (m *multiPageRDSMajorVersionsMock) DescribeDBMajorEngineVersions( } // rdsMajorVersion builds a minimal DBMajorEngineVersion for tests. -func rdsMajorVersion(engine, major string) rdstypes.DBMajorEngineVersion { +func rdsMajorVersion(major string) rdstypes.DBMajorEngineVersion { return rdstypes.DBMajorEngineVersion{ - Engine: aws.String(engine), + Engine: aws.String("mysql"), MajorEngineVersion: aws.String(major), SupportedEngineLifecycles: []rdstypes.SupportedEngineLifecycle{ { @@ -66,21 +66,21 @@ func TestFetchMajorEngineVersionsForEngine_Paginates(t *testing.T) { pages: []*awsrds.DescribeDBMajorEngineVersionsOutput{ { DBMajorEngineVersions: []rdstypes.DBMajorEngineVersion{ - rdsMajorVersion("mysql", "5.7"), - rdsMajorVersion("mysql", "8.0"), + rdsMajorVersion("5.7"), + rdsMajorVersion("8.0"), }, Marker: aws.String("tok1"), }, { DBMajorEngineVersions: []rdstypes.DBMajorEngineVersion{ - rdsMajorVersion("mysql", "8.4"), - rdsMajorVersion("mysql", "9.0"), + rdsMajorVersion("8.4"), + rdsMajorVersion("9.0"), }, Marker: aws.String("tok2"), }, { DBMajorEngineVersions: []rdstypes.DBMajorEngineVersion{ - rdsMajorVersion("mysql", "9.1"), + rdsMajorVersion("9.1"), }, Marker: nil, }, @@ -105,7 +105,7 @@ func TestFetchMajorEngineVersionsForEngine_EmptyMarkerTerminates(t *testing.T) { pages: []*awsrds.DescribeDBMajorEngineVersionsOutput{ { DBMajorEngineVersions: []rdstypes.DBMajorEngineVersion{ - rdsMajorVersion("mysql", "8.0"), + rdsMajorVersion("8.0"), }, Marker: aws.String(""), // empty string -- must terminate }, @@ -133,7 +133,7 @@ func (m *alwaysNextPageRDSMock) DescribeDBMajorEngineVersions( m.calls++ return &awsrds.DescribeDBMajorEngineVersionsOutput{ DBMajorEngineVersions: []rdstypes.DBMajorEngineVersion{ - rdsMajorVersion("mysql", fmt.Sprintf("5.%d", m.calls)), + rdsMajorVersion(fmt.Sprintf("5.%d", m.calls)), }, Marker: aws.String(fmt.Sprintf("tok%d", m.calls)), }, nil diff --git a/cmd/multi_service_filters_test.go b/cmd/multi_service_filters_test.go index ec2bc5f72..fcf3f1a2e 100644 --- a/cmd/multi_service_filters_test.go +++ b/cmd/multi_service_filters_test.go @@ -241,9 +241,9 @@ func TestShouldIncludeEngine(t *testing.T) { tests := []struct { name string - recommendation common.Recommendation includeEngines []string excludeEngines []string + recommendation common.Recommendation expected bool }{ { diff --git a/cmd/multi_service_helpers.go b/cmd/multi_service_helpers.go index b30426820..6218df4ba 100644 --- a/cmd/multi_service_helpers.go +++ b/cmd/multi_service_helpers.go @@ -15,12 +15,12 @@ import ( awsec2 "github.com/aws/aws-sdk-go-v2/service/ec2" ) -// EC2ClientInterface defines the interface for EC2 operations +// EC2ClientInterface defines the interface for EC2 operations. type EC2ClientInterface interface { DescribeRegions(ctx context.Context, params *awsec2.DescribeRegionsInput, optFns ...func(*awsec2.Options)) (*awsec2.DescribeRegionsOutput, error) } -// formatServices formats a list of services for display +// formatServices formats a list of services for display. func formatServices(services []common.ServiceType) string { names := make([]string, len(services)) for i, s := range services { @@ -29,7 +29,7 @@ func formatServices(services []common.ServiceType) string { return strings.Join(names, ", ") } -// getServiceDisplayName returns the display name for a service type +// getServiceDisplayName returns the display name for a service type. func getServiceDisplayName(service common.ServiceType) string { switch service { case common.ServiceRDS: @@ -68,14 +68,14 @@ func savingsPlanDisplayName(service common.ServiceType) (string, bool) { return name, ok } -// getAllAWSRegions retrieves all available AWS regions -func getAllAWSRegions(ctx context.Context, cfg aws.Config) ([]string, error) { +// getAllAWSRegions retrieves all available AWS regions. +func getAllAWSRegions(ctx context.Context, cfg aws.Config) ([]string, error) { //nolint:gocritic // Create EC2 client to get regions ec2Client := awsec2.NewFromConfig(cfg) return getAllAWSRegionsWithClient(ctx, ec2Client) } -// getAllAWSRegionsWithClient retrieves all available AWS regions using the provided client +// getAllAWSRegionsWithClient retrieves all available AWS regions using the provided client. func getAllAWSRegionsWithClient(ctx context.Context, ec2Client EC2ClientInterface) ([]string, error) { // Describe all regions result, err := ec2Client.DescribeRegions(ctx, &awsec2.DescribeRegionsInput{ @@ -96,7 +96,7 @@ func getAllAWSRegionsWithClient(ctx context.Context, ec2Client EC2ClientInterfac return regions, nil } -// discoverRegionsForService discovers regions that have recommendations for a specific service +// discoverRegionsForService discovers regions that have recommendations for a specific service. func discoverRegionsForService(ctx context.Context, client provider.RecommendationsClient, service common.ServiceType) ([]string, error) { recs, err := client.GetRecommendationsForService(ctx, service) if err != nil { @@ -104,7 +104,7 @@ func discoverRegionsForService(ctx context.Context, client provider.Recommendati } regionSet := make(map[string]bool) - for _, rec := range recs { + for _, rec := range recs { //nolint:gocritic if rec.Region != "" { regionSet[rec.Region] = true } @@ -119,13 +119,13 @@ func discoverRegionsForService(ctx context.Context, client provider.Recommendati return regions, nil } -// applyCommonCoverage applies coverage percentage to recommendations +// applyCommonCoverage applies coverage percentage to recommendations. func applyCommonCoverage(recs []common.Recommendation, coverage float64) []common.Recommendation { return ApplyCoverage(recs, coverage) } -// determineServicesToProcess returns the list of services to process based on flags -func determineServicesToProcess(cfg Config) []common.ServiceType { +// determineServicesToProcess returns the list of services to process based on flags. +func determineServicesToProcess(cfg Config) []common.ServiceType { //nolint:gocritic if cfg.AllServices { return getAllServices() } @@ -136,7 +136,7 @@ func determineServicesToProcess(cfg Config) []common.ServiceType { return []common.ServiceType{common.ServiceRDS} } -// printRunMode prints the current run mode (dry run or purchase) +// printRunMode prints the current run mode (dry run or purchase). func printRunMode(isDryRun bool) { if isDryRun { AppLogger.Println("🔍 DRY RUN MODE - No actual purchases will be made") @@ -145,13 +145,13 @@ func printRunMode(isDryRun bool) { } } -// printPaymentAndTerm prints the payment option and term information -func printPaymentAndTerm(cfg Config) { +// printPaymentAndTerm prints the payment option and term information. +func printPaymentAndTerm(cfg Config) { //nolint:gocritic AppLogger.Printf("💳 Payment option: %s, Term: %d year(s)\n", cfg.PaymentOption, cfg.TermYears) } -// generateCSVFilename generates a CSV filename based on the mode and timestamp -func generateCSVFilename(isDryRun bool, cfg Config) string { +// generateCSVFilename generates a CSV filename based on the mode and timestamp. +func generateCSVFilename(isDryRun bool, cfg Config) string { //nolint:gocritic if cfg.CSVOutput != "" { return cfg.CSVOutput } @@ -163,10 +163,10 @@ func generateCSVFilename(isDryRun bool, cfg Config) string { return fmt.Sprintf("ri-helper-%s-%s.csv", mode, timestamp) } -// groupRecommendationsByServiceRegion groups recommendations by service and region -func groupRecommendationsByServiceRegion(recommendations []common.Recommendation) map[common.ServiceType]map[string][]common.Recommendation { +// groupRecommendationsByServiceRegion groups recommendations by service and region. +func groupRecommendationsByServiceRegion(recommendations []common.Recommendation) map[common.ServiceType]map[string][]common.Recommendation { //nolint:gocritic recsByServiceRegion := make(map[common.ServiceType]map[string][]common.Recommendation) - for _, rec := range recommendations { + for _, rec := range recommendations { //nolint:gocritic if _, ok := recsByServiceRegion[rec.Service]; !ok { recsByServiceRegion[rec.Service] = make(map[string][]common.Recommendation) } @@ -175,8 +175,8 @@ func groupRecommendationsByServiceRegion(recommendations []common.Recommendation return recsByServiceRegion } -// populateAccountNames populates account names from account IDs using the cache -func populateAccountNames(ctx context.Context, recommendations []common.Recommendation, accountCache *AccountAliasCache) { +// populateAccountNames populates account names from account IDs using the cache. +func populateAccountNames(ctx context.Context, recommendations []common.Recommendation, accountCache *AccountAliasCache) { //nolint:gocritic for i := range recommendations { if recommendations[i].Account != "" { recommendations[i].AccountName = accountCache.GetAccountAlias(ctx, recommendations[i].Account) @@ -184,7 +184,7 @@ func populateAccountNames(ctx context.Context, recommendations []common.Recommen } } -// adjustRecsForDuplicates checks for existing RIs and adjusts recommendations to avoid duplicates +// adjustRecsForDuplicates checks for existing RIs and adjusts recommendations to avoid duplicates. func adjustRecsForDuplicates(ctx context.Context, recs []common.Recommendation, serviceClient provider.ServiceClient) ([]common.Recommendation, error) { duplicateChecker := NewDuplicateChecker(0) adjustedRecs, _, err := duplicateChecker.AdjustRecommendationsForExisting(ctx, recs, serviceClient) @@ -201,8 +201,8 @@ func adjustRecsForDuplicates(ctx context.Context, recs []common.Recommendation, return adjustedRecs, nil } -// createDryRunResult creates a purchase result for dry run mode -func createDryRunResult(rec common.Recommendation, region string, index int, cfg Config) common.PurchaseResult { +// createDryRunResult creates a purchase result for dry run mode. +func createDryRunResult(rec common.Recommendation, region string, index int, cfg Config) common.PurchaseResult { //nolint:gocritic return common.PurchaseResult{ Recommendation: rec, Success: true, @@ -212,30 +212,30 @@ func createDryRunResult(rec common.Recommendation, region string, index int, cfg } } -// createCancelledResults creates purchase results for cancelled purchases -func createCancelledResults(recs []common.Recommendation, region string, cfg Config) []common.PurchaseResult { +// createCancelledResults creates purchase results for canceled purchases. +func createCancelledResults(recs []common.Recommendation, region string, cfg Config) []common.PurchaseResult { //nolint:gocritic results := make([]common.PurchaseResult, len(recs)) for k := range recs { results[k] = common.PurchaseResult{ Recommendation: recs[k], Success: false, CommitmentID: generatePurchaseID(recs[k], region, k+1, false, effectiveSizingPct(cfg)), - Error: fmt.Errorf("purchase cancelled by user"), + Error: fmt.Errorf("purchase canceled by user"), Timestamp: time.Now(), } } return results } -// executePurchase executes an actual RI purchase -func executePurchase(ctx context.Context, rec common.Recommendation, region string, index int, serviceClient provider.ServiceClient, cfg Config) common.PurchaseResult { +// executePurchase executes an actual RI purchase. +func executePurchase(ctx context.Context, rec common.Recommendation, region string, index int, serviceClient provider.ServiceClient, cfg Config) common.PurchaseResult { //nolint:gocritic AppLogger.Printf(" ⚠️ ACTUAL PURCHASE: About to buy %d instances of %s\n", rec.Count, rec.ResourceType) // Compute the descriptive commitment ID up front and hand it to the // provider so the purchased commitment is named descriptively at AWS // (e.g. RDS ReservedDBInstanceId), not just in our local report. commitmentID := generatePurchaseID(rec, region, index, false, effectiveSizingPct(cfg)) opts := common.PurchaseOptions{Source: common.PurchaseSourceCLI, ReservationID: commitmentID} - result, err := serviceClient.PurchaseCommitment(ctx, rec, opts) + result, err := serviceClient.PurchaseCommitment(ctx, &rec, opts) if err != nil { result.Success = false result.Error = err @@ -246,8 +246,8 @@ func executePurchase(ctx context.Context, rec common.Recommendation, region stri return result } -// determineRegionsForService determines which regions to process for a given service -func determineRegionsForService(ctx context.Context, awsCfg aws.Config, recClient provider.RecommendationsClient, service common.ServiceType, configuredRegions []string) ([]string, error) { +// determineRegionsForService determines which regions to process for a given service. +func determineRegionsForService(ctx context.Context, awsCfg aws.Config, recClient provider.RecommendationsClient, service common.ServiceType, configuredRegions []string) ([]string, error) { //nolint:gocritic // If regions are explicitly configured, use those if len(configuredRegions) > 0 { return configuredRegions, nil @@ -270,7 +270,7 @@ func determineRegionsForService(ctx context.Context, awsCfg aws.Config, recClien return allRegions, nil } -// handleRegionDiscoveryError handles errors during region discovery by falling back to auto-discovery +// handleRegionDiscoveryError handles errors during region discovery by falling back to auto-discovery. func handleRegionDiscoveryError(ctx context.Context, recClient provider.RecommendationsClient, service common.ServiceType, originalErr error) ([]string, error) { AppLogger.Printf("❌ Failed to get AWS regions: %v\n", originalErr) AppLogger.Printf("🔍 Falling back to auto-discovery...\n") @@ -283,14 +283,14 @@ func handleRegionDiscoveryError(ctx context.Context, recClient provider.Recommen return discoveredRegions, nil } -// engineVersionData holds the results of engine version queries +// engineVersionData holds the results of engine version queries. type engineVersionData struct { instanceVersions map[string][]InstanceEngineVersion versionInfo map[string]MajorEngineVersionInfo } -// fetchEngineVersionData queries running instances and major engine versions for validation -func fetchEngineVersionData(ctx context.Context, cfg Config) engineVersionData { +// fetchEngineVersionData queries running instances and major engine versions for validation. +func fetchEngineVersionData(ctx context.Context, cfg Config) engineVersionData { //nolint:gocritic data := engineVersionData{ instanceVersions: make(map[string][]InstanceEngineVersion), versionInfo: make(map[string]MajorEngineVersionInfo), @@ -305,8 +305,8 @@ func fetchEngineVersionData(ctx context.Context, cfg Config) engineVersionData { return data } -// queryInstanceVersions queries running instances for engine version validation -func queryInstanceVersions(ctx context.Context, cfg Config) map[string][]InstanceEngineVersion { +// queryInstanceVersions queries running instances for engine version validation. +func queryInstanceVersions(ctx context.Context, cfg Config) map[string][]InstanceEngineVersion { //nolint:gocritic AppLogger.Printf("🔍 Querying running RDS instances across all regions to validate engine versions...\n") instanceVersions, err := queryRunningInstanceEngineVersions(ctx, cfg) if err != nil { @@ -319,8 +319,8 @@ func queryInstanceVersions(ctx context.Context, cfg Config) map[string][]Instanc return instanceVersions } -// queryMajorVersions queries major engine versions for extended support detection -func queryMajorVersions(ctx context.Context, cfg Config) map[string]MajorEngineVersionInfo { +// queryMajorVersions queries major engine versions for extended support detection. +func queryMajorVersions(ctx context.Context, cfg Config) map[string]MajorEngineVersionInfo { //nolint:gocritic AppLogger.Printf("🔍 Querying AWS RDS major engine versions for extended support information...\n") versionInfo, err := queryMajorEngineVersions(ctx, cfg) if err != nil { @@ -333,16 +333,16 @@ func queryMajorVersions(ctx context.Context, cfg Config) map[string]MajorEngineV return versionInfo } -// regionRecommendations holds the processed recommendations for a single region +// regionRecommendations holds the processed recommendations for a single region. type regionRecommendations struct { recommendations []common.Recommendation results []common.PurchaseResult } -// processRegionRecommendations fetches and processes recommendations for a single region +// processRegionRecommendations fetches and processes recommendations for a single region. func processRegionRecommendations( ctx context.Context, - awsCfg aws.Config, + awsCfg aws.Config, //nolint:gocritic recClient provider.RecommendationsClient, accountCache *AccountAliasCache, service common.ServiceType, @@ -350,7 +350,7 @@ func processRegionRecommendations( regionIndex, totalRegions int, engineData engineVersionData, isDryRun bool, - cfg Config, + cfg Config, //nolint:gocritic coverageMap recommendations.PoolCoverageMap, ) regionRecommendations { result := regionRecommendations{ @@ -408,13 +408,13 @@ func processRegionRecommendations( return result } -// fetchRecommendationsForRegion fetches recommendations from AWS for a specific region +// fetchRecommendationsForRegion fetches recommendations from AWS for a specific region. func fetchRecommendationsForRegion( ctx context.Context, recClient provider.RecommendationsClient, service common.ServiceType, region string, - cfg Config, + cfg Config, //nolint:gocritic ) []common.Recommendation { termStr := "1yr" if cfg.TermYears == 3 { @@ -432,7 +432,7 @@ func fetchRecommendationsForRegion( ExcludeSPTypes: cfg.ExcludeSPTypes, } - recs, err := recClient.GetRecommendations(ctx, params) + recs, err := recClient.GetRecommendations(ctx, ¶ms) if err != nil { AppLogger.Printf(" ❌ Failed to fetch recommendations: %v\n", err) return nil @@ -441,7 +441,7 @@ func fetchRecommendationsForRegion( return recs } -// populateRecommendationAccountNames populates account names from account IDs +// populateRecommendationAccountNames populates account names from account IDs. func populateRecommendationAccountNames(ctx context.Context, recs []common.Recommendation, accountCache *AccountAliasCache) { for i := range recs { if recs[i].Account != "" { @@ -450,12 +450,12 @@ func populateRecommendationAccountNames(ctx context.Context, recs []common.Recom } } -// applyRegionFilters applies region and instance type filters to recommendations +// applyRegionFilters applies region and instance type filters to recommendations. func applyRegionFilters( recs []common.Recommendation, engineData engineVersionData, region string, - cfg Config, + cfg Config, //nolint:gocritic ) []common.Recommendation { originalCount := len(recs) recs = applyFilters(recs, &cfg, engineData.instanceVersions, engineData.versionInfo, region) @@ -479,7 +479,7 @@ func applyRegionFilters( // ExistingCoveragePct further by the share of pool demand attributable to // RIs expiring within the window, so --target-coverage recommends // replacements before the cliff. Doesn't run if either input is empty. -func applyCoverageAndOverrides(recs []common.Recommendation, cfg Config, coverageMap recommendations.PoolCoverageMap, expiringCommitments []common.Commitment) []common.Recommendation { +func applyCoverageAndOverrides(recs []common.Recommendation, cfg Config, coverageMap recommendations.PoolCoverageMap, expiringCommitments []common.Commitment) []common.Recommendation { //nolint:gocritic recommendations.ApplyCoverageMapToRecommendations(recs, coverageMap) if cfg.RebuyWindowDays > 0 && len(expiringCommitments) > 0 { n := recommendations.AdjustExistingCoverageForExpiringCommitments(recs, expiringCommitments, cfg.RebuyWindowDays) @@ -515,19 +515,19 @@ func applyCoverageAndOverrides(recs []common.Recommendation, cfg Config, coverag return filteredRecs } -// checkDuplicatesAndApplyLimit checks for duplicate RIs and applies instance limits +// checkDuplicatesAndApplyLimit checks for duplicate RIs and applies instance limits. func checkDuplicatesAndApplyLimit( ctx context.Context, filteredRecs []common.Recommendation, serviceClient provider.ServiceClient, - cfg Config, + cfg Config, //nolint:gocritic ) []common.Recommendation { // Check for duplicate RIs to avoid double purchasing duplicateChecker := NewDuplicateChecker(0) adjustedRecs, _, err := duplicateChecker.AdjustRecommendationsForExistingRIs(ctx, filteredRecs, serviceClient) if err != nil { AppLogger.Printf(" ⚠️ Warning: Could not check for existing RIs: %v\n", err) - adjustedRecs = filteredRecs // Continue with original recommendations if check fails + // filteredRecs unchanged; continue with original recommendations } else { // Always use the adjusted recommendations (they might have different counts even if same length) originalInstances := CalculateTotalInstances(filteredRecs) @@ -556,14 +556,14 @@ func checkDuplicatesAndApplyLimit( // step for --target-coverage. func fetchAndFilterRegionRecs( ctx context.Context, - awsCfg aws.Config, + awsCfg aws.Config, //nolint:gocritic recClient provider.RecommendationsClient, accountCache *AccountAliasCache, service common.ServiceType, region string, regionIndex, totalRegions int, engineData engineVersionData, - cfg Config, + cfg Config, //nolint:gocritic coverageMap recommendations.PoolCoverageMap, ) []common.Recommendation { AppLogger.Printf("\n 📍 [%d/%d] Region: %s\n", regionIndex, totalRegions, region) @@ -618,12 +618,12 @@ func fetchAndFilterRegionRecs( // owned in the same pool. func fetchAllRecs( ctx context.Context, - awsCfg aws.Config, + awsCfg aws.Config, //nolint:gocritic recClient provider.RecommendationsClient, accountCache *AccountAliasCache, servicesToProcess []common.ServiceType, engineData engineVersionData, - cfg Config, + cfg Config, //nolint:gocritic coverageMap recommendations.PoolCoverageMap, ) []common.Recommendation { all := make([]common.Recommendation, 0) diff --git a/cmd/multi_service_helpers_test.go b/cmd/multi_service_helpers_test.go index 2e9ee56c3..5718601c2 100644 --- a/cmd/multi_service_helpers_test.go +++ b/cmd/multi_service_helpers_test.go @@ -176,8 +176,8 @@ func TestDiscoverRegionsForService(t *testing.T) { func TestFormatServices(t *testing.T) { tests := []struct { name string - services []common.ServiceType expected string + services []common.ServiceType }{ { name: "Empty list", @@ -242,9 +242,9 @@ func TestApplyCommonCoverage(t *testing.T) { tests := []struct { name string + expectedInstances []int coverage float64 expectedCount int - expectedInstances []int }{ { name: "100% coverage", @@ -337,7 +337,7 @@ func TestCreateCancelledResults(t *testing.T) { assert.False(t, result.Success) assert.Equal(t, recs[i], result.Recommendation) assert.NotNil(t, result.Error) - assert.Contains(t, result.Error.Error(), "cancelled") + assert.Contains(t, result.Error.Error(), "canceled") assert.Contains(t, result.CommitmentID, "us-west-2") } } @@ -368,7 +368,9 @@ func TestExecutePurchase(t *testing.T) { Timestamp: time.Now(), } var capturedOpts common.PurchaseOptions - mockClient.On("PurchaseCommitment", ctx, rec, mock.MatchedBy(func(o common.PurchaseOptions) bool { + mockClient.On("PurchaseCommitment", ctx, mock.MatchedBy(func(r *common.Recommendation) bool { + return r != nil && r.Service == rec.Service && r.ResourceType == rec.ResourceType && r.Count == rec.Count + }), mock.MatchedBy(func(o common.PurchaseOptions) bool { capturedOpts = o return o.Source == common.PurchaseSourceCLI })).Return(expectedResult, nil) @@ -463,9 +465,9 @@ func TestAdjustRecsForDuplicatesError(t *testing.T) { func TestGroupRecommendationsByServiceRegion(t *testing.T) { tests := []struct { + expectedGroups map[common.ServiceType]map[string]int name string recommendations []common.Recommendation - expectedGroups map[common.ServiceType]map[string]int // service -> region -> count }{ { name: "Single service single region", @@ -532,10 +534,10 @@ func TestGroupRecommendationsByServiceRegion(t *testing.T) { func TestGenerateCSVFilename(t *testing.T) { tests := []struct { + check func(t *testing.T, filename string) name string - isDryRun bool cfg Config - check func(t *testing.T, filename string) + isDryRun bool }{ { name: "Dry run mode generates dryrun filename", @@ -582,7 +584,7 @@ func TestPrintRunMode(t *testing.T) { printRunMode(false) } -func TestPrintPaymentAndTerm(t *testing.T) { +func TestPrintPaymentAndTerm(_ *testing.T) { // Capture output by disabling logger // Logger output disabled for testing @@ -731,7 +733,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..2e710d771 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, @@ -25,15 +25,15 @@ func calculateServiceStats(service common.ServiceType, recs []common.Recommendat } regionSet := make(map[string]bool) - for _, rec := range recs { - regionSet[rec.Region] = true - stats.InstancesProcessed += rec.Count - stats.TotalEstimatedSavings += rec.EstimatedSavings + for i := range recs { + regionSet[recs[i].Region] = true + stats.InstancesProcessed += recs[i].Count + stats.TotalEstimatedSavings += recs[i].EstimatedSavings } stats.RegionsProcessed = len(regionSet) - for _, result := range results { - if result.Success { + for i := range results { + if results[i].Success { stats.SuccessfulPurchases++ } else { stats.FailedPurchases++ @@ -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,8 +55,8 @@ func printServiceSummary(service common.ServiceType, stats ServiceProcessingStat } } -// printMultiServiceSummary prints the final summary for all services -func printMultiServiceSummary(allRecommendations []common.Recommendation, allResults []common.PurchaseResult, serviceStats map[common.ServiceType]ServiceProcessingStats, isDryRun bool) { +// printMultiServiceSummary prints the final summary for all services. +func printMultiServiceSummary(allRecommendations []common.Recommendation, _ []common.PurchaseResult, serviceStats map[common.ServiceType]ServiceProcessingStats, isDryRun bool) { printSummaryHeader(isDryRun) spStats, riStats, riAggregates := separateAndAggregateStats(serviceStats) @@ -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,8 +205,8 @@ func printArcheraPitch() { AppLogger.Println(" from a fraction of their insurance premiums.") } -// printSavingsPlansSection prints the Savings Plans summary section -func printSavingsPlansSection(allRecommendations []common.Recommendation, spStats ServiceProcessingStats) { +// printSavingsPlansSection prints the Savings Plans summary section. +func printSavingsPlansSection(allRecommendations []common.Recommendation, _ 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..4b01a9237 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,11 +16,12 @@ 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{} - for _, rec := range recommendations { + for i := range recommendations { + rec := &recommendations[i] if common.IsSavingsPlan(rec.Service) { if details, ok := rec.Details.(*common.SavingsPlanDetails); ok { switch details.PlanType { @@ -44,7 +45,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 +65,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,18 +89,19 @@ 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{} - for _, rec := range recommendations { + for i := range recommendations { + rec := &recommendations[i] if common.IsSavingsPlan(rec.Service) { if details, ok := rec.Details.(*common.SavingsPlanDetails); ok { switch details.PlanType { @@ -117,13 +119,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,17 +145,17 @@ 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 { + BestComputeSPName string + HasDatabaseSP bool Option1Savings float64 Option2Savings float64 Option3Savings float64 BestComputeSP float64 - BestComputeSPName string - 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 +182,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 +205,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..d1601dcc5 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 @@ -175,10 +175,10 @@ func TestPrintServiceSummary(t *testing.T) { func TestPrintMultiServiceSummary(t *testing.T) { tests := []struct { + stats map[common.ServiceType]ServiceProcessingStats name string recs []common.Recommendation results []common.PurchaseResult - stats map[common.ServiceType]ServiceProcessingStats isDryRun bool }{ { @@ -264,10 +264,10 @@ func TestPrintMultiServiceSummary(t *testing.T) { func TestPrintSavingsPlansSection(t *testing.T) { tests := []struct { + checkOutput func(t *testing.T, output string) name string recommendations []common.Recommendation stats ServiceProcessingStats - checkOutput func(t *testing.T, output string) }{ { name: "Prints Compute Savings Plans", @@ -406,11 +406,11 @@ func TestPrintSavingsPlansSection(t *testing.T) { func TestPrintComparisonSection(t *testing.T) { tests := []struct { + riStats map[common.ServiceType]ServiceProcessingStats + checkOutput func(t *testing.T, output string) name string recommendations []common.Recommendation - riStats map[common.ServiceType]ServiceProcessingStats riSavings float64 - checkOutput func(t *testing.T, output string) }{ { name: "Comparison with EC2 RIs and EC2 Instance SP", @@ -508,10 +508,10 @@ func TestPrintComparisonSection(t *testing.T) { func TestPrintFinalMessage(t *testing.T) { tests := []struct { name string - isDryRun bool - riSuccess int wantOutput []string notWanted []string + riSuccess int + isDryRun bool }{ { name: "Dry run shows no Archera pitch", diff --git a/cmd/multi_service_test.go b/cmd/multi_service_test.go index fc28c92e7..8e4c91400 100644 --- a/cmd/multi_service_test.go +++ b/cmd/multi_service_test.go @@ -23,8 +23,8 @@ func TestRunToolMultiService_Validation(t *testing.T) { }() tests := []struct { - name string setupVars func() + name string expectPanic bool }{ { @@ -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"} @@ -208,12 +208,12 @@ func TestProcessServiceWithMocks(t *testing.T) { }() tests := []struct { + setupFunc func() name string service common.ServiceType - isDryRun bool testRegions []string mockRecs []common.Recommendation - setupFunc func() + isDryRun bool }{ { name: "RDS dry run with recommendations", @@ -281,7 +281,7 @@ func TestProcessServiceWithMocks(t *testing.T) { IncludeSPTypes: toolCfg.IncludeSPTypes, ExcludeSPTypes: toolCfg.ExcludeSPTypes, } - mockClient.On("GetRecommendations", ctx, params).Return(tt.mockRecs, nil) + mockClient.On("GetRecommendations", ctx, ¶ms).Return(tt.mockRecs, nil) } // Set regions in toolCfg for this test @@ -349,7 +349,7 @@ func TestProcessService_SavingsPlansAccountLevel(t *testing.T) { mockRecs := []common.Recommendation{ {Service: common.ServiceSavingsPlansCompute, ResourceType: "ComputeSP", Count: 1, Region: "us-east-1", EstimatedSavings: 1000}, } - mockClient.On("GetRecommendations", ctx, params).Return(mockRecs, nil) + mockClient.On("GetRecommendations", ctx, ¶ms).Return(mockRecs, nil) accountCache := NewAccountAliasCache(awsCfg) recs, results := processService(ctx, awsCfg, mockClient, accountCache, common.ServiceSavingsPlansCompute, true, toolCfg, engineVersionData{}) @@ -396,7 +396,7 @@ func TestProcessService_WithInstanceLimit(t *testing.T) { {ResourceType: "db.t3.micro", Count: 10, Region: "us-east-1", EstimatedSavings: 100}, {ResourceType: "db.t3.small", Count: 10, Region: "us-east-1", EstimatedSavings: 200}, } - mockClient.On("GetRecommendations", ctx, params).Return(mockRecs, nil) + mockClient.On("GetRecommendations", ctx, ¶ms).Return(mockRecs, nil) accountCache := NewAccountAliasCache(awsCfg) recs, _ := processService(ctx, awsCfg, mockClient, accountCache, common.ServiceRDS, true, toolCfg, engineVersionData{}) @@ -438,7 +438,7 @@ func TestProcessService_WithOverrideCount(t *testing.T) { {ResourceType: "cache.t3.micro", Count: 10, Region: "us-east-1", EstimatedSavings: 100}, {ResourceType: "cache.t3.small", Count: 5, Region: "us-east-1", EstimatedSavings: 200}, } - mockClient.On("GetRecommendations", ctx, params).Return(mockRecs, nil) + mockClient.On("GetRecommendations", ctx, ¶ms).Return(mockRecs, nil) accountCache := NewAccountAliasCache(awsCfg) recs, _ := processService(ctx, awsCfg, mockClient, accountCache, common.ServiceElastiCache, true, toolCfg, engineVersionData{}) @@ -479,7 +479,7 @@ func TestProcessService_MultipleRegions(t *testing.T) { mockRecs := []common.Recommendation{ {ResourceType: "db.t3.small", Count: 2, Region: region, EstimatedSavings: 100}, } - mockClient.On("GetRecommendations", ctx, params).Return(mockRecs, nil) + mockClient.On("GetRecommendations", ctx, ¶ms).Return(mockRecs, nil) } accountCache := NewAccountAliasCache(awsCfg) @@ -589,9 +589,9 @@ func TestGenerateCSVFilenameHelper(t *testing.T) { name string service common.ServiceType payment string + expectParts []string term int dryRun bool - expectParts []string }{ { name: "RDS dry run", @@ -641,9 +641,6 @@ func TestMultiServiceConfig(t *testing.T) { Coverage: 0.0, }, }, - PaymentOption: "no-upfront", - TermYears: 3, - DryRun: true, } // Test enabled services count @@ -719,7 +716,7 @@ func applyCoverageToRecommendations(recs []common.Recommendation, coverage float return recs[:targetCount] } -func generateCSVFilenameTestHelper(service common.ServiceType, payment string, term int, dryRun bool) string { +func generateCSVFilenameTestHelper(service common.ServiceType, payment string, _ int, dryRun bool) string { mode := "purchase" if dryRun { mode = "dryrun" @@ -746,7 +743,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 @@ -764,10 +761,10 @@ type ServiceConfig struct { func TestApplyFilters_RegionFiltering(t *testing.T) { tests := []struct { name string + currentRegion string recs []common.Recommendation includeRegions []string excludeRegions []string - currentRegion string expectedCount int }{ { @@ -1219,7 +1216,7 @@ func TestProcessPurchaseLoopPurchaseFailure(t *testing.T) { Error: fmt.Errorf("API error: quota exceeded"), Timestamp: time.Now(), } - mockClient.On("PurchaseCommitment", ctx, recs[0], mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(failureResult, nil) + mockClient.On("PurchaseCommitment", ctx, mock.AnythingOfType("*common.Recommendation"), mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(failureResult, nil) t.Setenv("DISABLE_PURCHASE_DELAY", "true") @@ -1261,7 +1258,7 @@ func TestProcessPurchaseLoopUserCancellation(t *testing.T) { CommitmentID: "test-id", Timestamp: time.Now(), } - mockClient.On("PurchaseCommitment", ctx, rec, mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) + mockClient.On("PurchaseCommitment", ctx, mock.AnythingOfType("*common.Recommendation"), mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) } t.Setenv("DISABLE_PURCHASE_DELAY", "true") @@ -1310,7 +1307,7 @@ func TestProcessServicePurchasesUserCancellation(t *testing.T) { CommitmentID: "cache-purchase-123", Timestamp: time.Now(), } - mockClient.On("PurchaseCommitment", ctx, recs[0], mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) + mockClient.On("PurchaseCommitment", ctx, mock.AnythingOfType("*common.Recommendation"), mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) t.Setenv("DISABLE_PURCHASE_DELAY", "true") @@ -1381,7 +1378,7 @@ func TestExecutePurchaseWithEmptyPurchaseID(t *testing.T) { Error: nil, Timestamp: time.Now(), } - mockClient.On("PurchaseCommitment", ctx, rec, mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(expectedResult, nil) + mockClient.On("PurchaseCommitment", ctx, mock.AnythingOfType("*common.Recommendation"), mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(expectedResult, nil) // Logger output disabled for testing @@ -1447,14 +1444,17 @@ func TestProcessPurchaseLoopActualPurchase(t *testing.T) { mockClient := &MockServiceClient{} for i, rec := range recs { + recCopy := rec result := common.PurchaseResult{ - Recommendation: rec, + Recommendation: recCopy, Success: true, CommitmentID: fmt.Sprintf("purchase-id-%d", i), Error: nil, Timestamp: time.Now(), } - mockClient.On("PurchaseCommitment", ctx, rec, mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) + mockClient.On("PurchaseCommitment", ctx, mock.MatchedBy(func(r *common.Recommendation) bool { + return r != nil && r.ResourceType == recCopy.ResourceType + }), mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) } // Logger output disabled for testing @@ -1498,7 +1498,7 @@ func TestProcessPurchaseLoopWithConfirmation(t *testing.T) { Error: nil, Timestamp: time.Now(), } - mockClient.On("PurchaseCommitment", ctx, recs[0], mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) + mockClient.On("PurchaseCommitment", ctx, mock.AnythingOfType("*common.Recommendation"), mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) // Logger output disabled for testing @@ -1520,12 +1520,12 @@ func TestFilterAndAdjustRecommendations(t *testing.T) { defer saved.restore() tests := []struct { + setupFilters func() name string recommendations []common.Recommendation coverage float64 - setupFilters func() - expectedMin int // minimum expected recommendations - expectedMax int // maximum expected recommendations + expectedMin int + expectedMax int }{ { name: "100% coverage no filters", @@ -1617,10 +1617,10 @@ elasticache,us-west-2,redis,cache.t3.micro,All Upfront,12,1,123456789012 _ = tmpFile.Close() tests := []struct { - name string setupConfig func() - expectPanic bool validateFunc func(t *testing.T) + name string + expectPanic bool }{ { name: "Dry run mode", @@ -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..8decd4943 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,12 +26,12 @@ 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 } -func (m *MockRecommendationsClient) GetRecommendations(ctx context.Context, params common.RecommendationParams) ([]common.Recommendation, error) { +func (m *MockRecommendationsClient) GetRecommendations(ctx context.Context, params *common.RecommendationParams) ([]common.Recommendation, error) { args := m.Called(ctx, params) if args.Get(0) == nil { return nil, args.Error(1) @@ -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 } @@ -70,7 +70,7 @@ func (m *MockServiceClient) GetRegion() string { return args.String(0) } -func (m *MockServiceClient) GetRecommendations(ctx context.Context, params common.RecommendationParams) ([]common.Recommendation, error) { +func (m *MockServiceClient) GetRecommendations(ctx context.Context, params *common.RecommendationParams) ([]common.Recommendation, error) { args := m.Called(ctx, params) if args.Get(0) == nil { return nil, args.Error(1) @@ -78,17 +78,17 @@ func (m *MockServiceClient) GetRecommendations(ctx context.Context, params commo return args.Get(0).([]common.Recommendation), args.Error(1) } -func (m *MockServiceClient) PurchaseCommitment(ctx context.Context, rec common.Recommendation, opts common.PurchaseOptions) (common.PurchaseResult, error) { +func (m *MockServiceClient) PurchaseCommitment(ctx context.Context, rec *common.Recommendation, opts common.PurchaseOptions) (common.PurchaseResult, error) { args := m.Called(ctx, rec, opts) return args.Get(0).(common.PurchaseResult), args.Error(1) } -func (m *MockServiceClient) ValidateOffering(ctx context.Context, rec common.Recommendation) error { +func (m *MockServiceClient) ValidateOffering(ctx context.Context, rec *common.Recommendation) error { args := m.Called(ctx, rec) return args.Error(0) } -func (m *MockServiceClient) GetOfferingDetails(ctx context.Context, rec common.Recommendation) (*common.OfferingDetails, error) { +func (m *MockServiceClient) GetOfferingDetails(ctx context.Context, rec *common.Recommendation) (*common.OfferingDetails, error) { args := m.Called(ctx, rec) if args.Get(0) == nil { return nil, args.Error(1) @@ -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 + cache map[string]string + mu sync.RWMutex } -// 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/rekey/main.go b/cmd/rekey/main.go index e4084fc57..6e352bb48 100644 --- a/cmd/rekey/main.go +++ b/cmd/rekey/main.go @@ -18,8 +18,6 @@ import ( "os" "time" - "github.com/jackc/pgx/v5" - "github.com/LeanerCloud/CUDly/internal/credentials" "github.com/LeanerCloud/CUDly/internal/database" "github.com/LeanerCloud/CUDly/internal/secrets" @@ -43,7 +41,7 @@ func main() { defer cancel() if err := run(ctx); err != nil { - log.Fatalf("rekey: %v", err) + log.Fatalf("rekey: %v", err) //nolint:gocritic } } @@ -175,7 +173,7 @@ func rekeyOne(ctx context.Context, db *database.Connection, id, blob string, zer log.Printf("rekey: encrypt id=%s: %v", id, err) return outcomeErrored } - tx, err := db.BeginTx(ctx, pgx.TxOptions{}) + tx, err := db.BeginTx(ctx, nil) if err != nil { log.Printf("rekey: begin tx id=%s: %v", id, err) return outcomeErrored diff --git a/cmd/secrets_store.go b/cmd/secrets_store.go index 5f02e173f..fb205e6f3 100644 --- a/cmd/secrets_store.go +++ b/cmd/secrets_store.go @@ -8,27 +8,27 @@ 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 returns a list of secret ARNs matching the filter. ListSecrets(ctx context.Context, filter string) ([]string, error) - // UpdateSecret updates a secret with the given ID and value - UpdateSecret(ctx context.Context, secretID string, secretValue string) error + // UpdateSecret updates a secret with the given ID and value. + UpdateSecret(ctx context.Context, secretID, 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{ @@ -45,17 +45,17 @@ func (s *AWSSecretsStore) ListSecrets(ctx context.Context, filter string) ([]str } arns := make([]string, 0, len(result.SecretList)) - for _, secret := range result.SecretList { - if secret.ARN != nil { - arns = append(arns, *secret.ARN) + for i := range result.SecretList { + if result.SecretList[i].ARN != nil { + arns = append(arns, *result.SecretList[i].ARN) } } return arns, nil } -// UpdateSecret updates a secret with the given value -func (s *AWSSecretsStore) UpdateSecret(ctx context.Context, secretID string, secretValue string) error { +// UpdateSecret updates a secret with the given value. +func (s *AWSSecretsStore) UpdateSecret(ctx context.Context, secretID, secretValue string) error { input := &secretsmanager.UpdateSecretInput{ SecretId: aws.String(secretID), SecretString: aws.String(secretValue), diff --git a/cmd/server/main.go b/cmd/server/main.go index a15b8ef3b..fa6072b8c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,12 +5,13 @@ package main import ( "context" "flag" + "fmt" "log" "os" "strconv" "time" - "github.com/LeanerCloud/CUDly/internal/runtime" + runtime "github.com/LeanerCloud/CUDly/internal/runtime" "github.com/LeanerCloud/CUDly/internal/server" ) @@ -22,6 +23,12 @@ var ( ) func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { // Parse command line flags mode := flag.String("mode", "auto", "Runtime mode: auto, lambda, http") port := flag.Int("port", 8080, "HTTP server port (ignored in lambda mode)") @@ -43,7 +50,7 @@ func main() { // ApplicationConfig without going through os.Setenv("VERSION",...). app, err := server.NewApplication(ctx, Version) if err != nil { - log.Fatalf("Failed to initialize application: %v", err) + return fmt.Errorf("failed to initialize application: %w", err) } defer app.Close() @@ -54,13 +61,13 @@ func main() { log.Printf("Running scheduled task: %s (timeout: %v)", *task, timeout) taskType := server.ScheduledTaskType(*task) - result, err := app.HandleScheduledTask(taskCtx, taskType) + result, taskErr := app.HandleScheduledTask(taskCtx, taskType) cancel() - if err != nil { - log.Fatalf("Scheduled task %q failed: %v", *task, err) + if taskErr != nil { + return fmt.Errorf("scheduled task %q failed: %w", *task, taskErr) } log.Printf("Scheduled task %q completed successfully: %v", *task, result) - return + return nil } // Determine runtime mode @@ -73,11 +80,12 @@ func main() { server.StartLambdaHandler(app) case "http": if err := server.StartHTTPServer(app, *port); err != nil { - log.Fatalf("HTTP server failed: %v", err) + return fmt.Errorf("HTTP server failed: %w", err) } default: - log.Fatalf("Unknown runtime mode: %s", runtimeMode) + return fmt.Errorf("unknown runtime mode: %s", runtimeMode) } + return nil } // getTaskTimeout returns the task timeout from TASK_TIMEOUT env var or the default of 15 minutes. @@ -100,7 +108,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/cmd/validators_test.go b/cmd/validators_test.go index f4b45d70a..98dcd5728 100644 --- a/cmd/validators_test.go +++ b/cmd/validators_test.go @@ -12,10 +12,10 @@ import ( func TestValidateNumericRanges(t *testing.T) { tests := []struct { - name string setupFunc func() - wantErr bool + name string errMsg string + wantErr bool }{ { name: "valid coverage percentage", @@ -118,10 +118,10 @@ func TestValidateNumericRanges(t *testing.T) { func TestValidatePaymentAndTerm(t *testing.T) { tests := []struct { - name string setupFunc func() - wantErr bool + name string errMsg string + wantErr bool }{ { name: "valid payment option - no-upfront", @@ -214,8 +214,8 @@ func TestValidatePaymentAndTerm(t *testing.T) { func TestContainsService(t *testing.T) { tests := []struct { name string - services []common.ServiceType service common.ServiceType + services []common.ServiceType want bool }{ { @@ -259,10 +259,10 @@ func TestValidateFilePaths(t *testing.T) { tmpDir := t.TempDir() tests := []struct { - name string setupFunc func() func() - wantErr bool + name string errMsg string + wantErr bool }{ { name: "valid CSV output path", @@ -357,11 +357,11 @@ func TestValidateFilePaths(t *testing.T) { func TestValidateNoConflicts(t *testing.T) { tests := []struct { name string + itemType string + errMsg string include []string exclude []string - itemType string wantErr bool - errMsg string }{ { name: "no conflicts", @@ -431,14 +431,11 @@ func TestValidateNoConflicts(t *testing.T) { // this package is more friction than value). func TestValidateTargetCoverage(t *testing.T) { tests := []struct { - name string - target float64 - coverage float64 - wantErr bool - errSubstr string - // useCobraCmd controls whether the test builds a real cobra command - // with --coverage marked as Changed, exercising the precedence-log - // gate. False keeps the nil-cmd shortcut for pure range checks. + name string + errSubstr string + target float64 + coverage float64 + wantErr bool useCobraCmd bool }{ {name: "disabled (zero) is valid", target: 0, coverage: 80, wantErr: false}, @@ -498,9 +495,9 @@ func TestValidateTargetCoverage(t *testing.T) { func TestValidateCoverageLookbackDays(t *testing.T) { tests := []struct { name string + errSubstr string days int wantErr bool - errSubstr string }{ {name: "default 30 is valid", days: 30, wantErr: false}, {name: "1 day is valid", days: 1, wantErr: false}, diff --git a/internal/accounts/org_discovery.go b/internal/accounts/org_discovery.go index d0992ccaf..190e5d9b5 100644 --- a/internal/accounts/org_discovery.go +++ b/internal/accounts/org_discovery.go @@ -30,8 +30,11 @@ type orgListAccountsClient interface { // // The caller is responsible for using the appropriate credentials for the // management account (e.g., resolved via the credentials package). -func DiscoverOrgAccounts(ctx context.Context, cfg aws.Config) (*OrgDiscoveryResult, error) { - return discoverWithClient(ctx, organizations.NewFromConfig(cfg)) +func DiscoverOrgAccounts(ctx context.Context, cfg *aws.Config) (*OrgDiscoveryResult, error) { + if cfg == nil { + return nil, fmt.Errorf("discover org accounts: aws config is nil") + } + return discoverWithClient(ctx, organizations.NewFromConfig(*cfg)) } // discoverWithClient performs org discovery using the provided client, enabling diff --git a/internal/accounts/org_discovery_extra_test.go b/internal/accounts/org_discovery_extra_test.go index 088262140..dc6defc97 100644 --- a/internal/accounts/org_discovery_extra_test.go +++ b/internal/accounts/org_discovery_extra_test.go @@ -11,18 +11,18 @@ 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) { +func TestDiscoverOrgAccounts_CanceledContext(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } @@ -31,9 +31,9 @@ func TestDiscoverOrgAccounts_CancelledContext(t *testing.T) { cancel() // cancel immediately — no actual AWS call cfg := aws.Config{Region: "us-east-1"} // no credentials - result, err := DiscoverOrgAccounts(ctx, cfg) + 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/accounts/org_discovery_test.go b/internal/accounts/org_discovery_test.go index f28a4bb06..d07f92696 100644 --- a/internal/accounts/org_discovery_test.go +++ b/internal/accounts/org_discovery_test.go @@ -14,8 +14,8 @@ import ( // mockOrgsClient implements orgListAccountsClient for testing. type mockOrgsClient struct { - pages [][]orgtypes.Account err error + pages [][]orgtypes.Account call int } diff --git a/internal/analytics/collector.go b/internal/analytics/collector.go index aaaff5f1f..2499b6bcd 100644 --- a/internal/analytics/collector.go +++ b/internal/analytics/collector.go @@ -25,25 +25,25 @@ const ( // PostgreSQL for the historical-savings analytics time-series. It runs on a // schedule (see server.handleCollectAnalytics) across all tenants. type Collector struct { - store AnalyticsStore + store Store configStore config.StoreInterface } // CollectorConfig holds configuration for the collector. type CollectorConfig struct { - AnalyticsStore AnalyticsStore + Store Store } // NewCollector creates a new savings collector. func NewCollector(cfg CollectorConfig, configStore config.StoreInterface) (*Collector, error) { - if cfg.AnalyticsStore == nil { + if cfg.Store == nil { return nil, fmt.Errorf("analytics store is required") } if configStore == nil { return nil, fmt.Errorf("config store is required") } return &Collector{ - store: cfg.AnalyticsStore, + store: cfg.Store, configStore: configStore, }, nil } @@ -72,7 +72,7 @@ type aggregateData struct { // aggKey is the bucket identity. cloudAccountID is dereferenced (or "" when // nil) so two rows for the same provider account but differing UUID-vs-NULL // don't merge across the tenant boundary. -func aggKey(p config.PurchaseHistoryRecord, commitmentType string) string { +func aggKey(p *config.PurchaseHistoryRecord, commitmentType string) string { cloud := "" if p.CloudAccountID != nil { cloud = *p.CloudAccountID @@ -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) } @@ -133,9 +133,10 @@ func (c *Collector) Collect(ctx context.Context) error { func aggregatePurchases(ctx context.Context, purchases []config.PurchaseHistoryRecord, now time.Time) (serviceMap map[string]*aggregateData, activePurchases, skippedBadTerm int, err error) { serviceMap = make(map[string]*aggregateData) - for _, p := range purchases { + for i := range purchases { + p := &purchases[i] 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..448a65ca6 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 @@ -47,9 +47,9 @@ func (m *mockAnalyticsStore) BulkInsertSnapshots(ctx context.Context, snapshots return nil } -func (m *mockAnalyticsStore) QuerySavings(ctx context.Context, req QueryRequest) ([]SavingsSnapshot, error) { +func (m *mockAnalyticsStore) QuerySavings(ctx context.Context, req *QueryRequest) ([]SavingsSnapshot, error) { if m.querySavingsFunc != nil { - return m.querySavingsFunc(ctx, req) + return m.querySavingsFunc(ctx, *req) } return nil, nil } @@ -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) @@ -398,7 +398,7 @@ func (m *mockConfigStore) ReplaceRecommendations(_ context.Context, _ time.Time, func (m *mockConfigStore) UpsertRecommendations(_ context.Context, _ time.Time, _ []config.RecommendationRecord, _ []config.SuccessfulCollect) error { return nil } -func (m *mockConfigStore) ListStoredRecommendations(_ context.Context, _ config.RecommendationFilter) ([]config.RecommendationRecord, error) { +func (m *mockConfigStore) ListStoredRecommendations(_ context.Context, _ *config.RecommendationFilter) ([]config.RecommendationRecord, error) { return nil, nil } func (m *mockConfigStore) GetRecommendationsFreshness(_ context.Context) (*config.RecommendationsFreshness, error) { @@ -441,35 +441,35 @@ func activeRecord(provider, service, region string, term int, savings, upfront f func newTestCollector(t *testing.T, store *mockAnalyticsStore, cfgStore *mockConfigStore) *Collector { t.Helper() - collector, err := NewCollector(CollectorConfig{AnalyticsStore: store}, cfgStore) + collector, err := NewCollector(CollectorConfig{Store: store}, cfgStore) require.NoError(t, err) 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{}) + collector, err := NewCollector(CollectorConfig{Store: nil}, &mockConfigStore{}) assert.Nil(t, collector) require.Error(t, err) assert.Contains(t, err.Error(), "analytics store is required") }) t.Run("returns error when config store is nil", func(t *testing.T) { - collector, err := NewCollector(CollectorConfig{AnalyticsStore: &mockAnalyticsStore{}}, nil) + collector, err := NewCollector(CollectorConfig{Store: &mockAnalyticsStore{}}, nil) assert.Nil(t, collector) require.Error(t, err) assert.Contains(t, err.Error(), "config store is required") }) t.Run("creates collector successfully with valid inputs", func(t *testing.T) { - collector, err := NewCollector(CollectorConfig{AnalyticsStore: &mockAnalyticsStore{}}, &mockConfigStore{}) + collector, err := NewCollector(CollectorConfig{Store: &mockAnalyticsStore{}}, &mockConfigStore{}) require.NoError(t, err) assert.NotNil(t, collector) }) } -// 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/interfaces.go b/internal/analytics/interfaces.go index ca847e978..504879677 100644 --- a/internal/analytics/interfaces.go +++ b/internal/analytics/interfaces.go @@ -14,28 +14,28 @@ import ( // carry only one of them populated (CloudAccountID is NULL on the AWS ambient- // credentials path and on legacy rows), so both are written when available. type SavingsSnapshot struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - // CloudAccountID is the cloud_accounts UUID FK and the tenant key. Nil when - // the source row had no cloud_account_id (AWS ambient creds / legacy rows). - CloudAccountID *string `json:"cloud_account_id,omitempty"` - Timestamp time.Time `json:"timestamp"` - Provider string `json:"provider"` - Service string `json:"service"` - Region string `json:"region"` - CommitmentType string `json:"commitment_type"` // "RI" or "SavingsPlan" - TotalCommitment float64 `json:"total_commitment"` + Timestamp time.Time `json:"timestamp"` // TotalUsage is the on-demand-equivalent recurring spend the commitments in // this bucket cover. Nil when the source data carried no recurring/monthly // cost (e.g. AWS all-upfront), so AVG/SUM skip it instead of being dragged // toward zero (project rule feedback_nullable_not_zero). - TotalUsage *float64 `json:"total_usage,omitempty"` - TotalSavings float64 `json:"total_savings"` + TotalUsage *float64 `json:"total_usage,omitempty"` + // CloudAccountID is the cloud_accounts UUID FK and the tenant key. Nil when + // the source row had no cloud_account_id (AWS ambient creds / legacy rows). + CloudAccountID *string `json:"cloud_account_id,omitempty"` // CoveragePercentage is committed spend / total eligible (on-demand) spend. // Nil when no on-demand baseline was available to compute it; never a // placeholder 0 (feedback_nullable_not_zero). CoveragePercentage *float64 `json:"coverage_percentage,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` + AccountID string `json:"account_id"` + Provider string `json:"provider"` + Service string `json:"service"` + Region string `json:"region"` + CommitmentType string `json:"commitment_type"` // "RI" or "SavingsPlan" + ID string `json:"id"` + TotalCommitment float64 `json:"total_commitment"` + TotalSavings float64 `json:"total_savings"` } // QueryRequest defines parameters for querying savings data. @@ -46,46 +46,46 @@ type SavingsSnapshot struct { // "all accounts accessible to the caller" — the caller MUST enforce scoping // upstream before passing empty filters. type QueryRequest struct { - AccountUUIDs []string + StartDate time.Time + EndDate time.Time AccountExternalIDsByProvider map[string][]string Provider string // optional filter Service string // optional filter - StartDate time.Time - EndDate time.Time + AccountUUIDs []string Limit int } // MonthlySummary represents aggregated monthly savings. type MonthlySummary struct { Month time.Time `json:"month"` - AccountID string `json:"account_id"` CloudAccountID *string `json:"cloud_account_id,omitempty"` - Provider string `json:"provider"` - Service string `json:"service"` - TotalSavings float64 `json:"total_savings"` // AvgCoverage is nil when every snapshot in the bucket had NULL coverage. AvgCoverage *float64 `json:"avg_coverage,omitempty"` + AccountID string `json:"account_id"` + Provider string `json:"provider"` + Service string `json:"service"` + TotalSavings float64 `json:"total_savings"` SnapshotCount int `json:"snapshot_count"` } // ProviderBreakdown represents savings breakdown by provider. type ProviderBreakdown struct { + AvgCoverage *float64 `json:"avg_coverage,omitempty"` Provider string `json:"provider"` Service string `json:"service"` TotalSavings float64 `json:"total_savings"` - AvgCoverage *float64 `json:"avg_coverage,omitempty"` } // ServiceBreakdown represents savings breakdown by service. type ServiceBreakdown struct { + AvgCoverage *float64 `json:"avg_coverage,omitempty"` Service string `json:"service"` Region string `json:"region"` TotalSavings float64 `json:"total_savings"` - AvgCoverage *float64 `json:"avg_coverage,omitempty"` } -// AnalyticsStore defines the interface for analytics storage. -type AnalyticsStore interface { +// Store defines the interface for analytics storage. +type Store interface { // SaveSnapshot stores a single savings snapshot. SaveSnapshot(ctx context.Context, snapshot *SavingsSnapshot) error @@ -93,7 +93,7 @@ type AnalyticsStore interface { BulkInsertSnapshots(ctx context.Context, snapshots []SavingsSnapshot) error // QuerySavings retrieves savings snapshots based on query parameters. - QuerySavings(ctx context.Context, req QueryRequest) ([]SavingsSnapshot, error) + QuerySavings(ctx context.Context, req *QueryRequest) ([]SavingsSnapshot, error) // Aggregated queries (using materialized views for performance). Scoping is // the dual-column model; pass empty filters only after enforcing scope. diff --git a/internal/analytics/postgres_analytics.go b/internal/analytics/postgres_analytics.go index 4b9968c6a..6d7aa9059 100644 --- a/internal/analytics/postgres_analytics.go +++ b/internal/analytics/postgres_analytics.go @@ -33,8 +33,8 @@ func NewPostgresAnalyticsStore(db *database.Connection) *PostgresAnalyticsStore return &PostgresAnalyticsStore{db: db} } -// Verify PostgresAnalyticsStore implements AnalyticsStore. -var _ AnalyticsStore = (*PostgresAnalyticsStore)(nil) +// Verify PostgresAnalyticsStore implements Store. +var _ Store = (*PostgresAnalyticsStore)(nil) // accountFilterClause builds the dual-column account WHERE fragment plus the // full positional arg list for the analytics queries. baseArgs holds the fixed @@ -179,17 +179,17 @@ func (s *PostgresAnalyticsStore) BulkInsertSnapshots(ctx context.Context, snapsh // Validate commitment_type against the table CHECK before COPY so a // single bad value doesn't abort the entire batch server-side (L4). - if err := validateCommitmentType(snapshot.CommitmentType); err != nil { - return nil, fmt.Errorf("snapshot %d: %w", i, err) + if valErr := validateCommitmentType(snapshot.CommitmentType); valErr != nil { + return nil, fmt.Errorf("snapshot %d: %w", i, valErr) } // Marshal metadata as []byte so pgx transmits it as a JSON value for // the jsonb column rather than as a bytea literal. var metadataJSON []byte if snapshot.Metadata != nil { - data, err := json.Marshal(snapshot.Metadata) - if err != nil { - return nil, fmt.Errorf("failed to marshal metadata for snapshot %d: %w", i, err) + data, marshalErr := json.Marshal(snapshot.Metadata) + if marshalErr != nil { + return nil, fmt.Errorf("failed to marshal metadata for snapshot %d: %w", i, marshalErr) } metadataJSON = data } @@ -218,7 +218,7 @@ func (s *PostgresAnalyticsStore) BulkInsertSnapshots(ctx context.Context, snapsh } // QuerySavings retrieves savings snapshots based on query parameters. -func (s *PostgresAnalyticsStore) QuerySavings(ctx context.Context, req QueryRequest) ([]SavingsSnapshot, error) { +func (s *PostgresAnalyticsStore) QuerySavings(ctx context.Context, req *QueryRequest) ([]SavingsSnapshot, error) { accountClause, args := accountFilterClause(req.AccountUUIDs, req.AccountExternalIDsByProvider, []any{req.StartDate, req.EndDate}) // #nosec G201 — accountClause references only parameter placeholders built diff --git a/internal/analytics/postgres_analytics_db_test.go b/internal/analytics/postgres_analytics_db_test.go index c76da6ca1..538471863 100644 --- a/internal/analytics/postgres_analytics_db_test.go +++ b/internal/analytics/postgres_analytics_db_test.go @@ -31,14 +31,14 @@ func skipIfNoDocker(t *testing.T) { t.Skip("Skipping database tests (SKIP_DB_TESTS is set)") } - // Skip if running in CI without Docker + // In CI without a DOCKER_HOST, we still attempt the connection — the + // container setup will return an error and the caller will skip the test. if os.Getenv("CI") != "" && os.Getenv("DOCKER_HOST") == "" { - // Try to check if Docker is available - // If not, we'll catch the error when setting up the container + t.Log("CI environment detected without DOCKER_HOST; test will be skipped if Docker is unavailable") } } -// 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") @@ -204,7 +204,7 @@ func TestPostgresAnalyticsStore_QuerySavings_DB(t *testing.T) { EndDate: now, } - results, err := store.QuerySavings(ctx, req) + results, err := store.QuerySavings(ctx, &req) require.NoError(t, err) assert.Len(t, results, 3) }) @@ -217,7 +217,7 @@ func TestPostgresAnalyticsStore_QuerySavings_DB(t *testing.T) { EndDate: now, } - results, err := store.QuerySavings(ctx, req) + results, err := store.QuerySavings(ctx, &req) require.NoError(t, err) assert.Len(t, results, 2) for _, r := range results { @@ -233,7 +233,7 @@ func TestPostgresAnalyticsStore_QuerySavings_DB(t *testing.T) { EndDate: now, } - results, err := store.QuerySavings(ctx, req) + results, err := store.QuerySavings(ctx, &req) require.NoError(t, err) assert.Len(t, results, 1) assert.Equal(t, "rds", results[0].Service) @@ -247,7 +247,7 @@ func TestPostgresAnalyticsStore_QuerySavings_DB(t *testing.T) { Limit: 2, } - results, err := store.QuerySavings(ctx, req) + results, err := store.QuerySavings(ctx, &req) require.NoError(t, err) assert.Len(t, results, 2) }) @@ -259,7 +259,7 @@ func TestPostgresAnalyticsStore_QuerySavings_DB(t *testing.T) { EndDate: now, } - results, err := store.QuerySavings(ctx, req) + results, err := store.QuerySavings(ctx, &req) require.NoError(t, err) assert.Empty(t, results) }) @@ -468,7 +468,7 @@ func TestPostgresAnalyticsStore_BulkInsertSnapshots_DB(t *testing.T) { StartDate: now.Add(-24 * time.Hour), EndDate: now, } - results, err := store.QuerySavings(ctx, req) + results, err := store.QuerySavings(ctx, &req) require.NoError(t, err) assert.Len(t, results, 2) }) @@ -558,8 +558,8 @@ func TestPostgresAnalyticsStore_QueryMonthlyTotals_DB(t *testing.T) { } for _, snapshot := range testSnapshots { - err := store.SaveSnapshot(ctx, snapshot) - require.NoError(t, err) + saveErr := store.SaveSnapshot(ctx, snapshot) + require.NoError(t, saveErr) } // Refresh materialized views so data is available diff --git a/internal/analytics/postgres_analytics_integration_test.go b/internal/analytics/postgres_analytics_integration_test.go index 865f6e241..7e9b33393 100644 --- a/internal/analytics/postgres_analytics_integration_test.go +++ b/internal/analytics/postgres_analytics_integration_test.go @@ -162,7 +162,7 @@ func TestPostgresAnalyticsStore_QuerySavings(t *testing.T) { EndDate: now, } - results, err := store.QuerySavings(ctx, req) + results, err := store.QuerySavings(ctx, &req) require.NoError(t, err) assert.Len(t, results, 3) }) @@ -175,7 +175,7 @@ func TestPostgresAnalyticsStore_QuerySavings(t *testing.T) { EndDate: now, } - results, err := store.QuerySavings(ctx, req) + results, err := store.QuerySavings(ctx, &req) require.NoError(t, err) assert.Len(t, results, 2) for _, r := range results { @@ -191,7 +191,7 @@ func TestPostgresAnalyticsStore_QuerySavings(t *testing.T) { EndDate: now, } - results, err := store.QuerySavings(ctx, req) + results, err := store.QuerySavings(ctx, &req) require.NoError(t, err) assert.Len(t, results, 1) assert.Equal(t, "rds", results[0].Service) @@ -205,7 +205,7 @@ func TestPostgresAnalyticsStore_QuerySavings(t *testing.T) { Limit: 2, } - results, err := store.QuerySavings(ctx, req) + results, err := store.QuerySavings(ctx, &req) require.NoError(t, err) assert.Len(t, results, 2) }) @@ -217,7 +217,7 @@ func TestPostgresAnalyticsStore_QuerySavings(t *testing.T) { EndDate: now, } - results, err := store.QuerySavings(ctx, req) + results, err := store.QuerySavings(ctx, &req) require.NoError(t, err) assert.Empty(t, results) }) @@ -256,8 +256,8 @@ func TestPostgresAnalyticsStore_QueryMonthlyTotals(t *testing.T) { } for _, snapshot := range testSnapshots { - err := store.SaveSnapshot(ctx, snapshot) - require.NoError(t, err) + saveErr := store.SaveSnapshot(ctx, snapshot) + require.NoError(t, saveErr) } // Refresh materialized views so data is available @@ -466,7 +466,7 @@ func TestPostgresAnalyticsStore_BulkInsertSnapshots(t *testing.T) { StartDate: now.Add(-24 * time.Hour), EndDate: now, } - results, err := store.QuerySavings(ctx, req) + results, err := store.QuerySavings(ctx, &req) require.NoError(t, err) assert.Len(t, results, 2) }) diff --git a/internal/analytics/postgres_analytics_mock_test.go b/internal/analytics/postgres_analytics_mock_test.go index 97e41ffcb..86587ced4 100644 --- a/internal/analytics/postgres_analytics_mock_test.go +++ b/internal/analytics/postgres_analytics_mock_test.go @@ -15,12 +15,11 @@ 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 + // Create snapshot with unmarshallable metadata. snapshot := &SavingsSnapshot{ - ID: "test-id", Metadata: map[string]interface{}{"channel": make(chan int)}, } @@ -68,7 +67,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,17 +112,12 @@ 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 + // Same logic as SaveSnapshot: when metadata is nil the marshal branch + // is not taken, so metadataJSON stays nil. var metadataJSON []byte - - // Same logic as SaveSnapshot - if metadata != nil { - metadataJSON, _ = json.Marshal(metadata) - } - assert.Nil(t, metadataJSON) }) @@ -165,7 +159,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 +172,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 +195,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 +214,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_pgxmock_test.go b/internal/analytics/postgres_analytics_pgxmock_test.go index 5babf69c2..3f8071687 100644 --- a/internal/analytics/postgres_analytics_pgxmock_test.go +++ b/internal/analytics/postgres_analytics_pgxmock_test.go @@ -107,7 +107,7 @@ func TestPostgresAnalyticsStore_QuerySavings_Empty(t *testing.T) { WithArgs(anyArgs(3)...). WillReturnRows(rows) - result, err := store.QuerySavings(ctx, QueryRequest{ + result, err := store.QuerySavings(ctx, &QueryRequest{ AccountUUIDs: []string{"acct1"}, StartDate: time.Now().Add(-24 * time.Hour), EndDate: time.Now(), @@ -132,7 +132,7 @@ func TestPostgresAnalyticsStore_QuerySavings_WithFilters(t *testing.T) { WithArgs(anyArgs(6)...). WillReturnRows(rows) - result, err := store.QuerySavings(ctx, QueryRequest{ + result, err := store.QuerySavings(ctx, &QueryRequest{ AccountUUIDs: []string{"acct1"}, Provider: "aws", Service: "ec2", @@ -160,7 +160,7 @@ func TestPostgresAnalyticsStore_QuerySavings_WithMetadata(t *testing.T) { WithArgs(anyArgs(3)...). WillReturnRows(rows) - result, err := store.QuerySavings(ctx, QueryRequest{ + result, err := store.QuerySavings(ctx, &QueryRequest{ AccountUUIDs: []string{"acct1"}, StartDate: time.Now().Add(-24 * time.Hour), EndDate: time.Now(), @@ -178,7 +178,7 @@ func TestPostgresAnalyticsStore_QuerySavings_QueryError(t *testing.T) { WithArgs(anyArgs(3)...). WillReturnError(errors.New("db error")) - _, err := store.QuerySavings(ctx, QueryRequest{ + _, err := store.QuerySavings(ctx, &QueryRequest{ AccountUUIDs: []string{"acct1"}, StartDate: time.Now().Add(-24 * time.Hour), EndDate: time.Now(), @@ -201,7 +201,7 @@ func TestPostgresAnalyticsStore_QuerySavings_MetadataUnmarshalError(t *testing.T WithArgs(anyArgs(3)...). WillReturnRows(rows) - _, err := store.QuerySavings(ctx, QueryRequest{ + _, err := store.QuerySavings(ctx, &QueryRequest{ AccountUUIDs: []string{"acct1"}, StartDate: time.Now().Add(-time.Hour), EndDate: time.Now(), @@ -218,7 +218,7 @@ func TestPostgresAnalyticsStore_QuerySavings_ScanError(t *testing.T) { WithArgs(anyArgs(3)...). WillReturnRows(rows) - _, err := store.QuerySavings(ctx, QueryRequest{ + _, err := store.QuerySavings(ctx, &QueryRequest{ AccountUUIDs: []string{"acct1"}, StartDate: time.Now().Add(-time.Hour), EndDate: time.Now(), diff --git a/internal/analytics/postgres_analytics_test.go b/internal/analytics/postgres_analytics_test.go index e2f651fdc..fc001f2dc 100644 --- a/internal/analytics/postgres_analytics_test.go +++ b/internal/analytics/postgres_analytics_test.go @@ -40,14 +40,14 @@ func (s *testablePostgresAnalyticsStore) SaveSnapshot(ctx context.Context, snaps return s.PostgresAnalyticsStore.SaveSnapshot(ctx, snapshot) } -// Verify the wrapper still satisfies AnalyticsStore. -var _ AnalyticsStore = (*testablePostgresAnalyticsStore)(nil) +// Verify the wrapper still satisfies Store. +var _ Store = (*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() @@ -294,7 +293,7 @@ func TestQuerySavings(t *testing.T) { EndDate: now, } - snapshots, err := store.QuerySavings(context.Background(), req) + snapshots, err := store.QuerySavings(context.Background(), &req) require.NoError(t, err) assert.Len(t, snapshots, 1) assert.Equal(t, "snapshot-1", snapshots[0].ID) @@ -331,7 +330,7 @@ func TestQuerySavings(t *testing.T) { EndDate: now, } - _, err = store.QuerySavings(context.Background(), req) + _, err = store.QuerySavings(context.Background(), &req) require.NoError(t, err) assert.NoError(t, mock.ExpectationsWereMet()) }) @@ -363,7 +362,7 @@ func TestQuerySavings(t *testing.T) { EndDate: now, } - _, err = store.QuerySavings(context.Background(), req) + _, err = store.QuerySavings(context.Background(), &req) require.NoError(t, err) assert.NoError(t, mock.ExpectationsWereMet()) }) @@ -395,7 +394,7 @@ func TestQuerySavings(t *testing.T) { Limit: 10, } - _, err = store.QuerySavings(context.Background(), req) + _, err = store.QuerySavings(context.Background(), &req) require.NoError(t, err) assert.NoError(t, mock.ExpectationsWereMet()) }) @@ -426,7 +425,7 @@ func TestQuerySavings(t *testing.T) { EndDate: now, } - snapshots, err := store.QuerySavings(context.Background(), req) + snapshots, err := store.QuerySavings(context.Background(), &req) require.NoError(t, err) assert.NotNil(t, snapshots) assert.Empty(t, snapshots) @@ -453,7 +452,7 @@ func TestQuerySavings(t *testing.T) { EndDate: now, } - _, err = store.QuerySavings(context.Background(), req) + _, err = store.QuerySavings(context.Background(), &req) assert.Error(t, err) assert.Contains(t, err.Error(), "database error") assert.NoError(t, mock.ExpectationsWereMet()) @@ -488,13 +487,13 @@ func TestQuerySavings(t *testing.T) { EndDate: now, } - _, err = store.QuerySavings(context.Background(), req) + _, err = store.QuerySavings(context.Background(), &req) assert.Error(t, err) assert.NoError(t, mock.ExpectationsWereMet()) }) } -// 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) @@ -925,19 +924,24 @@ func TestQueryRequest(t *testing.T) { }) t.Run("handles optional fields", func(t *testing.T) { + start := time.Now().Add(-24 * time.Hour) + end := time.Now() req := QueryRequest{ AccountUUIDs: []string{"account-123"}, - StartDate: time.Now().Add(-24 * time.Hour), - EndDate: time.Now(), + StartDate: start, + EndDate: end, } + assert.Equal(t, []string{"account-123"}, req.AccountUUIDs) + assert.Equal(t, start, req.StartDate) + assert.Equal(t, end, req.EndDate) assert.Equal(t, "", req.Provider) // Optional, can be empty assert.Equal(t, "", req.Service) // Optional, can be empty assert.Equal(t, 0, req.Limit) // Optional, 0 means no limit }) } -// 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 +990,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 +1027,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,16 +1064,16 @@ 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, // but we can test it explicitly - var _ AnalyticsStore = (*PostgresAnalyticsStore)(nil) + var _ Store = (*PostgresAnalyticsStore)(nil) }) } -// 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() @@ -1096,13 +1100,13 @@ func TestQuerySavingsRowScanError(t *testing.T) { EndDate: now, } - _, err = store.QuerySavings(context.Background(), req) + _, err = store.QuerySavings(context.Background(), &req) assert.Error(t, err) assert.NoError(t, mock.ExpectationsWereMet()) }) } -// 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 +1129,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 +1155,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 +1181,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() @@ -1209,14 +1213,14 @@ func TestRowsErr(t *testing.T) { EndDate: now, } - snapshots, err := store.QuerySavings(context.Background(), req) + snapshots, err := store.QuerySavings(context.Background(), &req) require.NoError(t, err) assert.Len(t, snapshots, 1) assert.NoError(t, mock.ExpectationsWereMet()) }) } -// 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 +1246,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 +1273,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 +1300,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() @@ -1324,7 +1328,7 @@ func TestErrNoRowsHandling(t *testing.T) { EndDate: now, } - snapshots, err := store.QuerySavings(context.Background(), req) + snapshots, err := store.QuerySavings(context.Background(), &req) require.NoError(t, err) assert.NotNil(t, snapshots) assert.Empty(t, snapshots) @@ -1332,19 +1336,19 @@ 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() require.NoError(t, err) defer mock.Close() - var store AnalyticsStore = newTestableStore(mock) + var store Store = newTestableStore(mock) assert.NotNil(t, store) }) } -// 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 +1361,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/coverage_extras_test.go b/internal/api/coverage_extras_test.go index d385f8e3e..41063f37a 100644 --- a/internal/api/coverage_extras_test.go +++ b/internal/api/coverage_extras_test.go @@ -332,6 +332,7 @@ func TestHandler_rejectRIExchange_AlreadyProcessed(t *testing.T) { mockStore.On("GetRIExchangeRecord", ctx, "11111111-1111-1111-1111-111111111111").Return( &config.RIExchangeRecord{ID: "11111111-1111-1111-1111-111111111111", ApprovalToken: "tok"}, nil) // Transition returns nil indicating already processed + //nolint:misspell // DB schema value 'cancelled' -- see migration 000009_ri_exchange_history.up.sql mockStore.On("TransitionRIExchangeStatus", ctx, "11111111-1111-1111-1111-111111111111", "pending", "cancelled", mock.Anything). Return(nil, nil) diff --git a/internal/api/coverage_gaps_test.go b/internal/api/coverage_gaps_test.go index 501f0fee5..bafe3ac3d 100644 --- a/internal/api/coverage_gaps_test.go +++ b/internal/api/coverage_gaps_test.go @@ -139,7 +139,7 @@ func TestMergeServiceConfig_NewRecord(t *testing.T) { Coverage: 80, } - result, err := mergeServiceConfig(ctx, mockStore, incoming, `{"enabled":true,"term":3,"coverage":80}`) + result, err := mergeServiceConfig(ctx, mockStore, &incoming, `{"enabled":true,"term":3,"coverage":80}`) require.NoError(t, err) assert.Equal(t, incoming, result) } @@ -166,7 +166,7 @@ func TestMergeServiceConfig_ExistingRecord(t *testing.T) { Coverage: 80.0, } - result, err := mergeServiceConfig(ctx, mockStore, incoming, `{"enabled":true,"term":3,"coverage":80}`) + result, err := mergeServiceConfig(ctx, mockStore, &incoming, `{"enabled":true,"term":3,"coverage":80}`) require.NoError(t, err) // UI-editable fields are updated @@ -186,7 +186,7 @@ func TestMergeServiceConfig_DBError(t *testing.T) { incoming := config.ServiceConfig{Provider: "aws", Service: "rds"} - _, err := mergeServiceConfig(ctx, mockStore, incoming, `{"provider":"aws","service":"rds"}`) + _, err := mergeServiceConfig(ctx, mockStore, &incoming, `{"provider":"aws","service":"rds"}`) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read existing service config") } @@ -205,7 +205,7 @@ func TestMergeServiceConfig_NilExisting(t *testing.T) { Term: 3, } - result, err := mergeServiceConfig(ctx, mockStore, incoming, `{"enabled":true,"term":3}`) + result, err := mergeServiceConfig(ctx, mockStore, &incoming, `{"enabled":true,"term":3}`) require.NoError(t, err) assert.Equal(t, incoming, result) } @@ -482,11 +482,11 @@ func TestHandler_requiresCSRFValidation(t *testing.T) { func TestAmbientCredResult(t *testing.T) { tests := []struct { - name string acct *config.CloudAccount + name string + msgContains string wantOK bool wantFound bool - msgContains string }{ { name: "aws workload_identity_federation no ARN", @@ -787,10 +787,10 @@ func TestHandler_sendPurchaseApprovalEmail_NoNotificationEmail(t *testing.T) { // SendPurchaseApprovalRequest so tests can assert on recipient fields. type recordingEmailNotifier struct { stubEmailNotifier - captured email.NotificationData + captured *email.NotificationData } -func (r *recordingEmailNotifier) SendPurchaseApprovalRequest(_ context.Context, data email.NotificationData) error { +func (r *recordingEmailNotifier) SendPurchaseApprovalRequest(_ context.Context, data *email.NotificationData) error { r.captured = data return nil } @@ -885,8 +885,8 @@ func TestHandler_sendPurchaseApprovalEmail_ResponseRecipientFallsBackToContactEm // mockCredStoreHas is a credential store stub where HasCredential is configurable. type mockCredStoreHas struct { MockCredentialStore - has bool err error + has bool } func (m *mockCredStoreHas) HasCredential(_ context.Context, _, _ string) (bool, error) { @@ -902,37 +902,37 @@ func (s *stubEmailNotifier) SendToEmail(_ context.Context, _, _, _ string) error func (s *stubEmailNotifier) SendToEmailWithCCMultipart(_ context.Context, _ string, _ []string, _, _, _ string) error { return nil } -func (s *stubEmailNotifier) SendNewRecommendationsNotification(_ context.Context, _ email.NotificationData) error { +func (s *stubEmailNotifier) SendNewRecommendationsNotification(_ context.Context, _ *email.NotificationData) error { return nil } -func (s *stubEmailNotifier) SendScheduledPurchaseNotification(_ context.Context, _ email.NotificationData) error { +func (s *stubEmailNotifier) SendScheduledPurchaseNotification(_ context.Context, _ *email.NotificationData) error { return nil } -func (s *stubEmailNotifier) SendPurchaseConfirmation(_ context.Context, _ email.NotificationData) error { +func (s *stubEmailNotifier) SendPurchaseConfirmation(_ context.Context, _ *email.NotificationData) error { return nil } -func (s *stubEmailNotifier) SendPurchaseFailedNotification(_ context.Context, _ email.NotificationData) error { +func (s *stubEmailNotifier) SendPurchaseFailedNotification(_ context.Context, _ *email.NotificationData) error { return nil } func (s *stubEmailNotifier) SendPasswordResetEmail(_ context.Context, _, _ string) error { return nil } func (s *stubEmailNotifier) SendWelcomeEmail(_ context.Context, _, _, _ string) error { return nil } func (s *stubEmailNotifier) SendUserInviteEmail(_ context.Context, _, _ string) error { return nil } -func (s *stubEmailNotifier) SendRIExchangePendingApproval(_ context.Context, _ email.RIExchangeNotificationData) error { +func (s *stubEmailNotifier) SendRIExchangePendingApproval(_ context.Context, _ *email.RIExchangeNotificationData) error { return nil } -func (s *stubEmailNotifier) SendRIExchangeCompleted(_ context.Context, _ email.RIExchangeNotificationData) error { +func (s *stubEmailNotifier) SendRIExchangeCompleted(_ context.Context, _ *email.RIExchangeNotificationData) error { return nil } -func (s *stubEmailNotifier) SendPurchaseApprovalRequest(_ context.Context, _ email.NotificationData) error { +func (s *stubEmailNotifier) SendPurchaseApprovalRequest(_ context.Context, _ *email.NotificationData) error { return nil } -func (s *stubEmailNotifier) SendPurchaseScheduledNotification(_ context.Context, _ email.NotificationData) error { +func (s *stubEmailNotifier) SendPurchaseScheduledNotification(_ context.Context, _ *email.NotificationData) error { return nil } -func (s *stubEmailNotifier) SendRegistrationReceivedNotification(_ context.Context, _ email.RegistrationNotificationData) error { +func (s *stubEmailNotifier) SendRegistrationReceivedNotification(_ context.Context, _ *email.RegistrationNotificationData) error { return nil } -func (s *stubEmailNotifier) SendRegistrationDecisionNotification(_ context.Context, _ string, _ email.RegistrationDecisionData) error { +func (s *stubEmailNotifier) SendRegistrationDecisionNotification(_ context.Context, _ string, _ *email.RegistrationDecisionData) error { return nil } diff --git a/internal/api/db_rate_limiter.go b/internal/api/db_rate_limiter.go index 28742eb90..58fb2034f 100644 --- a/internal/api/db_rate_limiter.go +++ b/internal/api/db_rate_limiter.go @@ -16,16 +16,16 @@ import ( // This implementation uses a sliding window algorithm with the database as the backend, // making it suitable for Lambda functions and distributed systems. type DBRateLimiter struct { + lastCleanup time.Time pool *pgxpool.Pool - limits map[string]RateLimitConfig // endpoint -> config + limits map[string]RateLimitConfig + cleanupInterval time.Duration limitsMu sync.RWMutex - lastCleanup time.Time cleanupMu sync.Mutex cleanupRunning atomic.Bool - 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,14 +91,14 @@ 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 // rate limiter still denies correctly, and `cleanup()` evicts // expired rows on its 24-hour cycle. This is a small accounting // trade for atomicity and is acceptable for rate-limit semantics. -func (rl *DBRateLimiter) Allow(ctx context.Context, key string, endpoint string) (bool, error) { +func (rl *DBRateLimiter) Allow(ctx context.Context, key, endpoint string) (bool, error) { if rl == nil || rl.pool == nil { return true, nil } @@ -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,20 +197,20 @@ func (rl *DBRateLimiter) cleanup() { } } -// 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) { +// AllowWithIP is a convenience method that formats the key as an IP-based key. +func (rl *DBRateLimiter) AllowWithIP(ctx context.Context, ip, 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 -func (rl *DBRateLimiter) AllowWithEmail(ctx context.Context, email string, endpoint string) (bool, error) { +// AllowWithEmail is a convenience method that formats the key as an email-based key. +func (rl *DBRateLimiter) AllowWithEmail(ctx context.Context, email, 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 -func (rl *DBRateLimiter) AllowWithUser(ctx context.Context, userID string, endpoint string) (bool, error) { +// AllowWithUser is a convenience method that formats the key as a user-based key. +func (rl *DBRateLimiter) AllowWithUser(ctx context.Context, userID, 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..b48f87e58 100644 --- a/internal/api/exchange_lookup.go +++ b/internal/api/exchange_lookup.go @@ -14,7 +14,7 @@ import ( // reshape lookup needs. Scoped here so the closure stays unit-testable // against a tiny fake instead of the full StoreInterface mock. type recsLister interface { - ListStoredRecommendations(ctx context.Context, filter config.RecommendationFilter) ([]config.RecommendationRecord, error) + ListStoredRecommendations(ctx context.Context, filter *config.RecommendationFilter) ([]config.RecommendationRecord, error) } // purchaseRecLookupFromStore builds an exchange.PurchaseRecLookup that @@ -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. // @@ -40,7 +40,7 @@ type recsLister interface { // caller couldn't (or chose not to) resolve the source account. func purchaseRecLookupFromStore(store recsLister, accountID string) exchange.PurchaseRecLookup { return func(ctx context.Context, region, currencyCode string) ([]exchange.OfferingOption, error) { - filter := config.RecommendationFilter{ + filter := &config.RecommendationFilter{ Provider: "aws", Service: "ec2", Region: region, @@ -53,8 +53,8 @@ func purchaseRecLookupFromStore(store recsLister, accountID string) exchange.Pur return nil, err } out := make([]exchange.OfferingOption, 0, len(recs)) - for _, rec := range recs { - out = append(out, recommendationToOffering(rec, currencyCode)) + for i := range recs { + out = append(out, recommendationToOffering(&recs[i], currencyCode)) } return out, nil } @@ -70,7 +70,7 @@ func purchaseRecLookupFromStore(store recsLister, accountID string) exchange.Pur // SDK reports on ec2.ReservedInstance.Duration so the term-match guard // in pkg/exchange.fillAlternativesFromRecs can compare apples-to-apples // against RIInfo.TermSeconds populated from ec2.ConvertibleRI.Duration. -func recommendationToOffering(rec config.RecommendationRecord, currencyCode string) exchange.OfferingOption { +func recommendationToOffering(rec *config.RecommendationRecord, currencyCode string) exchange.OfferingOption { // AWS Cost Explorer returns MonthlyCost and UpfrontCost as totals for // the recommended Count of instances, not per-instance. The reshape // layer compares OfferingOption.EffectiveMonthlyCost against @@ -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 { @@ -194,9 +194,9 @@ func (h *Handler) resolveAWSCloudAccountID(ctx context.Context) (string, error) // no-op rather than a leak. return "", nil } - for _, a := range accounts { - if a.ExternalID == awsAccountID { - return a.ID, nil + for i := range accounts { + if accounts[i].ExternalID == awsAccountID { + return accounts[i].ID, nil } } // Resolved-but-unregistered: AWS host has accounts, but the running diff --git a/internal/api/exchange_lookup_test.go b/internal/api/exchange_lookup_test.go index 6a6bd7cc3..306d0e40a 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 } @@ -27,13 +27,13 @@ func (f failingRoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) // so tests can assert region / account / provider scoping landed in the // SQL query. Returns a configurable result set or error. type fakeRecsLister struct { - gotFilter config.RecommendationFilter - calls int - out []config.RecommendationRecord err error + gotFilter *config.RecommendationFilter + out []config.RecommendationRecord + calls int } -func (f *fakeRecsLister) ListStoredRecommendations(_ context.Context, filter config.RecommendationFilter) ([]config.RecommendationRecord, error) { +func (f *fakeRecsLister) ListStoredRecommendations(_ context.Context, filter *config.RecommendationFilter) ([]config.RecommendationRecord, error) { f.calls++ f.gotFilter = filter return f.out, f.err @@ -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,9 +215,9 @@ 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") + "nil MonthlyCost + 120 upfront / 12 months = 10/mo effective") } // TestResolveAWSCloudAccountID_FailsClosedOnSTSError pins the diff --git a/internal/api/handler.go b/internal/api/handler.go index bfaaefcda..217b647e2 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -14,7 +14,7 @@ import ( "github.com/LeanerCloud/CUDly/internal/credentials" "github.com/LeanerCloud/CUDly/internal/email" "github.com/LeanerCloud/CUDly/internal/oidc" - "github.com/LeanerCloud/CUDly/internal/runtime" + runtime "github.com/LeanerCloud/CUDly/internal/runtime" "github.com/LeanerCloud/CUDly/pkg/logging" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-sdk-go-v2/aws" @@ -23,105 +23,45 @@ import ( "github.com/aws/aws-sdk-go-v2/service/sts" ) -// Handler processes HTTP requests +// Handler processes HTTP requests. type Handler struct { - config config.StoreInterface - credStore credentials.CredentialStore - purchase PurchaseManagerInterface - scheduler SchedulerInterface - auth AuthServiceInterface - secretsARN string - apiKey string // Cached API key - corsAllowedOrigin string // CORS allowed origin - rateLimiter RateLimiterInterface - emailNotifier email.SenderInterface // Optional: purchase approval emails - dashboardURL string // Base URL for approval/cancel links - analyticsClient AnalyticsClientInterface // Optional: analytics client (Postgres-backed in prod) - analyticsCollector AnalyticsCollectorInterface // Optional: snapshot collector - analyticsSnapshots AnalyticsSnapshotStoreInterface // Optional: savings-snapshot time-series store - signer oidc.Signer // Optional: OIDC issuer signer (backed by cloud KMS) - issuerURL string // Canonical OIDC issuer URL (falls back to dashboardURL / request domain) - - awsCfgOnce sync.Once // guards one-time loading of the base AWS config - awsCfg aws.Config // cached base AWS config (no region override) - awsCfgErr error // error from loading the base config, if any - - sourceIdentityOnce sync.Once // guards one-time source identity resolution - sourceID *sourceIdentity // cached source cloud identity - - // Postgres-backed TTL cache for Cost Explorer - // GetReservationUtilization. Dashboard + RI Exchange page hits - // read from the shared cache table so Lambda containers don't each - // fan out to a paid CE API call on every page load. See - // ri_utilization_cache.go for the rationale; in-memory was ruled - // out because Lambda's short container lifetime means each cold - // start would bypass the cache entirely. - riUtilizationCacheOnce sync.Once - riUtilizationCache *riUtilizationCache - - // Optional AWS-client injection points used by the reshape handler - // integration test. When nil (the production default), the - // handler falls back to the direct AWS SDK constructors - // `awsprovider.NewEC2ClientDirect` and - // `awsprovider.NewRecommendationsClientDirect`. Tests set these - // to stubs that satisfy the narrow interfaces declared in - // `handler_ri_exchange.go` (reshapeEC2Client / reshapeRecsClient) - // so the test can exercise the handler end-to-end without live - // AWS credentials. Prod behaviour is unchanged because both - // fields stay nil. - reshapeEC2Factory func(aws.Config) reshapeEC2Client - reshapeRecsFactory func(aws.Config) reshapeRecsClient - - // Optional target-offerings EC2 client factory injected by tests. When nil - // (the production default), listTargetOfferings uses awsprovider.NewEC2ClientDirect. - targetOfferingsEC2Factory func(aws.Config) targetOfferingsEC2Client - - // Optional Azure exchange client factory injected by tests. When nil - // (the production default), buildAzureExchangeClient uses - // azidentity.NewDefaultAzureCredential to construct a real - // armreservations-backed client. - azureExchangeFactory func(subscriptionID string) azureExchangeClient - - // Optional account-resolver injection point used by the reshape - // handler integration test. When nil (the production default), the - // handler calls h.resolveAWSCloudAccountID which in turn invokes - // sts.GetCallerIdentity — fine in Lambda but fails on dev machines - // without AWS credentials. Tests set this to a fixed-result fake so - // the integration suite runs hermetically. - reshapeAccountResolver func(context.Context) (string, error) - - // Optional resolver for the running AWS account number, injected by - // the listConvertibleRIs tests so the account-scoping branch can run - // without live STS credentials. When nil (production default), the - // handler calls h.resolveAWSAccountID. Returns the raw AWS account - // number (e.g. "123456789012"), matching the account_id chip value. + signer oidc.Signer + credStore credentials.CredentialStore + purchase PurchaseManagerInterface + scheduler SchedulerInterface + auth AuthServiceInterface + commitmentOpts CommitmentOptsInterface + lambdaInvoker LambdaInvokerInterface + config config.StoreInterface + rateLimiter RateLimiterInterface + emailNotifier email.SenderInterface + awsCfgErr error + analyticsClient AnalyticsClientInterface + analyticsCollector AnalyticsCollectorInterface + analyticsSnapshots AnalyticsSnapshotStoreInterface + reshapeRecsFactory func(aws.Config) reshapeRecsClient + reshapeAccountResolver func(context.Context) (string, error) + discoverOrgFn func(context.Context, *aws.Config) (*accounts.OrgDiscoveryResult, error) riInstancesAccountResolver func(context.Context) (string, error) - - // Optional org-discovery factory used by tests to avoid live AWS - // Organizations API calls. When nil (production default), the handler - // falls back to accounts.DiscoverOrgAccounts which dials Organizations - // via the credentials resolved for the org-root account. - discoverOrgFn func(context.Context, aws.Config) (*accounts.OrgDiscoveryResult, error) - - // lambdaInvoker is the async-invoke client used by postRefreshRecommendations - // and triggerColdStartCollect. In production it is constructed lazily from the - // cached awsCfg. Tests inject a stub to avoid live Lambda calls. - lambdaInvoker LambdaInvokerInterface - - // commitmentOpts discovers which AWS (term, payment) combinations - // each service actually sells and validates saves against that data. - // Nil is valid: the endpoint returns unavailable and save-side - // validation no-ops, deferring to the frontend's hardcoded rules. - commitmentOpts CommitmentOptsInterface - - // encryptionKeySource is the env var name that resolved the credential - // encryption key. Empty when no credStore is configured. Used by the - // /health endpoint only — never logged outside that one place. - encryptionKeySource string + azureExchangeFactory func(subscriptionID string) azureExchangeClient + targetOfferingsEC2Factory func(aws.Config) targetOfferingsEC2Client + sourceID *sourceIdentity + reshapeEC2Factory func(aws.Config) reshapeEC2Client + riUtilizationCache *riUtilizationCache + corsAllowedOrigin string + dashboardURL string + issuerURL string + apiKey string + secretsARN string + encryptionKeySource string + awsCfg aws.Config + riUtilizationCacheOnce sync.Once + sourceIdentityOnce sync.Once + awsCfgOnce sync.Once } // getRIUtilizationCache returns the Postgres-backed TTL cache for Cost -// Explorer results, lazy-initialised on first call so tests that never +// Explorer results, lazy-initialized on first call so tests that never // exercise the RI Exchange paths don't need to wire it up. Lambda // detection happens here (once) via runtime.IsLambda so SWR is gated // off on Lambda where background goroutines freeze between @@ -133,8 +73,8 @@ func (h *Handler) getRIUtilizationCache() *riUtilizationCache { return h.riUtilizationCache } -// NewHandler creates a new API handler -func NewHandler(cfg HandlerConfig) *Handler { +// NewHandler creates a new API handler. +func NewHandler(cfg HandlerConfig) *Handler { //nolint:gocritic corsOrigin := cfg.CORSAllowedOrigin if corsOrigin == "" { // Security: CORS must be explicitly configured @@ -256,7 +196,7 @@ func (h *Handler) getAllowedAccounts(ctx context.Context, session *Session) ([]s return h.auth.GetAllowedAccountsAPI(ctx, session.UserID) } -// setSecurityHeaders adds comprehensive security headers to the response +// setSecurityHeaders adds comprehensive security headers to the response. func setSecurityHeaders(headers map[string]string) map[string]string { // Content Security Policy - restrictive for API responses // Only allow connections to same origin, block all other resources @@ -283,7 +223,7 @@ func setSecurityHeaders(headers map[string]string) map[string]string { return headers } -// HandleRequest processes a Lambda Function URL request +// HandleRequest processes a Lambda Function URL request. func (h *Handler) HandleRequest(ctx context.Context, req *events.LambdaFunctionURLRequest) (*events.LambdaFunctionURLResponse, error) { if req == nil { return h.buildResponse(400, h.buildResponseHeaders(), map[string]string{"error": "nil request"}, nil), nil @@ -308,7 +248,7 @@ func (h *Handler) HandleRequest(ctx context.Context, req *events.LambdaFunctionU return h.executeRequest(ctx, method, path, req, corsHeaders) } -// buildResponseHeaders creates response headers with security and CORS settings +// buildResponseHeaders creates response headers with security and CORS settings. func (h *Handler) buildResponseHeaders() map[string]string { corsHeaders := map[string]string{ "Content-Type": "application/json", @@ -326,7 +266,7 @@ func (h *Handler) buildResponseHeaders() map[string]string { return corsHeaders } -// validateRequest validates the incoming request and returns error response if validation fails +// validateRequest validates the incoming request and returns error response if validation fails. func (h *Handler) validateRequest(ctx context.Context, req *events.LambdaFunctionURLRequest, method, path string, corsHeaders map[string]string) *events.LambdaFunctionURLResponse { // Validate request body size if err := validateRequestBodySize(req.Body); err != nil { @@ -347,7 +287,7 @@ func (h *Handler) validateRequest(ctx context.Context, req *events.LambdaFunctio return nil } -// validateSecurity validates authentication and CSRF token +// validateSecurity validates authentication and CSRF token. func (h *Handler) validateSecurity(ctx context.Context, req *events.LambdaFunctionURLRequest, method, path string, corsHeaders map[string]string) *events.LambdaFunctionURLResponse { if h.isPublicEndpoint(path) { return nil @@ -367,7 +307,7 @@ func (h *Handler) validateSecurity(ctx context.Context, req *events.LambdaFuncti return nil } -// executeRequest routes and executes the API request +// executeRequest routes and executes the API request. func (h *Handler) executeRequest(ctx context.Context, method, path string, req *events.LambdaFunctionURLRequest, corsHeaders map[string]string) (*events.LambdaFunctionURLResponse, error) { response, err := h.routeRequest(ctx, method, path, req) @@ -379,8 +319,8 @@ func (h *Handler) executeRequest(ctx context.Context, method, path string, req * return h.buildResponse(statusCode, corsHeaders, response, nil), nil } -// handleRequestError converts an error to status code and response -func (h *Handler) handleRequestError(err error) (int, any) { +// handleRequestError converts an error to status code and response. +func (h *Handler) handleRequestError(err error) (int, any) { //nolint:gocritic if IsNotFoundError(err) { return 404, map[string]string{"error": "Not found"} } @@ -630,7 +570,7 @@ func (h *Handler) resolveAWSAccountID(ctx context.Context) (string, error) { // hosts with a broken SDK config surface the load error so the // multi-tenant scope filter in resolveAWSCloudAccountID fails closed // instead of degrading into an unscoped read. -func (h *Handler) resolveAWSCallerIdentity(ctx context.Context) (string, string, error) { +func (h *Handler) resolveAWSCallerIdentity(ctx context.Context) (string, string, error) { //nolint:gocritic if sourceCloud() != "aws" { // Azure/GCP host: short-circuit before any AWS SDK work. return "", "", nil @@ -661,7 +601,7 @@ func (h *Handler) resolveAWSCallerIdentity(ctx context.Context) (string, string, // parseArnPartition extracts the partition segment from an AWS ARN. // ARN format: arn:::::. -// Returns "" for inputs that aren't recognisable ARNs so the caller can +// Returns "" for inputs that aren't recognizable ARNs so the caller can // fall back to a default. Only the three known AWS partitions are // accepted — anything else is treated as malformed to avoid forwarding // attacker-controlled tokens into a JSON snippet the operator copy- diff --git a/internal/api/handler_accounts.go b/internal/api/handler_accounts.go index 4161357b1..3f31b2a5d 100644 --- a/internal/api/handler_accounts.go +++ b/internal/api/handler_accounts.go @@ -27,41 +27,38 @@ import ( // CloudAccountRequest is the request body for create/update account endpoints. type CloudAccountRequest struct { - Name string `json:"name"` - Description string `json:"description"` - ContactEmail string `json:"contact_email"` - Provider string `json:"provider"` - ExternalID string `json:"external_id"` - Enabled *bool `json:"enabled"` - // AWS + Enabled *bool `json:"enabled"` + AWSWebIdentityTokenFile string `json:"aws_web_identity_token_file"` + AzureClientID string `json:"azure_client_id"` + Provider string `json:"provider"` + Name string `json:"name"` + Description string `json:"description"` AWSAuthMode string `json:"aws_auth_mode"` AWSRoleARN string `json:"aws_role_arn"` AWSExternalID string `json:"aws_external_id"` + ContactEmail string `json:"contact_email"` + GCPWIFAudience string `json:"gcp_wif_audience"` + ExternalID string `json:"external_id"` + AzureSubscriptionID string `json:"azure_subscription_id"` + AzureTenantID string `json:"azure_tenant_id"` AWSBastionID string `json:"aws_bastion_id"` - AWSWebIdentityTokenFile string `json:"aws_web_identity_token_file"` + AzureAuthMode string `json:"azure_auth_mode"` + GCPProjectID string `json:"gcp_project_id"` + GCPClientEmail string `json:"gcp_client_email"` + GCPAuthMode string `json:"gcp_auth_mode"` AWSIsOrgRoot bool `json:"aws_is_org_root"` - // Azure - AzureSubscriptionID string `json:"azure_subscription_id"` - AzureTenantID string `json:"azure_tenant_id"` - AzureClientID string `json:"azure_client_id"` - AzureAuthMode string `json:"azure_auth_mode"` - // GCP - GCPProjectID string `json:"gcp_project_id"` - GCPClientEmail string `json:"gcp_client_email"` - GCPAuthMode string `json:"gcp_auth_mode"` - GCPWIFAudience string `json:"gcp_wif_audience"` // Full WIF provider resource, secret-free path only. } // CredentialsRequest is the request body for the save-credentials endpoint. type CredentialsRequest struct { - CredentialType string `json:"credential_type"` Payload map[string]interface{} `json:"payload"` + CredentialType string `json:"credential_type"` } // AccountTestResult is the response for the test-credentials endpoint. type AccountTestResult struct { - OK bool `json:"ok"` Message string `json:"message"` + OK bool `json:"ok"` } // AccountServiceOverrideRequest is the request body for service override endpoints. @@ -96,7 +93,7 @@ func (h *Handler) listAccounts(ctx context.Context, req *events.LambdaFunctionUR filter := buildAccountFilter(req.QueryStringParameters) - accounts, err := h.config.ListCloudAccounts(ctx, filter) + accounts, err := h.config.ListCloudAccounts(ctx, filter) //nolint:gocritic if err != nil { return nil, fmt.Errorf("accounts: %w", err) } @@ -114,7 +111,7 @@ func (h *Handler) listAccounts(ctx context.Context, req *events.LambdaFunctionUR } if !auth.IsUnrestrictedAccess(allowedAccounts) { filtered := accounts[:0] - for _, acct := range accounts { + for _, acct := range accounts { //nolint:gocritic if auth.MatchesAccount(allowedAccounts, acct.ID, acct.Name) { filtered = append(filtered, acct) } @@ -159,7 +156,7 @@ func (h *Handler) listAccountsMinimal(ctx context.Context, req *events.LambdaFun filter := buildAccountFilter(req.QueryStringParameters) - accounts, err := h.config.ListCloudAccounts(ctx, filter) + accounts, err := h.config.ListCloudAccounts(ctx, filter) //nolint:gocritic if err != nil { return nil, fmt.Errorf("accounts: %w", err) } @@ -190,7 +187,7 @@ func (h *Handler) listAccountsMinimal(ctx context.Context, req *events.LambdaFun } // markSelfAccount sets IsSelf=true on the account matching the source identity. -func (h *Handler) markSelfAccount(ctx context.Context, accounts []config.CloudAccount) { +func (h *Handler) markSelfAccount(ctx context.Context, accounts []config.CloudAccount) { //nolint:gocritic si := h.resolveSourceIdentity(ctx) if si == nil || si.ExternalID() == "" { return @@ -351,7 +348,7 @@ var validAccountProviders = map[string]bool{ } // validateCloudAccountRequest checks required fields and allowed values. -func validateCloudAccountRequest(req CloudAccountRequest) error { +func validateCloudAccountRequest(req CloudAccountRequest) error { //nolint:gocritic if req.Name == "" { return NewClientError(400, "name is required") } @@ -372,7 +369,7 @@ func validateCloudAccountRequest(req CloudAccountRequest) error { } // validateAuthMode checks that the provider-specific auth mode is a known value. -func validateAuthMode(req CloudAccountRequest) error { +func validateAuthMode(req CloudAccountRequest) error { //nolint:gocritic switch req.Provider { case "aws": return validateAWSAuthMode(req) @@ -412,7 +409,7 @@ func validateAuthMode(req CloudAccountRequest) error { // identity via the token subject claim (see resolveWebIdentityProvider // in internal/credentials/resolver.go), and stscreds.WebIdentityRoleOptions // has no ExternalID field. access_keys doesn't assume a role at all. -func validateAWSAuthMode(req CloudAccountRequest) error { +func validateAWSAuthMode(req CloudAccountRequest) error { //nolint:gocritic if req.AWSAuthMode != "" && !validAWSAuthModes[req.AWSAuthMode] { return NewClientError(400, "invalid aws_auth_mode") } @@ -443,7 +440,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). @@ -492,7 +489,7 @@ func isValidAWSExternalIDCharset(s string) bool { } // cloudAccountFromRequest maps a CloudAccountRequest to a config.CloudAccount. -func cloudAccountFromRequest(req CloudAccountRequest) *config.CloudAccount { +func cloudAccountFromRequest(req CloudAccountRequest) *config.CloudAccount { //nolint:gocritic a := &config.CloudAccount{ Name: req.Name, Description: req.Description, @@ -553,11 +550,11 @@ func (h *Handler) updateAccount(ctx context.Context, httpReq *events.LambdaFunct } var req CloudAccountRequest - if err := json.Unmarshal([]byte(httpReq.Body), &req); err != nil { + if err = json.Unmarshal([]byte(httpReq.Body), &req); err != nil { return nil, NewClientError(400, "invalid request body") } - if err := validateCloudAccountRequest(req); err != nil { + if err = validateCloudAccountRequest(req); err != nil { //nolint:gocritic // sloppyReassign: reuse outer err to avoid shadow return nil, err } @@ -599,7 +596,7 @@ func (h *Handler) deleteAccount(ctx context.Context, req *events.LambdaFunctionU // Verify the user can access this account AND that it exists. Returns 404 // for both "doesn't exist" and "out of scope" to avoid existence leakage. - if _, err := h.requireAccountAccess(ctx, session, id); err != nil { + if _, err = h.requireAccountAccess(ctx, session, id); err != nil { return nil, err } @@ -618,14 +615,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 +641,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", }) @@ -693,7 +690,7 @@ func (h *Handler) saveAccountCredentials(ctx context.Context, httpReq *events.La // Must precede the credStore-nil check so missing/out-of-scope accounts // return 404 rather than a 500 about credential store configuration. // Returns errNotFound for both cases to avoid existence disclosure. - if _, err := h.requireAccountAccess(ctx, session, id); err != nil { + if _, err = h.requireAccountAccess(ctx, session, id); err != nil { return nil, err } @@ -874,7 +871,7 @@ func runGCPFederatedTokenExchange(ctx context.Context, ts oauth2.TokenSource) Ac // gcpTokenExchangeAttempt runs one Token() call with a 15s deadline. // Returns (result, nil, _) on success, (_, err, true) on a retriable // IAM propagation error, (_, err, false) on any other failure. -func gcpTokenExchangeAttempt(ctx context.Context, ts oauth2.TokenSource) (AccountTestResult, error, bool) { +func gcpTokenExchangeAttempt(ctx context.Context, ts oauth2.TokenSource) (AccountTestResult, error, bool) { //nolint:revive // error-return: error is intentionally non-last; bool indicates retriability tokCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() tokenChan := make(chan tokenResult, 1) @@ -1028,7 +1025,7 @@ func (h *Handler) listAccountServiceOverrides(ctx context.Context, req *events.L return nil, err } - if _, err := h.requireAccountAccess(ctx, session, id); err != nil { + if _, err = h.requireAccountAccess(ctx, session, id); err != nil { return nil, err } @@ -1057,12 +1054,12 @@ func (h *Handler) saveAccountServiceOverride(ctx context.Context, httpReq *event return nil, err } - if _, err := h.requireAccountAccess(ctx, session, accountID); err != nil { + if _, err = h.requireAccountAccess(ctx, session, accountID); err != nil { return nil, err } var req AccountServiceOverrideRequest - if err := json.Unmarshal([]byte(httpReq.Body), &req); err != nil { + if err = json.Unmarshal([]byte(httpReq.Body), &req); err != nil { return nil, NewClientError(400, "invalid request body") } @@ -1074,11 +1071,11 @@ 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. - if err := h.checkCommitmentOptionCombo(ctx, config.ServiceConfig{ + if err := h.checkCommitmentOptionCombo(ctx, &config.ServiceConfig{ Provider: override.Provider, Service: override.Service, Term: derefInt(override.Term), @@ -1095,7 +1092,7 @@ func (h *Handler) saveAccountServiceOverride(ctx context.Context, httpReq *event } // buildServiceOverride constructs an AccountServiceOverride from request and existing data. -func buildServiceOverride(accountID, provider, service string, req AccountServiceOverrideRequest, existing *config.AccountServiceOverride, now time.Time) *config.AccountServiceOverride { +func buildServiceOverride(accountID, provider, service string, req AccountServiceOverrideRequest, existing *config.AccountServiceOverride, now time.Time) *config.AccountServiceOverride { //nolint:gocritic override := &config.AccountServiceOverride{ AccountID: accountID, Provider: provider, @@ -1117,12 +1114,12 @@ func buildServiceOverride(accountID, provider, service string, req AccountServic } // applyServiceOverrideFields copies sparse request fields onto an override. -func applyServiceOverrideFields(o *config.AccountServiceOverride, req AccountServiceOverrideRequest) { +func applyServiceOverrideFields(o *config.AccountServiceOverride, req AccountServiceOverrideRequest) { //nolint:gocritic applyOverrideScalars(o, req) applyOverrideSlices(o, req) } -func applyOverrideScalars(o *config.AccountServiceOverride, req AccountServiceOverrideRequest) { +func applyOverrideScalars(o *config.AccountServiceOverride, req AccountServiceOverrideRequest) { //nolint:gocritic if req.Enabled != nil { o.Enabled = req.Enabled } @@ -1140,7 +1137,7 @@ func applyOverrideScalars(o *config.AccountServiceOverride, req AccountServiceOv } } -func applyOverrideSlices(o *config.AccountServiceOverride, req AccountServiceOverrideRequest) { +func applyOverrideSlices(o *config.AccountServiceOverride, req AccountServiceOverrideRequest) { //nolint:gocritic if req.IncludeEngines != nil { o.IncludeEngines = req.IncludeEngines } @@ -1346,7 +1343,7 @@ func (h *Handler) listPlanAccounts(ctx context.Context, req *events.LambdaFuncti return nil, err } - accounts, err := h.config.GetPlanAccounts(ctx, id) + accounts, err := h.config.GetPlanAccounts(ctx, id) //nolint:gocritic if err != nil { return nil, fmt.Errorf("accounts: %w", err) } @@ -1476,12 +1473,12 @@ func (h *Handler) buildOrgRootAWSConfig(ctx context.Context, root *config.CloudA // runOrgDiscovery dispatches to the configured discovery function — the // injectable seam Handler.discoverOrgFn for tests, falling back to the real // accounts.DiscoverOrgAccounts in production. -func (h *Handler) runOrgDiscovery(ctx context.Context, cfg aws.Config) (*accounts.OrgDiscoveryResult, error) { +func (h *Handler) runOrgDiscovery(ctx context.Context, cfg aws.Config) (*accounts.OrgDiscoveryResult, error) { //nolint:gocritic discoverFn := h.discoverOrgFn if discoverFn == nil { discoverFn = accounts.DiscoverOrgAccounts } - disco, err := discoverFn(ctx, cfg) + disco, err := discoverFn(ctx, &cfg) if err != nil { return nil, fmt.Errorf("accounts: org discovery failed: %w", err) } diff --git a/internal/api/handler_accounts_external_id_test.go b/internal/api/handler_accounts_external_id_test.go index a029369c5..47b493e4c 100644 --- a/internal/api/handler_accounts_external_id_test.go +++ b/internal/api/handler_accounts_external_id_test.go @@ -12,38 +12,38 @@ 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) { cases := []struct { name string input string - wantError bool errContains string + wantError bool }{ // --- Valid values --- - {"uuid", "550e8400-e29b-41d4-a716-446655440000", false, ""}, - {"32 hex chars", "0123456789abcdef0123456789abcdef", false, ""}, - {"with all allowed punctuation", "abcd_+=,.@:/-1234", false, ""}, - {"max length boundary - 1224 chars", strings.Repeat("a", 1224), false, ""}, - {"min length boundary - 16 chars", "0123456789abcdef", false, ""}, + {name: "uuid", input: "550e8400-e29b-41d4-a716-446655440000"}, + {name: "32 hex chars", input: "0123456789abcdef0123456789abcdef"}, + {name: "with all allowed punctuation", input: "abcd_+=,.@:/-1234"}, + {name: "max length boundary - 1224 chars", input: strings.Repeat("a", 1224)}, + {name: "min length boundary - 16 chars", input: "0123456789abcdef"}, // --- Invalid: empty / whitespace --- - {"empty", "", true, "required"}, - {"whitespace only", " \t\n", true, "required"}, + {name: "empty", input: "", wantError: true, errContains: "required"}, + {name: "whitespace only", input: " \t\n", wantError: true, errContains: "required"}, // --- Invalid: length --- - {"too short - 1 char", "x", true, "16-1224"}, - {"too short - 15 chars", "0123456789abcde", true, "16-1224"}, - {"too long - 1225 chars", strings.Repeat("a", 1225), true, "16-1224"}, + {name: "too short - 1 char", input: "x", wantError: true, errContains: "16-1224"}, + {name: "too short - 15 chars", input: "0123456789abcde", wantError: true, errContains: "16-1224"}, + {name: "too long - 1225 chars", input: strings.Repeat("a", 1225), wantError: true, errContains: "16-1224"}, // --- Invalid: charset --- - {"contains space", "abcd1234abcd1234 invalid", true, "characters"}, - {"contains question mark", "abcd1234abcd1234?bad", true, "characters"}, - {"contains parens", "abcd1234abcd1234(x)", true, "characters"}, - {"contains semicolon", "abcd1234abcd1234;rm", true, "characters"}, - {"contains unicode", "abcd1234abcd1234é", true, "characters"}, + {name: "contains space", input: "abcd1234abcd1234 invalid", wantError: true, errContains: "characters"}, + {name: "contains question mark", input: "abcd1234abcd1234?bad", wantError: true, errContains: "characters"}, + {name: "contains parens", input: "abcd1234abcd1234(x)", wantError: true, errContains: "characters"}, + {name: "contains semicolon", input: "abcd1234abcd1234;rm", wantError: true, errContains: "characters"}, + {name: "contains unicode", input: "abcd1234abcd1234é", wantError: true, errContains: "characters"}, } for _, tc := range cases { @@ -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..b27701126 100644 --- a/internal/api/handler_accounts_test.go +++ b/internal/api/handler_accounts_test.go @@ -120,17 +120,16 @@ func standardUserSession() *Session { // setupStandardUserAuth stubs ValidateSession + a single HasPermissionAPI verb // for the Standard user. It deliberately does NOT grant view:accounts so a test // can assert the full listAccounts handler 403s while the minimal one succeeds. -func setupStandardUserAuth(ctx context.Context, mockAuth *MockAuthService, verb, resource string, allow bool, allowed []string) { +func setupStandardUserAuth(ctx context.Context, mockAuth *MockAuthService, resource string, allow bool, allowed []string) { session := standardUserSession() mockAuth.On("ValidateSession", ctx, "standard-token").Return(session, nil) - mockAuth.On("HasPermissionAPI", ctx, session.UserID, verb, resource).Return(allow, nil) + mockAuth.On("HasPermissionAPI", ctx, session.UserID, "view", resource).Return(allow, nil) mockAuth.On("GetAllowedAccountsAPI", ctx, session.UserID).Return(allowed, nil).Maybe() } -func standardRequest(body string) *events.LambdaFunctionURLRequest { +func standardRequest() *events.LambdaFunctionURLRequest { return &events.LambdaFunctionURLRequest{ Headers: map[string]string{"Authorization": "Bearer standard-token"}, - Body: body, } } @@ -139,7 +138,7 @@ func standardRequest(body string) *events.LambdaFunctionURLRequest { func TestListAccountsMinimal_StandardUserAllowed_NoSensitiveFields(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - setupStandardUserAuth(ctx, mockAuth, "view", "recommendations", true, []string{"*"}) + setupStandardUserAuth(ctx, mockAuth, "recommendations", true, []string{"*"}) full := sampleAccount() full.AWSAuthMode = "role_arn" @@ -153,7 +152,7 @@ func TestListAccountsMinimal_StandardUserAllowed_NoSensitiveFields(t *testing.T) } handler := &Handler{auth: mockAuth, config: customStore} - result, err := handler.listAccountsMinimal(ctx, standardRequest("")) + result, err := handler.listAccountsMinimal(ctx, standardRequest()) require.NoError(t, err) got := result.([]AccountSummary) @@ -163,7 +162,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) @@ -182,10 +181,10 @@ func TestListAccountsMinimal_StandardUserAllowed_NoSensitiveFields(t *testing.T) func TestListAccounts_StandardUserDenied_403(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - setupStandardUserAuth(ctx, mockAuth, "view", "accounts", false, nil) + setupStandardUserAuth(ctx, mockAuth, "accounts", false, nil) handler := &Handler{auth: mockAuth, config: setupAdminMock(ctx)} - result, err := handler.listAccounts(ctx, standardRequest("")) + result, err := handler.listAccounts(ctx, standardRequest()) assert.Nil(t, result) require.Error(t, err) ce, ok := IsClientError(err) @@ -198,10 +197,10 @@ func TestListAccounts_StandardUserDenied_403(t *testing.T) { func TestListAccountsMinimal_NoViewRecommendations_403(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - setupStandardUserAuth(ctx, mockAuth, "view", "recommendations", false, nil) + setupStandardUserAuth(ctx, mockAuth, "recommendations", false, nil) handler := &Handler{auth: mockAuth, config: setupAdminMock(ctx)} - result, err := handler.listAccountsMinimal(ctx, standardRequest("")) + result, err := handler.listAccountsMinimal(ctx, standardRequest()) assert.Nil(t, result) require.Error(t, err) ce, ok := IsClientError(err) @@ -214,7 +213,7 @@ func TestListAccountsMinimal_NoViewRecommendations_403(t *testing.T) { func TestListAccountsMinimal_ScopesByAllowedAccounts(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - setupStandardUserAuth(ctx, mockAuth, "view", "recommendations", true, []string{"Production"}) + setupStandardUserAuth(ctx, mockAuth, "recommendations", true, []string{"Production"}) prod := sampleAccount() prod.Name = "Production" @@ -227,7 +226,7 @@ func TestListAccountsMinimal_ScopesByAllowedAccounts(t *testing.T) { } handler := &Handler{auth: mockAuth, config: customStore} - result, err := handler.listAccountsMinimal(ctx, standardRequest("")) + result, err := handler.listAccountsMinimal(ctx, standardRequest()) require.NoError(t, err) got := result.([]AccountSummary) require.Len(t, got, 1) @@ -239,10 +238,10 @@ func TestListAccountsMinimal_ScopesByAllowedAccounts(t *testing.T) { func TestListAccountsMinimal_ReturnsEmptySlice(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - setupStandardUserAuth(ctx, mockAuth, "view", "recommendations", true, []string{"*"}) + setupStandardUserAuth(ctx, mockAuth, "recommendations", true, []string{"*"}) handler := &Handler{auth: mockAuth, config: setupAdminMock(ctx)} - result, err := handler.listAccountsMinimal(ctx, standardRequest("")) + result, err := handler.listAccountsMinimal(ctx, standardRequest()) require.NoError(t, err) got := result.([]AccountSummary) assert.NotNil(t, got) @@ -1198,7 +1197,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 } @@ -1424,7 +1423,7 @@ func TestDiscoverOrgAccounts_CredResolutionFailureIs5xx(t *testing.T) { // discoverOrgFn must NOT be reached — credential resolution fails // upstream of it. If the test triggers this, the test is // mis-wired (or the handler stopped failing on cred error). - discoverOrgFn: func(_ context.Context, _ aws.Config) (*accounts.OrgDiscoveryResult, error) { + discoverOrgFn: func(_ context.Context, _ *aws.Config) (*accounts.OrgDiscoveryResult, error) { t.Fatal("discoverOrgFn must not be called when credential resolution fails") return nil, nil }, @@ -1495,7 +1494,7 @@ func TestDiscoverOrgAccounts_HappyPathDedupesAndPersists(t *testing.T) { auth: mockAuth, config: store, credStore: credStore, - discoverOrgFn: func(_ context.Context, _ aws.Config) (*accounts.OrgDiscoveryResult, error) { + discoverOrgFn: func(_ context.Context, _ *aws.Config) (*accounts.OrgDiscoveryResult, error) { return &accounts.OrgDiscoveryResult{ Accounts: []config.CloudAccount{ {Provider: "aws", ExternalID: "200000000002", Name: "Already Known"}, // dedupe: skipped @@ -1576,7 +1575,7 @@ func TestDiscoverOrgAccounts_SkipsDuplicateKeyOnInsert(t *testing.T) { root.ID + "::aws_access_keys": []byte(`{"access_key_id":"AKIATEST","secret_access_key":"shh"}`), }, }, - discoverOrgFn: func(_ context.Context, _ aws.Config) (*accounts.OrgDiscoveryResult, error) { + discoverOrgFn: func(_ context.Context, _ *aws.Config) (*accounts.OrgDiscoveryResult, error) { return &accounts.OrgDiscoveryResult{ Accounts: []config.CloudAccount{ {Provider: "aws", ExternalID: "300000000003", Name: "Dup On Insert"}, @@ -1627,7 +1626,7 @@ func TestDiscoverOrgAccounts_AllowsNilDiscoveryResult(t *testing.T) { root.ID + "::aws_access_keys": []byte(`{"access_key_id":"AKIATEST","secret_access_key":"shh"}`), }, }, - discoverOrgFn: func(_ context.Context, _ aws.Config) (*accounts.OrgDiscoveryResult, error) { + discoverOrgFn: func(_ context.Context, _ *aws.Config) (*accounts.OrgDiscoveryResult, error) { return nil, nil }, } @@ -1705,12 +1704,11 @@ func scopedUserSession() *Session { } } -func setupScopedAuth(ctx context.Context, mockAuth *MockAuthService, userID, verb, resource string, allowed []string) { +func setupScopedAuth(ctx context.Context, mockAuth *MockAuthService, verb string, allowed []string) { session := scopedUserSession() - session.UserID = userID mockAuth.On("ValidateSession", ctx, "scoped-token").Return(session, nil) - mockAuth.On("HasPermissionAPI", ctx, userID, verb, resource).Return(true, nil) - mockAuth.On("GetAllowedAccountsAPI", ctx, userID).Return(allowed, nil) + mockAuth.On("HasPermissionAPI", ctx, session.UserID, verb, "accounts").Return(true, nil) + mockAuth.On("GetAllowedAccountsAPI", ctx, session.UserID).Return(allowed, nil) } func scopedRequest(body string) *events.LambdaFunctionURLRequest { @@ -1727,7 +1725,7 @@ const outOfScopeID = "22222222-2222-2222-2222-222222222222" func TestGetAccount_OutOfScope_Returns404(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - setupScopedAuth(ctx, mockAuth, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "view", "accounts", []string{"Production"}) + setupScopedAuth(ctx, mockAuth, "view", []string{"Production"}) acct := sampleAccount() acct.ID = outOfScopeID @@ -1748,7 +1746,7 @@ func TestGetAccount_InScope_ByName(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) // allowed_accounts = ["Production"] matches acct.Name - setupScopedAuth(ctx, mockAuth, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "view", "accounts", []string{"Production"}) + setupScopedAuth(ctx, mockAuth, "view", []string{"Production"}) acct := sampleAccount() acct.Name = "Production" @@ -1767,7 +1765,7 @@ func TestGetAccount_InScope_ByName(t *testing.T) { func TestDeleteAccount_OutOfScope_Returns404_NoDelete(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - setupScopedAuth(ctx, mockAuth, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "delete", "accounts", []string{"Production"}) + setupScopedAuth(ctx, mockAuth, "delete", []string{"Production"}) acct := sampleAccount() acct.ID = outOfScopeID @@ -1791,7 +1789,7 @@ func TestDeleteAccount_OutOfScope_Returns404_NoDelete(t *testing.T) { func TestSaveAccountCredentials_OutOfScope_Returns404(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - setupScopedAuth(ctx, mockAuth, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "update", "accounts", []string{"Production"}) + setupScopedAuth(ctx, mockAuth, "update", []string{"Production"}) acct := sampleAccount() acct.ID = outOfScopeID @@ -1809,13 +1807,13 @@ func TestSaveAccountCredentials_OutOfScope_Returns404(t *testing.T) { // mockConfigStoreAccounts embeds MockConfigStore and allows overriding specific account methods. type mockConfigStoreAccounts struct { + createErr error + updateErr error *MockConfigStore getResult *config.CloudAccount listResult []config.CloudAccount planAccountsResult []config.CloudAccount listOverridesResult []config.AccountServiceOverride - createErr error // optional override: return this err from CreateCloudAccount - updateErr error // optional override: return this err from UpdateCloudAccount } func (m *mockConfigStoreAccounts) GetCloudAccount(ctx context.Context, id string) (*config.CloudAccount, error) { @@ -1895,7 +1893,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..3e0418ef3 100644 --- a/internal/api/handler_analytics.go +++ b/internal/api/handler_analytics.go @@ -19,10 +19,10 @@ import ( type TrendsResponse struct { Start string `json:"start"` End string `json:"end"` - Months int `json:"months"` Monthly []analytics.MonthlySummary `json:"monthly"` Provider []analytics.ProviderBreakdown `json:"by_provider"` Service []analytics.ServiceBreakdown `json:"by_service"` + Months int `json:"months"` } // getAnalyticsTrends handles GET /api/analytics/trends. It returns the @@ -41,7 +41,8 @@ func (h *Handler) getAnalyticsTrends(ctx context.Context, req *events.LambdaFunc accountID := params["account_id"] // Enforce allowed_accounts scope BEFORE resolving filter ids so a scoped // user can never widen to "all" (empty filters mean all-accessible). - if err := h.validateAnalyticsAccountScope(ctx, session, accountID); err != nil { + err = h.validateAnalyticsAccountScope(ctx, session, accountID) + if err != nil { return nil, err } @@ -102,13 +103,13 @@ type AnalyticsResponse struct { // BreakdownResponse represents the response for the breakdown endpoint. type BreakdownResponse struct { + Data map[string]BreakdownValue `json:"data"` Dimension string `json:"dimension"` Start string `json:"start"` End string `json:"end"` - 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 +120,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. @@ -142,7 +143,8 @@ func (h *Handler) getHistoryAnalytics(ctx context.Context, req *events.LambdaFun if provider == "all" { provider = "" // explicit "no filter" sentinel } - if err := validateProvider(provider); err != nil { + err = validateProvider(provider) + if err != nil { return nil, err } @@ -150,7 +152,8 @@ func (h *Handler) getHistoryAnalytics(ctx context.Context, req *events.LambdaFun // We don't (yet) support analytics across a subset — the underlying // aggregate takes a single account_id. An unrestricted/admin session // can pass "" to mean "all accessible accounts". - if err := h.validateAnalyticsAccountScope(ctx, session, accountID); err != nil { + err = h.validateAnalyticsAccountScope(ctx, session, accountID) + if err != nil { return nil, err } @@ -180,7 +183,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 { @@ -198,7 +201,8 @@ func (h *Handler) getHistoryBreakdown(ctx context.Context, req *events.LambdaFun dimension = "service" } - if err := h.validateAnalyticsAccountScope(ctx, session, accountID); err != nil { + err = h.validateAnalyticsAccountScope(ctx, session, accountID) + if err != nil { return nil, err } @@ -267,10 +271,7 @@ func (h *Handler) triggerAnalyticsCollection(ctx context.Context, req *events.La } // parseDateRange parses start and end date strings with defaults. -func parseDateRange(startStr, endStr string) (time.Time, time.Time, error) { - var start, end time.Time - var err error - +func parseDateRange(startStr, endStr string) (start, end time.Time, err error) { // Default end to now if endStr == "" { end = time.Now().UTC() diff --git a/internal/api/handler_analytics_test.go b/internal/api/handler_analytics_test.go index 9622b55dc..78afb2863 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{ @@ -538,7 +538,7 @@ type MockAnalyticsSnapshotStore struct { mock.Mock } -func (m *MockAnalyticsSnapshotStore) QuerySavings(ctx context.Context, req analytics.QueryRequest) ([]analytics.SavingsSnapshot, error) { +func (m *MockAnalyticsSnapshotStore) QuerySavings(ctx context.Context, req *analytics.QueryRequest) ([]analytics.SavingsSnapshot, error) { args := m.Called(ctx, req) if args.Get(0) == nil { return nil, args.Error(1) diff --git a/internal/api/handler_apikeys.go b/internal/api/handler_apikeys.go index a3f3695db..a9ff2dffa 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 { @@ -47,7 +47,8 @@ func (h *Handler) createAPIKey(ctx context.Context, req *events.LambdaFunctionUR // surface is lower than the unauthenticated credential endpoints. Emit a // high-severity alert so the fail-open window is observable (02-M1). if h.rateLimiter != nil { - allowed, err := h.rateLimiter.AllowWithUser(ctx, session.UserID, "admin") + var allowed bool + allowed, err = h.rateLimiter.AllowWithUser(ctx, session.UserID, "admin") if err != nil { logging.Errorf("ALERT: rate limiter error on admin operation for user %s; proceeding fail-open (02-M1): %v", session.UserID, err) @@ -57,7 +58,8 @@ func (h *Handler) createAPIKey(ctx context.Context, req *events.LambdaFunctionUR } var createReq CreateAPIKeyRequest - if err := json.Unmarshal([]byte(req.Body), &createReq); err != nil { + err = json.Unmarshal([]byte(req.Body), &createReq) + if err != nil { return nil, NewClientError(400, "invalid request body") } @@ -69,7 +71,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 { @@ -81,14 +83,15 @@ func (h *Handler) deleteAPIKey(ctx context.Context, req *events.LambdaFunctionUR return nil, err } - if err := h.auth.DeleteAPIKeyAPI(ctx, session.UserID, keyID); err != nil { + err = h.auth.DeleteAPIKeyAPI(ctx, session.UserID, keyID) + if err != nil { return nil, fmt.Errorf("failed to delete API key: %w", err) } 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") @@ -101,7 +104,8 @@ func (h *Handler) revokeAPIKey(ctx context.Context, req *events.LambdaFunctionUR return nil, err } - if err := h.auth.RevokeAPIKeyAPI(ctx, session.UserID, keyID); err != nil { + err = h.auth.RevokeAPIKeyAPI(ctx, session.UserID, keyID) + if err != nil { return nil, fmt.Errorf("failed to revoke API key: %w", err) } @@ -130,7 +134,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..b8ed9a792 100644 --- a/internal/api/handler_auth.go +++ b/internal/api/handler_auth.go @@ -31,11 +31,11 @@ func (h *Handler) login(ctx context.Context, req *events.LambdaFunctionURLReques } // Decode base64-encoded password - if decoded, err := decodeBase64Password(loginReq.Password); err != nil { + decoded, err := decodeBase64Password(loginReq.Password) + if err != nil { return nil, err - } else { - loginReq.Password = decoded } + loginReq.Password = decoded response, err := h.auth.Login(ctx, loginReq) if err != nil { @@ -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") } @@ -205,7 +205,7 @@ func (h *Handler) resolveAuthenticatedUserID(ctx context.Context, req *events.La return session.UserID, nil } -func (h *Handler) checkAdminExists(ctx context.Context, req *events.LambdaFunctionURLRequest) (*AdminExistsResponse, error) { +func (h *Handler) checkAdminExists(ctx context.Context, _ *events.LambdaFunctionURLRequest) (*AdminExistsResponse, error) { if h.auth == nil { return nil, fmt.Errorf("authentication service not configured") } @@ -383,12 +383,14 @@ func (h *Handler) updateProfile(ctx context.Context, req *events.LambdaFunctionU // Parse request body var profileReq ProfileUpdateRequest - if err := json.Unmarshal([]byte(req.Body), &profileReq); err != nil { + err = json.Unmarshal([]byte(req.Body), &profileReq) + if err != nil { return nil, NewClientError(400, "invalid request body") } // Validate email format before decoding passwords (cheap check first). - if err := validateEmailFormat(profileReq.Email); err != nil { + err = validateEmailFormat(profileReq.Email) + if err != nil { return nil, err } @@ -399,7 +401,8 @@ func (h *Handler) updateProfile(ctx context.Context, req *events.LambdaFunctionU } // Update profile through auth service - if err := h.auth.UpdateUserProfile(ctx, session.UserID, profileReq.Email, currentPassword, newPassword); err != nil { + err = h.auth.UpdateUserProfile(ctx, session.UserID, profileReq.Email, currentPassword, newPassword) + if err != nil { return nil, mapProfileUpdateError(err) } @@ -461,7 +464,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") @@ -483,7 +486,8 @@ func (h *Handler) changePassword(ctx context.Context, req *events.LambdaFunction } var pwdReq ChangePasswordRequest - if err := json.Unmarshal([]byte(req.Body), &pwdReq); err != nil { + err = json.Unmarshal([]byte(req.Body), &pwdReq) + if err != nil { return nil, NewClientError(400, "invalid request body") } @@ -492,7 +496,8 @@ func (h *Handler) changePassword(ctx context.Context, req *events.LambdaFunction return nil, err } - if err := h.auth.ChangePasswordAPI(ctx, session.UserID, currentPassword, newPassword); err != nil { + err = h.auth.ChangePasswordAPI(ctx, session.UserID, currentPassword, newPassword) + if err != nil { return nil, err } @@ -544,7 +549,8 @@ func (h *Handler) mfaSetup(ctx context.Context, req *events.LambdaFunctionURLReq return nil, err } var body MFASetupRequest - if err := json.Unmarshal([]byte(req.Body), &body); err != nil { + err = json.Unmarshal([]byte(req.Body), &body) + if err != nil { return nil, NewClientError(400, "invalid request body") } password, err := decodeBase64Password(body.Password) @@ -567,7 +573,8 @@ func (h *Handler) mfaEnable(ctx context.Context, req *events.LambdaFunctionURLRe return nil, err } var body MFAEnableRequest - if err := json.Unmarshal([]byte(req.Body), &body); err != nil { + err = json.Unmarshal([]byte(req.Body), &body) + if err != nil { return nil, NewClientError(400, "invalid request body") } codes, err := h.auth.MFAEnableAPI(ctx, session.UserID, body.Code) @@ -587,14 +594,16 @@ func (h *Handler) mfaDisable(ctx context.Context, req *events.LambdaFunctionURLR return nil, err } var body MFADisableRequest - if err := json.Unmarshal([]byte(req.Body), &body); err != nil { + err = json.Unmarshal([]byte(req.Body), &body) + if err != nil { return nil, NewClientError(400, "invalid request body") } password, err := decodeBase64Password(body.Password) if err != nil { return nil, err } - if err := h.auth.MFADisableAPI(ctx, session.UserID, password, body.Code); err != nil { + err = h.auth.MFADisableAPI(ctx, session.UserID, password, body.Code) + if err != nil { return nil, mapMFAServiceError(err) } return &StatusResponse{Status: "mfa disabled"}, nil @@ -610,7 +619,8 @@ func (h *Handler) mfaRegenerateRecoveryCodes(ctx context.Context, req *events.La return nil, err } var body MFARegenerateRequest - if err := json.Unmarshal([]byte(req.Body), &body); err != nil { + err = json.Unmarshal([]byte(req.Body), &body) + if err != nil { return nil, NewClientError(400, "invalid request body") } codes, err := h.auth.MFARegenerateRecoveryCodesAPI(ctx, session.UserID, body.Code) @@ -621,7 +631,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..8db444529 100644 --- a/internal/api/handler_auth_test.go +++ b/internal/api/handler_auth_test.go @@ -1165,7 +1165,7 @@ func authedReq(token, body string) *events.LambdaFunctionURLRequest { func TestHandler_login_MFARequired_ReturnsMFASentinel(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - mockAuth.On("Login", ctx, mock.Anything).Return((*LoginResponse)(nil), ErrMFARequired_test()).Once() + mockAuth.On("Login", ctx, mock.Anything).Return((*LoginResponse)(nil), ErrMFARequiredTest()).Once() handler := &Handler{auth: mockAuth} req := &events.LambdaFunctionURLRequest{ @@ -1183,7 +1183,7 @@ func TestHandler_login_MFARequired_ReturnsMFASentinel(t *testing.T) { func TestHandler_login_InvalidMFACode_ReturnsCodedSentinel(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) - mockAuth.On("Login", ctx, mock.Anything).Return((*LoginResponse)(nil), ErrInvalidMFACode_test()).Once() + mockAuth.On("Login", ctx, mock.Anything).Return((*LoginResponse)(nil), ErrInvalidMFACodeTest()).Once() handler := &Handler{auth: mockAuth} req := &events.LambdaFunctionURLRequest{ @@ -1292,15 +1292,15 @@ func TestHandler_mfaRegenerateRecoveryCodes_HappyPath(t *testing.T) { mockAuth.AssertExpectations(t) } -// ErrMFARequired_test / ErrInvalidMFACode_test return the sentinel +// ErrMFARequiredTest / ErrInvalidMFACode_test return the sentinel // errors from the auth package via a non-importing path so the api // package's _test.go files don't need to import the whole auth // package just for two values. Both are exported sentinels in the // auth package (errors.go); the api package's login handler maps // them via errors.Is(). Here we just wrap them so the mocked Login // returns the same value the real service would. -func ErrMFARequired_test() error { return mfaRequiredSentinel } -func ErrInvalidMFACode_test() error { return mfaInvalidSentinel } +func ErrMFARequiredTest() error { return mfaRequiredSentinel } +func ErrInvalidMFACodeTest() error { return mfaInvalidSentinel } // Tests for GET /api/auth/me/permissions (issue #917). @@ -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() @@ -1497,7 +1497,7 @@ func TestHandler_login_ErrorEquivalence(t *testing.T) { // GetUserByEmail-error path in the real service). mockAuthNotFound := new(MockAuthService) mockAuthNotFound.On("Login", ctx, mock.Anything). - Return((*LoginResponse)(nil), errors.New("Check your email address and password and try again")).Once() + Return((*LoginResponse)(nil), errors.New("check your email address and password and try again")).Once() t.Cleanup(func() { mockAuthNotFound.AssertExpectations(t) }) handlerNotFound := &Handler{auth: mockAuthNotFound} @@ -1513,7 +1513,7 @@ func TestHandler_login_ErrorEquivalence(t *testing.T) { // Path 2: service returns the "wrong password" message (existing user). mockAuthWrongPass := new(MockAuthService) mockAuthWrongPass.On("Login", ctx, mock.Anything). - Return((*LoginResponse)(nil), errors.New("Check your email address and password and try again")).Once() + Return((*LoginResponse)(nil), errors.New("check your email address and password and try again")).Once() t.Cleanup(func() { mockAuthWrongPass.AssertExpectations(t) }) handlerWrongPass := &Handler{auth: mockAuthWrongPass} diff --git a/internal/api/handler_commitment_options.go b/internal/api/handler_commitment_options.go index 62833bf4b..b7a6e9978 100644 --- a/internal/api/handler_commitment_options.go +++ b/internal/api/handler_commitment_options.go @@ -15,16 +15,16 @@ import ( // omitted — those commitment rules stay hardcoded in the frontend because // their APIs don't expose a comparable probe. type commitmentOptionsResponse struct { - Status string `json:"status"` AWS map[string][]commitmentOptionCombo `json:"aws,omitempty"` + Status string `json:"status"` } // commitmentOptionCombo is one (term, payment) tuple as the frontend // consumes it. Dropping Provider/Service from the persisted Combo shape // keeps the wire payload compact. type commitmentOptionCombo struct { - Term int `json:"term"` Payment string `json:"payment"` + Term int `json:"term"` } // getCommitmentOptions returns the dynamically-probed AWS commitment @@ -32,9 +32,9 @@ type commitmentOptionCombo struct { // {"status":"unavailable"} (200, not a 5xx) so the frontend can fall // back to its hardcoded defaults without tripping the generic // error-toast path. -func (h *Handler) getCommitmentOptions(ctx context.Context) (*commitmentOptionsResponse, error) { +func (h *Handler) getCommitmentOptions(ctx context.Context) *commitmentOptionsResponse { if h.commitmentOpts == nil { - return &commitmentOptionsResponse{Status: "unavailable"}, nil + return &commitmentOptionsResponse{Status: "unavailable"} } opts, err := h.commitmentOpts.Get(ctx) if err != nil { @@ -46,7 +46,7 @@ func (h *Handler) getCommitmentOptions(ctx context.Context) (*commitmentOptionsR if !errors.Is(err, commitmentopts.ErrNoData) { logging.Warnf("commitmentopts handler: %v", err) } - return &commitmentOptionsResponse{Status: "unavailable"}, nil + return &commitmentOptionsResponse{Status: "unavailable"} } awsOpts := opts["aws"] @@ -62,7 +62,7 @@ func (h *Handler) getCommitmentOptions(ctx context.Context) (*commitmentOptionsR // filtered out by normalization). Treat as unavailable so the // frontend falls through to its hardcoded rules rather than // rendering an empty constraint set. - return &commitmentOptionsResponse{Status: "unavailable"}, nil + return &commitmentOptionsResponse{Status: "unavailable"} } - return &commitmentOptionsResponse{Status: "ok", AWS: out}, nil + return &commitmentOptionsResponse{Status: "ok", AWS: out} } diff --git a/internal/api/handler_commitment_options_test.go b/internal/api/handler_commitment_options_test.go index d4d3a92fc..b6128678b 100644 --- a/internal/api/handler_commitment_options_test.go +++ b/internal/api/handler_commitment_options_test.go @@ -28,9 +28,8 @@ func (s *stubCommitmentOpts) Validate(ctx context.Context, provider, service str func TestGetCommitmentOptions_NilService_ReturnsUnavailable(t *testing.T) { h := &Handler{commitmentOpts: nil} - resp, err := h.getCommitmentOptions(context.Background()) + resp := h.getCommitmentOptions(context.Background()) - require.NoError(t, err) assert.Equal(t, "unavailable", resp.Status) assert.Nil(t, resp.AWS) } @@ -42,9 +41,8 @@ func TestGetCommitmentOptions_ErrNoData_ReturnsUnavailable(t *testing.T) { }, }} - resp, err := h.getCommitmentOptions(context.Background()) + resp := h.getCommitmentOptions(context.Background()) - require.NoError(t, err) assert.Equal(t, "unavailable", resp.Status) assert.Nil(t, resp.AWS) } @@ -60,9 +58,8 @@ func TestGetCommitmentOptions_UnexpectedError_CollapsesToUnavailable(t *testing. }, }} - resp, err := h.getCommitmentOptions(context.Background()) + resp := h.getCommitmentOptions(context.Background()) - require.NoError(t, err) assert.Equal(t, "unavailable", resp.Status) assert.Nil(t, resp.AWS) } @@ -86,9 +83,8 @@ func TestGetCommitmentOptions_EmptyAWS_CollapsesToUnavailable(t *testing.T) { }, }} - resp, err := h.getCommitmentOptions(context.Background()) + resp := h.getCommitmentOptions(context.Background()) - require.NoError(t, err) assert.Equal(t, "unavailable", resp.Status) assert.Nil(t, resp.AWS) } @@ -112,9 +108,8 @@ func TestGetCommitmentOptions_Success_ReturnsAWSCombos(t *testing.T) { }, }} - resp, err := h.getCommitmentOptions(context.Background()) + resp := h.getCommitmentOptions(context.Background()) - require.NoError(t, err) assert.Equal(t, "ok", resp.Status) assert.Len(t, resp.AWS["rds"], 3) assert.Len(t, resp.AWS["elasticache"], 2) diff --git a/internal/api/handler_config.go b/internal/api/handler_config.go index 9daa5576f..a4fe7c32c 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; @@ -162,7 +162,7 @@ func (h *Handler) propagateGlobalDefaults(ctx context.Context, cfg *config.Globa // the save isn't AWS, or the combo is valid. Errors from Validate are // logged and swallowed (permissive) so a transient DB blip never blocks // a settings save. -func (h *Handler) checkCommitmentOptionCombo(ctx context.Context, cfg config.ServiceConfig) error { +func (h *Handler) checkCommitmentOptionCombo(ctx context.Context, cfg *config.ServiceConfig) error { if h.commitmentOpts == nil || cfg.Provider != "aws" || cfg.Term <= 0 || cfg.Payment == "" { return nil } @@ -205,16 +205,16 @@ var serviceConfigFilterKeys = []string{ // A "not found" error means no existing record — cfg is returned unchanged. // Any other DB error is returned to prevent a partial write from clobbering // previously configured filter fields. -func mergeServiceConfig(ctx context.Context, store config.StoreInterface, cfg config.ServiceConfig, body string) (config.ServiceConfig, error) { +func mergeServiceConfig(ctx context.Context, store config.StoreInterface, cfg *config.ServiceConfig, body string) (config.ServiceConfig, error) { existing, err := store.GetServiceConfig(ctx, cfg.Provider, cfg.Service) if err != nil { if strings.Contains(err.Error(), "not found") { - return cfg, nil // new record — no existing fields to preserve + return *cfg, nil // new record — no existing fields to preserve } - return cfg, fmt.Errorf("failed to read existing service config before update: %w", err) + return *cfg, fmt.Errorf("failed to read existing service config before update: %w", err) } if existing == nil { - return cfg, nil + return *cfg, nil } existing.Enabled = cfg.Enabled @@ -224,9 +224,9 @@ func mergeServiceConfig(ctx context.Context, store config.StoreInterface, cfg co present, perr := presentKeys(body, serviceConfigFilterKeys) if perr != nil { - return cfg, perr + return *cfg, perr } - overlayPresentFilterFields(existing, &cfg, present) + overlayPresentFilterFields(existing, cfg, present) return *existing, nil } @@ -334,7 +334,7 @@ func (h *Handler) updateServiceConfig(ctx context.Context, req *events.LambdaFun // recommendation-filter fields only when the body carried them, so a // partial PUT never zeroes a filter (ramp_schedule, or a filter set // out-of-band) the request didn't mean to touch. - merged, mergeErr := mergeServiceConfig(ctx, h.config, cfg, req.Body) + merged, mergeErr := mergeServiceConfig(ctx, h.config, &cfg, req.Body) if mergeErr != nil { return nil, mergeErr } @@ -345,7 +345,7 @@ func (h *Handler) updateServiceConfig(ctx context.Context, req *events.LambdaFun return nil, NewClientError(400, fmt.Sprintf("validation error: %s", err)) } - if err := h.checkCommitmentOptionCombo(ctx, cfg); err != nil { + if err := h.checkCommitmentOptionCombo(ctx, &cfg); err != nil { return nil, err } diff --git a/internal/api/handler_config_test.go b/internal/api/handler_config_test.go index dedb63c4f..b6e6a7a25 100644 --- a/internal/api/handler_config_test.go +++ b/internal/api/handler_config_test.go @@ -629,7 +629,7 @@ func TestMergeServiceConfig_PresenceAwareFilterOverlay(t *testing.T) { } body := `{"enabled":false,"term":1,"payment":"no-upfront","coverage":90,"include_engines":["postgres"],"min_count":7}` - merged, err := mergeServiceConfig(ctx, store, req, body) + merged, err := mergeServiceConfig(ctx, store, &req, body) require.NoError(t, err) assert.False(t, merged.Enabled) assert.Equal(t, 1, merged.Term) @@ -651,7 +651,7 @@ func TestMergeServiceConfig_PresenceAwareFilterOverlay(t *testing.T) { } body := `{"enabled":true,"term":3,"payment":"all-upfront","coverage":80}` - merged, err := mergeServiceConfig(ctx, store, req, body) + merged, err := mergeServiceConfig(ctx, store, &req, body) require.NoError(t, err) assert.Equal(t, 80.0, merged.Coverage) assert.Equal(t, []string{"mysql"}, merged.IncludeEngines, "omitted filter must be preserved") @@ -671,7 +671,7 @@ func TestMergeServiceConfig_PresenceAwareFilterOverlay(t *testing.T) { } body := `{"enabled":true,"term":3,"payment":"all-upfront","coverage":80,"include_engines":[]}` - merged, err := mergeServiceConfig(ctx, store, req, body) + merged, err := mergeServiceConfig(ctx, store, &req, body) require.NoError(t, err) assert.Empty(t, merged.IncludeEngines, "explicit empty list clears the filter") }) 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..3bebfe465 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 { @@ -42,7 +42,7 @@ func (h *Handler) getDashboardSummary(ctx context.Context, req *events.LambdaFun } } - recommendations, err := h.scheduler.ListRecommendations(ctx, config.RecommendationFilter{ + recommendations, err := h.scheduler.ListRecommendations(ctx, &config.RecommendationFilter{ Provider: params["provider"], }) 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. @@ -150,7 +150,7 @@ func (h *Handler) resolveAllowedAccountScope(ctx context.Context, session *Sessi // Non-nil empty slice: a sentinel meaning "scoped to zero accounts" so the // dual-column predicate matches no rows (never falls back to all-accounts). allowedUUIDs := []string{} - for _, a := range accounts { + for _, a := range accounts { //nolint:gocritic if auth.MatchesAccount(allowed, a.ID, a.Name) { allowedUUIDs = append(allowedUUIDs, a.ID) } @@ -173,7 +173,7 @@ func (h *Handler) filterDashboardRecommendations(ctx context.Context, session *S nameByID := h.resolveAccountNamesByID(ctx) filtered := recs[:0] - for _, rec := range recs { + for _, rec := range recs { //nolint:gocritic if rec.CloudAccountID == nil { continue } @@ -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. @@ -218,7 +218,7 @@ func (h *Handler) filterDashboardRecommendations(ctx context.Context, session *S // therefore correct: it projects "how much would I save if I only bought // RIs to cover X% of my instances" against the 100%-coverage baseline // that every provider gives us. Verified by TestSummarizeRecommendationsWithCoverage_100PctContract. -func summarizeRecommendationsWithCoverage( +func summarizeRecommendationsWithCoverage( //nolint:gocritic recs []config.RecommendationRecord, coverageByKey map[string]float64, ) (float64, map[string]ServiceSavings) { @@ -236,7 +236,7 @@ func summarizeRecommendationsWithCoverage( var total float64 byService := make(map[string]ServiceSavings) - for _, rep := range representatives { + for _, rep := range representatives { //nolint:gocritic scaled := rep.scaled total += scaled svc := byService[rep.rec.Service] @@ -277,7 +277,7 @@ type cellRepresentative struct { // variants so the backend by_service rollup and the frontend per-cell grouping // agree. A nil CloudAccountID maps to the empty segment, matching the // frontend's nullish-coalescing of cloud_account_id to an empty string. -func recCellKey(rec config.RecommendationRecord) string { +func recCellKey(rec config.RecommendationRecord) string { //nolint:gocritic account := "" if rec.CloudAccountID != nil { account = *rec.CloudAccountID @@ -303,7 +303,7 @@ func bestVariantPerCell( ) []cellRepresentative { indexByCell := make(map[string]int, len(recs)) reps := make([]cellRepresentative, 0, len(recs)) - for _, rec := range recs { + for _, rec := range recs { //nolint:gocritic scaled := scaledSavings(rec, coverageByKey) key := recCellKey(rec) if idx, ok := indexByCell[key]; ok { @@ -321,7 +321,7 @@ func bestVariantPerCell( // scaledSavings returns rec.Savings * min(max(coverage, 0), 100) / 100 when // a coverage entry exists for the rec's (account, provider, service) triple. // Otherwise returns rec.Savings unchanged. -func scaledSavings(rec config.RecommendationRecord, coverageByKey map[string]float64) float64 { +func scaledSavings(rec config.RecommendationRecord, coverageByKey map[string]float64) float64 { //nolint:gocritic if rec.CloudAccountID == nil || coverageByKey == nil { return rec.Savings } @@ -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 @@ -459,7 +459,7 @@ func (h *Handler) getUpcomingPurchases(ctx context.Context, req *events.LambdaFu // scheduler at instance-create time). func upcomingFromExecution(plan *config.PurchasePlan, exec *config.PurchaseExecution) UpcomingPurchase { var provider, service string - for _, svcCfg := range plan.Services { + for _, svcCfg := range plan.Services { //nolint:gocritic provider = svcCfg.Provider service = svcCfg.Service break @@ -482,12 +482,12 @@ func upcomingFromExecution(plan *config.PurchasePlan, exec *config.PurchaseExecu // 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) { +func (h *Handler) planIntersectsAllowed(ctx context.Context, planID string, allowed []string) (bool, error) { //nolint:unused // retained for scoped-user plan-visibility path 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 { + for _, acct := range accounts { //nolint:gocritic if auth.MatchesAccount(allowed, acct.ID, acct.Name) { return true, nil } @@ -499,7 +499,7 @@ func (h *Handler) planIntersectsAllowed(ctx context.Context, planID string, allo // 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 // absent here; they live on the authenticated GET /api/info/deployment endpoint (#633). -func (h *Handler) getPublicInfo(ctx context.Context, req *events.LambdaFunctionURLRequest) (*PublicInfoResponse, error) { +func (h *Handler) getPublicInfo(ctx context.Context, _ *events.LambdaFunctionURLRequest) (*PublicInfoResponse, error) { //nolint:unparam // error return kept for interface consistency // Check if admin exists adminExists := false if h.auth != nil { @@ -519,7 +519,7 @@ func (h *Handler) getPublicInfo(ctx context.Context, req *events.LambdaFunctionU // Requires at least AuthUser (enforced by the router). The two fields it returns // expose the AWS account ID and the Secrets Manager ARN path — neither should be // reachable without a valid session (#633). -func (h *Handler) getDeploymentInfo(ctx context.Context, _ *events.LambdaFunctionURLRequest) (*DeploymentInfoResponse, error) { +func (h *Handler) getDeploymentInfo(ctx context.Context, _ *events.LambdaFunctionURLRequest) (*DeploymentInfoResponse, error) { //nolint:unparam // error return is always nil; kept for interface consistency // Build the AWS Console deep-link to the Secrets Manager secret. var apiKeySecretURL string if h.secretsARN != "" { @@ -558,7 +558,7 @@ func (h *Handler) getDeploymentInfo(ctx context.Context, _ *events.LambdaFunctio // place). One year is approximated as 365 days — matches the original // dashboard arithmetic verbatim; leap-year precision isn't material for // a multi-year RI/SP/CUD term. -func commitmentExpiry(p config.PurchaseHistoryRecord) time.Time { +func commitmentExpiry(p config.PurchaseHistoryRecord) time.Time { //nolint:gocritic termDuration := time.Duration(p.Term) * 365 * 24 * time.Hour return p.Timestamp.Add(termDuration) } @@ -566,16 +566,16 @@ 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. // // Same predicate shared by the dashboard aggregate and the per-commitment // inventory endpoint. Status values: see PurchaseHistoryRecord.Status doc. -func isActiveCommitment(p config.PurchaseHistoryRecord, now time.Time) bool { +func isActiveCommitment(p config.PurchaseHistoryRecord, now time.Time) bool { //nolint:gocritic // Status is unpersisted (dynamodbav:"-"); DB rows always read back as "". - // Synthesised rows set it to "failed", "expired", "cancelled", "pending", + // Synthesized rows set it to "failed", "expired", "canceled", "pending", // "notified", "approved", "running", or "paused". Only "" and "completed" // represent a commitment that is actually live on the provider. if p.Status != "" && p.Status != "completed" { @@ -591,7 +591,7 @@ func isActiveCommitment(p config.PurchaseHistoryRecord, now time.Time) bool { // same "active" definition. func aggregateActiveCommitmentsPerService(purchases []config.PurchaseHistoryRecord, now time.Time) map[string]float64 { byService := make(map[string]float64) - for _, p := range purchases { + for _, p := range purchases { //nolint:gocritic if !isActiveCommitment(p, now) { continue } @@ -675,7 +675,7 @@ func (h *Handler) calculateCommitmentMetrics(ctx context.Context, accountUUIDs [ committedMonthly += v } - for _, p := range purchases { + for _, p := range purchases { //nolint:gocritic if !isActiveCommitment(p, currentTime) { continue } @@ -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..b528c6048 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{ @@ -207,9 +207,9 @@ func TestSummarizeRecommendationsWithCoverage(t *testing.T) { _ = acctB // referenced only via rec(acctB, …) inside test cases tests := []struct { + coverage map[string]float64 name string recs []config.RecommendationRecord - coverage map[string]float64 wantTotal float64 }{ { @@ -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_docs.go b/internal/api/handler_docs.go index 2018bd397..67ab4b799 100644 --- a/internal/api/handler_docs.go +++ b/internal/api/handler_docs.go @@ -72,7 +72,7 @@ const docsPageCSP = "default-src 'none'; " + // serveDocsUI returns a self-contained HTML page with Swagger UI loaded from CDN. // The response carries a relaxed Content-Security-Policy (docsPageCSP) so the // CDN assets and bootstrap script actually run; without it the page is blank. -func (h *Handler) serveDocsUI(_ context.Context, _ *events.LambdaFunctionURLRequest, _ map[string]string) (any, error) { +func (h *Handler) serveDocsUI() *rawResponse { html := ` @@ -95,38 +95,32 @@ func (h *Handler) serveDocsUI(_ context.Context, _ *events.LambdaFunctionURLRequ contentType: "text/html; charset=utf-8", body: html, csp: docsPageCSP, - }, nil + } } // serveOpenAPISpec returns the raw OpenAPI YAML specification. -func (h *Handler) serveOpenAPISpec(_ context.Context, _ *events.LambdaFunctionURLRequest, _ map[string]string) (any, error) { +func (h *Handler) serveOpenAPISpec() *rawResponse { return &rawResponse{ contentType: "application/yaml; charset=utf-8", body: string(openapiSpec), - }, nil + } } // docsHandler dispatches /docs and /api/docs requests. // Requests ending in /openapi.yaml serve the raw spec; everything else serves the UI. -func (h *Handler) docsHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { +func (h *Handler) docsHandler(_ context.Context, req *events.LambdaFunctionURLRequest, _ map[string]string) (any, error) { path := req.RequestContext.HTTP.Path - var ( - response any - err error - ) + var raw *rawResponse if strings.HasSuffix(path, "/openapi.yaml") { - response, err = h.serveOpenAPISpec(ctx, req, params) + raw = h.serveOpenAPISpec() } else { - response, err = h.serveDocsUI(ctx, req, params) - } - if err != nil || req.RequestContext.HTTP.Method != "HEAD" { - return response, err + raw = h.serveDocsUI() } - if raw, ok := response.(*rawResponse); ok { - // rawResponse currently contains only strings, so a shallow copy is safe. - head := *raw - head.body = "" - return &head, nil + if req.RequestContext.HTTP.Method != "HEAD" { + return raw, nil } - return response, nil + // rawResponse currently contains only strings, so a shallow copy is safe. + head := *raw + head.body = "" + return &head, nil } diff --git a/internal/api/handler_docs_test.go b/internal/api/handler_docs_test.go index 948bcb9d7..3a0b8c13e 100644 --- a/internal/api/handler_docs_test.go +++ b/internal/api/handler_docs_test.go @@ -1,7 +1,6 @@ package api import ( - "context" "regexp" "strings" "testing" @@ -12,10 +11,7 @@ import ( // docsBodyForTest renders the served /docs HTML and returns its body. func docsBodyForTest(t *testing.T) string { t.Helper() - resp, err := (&Handler{}).serveDocsUI(context.Background(), nil, nil) - require.NoError(t, err) - raw, ok := resp.(*rawResponse) - require.True(t, ok, "serveDocsUI should return *rawResponse") + raw := (&Handler{}).serveDocsUI() return raw.body } diff --git a/internal/api/handler_federation.go b/internal/api/handler_federation.go index f349e276c..bbf79de5e 100644 --- a/internal/api/handler_federation.go +++ b/internal/api/handler_federation.go @@ -91,7 +91,7 @@ func gcpOIDCIssuerURI(source, tenantID string) string { } // renderTemplate renders a named template from the embedded iacfiles.Templates FS. -func renderTemplate(tmplPath string, data federationIaCData) (string, error) { +func renderTemplate(tmplPath string, data *federationIaCData) (string, error) { tmplBytes, err := iacfiles.Templates.ReadFile(tmplPath) if err != nil { return "", fmt.Errorf("read template %s: %w", tmplPath, err) @@ -138,16 +138,16 @@ func (h *Handler) getFederationIaC(ctx context.Context, req *events.LambdaFuncti // Reject impossible target/source combinations early — before bundle // construction — so the caller gets a clear 400 instead of a downloadable // bundle that fails at terraform apply with a cryptic IAM error. See #42. - if err = validateFederationTargetSource(target, source); err != nil { + if err := validateFederationTargetSource(target, source); err != nil { return nil, err } apiURL := deriveFederationAPIURL(h.dashboardURL, req.RequestContext.DomainName) data := buildGenericIaCData(target, source, apiURL) - if err = h.populateSourceAccountID(ctx, source, &data); err != nil { + if err := h.populateSourceAccountID(ctx, source, &data); err != nil { return nil, err } - if err = h.validateSourceIdentity(ctx); err != nil { + if err := h.validateSourceIdentity(ctx); err != nil { return nil, err } // ContactEmail is always the email of the authenticated user who requested @@ -157,9 +157,9 @@ func (h *Handler) getFederationIaC(ctx context.Context, req *events.LambdaFuncti data.ContactEmail = session.Email if formatNeedsZip(format) { - return h.buildZipResponse(data, target, source, format, "target") + return h.buildZipResponse(&data, target, source, format, "target") } - return h.renderSingleFile(data, target, source, format) + return h.renderSingleFile(&data, target, source, format) } // deriveFederationAPIURL returns the configured dashboard URL, or derives it @@ -255,14 +255,15 @@ func validateFederationTargetSource(target, source string) error { } // renderSingleFile renders a single-file IaC template (currently only "cli" is supported). -func (h *Handler) renderSingleFile(data federationIaCData, target, source, format string) (*FederationIaCResponse, error) { +func (h *Handler) renderSingleFile(data *federationIaCData, target, source, format string) (*FederationIaCResponse, error) { tmplPath, filename, contentType, err := singleFileSpec(target, source, format, "target") if err != nil { return nil, err } renderData := data if format == "cli" { - renderData = shellEscapeData(data) + escaped := shellEscapeData(data) + renderData = &escaped } content, err := renderTemplate(tmplPath, renderData) if err != nil { @@ -273,8 +274,8 @@ func (h *Handler) renderSingleFile(data federationIaCData, target, source, forma // shellEscapeData returns a copy of data with all fields interpolated into CLI // shell templates escaped for safe use inside double-quoted bash strings. -func shellEscapeData(data federationIaCData) federationIaCData { - d := data +func shellEscapeData(data *federationIaCData) federationIaCData { + d := *data d.AccountName = shellEscape(data.AccountName) d.AccountExternalID = shellEscape(data.AccountExternalID) d.AccountSlug = shellEscape(data.AccountSlug) @@ -305,7 +306,7 @@ func formatNeedsZip(format string) bool { // buildZipResponse is the single encoder for zip-format IaC downloads. It dispatches // to the appropriate builder, which returns raw bytes + filename, then base64-wraps // the result into a FederationIaCResponse. -func (h *Handler) buildZipResponse(data federationIaCData, target, source, format, slug string) (*FederationIaCResponse, error) { +func (h *Handler) buildZipResponse(data *federationIaCData, target, source, format, slug string) (*FederationIaCResponse, error) { var ( zipBytes []byte filename string @@ -384,7 +385,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) @@ -431,22 +432,22 @@ func cliScriptSpec(target, source, slug string) (tmplPath, filename, contentType // // Returns the raw zip bytes and output filename. base64 wrapping happens in // buildZipResponse. -func buildFederationBundle(data federationIaCData, target, source, slug string) ([]byte, string, error) { +func buildFederationBundle(data *federationIaCData, target, source, slug string) (zipBytes []byte, filename string, err error) { var buf bytes.Buffer zw := zip.NewWriter(&buf) - if err := addBundleTerraform(zw, data, target, source, slug); err != nil { + if err = addBundleTerraform(zw, data, target, source, slug); err != nil { //nolint:gocritic return nil, "", err } - if err := addBundleCFN(zw, data, target, source, slug); err != nil { + if err = addBundleCFN(zw, data, target, source, slug); err != nil { //nolint:gocritic return nil, "", err } readme := buildBundleReadme(data, target, source) - if err := addStringToZip(zw, "README.txt", readme); err != nil { + if err = addStringToZip(zw, "README.txt", readme); err != nil { return nil, "", fmt.Errorf("bundle: write readme: %w", err) } - if err := zw.Close(); err != nil { + if err = zw.Close(); err != nil { return nil, "", fmt.Errorf("bundle: finalize zip: %w", err) } @@ -455,16 +456,16 @@ func buildFederationBundle(data federationIaCData, target, source, slug string) // buildCFNZip creates a self-contained CloudFormation zip with template.yaml, // the parameters JSON, and deploy-cfn.sh. Returns raw zip bytes + filename. -func buildCFNZip(data federationIaCData, target, source, slug string) ([]byte, string, error) { +func buildCFNZip(data *federationIaCData, target, source, slug string) (zipBytes []byte, filename string, err error) { if target != "aws" { return nil, "", NewClientError(400, "format=cfn requires target=aws") } var buf bytes.Buffer zw := zip.NewWriter(&buf) - if err := writeCFNFiles(zw, data, source, slug); err != nil { + if err = writeCFNFiles(zw, data, source, slug); err != nil { //nolint:gocritic return nil, "", err } - if err := zw.Close(); err != nil { + if err = zw.Close(); err != nil { return nil, "", fmt.Errorf("cfn: finalize zip: %w", err) } zipName := slug + "-aws-wif-cfn.zip" @@ -492,7 +493,7 @@ func azureTemplateName(format string) string { // identity, then deploy this template to assign the Reservation Purchaser role). // // format must be "bicep" or "arm". target must be "azure". -func buildAzureTemplateZip(format string, data federationIaCData, target, slug string) ([]byte, string, error) { +func buildAzureTemplateZip(format string, data *federationIaCData, target, slug string) (zipBytes []byte, filename string, err error) { if target != "azure" { return nil, "", NewClientError(400, "format="+format+" requires target=azure") } @@ -502,10 +503,10 @@ func buildAzureTemplateZip(format string, data federationIaCData, target, slug s } var buf bytes.Buffer zw := zip.NewWriter(&buf) - if err := writeAzureTemplateFiles(zw, data, format, templateName); err != nil { + if err = writeAzureTemplateFiles(zw, data, format, templateName); err != nil { //nolint:gocritic return nil, "", err } - if err := zw.Close(); err != nil { + if err = zw.Close(); err != nil { return nil, "", fmt.Errorf("azure %s: finalize zip: %w", format, err) } return buf.Bytes(), slug + "-azure-wif-" + format + ".zip", nil @@ -514,7 +515,7 @@ func buildAzureTemplateZip(format string, data federationIaCData, target, slug s // writeAzureTemplateFiles reads the static Azure template, renders the // parameters file, deploy script, and README, then writes all four into the zip. // The deploy script is marked executable (mode 0755) in the zip header. -func writeAzureTemplateFiles(zw *zip.Writer, data federationIaCData, format, templateName string) error { +func writeAzureTemplateFiles(zw *zip.Writer, data *federationIaCData, format, templateName string) error { templateBytes, err := cudlyiac.Modules.ReadFile("federation/azure-target/bicep/" + templateName) if err != nil { return fmt.Errorf("azure %s: read template: %w", format, err) @@ -550,7 +551,7 @@ func writeAzureTemplateFiles(zw *zip.Writer, data federationIaCData, format, tem return nil } -func buildAzureTemplateReadme(data federationIaCData, format string) string { +func buildAzureTemplateReadme(data *federationIaCData, format string) string { var sb strings.Builder sb.WriteString("CUDly Azure Federation — ") if format == "bicep" { @@ -560,7 +561,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") @@ -573,7 +574,7 @@ func buildAzureTemplateReadme(data federationIaCData, format string) string { } // addBundleTerraform adds the Terraform module files and generated .tfvars to the zip. -func addBundleTerraform(zw *zip.Writer, data federationIaCData, target, source, slug string) error { +func addBundleTerraform(zw *zip.Writer, data *federationIaCData, target, source, slug string) error { tfDir := bundleModuleDir(target, source) + "/terraform" if err := addDirToZip(zw, cudlyiac.Modules, tfDir, "terraform"); err != nil { return fmt.Errorf("bundle: terraform dir: %w", err) @@ -591,7 +592,7 @@ func addBundleTerraform(zw *zip.Writer, data federationIaCData, target, source, // addBundleCFN adds CloudFormation files to the zip for AWS target bundles // (both cross-account and WIF). Thin wrapper around writeCFNFiles. -func addBundleCFN(zw *zip.Writer, data federationIaCData, target, source, slug string) error { +func addBundleCFN(zw *zip.Writer, data *federationIaCData, target, source, slug string) error { if target != "aws" { return nil } @@ -604,7 +605,7 @@ func addBundleCFN(zw *zip.Writer, data federationIaCData, target, source, slug s // // Dispatches on source: "aws" → cross-account IAM role template; anything else // → AWS WIF template. -func writeCFNFiles(zw *zip.Writer, data federationIaCData, source, slug string) error { +func writeCFNFiles(zw *zip.Writer, data *federationIaCData, source, slug string) error { cfTemplatePath := "federation/aws-target/cloudformation/template.yaml" deployTmplPath := "templates/aws-cfn-deploy.sh.tmpl" if source == "aws" { @@ -627,8 +628,8 @@ func writeCFNFiles(zw *zip.Writer, data federationIaCData, source, slug string) } // Shell-escape template values before rendering the deploy script to prevent // injection via account names or OIDC URLs containing shell metacharacters. - escapedData := shellEscapeData(data) - deployScript, err := renderTemplate(deployTmplPath, escapedData) + escaped := shellEscapeData(data) + deployScript, err := renderTemplate(deployTmplPath, &escaped) if err != nil { return fmt.Errorf("cfn: %w", err) } @@ -689,13 +690,13 @@ func bundleZipName(target, source, slug string) string { } } -func buildBundleReadme(data federationIaCData, target, source string) string { +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": @@ -754,7 +755,7 @@ type cfParam struct { // // Dispatches on source: "aws" → cross-account params (SourceAccountID, ExternalID); // anything else → AWS WIF params (OIDC values). -func buildCFParamsJSON(data federationIaCData, source string) (string, error) { +func buildCFParamsJSON(data *federationIaCData, source string) (string, error) { var params []cfParam if source == "aws" { params = []cfParam{ diff --git a/internal/api/handler_federation_test.go b/internal/api/handler_federation_test.go index 10896294c..9d9a8127f 100644 --- a/internal/api/handler_federation_test.go +++ b/internal/api/handler_federation_test.go @@ -1035,8 +1035,8 @@ func TestGetFederationIaC_RejectsImpossibleTargetSourceCombo(t *testing.T) { target string source string sourceCloud string - wantStatus int wantErrSub string + wantStatus int }{ // aws-cross-account cases (original #42 coverage) { @@ -1131,9 +1131,9 @@ func TestValidateFederationTargetSource(t *testing.T) { target string source string sourceCloud string - wantErr bool - wantCode int wantSub string + wantCode int + wantErr bool }{ // Self-source combos on the correct cloud: allowed {name: "aws-self-source-on-aws", target: "aws", source: "aws", sourceCloud: "aws", wantErr: false}, diff --git a/internal/api/handler_groups.go b/internal/api/handler_groups.go index eb964f0df..6fdac77d0 100644 --- a/internal/api/handler_groups.go +++ b/internal/api/handler_groups.go @@ -9,9 +9,9 @@ import ( "github.com/aws/aws-lambda-go/events" ) -// Group management handlers +// 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 { @@ -34,7 +34,8 @@ func (h *Handler) createGroup(ctx context.Context, req *events.LambdaFunctionURL // Rate limiting: 30 admin operations per user per minute if h.rateLimiter != nil { - allowed, err := h.rateLimiter.AllowWithUser(ctx, session.UserID, "admin") + var allowed bool + allowed, err = h.rateLimiter.AllowWithUser(ctx, session.UserID, "admin") if err != nil { // Log but continue on rate limiter errors } else if !allowed { @@ -43,7 +44,7 @@ func (h *Handler) createGroup(ctx context.Context, req *events.LambdaFunctionURL } var createReq auth.APICreateGroupRequest - if err := json.Unmarshal([]byte(req.Body), &createReq); err != nil { + if err = json.Unmarshal([]byte(req.Body), &createReq); err != nil { return nil, NewClientError(400, "invalid request body") } @@ -55,7 +56,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 +75,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 +99,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..c1f8f799d 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. @@ -114,7 +114,7 @@ func (h *Handler) getHistory(ctx context.Context, req *events.LambdaFunctionURLR // wave-2) appear in the History view with a Revoke button before the cloud SDK // call fires. Without this entry the row is invisible to the History UI, making // the Revoke button unreachable (issue #290, second-wave CR Finding E). -var historyExecutionStatuses = []string{"pending", "notified", "scheduled", "approved", "running", "paused", "completed", "partially_completed", "failed", "expired", "cancelled"} +var historyExecutionStatuses = []string{"pending", "notified", "scheduled", "approved", "running", "paused", "completed", "partially_completed", "failed", "expired", "cancelled"} //nolint:misspell // DB schema value -- see migration 000001_initial_schema.up.sql // approvalExpiryWindow is how long a pending approval stays actionable // before the History view flips it to "expired". Aligns with the @@ -138,7 +138,7 @@ const approvalExpiryWindow = 7 * 24 * time.Hour // The filter set (issue #701) is applied in Go against the synthetic row: // provider via the recs' collapsed provider, account via CloudAccountID, // date via ScheduledDate. -func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyFilters) ([]config.PurchaseHistoryRecord, []config.PurchaseExecution) { +func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyFilters) ([]config.PurchaseHistoryRecord, []config.PurchaseExecution) { //nolint:gocritic executions, err := h.config.GetExecutionsByStatuses(ctx, historyExecutionStatuses, config.DefaultListLimit) if err != nil { logging.Warnf("history: failed to load non-completed executions: %v", err) @@ -151,11 +151,11 @@ func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyF userEmailCache := h.resolveUserEmails(ctx, executions) out := make([]config.PurchaseHistoryRecord, 0, len(executions)) var staleExecs []config.PurchaseExecution - for _, exec := range executions { + for _, exec := range executions { //nolint:gocritic // 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 @@ -183,7 +183,7 @@ func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyF // approval older than approvalExpiryWindow that should be transitioned to // "expired". Extracted so both fetchExecutionsAsHistory and the async sweep // share one staleness check. -func isStaleExecution(exec config.PurchaseExecution) bool { +func isStaleExecution(exec config.PurchaseExecution) bool { //nolint:gocritic if exec.Status != "pending" && exec.Status != "notified" { return false } @@ -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. @@ -209,7 +209,7 @@ func (h *Handler) expireStaleExecutionsAsync(staleExecs []config.PurchaseExecuti } go func() { ctx := context.Background() - for _, exec := range staleExecs { + for _, exec := range staleExecs { //nolint:gocritic _, err := h.config.TransitionExecutionStatus(ctx, exec.ExecutionID, []string{"pending", "notified"}, "expired", nil) if err != nil { logging.Warnf("history: async expire of execution %s failed: %v", exec.ExecutionID, err) @@ -226,7 +226,7 @@ func (h *Handler) expireStaleExecutionsAsync(staleExecs []config.PurchaseExecuti // creators, not the number of execution rows. func (h *Handler) resolveUserEmails(ctx context.Context, executions []config.PurchaseExecution) map[string]string { seen := make(map[string]struct{}) - for _, exec := range executions { + for _, exec := range executions { //nolint:gocritic if exec.CreatedByUserID != nil && *exec.CreatedByUserID != "" { seen[*exec.CreatedByUserID] = struct{}{} } @@ -247,7 +247,7 @@ func (h *Handler) resolveUserEmails(ctx context.Context, executions []config.Pur // 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 { +func (h *Handler) expireIfStale(ctx context.Context, exec config.PurchaseExecution) config.PurchaseExecution { //nolint:gocritic,unused // hugeParam: exec passed by value (value semantics); function retained for future use if exec.Status != "pending" && exec.Status != "notified" { return exec } @@ -287,7 +287,7 @@ func (h *Handler) resolvePendingApproverEmail(ctx context.Context) string { // message as the status description so the user sees WHY it failed (e.g. // "send failed: Missing domain"). createdByEmail is the resolved email for // the execution's creator (empty when not resolvable). -func executionToHistoryRow(exec config.PurchaseExecution, approver, createdByEmail string) config.PurchaseHistoryRecord { +func executionToHistoryRow(exec config.PurchaseExecution, approver, createdByEmail string) config.PurchaseHistoryRecord { //nolint:gocritic var accountID string if exec.CloudAccountID != nil { accountID = *exec.CloudAccountID @@ -337,7 +337,7 @@ func executionToHistoryRow(exec config.PurchaseExecution, approver, createdByEma // annotateHistoryRowByStatus fills in Approver + StatusDescription on the // row based on exec.Status. Split from executionToHistoryRow to keep the // switch below under the gocyclo threshold. -func annotateHistoryRowByStatus(row *config.PurchaseHistoryRecord, exec config.PurchaseExecution, approver string) { +func annotateHistoryRowByStatus(row *config.PurchaseHistoryRecord, exec config.PurchaseExecution, approver string) { //nolint:gocritic switch exec.Status { case "pending", "notified": row.Approver = approver @@ -345,7 +345,7 @@ func annotateHistoryRowByStatus(row *config.PurchaseHistoryRecord, exec config.P row.StatusDescription = exec.Error case "expired": row.StatusDescription = "approval link expired (not approved within 7 days)" - case "cancelled": + case "cancelled": //nolint:misspell // DB schema value -- see migration 000001_initial_schema.up.sql annotateCancelled(row, exec, approver) default: // In-flight (approved/running/scheduled/paused) and audit-gap @@ -359,7 +359,7 @@ func annotateHistoryRowByStatus(row *config.PurchaseHistoryRecord, exec config.P // for annotateHistoryRowByStatus: approved/running, scheduled, paused, // partially_completed, and completed (audit-gap). Extracted to keep the parent // switch under the cyclomatic-complexity limit. -func annotateInFlightOrAuditGapRow(row *config.PurchaseHistoryRecord, exec config.PurchaseExecution, approver string) { +func annotateInFlightOrAuditGapRow(row *config.PurchaseHistoryRecord, exec config.PurchaseExecution, approver string) { //nolint:gocritic switch exec.Status { case "approved", "running": // In-flight (issue #621): approved/running rows are NOT terminal — @@ -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,30 +410,30 @@ 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; // authoritative accountable party but not necessarily the clicker. // Used on legacy token-only paths (async workers, old email clicks). -func annotateCancelled(row *config.PurchaseHistoryRecord, exec config.PurchaseExecution, approver string) { +func annotateCancelled(row *config.PurchaseHistoryRecord, exec config.PurchaseExecution, approver string) { //nolint:gocritic if exec.CancelledBy != nil && *exec.CancelledBy != "" { row.Approver = *exec.CancelledBy - row.StatusDescription = "cancelled by " + *exec.CancelledBy + row.StatusDescription = "canceled by " + *exec.CancelledBy return } if approver != "" { row.Approver = approver - row.StatusDescription = "cancelled by " + approver + " (via approval link)" + row.StatusDescription = "canceled by " + approver + " (via approval link)" return } - row.StatusDescription = "cancelled via approval link" + row.StatusDescription = "canceled via approval link" } // annotateApproved resolves who approved the execution using the same // two-tier lookup as annotateCancelled — session-authed click wins, // notification inbox fills in otherwise. -func annotateApproved(row *config.PurchaseHistoryRecord, exec config.PurchaseExecution, approver string) { +func annotateApproved(row *config.PurchaseHistoryRecord, exec config.PurchaseExecution, approver string) { //nolint:gocritic if exec.ApprovedBy != nil && *exec.ApprovedBy != "" { row.Approver = *exec.ApprovedBy row.StatusDescription = "approved by " + *exec.ApprovedBy @@ -461,7 +461,7 @@ func annotateApproved(row *config.PurchaseHistoryRecord, exec config.PurchaseExe // "multiple" region, term collapsed to the shared value (0 when recs // disagree), and execution-level cost totals. A single row cannot honestly // show one resource type or term for a heterogeneous basket. -func projectRecommendationFields(row *config.PurchaseHistoryRecord, exec config.PurchaseExecution) { +func projectRecommendationFields(row *config.PurchaseHistoryRecord, exec config.PurchaseExecution) { //nolint:gocritic recs := exec.Recommendations if len(recs) == 1 { r := recs[0] @@ -493,7 +493,7 @@ func collapseRecommendationService(recs []config.RecommendationRecord) string { return "multiple" } s := recs[0].Service - for _, r := range recs[1:] { + for _, r := range recs[1:] { //nolint:gocritic if r.Service != s { return "multiple" } @@ -510,7 +510,7 @@ func collapseRecommendationTerm(recs []config.RecommendationRecord) int { return 0 } t := recs[0].Term - for _, r := range recs[1:] { + for _, r := range recs[1:] { //nolint:gocritic if r.Term != t { return 0 } @@ -526,7 +526,7 @@ func collapseRecommendationProvider(recs []config.RecommendationRecord) string { return "multiple" } p := recs[0].Provider - for _, r := range recs[1:] { + for _, r := range recs[1:] { //nolint:gocritic if r.Provider != p { return "multiple" } @@ -543,7 +543,7 @@ func collapseRecommendationPayment(recs []config.RecommendationRecord) string { return "" } p := recs[0].Payment - for _, r := range recs[1:] { + for _, r := range recs[1:] { //nolint:gocritic if r.Payment != p { return "" } @@ -564,7 +564,7 @@ func collapseRecommendationAccount(recs []config.RecommendationRecord) string { if recs[0].CloudAccountID != nil { first = *recs[0].CloudAccountID } - for _, r := range recs[1:] { + for _, r := range recs[1:] { //nolint:gocritic var cur string if r.CloudAccountID != nil { cur = *r.CloudAccountID @@ -584,7 +584,7 @@ func collapseRecommendationAccount(recs []config.RecommendationRecord) string { func sumRecommendationMonthlyCostPtr(recs []config.RecommendationRecord) *float64 { var total float64 anyNonNil := false - for _, r := range recs { + for _, r := range recs { //nolint:gocritic if r.MonthlyCost != nil { total += *r.MonthlyCost anyNonNil = true @@ -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. @@ -625,24 +625,14 @@ const MaxHistoryDateRangeDays = 366 // times are zero-valued and the SQL/in-memory date predicates are skipped // entirely (so legacy clients that don't send dates keep working). type historyFilters struct { - Provider string - LegacyAccountID string - AccountIDs []string - // ExternalIDsByProvider are the cloud-provider external account numbers - // resolved from AccountIDs (the UUIDs) via Handler.resolveAccountFilterIDs, - // grouped by provider so the external-id match stays provider-scoped (a - // reused external number across providers cannot leak rows). Populated by - // the handler AFTER parse (the resolution needs a DB read), not by - // parseHistoryFilters. Both the SQL path (provider = $p AND account_id = - // 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). - ExternalIDsByProvider map[string][]string - HasDate bool Start time.Time End time.Time + ExternalIDsByProvider map[string][]string + Provider string + LegacyAccountID string + AccountIDs []string Limit int + HasDate bool } // parseHistoryFilters validates and normalises the /api/history query string. @@ -706,7 +696,7 @@ func parseHistoryFilters(params map[string]string) (historyFilters, error) { // fields emit exactly that format, so accepting RFC 3339 here would be a // surface area not exercised in production and a divergence from the // analytics handler's broader format set. -func parseHistoryDateRange(startStr, endStr string) (time.Time, time.Time, bool, error) { +func parseHistoryDateRange(startStr, endStr string) (time.Time, time.Time, bool, error) { //nolint:gocritic if startStr == "" && endStr == "" { return time.Time{}, time.Time{}, false, nil } @@ -739,7 +729,7 @@ func parseHistoryDateRange(startStr, endStr string) (time.Time, time.Time, bool, // string returns the zero value for that side; the caller is responsible for // substituting a sensible default. The end side is rolled forward to end-of- // day so a date input is inclusive of the chosen day. -func parseHistoryDateBounds(startStr, endStr string) (time.Time, time.Time, error) { +func parseHistoryDateBounds(startStr, endStr string) (time.Time, time.Time, error) { //nolint:gocritic const layout = "2006-01-02" var start, end time.Time if startStr != "" { @@ -784,7 +774,7 @@ func parseHistoryDateBounds(startStr, endStr string) (time.Time, time.Time, erro // disagree or have no account) is excluded once account_ids is non-empty. // - Date: matches when ScheduledDate is within [Start, End]. Inclusive // both sides; End is the end-of-day for YYYY-MM-DD inputs. -func (f historyFilters) matchesExecution(exec config.PurchaseExecution) bool { +func (f historyFilters) matchesExecution(exec config.PurchaseExecution) bool { //nolint:gocritic if f.Provider != "" { if !executionHasProvider(exec, f.Provider) { return false @@ -816,14 +806,14 @@ 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. // // Uses the same two-level account resolution as executionToHistoryRow: // exec.CloudAccountID first, then the rec-level fallback, so web bulk-purchase // executions (exec.CloudAccountID == nil) are not silently dropped. -func accountMatchesFilters(exec config.PurchaseExecution, accountIDs []string, externalIDsByProvider map[string][]string) bool { +func accountMatchesFilters(exec config.PurchaseExecution, accountIDs []string, externalIDsByProvider map[string][]string) bool { //nolint:gocritic var accountID string if exec.CloudAccountID != nil { accountID = *exec.CloudAccountID @@ -849,8 +839,8 @@ func accountMatchesFilters(exec config.PurchaseExecution, accountIDs []string, e // executionProvider returns the execution's provider, taken from its first // recommendation (all recs in an execution share a provider in practice). // Returns "" when no recommendation carries one. -func executionProvider(exec config.PurchaseExecution) string { - for _, r := range exec.Recommendations { +func executionProvider(exec config.PurchaseExecution) string { //nolint:gocritic + for _, r := range exec.Recommendations { //nolint:gocritic if r.Provider != "" { return r.Provider } @@ -860,8 +850,8 @@ func executionProvider(exec config.PurchaseExecution) string { // executionHasProvider reports whether any of the execution's // recommendations carries the given provider value. -func executionHasProvider(exec config.PurchaseExecution, provider string) bool { - for _, r := range exec.Recommendations { +func executionHasProvider(exec config.PurchaseExecution, provider string) bool { //nolint:gocritic + for _, r := range exec.Recommendations { //nolint:gocritic if r.Provider == provider { return true } @@ -897,7 +887,7 @@ func stringInSlice(needle string, haystack []string) bool { // column inputs by the caller (getHistory) and arrives here folded into // AccountIDs/ExternalIDsByProvider, so there is no longer a separate // single-column legacy path to coerce identifiers through. -func (h *Handler) fetchPurchaseHistory(ctx context.Context, filters historyFilters) ([]config.PurchaseHistoryRecord, error) { +func (h *Handler) fetchPurchaseHistory(ctx context.Context, filters historyFilters) ([]config.PurchaseHistoryRecord, error) { //nolint:gocritic noFilters := !filters.HasDate && filters.Provider == "" && len(filters.AccountIDs) == 0 && len(filters.ExternalIDsByProvider) == 0 if noFilters { @@ -928,7 +918,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) @@ -983,7 +973,7 @@ func (h *Handler) filterPurchaseHistoryByAllowedAccounts(ctx context.Context, se } nameByID := h.resolveAccountNamesByID(ctx) filtered := make([]config.PurchaseHistoryRecord, 0, len(purchases)) - for _, p := range purchases { + for _, p := range purchases { //nolint:gocritic // Empty AccountID: unattributed ambient/multi-account synthesized row. // Pass through so scoped users see in-flight financial actions that // cannot be pinned to a single account (issue #1032 / #621 regression). @@ -1000,9 +990,9 @@ func (h *Handler) filterPurchaseHistoryByAllowedAccounts(ctx context.Context, se func summarizePurchaseHistory(purchases []config.PurchaseHistoryRecord) HistorySummary { summary := HistorySummary{TotalPurchases: len(purchases)} - for _, p := range purchases { + for _, p := range purchases { //nolint:gocritic // Non-completed rows count toward TotalPurchases and their specific - // bucket (pending / in-progress / failed / expired / cancelled) but + // bucket (pending / in-progress / failed / expired / canceled) but // are excluded from the dollar totals — the money hasn't been committed // for any of those states. "completed" and unset (legacy DB rows that // pre-date the status field) both count as completed. @@ -1022,21 +1012,21 @@ func summarizePurchaseHistory(purchases []config.PurchaseHistoryRecord) HistoryS case "expired": summary.TotalExpired++ continue - case "cancelled": - // A cancelled purchase represents zero committed spend and zero + case "cancelled": //nolint:misspell // DB schema value -- see migration 000001_initial_schema.up.sql + // A canceled purchase represents zero committed spend and zero // realized savings (issue #736). Exclude from all dollar KPIs and - // from TotalCompleted — the money was never committed. + // from TotalCompleted -- the money was never committed. 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_history_test.go b/internal/api/handler_history_test.go index 253fbca4b..46942a211 100644 --- a/internal/api/handler_history_test.go +++ b/internal/api/handler_history_test.go @@ -781,11 +781,11 @@ func TestHandler_getHistory_AuditGapCompletedVisible(t *testing.T) { assert.Equal(t, "gap-1", row.PurchaseID) assert.Equal(t, "completed", row.Status) assert.Contains(t, row.StatusDescription, "history record could not be saved", "the audit gap must be surfaced to the user") - assert.True(t, row.IsAuditGap, "synthesised audit-gap row must carry the explicit IsAuditGap marker") + assert.True(t, row.IsAuditGap, "synthesized audit-gap row must carry the explicit IsAuditGap marker") assert.Equal(t, 1, resp.Summary.TotalCompleted, "money was committed, so it counts as completed") - // Double-count guard: the synthesised audit-gap row is an audit flag, not a + // Double-count guard: the synthesized audit-gap row is an audit flag, not a // money source. A partially-saved multi-rec execution can have BOTH some - // purchase_history rows AND this synthesised row, so its execution-level + // purchase_history rows AND this synthesized row, so its execution-level // dollars must NOT be added to the committed totals (those come from the // purchase_history rows that actually saved). assert.Equal(t, 0.0, resp.Summary.TotalUpfront, "audit-gap row must not contribute execution-level dollars (double-count risk)") @@ -881,9 +881,9 @@ func TestHandler_getHistory_CompletedDBRowWithDescriptionStillCounts(t *testing. } // TestHandler_getHistory_FilterParams is the issue #701 primary regression -// guard. /api/history must honour the provider / account_ids / start / end -// query params the frontend sends — both on the SQL path (purchase_history -// rows in fetchPurchaseHistory) and on the in-memory path (synthesised +// guard. /api/history must honor the provider / account_ids / start / end +// query params the frontend sends -- both on the SQL path (purchase_history +// rows in fetchPurchaseHistory) and on the in-memory path (synthesized // execution rows in fetchExecutionsAsHistory). The filters were previously // dropped silently; visible filter affordances were no-ops. // @@ -1061,12 +1061,7 @@ func TestHandler_getHistory_FilterParams(t *testing.T) { outOfRangeDay := now.AddDate(0, 0, -10) // older than the window AND outside the requested range mockStore.On("GetPurchaseHistoryFiltered", ctx, - mock.MatchedBy(func(f config.PurchaseHistoryFilter) bool { - return f.Provider == "" && len(f.AccountIDs) == 0 && len(f.ExternalIDsByProvider) == 0 && - f.Start != nil && f.Start.Format("2006-01-02") == startDay && - f.End != nil && f.End.Format("2006-01-02") == endDay && - f.Limit == config.DefaultListLimit - }), + mock.MatchedBy(dateRangeOnlyFilterMatcher(startDay, endDay)), ).Return([]config.PurchaseHistoryRecord{{AccountID: "in-range", PurchaseID: "p-in-range"}}, nil).Once() execs := []config.PurchaseExecution{ @@ -1100,10 +1095,7 @@ func TestHandler_getHistory_FilterParams(t *testing.T) { uuidA := "55555555-5555-5555-5555-555555555555" mockStore.On("GetPurchaseHistoryFiltered", ctx, - mock.MatchedBy(func(f config.PurchaseHistoryFilter) bool { - return f.Provider == "aws" && len(f.AccountIDs) == 1 && f.AccountIDs[0] == uuidA && - f.Start != nil && f.End != nil && f.Limit == config.DefaultListLimit - }), + mock.MatchedBy(providerAccountDateFilterMatcher(uuidA)), ).Return([]config.PurchaseHistoryRecord{{AccountID: "match", PurchaseID: "p-match"}}, nil).Once() now := time.Now().UTC() @@ -1157,6 +1149,28 @@ func TestHandler_getHistory_FilterParams(t *testing.T) { }) } +// dateRangeOnlyFilterMatcher returns a MatchedBy predicate asserting the SQL +// filter carries only the given start/end days (no provider/account predicates) +// plus the default limit. +func dateRangeOnlyFilterMatcher(startDay, endDay string) func(config.PurchaseHistoryFilter) bool { + return func(f config.PurchaseHistoryFilter) bool { + return f.Provider == "" && len(f.AccountIDs) == 0 && len(f.ExternalIDsByProvider) == 0 && + f.Start != nil && f.Start.Format("2006-01-02") == startDay && + f.End != nil && f.End.Format("2006-01-02") == endDay && + f.Limit == config.DefaultListLimit + } +} + +// providerAccountDateFilterMatcher returns a MatchedBy predicate asserting the +// SQL filter combines provider=aws, the single account UUID, a non-nil date +// range, and the default limit. +func providerAccountDateFilterMatcher(uuidA string) func(config.PurchaseHistoryFilter) bool { + return func(f config.PurchaseHistoryFilter) bool { + return f.Provider == "aws" && len(f.AccountIDs) == 1 && f.AccountIDs[0] == uuidA && + f.Start != nil && f.End != nil && f.Limit == config.DefaultListLimit + } +} + // TestHandler_getHistory_FilterValidation covers the 400-on-malformed-input // paths for issue #701: an invalid provider, a non-UUID account_id, a date // that doesn't parse as YYYY-MM-DD, an inverted range, and a range that @@ -1164,10 +1178,10 @@ func TestHandler_getHistory_FilterParams(t *testing.T) { // #414). Each must return a 400 ClientError; none must reach the store. func TestHandler_getHistory_FilterValidation(t *testing.T) { cases := []struct { - name string params map[string]string - wantCode int + name string wantContain string + wantCode int }{ { name: "invalid provider", @@ -1651,7 +1665,7 @@ func TestMatchesExecution_ExternalIDOnlyPending(t *testing.T) { // TestHandler_getHistory_CompletedExecutionNotDuplicated guards the dedup path. // The store loads "completed" executions now (so audit-gap rows can surface), // but a NORMAL completed execution (Error=="") is already represented by its -// purchase_history rows and must NOT be synthesised a second time. The History +// purchase_history rows and must NOT be synthesized a second time. The History // list must contain exactly one row for that purchase. func TestHandler_getHistory_CompletedExecutionNotDuplicated(t *testing.T) { ctx := context.Background() @@ -1664,7 +1678,7 @@ func TestHandler_getHistory_CompletedExecutionNotDuplicated(t *testing.T) { // execution (exec-clean-1) and the purchase_history row (ri-commitment-1) // are separate records with different IDs; the test does not assert they // match. It asserts that ALL clean completed executions are skipped (not - // synthesised) because they are assumed already represented by their + // synthesized) because they are assumed already represented by their // purchase_history rows, so the surviving row is the purchase_history one. cleanCompletedExec := []config.PurchaseExecution{ { @@ -1694,12 +1708,12 @@ func TestHandler_getHistory_CompletedExecutionNotDuplicated(t *testing.T) { } // TestSummarizePurchaseHistory_CancelledExcludedFromKPIs is the regression -// test for issue #736. Cancelling a pending purchase must not add its upfront +// test for issue #736. Canceling a pending purchase must not add its upfront // cost or savings to the KPI totals. Specifically: // - TotalUpfront, TotalMonthlySavings, TotalAnnualSavings must reflect only // the approved/completed rows. -// - TotalCompleted must not include cancelled rows. -// - A pre-existing cancelled row in the dataset must also be excluded. +// - TotalCompleted must not include canceled rows. +// - A pre-existing canceled row in the dataset must also be excluded. func TestSummarizePurchaseHistory_CancelledExcludedFromKPIs(t *testing.T) { purchases := []config.PurchaseHistoryRecord{ // Three completed rows that should contribute to the KPI totals. @@ -1708,29 +1722,29 @@ func TestSummarizePurchaseHistory_CancelledExcludedFromKPIs(t *testing.T) { {Status: "", UpfrontCost: 50.0, EstimatedSavings: 5.0}, // legacy row, no status // One pending row that should be counted as pending, not completed. {Status: "pending", UpfrontCost: 999.0, EstimatedSavings: 99.0}, - // Two cancelled rows — the regression case from issue #736. + // Two canceled rows -- the regression case from issue #736. // Neither must appear in the dollar KPIs or TotalCompleted. - {Status: "cancelled", UpfrontCost: 500.0, EstimatedSavings: 50.0}, - {Status: "cancelled", UpfrontCost: 750.0, EstimatedSavings: 75.0}, + {Status: "cancelled", UpfrontCost: 500.0, EstimatedSavings: 50.0}, //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql + {Status: "cancelled", UpfrontCost: 750.0, EstimatedSavings: 75.0}, //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql } summary := summarizePurchaseHistory(purchases) assert.Equal(t, 6, summary.TotalPurchases, "all rows count toward TotalPurchases") - assert.Equal(t, 3, summary.TotalCompleted, "cancelled rows must not inflate TotalCompleted") + assert.Equal(t, 3, summary.TotalCompleted, "canceled rows must not inflate TotalCompleted") assert.Equal(t, 1, summary.TotalPending) assert.InDelta(t, 350.0, summary.TotalUpfront, 0.001, - "cancelled upfront cost must not be included in TotalUpfront (issue #736)") + "canceled upfront cost must not be included in TotalUpfront (issue #736)") assert.InDelta(t, 35.0, summary.TotalMonthlySavings, 0.001, - "cancelled savings must not be included in TotalMonthlySavings (issue #736)") + "canceled savings must not be included in TotalMonthlySavings (issue #736)") assert.InDelta(t, 420.0, summary.TotalAnnualSavings, 0.001, - "TotalAnnualSavings = TotalMonthlySavings * 12 and must exclude cancelled (issue #736)") + "TotalAnnualSavings = TotalMonthlySavings * 12 and must exclude canceled (issue #736)") } // TestSummarizePurchaseHistory_CancelPendingDoesNotChangeKPIs mirrors the // QA reproduction scenario from issue #736: start with N approved purchases, -// observe KPI totals, then add a cancelled execution and assert the totals +// observe KPI totals, then add a canceled execution and assert the totals // are unchanged. // TestHandler_getHistory_LimitParsing is the 01-M1 regression guard. // Prior to the fix, parseHistoryFilters used fmt.Sscanf to parse the limit @@ -1821,20 +1835,20 @@ func TestSummarizePurchaseHistory_CancelPendingDoesNotChangeKPIs(t *testing.T) { } before := summarizePurchaseHistory(baseline) - // After: same rows plus one cancelled execution (the pending that got cancelled). - withCancelled := append(baseline, config.PurchaseHistoryRecord{ //nolint:gocritic - Status: "cancelled", + // After: same rows plus one canceled execution (the pending that got canceled). + withCancelled := append(baseline, config.PurchaseHistoryRecord{ + Status: "cancelled", //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql UpfrontCost: 999.0, EstimatedSavings: 99.0, }) after := summarizePurchaseHistory(withCancelled) assert.Equal(t, before.TotalUpfront, after.TotalUpfront, - "cancelling a pending purchase must not change TotalUpfront (issue #736)") + "canceling a pending purchase must not change TotalUpfront (issue #736)") assert.Equal(t, before.TotalMonthlySavings, after.TotalMonthlySavings, - "cancelling a pending purchase must not change TotalMonthlySavings (issue #736)") + "canceling a pending purchase must not change TotalMonthlySavings (issue #736)") assert.Equal(t, before.TotalAnnualSavings, after.TotalAnnualSavings, - "cancelling a pending purchase must not change TotalAnnualSavings (issue #736)") + "canceling a pending purchase must not change TotalAnnualSavings (issue #736)") assert.Equal(t, before.TotalCompleted, after.TotalCompleted, - "cancelling a pending purchase must not change TotalCompleted (issue #736)") + "canceling a pending purchase must not change TotalCompleted (issue #736)") } diff --git a/internal/api/handler_inventory.go b/internal/api/handler_inventory.go index 8d1f99878..679ee0c14 100644 --- a/internal/api/handler_inventory.go +++ b/internal/api/handler_inventory.go @@ -52,7 +52,7 @@ func (h *Handler) listActiveCommitments(ctx context.Context, req *events.LambdaF now := time.Now() commitments := make([]InventoryCommitment, 0, len(purchases)) - for _, p := range purchases { + for _, p := range purchases { //nolint:gocritic if !isActiveCommitment(p, now) { continue } @@ -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 @@ -108,7 +108,7 @@ func (h *Handler) fetchCommitmentRecords(ctx context.Context, params map[string] // lowercase in the store (aws, azure, gcp). if provider := params["provider"]; provider != "" { filtered := rows[:0] - for _, r := range rows { + for _, r := range rows { //nolint:gocritic if r.Provider == provider { filtered = append(filtered, r) } @@ -123,7 +123,7 @@ func (h *Handler) fetchCommitmentRecords(ctx context.Context, params map[string] // response-layer InventoryCommitment. The ID is namespaced by account so // the JSON payload is globally unique without a DB schema change — // purchase_id alone is only unique within an account. -func buildInventoryCommitment(p config.PurchaseHistoryRecord, accountName string) InventoryCommitment { +func buildInventoryCommitment(p config.PurchaseHistoryRecord, accountName string) InventoryCommitment { //nolint:gocritic return InventoryCommitment{ ID: p.AccountID + ":" + p.PurchaseID, Provider: p.Provider, @@ -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,12 +179,12 @@ 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). coveredByKey := make(map[string]float64) - for _, p := range purchases { + for _, p := range purchases { //nolint:gocritic if !isActiveCommitment(p, now) { continue } @@ -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)) @@ -227,8 +227,8 @@ func (h *Handler) getCoverageBreakdown(ctx context.Context, req *events.LambdaFu // // Extracted from getCoverageBreakdown to keep that function under the // gocyclo budget after PR #881's extraction. -func buildCoverageRecFilter(params map[string]string) config.RecommendationFilter { - filter := config.RecommendationFilter{} +func buildCoverageRecFilter(params map[string]string) *config.RecommendationFilter { + filter := &config.RecommendationFilter{} if accountID := params["account_id"]; accountID != "" { filter.AccountIDs = []string{accountID} } @@ -241,7 +241,7 @@ func buildCoverageRecFilter(params map[string]string) config.RecommendationFilte // cyclomatic limit. func aggregateOnDemandByKey(recs []config.RecommendationRecord, providerFilter string) map[string]float64 { out := make(map[string]float64) - for _, rec := range recs { + for _, rec := range recs { //nolint:gocritic if providerFilter != "" && rec.Provider != providerFilter { continue } @@ -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,13 +265,13 @@ 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 { +func commitmentCoveredMonthly(p config.PurchaseHistoryRecord) float64 { //nolint:gocritic var covered float64 if p.MonthlyCost != nil { covered += *p.MonthlyCost diff --git a/internal/api/handler_inventory_test.go b/internal/api/handler_inventory_test.go index 68d9e2764..df8a88433 100644 --- a/internal/api/handler_inventory_test.go +++ b/internal/api/handler_inventory_test.go @@ -8,6 +8,7 @@ import ( "github.com/LeanerCloud/CUDly/internal/config" "github.com/aws/aws-lambda-go/events" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -55,7 +56,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() @@ -447,7 +448,7 @@ func TestHandler_getCoverageBreakdown_Integration(t *testing.T) { mockStore.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { return []config.CloudAccount{}, nil } - mockScheduler.On("ListRecommendations", ctx, config.RecommendationFilter{}).Return(recs, nil) + mockScheduler.On("ListRecommendations", ctx, mock.Anything).Return(recs, nil) mockAuth, req := adminInventoryReq(ctx) handler := &Handler{auth: mockAuth, config: mockStore, scheduler: mockScheduler} @@ -541,7 +542,7 @@ func TestHandler_getCoverageBreakdown_ProviderAndAccountChip(t *testing.T) { mockScheduler.On( "ListRecommendations", ctx, - config.RecommendationFilter{AccountIDs: []string{"acc-1"}}, + &config.RecommendationFilter{AccountIDs: []string{"acc-1"}}, ).Return(acc1Recs, nil) mockAuth, req := adminInventoryReq(ctx) @@ -593,7 +594,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 +603,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 +619,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,8 +650,8 @@ 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. - mockScheduler.On("ListRecommendations", ctx, config.RecommendationFilter{}).Return([]config.RecommendationRecord{}, nil) + // amortized upfront makes Azure 100% covered for compute. + mockScheduler.On("ListRecommendations", ctx, mock.Anything).Return([]config.RecommendationRecord{}, nil) mockAuth, req := adminInventoryReq(ctx) handler := &Handler{auth: mockAuth, config: mockStore, scheduler: mockScheduler} @@ -677,9 +678,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..c94e458d5 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) { @@ -838,7 +838,7 @@ func TestPerAccountPerms_CoverageBreakdown_RecsFilteredByAllowedAccounts(t *test } mockSched := new(MockScheduler) - mockSched.On("ListRecommendations", ctx, config.RecommendationFilter{}). + mockSched.On("ListRecommendations", ctx, mock.Anything). Return([]config.RecommendationRecord{recA, recB}, nil) mockStore := new(MockConfigStore) @@ -909,7 +909,7 @@ func TestPerAccountPerms_CoverageBreakdown_AdminSeesAll(t *testing.T) { } mockSched := new(MockScheduler) - mockSched.On("ListRecommendations", ctx, config.RecommendationFilter{}). + mockSched.On("ListRecommendations", ctx, mock.Anything). Return([]config.RecommendationRecord{recA, recB}, nil) mockStore := new(MockConfigStore) diff --git a/internal/api/handler_plans.go b/internal/api/handler_plans.go index 43a659303..95851a68d 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. @@ -166,7 +166,8 @@ func (h *Handler) getPlan(ctx context.Context, req *events.LambdaFunctionURLRequ return nil, err } - if err := h.requirePlanAccess(ctx, session, planID); err != nil { + err = h.requirePlanAccess(ctx, session, planID) + if err != nil { return nil, err } @@ -194,12 +195,14 @@ func (h *Handler) updatePlan(ctx context.Context, httpReq *events.LambdaFunction return nil, err } - if err := h.requirePlanAccess(ctx, session, planID); err != nil { + err = h.requirePlanAccess(ctx, session, planID) + if err != nil { return nil, err } var req PlanRequest - if err := json.Unmarshal([]byte(httpReq.Body), &req); err != nil { + err = json.Unmarshal([]byte(httpReq.Body), &req) + if err != nil { return nil, NewClientError(400, "invalid request body") } @@ -271,7 +274,8 @@ func (h *Handler) createPlannedPurchases(ctx context.Context, httpReq *events.La return nil, err } - if err := h.requirePlanAccess(ctx, session, planID); err != nil { + err = h.requirePlanAccess(ctx, session, planID) + if err != nil { return nil, err } @@ -297,7 +301,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 +327,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 +377,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 +427,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"` @@ -434,7 +438,7 @@ type PatchPlanRequest struct { // applyPatchFields applies validated partial-update fields to a plan. func applyPatchFields(plan *config.PurchasePlan, req PatchPlanRequest) error { if req.Name != nil { - if len(*req.Name) == 0 { + if *req.Name == "" { return NewClientError(400, "plan name cannot be empty") } if len(*req.Name) > 255 { @@ -457,7 +461,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 @@ -468,12 +472,14 @@ func (h *Handler) patchPlan(ctx context.Context, httpReq *events.LambdaFunctionU return nil, err } - if err := h.requirePlanAccess(ctx, session, planID); err != nil { + err = h.requirePlanAccess(ctx, session, planID) + if err != nil { return nil, err } var req PatchPlanRequest - if err := json.Unmarshal([]byte(httpReq.Body), &req); err != nil { + err = json.Unmarshal([]byte(httpReq.Body), &req) + if err != nil { return nil, NewClientError(400, "invalid request body") } diff --git a/internal/api/handler_plans_test.go b/internal/api/handler_plans_test.go index 8cf2366f8..653972175 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. // @@ -780,9 +780,9 @@ func TestCalculateNextExecutionDate(t *testing.T) { now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) tests := []struct { - name string - plan *config.PurchasePlan expected time.Time + plan *config.PurchasePlan + name string }{ { name: "immediate type", diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index 09a11a762..f6d10f671 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -38,26 +38,26 @@ func buildSuppressions(recs []config.RecommendationRecord, executionID string, c } agg := map[key]int{} order := []key{} - for _, rec := range recs { - if rec.Count <= 0 { + for i := range recs { + if recs[i].Count <= 0 { continue } accountID := "" - if rec.CloudAccountID != nil { - accountID = *rec.CloudAccountID + if recs[i].CloudAccountID != nil { + accountID = *recs[i].CloudAccountID } k := key{ accountID: accountID, - provider: rec.Provider, - service: rec.Service, - region: rec.Region, - resourceType: rec.ResourceType, - engine: rec.Engine, + provider: recs[i].Provider, + service: recs[i].Service, + region: recs[i].Region, + resourceType: recs[i].ResourceType, + engine: recs[i].Engine, } if _, seen := agg[k]; !seen { order = append(order, k) } - agg[k] += rec.Count + agg[k] += recs[i].Count } out := make([]config.PurchaseSuppression, 0, len(order)) @@ -131,19 +131,19 @@ func (h *Handler) getPlannedPurchases(ctx context.Context, req *events.LambdaFun allowedPlan := make(map[string]bool) var purchases []PlannedPurchase - for _, exec := range executions { - plan := planMap[exec.PlanID] + for i := range executions { + plan := planMap[executions[i].PlanID] if plan == nil { continue } - ok, err := h.isPlanAllowedCached(ctx, session, exec.PlanID, allowedPlan) + ok, err := h.isPlanAllowedCached(ctx, session, executions[i].PlanID, allowedPlan) if err != nil { return nil, err } if !ok { continue } - purchases = append(purchases, buildPlannedPurchase(plan, &exec)) + purchases = append(purchases, buildPlannedPurchase(plan, &executions[i])) } return &PlannedPurchasesResponse{ @@ -153,7 +153,7 @@ func (h *Handler) getPlannedPurchases(ctx context.Context, req *events.LambdaFun // isPlanAllowedCached resolves and memoises whether the session may see the given plan. // NotFound errors are treated as "not allowed" (not an error) so missing plans don't -// surface as 500s, mirroring the previous inline behaviour. +// surface as 500s, mirroring the previous inline behavior. func (h *Handler) isPlanAllowedCached(ctx context.Context, session *Session, planID string, cache map[string]bool) (bool, error) { if ok, cached := cache[planID]; cached { return ok, nil @@ -168,15 +168,15 @@ func (h *Handler) isPlanAllowedCached(ctx context.Context, session *Session, pla } // buildPlannedPurchase converts a (plan, execution) pair into the API-facing PlannedPurchase. -// Provider/service/term/payment are taken from the first service entry, matching prior behaviour. +// Provider/service/term/payment are taken from the first service entry, matching prior behavior. func buildPlannedPurchase(plan *config.PurchasePlan, exec *config.PurchaseExecution) PlannedPurchase { var provider, service, payment string var term int - for _, svcCfg := range plan.Services { - provider = svcCfg.Provider - service = svcCfg.Service - term = svcCfg.Term - payment = svcCfg.Payment + for k := range plan.Services { + provider = plan.Services[k].Provider + service = plan.Services[k].Service + term = plan.Services[k].Term + payment = plan.Services[k].Payment break } return PlannedPurchase{ @@ -220,7 +220,7 @@ func buildPlannedPurchase(plan *config.PurchasePlan, exec *config.PurchaseExecut // feedback_fail_closed_middleware.md. // // Only fetches the execution on the creator-match path: admin and update-any -// callers are authorised without a store round-trip (and admin sessions have +// callers are authorized without a store round-trip (and admin sessions have // unrestricted access, so requireExecutionAccess skipped the fetch too). func (h *Handler) authorizeExecutionManagement(ctx context.Context, session *Session, executionID string) error { if session.UserID == apiKeyAdminUserID { @@ -341,44 +341,51 @@ func (h *Handler) deletePlannedPurchase(ctx context.Context, req *events.LambdaF if err != nil { return nil, err } - if err := h.requireExecutionAccess(ctx, session, executionID); err != nil { + if err = h.requireExecutionAccess(ctx, session, executionID); err != nil { //nolint:gocritic return nil, err } - if err := h.authorizeExecutionManagement(ctx, session, executionID); err != nil { + if err = h.authorizeExecutionManagement(ctx, session, executionID); err != nil { //nolint:gocritic return nil, err } - cancelled, err := h.cancelOrRecoverExecution(ctx, executionID, resolveCreatorUserID(session)) + exec, err := h.cancelOrRecoverExecution(ctx, executionID, resolveCreatorUserID(session)) if err != nil { return nil, err } // Set the parent plan's enabled flag to false so the Plans page toggle // reflects the disable action immediately. Issue #774: previously the - // execution was cancelled but plan.enabled was left true, causing + // execution was canceled but plan.enabled was left true, causing // inconsistent state between the Scheduled Purchases and Plans views. - if cancelled.PlanID != "" { - if err := h.disablePlan(ctx, cancelled.PlanID); err != nil { + if exec.PlanID != "" { + if err := h.disablePlan(ctx, exec.PlanID); err != nil { return nil, err } } - return &StatusResponse{Status: "cancelled"}, nil + // Report the status the store actually persisted rather than a hardcoded + // literal, so the response can never drift from the DB value. + return &StatusResponse{Status: exec.Status}, nil } -// cancelOrRecoverExecution transitions the execution to "cancelled" if it is -// still in {pending, paused}. If a prior attempt already cancelled it +// cancelOrRecoverExecution transitions the execution to "canceled" if it is +// still in {pending, paused}. If a prior attempt already canceled it // (ErrExecutionNotInExpectedStatus), it fetches the row instead so the caller // can still drive the plan-disable side-effect, keeping the operation // idempotent across retries. // actor is the UUID of the user initiating the cancel (nil for system-initiated paths). func (h *Handler) cancelOrRecoverExecution(ctx context.Context, executionID string, actor *string) (*config.PurchaseExecution, error) { - cancelled, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "paused"}, "cancelled", actor) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql + result, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "paused"}, "cancelled", actor) if err == nil { - return cancelled, nil + return result, nil } if !errors.Is(err, config.ErrExecutionNotInExpectedStatus) { - return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be cancelled: %v", executionID, err)) + // A non-conflict failure (e.g. a transient DB error) is an unexpected + // server-side fault, not a 409 status conflict. Return a plain error so + // handleRequestError maps it to 500 with a generic body and the raw + // backend text stays in the server log instead of the client response. + return nil, fmt.Errorf("failed to cancel execution %s: %w", executionID, err) } existing, getErr := h.config.GetExecutionByID(ctx, executionID) if errors.Is(getErr, config.ErrNotFound) { @@ -387,9 +394,13 @@ func (h *Handler) cancelOrRecoverExecution(ctx context.Context, executionID stri if getErr != nil { return nil, fmt.Errorf("disable plan: failed to get execution %s after conflict: %w", executionID, getErr) } + if existing == nil { + return nil, NewClientError(404, fmt.Sprintf("execution %s not found", executionID)) + } + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql if existing.Status != "cancelled" { return nil, NewClientError(409, fmt.Sprintf( - "execution %s cannot be cancelled (status=%s)", + "execution %s cannot be canceled (status=%s)", executionID, existing.Status)) } return existing, nil @@ -447,7 +458,7 @@ func (h *Handler) loadApproveExecution(ctx context.Context, execID string) (*con return execution, nil } -// Purchase action handlers +// Purchase action handlers. func (h *Handler) approvePurchase(ctx context.Context, req *events.LambdaFunctionURLRequest, execID, token string) (any, error) { if err := validateUUID(execID); err != nil { return nil, err @@ -634,7 +645,7 @@ func (h *Handler) authorizeSessionApprove(ctx context.Context, session *Session, // The scheduler picks up rows with status=scheduled and // scheduled_execution_at <= NOW() and fires the actual SDK call. // Revoking a status=scheduled execution (via the revoke handler or the -// History "Revoke" button) transitions it to "cancelled" at zero cloud cost. +// History "Revoke" button) transitions it to "canceled" at zero cloud cost. func (h *Handler) approveWithDelay(ctx context.Context, execution *config.PurchaseExecution, delay time.Duration, actor string, transitionedBy *string) (any, error) { updated, err := h.scheduleApprovedExecution(ctx, execution, delay, actor, transitionedBy) if err != nil { @@ -671,9 +682,9 @@ func (h *Handler) approveWithDelay(ctx context.Context, execution *config.Purcha // // The atomic CAS (TransitionExecutionStatus WHERE status IN (pending,notified)) // prevents a silent revoke loss: if a concurrent Cancel flipped the row to -// "cancelled" between the caller's SELECT and this write, TransitionExecutionStatus +// "canceled" between the caller's SELECT and this write, TransitionExecutionStatus // returns ErrExecutionNotInExpectedStatus and we surface a 409 instead of -// blindly overwriting the cancelled state. +// blindly overwriting the canceled state. func (h *Handler) scheduleApprovedExecution(ctx context.Context, execution *config.PurchaseExecution, delay time.Duration, actor string, transitionedBy *string) (*config.PurchaseExecution, error) { updated, err := h.config.TransitionExecutionStatus(ctx, execution.ExecutionID, []string{"pending", "notified"}, "scheduled", transitionedBy) if err != nil { @@ -710,16 +721,16 @@ func buildScheduledEmailData(dashboardURL string, execution *config.PurchaseExec } // Build a minimal summaries slice from the stored recommendations. - var summaries []email.RecommendationSummary - for _, r := range execution.Recommendations { + summaries := make([]email.RecommendationSummary, 0, len(execution.Recommendations)) + for i := range execution.Recommendations { summaries = append(summaries, email.RecommendationSummary{ - Service: r.Service, - ResourceType: r.ResourceType, - Region: r.Region, - Count: r.Count, - Term: r.Term, - Payment: r.Payment, - UpfrontCost: r.UpfrontCost, + Service: execution.Recommendations[i].Service, + ResourceType: execution.Recommendations[i].ResourceType, + Region: execution.Recommendations[i].Region, + Count: execution.Recommendations[i].Count, + Term: execution.Recommendations[i].Term, + Payment: execution.Recommendations[i].Payment, + UpfrontCost: execution.Recommendations[i].UpfrontCost, }) } @@ -768,7 +779,7 @@ func (h *Handler) sendPurchaseScheduledEmail(ctx context.Context, execution *con data := buildScheduledEmailData(h.dashboardURL, execution, globalCfg, actor) - if sendErr := h.emailNotifier.SendPurchaseScheduledNotification(ctx, data); sendErr != nil { + if sendErr := h.emailNotifier.SendPurchaseScheduledNotification(ctx, &data); sendErr != nil { logging.Errorf("sendPurchaseScheduledEmail: send failed for execution %s: %v", execution.ExecutionID, sendErr) } } @@ -891,6 +902,7 @@ func (h *Handler) cancelPurchase(ctx context.Context, req *events.LambdaFunction if err := h.purchase.CancelExecution(ctx, execID, token, actor); err != nil { return nil, err } + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql return map[string]string{"status": "cancelled"}, nil } @@ -900,17 +912,17 @@ func (h *Handler) cancelPurchase(ctx context.Context, req *events.LambdaFunction // cancelPurchaseViaSession is the session-authed branch of cancelPurchase. // Enforces the cancel-any/cancel-own RBAC matrix, validates the execution // is in a cancellable state (pending|notified), atomically flips the row -// to "cancelled" AND drops its purchase_suppressions in the same -// transaction, and stamps session.Email onto CancelledBy. The History -// UI's annotateCancelled() helper renders CancelledBy as -// "cancelled by " at read time — see handler_history.go. +// to "canceled" AND drops its purchase_suppressions in the same +// transaction, and stamps session.Email onto CanceledBy. The History +// UI's annotateCanceled() helper renders CanceledBy as +// "canceled by " at read time — see handler_history.go. // // The atomic suppression cleanup mirrors purchase.Manager.CancelExecution // on the email-token path: an executePurchase upfront writes // purchase_suppressions to hide the just-bought capacity from the // recommendations list during the grace window, and cancel must drop // those rows in the same commit so a crash between the two writes can't -// leave the rec list hiding capacity the user already cancelled. +// leave the rec list hiding capacity the user already canceled. func (h *Handler) cancelPurchaseViaSession(ctx context.Context, req *events.LambdaFunctionURLRequest, execution *config.PurchaseExecution) (any, error) { // These endpoints are AuthPublic so the outer middleware skips CSRF. // Enforce it here for the session-authed sub-path: the session bearer @@ -925,44 +937,46 @@ func (h *Handler) cancelPurchaseViaSession(ctx context.Context, req *events.Lamb } if !execution.IsCancelable() { - return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be cancelled (status=%s)", execution.ExecutionID, execution.Status)) + return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be canceled (status=%s)", execution.ExecutionID, execution.Status)) } if err := h.authorizeSessionCancel(ctx, session, execution); err != nil { return nil, err } - // Atomically flip status from pending/notified to cancelled + clear + // Atomically flip status from pending/notified to canceled + clear // suppressions in one tx. CancelExecutionAtomic issues a conditional // UPDATE WHERE status IN ('pending','notified'), so a concurrent approve // that has already transitioned the row to 'approved' causes zero rows // to be affected and we return a 409 with the current status rather // than silently overwriting an approved purchase. - var cancelledBy *string + var canceledBy *string if session.Email != "" { e := session.Email - cancelledBy = &e + canceledBy = &e } - var cancelled bool + var cancelOK bool var currentStatus string if err := h.config.WithTx(ctx, func(tx pgx.Tx) error { var err error - cancelled, currentStatus, err = h.config.CancelExecutionAtomic(ctx, tx, execution.ExecutionID, cancelledBy) + cancelOK, currentStatus, err = h.config.CancelExecutionAtomic(ctx, tx, execution.ExecutionID, canceledBy) if err != nil { return err } - if !cancelled { + if !cancelOK { return nil } return h.config.DeleteSuppressionsByExecutionTx(ctx, tx, execution.ExecutionID) }); err != nil { return nil, fmt.Errorf("cancel execution %s: %w", execution.ExecutionID, err) } - if !cancelled { - return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be cancelled: a concurrent operation already transitioned it to %q", execution.ExecutionID, currentStatus)) + if !cancelOK { + return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be canceled: a concurrent operation already transitioned it to %q", execution.ExecutionID, currentStatus)) } - return map[string]string{"status": "cancelled"}, nil + // Report the status the CAS actually persisted (currentStatus) rather than a + // hardcoded literal, so the response can never drift from the DB value. + return map[string]string{"status": currentStatus}, nil } // authorizeSessionCancel returns nil when the session is permitted to cancel @@ -1393,7 +1407,7 @@ func (h *Handler) tryResolveActorEmail(ctx context.Context, req *events.LambdaFu // failures into the contact_email gate — exactly the misclassification // the propagate-vs-fall-through split is meant to prevent. func isPermissionDenied(err error) bool { - ce, ok := err.(*clientError) + ce, ok := err.(*clientError) //nolint:errorlint // strict (unwrapped) assertion is deliberate; see comment above return ok && ce.code == 403 } @@ -1401,7 +1415,7 @@ func isPermissionDenied(err error) bool { // the request carries no Bearer token, the auth service isn't configured, // or session validation fails. Mirrors tryResolveActorEmail's silent // best-effort semantics so AuthPublic callers can opt into session-aware -// behaviour without forcing a 401 on tokenless flows. +// behavior without forcing a 401 on tokenless flows. func (h *Handler) tryGetSession(ctx context.Context, req *events.LambdaFunctionURLRequest) *Session { if req == nil || h.auth == nil { return nil @@ -1443,7 +1457,7 @@ func buildPurchaseDetailsResponse(execution *config.PurchaseExecution, planName return response } -// getPurchaseDetails returns details about a specific purchase execution +// getPurchaseDetails returns details about a specific purchase execution. func (h *Handler) getPurchaseDetails(ctx context.Context, req *events.LambdaFunctionURLRequest, executionID string) (any, error) { if err := validateUUID(executionID); err != nil { return nil, err @@ -1481,24 +1495,11 @@ func (h *Handler) getPurchaseDetails(ctx context.Context, req *events.LambdaFunc return buildPurchaseDetailsResponse(execution, planName), nil } -// ExecutePurchaseRequest represents the request to execute purchases +// ExecutePurchaseRequest represents the request to execute purchases. type ExecutePurchaseRequest struct { + ExecuteMode string `json:"execute_mode,omitempty"` Recommendations []config.RecommendationRecord `json:"recommendations"` - // CapacityPercent is what fraction (1..100) of the originally- - // recommended counts the user chose in the bulk Purchase flow. - // Audit-only: the Recommendations slice already carries scaled - // counts, so backend math ignores this field for purchase work. - // 0 / absent defaults to 100 ("full capacity"). - CapacityPercent int `json:"capacity_percent,omitempty"` - // ExecuteMode controls whether this request bypasses the approval - // email and executes the purchase immediately. The only accepted - // non-empty value is "direct"; any other value is treated as the - // default approval-required flow. The handler re-checks the - // execute-any/execute-own RBAC gate before honouring "direct", - // even if the session already passed the execute:purchases gate in - // validateExecutePurchaseRequest, so a client that sets this field - // without the privilege receives a 403 rather than silent fallback. - ExecuteMode string `json:"execute_mode,omitempty"` + CapacityPercent int `json:"capacity_percent,omitempty"` } // validateExecutePurchaseRequest handles the permission check, body parse, @@ -1600,15 +1601,15 @@ func (h *Handler) finalizePurchaseStatus(ctx context.Context, execution *config. // validateAndTotalRecommendations validates each recommendation and returns totals. func validateAndTotalRecommendations(recs []config.RecommendationRecord) (upfront, savings float64, err error) { const maxAmount = 10_000_000 // $10M sanity cap - for i, rec := range recs { - if rec.UpfrontCost < 0 { - return 0, 0, NewClientError(400, fmt.Sprintf("recommendation %d has negative upfront cost: %.2f", i, rec.UpfrontCost)) + for i := range recs { + if recs[i].UpfrontCost < 0 { + return 0, 0, NewClientError(400, fmt.Sprintf("recommendation %d has negative upfront cost: %.2f", i, recs[i].UpfrontCost)) } - if rec.Savings < 0 { - return 0, 0, NewClientError(400, fmt.Sprintf("recommendation %d has negative savings: %.2f", i, rec.Savings)) + if recs[i].Savings < 0 { + return 0, 0, NewClientError(400, fmt.Sprintf("recommendation %d has negative savings: %.2f", i, recs[i].Savings)) } - upfront += rec.UpfrontCost - savings += rec.Savings + upfront += recs[i].UpfrontCost + savings += recs[i].Savings } if upfront > maxAmount { return 0, 0, NewClientError(400, fmt.Sprintf("total upfront cost %.2f exceeds maximum allowed (%.2f)", upfront, float64(maxAmount))) @@ -1733,15 +1734,15 @@ const purchaseIdempotencyWindow = 2 * time.Minute // differently. Closes issue #644. func purchaseIdempotencyKey(creatorID string, recs []config.RecommendationRecord, capacityPercent int) string { tuples := make([]string, 0, len(recs)) - for _, r := range recs { + for i := range recs { acct := "" - if r.CloudAccountID != nil { - acct = *r.CloudAccountID + if recs[i].CloudAccountID != nil { + acct = *recs[i].CloudAccountID } tuples = append(tuples, fmt.Sprintf("%s|%s|%s|%s|%s|%s|%d|%d|%s", - strings.ToLower(strings.TrimSpace(r.Provider)), - r.Service, r.Region, r.ResourceType, r.Engine, acct, - r.Count, r.Term, strings.ToLower(strings.TrimSpace(r.Payment)))) + strings.ToLower(strings.TrimSpace(recs[i].Provider)), + recs[i].Service, recs[i].Region, recs[i].ResourceType, recs[i].Engine, acct, + recs[i].Count, recs[i].Term, strings.ToLower(strings.TrimSpace(recs[i].Payment)))) } sort.Strings(tuples) h := sha256.New() @@ -1785,30 +1786,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 @@ -1888,7 +1865,7 @@ func (h *Handler) executePurchase(ctx context.Context, req *events.LambdaFunctio // than leaving a half-committed tx state. Errors here don't block // the purchase — we just default the grace period per-provider. var gracePeriodCfg *config.GlobalConfig - if g, err := h.config.GetGlobalConfig(ctx); err == nil { + if g, cfgErr := h.config.GetGlobalConfig(ctx); cfgErr == nil { gracePeriodCfg = g } @@ -2067,7 +2044,7 @@ func approvalResponseRecipient(globalNotify, to string) string { // // Errors are also logged at Errorf level so they show up in CloudWatch, but // the reason string is what the API response surfaces to the UI. -func (h *Handler) sendPurchaseApprovalEmail(ctx context.Context, req *events.LambdaFunctionURLRequest, execution *config.PurchaseExecution, recs []config.RecommendationRecord, totalUpfront, totalSavings float64) (bool, string, string) { +func (h *Handler) sendPurchaseApprovalEmail(ctx context.Context, req *events.LambdaFunctionURLRequest, execution *config.PurchaseExecution, recs []config.RecommendationRecord, totalUpfront, totalSavings float64) (sent bool, reason, responseRecipient string) { if h.emailNotifier == nil { return false, "email notifier not configured for this deployment", "" } @@ -2093,16 +2070,16 @@ func (h *Handler) sendPurchaseApprovalEmail(ctx context.Context, req *events.Lam // resolvePendingApproverEmail, which always returns globalNotify when set. Using // globalNotify here keeps both displays consistent. When globalNotify is empty, // fall back to to (the per-account contact_email). See issue #735. - responseRecipient := approvalResponseRecipient(globalNotify, to) + responseRecipient = approvalResponseRecipient(globalNotify, to) summaries := make([]email.RecommendationSummary, 0, len(recs)) - for _, rec := range recs { + for i := range recs { summaries = append(summaries, email.RecommendationSummary{ - Service: rec.Service, - ResourceType: rec.ResourceType, - Engine: rec.Engine, - Region: rec.Region, - Count: rec.Count, - MonthlySavings: rec.Savings, + Service: recs[i].Service, + ResourceType: recs[i].ResourceType, + Engine: recs[i].Engine, + Region: recs[i].Region, + Count: recs[i].Count, + MonthlySavings: recs[i].Savings, }) } dashboardBase := h.resolveDashboardURL(req) @@ -2118,7 +2095,7 @@ func (h *Handler) sendPurchaseApprovalEmail(ctx context.Context, req *events.Lam AuthorizedApprovers: approvers, } data.ArcheraEducationURL = archeraEducationURL(dashboardBase) - if err := h.emailNotifier.SendPurchaseApprovalRequest(ctx, data); err != nil { + if err := h.emailNotifier.SendPurchaseApprovalRequest(ctx, &data); err != nil { logging.Errorf("Failed to send purchase approval email: %v", err) switch { case errors.Is(err, email.ErrNoRecipient): @@ -2158,7 +2135,7 @@ func (h *Handler) resolveDashboardURL(req *events.LambdaFunctionURLRequest) stri return "" } -// resolveApprovalRecipients computes the To / Cc / authorised-approver sets +// resolveApprovalRecipients computes the To / Cc / authorized-approver sets // for a purchase approval email based on the recommendations' account // contact emails and the global Settings → General notification email. // @@ -2166,21 +2143,21 @@ func (h *Handler) resolveDashboardURL(req *events.LambdaFunctionURLRequest) stri // purchase in that account; the global notification inbox is informed for // visibility. So we direct the email at the contact email(s) as To, list // any *other* contact emails plus the global notification email as Cc, -// and the approve/cancel token is only honoured for session holders whose -// email matches one of the authorised approvers (case-insensitive). +// and the approve/cancel token is only honored for session holders whose +// email matches one of the authorized approvers (case-insensitive). // -// **Authorisation policy** (post-security-hardening): the authorised- +// **Authorisation policy** (post-security-hardening): the authorized- // approver set is ALWAYS the per-account contact_email list, never the // global notification email. The global notify mailbox is informed of the // purchase via Cc but cannot itself approve. If no recommendation has a // per-account contact_email, the approver set is empty and the caller // must reject the approval with a clear error directing the operator to // set a contact email on the account. This closes the loophole where a -// catch-all inbox could authorise spend on accounts it doesn't own. +// catch-all inbox could authorize spend on accounts it doesn't own. // // Returns ("", nil, nil, nil) when neither contact_email nor globalNotify // is configured — the caller surfaces a user-facing error. -func (h *Handler) resolveApprovalRecipients(ctx context.Context, recs []config.RecommendationRecord, globalNotify string) (to string, cc []string, approvers []string, err error) { +func (h *Handler) resolveApprovalRecipients(ctx context.Context, recs []config.RecommendationRecord, globalNotify string) (to string, cc, approvers []string, err error) { contactEmails, err := h.gatherAccountContactEmails(ctx, recs) if err != nil { return "", nil, nil, err @@ -2225,7 +2202,7 @@ func (h *Handler) resolveApprovalRecipients(ctx context.Context, recs []config.R // insertion-ordered list of contact emails for the unique accounts // referenced by recs. Accounts without a CloudAccountID or without a // contact_email are silently skipped — they're not an error, just not a -// contribution to the authorised-approver set. A real DB error from +// contribution to the authorized-approver set. A real DB error from // lookupContactEmail is propagated so the caller surfaces it as a // retriable failure instead of silently degrading to a globalNotify // fallback (which would be wrong: a transient DB blip should not change @@ -2267,11 +2244,11 @@ func (h *Handler) gatherAccountContactEmails(ctx context.Context, recs []config. func uniqueAccountIDsFromRecs(recs []config.RecommendationRecord) []string { seen := map[string]bool{} var out []string - for _, rec := range recs { - if rec.CloudAccountID == nil || *rec.CloudAccountID == "" { + for i := range recs { + if recs[i].CloudAccountID == nil || *recs[i].CloudAccountID == "" { continue } - id := *rec.CloudAccountID + id := *recs[i].CloudAccountID if seen[id] { continue } @@ -2286,7 +2263,7 @@ func uniqueAccountIDsFromRecs(recs []config.RecommendationRecord) []string { // // - real DB error → return ("", err). The caller propagates as a // retriable failure rather than silently treating the actor as -// unauthorised or falling through to globalNotify; a transient +// unauthorized or falling through to globalNotify; a transient // blip should not change who is allowed to approve. // - account-not-found (GetCloudAccount returns nil, nil per pgx // ErrNoRows handling in the postgres store) → return ("", nil). @@ -2319,9 +2296,9 @@ func (h *Handler) lookupContactEmail(ctx context.Context, id string) (string, er // authorizeApprovalAction returns the actor email to record on an // approve/cancel action, after enforcing that the session-authenticated -// user's email is on the authorised-approver list for the given execution. +// user's email is on the authorized-approver list for the given execution. // Returns a 403 ClientError when the session is missing or the email -// doesn't match. The returned actor is stored as approved_by/cancelled_by +// doesn't match. The returned actor is stored as approved_by/canceled_by // on the execution. // // Rationale: the approve/cancel API routes are AuthPublic (token-only) for @@ -2359,5 +2336,5 @@ func (h *Handler) authorizeApprovalAction(ctx context.Context, req *events.Lambd return actor, nil } } - return "", NewClientError(403, "your session email is not the authorised approver for this purchase") + return "", NewClientError(403, "your session email is not the authorized approver for this purchase") } diff --git a/internal/api/handler_purchases_guards_test.go b/internal/api/handler_purchases_guards_test.go index eb8c09ecf..c7ffd7cd1 100644 --- a/internal/api/handler_purchases_guards_test.go +++ b/internal/api/handler_purchases_guards_test.go @@ -44,61 +44,61 @@ func TestValidatePurchaseRecommendation(t *testing.T) { wantError bool }{ // --- AWS canonical set --- - {"valid aws all-upfront 3y", validRec(), false}, - {"valid aws no-upfront 1y", mutate(func(r *config.RecommendationRecord) { r.Payment = "no-upfront"; r.Term = 1 }), false}, - {"valid aws partial-upfront", mutate(func(r *config.RecommendationRecord) { r.Payment = "partial-upfront" }), false}, - {"aws rejects azure-only monthly", mutate(func(r *config.RecommendationRecord) { r.Payment = "monthly" }), true}, - {"aws rejects azure-only upfront", mutate(func(r *config.RecommendationRecord) { r.Payment = "upfront" }), true}, + {name: "valid aws all-upfront 3y", rec: validRec()}, + {name: "valid aws no-upfront 1y", rec: mutate(func(r *config.RecommendationRecord) { r.Payment = "no-upfront"; r.Term = 1 })}, + {name: "valid aws partial-upfront", rec: mutate(func(r *config.RecommendationRecord) { r.Payment = "partial-upfront" })}, + {name: "aws rejects azure-only monthly", rec: mutate(func(r *config.RecommendationRecord) { r.Payment = "monthly" }), wantError: true}, + {name: "aws rejects azure-only upfront", rec: mutate(func(r *config.RecommendationRecord) { r.Payment = "upfront" }), wantError: true}, // --- Azure canonical set --- - {"valid azure upfront", mutate(func(r *config.RecommendationRecord) { r.Provider = "azure"; r.Payment = "upfront" }), false}, - {"valid azure monthly", mutate(func(r *config.RecommendationRecord) { r.Provider = "azure"; r.Payment = "monthly" }), false}, + {name: "valid azure upfront", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "azure"; r.Payment = "upfront" })}, + {name: "valid azure monthly", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "azure"; r.Payment = "monthly" })}, // Legacy AWS-style tokens on Azure are normalized to Azure-canonical before validation. - {"azure accepts legacy all-upfront (coerced to upfront)", mutate(func(r *config.RecommendationRecord) { + {name: "azure accepts legacy all-upfront (coerced to upfront)", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "azure" r.Payment = "all-upfront" - }), false}, - {"azure accepts legacy no-upfront (coerced to monthly)", mutate(func(r *config.RecommendationRecord) { + })}, + {name: "azure accepts legacy no-upfront (coerced to monthly)", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "azure" r.Payment = "no-upfront" - }), false}, - {"azure accepts legacy partial-upfront (coerced to upfront)", mutate(func(r *config.RecommendationRecord) { + })}, + {name: "azure accepts legacy partial-upfront (coerced to upfront)", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "azure" r.Payment = "partial-upfront" - }), false}, - {"azure rejects unknown token", mutate(func(r *config.RecommendationRecord) { + })}, + {name: "azure rejects unknown token", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "azure" r.Payment = "foo" - }), true}, + }), wantError: true}, // --- GCP canonical set (monthly-only) --- - {"valid gcp monthly", mutate(func(r *config.RecommendationRecord) { r.Provider = "gcp"; r.Payment = "monthly" }), false}, + {name: "valid gcp monthly", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "gcp"; r.Payment = "monthly" })}, // Legacy tokens on GCP are all normalized to monthly. - {"gcp accepts legacy upfront (coerced to monthly)", mutate(func(r *config.RecommendationRecord) { + {name: "gcp accepts legacy upfront (coerced to monthly)", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "gcp" r.Payment = "upfront" - }), false}, - {"gcp accepts legacy all-upfront (coerced to monthly)", mutate(func(r *config.RecommendationRecord) { + })}, + {name: "gcp accepts legacy all-upfront (coerced to monthly)", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "gcp" r.Payment = "all-upfront" - }), false}, - {"gcp accepts legacy no-upfront (coerced to monthly)", mutate(func(r *config.RecommendationRecord) { + })}, + {name: "gcp accepts legacy no-upfront (coerced to monthly)", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "gcp" r.Payment = "no-upfront" - }), false}, - {"gcp rejects unknown token", mutate(func(r *config.RecommendationRecord) { + })}, + {name: "gcp rejects unknown token", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "gcp" r.Payment = "foo" - }), true}, + }), wantError: true}, // --- General --- - {"payment case-insensitive", mutate(func(r *config.RecommendationRecord) { r.Payment = "All-Upfront" }), false}, - {"invalid term 7", mutate(func(r *config.RecommendationRecord) { r.Term = 7 }), true}, - {"invalid term 0", mutate(func(r *config.RecommendationRecord) { r.Term = 0 }), true}, - {"invalid payment foo", mutate(func(r *config.RecommendationRecord) { r.Payment = "foo" }), true}, - {"negative count", mutate(func(r *config.RecommendationRecord) { r.Count = -1 }), true}, - {"zero count", mutate(func(r *config.RecommendationRecord) { r.Count = 0 }), true}, - {"empty service", mutate(func(r *config.RecommendationRecord) { r.Service = "" }), true}, - {"empty provider rejected", mutate(func(r *config.RecommendationRecord) { r.Provider = "" }), true}, - {"all provider rejected", mutate(func(r *config.RecommendationRecord) { r.Provider = "all" }), true}, - {"unknown provider rejected", mutate(func(r *config.RecommendationRecord) { r.Provider = "ibm" }), true}, + {name: "payment case-insensitive", rec: mutate(func(r *config.RecommendationRecord) { r.Payment = "All-Upfront" })}, + {name: "invalid term 7", rec: mutate(func(r *config.RecommendationRecord) { r.Term = 7 }), wantError: true}, + {name: "invalid term 0", rec: mutate(func(r *config.RecommendationRecord) { r.Term = 0 }), wantError: true}, + {name: "invalid payment foo", rec: mutate(func(r *config.RecommendationRecord) { r.Payment = "foo" }), wantError: true}, + {name: "negative count", rec: mutate(func(r *config.RecommendationRecord) { r.Count = -1 }), wantError: true}, + {name: "zero count", rec: mutate(func(r *config.RecommendationRecord) { r.Count = 0 }), wantError: true}, + {name: "empty service", rec: mutate(func(r *config.RecommendationRecord) { r.Service = "" }), wantError: true}, + {name: "empty provider rejected", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "" }), wantError: true}, + {name: "all provider rejected", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "all" }), wantError: true}, + {name: "unknown provider rejected", rec: mutate(func(r *config.RecommendationRecord) { r.Provider = "ibm" }), wantError: true}, } for _, tt := range tests { tt := tt diff --git a/internal/api/handler_purchases_revoke.go b/internal/api/handler_purchases_revoke.go index 2f9827fd8..e0812cb87 100644 --- a/internal/api/handler_purchases_revoke.go +++ b/internal/api/handler_purchases_revoke.go @@ -58,12 +58,9 @@ const azureRefundSafetyMargin = 1 * time.Hour // revokeQuoteResult is the JSON body returned by // GET /api/purchases/revoke/calculate/{id}. type revokeQuoteResult struct { - // RefundAmount is the amount Azure will refund (from CalculateRefund). - RefundAmount float64 `json:"refund_amount"` - // RefundCurrency is the ISO-4217 currency code (e.g. "USD"). - RefundCurrency string `json:"refund_currency"` - // QuotedAt is an RFC3339 timestamp of when this quote was generated. - QuotedAt string `json:"quoted_at"` + RefundCurrency string `json:"refund_currency"` + QuotedAt string `json:"quoted_at"` + RefundAmount float64 `json:"refund_amount"` } // revokeConfirmBody is the JSON body expected on @@ -110,8 +107,8 @@ type revokePurchaseResult struct { // with no retry button (issue #290 Finding #6). type revokeReconcilePendingResult struct { Code string `json:"code"` - AzureReturned bool `json:"azure_returned"` Message string `json:"message"` + AzureReturned bool `json:"azure_returned"` } // revokeMarkRetryBackoffs are the sleep durations between consecutive @@ -130,7 +127,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) { @@ -227,7 +224,7 @@ func (h *Handler) loadAndRevokePurchaseHistory(ctx context.Context, req *events. // // The method enforces revoke-any/revoke-own RBAC (same permissions as the // completed-purchase revoke path), then atomically transitions the execution -// to "cancelled" and removes its purchase_suppressions. +// to "canceled" and removes its purchase_suppressions. // // Returns 410 Gone only when the CAS observes the row already transitioned out // of "scheduled" (the scheduler fired the SDK call between our SELECT and the @@ -241,40 +238,40 @@ 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 } - // Atomically transition from scheduled -> cancelled and remove suppressions. - var cancelledBy *string + // Atomically transition from scheduled -> canceled and remove suppressions. + var canceledBy *string if session.Email != "" { e := session.Email - cancelledBy = &e + canceledBy = &e } - var cancelled bool + var canceled bool var currentStatus string if err := h.config.WithTx(ctx, func(tx pgx.Tx) error { var err error // The scheduled-revoke path uses its own CAS variant that flips ONLY - // status='scheduled' -> 'cancelled'. CancelExecutionAtomic accepts + // status='scheduled' -> 'canceled'. CancelExecutionAtomic accepts // only ('pending','notified') and would always return zero rows on // 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, canceledBy) 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,11 +280,13 @@ 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) + // Report the status the CAS actually persisted (currentStatus) rather than a + // hardcoded literal, so the response can never drift from the DB value. return map[string]string{ - "status": "cancelled", - "message": "Purchase cancelled. No cloud API call was made; no cost incurred.", + "status": currentStatus, + "message": "Purchase canceled. No cloud API call was made; no cost incurred.", }, nil } @@ -421,7 +420,10 @@ func (h *Handler) calculateAzureRevoke(ctx context.Context, req *events.LambdaFu return nil, fmt.Errorf("revoke/calculate: create calculate-refund client: %w", err) } - quantity := int32(count) //nolint:gosec + quantity, err := safeInt32(count) + if err != nil { + return nil, err + } calcResp, err := calcClient.Post(ctx, orderID, armreservations.CalculateRefundRequest{ Properties: &armreservations.CalculateRefundRequestProperties{ ReservationToReturn: &armreservations.ReservationToReturn{ @@ -452,7 +454,7 @@ func (h *Handler) calculateAzureRevoke(ctx context.Context, req *events.LambdaFu // parse the reservation order/ID from the ARM path. Extracted to keep // calculateAzureRevoke under the cyclomatic-complexity limit. Returns the loaded // record plus the parsed orderID, reservationID, and commitment count. -func (h *Handler) validateAzureRevokeRequest(ctx context.Context, req *events.LambdaFunctionURLRequest, purchaseID string) (*config.PurchaseHistoryRecord, string, string, int, error) { +func (h *Handler) validateAzureRevokeRequest(ctx context.Context, req *events.LambdaFunctionURLRequest, purchaseID string) (record *config.PurchaseHistoryRecord, orderID, reservationID string, count int, err error) { if purchaseID == "" { return nil, "", "", 0, NewClientError(400, "purchase_id is required") } @@ -460,12 +462,13 @@ func (h *Handler) validateAzureRevokeRequest(ctx context.Context, req *events.La return nil, "", "", 0, NewClientError(403, "authentication service not configured") } - session, err := h.requireSession(ctx, req) + var session *Session + session, err = h.requireSession(ctx, req) if err != nil { return nil, "", "", 0, err } - record, err := h.config.GetPurchaseHistoryByPurchaseID(ctx, purchaseID) + record, err = h.config.GetPurchaseHistoryByPurchaseID(ctx, purchaseID) if err != nil { return nil, "", "", 0, fmt.Errorf("revoke/calculate: load purchase %s: %w", purchaseID, err) } @@ -473,11 +476,12 @@ func (h *Handler) validateAzureRevokeRequest(ctx context.Context, req *events.La return nil, "", "", 0, NewClientError(404, "purchase not found") } - if err := h.authorizeSessionRevoke(ctx, session, record); err != nil { + err = h.authorizeSessionRevoke(ctx, session, record) + if err != nil { return nil, "", "", 0, err } - orderID, reservationID, err := azureRevokeWindowAndIDs(record) + orderID, reservationID, err = azureRevokeWindowAndIDs(record) if err != nil { return nil, "", "", 0, err } @@ -488,7 +492,7 @@ func (h *Handler) validateAzureRevokeRequest(ctx context.Context, req *events.La // window check, then parses the reservation order/ID from the ARM path. // Extracted from validateAzureRevokeRequest to keep both under the cyclomatic- // complexity limit. Returns 422 ClientErrors for every reject case. -func azureRevokeWindowAndIDs(record *config.PurchaseHistoryRecord) (string, string, error) { +func azureRevokeWindowAndIDs(record *config.PurchaseHistoryRecord) (orderID, reservationID string, err error) { if record.Provider != "azure" { return "", "", NewClientError(422, fmt.Sprintf("provider %q does not support refund calculation", record.Provider)) } @@ -506,7 +510,7 @@ func azureRevokeWindowAndIDs(record *config.PurchaseHistoryRecord) (string, stri )) } - orderID, reservationID, err := parseAzureReservationIDs(record.PurchaseID) + orderID, reservationID, err = parseAzureReservationIDs(record.PurchaseID) if err != nil { return "", "", NewClientError(422, "cannot determine Azure reservation order ID from purchase record; contact Azure Support to request a refund") } @@ -519,9 +523,7 @@ func azureRevokeWindowAndIDs(record *config.PurchaseHistoryRecord) (string, stri // extractAzureRefundQuote pulls the refund amount and currency out of a // CalculateRefund response, guarding every nil pointer in the chain. Returns // zero values when the response carries no billing-refund amount. -func extractAzureRefundQuote(resp armreservations.CalculateRefundClientPostResponse) (float64, string) { - var refundAmount float64 - var refundCurrency string +func extractAzureRefundQuote(resp armreservations.CalculateRefundClientPostResponse) (refundAmount float64, refundCurrency string) { if resp.Properties != nil && resp.Properties.BillingRefundAmount != nil { if resp.Properties.BillingRefundAmount.Amount != nil { refundAmount = *resp.Properties.BillingRefundAmount.Amount @@ -604,7 +606,10 @@ func (h *Handler) callAzureReturn( } // Step 1: CalculateRefund -> sessionID + quoted amount (TOCTOU check). - quantity := int32(record.Count) //nolint:gosec // Count > 0 validated at purchase + quantity, err := safeInt32(record.Count) + if err != nil { + return nil, err + } sessionID, calcRefundAmount, calcRefundCurrency, err := h.azureCalculateRefund(ctx, calcClient, orderID, reservationID, quantity) if err != nil { return nil, err @@ -655,8 +660,9 @@ func (h *Handler) callAzureReturn( // azureCalculateRefund runs the CalculateRefund step and parses out the session // ID (required by Return) and the quoted refund amount/currency (for the TOCTOU // check). Errors are classified into 400 (client) vs 500 (transient). -func (h *Handler) azureCalculateRefund(ctx context.Context, calcClient azureCalculateRefundClient, orderID, reservationID string, quantity int32) (string, *float64, string, error) { - calcResp, err := calcClient.Post(ctx, orderID, armreservations.CalculateRefundRequest{ +func (h *Handler) azureCalculateRefund(ctx context.Context, calcClient azureCalculateRefundClient, orderID, reservationID string, quantity int32) (sessionID string, refundAmount *float64, refundCurrency string, err error) { + var calcResp armreservations.CalculateRefundClientPostResponse + calcResp, err = calcClient.Post(ctx, orderID, armreservations.CalculateRefundRequest{ Properties: &armreservations.CalculateRefundRequestProperties{ ReservationToReturn: &armreservations.ReservationToReturn{ ReservationID: &reservationID, @@ -672,9 +678,6 @@ func (h *Handler) azureCalculateRefund(ctx context.Context, calcClient azureCalc return "", nil, "", fmt.Errorf("revoke azure: CalculateRefund failed: %w", err) } - var sessionID string - var calcRefundAmount *float64 - var calcRefundCurrency string if calcResp.Properties != nil { if calcResp.Properties.SessionID != nil { sessionID = *calcResp.Properties.SessionID @@ -682,14 +685,14 @@ func (h *Handler) azureCalculateRefund(ctx context.Context, calcClient azureCalc if calcResp.Properties.BillingRefundAmount != nil { if calcResp.Properties.BillingRefundAmount.Amount != nil { v := *calcResp.Properties.BillingRefundAmount.Amount - calcRefundAmount = &v + refundAmount = &v } if calcResp.Properties.BillingRefundAmount.CurrencyCode != nil { - calcRefundCurrency = *calcResp.Properties.BillingRefundAmount.CurrencyCode + refundCurrency = *calcResp.Properties.BillingRefundAmount.CurrencyCode } } } - return sessionID, calcRefundAmount, calcRefundCurrency, nil + return sessionID, refundAmount, refundCurrency, nil } // handleAzureReturnError clears the in-flight flag (no refund was issued) and @@ -840,3 +843,13 @@ func isAzureWindowEdgeError(err error) bool { // toPtr returns a pointer to its argument. Generic helper used by the Azure // revocation call-site to construct ARM struct fields without temp variables. func toPtr[T any](v T) *T { return &v } + +// safeInt32 converts n to int32, returning a ClientError when n exceeds the +// int32 range. Azure reservation quantities are always small non-negative +// integers, so this guard is purely defensive against malformed DB values. +func safeInt32(n int) (int32, error) { + if n < math.MinInt32 || n > math.MaxInt32 { + return 0, NewClientError(422, fmt.Sprintf("reservation quantity %d is out of int32 range", n)) + } + return int32(n), nil +} diff --git a/internal/api/handler_purchases_revoke_test.go b/internal/api/handler_purchases_revoke_test.go index 4f3a8fae2..102a602e5 100644 --- a/internal/api/handler_purchases_revoke_test.go +++ b/internal/api/handler_purchases_revoke_test.go @@ -39,9 +39,9 @@ func (s *stubReturnClient) Post(ctx context.Context, orderID string, body armres } // sessionReq builds a minimal request with a bearer token. -func sessionReq(token string) *events.LambdaFunctionURLRequest { +func sessionReq() *events.LambdaFunctionURLRequest { return &events.LambdaFunctionURLRequest{ - Headers: map[string]string{"Authorization": "Bearer " + token}, + Headers: map[string]string{"Authorization": "Bearer tok"}, } } @@ -90,7 +90,7 @@ func TestRevokePurchase_NilAuthService(t *testing.T) { // auth == nil: the handler must fail closed with a 403 ClientError before // reaching any session or store call. No mock setup needed. h := &Handler{auth: nil} - _, err := h.revokePurchase(ctx, sessionReq("tok"), "pid") + _, err := h.revokePurchase(ctx, sessionReq(), "pid") require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok) @@ -101,7 +101,7 @@ func TestRevokePurchase_EmptyPurchaseID(t *testing.T) { t.Parallel() ctx := context.Background() h := &Handler{} - _, err := h.revokePurchase(ctx, sessionReq("tok"), "") + _, err := h.revokePurchase(ctx, sessionReq(), "") require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok) @@ -125,7 +125,7 @@ func TestRevokePurchase_PurchaseNotFound(t *testing.T) { mockStore.On("GetPurchaseHistoryByPurchaseID", ctx, "pid-1").Return((*config.PurchaseHistoryRecord)(nil), nil) h := &Handler{config: mockStore, auth: mockAuth} - _, err := h.revokePurchase(ctx, sessionReq("tok"), "pid-1") + _, err := h.revokePurchase(ctx, sessionReq(), "pid-1") require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok) @@ -154,7 +154,7 @@ func TestRevokePurchase_AlreadyRevoked(t *testing.T) { mockStore.On("GetPurchaseHistoryByPurchaseID", ctx, r.PurchaseID).Return(r, nil) h := &Handler{config: mockStore, auth: mockAuth} - result, err := h.revokePurchase(ctx, sessionReq("tok"), r.PurchaseID) + result, err := h.revokePurchase(ctx, sessionReq(), r.PurchaseID) require.NoError(t, err) m, ok := result.(*revokePurchaseResult) require.True(t, ok) @@ -182,7 +182,7 @@ func TestRevokePurchase_AWSReturns422(t *testing.T) { mockStore.On("GetPurchaseHistoryByPurchaseID", ctx, r.PurchaseID).Return(r, nil) h := &Handler{config: mockStore, auth: mockAuth} - _, err := h.revokePurchase(ctx, sessionReq("tok"), r.PurchaseID) + _, err := h.revokePurchase(ctx, sessionReq(), r.PurchaseID) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok) @@ -209,7 +209,7 @@ func TestRevokePurchase_GCPReturns422(t *testing.T) { mockStore.On("GetPurchaseHistoryByPurchaseID", ctx, r.PurchaseID).Return(r, nil) h := &Handler{config: mockStore, auth: mockAuth} - _, err := h.revokePurchase(ctx, sessionReq("tok"), r.PurchaseID) + _, err := h.revokePurchase(ctx, sessionReq(), r.PurchaseID) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok) @@ -236,7 +236,7 @@ func TestRevokePurchase_AzureOutsideWindow(t *testing.T) { mockStore.On("GetPurchaseHistoryByPurchaseID", ctx, r.PurchaseID).Return(r, nil) h := &Handler{config: mockStore, auth: mockAuth} - _, err := h.revokePurchase(ctx, sessionReq("tok"), r.PurchaseID) + _, err := h.revokePurchase(ctx, sessionReq(), r.PurchaseID) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok) @@ -351,7 +351,7 @@ func TestRevokePurchase_UsesStampedWindow(t *testing.T) { mockStore.On("GetPurchaseHistoryByPurchaseID", ctx, r.PurchaseID).Return(r, nil) h := &Handler{config: mockStore, auth: mockAuth} - _, err := h.revokePurchase(ctx, sessionReq("tok"), r.PurchaseID) + _, err := h.revokePurchase(ctx, sessionReq(), r.PurchaseID) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok) @@ -410,7 +410,7 @@ func TestParseAzureReservationIDs(t *testing.T) { wantResID: "", }, { - name: "unrecognised path", + name: "unrecognized path", purchaseID: "some-plain-id", wantErr: true, }, @@ -521,7 +521,7 @@ func TestAuthorizeSessionRevoke_NoPermission(t *testing.T) { } // TestAuthorizeSessionRevoke_RevokeOwn_NilAccountID verifies the fail-closed -// behaviour: a revoke-own caller must be denied when the purchase row carries +// behavior: a revoke-own caller must be denied when the purchase row carries // no cloud_account_id (legacy/unscoped row), because ownership cannot be // verified without an account association. func TestAuthorizeSessionRevoke_RevokeOwn_NilAccountID(t *testing.T) { @@ -563,7 +563,7 @@ func scheduledExecution(executionID string, createdByUserID string) *config.Purc } // TestRevokePurchase_ScheduledExecution_AdminFreeCancel verifies that revoking -// a scheduled execution as admin transitions it to cancelled without any +// a scheduled execution as admin transitions it to canceled without any // provider SDK call (no MarkPurchaseRevoked expected). func TestRevokePurchase_ScheduledExecution_AdminFreeCancel(t *testing.T) { t.Parallel() @@ -580,14 +580,20 @@ func TestRevokePurchase_ScheduledExecution_AdminFreeCancel(t *testing.T) { mockAuth.On("ValidateSession", ctx, "tok").Return(adminSess, nil) exec := scheduledExecution(execID, "") mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) - // CancelExecutionAtomic and DeleteSuppressionsByExecutionTx use mock defaults - // (WithTx calls fn(nil), CancelExecutionAtomic returns true/"cancelled"/nil). + // A scheduled row is canceled via CancelScheduledExecutionAtomic. Stub its + // return explicitly (matching the assertion below) rather than leaning on the + // shared mock default, so the setup and the asserted status can never drift. + mockStore.On("CancelScheduledExecutionAtomic", ctx, mock.Anything, execID, mock.Anything). + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql + Return(true, "cancelled", nil).Once() + mockStore.On("DeleteSuppressionsByExecutionTx", ctx, mock.Anything, execID).Return(nil).Once() h := &Handler{config: mockStore, auth: mockAuth} - result, err := h.revokePurchase(ctx, sessionReq("tok"), execID) + result, err := h.revokePurchase(ctx, sessionReq(), execID) require.NoError(t, err) m, ok := result.(map[string]string) require.True(t, ok) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", m["status"]) assert.Contains(t, m["message"], "No cloud API call") } @@ -621,14 +627,16 @@ func TestRevokePurchase_ScheduledExecution_PastTimestampStillCancellable(t *test } mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockStore.On("CancelScheduledExecutionAtomic", ctx, mock.Anything, execID, mock.Anything). + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql Return(true, "cancelled", nil).Once() mockStore.On("DeleteSuppressionsByExecutionTx", ctx, mock.Anything, execID).Return(nil).Once() h := &Handler{config: mockStore, auth: mockAuth} - result, err := h.revokePurchase(ctx, sessionReq("tok"), execID) + result, err := h.revokePurchase(ctx, sessionReq(), execID) require.NoError(t, err) m, ok := result.(map[string]string) require.True(t, ok) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", m["status"]) assert.Contains(t, m["message"], "No cloud API call") } @@ -657,7 +665,7 @@ func TestRevokePurchase_ScheduledExecution_CASRace(t *testing.T) { Return(false, "approved", nil) h := &Handler{config: mockStore, auth: mockAuth} - _, err := h.revokePurchase(ctx, sessionReq("tok"), execID) + _, err := h.revokePurchase(ctx, sessionReq(), execID) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok) @@ -670,7 +678,7 @@ func TestRevokePurchase_ScheduledExecution_CASRace(t *testing.T) { // dispatched into CancelExecutionAtomic. That method's SQL guard is // status IN ('pending','notified'), which never matches a scheduled row, so // EVERY revoke attempt on a scheduled execution returned 410 -- including the -// happy path. Mock-default success ("true,cancelled,nil") in MockConfigStore +// happy path. Mock-default success ("true,canceled,nil") in MockConfigStore // hid the bug; the handler now calls CancelScheduledExecutionAtomic instead. // // This test pins the expected mock method explicitly with a captured assertion @@ -692,18 +700,20 @@ func TestRevokePurchase_ScheduledExecution_BugReg_HappyPathCAS(t *testing.T) { exec := scheduledExecution(execID, "") mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockStore.On("CancelScheduledExecutionAtomic", ctx, mock.Anything, execID, mock.Anything). + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql Return(true, "cancelled", nil).Once() // Suppression cleanup must run inside the same tx as the CAS. mockStore.On("DeleteSuppressionsByExecutionTx", ctx, mock.Anything, execID).Return(nil).Once() h := &Handler{config: mockStore, auth: mockAuth} - result, err := h.revokePurchase(ctx, sessionReq("tok"), execID) + result, err := h.revokePurchase(ctx, sessionReq(), execID) // Negative invariant: the WRONG method must never be called for a scheduled row. // Placed AFTER the handler call so it actually fires post-execution (Finding F-1, second-wave CR). mockStore.AssertNotCalled(t, "CancelExecutionAtomic", mock.Anything, mock.Anything, mock.Anything, mock.Anything) require.NoError(t, err) m, ok := result.(map[string]string) require.True(t, ok) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", m["status"]) assert.Contains(t, m["message"], "No cloud API call") } @@ -732,10 +742,11 @@ func TestRevokePurchase_ScheduledExecution_RevokeOwnCreator(t *testing.T) { mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) h := &Handler{config: mockStore, auth: mockAuth} - result, err := h.revokePurchase(ctx, sessionReq("tok"), execID) + result, err := h.revokePurchase(ctx, sessionReq(), execID) require.NoError(t, err) m, ok := result.(map[string]string) require.True(t, ok) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", m["status"]) } @@ -763,7 +774,7 @@ func TestRevokePurchase_ScheduledExecution_RevokeOwnWrongCreator(t *testing.T) { mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) h := &Handler{config: mockStore, auth: mockAuth} - _, err := h.revokePurchase(ctx, sessionReq("tok"), execID) + _, err := h.revokePurchase(ctx, sessionReq(), execID) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok) @@ -784,7 +795,7 @@ func TestAuthorizeSessionRevokeExecution_Admin(t *testing.T) { } // TestAuthorizeSessionRevokeExecution_NilCreatorDenied verifies fail-closed -// behaviour: a revoke-own caller with no CreatedByUserID on the execution is +// behavior: a revoke-own caller with no CreatedByUserID on the execution is // denied. func TestAuthorizeSessionRevokeExecution_NilCreatorDenied(t *testing.T) { t.Parallel() @@ -811,9 +822,9 @@ func TestAuthorizeSessionRevokeExecution_NilCreatorDenied(t *testing.T) { // stubCalcRefundClientWithAmount is a CalculateRefund stub that returns a // specified refund amount + currency. type stubCalcRefundClientWithAmount struct { - amount float64 currency string sessID string + amount float64 } func (s *stubCalcRefundClientWithAmount) Post(_ context.Context, _ string, _ armreservations.CalculateRefundRequest, _ *armreservations.CalculateRefundClientPostOptions) (armreservations.CalculateRefundClientPostResponse, error) { @@ -1008,7 +1019,7 @@ func TestLoadAndRevokePurchaseHistory_RevocationInFlightReturns207(t *testing.T) mockStore.On("GetPurchaseHistoryByPurchaseID", ctx, r.PurchaseID).Return(r, nil) h := &Handler{config: mockStore, auth: mockAuth} - result, err := h.revokePurchase(ctx, sessionReq("tok"), r.PurchaseID) + result, err := h.revokePurchase(ctx, sessionReq(), r.PurchaseID) require.NoError(t, err) pending, ok := result.(*revokeReconcilePendingResult) @@ -1048,7 +1059,7 @@ func TestRevokePurchase_AzureWithinSafetyMarginRejected(t *testing.T) { mockStore.On("GetPurchaseHistoryByPurchaseID", ctx, r.PurchaseID).Return(r, nil) h := &Handler{config: mockStore, auth: mockAuth} - _, err := h.revokePurchase(ctx, sessionReq("tok"), r.PurchaseID) + _, err := h.revokePurchase(ctx, sessionReq(), r.PurchaseID) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok) @@ -1094,8 +1105,8 @@ func TestCallAzureReturn_JustOutsideSafetyMargin(t *testing.T) { func TestIsAzureWindowEdgeError(t *testing.T) { t.Parallel() tests := []struct { - name string err error + name string wantYes bool }{ { @@ -1339,7 +1350,7 @@ func TestRevokePurchase_GetExecutionByIDDBError_Returns500(t *testing.T) { mockStore.AssertNotCalled(t, "GetPurchaseHistoryByPurchaseID", mock.Anything, mock.Anything) h := &Handler{config: mockStore, auth: mockAuth} - _, err := h.revokePurchase(ctx, sessionReq("tok"), "pid-dberr") + _, err := h.revokePurchase(ctx, sessionReq(), "pid-dberr") require.Error(t, err, "DB error from GetExecutionByID must surface as an error, not silent passthrough") // The error wraps the DB error; it is NOT a ClientError (not user-facing 500 shape) // but the router will convert it to a 500 response. Verify the DB error is present. @@ -1349,11 +1360,11 @@ func TestRevokePurchase_GetExecutionByIDDBError_Returns500(t *testing.T) { // TestRevokePurchase_ConcurrentScheduledRevoke_OneWinsOneGets410 verifies that // two parallel revoke requests for the same scheduled execution produce the -// correct outcomes: the first CAS wins (cancelled), the second CAS loses and +// correct outcomes: the first CAS wins (canceled), the second CAS loses and // returns 410 (Finding B, second-wave CR). // // The fix drops the racy "status == scheduled" pre-check and lets -// CancelScheduledExecutionAtomic decide. A second call with !cancelled means +// CancelScheduledExecutionAtomic decide. A second call with !canceled means // the scheduler (or first caller) already transitioned the row. func TestRevokePurchase_ConcurrentScheduledRevoke_OneWinsOneGets410(t *testing.T) { t.Parallel() @@ -1376,18 +1387,20 @@ func TestRevokePurchase_ConcurrentScheduledRevoke_OneWinsOneGets410(t *testing.T exec := scheduledExecution(execID, "") mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockStore.On("CancelScheduledExecutionAtomic", ctx, mock.Anything, execID, mock.Anything). + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql Return(true, "cancelled", nil).Once() mockStore.On("DeleteSuppressionsByExecutionTx", ctx, mock.Anything, execID).Return(nil).Once() h := &Handler{config: mockStore, auth: mockAuth} - result, err := h.revokePurchase(ctx, sessionReq("tok"), execID) + result, err := h.revokePurchase(ctx, sessionReq(), execID) require.NoError(t, err) m, ok := result.(map[string]string) require.True(t, ok) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", m["status"]) }) - // --- Second caller: CAS returns !cancelled (scheduler or first caller won) --- + // --- Second caller: CAS returns !canceled (scheduler or first caller won) --- t.Run("second caller gets 410", func(t *testing.T) { t.Parallel() mockStore := new(MockConfigStore) @@ -1400,12 +1413,12 @@ func TestRevokePurchase_ConcurrentScheduledRevoke_OneWinsOneGets410(t *testing.T mockAuth.On("ValidateSession", ctx, "tok").Return(adminSess, nil) exec := scheduledExecution(execID, "") mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) - // CAS returns !cancelled because the row was already transitioned. + // CAS returns !canceled because the row was already transitioned. mockStore.On("CancelScheduledExecutionAtomic", ctx, mock.Anything, execID, mock.Anything). Return(false, "completed", nil).Once() h := &Handler{config: mockStore, auth: mockAuth} - _, err := h.revokePurchase(ctx, sessionReq("tok"), execID) + _, err := h.revokePurchase(ctx, sessionReq(), execID) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok, "CAS race-lost must surface as a ClientError") diff --git a/internal/api/handler_purchases_test.go b/internal/api/handler_purchases_test.go index 946a456ff..83c1cfbf1 100644 --- a/internal/api/handler_purchases_test.go +++ b/internal/api/handler_purchases_test.go @@ -19,7 +19,7 @@ import ( // recommendation against an account whose contact_email is `contact`. Used // to satisfy the post-hardening approver-set policy (see // authorizeApprovalAction): the global notify mailbox is no longer an -// authorised approver, so tests must wire a per-account contact email. +// authorized approver, so tests must wire a per-account contact email. func approvalTestExec(execID, contact string, mockConfig *MockConfigStore) *config.PurchaseExecution { accountID := "acct-1" exec := &config.PurchaseExecution{ @@ -110,6 +110,7 @@ func TestHandler_cancelPurchase(t *testing.T) { require.NoError(t, err) resultMap := result.(map[string]string) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", resultMap["status"]) } @@ -126,14 +127,14 @@ func TestHandler_approvePurchase_RejectsMismatchedSession(t *testing.T) { }, nil) mockAuth := new(MockAuthService) - // Session belongs to someone who is NOT the authorised approver. + // Session belongs to someone who is NOT the authorized approver. mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: "wrong@example.com"}, nil) // After issue #286 the dispatch consults approve-{any,own} BEFORE // the contact_email gate. The wrong@example.com session has neither // verb, so the dispatch returns 403 from authorizeSessionApprove, // `isPermissionDenied(err)` matches, and execution falls through to // the token branch's authorizeApprovalAction — which is what - // produces the "not the authorised approver" error this test pins. + // produces the "not the authorized approver" error this test pins. mockAuth.On("HasPermissionAPI", ctx, "", "approve-any", "purchases").Return(false, nil).Maybe() mockAuth.On("HasPermissionAPI", ctx, "", "approve-own", "purchases").Return(false, nil).Maybe() @@ -146,7 +147,7 @@ func TestHandler_approvePurchase_RejectsMismatchedSession(t *testing.T) { } _, err := handler.approvePurchase(ctx, req, execID, "valid-token") require.Error(t, err) - assert.Contains(t, err.Error(), "not the authorised approver") + assert.Contains(t, err.Error(), "not the authorized approver") // ApproveExecution must not have been called — purchase manager mock // asserts nothing by construction; a .On(...) entry above would create // a false positive, so we pin the negative by confirming the error is @@ -155,7 +156,7 @@ func TestHandler_approvePurchase_RejectsMismatchedSession(t *testing.T) { } // TestHandler_approvePurchase_RejectsMissingContactEmail covers the -// security-hardened behaviour: when an execution's recommendations do not +// security-hardened behavior: when an execution's recommendations do not // resolve to ANY per-account contact_email, the approval is rejected even // if the session belongs to the global notification mailbox. Closes the // loophole where a catch-all inbox could approve purchases on accounts it @@ -562,7 +563,7 @@ func TestHandler_approvePurchase_RejectsGlobalNotifyWhenContactSet(t *testing.T) // Issue #286: dispatch consults approve-{any,own} BEFORE the // contact_email gate. Returning false for both verbs lets the // dispatch fall through to the token branch where the - // "not the authorised approver" check fires. + // "not the authorized approver" check fires. mockAuth.On("HasPermissionAPI", ctx, "", "approve-any", "purchases").Return(false, nil).Maybe() mockAuth.On("HasPermissionAPI", ctx, "", "approve-own", "purchases").Return(false, nil).Maybe() @@ -575,7 +576,7 @@ func TestHandler_approvePurchase_RejectsGlobalNotifyWhenContactSet(t *testing.T) } _, err := handler.approvePurchase(ctx, req, execID, "valid-token") require.Error(t, err) - assert.Contains(t, err.Error(), "not the authorised approver") + assert.Contains(t, err.Error(), "not the authorized approver") mockPurchase.AssertNotCalled(t, "ApproveExecution") } @@ -671,10 +672,10 @@ func TestHandler_resolveApprovalRecipients_ContactBecomesTo(t *testing.T) { } // TestHandler_resolveApprovalRecipients_NoContactEmail covers the security- -// hardened behaviour: when no recommendation has a per-account contact_email, +// hardened behavior: when no recommendation has a per-account contact_email, // the global notify mailbox receives the email (To) but is NOT added to the // approver set. This closes the loophole where a catch-all inbox could -// authorise spend on accounts it doesn't own; authorizeApprovalAction will +// authorize spend on accounts it doesn't own; authorizeApprovalAction will // reject the approve/cancel because approvers is empty. func TestHandler_resolveApprovalRecipients_NoContactEmail(t *testing.T) { ctx := context.Background() @@ -701,7 +702,7 @@ func TestHandler_resolveApprovalRecipients_NoContactEmail(t *testing.T) { // TestHandler_resolveApprovalRecipients_LookupErrorPropagates verifies // the regression CodeRabbit flagged: a transient GetCloudAccount error // must NOT silently degrade to a globalNotify-only fallback (which -// would change who is authorised to approve based on a DB blip). +// would change who is authorized to approve based on a DB blip). // Instead, the lookup error propagates to the caller, which surfaces // it as a retriable failure so the operator's next attempt sees the // real approver list. @@ -907,7 +908,7 @@ func TestHandler_getPlannedPurchases_PausedStaysVisible(t *testing.T) { assert.Equal(t, "pending", result.Purchases[0].Status) assert.Equal(t, "paused", result.Purchases[1].Status) // The paused row is present, not dropped. - var statuses []string + statuses := make([]string, 0, len(result.Purchases)) for _, p := range result.Purchases { statuses = append(statuses, p.Status) } @@ -1120,10 +1121,10 @@ func TestHandler_deletePlannedPurchase(t *testing.T) { Email: "admin@example.com", } - cancelled := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "cancelled"} + canceledExec := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "cancelled"} //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending", "paused"}, "cancelled", mock.Anything).Return(cancelled, nil) + mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending", "paused"}, "cancelled", mock.Anything).Return(canceledExec, nil) //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql handler := &Handler{config: mockStore, auth: mockAuth} @@ -1135,6 +1136,7 @@ func TestHandler_deletePlannedPurchase(t *testing.T) { result, err := handler.deletePlannedPurchase(ctx, req, "11111111-1111-1111-1111-111111111111") require.NoError(t, err) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", result.Status) } @@ -1155,10 +1157,10 @@ func TestHandler_deletePlannedPurchase_DisablesPlan(t *testing.T) { planID := "22222222-2222-2222-2222-222222222222" execID := "11111111-1111-1111-1111-111111111111" - cancelled := &config.PurchaseExecution{ + canceledExec := &config.PurchaseExecution{ ExecutionID: execID, PlanID: planID, - Status: "cancelled", + Status: "cancelled", //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql } plan := &config.PurchasePlan{ ID: planID, @@ -1168,7 +1170,7 @@ func TestHandler_deletePlannedPurchase_DisablesPlan(t *testing.T) { mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled", mock.Anything).Return(cancelled, nil) + mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled", mock.Anything).Return(canceledExec, nil) //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql mockStore.On("GetPurchasePlan", ctx, planID).Return(plan, nil) // Assert that UpdatePurchasePlan is called with enabled=false. mockStore.On("UpdatePurchasePlan", ctx, mock.MatchedBy(func(p *config.PurchasePlan) bool { @@ -1184,6 +1186,7 @@ func TestHandler_deletePlannedPurchase_DisablesPlan(t *testing.T) { } result, err := handler.deletePlannedPurchase(ctx, req, execID) require.NoError(t, err) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", result.Status) // Plan struct is mutated in place; confirm the flag was flipped. assert.False(t, plan.Enabled, "plan.Enabled must be false after disable") @@ -1206,10 +1209,10 @@ func TestHandler_deletePlannedPurchase_AlreadyDisabledPlan(t *testing.T) { planID := "33333333-3333-3333-3333-333333333333" execID := "44444444-4444-4444-4444-444444444444" - cancelled := &config.PurchaseExecution{ + canceledExec := &config.PurchaseExecution{ ExecutionID: execID, PlanID: planID, - Status: "cancelled", + Status: "cancelled", //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql } // Plan already disabled - UpdatePurchasePlan must NOT be called. plan := &config.PurchasePlan{ @@ -1220,7 +1223,7 @@ func TestHandler_deletePlannedPurchase_AlreadyDisabledPlan(t *testing.T) { mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled", mock.Anything).Return(cancelled, nil) + mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled", mock.Anything).Return(canceledExec, nil) //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql mockStore.On("GetPurchasePlan", ctx, planID).Return(plan, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1232,6 +1235,7 @@ func TestHandler_deletePlannedPurchase_AlreadyDisabledPlan(t *testing.T) { } result, err := handler.deletePlannedPurchase(ctx, req, execID) require.NoError(t, err) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", result.Status) } @@ -1261,7 +1265,7 @@ func TestHandler_deletePlannedPurchase_ConflictRetryDisablesPlan(t *testing.T) { existingExec := &config.PurchaseExecution{ ExecutionID: execID, PlanID: planID, - Status: "cancelled", + Status: "cancelled", //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql } plan := &config.PurchasePlan{ ID: planID, @@ -1271,7 +1275,7 @@ func TestHandler_deletePlannedPurchase_ConflictRetryDisablesPlan(t *testing.T) { mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled", mock.Anything).Return(nil, conflictErr) + mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled", mock.Anything).Return(nil, conflictErr) //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql mockStore.On("GetExecutionByID", ctx, execID).Return(existingExec, nil) mockStore.On("GetPurchasePlan", ctx, planID).Return(plan, nil) mockStore.On("UpdatePurchasePlan", ctx, mock.MatchedBy(func(p *config.PurchasePlan) bool { @@ -1285,6 +1289,7 @@ func TestHandler_deletePlannedPurchase_ConflictRetryDisablesPlan(t *testing.T) { } result, err := handler.deletePlannedPurchase(ctx, req, execID) require.NoError(t, err) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", result.Status) assert.False(t, plan.Enabled, "plan.Enabled must be false after conflict-retry disable") } @@ -1311,7 +1316,7 @@ func TestHandler_deletePlannedPurchase_ConflictRetryAlreadyDisabled(t *testing.T existingExec := &config.PurchaseExecution{ ExecutionID: execID, PlanID: planID, - Status: "cancelled", + Status: "cancelled", //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql } // Plan already disabled; UpdatePurchasePlan must NOT be called. plan := &config.PurchasePlan{ @@ -1322,7 +1327,7 @@ func TestHandler_deletePlannedPurchase_ConflictRetryAlreadyDisabled(t *testing.T mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled", mock.Anything).Return(nil, conflictErr) + mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled", mock.Anything).Return(nil, conflictErr) //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql mockStore.On("GetExecutionByID", ctx, execID).Return(existingExec, nil) mockStore.On("GetPurchasePlan", ctx, planID).Return(plan, nil) // UpdatePurchasePlan is intentionally NOT registered; AssertExpectations @@ -1335,13 +1340,14 @@ func TestHandler_deletePlannedPurchase_ConflictRetryAlreadyDisabled(t *testing.T } result, err := handler.deletePlannedPurchase(ctx, req, execID) require.NoError(t, err) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", result.Status) } // TestHandler_deletePlannedPurchase_ConflictRetryRunningReturns409 is a // regression test for CR #995 Finding 1: when TransitionExecutionStatus // returns ErrExecutionNotInExpectedStatus but the fetched row is NOT -// "cancelled" (e.g. the execution raced to "running"), cancelOrRecoverExecution +// "canceled" (e.g. the execution raced to "running"), cancelOrRecoverExecution // must return a 409 and must NOT call disablePlan (no GetPurchasePlan call). func TestHandler_deletePlannedPurchase_ConflictRetryRunningReturns409(t *testing.T) { ctx := context.Background() @@ -1359,7 +1365,7 @@ func TestHandler_deletePlannedPurchase_ConflictRetryRunningReturns409(t *testing conflictErr := fmt.Errorf("%w: execution %s cannot transition", config.ErrExecutionNotInExpectedStatus, execID) - // The execution raced to "running" — not "cancelled". + // The execution raced to "running" -- not "canceled". runningExec := &config.PurchaseExecution{ ExecutionID: execID, PlanID: planID, @@ -1368,9 +1374,9 @@ func TestHandler_deletePlannedPurchase_ConflictRetryRunningReturns409(t *testing mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled", mock.Anything).Return(nil, conflictErr) + mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled", mock.Anything).Return(nil, conflictErr) //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql mockStore.On("GetExecutionByID", ctx, execID).Return(runningExec, nil) - // GetPurchasePlan must NOT be called — AssertExpectations verifies this. + // GetPurchasePlan must NOT be called -- AssertExpectations verifies this. handler := &Handler{config: mockStore, auth: mockAuth} @@ -1384,7 +1390,7 @@ func TestHandler_deletePlannedPurchase_ConflictRetryRunningReturns409(t *testing ce, ok := IsClientError(err) require.True(t, ok, "expected ClientError, got %T: %v", err, err) assert.Equal(t, 409, ce.code, "status mismatch must return 409") - assert.Contains(t, ce.message, "cannot be cancelled", "error must name the action") + assert.Contains(t, ce.message, "cannot be canceled", "error must name the action") assert.Contains(t, ce.message, "running", "error must include actual status") } @@ -1521,7 +1527,7 @@ func TestHandler_deletePlannedPurchase_NilExecution(t *testing.T) { mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"pending", "paused"}, "cancelled", mock.Anything).Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) + mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"pending", "paused"}, "cancelled", mock.Anything).Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql handler := &Handler{config: mockStore, auth: mockAuth} @@ -2254,32 +2260,34 @@ func sessionCancelReq() *events.LambdaFunctionURLRequest { // cancel commits in a single tx via CancelExecutionAtomic + // DeleteSuppressionsByExecutionTx; the mock store's WithTx default // forwards fn(nil) and CancelExecutionAtomic default returns -// (true, "cancelled", nil) when no explicit expectation is registered. +// (canceled=true, the persisted cancel status, nil) when no explicit +// expectation is registered. // // Asserts the audit-stamp invariant: when session.Email is non-empty -// the cancelledBy pointer passed to CancelExecutionAtomic must carry +// the canceledBy pointer passed to CancelExecutionAtomic must carry // that email so the DB column is stamped correctly for History UI // attribution. func runSessionCancelAllowed(t *testing.T, exec *config.PurchaseExecution, session *Session, hasAny, hasOwn bool) { t.Helper() handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, hasAny, hasOwn) - // Capture the cancelledBy pointer passed to CancelExecutionAtomic + // Capture the canceledBy pointer passed to CancelExecutionAtomic // so we can assert attribution was stamped correctly. - var capturedCancelledBy *string + var capturedCanceledBy *string mockConfig.On("CancelExecutionAtomic", mock.Anything, mock.Anything, cancelExecID, mock.Anything). Run(func(args mock.Arguments) { if v, ok := args.Get(3).(*string); ok { - capturedCancelledBy = v + capturedCanceledBy = v } }). - Return(true, "cancelled", nil) + Return(true, "cancelled", nil) //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql // When cancel succeeds the transaction must also clean up suppressions. mockConfig.On("DeleteSuppressionsByExecutionTx", mock.Anything, mock.Anything, cancelExecID). Return(nil) result, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "") require.NoError(t, err) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", result.(map[string]string)["status"]) // Verify the atomic cancel was called — this is the primary guard against // regressions that skip the conditional UPDATE. @@ -2287,8 +2295,8 @@ func runSessionCancelAllowed(t *testing.T, exec *config.PurchaseExecution, sessi // Verify suppression cleanup ran within the same transaction. mockConfig.AssertCalled(t, "DeleteSuppressionsByExecutionTx", mock.Anything, mock.Anything, cancelExecID) if session != nil && session.Email != "" { - require.NotNil(t, capturedCancelledBy, "cancelledBy must be stamped when session has an email") - assert.Equal(t, session.Email, *capturedCancelledBy, "cancelledBy must equal session.Email for audit attribution") + require.NotNil(t, capturedCanceledBy, "canceledBy must be stamped when session has an email") + assert.Equal(t, session.Email, *capturedCanceledBy, "canceledBy must equal session.Email for audit attribution") } // Verify the session-auth boundary actually fired — without this a // regression that bypassed ValidateSession (or stopped consulting @@ -2304,9 +2312,9 @@ func TestHandler_cancelPurchase_Session_Admin_AllowsAny(t *testing.T) { CreatedByUserID: &creator, } session := &Session{UserID: cancelCallerID, Email: "admin@example.com"} - // Admin == Administrators-group member, modelled as a cancel-any holder + // Admin == Administrators-group member, modeled as a cancel-any holder // (issue #907 removed the role short-circuit); the row belongs to another - // user, so cancel-any is what authorises the action. + // user, so cancel-any is what authorizes the action. runSessionCancelAllowed(t, exec, session, true, false) } @@ -2385,7 +2393,7 @@ func TestHandler_cancelPurchase_Session_RejectsTerminalStatus(t *testing.T) { _, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "") require.Error(t, err) - assert.Contains(t, err.Error(), "cannot be cancelled") + assert.Contains(t, err.Error(), "cannot be canceled") assert.Contains(t, err.Error(), "completed") mockConfig.AssertNotCalled(t, "WithTx") mockConfig.AssertNotCalled(t, "SavePurchaseExecution") @@ -2399,7 +2407,7 @@ func TestHandler_cancelPurchase_Session_RejectsTerminalStatus(t *testing.T) { // the focus on the status guard (which fires before authorizeSessionCancel) // rather than the RBAC matrix, already covered by the matrix tests above. func TestHandler_cancelPurchase_Session_RejectsEachNonCancelableStatus(t *testing.T) { - rejected := []string{"approved", "running", "paused", "failed", "expired", "completed", "cancelled"} + rejected := []string{"approved", "running", "paused", "failed", "expired", "completed", "cancelled"} //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql for _, status := range rejected { t.Run(status, func(t *testing.T) { creator := cancelCallerID @@ -2414,7 +2422,7 @@ func TestHandler_cancelPurchase_Session_RejectsEachNonCancelableStatus(t *testin _, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "") require.Error(t, err) - assert.Contains(t, err.Error(), "cannot be cancelled") + assert.Contains(t, err.Error(), "cannot be canceled") assert.Contains(t, err.Error(), status) mockConfig.AssertNotCalled(t, "WithTx") mockConfig.AssertNotCalled(t, "SavePurchaseExecution") @@ -2439,7 +2447,7 @@ func TestHandler_cancelPurchase_Session_AllowsEachCancelableStatus(t *testing.T) } session := &Session{UserID: cancelCallerID, Email: "admin@example.com"} // Caller owns the row (creator == cancelCallerID); cancel-own - // authorises it (issue #907 group-only authz). + // authorizes it (issue #907 group-only authz). runSessionCancelAllowed(t, exec, session, false, true) }) } @@ -2460,7 +2468,7 @@ func TestHandler_cancelPurchase_Session_RaceWithApprove(t *testing.T) { } session := &Session{UserID: cancelCallerID, Email: "admin@example.com"} - // Caller owns the row; cancel-own authorises it (issue #907). + // Caller owns the row; cancel-own authorizes it (issue #907). handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false, true) // Simulate concurrent approve winning between IsCancelable check and // the conditional UPDATE inside the tx. @@ -2560,33 +2568,34 @@ func TestHandler_cancelPurchase_DeepLink_AdminBypassesContactEmailGate(t *testin // test is independent of how that authority is derived. handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, true, false) - // Capture cancelledBy to verify the audit-stamp is passed to the + // Capture canceledBy to verify the audit-stamp is passed to the // atomic UPDATE. - var capturedCancelledBy *string + var capturedCanceledBy *string mockConfig.On("CancelExecutionAtomic", mock.Anything, mock.Anything, cancelExecID, mock.Anything). Run(func(args mock.Arguments) { if v, ok := args.Get(3).(*string); ok { - capturedCancelledBy = v + capturedCanceledBy = v } }). - Return(true, "cancelled", nil) + Return(true, "cancelled", nil) //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql - // Token IS present in the URL — the deep-link flow always sends one. + // Token IS present in the URL -- the deep-link flow always sends one. // The fix's whole point is that the admin session takes the // session-authed branch instead of routing through the token path. result, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "deep-link-token") require.NoError(t, err, "admin clicking Cancel from notification email must succeed even when no contact_email is configured") + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", result.(map[string]string)["status"]) - require.NotNil(t, capturedCancelledBy, "session-authed branch must stamp cancelledBy") - assert.Equal(t, session.Email, *capturedCancelledBy) + require.NotNil(t, capturedCanceledBy, "session-authed branch must stamp canceledBy") + assert.Equal(t, session.Email, *capturedCanceledBy) // Critical security assertion: the token branch's contact_email gate // (authorizeApprovalAction -> GetGlobalConfig -> resolveApprovalRecipients) // was NOT consulted. If a regression re-routed admins through the // token path, GetGlobalConfig would fire because the gate fetches // the global notification email; asserting it didn't is the cleanest - // way to pin the new branch behaviour. + // way to pin the new branch behavior. mockConfig.AssertNotCalled(t, "GetGlobalConfig", mock.Anything) mockAuth.AssertExpectations(t) } @@ -2611,10 +2620,11 @@ func TestHandler_cancelPurchase_DeepLink_CancelOwnBypassesContactEmailGate(t *te handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, false /*hasAny*/, true /*hasOwn*/) // CancelExecutionAtomic is called by the session-authed branch. mockConfig.On("CancelExecutionAtomic", mock.Anything, mock.Anything, cancelExecID, mock.Anything). - Return(true, "cancelled", nil) + Return(true, "cancelled", nil) //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql result, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "deep-link-token") require.NoError(t, err) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", result.(map[string]string)["status"]) mockConfig.AssertNotCalled(t, "GetGlobalConfig", mock.Anything) mockAuth.AssertExpectations(t) @@ -2627,15 +2637,15 @@ func TestHandler_cancelPurchase_DeepLink_CancelOwnBypassesContactEmailGate(t *te // triggers the fall-through to the contact_email gate. func TestIsPermissionDenied(t *testing.T) { cases := []struct { - name string err error + name string want bool }{ - {"nil error is not denial", nil, false}, - {"plain 403 ClientError is denial", NewClientError(403, "permission denied"), true}, - {"500 ClientError is not denial", NewClientError(500, "auth service down"), false}, - {"401 ClientError is not denial", NewClientError(401, "no session"), false}, - {"non-ClientError is not denial", errors.New("auth backend timeout"), false}, + {name: "nil error is not denial", err: nil, want: false}, + {name: "plain 403 ClientError is denial", err: NewClientError(403, "permission denied"), want: true}, + {name: "500 ClientError is not denial", err: NewClientError(500, "auth service down"), want: false}, + {name: "401 ClientError is not denial", err: NewClientError(401, "no session"), want: false}, + {name: "non-ClientError is not denial", err: errors.New("auth backend timeout"), want: false}, { name: "wrapped 403 is NOT denial (errors.As-style unwrap is rejected)", err: fmt.Errorf("permission check failed: %w", NewClientError(403, "denied")), @@ -2824,7 +2834,7 @@ func TestHandler_retryPurchase_Admin_AllowsAny(t *testing.T) { Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } session := &Session{UserID: retryCallerID, Email: "admin@example.com"} - // Admin (Administrators-group member) modelled as a retry-any holder; the + // Admin (Administrators-group member) modeled as a retry-any holder; the // row belongs to another user (issue #907 group-only authz). newExec, updated := runSessionRetryAllowed(t, failed, session, true, false, sessionRetryReq()) assert.Equal(t, "pending", newExec.Status) @@ -2940,7 +2950,7 @@ func TestHandler_retryPurchase_PersistentFailure_BlocksWithOpsHint(t *testing.T) CreatedByUserID: &creator, } session := &Session{UserID: retryCallerID} - // Caller owns the row; retry-own authorises it (issue #907). + // Caller owns the row; retry-own authorizes it (issue #907). handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, true) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) @@ -2968,7 +2978,7 @@ func TestHandler_retryPurchase_PersistentFailure_NoMatch_AllowsRetry(t *testing. Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } session := &Session{UserID: retryCallerID} - // Caller owns the row; retry-own authorises it (issue #907). + // Caller owns the row; retry-own authorizes it (issue #907). runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReq()) } @@ -2982,7 +2992,7 @@ func TestHandler_retryPurchase_Threshold_BlocksAtFive_NoForce(t *testing.T) { Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } session := &Session{UserID: retryCallerID} - // Caller owns the row; retry-own authorises it (issue #907). + // Caller owns the row; retry-own authorizes it (issue #907). handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, true) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) @@ -3005,7 +3015,7 @@ func TestHandler_retryPurchase_Threshold_AllowsWithForce(t *testing.T) { Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } session := &Session{UserID: retryCallerID} - // Caller owns the row; retry-own authorises it (issue #907). + // Caller owns the row; retry-own authorizes it (issue #907). newExec, _ := runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReqWithForce()) assert.Equal(t, 6, newExec.RetryAttemptN, "force=true past threshold still increments the chain count") } @@ -3020,7 +3030,7 @@ func TestHandler_retryPurchase_JustUnderThreshold_AllowsNoForce(t *testing.T) { Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } session := &Session{UserID: retryCallerID} - // Caller owns the row; retry-own authorises it (issue #907). + // Caller owns the row; retry-own authorizes it (issue #907). newExec, _ := runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReq()) assert.Equal(t, 5, newExec.RetryAttemptN) } @@ -3039,7 +3049,7 @@ func TestHandler_retryPurchase_AlreadyRetried_Rejects(t *testing.T) { Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } session := &Session{UserID: retryCallerID} - // Caller owns the row; retry-own authorises it (issue #907). + // Caller owns the row; retry-own authorizes it (issue #907). handler, mockConfig, _ := buildSessionRetryHandler(failed, session, false, true) _, err := handler.retryPurchase(context.Background(), sessionRetryReq(), retryExecID) require.Error(t, err) @@ -3077,7 +3087,7 @@ func TestHandler_retryPurchase_PreservesPlanMetadata(t *testing.T) { Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } session := &Session{UserID: retryCallerID} - // Caller owns the row; retry-own authorises it (issue #907). + // Caller owns the row; retry-own authorizes it (issue #907). newExec, _ := runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReq()) assert.Equal(t, "plan-abc", newExec.PlanID, "successor must inherit predecessor PlanID") assert.Equal(t, 3, newExec.StepNumber, "successor must inherit predecessor StepNumber") @@ -3134,7 +3144,7 @@ func TestPersistRetryExecution_ApprovalTokenNotUUID(t *testing.T) { Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Term: 1}}, } session := &Session{UserID: retryCallerID, Email: "admin@example.com"} - // Caller owns the row; retry-own authorises it (issue #907). + // Caller owns the row; retry-own authorizes it (issue #907). newExec, _ := runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReq()) // 64 hex characters = 32 bytes = 256 bits. UUID format is 36 chars @@ -3165,7 +3175,7 @@ func TestPersistRetryExecution_ApprovalTokenExpiresAtSet(t *testing.T) { session := &Session{UserID: retryCallerID, Email: "admin@example.com"} before := time.Now() - // Caller owns the row; retry-own authorises it (issue #907). + // Caller owns the row; retry-own authorizes it (issue #907). newExec, _ := runSessionRetryAllowed(t, failed, session, false, true, sessionRetryReq()) after := time.Now() @@ -3528,7 +3538,7 @@ func TestHandler_authorizeSessionExecuteDirect_NilAuth(t *testing.T) { // cancel a scheduled purchase created by ANOTHER user. They drive the real // handlers end-to-end (ValidateSession -> requirePermission -> account scope // -> authorizeExecutionManagement -> transition) and FAIL against the pre-fix -// handler, which honoured the request because only update:purchases was +// handler, which honored the request because only update:purchases was // checked. const ownExecID = "12121212-1212-1212-1212-121212121212" @@ -3538,7 +3548,7 @@ const ownUserB = "bbbb2222-2222-2222-2222-222222222222" // creator of P2 // buildManageHandler wires a non-admin "user-token" session for userID with // account access (empty allowed_accounts -> all accessible) and the given // update-any grant. The stored execution is created by creatorID. -func buildManageHandler(userID, creatorID string, hasUpdateAny bool) (*Handler, *MockConfigStore, *MockAuthService) { +func buildManageHandler(userID, creatorID string, hasUpdateAny bool) (*Handler, *MockConfigStore, *MockAuthService) { //nolint:unparam // userID always ownUserA in current tests; parameterized for fixture flexibility mockAuth := new(MockAuthService) mockAuth.On("ValidateSession", mock.Anything, "user-token").Return(&Session{UserID: userID}, nil) mockAuth.On("HasPermissionAPI", mock.Anything, userID, "update", "purchases").Return(true, nil).Maybe() @@ -3813,13 +3823,13 @@ func TestGatherAccountContactEmails_DBError_NoPIILeak(t *testing.T) { // TestHandler_scheduleApprovedExecution_CASGuardsConcurrentCancel verifies the // CAS safety property of scheduleApprovedExecution (Finding #2): if a -// concurrent Cancel flips the execution to "cancelled" before the approve +// concurrent Cancel flips the execution to "canceled" before the approve // writes, TransitionExecutionStatus returns ErrExecutionNotInExpectedStatus and // scheduleApprovedExecution surfaces that error rather than silently -// overwriting the cancelled state. +// overwriting the canceled state. // // In the old blind-write code, SavePurchaseExecution would overwrite the -// "cancelled" row with status="scheduled", losing the revoke. With the CAS fix +// "canceled" row with status="scheduled", losing the revoke. With the CAS fix // the row is never touched after a concurrent cancel wins. func TestHandler_scheduleApprovedExecution_CASGuardsConcurrentCancel(t *testing.T) { ctx := context.Background() @@ -3830,7 +3840,7 @@ func TestHandler_scheduleApprovedExecution_CASGuardsConcurrentCancel(t *testing. Status: "pending", } - concurrentCancelErr := fmt.Errorf("%w: execution %s is in status \"cancelled\", not one of [pending notified]", + concurrentCancelErr := fmt.Errorf("%w: execution %s is in status \"canceled\", not one of [pending notified]", config.ErrExecutionNotInExpectedStatus, execID) mockConfig := new(MockConfigStore) @@ -3842,7 +3852,7 @@ func TestHandler_scheduleApprovedExecution_CASGuardsConcurrentCancel(t *testing. _, err := handler.scheduleApprovedExecution(ctx, exec, 48*time.Hour, "actor@example.com", nil) require.Error(t, err, "concurrent cancel must surface as an error, not a silent overwrite") - // SavePurchaseExecution must NEVER be called: the cancelled row is untouched. + // SavePurchaseExecution must NEVER be called: the canceled row is untouched. mockConfig.AssertNotCalled(t, "SavePurchaseExecution", mock.Anything, mock.Anything) mockConfig.AssertExpectations(t) } @@ -3891,7 +3901,7 @@ func TestApproveWithDelay_CASLostMaps409(t *testing.T) { execID := "exec-cas-409" exec := &config.PurchaseExecution{ExecutionID: execID, Status: "pending"} - concurrentCancelErr := fmt.Errorf("%w: execution %s already cancelled", + concurrentCancelErr := fmt.Errorf("%w: execution %s already canceled", config.ErrExecutionNotInExpectedStatus, execID) mockConfig := new(MockConfigStore) diff --git a/internal/api/handler_recommendations.go b/internal/api/handler_recommendations.go index 943cc6782..aea7ac48d 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): @@ -37,16 +37,16 @@ import ( // min_savings_pct: effective savings percentage floor (0-100 scale). // Both are optional; absent or "0" means no floor. Fractions are rejected (a // user typing "30%" expects a percentage, not $30.5). -func parseRecommendationFilter(params map[string]string) (config.RecommendationFilter, error) { +func parseRecommendationFilter(params map[string]string) (*config.RecommendationFilter, error) { // Validate input parameters to prevent injection attacks. if err := validateProvider(params["provider"]); err != nil { - return config.RecommendationFilter{}, err + return nil, err } if err := validateServiceName(params["service"]); err != nil { - return config.RecommendationFilter{}, err + return nil, err } if err := validateRegion(params["region"]); err != nil { - return config.RecommendationFilter{}, err + return nil, err } // parseAccountIDs splits, trims, and UUID-validates the comma-separated @@ -55,22 +55,22 @@ func parseRecommendationFilter(params map[string]string) (config.RecommendationF // MaxAccountIDsPerRequest (200). See validation.go::parseAccountIDs. accountIDs, err := parseAccountIDs(params["account_ids"]) if err != nil { - return config.RecommendationFilter{}, NewClientError(400, err.Error()) + return nil, NewClientError(400, err.Error()) } minSavingsUSD, err := parseMinSavingsParam(params["min_savings_usd"], "min_savings_usd") if err != nil { - return config.RecommendationFilter{}, err + return nil, err } minSavingsPct, err := parseMinSavingsParam(params["min_savings_pct"], "min_savings_pct") if err != nil { - return config.RecommendationFilter{}, err + return nil, err } if minSavingsPct < 0 || minSavingsPct > 100 { - return config.RecommendationFilter{}, NewClientError(400, "min_savings_pct must be between 0 and 100") + return nil, NewClientError(400, "min_savings_pct must be between 0 and 100") } - return config.RecommendationFilter{ + return &config.RecommendationFilter{ Provider: params["provider"], Service: params["service"], Region: params["region"], @@ -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") @@ -129,18 +129,18 @@ func (h *Handler) filterRecommendationsByAllowedAccounts(ctx context.Context, se return nil, fmt.Errorf("failed to list accounts for filter: %w", err) } nameByID := make(map[string]string, len(accounts)) - for _, a := range accounts { - nameByID[a.ID] = a.Name + for i := range accounts { + nameByID[accounts[i].ID] = accounts[i].Name } filtered := recs[:0] - for _, rec := range recs { - if rec.CloudAccountID == nil { + for i := range recs { + if recs[i].CloudAccountID == nil { continue } - id := *rec.CloudAccountID + id := *recs[i].CloudAccountID if auth.MatchesAccount(allowedAccounts, id, nameByID[id]) { - filtered = append(filtered, rec) + filtered = append(filtered, recs[i]) } } return filtered, nil @@ -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..53895819e 100644 --- a/internal/api/handler_recommendations_refresh.go +++ b/internal/api/handler_recommendations_refresh.go @@ -59,9 +59,8 @@ func (h *Handler) postRefreshRecommendations(ctx context.Context, req *events.La return nil, err } - // Read current freshness so we can include last_collected_at in the 202 body. - freshness, err := h.config.GetRecommendationsFreshness(ctx) - if err != nil { + // Pre-flight check: verify freshness is readable before marking started. + if _, err := h.config.GetRecommendationsFreshness(ctx); err != nil { return nil, fmt.Errorf("failed to read freshness: %w", err) } @@ -75,7 +74,7 @@ func (h *Handler) postRefreshRecommendations(ctx context.Context, req *events.La return nil, NewClientError(409, "recommendation collection already in progress; try again in a few minutes") } - freshness, err = h.runMarkedCollection(ctx) + freshness, err := h.runMarkedCollection(ctx) if err != nil { return nil, err } @@ -133,7 +132,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 { @@ -204,7 +203,7 @@ func (h *Handler) getLambdaInvoker(ctx context.Context) (LambdaInvokerInterface, // 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) { +func (h *Handler) triggerColdStartCollect(ctx context.Context) (*config.RecommendationsFreshness, error) { //nolint:unused // retained for cold-start orchestration path schedulerARN := os.Getenv("SCHEDULER_LAMBDA_ARN") if schedulerARN != "" { // Atomic mark. ok=false means another caller already marked it — we diff --git a/internal/api/handler_recommendations_test.go b/internal/api/handler_recommendations_test.go index 8fabe8521..12ad844f1 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) @@ -470,7 +470,7 @@ func TestGetRecommendations_MinSavingsFilters(t *testing.T) { t.Run("min_savings_usd filter is wired to RecommendationFilter.MinSavingsUSD not MinSavingsPct", func(t *testing.T) { mockScheduler := new(MockScheduler) - mockScheduler.On("ListRecommendations", ctx, mock.MatchedBy(func(f config.RecommendationFilter) bool { + mockScheduler.On("ListRecommendations", ctx, mock.MatchedBy(func(f *config.RecommendationFilter) bool { // Dollar filter must be set; percentage filter must be zero. return f.MinSavingsUSD == 30 && f.MinSavingsPct == 0 })).Return([]config.RecommendationRecord{}, nil) @@ -487,7 +487,7 @@ func TestGetRecommendations_MinSavingsFilters(t *testing.T) { t.Run("min_savings_pct filter is wired to RecommendationFilter.MinSavingsPct not MinSavingsUSD", func(t *testing.T) { mockScheduler := new(MockScheduler) - mockScheduler.On("ListRecommendations", ctx, mock.MatchedBy(func(f config.RecommendationFilter) bool { + mockScheduler.On("ListRecommendations", ctx, mock.MatchedBy(func(f *config.RecommendationFilter) bool { // Percentage filter must be set; dollar filter must be zero. return f.MinSavingsPct == 30 && f.MinSavingsUSD == 0 })).Return([]config.RecommendationRecord{}, nil) @@ -505,7 +505,7 @@ func TestGetRecommendations_MinSavingsFilters(t *testing.T) { t.Run("both filters can be combined independently", func(t *testing.T) { mockScheduler := new(MockScheduler) - mockScheduler.On("ListRecommendations", ctx, mock.MatchedBy(func(f config.RecommendationFilter) bool { + mockScheduler.On("ListRecommendations", ctx, mock.MatchedBy(func(f *config.RecommendationFilter) bool { return f.MinSavingsUSD == 50 && f.MinSavingsPct == 20 })).Return([]config.RecommendationRecord{}, nil) t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) @@ -560,7 +560,7 @@ func TestGetRecommendations_MinSavingsFilters(t *testing.T) { t.Run("absent filters pass through zero values in RecommendationFilter", func(t *testing.T) { mockScheduler := new(MockScheduler) - mockScheduler.On("ListRecommendations", ctx, mock.MatchedBy(func(f config.RecommendationFilter) bool { + mockScheduler.On("ListRecommendations", ctx, mock.MatchedBy(func(f *config.RecommendationFilter) bool { return f.MinSavingsUSD == 0 && f.MinSavingsPct == 0 })).Return([]config.RecommendationRecord{}, nil) t.Cleanup(func() { mockScheduler.AssertExpectations(t) }) @@ -577,15 +577,15 @@ func TestGetRecommendations_MinSavingsFilters(t *testing.T) { func TestConfidenceBucketFor(t *testing.T) { cases := []struct { name string + want string savings float64 count int - want string }{ - {"high requires both signals", 250, 4, "high"}, - {"savings without fleet falls to medium", 250, 1, "medium"}, - {"medium on savings alone", 60, 1, "medium"}, - {"low when neither threshold is met", 10, 1, "low"}, - {"count clamped to >=1 — savings still drives bucket", 60, 0, "medium"}, + {name: "high requires both signals", savings: 250, count: 4, want: "high"}, + {name: "savings without fleet falls to medium", savings: 250, count: 1, want: "medium"}, + {name: "medium on savings alone", savings: 60, count: 1, want: "medium"}, + {name: "low when neither threshold is met", savings: 10, count: 1, want: "low"}, + {name: "count clamped to >=1 — savings still drives bucket", savings: 60, count: 0, want: "medium"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/api/handler_registrations.go b/internal/api/handler_registrations.go index b9d7a8032..ffafe8000 100644 --- a/internal/api/handler_registrations.go +++ b/internal/api/handler_registrations.go @@ -112,7 +112,7 @@ func (h *Handler) submitRegistration(ctx context.Context, req *events.LambdaFunc // Notify admins (synchronous, errors logged but not propagated). if h.emailNotifier != nil { to, cc, approvers := h.resolveRegistrationRecipients(ctx) - if notifyErr := h.emailNotifier.SendRegistrationReceivedNotification(context.Background(), email.RegistrationNotificationData{ + if notifyErr := h.emailNotifier.SendRegistrationReceivedNotification(context.Background(), &email.RegistrationNotificationData{ AccountName: body.AccountName, Provider: body.Provider, ExternalID: body.ExternalID, @@ -237,11 +237,11 @@ func (h *Handler) encryptRegistrationCredential(payload string) (string, error) // notifyRegistrant sends an email about an approval or rejection. // Errors are logged but not propagated (matching sendPurchaseApprovalEmail pattern). -func (h *Handler) notifyRegistrant(reg *config.AccountRegistration, data email.RegistrationDecisionData) { +func (h *Handler) notifyRegistrant(reg *config.AccountRegistration, data email.RegistrationDecisionData) { //nolint:gocritic if h.emailNotifier == nil || reg.ContactEmail == "" { return } - if err := h.emailNotifier.SendRegistrationDecisionNotification(context.Background(), reg.ContactEmail, data); err != nil { + if err := h.emailNotifier.SendRegistrationDecisionNotification(context.Background(), reg.ContactEmail, &data); err != nil { logging.Warnf("failed to send registration decision notification: %v", err) } } @@ -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 @@ -467,7 +467,7 @@ func generateReferenceToken() (string, error) { // // The account's own ContactEmail is NOT included in the approver set // because the submitter can't review their own registration. -func (h *Handler) resolveRegistrationRecipients(ctx context.Context) (to string, cc []string, approvers []string) { +func (h *Handler) resolveRegistrationRecipients(ctx context.Context) (to string, cc, approvers []string) { adminEmails := h.gatherAdminEmails(ctx) globalNotify := h.globalNotificationEmail(ctx) @@ -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_autoenable_test.go b/internal/api/handler_registrations_autoenable_test.go index acbeb3f84..222723afa 100644 --- a/internal/api/handler_registrations_autoenable_test.go +++ b/internal/api/handler_registrations_autoenable_test.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name matches directory per Go convention import ( "testing" @@ -8,8 +8,8 @@ import ( func TestAccountHasCredentialFreePath(t *testing.T) { cases := []struct { - name string acct *config.CloudAccount + name string want bool }{ { 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..8c9463ea5 100644 --- a/internal/api/handler_ri_exchange.go +++ b/internal/api/handler_ri_exchange.go @@ -50,10 +50,10 @@ 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 { +func (h *Handler) buildReshapeEC2Client(cfg aws.Config) reshapeEC2Client { //nolint:gocritic if h.reshapeEC2Factory != nil { return h.reshapeEC2Factory(cfg) } @@ -62,7 +62,7 @@ func (h *Handler) buildReshapeEC2Client(cfg aws.Config) reshapeEC2Client { // buildReshapeRecsClient mirrors buildReshapeEC2Client for the // recommendations adapter. -func (h *Handler) buildReshapeRecsClient(cfg aws.Config) reshapeRecsClient { +func (h *Handler) buildReshapeRecsClient(cfg aws.Config) reshapeRecsClient { //nolint:gocritic if h.reshapeRecsFactory != nil { return h.reshapeRecsFactory(cfg) } @@ -77,9 +77,9 @@ 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 { +func (h *Handler) buildTargetOfferingsEC2Client(cfg aws.Config) targetOfferingsEC2Client { //nolint:gocritic if h.targetOfferingsEC2Factory != nil { return h.targetOfferingsEC2Factory(cfg) } @@ -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 @@ -164,7 +164,7 @@ func (h *Handler) listTargetOfferings(ctx context.Context, req *events.LambdaFun // azureExchangeClient is the narrow interface that listExchangeableAzureRIs // needs from the Azure compute client. Satisfied by -// *azurecompute.ComputeClient; a stub can be injected via +// *azurecompute.Client; a stub can be injected via // Handler.azureExchangeFactory for tests. type azureExchangeClient interface { ListExchangeableReservations(ctx context.Context) ([]azurecompute.ExchangeableReservation, error) @@ -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 @@ -447,7 +447,7 @@ func parseThresholdParam(params map[string]string) (float64, error) { // Used to populate exchange.RIInfo.MonthlyCost so the cross-family // dollar-units pre-filter can compare against per-target offering // costs computed with the same formula. -func monthlyCostFromConvertibleRI(ri ec2svc.ConvertibleRI) float64 { +func monthlyCostFromConvertibleRI(ri ec2svc.ConvertibleRI) float64 { //nolint:gocritic if ri.Duration <= 0 { return 0 } @@ -461,7 +461,7 @@ func monthlyCostFromConvertibleRI(ri ec2svc.ConvertibleRI) float64 { // convertToExchangeTypes converts provider-specific types to the exchange package types. func convertToExchangeTypes(instances []ec2svc.ConvertibleRI, utilData []recommendations.RIUtilization) ([]exchange.RIInfo, []exchange.UtilizationInfo) { riInfos := make([]exchange.RIInfo, len(instances)) - for i, inst := range instances { + for i, inst := range instances { //nolint:gocritic riInfos[i] = exchange.RIInfo{ ID: inst.ReservedInstanceID, InstanceType: inst.InstanceType, @@ -491,9 +491,9 @@ func convertToExchangeTypes(instances []ec2svc.ConvertibleRI, utilData []recomme // reshapeRequestParams groups parsed query parameters for getReshapeRecommendations. type reshapeRequestParams struct { + region string threshold float64 lookbackDays int - region string } // parseReshapeParams parses the threshold, lookback_days, and region query @@ -613,7 +613,7 @@ func (h *Handler) attachReshapeStaleness(ctx context.Context, resp *ReshapeRecom // populated value is sufficient and avoids a noisy mismatch panic when // some entries are missing the field. func firstNonEmptyCurrency(instances []ec2svc.ConvertibleRI) string { - for _, inst := range instances { + for _, inst := range instances { //nolint:gocritic if inst.CurrencyCode != "" { return inst.CurrencyCode } @@ -669,7 +669,7 @@ func (h *Handler) getExchangeQuote(ctx context.Context, req *events.LambdaFuncti } region := cfg.Region - quote, err := exchange.GetExchangeQuote(ctx, exchange.ExchangeQuoteRequest{ + quote, err := exchange.GetExchangeQuote(ctx, &exchange.QuoteRequest{ Region: region, ReservedIDs: body.RIIDs, Targets: toExchangeTargets(body.Targets), @@ -689,7 +689,7 @@ func (h *Handler) getExchangeQuote(ctx context.Context, req *events.LambdaFuncti // Extracted from executeExchange to keep the handler below the // cyclomatic-complexity threshold; every branch here becomes a // separate test case so the logic stays inspectable. -func validateExecuteExchangeBody(body ExchangeExecuteRequestBody) error { +func validateExecuteExchangeBody(body ExchangeExecuteRequestBody) error { //nolint:gocritic if len(body.RIIDs) == 0 { return NewClientError(400, "ri_ids is required") } @@ -735,7 +735,7 @@ func (h *Handler) executeExchange(ctx context.Context, req *events.LambdaFunctio region := body.Region - exchangeID, quote, err := exchange.ExecuteExchange(ctx, exchange.ExchangeExecuteRequest{ + exchangeID, quote, err := exchange.ExecuteExchange(ctx, &exchange.ExecuteRequest{ Region: region, ReservedIDs: body.RIIDs, Targets: toExchangeTargets(body.Targets), @@ -806,9 +806,9 @@ type RIUtilizationResponse struct { // RecsCollectedAt carries the raw timestamp so the frontend can build // its own relative-time label ("last collected 23h ago"). type ReshapeRecommendationsResponse struct { - Recommendations []exchange.ReshapeRecommendation `json:"recommendations"` - RecsStaleness string `json:"recs_staleness,omitempty"` RecsCollectedAt *time.Time `json:"recs_collected_at,omitempty"` + RecsStaleness string `json:"recs_staleness,omitempty"` + Recommendations []exchange.ReshapeRecommendation `json:"recommendations"` } // reshapeSoftStaleThreshold is the age at which the reshape recs banner @@ -848,11 +848,11 @@ type ExchangeTargetBody struct { // both are present, `targets` wins. Exactly one of them must be // provided (or the handler returns 400). type ExchangeQuoteRequestBody struct { + TargetOfferingID string `json:"target_offering_id,omitempty"` + Region string `json:"region,omitempty"` RIIDs []string `json:"ri_ids"` Targets []ExchangeTargetBody `json:"targets,omitempty"` - TargetOfferingID string `json:"target_offering_id,omitempty"` TargetCount int32 `json:"target_count,omitempty"` - Region string `json:"region,omitempty"` } // ExchangeExecuteRequestBody is the request body for the execute endpoint. @@ -862,12 +862,12 @@ type ExchangeQuoteRequestBody struct { // checking naturally becomes a total when `targets[]` has multiple // entries. type ExchangeExecuteRequestBody struct { - RIIDs []string `json:"ri_ids"` - Targets []ExchangeTargetBody `json:"targets,omitempty"` TargetOfferingID string `json:"target_offering_id,omitempty"` - TargetCount int32 `json:"target_count,omitempty"` MaxPaymentDueUSD string `json:"max_payment_due_usd"` Region string `json:"region,omitempty"` + RIIDs []string `json:"ri_ids"` + Targets []ExchangeTargetBody `json:"targets,omitempty"` + TargetCount int32 `json:"target_count,omitempty"` } // toExchangeTargets converts the HTTP-shaped targets into the @@ -887,8 +887,8 @@ func toExchangeTargets(targets []ExchangeTargetBody) []exchange.TargetConfig { // ExchangeExecuteResponse is the response from a successful exchange execution. type ExchangeExecuteResponse struct { - ExchangeID string `json:"exchange_id"` - Quote *exchange.ExchangeQuoteSummary `json:"quote"` + Quote *exchange.QuoteSummary `json:"quote"` + ExchangeID string `json:"exchange_id"` } // getRIExchangeConfig returns the current RI exchange automation settings. @@ -973,7 +973,7 @@ func (h *Handler) getRIExchangeHistory(ctx context.Context, req *events.LambdaFu if !auth.IsUnrestrictedAccess(allowed) { nameByID := h.resolveAccountNamesByID(ctx) filtered := records[:0] - for _, r := range records { + for _, r := range records { //nolint:gocritic if auth.MatchesAccount(allowed, r.AccountID, nameByID[r.AccountID]) { filtered = append(filtered, r) } @@ -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) { @@ -1264,11 +1264,11 @@ func (h *Handler) executeApprovedExchange(ctx context.Context, id string, record } perExchangeCap := new(big.Rat).SetFloat64(globalCfg.RIExchangeMaxPerExchangeUSD) - exchangeID, _, execErr := exchange.ExecuteExchange(ctx, exchange.ExchangeExecuteRequest{ + exchangeID, _, execErr := exchange.ExecuteExchange(ctx, &exchange.ExecuteRequest{ Region: region, ReservedIDs: record.SourceRIIDs, TargetOfferingID: record.TargetOfferingID, - TargetCount: int32(record.TargetCount), + TargetCount: int32(record.TargetCount), //nolint:gosec MaxPaymentDueUSD: perExchangeCap, }) if execErr != nil { @@ -1333,35 +1333,36 @@ func (h *Handler) rejectRIExchange(ctx context.Context, id, token string) (any, } // Token-based rejection: no session user, so transitioned_by = NULL. + //nolint:misspell // DB schema value 'cancelled' -- see migration 000009_ri_exchange_history.up.sql transitioned, err := h.config.TransitionRIExchangeStatus(ctx, id, "pending", "cancelled", nil) if err != nil { 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") + return nil, NewClientError(409, "exchange already processed, expired, or was cancelled") //nolint:misspell // DB schema value } - return map[string]string{"status": "cancelled"}, nil + return map[string]string{"status": "cancelled"}, nil //nolint:misspell // DB schema value } // RIExchangeConfigResponse is the response for GET /api/ri-exchange/config. type RIExchangeConfigResponse struct { - AutoExchangeEnabled bool `json:"auto_exchange_enabled"` Mode string `json:"mode"` UtilizationThreshold float64 `json:"utilization_threshold"` MaxPaymentPerExchangeUSD float64 `json:"max_payment_per_exchange_usd"` MaxPaymentDailyUSD float64 `json:"max_payment_daily_usd"` LookbackDays int `json:"lookback_days"` + AutoExchangeEnabled bool `json:"auto_exchange_enabled"` } // RIExchangeConfigUpdateRequest is the request body for PUT /api/ri-exchange/config. type RIExchangeConfigUpdateRequest struct { - AutoExchangeEnabled bool `json:"auto_exchange_enabled"` Mode string `json:"mode"` UtilizationThreshold float64 `json:"utilization_threshold"` MaxPaymentPerExchangeUSD float64 `json:"max_payment_per_exchange_usd"` MaxPaymentDailyUSD float64 `json:"max_payment_daily_usd"` LookbackDays int `json:"lookback_days"` + AutoExchangeEnabled bool `json:"auto_exchange_enabled"` } func (r *RIExchangeConfigUpdateRequest) validate() error { diff --git a/internal/api/handler_ri_exchange_integration_test.go b/internal/api/handler_ri_exchange_integration_test.go index 865ddb666..e8307ebcc 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 @@ -100,7 +100,7 @@ func reshapeRequest() *events.LambdaFunctionURLRequest { // recommendations directly into the store so the reshape lookup has // something to read. Bypasses the scheduler so the test only exercises // the read-side mapping logic. -func seedRecsForRegion(ctx context.Context, t *testing.T, store *config.PostgresStore, region string, recs []config.RecommendationRecord) { +func seedRecsForRegion(ctx context.Context, t *testing.T, store *config.PostgresStore, _ string, recs []config.RecommendationRecord) { t.Helper() require.NoError(t, store.ReplaceRecommendations(ctx, time.Now(), recs), "seeding recommendations into the test container failed") @@ -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_ri_exchange_test.go b/internal/api/handler_ri_exchange_test.go index 250180c49..d60914b33 100644 --- a/internal/api/handler_ri_exchange_test.go +++ b/internal/api/handler_ri_exchange_test.go @@ -26,7 +26,7 @@ func TestListConvertibleRIs_RequiresAdmin(t *testing.T) { assert.Contains(t, err.Error(), "authentication") } -// issue #871: the AWS convertible-RI list must honour the Main Header global +// issue #871: the AWS convertible-RI list must honor the Main Header global // account filter. When the ?account_id= chip selects an account other than the // running (ambient) AWS account, none of these RIs belong to it, so the // handler returns an empty list without touching AWS config. @@ -262,7 +262,8 @@ func TestRejectRIExchange_AlreadyCompleted(t *testing.T) { ExchangeID: "exch-already-done", }, nil) - // Transition from pending→cancelled fails (record is not pending) + // Transition from pending to cancelled fails (record is not pending). + //nolint:misspell // DB schema value 'cancelled' -- see migration 000009_ri_exchange_history.up.sql mockStore.On("TransitionRIExchangeStatus", ctx, id, "pending", "cancelled", mock.Anything). Return((*config.RIExchangeRecord)(nil), nil) @@ -283,14 +284,14 @@ func TestApproveRIExchange_AlreadyCancelled(t *testing.T) { id := "550e8400-e29b-41d4-a716-446655440001" token := "valid-token-456" - // Record exists but was cancelled by a newer analysis run + // Record exists but was canceled by a newer analysis run mockStore.On("GetRIExchangeRecord", ctx, id).Return(&config.RIExchangeRecord{ ID: id, ApprovalToken: token, - Status: "cancelled", + Status: "cancelled", //nolint:misspell // DB schema value 'cancelled' -- see migration 000009_ri_exchange_history.up.sql }, nil) - // Transition from pending→processing fails (record is cancelled) + // Transition from pending→processing fails (record is canceled) mockStore.On("TransitionRIExchangeStatus", ctx, id, "pending", "processing", mock.Anything). Return((*config.RIExchangeRecord)(nil), nil) @@ -529,7 +530,7 @@ func TestRejectRIExchange_MissingToken(t *testing.T) { // TestRejectRIExchange_EmptyStoredToken is a regression test for issue #399. // A record with an empty ApprovalToken must be rejected with 403 rather than -// being cancelled by any caller passing an empty token string, because +// being canceled by any caller passing an empty token string, because // crypto/subtle.ConstantTimeCompare([]byte(""), []byte("")) == 1. func TestRejectRIExchange_EmptyStoredToken(t *testing.T) { mockStore := new(MockConfigStore) @@ -615,11 +616,11 @@ func TestGetReshapeRecommendations_EmptyRegionUsesConfigRegion(t *testing.T) { // the return value. Returning nil/nil from ListStoredRecommendations // means "no recs in this region" which the downstream pipeline // treats as empty alternatives — fine for our purposes. - var capturedFilters []config.RecommendationFilter + var capturedFilters []*config.RecommendationFilter mockStore.On("ListStoredRecommendations", mock.Anything, mock.Anything). Return([]config.RecommendationRecord(nil), nil). Run(func(args mock.Arguments) { - capturedFilters = append(capturedFilters, args.Get(1).(config.RecommendationFilter)) + capturedFilters = append(capturedFilters, args.Get(1).(*config.RecommendationFilter)) }) h := &Handler{ @@ -693,7 +694,7 @@ func TestGetReshapeRecommendations_EmptyRegionUsesConfigRegion(t *testing.T) { } } -// Suppress unused import warnings +// Suppress unused import warnings. var _ = mock.Anything var _ = time.Now var _ = config.RIExchangeRecord{} @@ -703,8 +704,8 @@ var _ = config.RIExchangeRecord{} // stubAzureExchangeClient is a minimal implementation of azureExchangeClient // for unit tests. type stubAzureExchangeClient struct { - reservations []azurecompute.ExchangeableReservation err error + reservations []azurecompute.ExchangeableReservation } func (s *stubAzureExchangeClient) ListExchangeableReservations(_ context.Context) ([]azurecompute.ExchangeableReservation, error) { @@ -1056,9 +1057,9 @@ func (m *mockAuthForExchange) MFARegenerateRecoveryCodesAPI(_ context.Context, _ // step. Both are configurable per-test so we can exercise the 404 and // happy paths without live AWS. type stubTargetOfferingsEC2 struct { + err error instances []ec2svc.ConvertibleRI offerings []ec2svc.TargetOffering - err error } func (s *stubTargetOfferingsEC2) ListConvertibleReservedInstances(_ context.Context) ([]ec2svc.ConvertibleRI, error) { @@ -1176,13 +1177,6 @@ func TestGetExchangeQuote_ValidOfferingIDPassesFormat(t *testing.T) { // fail because there's no real EC2 client wired -- but the test // exercises that the regex guard does NOT fire on a valid UUID. // We stub the handler to short-circuit before the AWS call. - called := false - stub := &stubTargetOfferingsEC2{ - instances: []ec2svc.ConvertibleRI{}, - } - _ = stub - _ = called - h := &Handler{auth: &mockAuthForExchange{}} // The handler calls exchange.GetExchangeQuote next; that will fail @@ -1251,7 +1245,7 @@ func TestMapAWSExchangeError_ClientFault4xx(t *testing.T) { } func TestMapAWSExchangeError_ServerFault5xx(t *testing.T) { - // An AWS error with an unrecognised code must stay 500 + // An AWS error with an unrecognized code must stay 500 apiErr := &fakeAPIError{code: "InternalError", message: "AWS is having a bad day"} mapped := mapAWSExchangeError("exchange quote failed", apiErr) ce, ok := IsClientError(mapped) @@ -1371,18 +1365,18 @@ func TestClassifyRecsAge(t *testing.T) { t.Parallel() cases := []struct { name string - age time.Duration want string + age time.Duration }{ - {"zero age is fresh", 0, ""}, - {"30 minutes is fresh", 30 * time.Minute, ""}, - {"just under soft threshold", reshapeSoftStaleThreshold - time.Minute, ""}, - {"exactly soft threshold", reshapeSoftStaleThreshold, "soft"}, - {"13 hours is soft", 13 * time.Hour, "soft"}, - {"just under hard threshold", reshapeHardStaleThreshold - time.Minute, "soft"}, - {"exactly hard threshold", reshapeHardStaleThreshold, "hard"}, - {"25 hours is hard", 25 * time.Hour, "hard"}, - {"48 hours is hard", 48 * time.Hour, "hard"}, + {name: "zero age is fresh", age: 0, want: ""}, + {name: "30 minutes is fresh", age: 30 * time.Minute, want: ""}, + {name: "just under soft threshold", age: reshapeSoftStaleThreshold - time.Minute, want: ""}, + {name: "exactly soft threshold", age: reshapeSoftStaleThreshold, want: "soft"}, + {name: "13 hours is soft", age: 13 * time.Hour, want: "soft"}, + {name: "just under hard threshold", age: reshapeHardStaleThreshold - time.Minute, want: "soft"}, + {name: "exactly hard threshold", age: reshapeHardStaleThreshold, want: "hard"}, + {name: "25 hours is hard", age: 25 * time.Hour, want: "hard"}, + {name: "48 hours is hard", age: 48 * time.Hour, want: "hard"}, } for _, tc := range cases { tc := tc @@ -1449,9 +1443,10 @@ func TestRejectRIExchange_TokenPathActorIsNil(t *testing.T) { ID: id, Status: "pending", ApprovalToken: "tok", }, nil) // Token path: actor must be nil. + //nolint:misspell // DB schema value 'cancelled' -- see migration 000009_ri_exchange_history.up.sql mockStore.On("TransitionRIExchangeStatus", ctx, id, "pending", "cancelled", (*string)(nil), - ).Return(&config.RIExchangeRecord{ID: id, Status: "cancelled"}, nil) + ).Return(&config.RIExchangeRecord{ID: id, Status: "cancelled"}, nil) //nolint:misspell // DB schema value 'cancelled' -- see migration 000009_ri_exchange_history.up.sql _, err := (&Handler{config: mockStore}).rejectRIExchange(ctx, id, "tok") require.NoError(t, err) diff --git a/internal/api/handler_router.go b/internal/api/handler_router.go index d114662de..8175864e6 100644 --- a/internal/api/handler_router.go +++ b/internal/api/handler_router.go @@ -7,15 +7,15 @@ import ( "github.com/aws/aws-lambda-go/events" ) -// 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 +// 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. 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,22 +24,17 @@ 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) - return ok + var nfe *notFoundError + return errors.As(err, &nfe) } // clientError represents an error that should be returned to the client with a specific HTTP status code. type clientError struct { + details map[string]any message string code int - // details carries optional structured fields (e.g. ops_hint, - // retry_attempt_n) that the response writer surfaces alongside the - // human message. Used by retry-soft-block responses (issue #47) so - // the frontend can render a confirm-with-warning UX without parsing - // the message string. - details map[string]any } func (e *clientError) Error() string { return e.message } 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_test.go b/internal/api/handler_test.go index a3449592d..3229e3f16 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -839,6 +839,7 @@ func TestHandler_HandleRequest_CancelPurchase(t *testing.T) { var body map[string]string err = json.Unmarshal([]byte(resp.Body), &body) require.NoError(t, err) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql assert.Equal(t, "cancelled", body["status"]) } @@ -904,7 +905,7 @@ func TestHandler_HandleRequest_Error(t *testing.T) { assert.Equal(t, "Internal server error", body["error"]) } -// Integration tests for dashboard endpoints +// Integration tests for dashboard endpoints. func TestHandler_HandleRequest_GetDashboardSummary(t *testing.T) { ctx := context.Background() mockScheduler := new(MockScheduler) @@ -1171,8 +1172,9 @@ func TestHandler_HandleRequest_DeletePlannedPurchase(t *testing.T) { mockAuth.grantAdmin() mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) - cancelled := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "cancelled"} - mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending", "paused"}, "cancelled", mock.Anything).Return(cancelled, nil) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql + cancelled := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "cancelled"} //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql + mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending", "paused"}, "cancelled", mock.Anything).Return(cancelled, nil) //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql handler := &Handler{config: mockStore, auth: mockAuth, corsAllowedOrigin: "*", apiKey: "test-key"} @@ -1238,7 +1240,7 @@ func TestHandler_HandleRequest_CreatePlannedPurchases(t *testing.T) { assert.Equal(t, 200, resp.StatusCode) } -// Tests for edge cases in getPlan +// Tests for edge cases in getPlan. func TestHandler_HandleRequest_GetPlan_Error(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -1270,7 +1272,7 @@ func TestHandler_HandleRequest_GetPlan_Error(t *testing.T) { assert.Equal(t, 500, resp.StatusCode) } -// Test for deleteUser edge case - self deletion prevention +// Test for deleteUser edge case - self deletion prevention. func TestHandler_HandleRequest_DeleteUser_SelfDeletion(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) @@ -1309,7 +1311,7 @@ func TestHandler_HandleRequest_DeleteUser_SelfDeletion(t *testing.T) { assert.Equal(t, "cannot delete your own account", body["error"]) } -// Test for listPlans error case +// Test for listPlans error case. func TestHandler_HandleRequest_ListPlans_Error(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -1404,7 +1406,7 @@ func TestHandler_HandleRequest_ListPlans_UnassignedFlagged(t *testing.T) { assert.True(t, lp.Unassigned, "zero-account plan must have unassigned=true") } -// Test for updateConfig error case - invalid JSON returns 500 (not 400) +// Test for updateConfig error case - invalid JSON returns 500 (not 400). func TestHandler_HandleRequest_UpdateConfig_InvalidJSON(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) @@ -1437,7 +1439,7 @@ func TestHandler_HandleRequest_UpdateConfig_InvalidJSON(t *testing.T) { assert.Equal(t, 400, resp.StatusCode) } -// Nil-body success responses (e.g. DELETE /accounts/:id) must serialise as +// Nil-body success responses (e.g. DELETE /accounts/:id) must serialize as // "{}" rather than the empty string. Empty-string bodies caused // `response.json()` in the frontend to throw SyntaxError, surfacing as a // "JSON format error" toast even though the underlying delete succeeded. @@ -1448,7 +1450,7 @@ func TestHandler_buildResponse_NilBodyEmitsEmptyJSONObject(t *testing.T) { // buildResponse never returns an error; converts all failure modes to 500 body. resp := h.buildResponse(200, headers, nil, nil) assert.Equal(t, 200, resp.StatusCode) - assert.Equal(t, "{}", resp.Body, "nil-body success must serialise as {} so the frontend's response.json() doesn't throw") + assert.Equal(t, "{}", resp.Body, "nil-body success must serialize as {} so the frontend's response.json() doesn't throw") } func TestHandler_buildResponse_BodyMarshalsAsBefore(t *testing.T) { diff --git a/internal/api/handler_users.go b/internal/api/handler_users.go index 36befda8a..3b7c3fabb 100644 --- a/internal/api/handler_users.go +++ b/internal/api/handler_users.go @@ -10,9 +10,9 @@ import ( "github.com/aws/aws-lambda-go/events" ) -// User management handlers +// 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 { @@ -35,7 +35,8 @@ func (h *Handler) createUser(ctx context.Context, req *events.LambdaFunctionURLR // Rate limiting: 30 admin operations per user per minute if h.rateLimiter != nil { - allowed, err := h.rateLimiter.AllowWithUser(ctx, session.UserID, "admin") + var allowed bool + allowed, err = h.rateLimiter.AllowWithUser(ctx, session.UserID, "admin") if err != nil { // Log but continue on rate limiter errors } else if !allowed { @@ -44,23 +45,24 @@ func (h *Handler) createUser(ctx context.Context, req *events.LambdaFunctionURLR } var createReq auth.APICreateUserRequest - if err := json.Unmarshal([]byte(req.Body), &createReq); err != nil { + err = json.Unmarshal([]byte(req.Body), &createReq) + if err != nil { return nil, NewClientError(400, "invalid request body") } // 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") } // Decode base64-encoded password - if decoded, err := decodeBase64Password(createReq.Password); err != nil { - return nil, err - } else { - createReq.Password = decoded + decoded, decErr := decodeBase64Password(createReq.Password) + if decErr != nil { + return nil, decErr } + createReq.Password = decoded user, err := h.auth.CreateUserAPI(ctx, createReq) if err != nil { @@ -72,7 +74,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 +94,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 +113,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 { @@ -124,7 +126,8 @@ func (h *Handler) updateUser(ctx context.Context, req *events.LambdaFunctionURLR } var updateReq auth.APIUpdateUserRequest - if err := json.Unmarshal([]byte(req.Body), &updateReq); err != nil { + err = json.Unmarshal([]byte(req.Body), &updateReq) + if err != nil { return nil, NewClientError(400, "invalid request body") } @@ -139,7 +142,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/handler_version.go b/internal/api/handler_version.go index fdc256d34..93a20aeaa 100644 --- a/internal/api/handler_version.go +++ b/internal/api/handler_version.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name matches directory per Go convention import ( "context" @@ -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..b77abbc4f 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,8 @@ 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{}) + require.NotNil(t, resp) 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 f112df25f..5c3b6cadf 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"` + Status string `json:"status"` } -// 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,8 +107,8 @@ func (h *Handler) checkCredentialStore() HealthCheck { return HealthCheck{Status: "healthy"} } -// checkAuthService checks if the auth service is accessible -func (h *Handler) checkAuthService(ctx context.Context) HealthCheck { +// checkAuthService checks if the auth service is accessible. +func (h *Handler) checkAuthService(_ context.Context) HealthCheck { if h.auth == nil { return HealthCheck{ Status: "unhealthy", diff --git a/internal/api/inmemory_rate_limiter.go b/internal/api/inmemory_rate_limiter.go index 7a36f7eef..648599098 100644 --- a/internal/api/inmemory_rate_limiter.go +++ b/internal/api/inmemory_rate_limiter.go @@ -16,23 +16,23 @@ import ( // memory growth from rotating attacker source IPs (02-M3). 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 +// 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. type InMemoryRateLimiter struct { - mu sync.Mutex attempts map[string]*inMemoryRateLimitEntry limits map[string]RateLimitConfig // endpoint -> config + mu sync.Mutex } type inMemoryRateLimitEntry struct { - count int resetTime time.Time + count int } -// 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) @@ -83,15 +83,15 @@ func (rl *InMemoryRateLimiter) evictIfAtCap(now time.Time) { // Must be called with rl.mu held. func (rl *InMemoryRateLimiter) evictOldest() { type kv struct { - key string - t time.Time + resetTime time.Time + key string } pairs := make([]kv, 0, len(rl.attempts)) for k, v := range rl.attempts { - pairs = append(pairs, kv{k, v.resetTime}) + pairs = append(pairs, kv{v.resetTime, k}) } sort.Slice(pairs, func(i, j int) bool { - return pairs[i].t.Before(pairs[j].t) + return pairs[i].resetTime.Before(pairs[j].resetTime) }) target := inMemoryRateLimitMaxEntries * 4 / 5 for i := 0; len(rl.attempts) > target && i < len(pairs); i++ { @@ -102,7 +102,7 @@ func (rl *InMemoryRateLimiter) evictOldest() { // Allow checks if a request should be allowed based on rate limits. // The key should be formatted as "IP#{ip}" or "EMAIL#{email}". // The endpoint identifies which rate limit configuration to use. -func (rl *InMemoryRateLimiter) Allow(ctx context.Context, key string, endpoint string) (bool, error) { +func (rl *InMemoryRateLimiter) Allow(ctx context.Context, key, endpoint string) (bool, error) { if rl == nil { return true, nil } @@ -133,20 +133,20 @@ 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 -func (rl *InMemoryRateLimiter) AllowWithIP(ctx context.Context, ip string, endpoint string) (bool, error) { +// AllowWithIP is a convenience method that formats the key as an IP-based key. +func (rl *InMemoryRateLimiter) AllowWithIP(ctx context.Context, ip, 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 -func (rl *InMemoryRateLimiter) AllowWithEmail(ctx context.Context, email string, endpoint string) (bool, error) { +// AllowWithEmail is a convenience method that formats the key as an email-based key. +func (rl *InMemoryRateLimiter) AllowWithEmail(ctx context.Context, email, 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 -func (rl *InMemoryRateLimiter) AllowWithUser(ctx context.Context, userID string, endpoint string) (bool, error) { +// AllowWithUser is a convenience method that formats the key as a user-based key. +func (rl *InMemoryRateLimiter) AllowWithUser(ctx context.Context, userID, 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..f6b4f2fbb 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) @@ -100,7 +100,7 @@ func (h *Handler) checkBearerToken(ctx context.Context, req *events.LambdaFuncti // with SigV4, which overwrites the standard Authorization header. func (h *Handler) extractBearerToken(req *events.LambdaFunctionURLRequest) string { // First check X-Authorization (used by frontend with CloudFront OAC) - auth := req.Headers["x-authorization"] + auth := req.Headers["x-authorization"] //nolint:gocritic if auth == "" { auth = req.Headers["X-Authorization"] } @@ -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/middleware_test.go b/internal/api/middleware_test.go index 9b4e7312f..b75ebe653 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -37,10 +37,10 @@ func TestHandler_isPublicEndpoint(t *testing.T) { func TestHandler_authenticate(t *testing.T) { tests := []struct { - name string - apiKey string headers map[string]string params map[string]string + name string + apiKey string expected bool }{ { diff --git a/internal/api/mocks_test.go b/internal/api/mocks_test.go index 372bb9f09..709083f75 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 } @@ -74,7 +74,7 @@ func (m *MockScheduler) CollectRecommendations(ctx context.Context) (*scheduler. return args.Get(0).(*scheduler.CollectResult), args.Error(1) } -func (m *MockScheduler) ListRecommendations(ctx context.Context, filter config.RecommendationFilter) ([]config.RecommendationRecord, error) { +func (m *MockScheduler) ListRecommendations(ctx context.Context, filter *config.RecommendationFilter) ([]config.RecommendationRecord, error) { args := m.Called(ctx, filter) if args.Get(0) == nil { return nil, args.Error(1) @@ -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..722c644e5 100644 --- a/internal/api/rate_limiter.go +++ b/internal/api/rate_limiter.go @@ -5,15 +5,15 @@ 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 -func NewRateLimitConfig(maxAttempts int, windowSecs int) RateLimitConfig { +// NewRateLimitConfig creates a new RateLimitConfig. +func NewRateLimitConfig(maxAttempts, windowSecs int) RateLimitConfig { return RateLimitConfig{ MaxAttempts: maxAttempts, WindowSecs: windowSecs, @@ -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..51c63ddf8 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. // @@ -74,8 +74,8 @@ type riUtilizationFetcher func(ctx context.Context, lookbackDays int) ([]recomme // refresh, avoiding a thundering-herd CE fan-out. type riUtilizationCache struct { store riUtilizationCacheStore - isLambda bool sf singleflight.Group + isLambda bool } func newRIUtilizationCache(store riUtilizationCacheStore, isLambda bool) *riUtilizationCache { @@ -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..635dc7912 100644 --- a/internal/api/ri_utilization_cache_test.go +++ b/internal/api/ri_utilization_cache_test.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name matches directory per Go convention import ( "context" @@ -16,11 +16,11 @@ 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 getErr error + entries map[string]config.RIUtilizationCacheEntry + mu sync.Mutex } func newFakeRIUtilCacheStore() *fakeRIUtilCacheStore { @@ -59,8 +59,10 @@ 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. -func (f *fakeRIUtilCacheStore) seedStale(t *testing.T, region string, lookbackDays int, data []recommendations.RIUtilization, age time.Duration) { +func (f *fakeRIUtilCacheStore) seedStale(t *testing.T, data []recommendations.RIUtilization, age time.Duration) { t.Helper() + const region = "us-east-1" + const lookbackDays = 30 payload, err := json.Marshal(data) if err != nil { t.Fatalf("seedStale marshal: %v", err) @@ -124,7 +126,7 @@ func TestRIUtilizationCache_StaleOnNonLambdaServesStaleAndKicksRefresh(t *testin staleData := []recommendations.RIUtilization{{ReservedInstanceID: "ri-stale"}} freshData := []recommendations.RIUtilization{{ReservedInstanceID: "ri-fresh"}} // Seed a row whose age is between the soft TTL (50ms) and hard TTL (1h). - store.seedStale(t, "us-east-1", 30, staleData, 100*time.Millisecond) + store.seedStale(t, staleData, 100*time.Millisecond) c := newRIUtilizationCache(store, false) // non-Lambda @@ -157,22 +159,30 @@ func TestRIUtilizationCache_StaleOnNonLambdaServesStaleAndKicksRefresh(t *testin } // Give the storePayload write a beat to land. - deadline := time.Now().Add(500 * time.Millisecond) + if !waitForStoredRI(store, "us-east-1", 30, "ri-fresh", 500*time.Millisecond) { + t.Fatalf("background refresh did not update the stored row") + } +} + +// waitForStoredRI polls the store until it holds exactly one row with the +// given ReservedInstanceID, or the timeout elapses. It returns true on match. +func waitForStoredRI(store *fakeRIUtilCacheStore, region string, days int, wantRIID string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { - latest, ok := store.latest("us-east-1", 30) - if ok && len(latest) == 1 && latest[0].ReservedInstanceID == "ri-fresh" { - return + latest, ok := store.latest(region, days) + if ok && len(latest) == 1 && latest[0].ReservedInstanceID == wantRIID { + return true } time.Sleep(10 * time.Millisecond) } - t.Fatalf("background refresh did not update the stored row") + return false } func TestRIUtilizationCache_StaleOnLambdaBlocksForSyncRefetch(t *testing.T) { store := newFakeRIUtilCacheStore() staleData := []recommendations.RIUtilization{{ReservedInstanceID: "ri-stale"}} freshData := []recommendations.RIUtilization{{ReservedInstanceID: "ri-fresh"}} - store.seedStale(t, "us-east-1", 30, staleData, 100*time.Millisecond) + store.seedStale(t, staleData, 100*time.Millisecond) c := newRIUtilizationCache(store, true) // Lambda mode @@ -184,7 +194,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) } @@ -195,7 +205,7 @@ func TestRIUtilizationCache_HardExpirySyncRefetch(t *testing.T) { staleData := []recommendations.RIUtilization{{ReservedInstanceID: "ri-stale"}} freshData := []recommendations.RIUtilization{{ReservedInstanceID: "ri-fresh"}} // Age (2h) exceeds both soft (15m) and hard (1h). - store.seedStale(t, "us-east-1", 30, staleData, 2*time.Hour) + store.seedStale(t, staleData, 2*time.Hour) c := newRIUtilizationCache(store, false) // non-Lambda, but hard-expired @@ -217,7 +227,7 @@ func TestRIUtilizationCache_SingleflightCollapsesConcurrentRefreshes(t *testing. store := newFakeRIUtilCacheStore() staleData := []recommendations.RIUtilization{{ReservedInstanceID: "ri-stale"}} freshData := []recommendations.RIUtilization{{ReservedInstanceID: "ri-fresh"}} - store.seedStale(t, "us-east-1", 30, staleData, 100*time.Millisecond) + store.seedStale(t, staleData, 100*time.Millisecond) c := newRIUtilizationCache(store, false) @@ -225,7 +235,7 @@ 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) { + fetch := func(_ context.Context, _ int) ([]recommendations.RIUtilization, error) { //nolint:unparam // error always nil; closure test stub calls.Add(1) <-release return freshData, nil diff --git a/internal/api/router.go b/internal/api/router.go index f69a00dc6..ea8354208 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,28 +48,20 @@ 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") - PathPrefix string // Path must start with this (e.g., "/api/users/") - PathSuffix string // Path must end with this (e.g., "/revoke") - Method string // HTTP method (e.g., "GET", "POST") - - // Handler function - Handler RouteHandler - - // Auth controls authentication level. REQUIRED — leaving this unset - // (zero value) causes NewRouter to panic at startup so every route - // author makes an explicit AuthAdmin / AuthUser / AuthPublic choice. - // See AuthLevel doc for the history behind the mandatory-field rule. - Auth AuthLevel + Handler RouteHandler + ExactPath string + PathPrefix string + PathSuffix string + Method string + Auth AuthLevel } -// Router manages request routing +// Router manages request routing. type Router struct { - routes []Route h *Handler + routes []Route } // NewRouter creates a new router with all routes configured. @@ -101,7 +93,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 +355,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 +379,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,8 +389,8 @@ 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 -func (r *Router) matches(route Route, method, path string) bool { +// matches checks if a route matches the given method and path. +func (r *Router) matches(route Route, method, path string) bool { //nolint:gocritic // Check method (if specified) if route.Method != "" && route.Method != method { return false @@ -421,8 +413,8 @@ func (r *Router) matches(route Route, method, path string) bool { return route.PathPrefix != "" || route.PathSuffix != "" } -// extractParams extracts path parameters from the route -func (r *Router) extractParams(route Route, path string) map[string]string { +// extractParams extracts path parameters from the route. +func (r *Router) extractParams(route Route, path string) map[string]string { //nolint:gocritic params := make(map[string]string) // Extract ID from prefix-based routes @@ -466,7 +458,7 @@ func (r *Router) updateServiceConfigHandler(ctx context.Context, req *events.Lam } func (r *Router) commitmentOptionsHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { - return r.h.getCommitmentOptions(ctx) + return r.h.getCommitmentOptions(ctx), nil } func (r *Router) getRecommendationsHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { @@ -734,8 +726,8 @@ func (r *Router) getPublicInfoHandler(ctx context.Context, req *events.LambdaFun return r.h.getPublicInfo(ctx, req) } -func (r *Router) getVersionHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { - return r.h.getVersion(ctx, req) +func (r *Router) getVersionHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, _ map[string]string) (any, error) { + return r.h.getVersion(ctx, req), nil } func (r *Router) getDeploymentInfoHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { @@ -808,7 +800,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_660_permission_flips_test.go b/internal/api/router_660_permission_flips_test.go index c56a0a51a..f768604c6 100644 --- a/internal/api/router_660_permission_flips_test.go +++ b/internal/api/router_660_permission_flips_test.go @@ -71,9 +71,9 @@ func reqWithBearer(token string) *events.LambdaFunctionURLRequest { } } -func reqWithBearerAndBody(token, body string) *events.LambdaFunctionURLRequest { +func reqWithBearerAndBody(body string) *events.LambdaFunctionURLRequest { return &events.LambdaFunctionURLRequest{ - Headers: map[string]string{"Authorization": "Bearer " + token}, + Headers: map[string]string{"Authorization": "Bearer user-token"}, Body: body, } } @@ -172,14 +172,14 @@ func TestUpdatePlan_PermissionGate(t *testing.T) { mockStore.On("UpdatePurchasePlan", ctx, mock.AnythingOfType("*config.PurchasePlan")).Return(nil) h := &Handler{auth: mockAuth, config: mockStore} - _, err := h.updatePlan(ctx, reqWithBearerAndBody("user-token", body), planID) + _, err := h.updatePlan(ctx, reqWithBearerAndBody(body), planID) assertNotForbidden(t, err) }) t.Run("user without update:plans is rejected with 403", func(t *testing.T) { mockAuth := authForUserWith(ctx, t, userID, "update", "plans", false) h := &Handler{auth: mockAuth, config: new(MockConfigStore)} - _, err := h.updatePlan(ctx, reqWithBearerAndBody("user-token", body), planID) + _, err := h.updatePlan(ctx, reqWithBearerAndBody(body), planID) assert403(t, err) }) } @@ -196,7 +196,7 @@ func TestPausePlannedPurchase_PermissionGate(t *testing.T) { t.Run("creator with update:purchases can pause their own planned purchase", func(t *testing.T) { // Issue #950: a standard user manages only the scheduled purchases - // they created. update-any is false; the creator match authorises. + // they created. update-any is false; the creator match authorizes. mockAuth := authForUserWith(ctx, t, userID, "update", "purchases", true) mockAuth.On("GetAllowedAccountsAPI", ctx, userID).Return([]string{}, nil) mockAuth.On("HasPermissionAPI", ctx, userID, "update-any", "purchases").Return(false, nil) @@ -284,7 +284,7 @@ func TestDeletePlannedPurchase_PermissionGate(t *testing.T) { t.Run("creator with delete:purchases can delete their own planned purchase", func(t *testing.T) { // Issue #950: ownership gate also applies to delete; a creator with - // delete:purchases (no update-any) is authorised by the creator match. + // delete:purchases (no update-any) is authorized by the creator match. mockAuth := authForUserWith(ctx, t, userID, "delete", "purchases", true) mockAuth.On("GetAllowedAccountsAPI", ctx, userID).Return([]string{}, nil) mockAuth.On("HasPermissionAPI", ctx, userID, "update-any", "purchases").Return(false, nil) @@ -292,8 +292,9 @@ func TestDeletePlannedPurchase_PermissionGate(t *testing.T) { mockStore := new(MockConfigStore) mockStore.On("GetExecutionByID", ctx, execID). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending", CreatedByUserID: &creator}, nil) - mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled", mock.Anything). - Return(&config.PurchaseExecution{ExecutionID: execID, Status: "cancelled"}, nil) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql + mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled", mock.Anything). //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql + Return(&config.PurchaseExecution{ExecutionID: execID, Status: "cancelled"}, nil) //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql h := &Handler{auth: mockAuth, config: mockStore} _, err := h.deletePlannedPurchase(ctx, reqWithBearer("user-token"), execID) @@ -337,7 +338,7 @@ func TestExecuteExchange_PermissionGate(t *testing.T) { mockAuth := authForUserWith(ctx, t, userID, "execute", "ri-exchange", false) h := &Handler{auth: mockAuth} body := `{"ri_ids":["ri-abc"],"targets":[{"offering_id":"of-1"}],"max_payment_due_usd":"1000"}` - _, err := h.executeExchange(ctx, reqWithBearerAndBody("user-token", body)) + _, err := h.executeExchange(ctx, reqWithBearerAndBody(body)) assert403(t, err) }) @@ -349,7 +350,7 @@ func TestExecuteExchange_PermissionGate(t *testing.T) { mockAuth := authForUserWith(ctx, t, userID, "execute", "ri-exchange", true) h := &Handler{auth: mockAuth} body := `{"ri_ids":["ri-abc"],"targets":[{"offering_id":"of-1"}],"max_payment_due_usd":"1000"}` - _, err := h.executeExchange(ctx, reqWithBearerAndBody("user-token", body)) + _, err := h.executeExchange(ctx, reqWithBearerAndBody(body)) // The error will be from the AWS SDK (not a 403), proving the gate passed. assertNotForbidden(t, err) }) @@ -369,7 +370,7 @@ func TestExecuteExchange_PermissionGate(t *testing.T) { h := &Handler{auth: mockAuth} body := `{"ri_ids":["ri-abc"],"targets":[{"offering_id":"of-1"}],"max_payment_due_usd":"1000"}` - _, err := h.executeExchange(ctx, reqWithBearerAndBody("user-token", body)) + _, err := h.executeExchange(ctx, reqWithBearerAndBody(body)) assert403(t, err) }) } @@ -384,7 +385,7 @@ func TestUpdateRIExchangeConfig_PermissionGate(t *testing.T) { mockAuth := authForUserWith(ctx, t, userID, "update", "config", false) h := &Handler{auth: mockAuth} body := `{"auto_exchange_enabled":false}` - _, err := h.updateRIExchangeConfig(ctx, reqWithBearerAndBody("user-token", body)) + _, err := h.updateRIExchangeConfig(ctx, reqWithBearerAndBody(body)) assert403(t, err) }) @@ -395,15 +396,15 @@ func TestUpdateRIExchangeConfig_PermissionGate(t *testing.T) { mockStore.On("SaveGlobalConfig", ctx, &config.GlobalConfig{}).Return(nil).Maybe() h := &Handler{auth: mockAuth, config: mockStore} body := `{"auto_exchange_enabled":false,"mode":"recommend","utilization_threshold":80,"max_payment_per_exchange_usd":"0","max_payment_daily_usd":"0"}` - _, err := h.updateRIExchangeConfig(ctx, reqWithBearerAndBody("user-token", body)) + _, err := h.updateRIExchangeConfig(ctx, reqWithBearerAndBody(body)) assertNotForbidden(t, err) }) } -// ---- Router-level gate (defence-in-depth) --------------------------------- +// ---- Router-level gate (defense-in-depth) --------------------------------- // TestRouter_MutatingRoutes_RequireAuth confirms that the AuthUser check at -// the router level (defence-in-depth) still rejects completely unauthenticated +// the router level (defense-in-depth) still rejects completely unauthenticated // callers before they reach the handler — even after the flip from AuthAdmin. func TestRouter_MutatingRoutes_RequireAuth(t *testing.T) { ctx := context.Background() diff --git a/internal/api/router_auth_test.go b/internal/api/router_auth_test.go index df1c99730..81dd3d288 100644 --- a/internal/api/router_auth_test.go +++ b/internal/api/router_auth_test.go @@ -1,4 +1,4 @@ -package api +package api //nolint:revive // package name matches directory per Go convention import ( "testing" 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/router_handlers_test.go b/internal/api/router_handlers_test.go index a0d326b69..53ed27788 100644 --- a/internal/api/router_handlers_test.go +++ b/internal/api/router_handlers_test.go @@ -266,7 +266,7 @@ func TestRouter_discoverOrgAccountsHandler(t *testing.T) { // handler. The handler itself returns 400 on the empty body the // router test sends — that's fine for proving dispatch worked // (the call reached past auth and into the body parser). Full - // behaviour is exercised in handler_accounts_test.go. + // behavior is exercised in handler_accounts_test.go. ctx := context.Background() mockAuth := new(MockAuthService) adminSession := &Session{UserID: "uid"} @@ -496,9 +496,9 @@ func TestHandler_updateRIExchangeConfig_ValidationError(t *testing.T) { func TestHandler_getRIExchangeConfigUpdateRequest_Validate(t *testing.T) { tests := []struct { name string + errMsg string req RIExchangeConfigUpdateRequest wantErr bool - errMsg string }{ { name: "valid manual mode", @@ -784,18 +784,20 @@ func TestHandler_rejectRIExchange_ValidTokenAndRecord(t *testing.T) { ApprovalToken: "tok", Status: "pending", }, nil) - // rejectRIExchange transitions to "cancelled", not "rejected" - mockStore.On("TransitionRIExchangeStatus", ctx, "11111111-1111-1111-1111-111111111111", "pending", "cancelled", mock.Anything). - Return(&config.RIExchangeRecord{ + // rejectRIExchange transitions to "cancelled" (DB canonical), not "rejected" + //nolint:misspell // DB schema value 'cancelled' -- see migration 000009_ri_exchange_history.up.sql + mockStore.On("TransitionRIExchangeStatus", ctx, "11111111-1111-1111-1111-111111111111", "pending", "cancelled", mock.Anything). //nolint:misspell // DB schema value 'cancelled' -- see migration 000009_ri_exchange_history.up.sql + Return(&config.RIExchangeRecord{ ID: "11111111-1111-1111-1111-111111111111", - Status: "cancelled", + Status: "cancelled", //nolint:misspell // DB schema value 'cancelled' -- see migration 000009_ri_exchange_history.up.sql }, nil) h := &Handler{config: mockStore} result, err := h.rejectRIExchange(ctx, "11111111-1111-1111-1111-111111111111", "tok") require.NoError(t, err) m := result.(map[string]string) - assert.Equal(t, "cancelled", m["status"]) + //nolint:misspell // DB schema value 'cancelled' -- see migration 000009_ri_exchange_history.up.sql + assert.Equal(t, "cancelled", m["status"]) //nolint:misspell // DB schema value 'cancelled' -- see migration 000009_ri_exchange_history.up.sql } // --------------------------------------------------------------------------- @@ -909,7 +911,7 @@ func TestHandler_docsHandler_OpenAPISpec(t *testing.T) { _ = err // may return error if file not found; we just ensure no panic } -// Ensure we hit the time.Now path in getRIExchangeHistory +// Ensure we hit the time.Now path in getRIExchangeHistory. func TestHandler_getRIExchangeHistory_SinceTime(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) diff --git a/internal/api/scoping.go b/internal/api/scoping.go index 57280ec6c..e6f4f7f6b 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. @@ -67,7 +52,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) @@ -81,8 +66,8 @@ func (h *Handler) requirePlanAccess(ctx context.Context, session *Session, planI if err != nil { return fmt.Errorf("failed to get plan accounts: %w", err) } - for _, acct := range accounts { - if auth.MatchesAccount(allowed, acct.ID, acct.Name) { + for i := range accounts { + if auth.MatchesAccount(allowed, accounts[i].ID, accounts[i].Name) { return nil } } @@ -103,11 +88,11 @@ func (h *Handler) validatePurchaseRecommendationScope(ctx context.Context, sessi return nil } nameByID := h.resolveAccountNamesByID(ctx) - for i, rec := range recs { - if rec.CloudAccountID == nil { + for i := range recs { + if recs[i].CloudAccountID == nil { return NewClientError(400, fmt.Sprintf("recommendation %d has no cloud_account_id; scoped users cannot execute unattributed recommendations", i)) } - id := *rec.CloudAccountID + id := *recs[i].CloudAccountID if !auth.MatchesAccount(allowed, id, nameByID[id]) { return NewClientError(403, fmt.Sprintf("recommendation %d targets account %s which is outside your allowed_accounts", i, id)) } @@ -166,7 +151,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 @@ -177,9 +162,9 @@ func (h *Handler) resolveAccountFilterIDs(ctx context.Context, uuids []string) ( } type provExt struct{ provider, externalID string } byUUID := make(map[string]provExt, len(accounts)) - for _, a := range accounts { - if a.ExternalID != "" { - byUUID[a.ID] = provExt{provider: a.Provider, externalID: a.ExternalID} + for i := range accounts { + if accounts[i].ExternalID != "" { + byUUID[accounts[i].ID] = provExt{provider: accounts[i].Provider, externalID: accounts[i].ExternalID} } } for _, u := range uuids { @@ -220,11 +205,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 == "" { @@ -234,12 +219,12 @@ func (h *Handler) resolveSingleAccountFilterIDs(ctx context.Context, accountID s if err != nil { return nil, map[string][]string{"": {accountID}} } - for _, a := range accounts { - if a.ID == accountID { + for i := range accounts { + if accounts[i].ID == accountID { // Known UUID: match cloud_account_id by UUID and, when present, // account_id by the resolved external number scoped to its provider. - if a.ExternalID != "" { - return []string{accountID}, map[string][]string{a.Provider: {a.ExternalID}} + if accounts[i].ExternalID != "" { + return []string{accountID}, map[string][]string{accounts[i].Provider: {accounts[i].ExternalID}} } return []string{accountID}, nil } @@ -266,10 +251,10 @@ func (h *Handler) resolveAccountNamesByID(ctx context.Context) map[string]string } // Allocate 2x capacity since each account contributes up to two keys. nameByID := make(map[string]string, len(accounts)*2) - for _, a := range accounts { - nameByID[a.ID] = a.Name - if a.ExternalID != "" { - nameByID[a.ExternalID] = a.Name + for i := range accounts { + nameByID[accounts[i].ID] = accounts[i].Name + if accounts[i].ExternalID != "" { + nameByID[accounts[i].ExternalID] = accounts[i].Name } } return nameByID diff --git a/internal/api/types.go b/internal/api/types.go index e88590339..46a5aa6c0 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -14,8 +14,8 @@ import ( "github.com/LeanerCloud/CUDly/internal/scheduler" ) -// RateLimiterInterface defines the interface for rate limiting implementations -// This allows for both in-memory and database-backed rate limiters +// RateLimiterInterface defines the interface for rate limiting implementations. +// 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,45 +31,27 @@ 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 - PurchaseManager PurchaseManagerInterface - Scheduler SchedulerInterface - AuthService AuthServiceInterface - APIKeySecretARN string - EnableDashboard bool - DashboardBucket string - CORSAllowedOrigin string // CORS allowed origin (default "*") - RateLimiter RateLimiterInterface - EmailNotifier email.SenderInterface // Optional: used to send purchase approval emails - DashboardURL string // Base URL for approval/cancel links in emails - // Analytics configuration (optional) - AnalyticsClient AnalyticsClientInterface - AnalyticsCollector AnalyticsCollectorInterface - // AnalyticsSnapshots serves the savings-snapshot time-series (coverage %, - // utilization, committed spend, realized savings over time) backed by the - // savings_snapshots store. Optional; nil disables /api/analytics/trends. - AnalyticsSnapshots AnalyticsSnapshotStoreInterface - // OIDCSigner is the cloud-agnostic signer that backs - // /.well-known/openid-configuration and /.well-known/jwks.json. - // Nil disables the OIDC issuer endpoints (they return 404). - OIDCSigner oidc.Signer - // OIDCIssuerURL is the canonical issuer URL the OIDC handlers - // publish in the Discovery document. Must match what Azure AD - // federated credentials are registered with. - OIDCIssuerURL string - // CommitmentOpts discovers which (term, payment) combinations each - // AWS service actually sells and validates saves against that data. - // Nil disables both the /api/commitment-options endpoint (returns - // unavailable) and save-side validation in updateServiceConfig. - CommitmentOpts CommitmentOptsInterface - // EncryptionKeySource is the env var name that resolved the credential - // encryption key (e.g. "CREDENTIAL_ENCRYPTION_KEY_SECRET_NAME"). Empty - // when no credStore is configured. Used by the /health endpoint to - // surface which key source is in use and detect dev-key state. + AnalyticsClient AnalyticsClientInterface + AnalyticsCollector AnalyticsCollectorInterface + PurchaseManager PurchaseManagerInterface + RateLimiter RateLimiterInterface + AuthService AuthServiceInterface + CommitmentOpts CommitmentOptsInterface + OIDCSigner oidc.Signer + AnalyticsSnapshots AnalyticsSnapshotStoreInterface + CredentialStore credentials.CredentialStore + EmailNotifier email.SenderInterface + Scheduler SchedulerInterface + ConfigStore config.StoreInterface + DashboardURL string + CORSAllowedOrigin string + DashboardBucket string + OIDCIssuerURL string + APIKeySecretARN string EncryptionKeySource string + EnableDashboard bool } // CommitmentOptsInterface lets us swap the real *commitmentopts.Service for @@ -95,7 +77,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 } @@ -108,24 +90,24 @@ type AnalyticsCollectorInterface interface { // carrying only one identifier are still matched. Both empty means "all" — the // handler MUST enforce allowed_accounts scope before passing empty filters. type AnalyticsSnapshotStoreInterface interface { - QuerySavings(ctx context.Context, req analytics.QueryRequest) ([]analytics.SavingsSnapshot, error) + QuerySavings(ctx context.Context, req *analytics.QueryRequest) ([]analytics.SavingsSnapshot, error) QueryMonthlyTotals(ctx context.Context, accountUUIDs []string, accountExternalIDsByProvider map[string][]string, months int) ([]analytics.MonthlySummary, error) QueryByProvider(ctx context.Context, accountUUIDs []string, accountExternalIDsByProvider map[string][]string, startDate, endDate time.Time) ([]analytics.ProviderBreakdown, error) 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"` + ByService map[string]float64 `json:"by_service,omitempty"` + ByProvider map[string]float64 `json:"by_provider,omitempty"` TotalSavings float64 `json:"total_savings"` TotalUpfront float64 `json:"total_upfront"` PurchaseCount int `json:"purchase_count"` CumulativeSavings float64 `json:"cumulative_savings"` - ByService map[string]float64 `json:"by_service,omitempty"` - 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 +116,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,10 +139,10 @@ 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) + ListRecommendations(ctx context.Context, filter *config.RecommendationFilter) ([]config.RecommendationRecord, error) // GetRecommendationByID fetches a single rec by its application-level id, // bypassing account-override filtering so deep-linked URLs to override- // hidden recs resolve. hiddenBy is non-nil when the rec would be dropped by @@ -169,8 +151,8 @@ type SchedulerInterface interface { GetRecommendationByID(ctx context.Context, id string) (rec *config.RecommendationRecord, hiddenBy []string, err error) } -// AuthServiceInterface defines auth service methods used by handler -// Note: This interface uses API-specific types that are converted from auth package types +// AuthServiceInterface defines auth service methods used by handler. +// 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,10 +206,10 @@ 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"` + Password string `json:"password"` //nolint:gosec MFACode string `json:"mfa_code,omitempty"` } @@ -247,7 +229,7 @@ type UserInfo struct { type SetupAdminRequest struct { Email string `json:"email"` - Password string `json:"password"` + Password string `json:"password"` //nolint:gosec } type PasswordResetRequest struct { @@ -267,45 +249,45 @@ type Session struct { type User struct { ID string `json:"id"` Email string `json:"email"` - Groups []string `json:"groups,omitempty"` - MFAEnabled bool `json:"mfa_enabled"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` + Groups []string `json:"groups,omitempty"` + MFAEnabled bool `json:"mfa_enabled"` } // CreateUserRequest represents a request to create a new user. Groups must be // non-empty: authorization is group-membership-only (issue #907). type CreateUserRequest struct { Email string `json:"email"` - Password string `json:"password"` + Password string `json:"password"` //nolint:gosec 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"` Description string `json:"description,omitempty"` - Permissions []Permission `json:"permissions"` - AllowedAccounts []string `json:"allowed_accounts,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` + Permissions []Permission `json:"permissions"` + AllowedAccounts []string `json:"allowed_accounts,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 { + Constraints *PermissionConstraint `json:"constraints,omitempty"` 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 +296,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 +304,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 +312,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,20 +327,20 @@ 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"` - SourceCloud string `json:"source_cloud,omitempty"` SourceIdentity *sourceIdentity `json:"source_identity,omitempty"` + SourceCloud string `json:"source_cloud,omitempty"` + Services []config.ServiceConfig `json:"services"` } -// 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,11 +348,11 @@ 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"` Regions []string `json:"regions"` + Summary RecommendationsSummary `json:"summary"` } // UsagePoint is a single sample in the per-recommendation usage time @@ -378,7 +360,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 +376,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 +394,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 +407,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,10 +434,10 @@ 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"` + Password string `json:"password"` //nolint:gosec } // MFASetupResponse returns the freshly-generated secret + the @@ -463,7 +445,7 @@ type MFASetupRequest struct { // already persisted server-side as the pending secret; clients do // not need to round-trip it back on enable. type MFASetupResponse struct { - Secret string `json:"secret"` + Secret string `json:"secret"` //nolint:gosec ProvisioningURI string `json:"provisioning_uri"` } @@ -483,7 +465,7 @@ type MFAEnableResponse struct { // MFADisableRequest turns off MFA. Requires the current password AND // a fresh proof-of-possession (TOTP code or unused recovery code). type MFADisableRequest struct { - Password string `json:"password"` + Password string `json:"password"` //nolint:gosec Code string `json:"code"` } @@ -500,7 +482,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,8 +508,9 @@ 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 { + ByService map[string]ServiceSavings `json:"by_service"` PotentialMonthlySavings float64 `json:"potential_monthly_savings"` TotalRecommendations int `json:"total_recommendations"` ActiveCommitments int `json:"active_commitments"` @@ -535,10 +518,9 @@ type DashboardSummaryResponse struct { CurrentCoverage float64 `json:"current_coverage"` TargetCoverage float64 `json:"target_coverage"` YTDSavings float64 `json:"ytd_savings"` - 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"` @@ -558,25 +540,22 @@ type ServiceSavings struct { // The field stays in the response shape so a future "expiring soon" // sub-state has a slot without a breaking API change. type InventoryCommitment struct { - ID string `json:"id"` - Provider string `json:"provider"` - AccountID string `json:"account_id"` - AccountName string `json:"account_name,omitempty"` - Service string `json:"service"` - ResourceType string `json:"resource_type,omitempty"` - Region string `json:"region"` - Count int `json:"count"` - TermYears int `json:"term_years"` - PaymentOption string `json:"payment_option,omitempty"` - StartDate time.Time `json:"start_date"` - EndDate time.Time `json:"end_date"` - UpfrontCost float64 `json:"upfront_cost"` - // MonthlyCost is nil when the source purchase_history row has a NULL - // monthly_cost (provider did not return a monthly breakdown). The - // frontend renders "—" for nil and "$X.XX" when non-nil. - MonthlyCost *float64 `json:"monthly_cost"` - EstimatedSavings float64 `json:"estimated_savings"` - Status string `json:"status"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + MonthlyCost *float64 `json:"monthly_cost"` + Provider string `json:"provider"` + AccountID string `json:"account_id"` + AccountName string `json:"account_name,omitempty"` + Service string `json:"service"` + ResourceType string `json:"resource_type,omitempty"` + Region string `json:"region"` + Status string `json:"status"` + ID string `json:"id"` + PaymentOption string `json:"payment_option,omitempty"` + TermYears int `json:"term_years"` + UpfrontCost float64 `json:"upfront_cost"` + EstimatedSavings float64 `json:"estimated_savings"` + Count int `json:"count"` } // InventoryCommitmentsResponse is the envelope returned by @@ -595,10 +574,10 @@ type InventoryCommitmentsResponse struct { // are zero (no usage detected), not 0, to preserve the "absent" // semantic per feedback_nullable_not_zero. type CoverageServiceRow struct { + CoveragePct *float64 `json:"coverage_pct"` Service string `json:"service"` CoveredMonthly float64 `json:"covered_monthly"` OnDemandMonthly float64 `json:"on_demand_monthly"` - CoveragePct *float64 `json:"coverage_pct"` } // ProviderCoverageSection is the per-provider block returned by @@ -608,9 +587,9 @@ type CoverageServiceRow struct { // OverallCoveragePct follows the same null-vs-zero contract as // CoverageServiceRow.CoveragePct. type ProviderCoverageSection struct { + OverallCoveragePct *float64 `json:"overall_coverage_pct"` Provider string `json:"provider"` Services []CoverageServiceRow `json:"services"` - OverallCoveragePct *float64 `json:"overall_coverage_pct"` } // CoverageBreakdownResponse is the envelope returned by @@ -619,7 +598,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"` } @@ -639,6 +618,7 @@ type UpcomingPurchaseResponse struct { // aggressive — operators usually want "skip this scheduled run", not // "nuke the recurring template". type UpcomingPurchase struct { + CreatedByUserID *string `json:"created_by_user_id,omitempty"` ExecutionID string `json:"execution_id"` PlanID string `json:"plan_id"` PlanName string `json:"plan_name"` @@ -648,25 +628,17 @@ type UpcomingPurchase struct { StepNumber int `json:"step_number"` TotalSteps int `json:"total_steps"` EstimatedSavings float64 `json:"estimated_savings"` - // CreatedByUserID propagates the underlying execution's - // created_by_user_id so the dashboard widget can apply the same - // creator-scope ownership gate the Plans page uses (issue #950). - // Without it the widget renders a "Cancel" button on every row - // while the backend now 403s for non-owners -- a UX hole that - // surfaces as a confusing toast on click. Mirrors the field on - // PlannedPurchase / PurchaseHistoryEntry. omitempty so legacy - // NULL-creator rows keep the JSON shape they had pre-fix. - 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"` + CreatedByUserID *string `json:"created_by_user_id,omitempty"` + Status string `json:"status"` PlanID string `json:"plan_id"` PlanName string `json:"plan_name"` ScheduledDate string `json:"scheduled_date"` @@ -674,50 +646,36 @@ type PlannedPurchase struct { Service string `json:"service"` ResourceType string `json:"resource_type"` Region string `json:"region"` - Count int `json:"count"` - Term int `json:"term"` + ID string `json:"id"` Payment string `json:"payment"` - EstimatedSavings float64 `json:"estimated_savings"` + Count int `json:"count"` UpfrontCost float64 `json:"upfront_cost"` - Status string `json:"status"` + EstimatedSavings float64 `json:"estimated_savings"` StepNumber int `json:"step_number"` TotalSteps int `json:"total_steps"` - // CreatedByUserID is the UUID of the user who created the scheduled - // purchase, mirroring PurchaseHistoryRecord.CreatedByUserID. The - // frontend gates the row action buttons on creator-scope ownership - // (issue #950); omitted for legacy rows with a NULL creator. - CreatedByUserID *string `json:"created_by_user_id,omitempty"` + Term int `json:"term"` } -// 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 +// 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. type PlanRequest struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Enabled bool `json:"enabled"` - AutoPurchase bool `json:"auto_purchase"` - NotificationDaysBefore int `json:"notification_days_before"` - // Frontend sends these as top-level fields - Provider string `json:"provider,omitempty"` - Service string `json:"service,omitempty"` - Term int `json:"term,omitempty"` - Payment string `json:"payment,omitempty"` - TargetCoverage int `json:"target_coverage,omitempty"` - // Ramp schedule as string from frontend (immediate, weekly-25pct, monthly-10pct, custom) - RampSchedule string `json:"ramp_schedule,omitempty"` - CustomStepPercent int `json:"custom_step_percent,omitempty"` - CustomIntervalDays int `json:"custom_interval_days,omitempty"` - - // TargetAccounts is the list of cloud_account UUIDs the plan will purchase - // for. Required (non-empty) on POST /plans -- a plan with no rows in - // plan_accounts is a "universal plan", which the design no longer allows: - // every plan must be tied to at least one explicit account. The handler - // inserts the plan_accounts rows immediately after CreatePurchasePlan so - // the two writes are observed together by downstream consumers. - TargetAccounts []string `json:"target_accounts,omitempty"` -} - -// toPurchasePlan converts a PlanRequest to a config.PurchasePlan + Payment string `json:"payment,omitempty"` + Description string `json:"description,omitempty"` + RampSchedule string `json:"ramp_schedule,omitempty"` + Name string `json:"name"` + Provider string `json:"provider,omitempty"` + Service string `json:"service,omitempty"` + TargetAccounts []string `json:"target_accounts,omitempty"` + TargetCoverage int `json:"target_coverage,omitempty"` + Term int `json:"term,omitempty"` + NotificationDaysBefore int `json:"notification_days_before"` + CustomStepPercent int `json:"custom_step_percent,omitempty"` + CustomIntervalDays int `json:"custom_interval_days,omitempty"` + AutoPurchase bool `json:"auto_purchase"` + Enabled bool `json:"enabled"` +} + +// toPurchasePlan converts a PlanRequest to a config.PurchasePlan. func (r *PlanRequest) toPurchasePlan() *config.PurchasePlan { now := time.Now() plan := &config.PurchasePlan{ @@ -736,7 +694,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 +711,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 +737,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 +770,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,35 +785,35 @@ 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"` + Count int `json:"count"` } -// 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"` + Summary HistorySummary `json:"summary"` } // HistorySummary provides aggregate statistics for purchase history. // 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..cc08b1801 100644 --- a/internal/api/types_apikeys.go +++ b/internal/api/types_apikeys.go @@ -2,29 +2,29 @@ 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 { + ExpiresAt *time.Time `json:"expires_at,omitempty"` 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"` + APIKey string `json:"api_key"` //nolint:gosec + Info *KeyInfo `json:"info"` + KeyID string `json:"key_id"` } -// APIKeyInfo represents public information about an API key -type APIKeyInfo struct { +// KeyInfo represents public information about an API key. +type KeyInfo struct { ID string `json:"id"` Name string `json:"name"` - KeyPrefix string `json:"key_prefix"` // First 8 chars for display - Permissions []Permission `json:"permissions,omitempty"` + KeyPrefix string `json:"key_prefix"` ExpiresAt string `json:"expires_at,omitempty"` CreatedAt string `json:"created_at"` LastUsedAt string `json:"last_used_at,omitempty"` + Permissions []Permission `json:"permissions,omitempty"` IsActive bool `json:"is_active"` } diff --git a/internal/api/validation.go b/internal/api/validation.go index 460f2b59d..150b54d42 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") @@ -313,18 +313,18 @@ func rejectUnknownKeys(credentialType string, payload map[string]interface{}, al // payloadDepth returns the maximum nesting depth of m, counting the top-level // map as depth 1. A nested map adds 1; non-map values do not. func payloadDepth(m map[string]interface{}, current int) int { - max := current + depth := current for _, v := range m { if nested, ok := v.(map[string]interface{}); ok { - if d := payloadDepth(nested, current+1); d > max { - max = d + if d := payloadDepth(nested, current+1); d > depth { + depth = d } } } - return max + return depth } -// 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)) @@ -617,7 +617,7 @@ func decodeBase64Password(encoded string) (string, error) { // // paramName is included in the error message so callers can distinguish // min_savings_usd vs min_savings_pct errors in client logs. -func parseMinSavingsParam(raw string, paramName string) (float64, error) { +func parseMinSavingsParam(raw, paramName string) (float64, error) { raw = strings.TrimSpace(raw) if raw == "" || raw == "0" { return 0, nil diff --git a/internal/api/validation_test.go b/internal/api/validation_test.go index d3db0a33f..8b253f4e6 100644 --- a/internal/api/validation_test.go +++ b/internal/api/validation_test.go @@ -170,22 +170,22 @@ func TestValidateUUID(t *testing.T) { func TestValidateContentType(t *testing.T) { t.Parallel() tests := []struct { + headers map[string]string name string method string body string - headers map[string]string wantError bool }{ - {"GET request without body", "GET", "", nil, false}, - {"POST with json content type", "POST", `{"key": "value"}`, map[string]string{"Content-Type": "application/json"}, false}, - {"POST with json and charset", "POST", `{"key": "value"}`, map[string]string{"Content-Type": "application/json; charset=utf-8"}, false}, - {"PUT with json content type", "PUT", `{"key": "value"}`, map[string]string{"content-type": "application/json"}, false}, - {"POST with form content type", "POST", "key=value", map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, false}, - {"POST without body is ok", "POST", "", nil, false}, - {"POST with body but no content type", "POST", `{"key": "value"}`, nil, true}, - {"POST with unsupported content type", "POST", `{"key": "value"}`, map[string]string{"Content-Type": "text/plain"}, true}, - {"DELETE without body", "DELETE", "", nil, false}, - {"PATCH with json", "PATCH", `{"key": "value"}`, map[string]string{"Content-Type": "application/json"}, false}, + {name: "GET request without body", method: "GET"}, + {name: "POST with json content type", method: "POST", body: `{"key": "value"}`, headers: map[string]string{"Content-Type": "application/json"}}, + {name: "POST with json and charset", method: "POST", body: `{"key": "value"}`, headers: map[string]string{"Content-Type": "application/json; charset=utf-8"}}, + {name: "PUT with json content type", method: "PUT", body: `{"key": "value"}`, headers: map[string]string{"content-type": "application/json"}}, + {name: "POST with form content type", method: "POST", body: "key=value", headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}}, + {name: "POST without body is ok", method: "POST"}, + {name: "POST with body but no content type", method: "POST", body: `{"key": "value"}`, wantError: true}, + {name: "POST with unsupported content type", method: "POST", body: `{"key": "value"}`, headers: map[string]string{"Content-Type": "text/plain"}, wantError: true}, + {name: "DELETE without body", method: "DELETE"}, + {name: "PATCH with json", method: "PATCH", body: `{"key": "value"}`, headers: map[string]string{"Content-Type": "application/json"}}, } for _, tt := range tests { diff --git a/internal/auth/errors.go b/internal/auth/errors.go index ad896571b..6f3dcda24 100644 --- a/internal/auth/errors.go +++ b/internal/auth/errors.go @@ -38,7 +38,7 @@ var ( // caller-supplied current password does not match the stored hash. Mapped // to 401 at the API layer (the acting user is verifying their own // credential, so a precise message is safe -- issue #929). - ErrCurrentPasswordIncorrect = errors.New("Current password is incorrect") + ErrCurrentPasswordIncorrect = errors.New("current password is incorrect") // MFA login-gate sentinels — used by the login API handler to map // to machine-readable response codes (mfa_required / 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..f0f21523e 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,57 +25,57 @@ 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 // cannot distinguish a missing account from a wrong password (anti-enumeration, // closes issues #416 and #993). - genericLoginError = "Check your email address and password and try again" + 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 - sessionDuration time.Duration - dashboardURL string - bcryptCostOverride int // if > 0, overrides bcryptCost const (used by tests for speed) - onPasswordChange func(ctx context.Context, userID, newPassword string) + store StoreInterface + emailSender EmailSenderInterface + // lastUsedSFG deduplicates concurrent UpdateLastUsed calls for the + // same API key so a burst of authenticated requests does not spawn + // an unbounded number of goroutines. + lastUsedSFG singleflight.Group + onPasswordChange func(ctx context.Context, userID, newPassword string) + dashboardURL string // csrfKey is the server-side key used to derive CSRF tokens as // HMAC-SHA256(csrfKey, rawSessionToken). Tokens are never stored // in cleartext; validation recomputes the HMAC and compares. // A random key is generated at NewService time when not supplied. - csrfKey []byte - // lastUsedSFG deduplicates concurrent UpdateLastUsed calls for the - // same API key so a burst of authenticated requests does not spawn - // an unbounded number of goroutines. - lastUsedSFG singleflight.Group + csrfKey []byte + sessionDuration time.Duration + bcryptCostOverride int // if > 0, overrides bcryptCost const (used by tests for speed) } -// ServiceConfig holds configuration for the auth service +// ServiceConfig holds configuration for the auth service. type ServiceConfig struct { Store StoreInterface EmailSender EmailSenderInterface - SessionDuration time.Duration - DashboardURL string OnPasswordChange func(ctx context.Context, userID, newPassword string) + DashboardURL string // CSRFKey is the server-side secret used to derive CSRF tokens as // HMAC-SHA256(CSRFKey, rawSessionToken). Must be 32 bytes for // 256-bit security. When empty, NewService generates a random key and // logs a warning; all existing sessions will require re-login on restart. - CSRFKey []byte + CSRFKey []byte + SessionDuration time.Duration } -// NewService creates a new auth service -func NewService(cfg ServiceConfig) *Service { +// NewService creates a new auth service. +func NewService(cfg ServiceConfig) *Service { //nolint:gocritic // hugeParam: ServiceConfig (88 bytes) passed by value; all callers own the literal 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..317629564 100644 --- a/internal/auth/service_api.go +++ b/internal/auth/service_api.go @@ -19,11 +19,11 @@ import ( type APIUser struct { ID string `json:"id"` Email string `json:"email"` - Groups []string `json:"groups"` - MFAEnabled bool `json:"mfa_enabled"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` LastLogin string `json:"last_login,omitempty"` + Groups []string `json:"groups"` + MFAEnabled bool `json:"mfa_enabled"` } // APIGroup is the group type for API responses. @@ -34,20 +34,20 @@ type APIGroup struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` - Permissions []APIPermission `json:"permissions"` - AllowedAccounts []string `json:"allowed_accounts"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` + Permissions []APIPermission `json:"permissions"` + AllowedAccounts []string `json:"allowed_accounts"` } -// APIPermission is the permission type for API responses +// APIPermission is the permission type for API responses. type APIPermission struct { + Constraints *APIPermissionConstraint `json:"constraints,omitempty"` 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"` @@ -60,7 +60,7 @@ type APIPermissionConstraint struct { // Groups must be non-empty: authorization is group-membership-only (issue #907). type APICreateUserRequest struct { Email string `json:"email"` - Password string `json:"password"` + Password string `json:"password"` //nolint:gosec Groups []string `json:"groups,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) } @@ -380,7 +380,7 @@ func (s *Service) GetUserPermissionsAPI(ctx context.Context, userID string) (any // the frontend renders as a QR code). Wraps MFASetup; thin shim // exists so the api package can refer to a stable signature without // importing the auth package's internal MFASetupResult type. -func (s *Service) MFASetupAPI(ctx context.Context, userID, password string) (string, string, error) { +func (s *Service) MFASetupAPI(ctx context.Context, userID, password string) (string, string, error) { //nolint:gocritic result, err := s.MFASetup(ctx, userID, password) if err != nil { return "", "", err 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..ef2dbc2da 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[:]) @@ -265,7 +265,7 @@ func (s *Service) ValidateUserAPIKey(ctx context.Context, apiKey string) (*UserA return nil, nil, fmt.Errorf("invalid API key") } - if err := validateAPIKeyStatus(key); err != nil { + if err = validateAPIKeyStatus(key); err != nil { //nolint:gocritic // sloppyReassign: reuse outer err to avoid shadow return nil, nil, err } @@ -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..0e48f7533 100644 --- a/internal/auth/service_apikeys_api.go +++ b/internal/auth/service_apikeys_api.go @@ -9,38 +9,38 @@ 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 { + ExpiresAt *time.Time `json:"expires_at,omitempty"` 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 { + CreatedAt time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` ID string `json:"id"` Name string `json:"name"` KeyPrefix string `json:"key_prefix"` Permissions []Permission `json:"permissions,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - LastUsedAt *time.Time `json:"last_used_at,omitempty"` 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"` + APIKey string `json:"api_key"` //nolint:gosec // G117: intentional credential field; full key returned once on creation + KeyID string `json:"key_id"` } -// 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..b84a8d9c4 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) @@ -36,14 +36,14 @@ func TestLogin_AccountLockout_BeforePasswordCheck(t *testing.T) { resp, err := service.Login(ctx, req) assert.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "Check your email address and password and try again") // Generic error to prevent user enumeration + assert.Contains(t, err.Error(), "check your email address and password and try again") // Generic error to prevent user enumeration mockStore.AssertExpectations(t) // Verify UpdateUser was NOT called - lockout check happens first 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) @@ -297,12 +297,12 @@ func TestLogin_AccountLockout_GenericErrorMessage(t *testing.T) { // Error message should be generic to prevent user enumeration // Should NOT reveal that account is locked - assert.Equal(t, "Check your email address and password and try again", err.Error()) + assert.Equal(t, "check your email address and password and try again", err.Error()) mockStore.AssertExpectations(t) } -// TestRecordFailedLogin verifies recordFailedLogin function behavior +// TestRecordFailedLogin verifies recordFailedLogin function behavior. func TestRecordFailedLogin(t *testing.T) { ctx := context.Background() @@ -369,18 +369,18 @@ 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. +// 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 equalize response timing. func TestLogin_OWASPEnumerationInvariant(t *testing.T) { - const wantMsg = "Check your email address and password and try again" + const wantMsg = "check your email address and password and try again" ctx := context.Background() lockUntil := time.Now().Add(10 * time.Minute) type scenario struct { - name string getUser func(t *testing.T) *User // nil means "user not found (nil return)" storeError error // non-nil means GetUserByEmail returns an error + name string } scenarios := []scenario{ diff --git a/internal/auth/service_mfa.go b/internal/auth/service_mfa.go index 789834328..59be6c08e 100644 --- a/internal/auth/service_mfa.go +++ b/internal/auth/service_mfa.go @@ -4,7 +4,7 @@ import ( "context" "crypto/hmac" "crypto/rand" - "crypto/sha1" + "crypto/sha1" //nolint:gosec "crypto/subtle" "encoding/base32" "fmt" @@ -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) @@ -266,12 +266,12 @@ func (s *Service) consumeRecoveryCode(user *User, entered string) bool { // secret, so a stateless client-side carrier (signed token) is not // needed. type MFASetupResult struct { - Secret string + Secret string //nolint:gosec ProvisioningURI string } // 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 — @@ -380,7 +380,7 @@ func (s *Service) MFAEnable(ctx context.Context, userID, code string) ([]string, if err != nil || user == nil { return nil, fmt.Errorf("%w", ErrMFAAuthFailed) } - if err := s.validatePendingMFAEnrollment(ctx, user, code); err != nil { + if err = s.validatePendingMFAEnrollment(ctx, user, code); err != nil { //nolint:gocritic // sloppyReassign: reuse outer err to avoid shadow return nil, err } @@ -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..0ea7d27ee 100644 --- a/internal/auth/service_password.go +++ b/internal/auth/service_password.go @@ -19,16 +19,15 @@ 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. // // Generated once at compile time with cost bcryptCost (12). -// nolint:gosec -- this is a public sentinel hash, not a credential -var dummyPasswordHash = "$2a$12$iAMeexq41AwZ2Dj9oAvGfeVHQxK5ffLPPTNxwPB8bsf7olA730dxO" +const 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 +43,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", @@ -106,7 +105,7 @@ func containsRepeatedChars(password string, n int) bool { // Checks the current password hash and the prior-password history separately so // the caller can render a more useful message for the dominant case (user // re-typing their existing password on the reset form): see issue #459. -func (s *Service) checkPasswordHistory(newPassword string, currentHash string, passwordHistory []string) error { +func (s *Service) checkPasswordHistory(newPassword, currentHash string, passwordHistory []string) error { // Check against current password first; distinct message so the user // can tell "I typed my current one" from "this matches an old one". if currentHash != "" && s.verifyPassword(newPassword, currentHash) { @@ -122,7 +121,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 +134,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 +165,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 +194,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 +205,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 { @@ -225,12 +224,12 @@ func (s *Service) ChangePassword(ctx context.Context, userID string, req ChangeP } // Validate new password against requirements - if err := s.validatePassword(req.NewPassword); err != nil { + if err = s.validatePassword(req.NewPassword); err != nil { //nolint:gocritic // sloppyReassign: reuse outer err to avoid shadow return err } // Check password history to prevent reuse (includes current password) - if err := s.checkPasswordHistory(req.NewPassword, user.PasswordHash, user.PasswordHistory); err != nil { + if err = s.checkPasswordHistory(req.NewPassword, user.PasswordHash, user.PasswordHistory); err != nil { //nolint:gocritic // sloppyReassign: reuse outer err to avoid shadow return err } @@ -259,7 +258,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 +327,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 +477,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..1cc6dc3b8 100644 --- a/internal/auth/service_test.go +++ b/internal/auth/service_test.go @@ -54,7 +54,7 @@ func TestService_Login(t *testing.T) { resp, err := service.Login(ctx, req) assert.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "Check your email address and password and try again") + assert.Contains(t, err.Error(), "check your email address and password and try again") mockStore.AssertExpectations(t) }) @@ -77,7 +77,7 @@ func TestService_Login(t *testing.T) { resp, err := service.Login(ctx, req) assert.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "Check your email address and password and try again") + assert.Contains(t, err.Error(), "check your email address and password and try again") mockStore.AssertExpectations(t) }) @@ -101,7 +101,7 @@ func TestService_Login(t *testing.T) { resp, err := service.Login(ctx, req) assert.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "Check your email address and password and try again") + assert.Contains(t, err.Error(), "check your email address and password and try again") mockStore.AssertExpectations(t) }) @@ -462,7 +462,7 @@ func TestLogin_WithMFA_NoSecret(t *testing.T) { MFASecret: "", // Data-integrity anomaly: MFA enabled but no secret stored } - const genericMsg = "Check your email address and password and try again" + const genericMsg = "check your email address and password and try again" // Right password, no MFA secret -- must return the generic error. t.Run("correct password no MFA secret returns generic error", func(t *testing.T) { @@ -509,7 +509,7 @@ func TestLogin_WithMFA_NoSecret(t *testing.T) { }) } -// Test UpdateUserProfile +// Test UpdateUserProfile. func TestService_ErrorPaths(t *testing.T) { ctx := context.Background() @@ -810,7 +810,7 @@ func TestService_Login_LockedUser(t *testing.T) { resp, err := service.Login(ctx, req) assert.Error(t, err) assert.Nil(t, resp) - assert.Contains(t, err.Error(), "Check your email address and password and try again") + assert.Contains(t, err.Error(), "check your email address and password and try again") mockStore.AssertExpectations(t) } @@ -836,7 +836,7 @@ func TestService_Login_EmptyPasswordHash(t *testing.T) { assert.Error(t, err) assert.Nil(t, resp) // Must return generic error, not a message that leaks account state - assert.Contains(t, err.Error(), "Check your email address and password and try again") + assert.Contains(t, err.Error(), "check your email address and password and try again") mockStore.AssertExpectations(t) } diff --git a/internal/auth/service_user.go b/internal/auth/service_user.go index 28eefb64f..2fb2616df 100644 --- a/internal/auth/service_user.go +++ b/internal/auth/service_user.go @@ -26,12 +26,12 @@ func (s *Service) SetupAdmin(ctx context.Context, req SetupAdminRequest) (*Login return nil, ErrAdminExists } - if _, err := mail.ParseAddress(req.Email); err != nil { + if _, parseErr := mail.ParseAddress(req.Email); parseErr != nil { return nil, ErrInvalidEmail } - if err := s.validatePassword(req.Password); err != nil { - return nil, fmt.Errorf("%w: %v", ErrPasswordPolicy, err) + if valErr := s.validatePassword(req.Password); valErr != nil { + return nil, fmt.Errorf("%w: %w", ErrPasswordPolicy, valErr) } 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) } @@ -137,11 +137,11 @@ func (s *Service) validateCreateUserRequest(ctx context.Context, req CreateUserR if req.Password == "" { return nil } - if err := s.validatePassword(req.Password); err != nil { + if valErr := s.validatePassword(req.Password); valErr != nil { // 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, valErr) } 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 { @@ -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 { @@ -435,14 +433,13 @@ func addsNewGroup(prior, next []string) bool { } // applyUpdateUserRequest applies the non-nil fields of req to user, validating as needed. -func applyUpdateUserRequest(user *User, req UpdateUserRequest) error { +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 @@ -487,8 +484,8 @@ 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 -func (s *Service) UpdateUserProfile(ctx context.Context, userID string, email string, currentPassword string, newPassword string) error { +// UpdateUserProfile allows a user to update their own email and password. +func (s *Service) UpdateUserProfile(ctx context.Context, userID, email, currentPassword, newPassword string) error { user, err := s.store.GetUserByID(ctx, userID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -504,17 +501,18 @@ func (s *Service) UpdateUserProfile(ctx context.Context, userID string, email st return ErrCurrentPasswordIncorrect } - if err := s.updateUserEmail(ctx, user, email); err != nil { - return err + if emailErr := s.updateUserEmail(ctx, user, email); emailErr != nil { + return emailErr } - passwordChanged, err := s.updateUserPassword(user, newPassword) + var passwordChanged bool + passwordChanged, err = s.updateUserPassword(user, newPassword) if err != nil { return err } user.UpdatedAt = time.Now() - if err := s.store.UpdateUser(ctx, user); err != nil { + if err = s.store.UpdateUser(ctx, user); err != nil { return fmt.Errorf("failed to update user: %w", err) } @@ -577,12 +575,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..ceffd1896 100644 --- a/internal/auth/types.go +++ b/internal/auth/types.go @@ -5,21 +5,20 @@ 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"` - PasswordHash string `json:"-" dynamodbav:"PasswordHash"` - Salt string `json:"-" dynamodbav:"Salt"` - GroupIDs []string `json:"group_ids,omitempty" dynamodbav:"GroupIDs"` - CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"` - UpdatedAt time.Time `json:"updated_at" dynamodbav:"UpdatedAt"` - LastLoginAt *time.Time `json:"last_login_at,omitempty" dynamodbav:"LastLoginAt"` - PasswordResetToken string `json:"-" dynamodbav:"PasswordResetToken,omitempty"` - PasswordResetExpiry *time.Time `json:"-" dynamodbav:"PasswordResetExpiry,omitempty"` - Active bool `json:"active" dynamodbav:"Active"` - MFAEnabled bool `json:"mfa_enabled" dynamodbav:"MFAEnabled"` - MFASecret string `json:"-" dynamodbav:"MFASecret,omitempty"` + CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"` + UpdatedAt time.Time `json:"updated_at" dynamodbav:"UpdatedAt"` + LastLoginAt *time.Time `json:"last_login_at,omitempty" dynamodbav:"LastLoginAt"` + PasswordResetExpiry *time.Time `json:"-" dynamodbav:"PasswordResetExpiry,omitempty"` + MFAPendingSecretExpiresAt *time.Time `json:"-" dynamodbav:"MFAPendingSecretExpiresAt,omitempty"` + LockedUntil *time.Time `json:"-" dynamodbav:"LockedUntil,omitempty"` + ID string `json:"id" dynamodbav:"PK"` + Email string `json:"email" dynamodbav:"Email"` + PasswordHash string `json:"-" dynamodbav:"PasswordHash"` + Salt string `json:"-" dynamodbav:"Salt"` + PasswordResetToken string `json:"-" dynamodbav:"PasswordResetToken,omitempty"` + MFASecret string `json:"-" dynamodbav:"MFASecret,omitempty"` // MFA enrollment carrier fields (issue #497). Populated by // MFASetup and consumed by MFAEnable; both cleared on successful // enable / disable. Persisting the pending secret here (instead @@ -28,48 +27,49 @@ type User struct { // An abandoned enrollment expires harmlessly because the active // MFASecret + MFAEnabled fields stay untouched until enable // succeeds. - MFAPendingSecret string `json:"-" dynamodbav:"MFAPendingSecret,omitempty"` - MFAPendingSecretExpiresAt *time.Time `json:"-" dynamodbav:"MFAPendingSecretExpiresAt,omitempty"` + MFAPendingSecret string `json:"-" dynamodbav:"MFAPendingSecret,omitempty"` + GroupIDs []string `json:"group_ids,omitempty" dynamodbav:"GroupIDs"` // MFARecoveryCodes holds bcrypt hashes of single-use recovery // codes generated at enable / regenerate time. The matching hash // is removed from the slice when consumed during login or disable. MFARecoveryCodes []string `json:"-" dynamodbav:"MFARecoveryCodes,omitempty"` - // Account lockout fields for brute-force protection - FailedLoginAttempts int `json:"-" dynamodbav:"FailedLoginAttempts,omitempty"` - LockedUntil *time.Time `json:"-" dynamodbav:"LockedUntil,omitempty"` // Password history for preventing reuse (stores up to 5 previous password hashes) PasswordHistory []string `json:"-" dynamodbav:"PasswordHistory,omitempty"` + // Account lockout fields for brute-force protection + FailedLoginAttempts int `json:"-" dynamodbav:"FailedLoginAttempts,omitempty"` + Active bool `json:"active" dynamodbav:"Active"` + MFAEnabled bool `json:"mfa_enabled" dynamodbav:"MFAEnabled"` } -// Group represents a permission group +// Group represents a permission group. type Group struct { + CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"` + UpdatedAt time.Time `json:"updated_at" dynamodbav:"UpdatedAt"` ID string `json:"id" dynamodbav:"PK"` Name string `json:"name" dynamodbav:"Name"` Description string `json:"description,omitempty" dynamodbav:"Description"` + CreatedBy string `json:"created_by" dynamodbav:"CreatedBy"` Permissions []Permission `json:"permissions" dynamodbav:"Permissions"` AllowedAccounts []string `json:"allowed_accounts,omitempty" dynamodbav:"AllowedAccounts"` // SystemManaged marks groups that are seeded by migrations and // should not be renamed or deleted via the API. Only membership // can change for system-managed groups. - SystemManaged bool `json:"system_managed,omitempty" dynamodbav:"SystemManaged"` - CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"` - UpdatedAt time.Time `json:"updated_at" dynamodbav:"UpdatedAt"` - CreatedBy string `json:"created_by" dynamodbav:"CreatedBy"` + SystemManaged bool `json:"system_managed,omitempty" dynamodbav:"SystemManaged"` } -// Permission defines what actions a group can perform +// Permission defines what actions a group can perform. type Permission struct { + + // Constraints limit the permission to specific contexts + Constraints *PermissionConstraints `json:"constraints,omitempty" dynamodbav:"Constraints"` // Action: view, purchase, configure, admin Action string `json:"action" dynamodbav:"Action"` // Resource type: recommendations, plans, history, config, users Resource string `json:"resource" dynamodbav:"Resource"` - - // Constraints limit the permission to specific contexts - 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,23 +87,23 @@ 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 { + CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"` + ExpiresAt *time.Time `json:"expires_at,omitempty" dynamodbav:"ExpiresAt"` + LastUsedAt *time.Time `json:"last_used_at,omitempty" dynamodbav:"LastUsedAt"` ID string `json:"id" dynamodbav:"PK"` // UUID string UserID string `json:"user_id" dynamodbav:"UserID"` // User who owns this key Name string `json:"name" dynamodbav:"Name"` // Human-readable name KeyPrefix string `json:"key_prefix" dynamodbav:"KeyPrefix"` // First 8 chars for display KeyHash string `json:"-" dynamodbav:"KeyHash"` // SHA-256 hash of the full key Permissions []Permission `json:"permissions,omitempty" dynamodbav:"Permissions"` // Scoped permissions - ExpiresAt *time.Time `json:"expires_at,omitempty" dynamodbav:"ExpiresAt"` - CreatedAt time.Time `json:"created_at" dynamodbav:"CreatedAt"` - LastUsedAt *time.Time `json:"last_used_at,omitempty" dynamodbav:"LastUsedAt"` IsActive bool `json:"is_active" dynamodbav:"IsActive"` } // AuthContext represents the complete authorization context for a user // It combines group memberships and the permissions computed from them. -type AuthContext struct { +type AuthContext struct { //nolint:revive // stutter: AuthContext is the established public name; renaming would break callers User *User Groups []*Group AllowedAccounts []string // Computed from all groups (union) @@ -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"` + Password string `json:"password"` //nolint:gosec 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"` @@ -259,7 +259,7 @@ type PasswordResetConfirm struct { // one group: authorization derives entirely from group membership (issue #907). type CreateUserRequest struct { Email string `json:"email"` - Password string `json:"password"` + Password string `json:"password"` //nolint:gosec GroupIDs []string `json:"group_ids,omitempty"` } @@ -275,37 +275,37 @@ type CreateUserRequest struct { // (including empty) slice replaces the membership and must be non-empty. type UpdateUserRequest struct { Email *string `json:"email,omitempty"` - GroupIDs []string `json:"group_ids,omitempty"` Active *bool `json:"active,omitempty"` + GroupIDs []string `json:"group_ids,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"` + Password string `json:"password"` //nolint:gosec } -// CreateAPIKeyRequest for creating a new user API key +// CreateAPIKeyRequest for creating a new user API key. type CreateAPIKeyRequest struct { + ExpiresAt *time.Time `json:"expires_at,omitempty"` 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"` + APIKey string `json:"api_key"` //nolint:gosec // G117: intentional credential field; full key returned once on creation + KeyID string `json:"key_id"` } -// 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.go b/internal/commitmentopts/probe.go index d954eb79a..37c075de0 100644 --- a/internal/commitmentopts/probe.go +++ b/internal/commitmentopts/probe.go @@ -104,8 +104,8 @@ func walkPaginated( // service (2 terms × 3 payments). func collect(service string, raw []rawOffer) []Combo { type key struct { - term int payment string + term int } seen := make(map[key]struct{}, len(raw)) out := make([]Combo, 0, len(raw)) @@ -137,8 +137,8 @@ func collect(service string, raw []rawOffer) []Combo { // into collect(). Keeping the shape uniform means normalization lives in // exactly one place. type rawOffer struct { - durationSeconds int64 payment string + durationSeconds int64 } // --------------------------------------------------------------------------- @@ -156,7 +156,7 @@ type RDSDescribeOfferings interface { type RDSProber struct { // NewClient builds a client from the probe's aws.Config. Override in // tests to return a mock. - NewClient func(cfg aws.Config) RDSDescribeOfferings + NewClient func(cfg *aws.Config) RDSDescribeOfferings } // Service returns "rds". @@ -164,7 +164,7 @@ func (p *RDSProber) Service() string { return "rds" } // Probe returns the normalized (term, payment) combos RDS currently sells // against db.t3.micro. -func (p *RDSProber) Probe(ctx context.Context, cfg aws.Config) ([]Combo, error) { +func (p *RDSProber) Probe(ctx context.Context, cfg *aws.Config) ([]Combo, error) { client := p.client(cfg) raw, err := walkPaginated(ctx, p.Service(), func(ctx context.Context, token *string) ([]rawOffer, *string, error) { out, err := client.DescribeReservedDBInstancesOfferings(ctx, &rds.DescribeReservedDBInstancesOfferingsInput{ @@ -190,11 +190,11 @@ func (p *RDSProber) Probe(ctx context.Context, cfg aws.Config) ([]Combo, error) return collect(p.Service(), raw), nil } -func (p *RDSProber) client(cfg aws.Config) RDSDescribeOfferings { +func (p *RDSProber) client(cfg *aws.Config) RDSDescribeOfferings { if p.NewClient != nil { return p.NewClient(cfg) } - return rds.NewFromConfig(cfg) + return rds.NewFromConfig(*cfg) } // --------------------------------------------------------------------------- @@ -208,14 +208,14 @@ type ElastiCacheDescribeOfferings interface { // ElastiCacheProber probes elasticache:DescribeReservedCacheNodesOfferings. type ElastiCacheProber struct { - NewClient func(cfg aws.Config) ElastiCacheDescribeOfferings + NewClient func(cfg *aws.Config) ElastiCacheDescribeOfferings } // Service returns "elasticache". func (p *ElastiCacheProber) Service() string { return "elasticache" } // Probe returns the combos for cache.t3.micro. -func (p *ElastiCacheProber) Probe(ctx context.Context, cfg aws.Config) ([]Combo, error) { +func (p *ElastiCacheProber) Probe(ctx context.Context, cfg *aws.Config) ([]Combo, error) { client := p.client(cfg) raw, err := walkPaginated(ctx, p.Service(), func(ctx context.Context, token *string) ([]rawOffer, *string, error) { out, err := client.DescribeReservedCacheNodesOfferings(ctx, &elasticache.DescribeReservedCacheNodesOfferingsInput{ @@ -241,11 +241,11 @@ func (p *ElastiCacheProber) Probe(ctx context.Context, cfg aws.Config) ([]Combo, return collect(p.Service(), raw), nil } -func (p *ElastiCacheProber) client(cfg aws.Config) ElastiCacheDescribeOfferings { +func (p *ElastiCacheProber) client(cfg *aws.Config) ElastiCacheDescribeOfferings { if p.NewClient != nil { return p.NewClient(cfg) } - return elasticache.NewFromConfig(cfg) + return elasticache.NewFromConfig(*cfg) } // --------------------------------------------------------------------------- @@ -261,14 +261,14 @@ type OpenSearchDescribeOfferings interface { // OpenSearchProber probes opensearch:DescribeReservedInstanceOfferings. type OpenSearchProber struct { - NewClient func(cfg aws.Config) OpenSearchDescribeOfferings + NewClient func(cfg *aws.Config) OpenSearchDescribeOfferings } // Service returns "opensearch". func (p *OpenSearchProber) Service() string { return "opensearch" } // Probe returns the combos for t3.small.search. -func (p *OpenSearchProber) Probe(ctx context.Context, cfg aws.Config) ([]Combo, error) { +func (p *OpenSearchProber) Probe(ctx context.Context, cfg *aws.Config) ([]Combo, error) { client := p.client(cfg) raw, err := walkPaginated(ctx, p.Service(), func(ctx context.Context, token *string) ([]rawOffer, *string, error) { out, err := client.DescribeReservedInstanceOfferings(ctx, &opensearch.DescribeReservedInstanceOfferingsInput{ @@ -296,11 +296,11 @@ func (p *OpenSearchProber) Probe(ctx context.Context, cfg aws.Config) ([]Combo, return collect(p.Service(), raw), nil } -func (p *OpenSearchProber) client(cfg aws.Config) OpenSearchDescribeOfferings { +func (p *OpenSearchProber) client(cfg *aws.Config) OpenSearchDescribeOfferings { if p.NewClient != nil { return p.NewClient(cfg) } - return opensearch.NewFromConfig(cfg) + return opensearch.NewFromConfig(*cfg) } // --------------------------------------------------------------------------- @@ -316,14 +316,14 @@ type RedshiftDescribeOfferings interface { // RedshiftProber probes redshift:DescribeReservedNodeOfferings. type RedshiftProber struct { - NewClient func(cfg aws.Config) RedshiftDescribeOfferings + NewClient func(cfg *aws.Config) RedshiftDescribeOfferings } // Service returns "redshift". func (p *RedshiftProber) Service() string { return "redshift" } // Probe returns the combos for dc2.large. -func (p *RedshiftProber) Probe(ctx context.Context, cfg aws.Config) ([]Combo, error) { +func (p *RedshiftProber) Probe(ctx context.Context, cfg *aws.Config) ([]Combo, error) { client := p.client(cfg) raw, err := walkPaginated(ctx, p.Service(), func(ctx context.Context, token *string) ([]rawOffer, *string, error) { out, err := client.DescribeReservedNodeOfferings(ctx, &redshift.DescribeReservedNodeOfferingsInput{ @@ -351,11 +351,11 @@ func (p *RedshiftProber) Probe(ctx context.Context, cfg aws.Config) ([]Combo, er return collect(p.Service(), raw), nil } -func (p *RedshiftProber) client(cfg aws.Config) RedshiftDescribeOfferings { +func (p *RedshiftProber) client(cfg *aws.Config) RedshiftDescribeOfferings { if p.NewClient != nil { return p.NewClient(cfg) } - return redshift.NewFromConfig(cfg) + return redshift.NewFromConfig(*cfg) } // --------------------------------------------------------------------------- @@ -369,14 +369,14 @@ type MemoryDBDescribeOfferings interface { // MemoryDBProber probes memorydb:DescribeReservedNodesOfferings. type MemoryDBProber struct { - NewClient func(cfg aws.Config) MemoryDBDescribeOfferings + NewClient func(cfg *aws.Config) MemoryDBDescribeOfferings } // Service returns "memorydb". func (p *MemoryDBProber) Service() string { return "memorydb" } // Probe returns the combos for db.r6g.large. -func (p *MemoryDBProber) Probe(ctx context.Context, cfg aws.Config) ([]Combo, error) { +func (p *MemoryDBProber) Probe(ctx context.Context, cfg *aws.Config) ([]Combo, error) { client := p.client(cfg) raw, err := walkPaginated(ctx, p.Service(), func(ctx context.Context, token *string) ([]rawOffer, *string, error) { out, err := client.DescribeReservedNodesOfferings(ctx, &memorydb.DescribeReservedNodesOfferingsInput{ @@ -402,11 +402,11 @@ func (p *MemoryDBProber) Probe(ctx context.Context, cfg aws.Config) ([]Combo, er return collect(p.Service(), raw), nil } -func (p *MemoryDBProber) client(cfg aws.Config) MemoryDBDescribeOfferings { +func (p *MemoryDBProber) client(cfg *aws.Config) MemoryDBDescribeOfferings { if p.NewClient != nil { return p.NewClient(cfg) } - return memorydb.NewFromConfig(cfg) + return memorydb.NewFromConfig(*cfg) } // --------------------------------------------------------------------------- @@ -420,7 +420,7 @@ type EC2DescribeOfferings interface { // EC2Prober probes ec2:DescribeReservedInstancesOfferings. type EC2Prober struct { - NewClient func(cfg aws.Config) EC2DescribeOfferings + NewClient func(cfg *aws.Config) EC2DescribeOfferings } // Service returns "ec2". @@ -430,7 +430,7 @@ func (p *EC2Prober) Service() string { return "ec2" } // false so we only see AWS-native (standard/convertible) offerings — the // Marketplace resale market has arbitrary durations that would pollute // normalization. -func (p *EC2Prober) Probe(ctx context.Context, cfg aws.Config) ([]Combo, error) { +func (p *EC2Prober) Probe(ctx context.Context, cfg *aws.Config) ([]Combo, error) { client := p.client(cfg) raw, err := walkPaginated(ctx, p.Service(), func(ctx context.Context, token *string) ([]rawOffer, *string, error) { out, err := client.DescribeReservedInstancesOfferings(ctx, &ec2.DescribeReservedInstancesOfferingsInput{ @@ -443,7 +443,8 @@ func (p *EC2Prober) Probe(ctx context.Context, cfg aws.Config) ([]Combo, error) return nil, nil, err } offers := make([]rawOffer, 0, len(out.ReservedInstancesOfferings)) - for _, o := range out.ReservedInstancesOfferings { + for i := range out.ReservedInstancesOfferings { + o := &out.ReservedInstancesOfferings[i] offers = append(offers, rawOffer{ durationSeconds: aws.ToInt64(o.Duration), payment: string(o.OfferingType), @@ -457,11 +458,11 @@ func (p *EC2Prober) Probe(ctx context.Context, cfg aws.Config) ([]Combo, error) return collect(p.Service(), raw), nil } -func (p *EC2Prober) client(cfg aws.Config) EC2DescribeOfferings { +func (p *EC2Prober) client(cfg *aws.Config) EC2DescribeOfferings { if p.NewClient != nil { return p.NewClient(cfg) } - return ec2.NewFromConfig(cfg) + return ec2.NewFromConfig(*cfg) } // --------------------------------------------------------------------------- @@ -500,7 +501,7 @@ type SavingsPlansDescribeOfferings interface { type SavingsPlansProber struct { // NewClient builds a client from the probe's aws.Config. Override in // tests to return a mock. - NewClient func(cfg aws.Config) SavingsPlansDescribeOfferings + NewClient func(cfg *aws.Config) SavingsPlansDescribeOfferings } // Service returns "savings-plans" — a sentinel used only for error @@ -512,7 +513,7 @@ func (p *SavingsPlansProber) Service() string { return "savings-plans" } // types and returns combos keyed with the per-product service slug. Empty // results for a plan type are silently dropped so the caller never stores // a zero-combo entry that would incorrectly suppress the fallback. -func (p *SavingsPlansProber) Probe(ctx context.Context, cfg aws.Config) ([]Combo, error) { +func (p *SavingsPlansProber) Probe(ctx context.Context, cfg *aws.Config) ([]Combo, error) { client := p.client(cfg) var all []Combo for _, pk := range spPlanKeys { @@ -544,7 +545,8 @@ func (p *SavingsPlansProber) probeOnePlanType( return nil, nil, err } offers := make([]rawOffer, 0, len(out.SearchResults)) - for _, o := range out.SearchResults { + for i := range out.SearchResults { + o := &out.SearchResults[i] offers = append(offers, rawOffer{ durationSeconds: o.DurationSeconds, payment: string(o.PaymentOption), @@ -567,13 +569,13 @@ func (p *SavingsPlansProber) probeOnePlanType( // share the same (duration, payment) matrix — we only need unique combos. func dedupeRaw(raw []rawOffer) []rawOffer { type key struct { - dur int64 payment string + dur int64 } seen := make(map[key]struct{}, len(raw)) out := make([]rawOffer, 0, len(raw)) for _, r := range raw { - k := key{r.durationSeconds, r.payment} + k := key{payment: r.payment, dur: r.durationSeconds} if _, dup := seen[k]; dup { continue } @@ -593,11 +595,11 @@ func collectWithService(service string, raw []rawOffer) []Combo { return collect(service, raw) } -func (p *SavingsPlansProber) client(cfg aws.Config) SavingsPlansDescribeOfferings { +func (p *SavingsPlansProber) client(cfg *aws.Config) SavingsPlansDescribeOfferings { if p.NewClient != nil { return p.NewClient(cfg) } - return savingsplans.NewFromConfig(cfg) + return savingsplans.NewFromConfig(*cfg) } // DefaultProbers returns one prober instance per commitment-capable diff --git a/internal/commitmentopts/probe_azure.go b/internal/commitmentopts/probe_azure.go index d982c80ab..4a6e51a62 100644 --- a/internal/commitmentopts/probe_azure.go +++ b/internal/commitmentopts/probe_azure.go @@ -40,17 +40,17 @@ const azureProbeHourlyCommitment float64 = 0.001 // includes P5Y and drops it if ValidatePurchase rejects it, so the persisted // combos always reflect live API reality rather than the SDK enum. var azureCandidateCombos = []struct { - termYears int - azureTerm armbillingbenefits.Term azurePlan *armbillingbenefits.BillingPlan + azureTerm armbillingbenefits.Term paymentName string + termYears int }{ - {1, armbillingbenefits.TermP1Y, nil, "all-upfront"}, - {1, armbillingbenefits.TermP1Y, billingPlanP1M(), "monthly"}, - {3, armbillingbenefits.TermP3Y, nil, "all-upfront"}, - {3, armbillingbenefits.TermP3Y, billingPlanP1M(), "monthly"}, - {5, armbillingbenefits.TermP5Y, nil, "all-upfront"}, - {5, armbillingbenefits.TermP5Y, billingPlanP1M(), "monthly"}, + {termYears: 1, azureTerm: armbillingbenefits.TermP1Y, azurePlan: nil, paymentName: "all-upfront"}, + {termYears: 1, azureTerm: armbillingbenefits.TermP1Y, azurePlan: billingPlanP1M(), paymentName: "monthly"}, + {termYears: 3, azureTerm: armbillingbenefits.TermP3Y, azurePlan: nil, paymentName: "all-upfront"}, + {termYears: 3, azureTerm: armbillingbenefits.TermP3Y, azurePlan: billingPlanP1M(), paymentName: "monthly"}, + {termYears: 5, azureTerm: armbillingbenefits.TermP5Y, azurePlan: nil, paymentName: "all-upfront"}, + {termYears: 5, azureTerm: armbillingbenefits.TermP5Y, azurePlan: billingPlanP1M(), paymentName: "monthly"}, } // billingPlanP1M returns a pointer to BillingPlanP1M. Using a function avoids diff --git a/internal/commitmentopts/probe_test.go b/internal/commitmentopts/probe_test.go index 7162c6434..7d4372865 100644 --- a/internal/commitmentopts/probe_test.go +++ b/internal/commitmentopts/probe_test.go @@ -69,11 +69,11 @@ func TestRDSProber_Probe(t *testing.T) { }, nil }, } - p := &RDSProber{NewClient: func(cfg aws.Config) RDSDescribeOfferings { return fake }} + p := &RDSProber{NewClient: func(cfg *aws.Config) RDSDescribeOfferings { return fake }} assert.Equal(t, "rds", p.Service()) - got, err := p.Probe(context.Background(), aws.Config{}) + got, err := p.Probe(context.Background(), &aws.Config{}) require.NoError(t, err) got = sortCombos(got) want := []Combo{ @@ -90,14 +90,14 @@ func TestRDSProber_ErrorPropagates(t *testing.T) { fake := &fakeRDS{fn: func(*rds.DescribeReservedDBInstancesOfferingsInput) (*rds.DescribeReservedDBInstancesOfferingsOutput, error) { return nil, boom }} - p := &RDSProber{NewClient: func(cfg aws.Config) RDSDescribeOfferings { return fake }} - _, err := p.Probe(context.Background(), aws.Config{}) + p := &RDSProber{NewClient: func(cfg *aws.Config) RDSDescribeOfferings { return fake }} + _, err := p.Probe(context.Background(), &aws.Config{}) require.Error(t, err) assert.ErrorIs(t, err, boom) } 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 @@ -109,8 +109,8 @@ func TestRDSProber_PageCap(t *testing.T) { Marker: aws.String("more"), }, nil }} - p := &RDSProber{NewClient: func(cfg aws.Config) RDSDescribeOfferings { return fake }} - _, err := p.Probe(context.Background(), aws.Config{}) + p := &RDSProber{NewClient: func(cfg *aws.Config) RDSDescribeOfferings { return fake }} + _, err := p.Probe(context.Background(), &aws.Config{}) require.NoError(t, err) assert.Equal(t, maxPages, calls) } @@ -141,10 +141,10 @@ func TestElastiCacheProber_Probe(t *testing.T) { }, nil }, } - p := &ElastiCacheProber{NewClient: func(cfg aws.Config) ElastiCacheDescribeOfferings { return fake }} + p := &ElastiCacheProber{NewClient: func(cfg *aws.Config) ElastiCacheDescribeOfferings { return fake }} assert.Equal(t, "elasticache", p.Service()) - got, err := p.Probe(context.Background(), aws.Config{}) + got, err := p.Probe(context.Background(), &aws.Config{}) require.NoError(t, err) got = sortCombos(got) want := []Combo{ @@ -191,10 +191,10 @@ func TestOpenSearchProber_Probe(t *testing.T) { }, nil }, } - p := &OpenSearchProber{NewClient: func(cfg aws.Config) OpenSearchDescribeOfferings { return fake }} + p := &OpenSearchProber{NewClient: func(cfg *aws.Config) OpenSearchDescribeOfferings { return fake }} assert.Equal(t, "opensearch", p.Service()) - got, err := p.Probe(context.Background(), aws.Config{}) + got, err := p.Probe(context.Background(), &aws.Config{}) require.NoError(t, err) got = sortCombos(got) want := []Combo{ @@ -241,10 +241,10 @@ func TestRedshiftProber_Probe(t *testing.T) { }, nil }, } - p := &RedshiftProber{NewClient: func(cfg aws.Config) RedshiftDescribeOfferings { return fake }} + p := &RedshiftProber{NewClient: func(cfg *aws.Config) RedshiftDescribeOfferings { return fake }} assert.Equal(t, "redshift", p.Service()) - got, err := p.Probe(context.Background(), aws.Config{}) + got, err := p.Probe(context.Background(), &aws.Config{}) require.NoError(t, err) got = sortCombos(got) want := []Combo{ @@ -280,10 +280,10 @@ func TestMemoryDBProber_Probe(t *testing.T) { }, nil }, } - p := &MemoryDBProber{NewClient: func(cfg aws.Config) MemoryDBDescribeOfferings { return fake }} + p := &MemoryDBProber{NewClient: func(cfg *aws.Config) MemoryDBDescribeOfferings { return fake }} assert.Equal(t, "memorydb", p.Service()) - got, err := p.Probe(context.Background(), aws.Config{}) + got, err := p.Probe(context.Background(), &aws.Config{}) require.NoError(t, err) got = sortCombos(got) want := []Combo{ @@ -325,10 +325,10 @@ func TestEC2Prober_Probe(t *testing.T) { }, nil }, } - p := &EC2Prober{NewClient: func(cfg aws.Config) EC2DescribeOfferings { return fake }} + p := &EC2Prober{NewClient: func(cfg *aws.Config) EC2DescribeOfferings { return fake }} assert.Equal(t, "ec2", p.Service()) - got, err := p.Probe(context.Background(), aws.Config{}) + got, err := p.Probe(context.Background(), &aws.Config{}) require.NoError(t, err) got = sortCombos(got) want := []Combo{ @@ -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 @@ -502,9 +502,9 @@ func TestSavingsPlansProber_Probe(t *testing.T) { return &savingsplans.DescribeSavingsPlansOfferingsOutput{}, nil }, } - p := &SavingsPlansProber{NewClient: func(_ aws.Config) SavingsPlansDescribeOfferings { return fake }} + p := &SavingsPlansProber{NewClient: func(_ *aws.Config) SavingsPlansDescribeOfferings { return fake }} - got, err := p.Probe(context.Background(), aws.Config{}) + got, err := p.Probe(context.Background(), &aws.Config{}) require.NoError(t, err) var computeCombos []Combo @@ -538,9 +538,9 @@ func TestSavingsPlansProber_AllPlanTypes(t *testing.T) { }, nil }, } - p := &SavingsPlansProber{NewClient: func(_ aws.Config) SavingsPlansDescribeOfferings { return fake }} + p := &SavingsPlansProber{NewClient: func(_ *aws.Config) SavingsPlansDescribeOfferings { return fake }} - got, err := p.Probe(context.Background(), aws.Config{}) + got, err := p.Probe(context.Background(), &aws.Config{}) require.NoError(t, err) byService := make(map[string][]Combo) @@ -572,9 +572,9 @@ func TestSavingsPlansProber_EmptyResultDropped(t *testing.T) { return &savingsplans.DescribeSavingsPlansOfferingsOutput{}, nil }, } - p := &SavingsPlansProber{NewClient: func(_ aws.Config) SavingsPlansDescribeOfferings { return fake }} + p := &SavingsPlansProber{NewClient: func(_ *aws.Config) SavingsPlansDescribeOfferings { return fake }} - got, err := p.Probe(context.Background(), aws.Config{}) + got, err := p.Probe(context.Background(), &aws.Config{}) require.NoError(t, err) assert.Empty(t, got, "empty offerings should produce no combos") } @@ -588,9 +588,9 @@ func TestSavingsPlansProber_ErrorPropagates(t *testing.T) { return nil, boom }, } - p := &SavingsPlansProber{NewClient: func(_ aws.Config) SavingsPlansDescribeOfferings { return fake }} + p := &SavingsPlansProber{NewClient: func(_ *aws.Config) SavingsPlansDescribeOfferings { return fake }} - _, err := p.Probe(context.Background(), aws.Config{}) + _, err := p.Probe(context.Background(), &aws.Config{}) require.Error(t, err) assert.ErrorIs(t, err, boom) } @@ -610,9 +610,9 @@ func TestSavingsPlansProber_PageCap(t *testing.T) { }, nil }, } - p := &SavingsPlansProber{NewClient: func(_ aws.Config) SavingsPlansDescribeOfferings { return fake }} + p := &SavingsPlansProber{NewClient: func(_ *aws.Config) SavingsPlansDescribeOfferings { return fake }} - _, err := p.Probe(context.Background(), aws.Config{}) + _, err := p.Probe(context.Background(), &aws.Config{}) require.NoError(t, err) // 4 plan types x maxPages calls each assert.Equal(t, 4*maxPages, calls) @@ -637,9 +637,9 @@ func TestSavingsPlansProber_DedupedAcrossProductTypes(t *testing.T) { return &savingsplans.DescribeSavingsPlansOfferingsOutput{}, nil }, } - p := &SavingsPlansProber{NewClient: func(_ aws.Config) SavingsPlansDescribeOfferings { return fake }} + p := &SavingsPlansProber{NewClient: func(_ *aws.Config) SavingsPlansDescribeOfferings { return fake }} - got, err := p.Probe(context.Background(), aws.Config{}) + got, err := p.Probe(context.Background(), &aws.Config{}) require.NoError(t, err) var compute []Combo diff --git a/internal/commitmentopts/service.go b/internal/commitmentopts/service.go index a290f7cf6..c7f59f659 100644 --- a/internal/commitmentopts/service.go +++ b/internal/commitmentopts/service.go @@ -2,6 +2,7 @@ package commitmentopts import ( "context" + "errors" "fmt" "sync" @@ -93,7 +94,7 @@ func (s *Service) probeAndPersist(ctx context.Context) (Options, error) { for i, p := range s.probers { i, p := i, p group.Go(func() error { - combos, err := p.Probe(gctx, cfg) + combos, err := p.Probe(gctx, &cfg) if err != nil { return fmt.Errorf("probe %s: %w", p.Service(), err) } @@ -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/commitmentopts/service_test.go b/internal/commitmentopts/service_test.go index ab25aee03..8d31f2d40 100644 --- a/internal/commitmentopts/service_test.go +++ b/internal/commitmentopts/service_test.go @@ -16,16 +16,16 @@ import ( // fakeStore is a memory-backed Store used throughout service_test.go. It // tracks call counts so tests can assert the Save-once invariant. type fakeStore struct { - mu sync.Mutex - opts Options - has bool - saves int32 saveErr error getErr error hasErr error + opts Options + saveHook func([]Combo, string) savedID string savedCnt int - saveHook func([]Combo, string) + mu sync.Mutex + saves int32 + has bool } func (f *fakeStore) Get(ctx context.Context) (Options, bool, error) { @@ -68,8 +68,8 @@ func (f *fakeStore) HasData(ctx context.Context) (bool, error) { // fakeAccounts returns a fixed list. type fakeAccounts struct { - accounts []config.CloudAccount err error + accounts []config.CloudAccount } func (f *fakeAccounts) ListCloudAccounts(ctx context.Context, filter config.CloudAccountFilter) ([]config.CloudAccount, error) { @@ -81,13 +81,13 @@ func (f *fakeAccounts) ListCloudAccounts(ctx context.Context, filter config.Clou // stubProber returns a fixed set of combos (or an error). type stubProber struct { + err error name string combos []Combo - err error } func (s *stubProber) Service() string { return s.name } -func (s *stubProber) Probe(ctx context.Context, _ aws.Config) ([]Combo, error) { +func (s *stubProber) Probe(ctx context.Context, _ *aws.Config) ([]Combo, error) { if s.err != nil { return nil, s.err } @@ -276,11 +276,11 @@ func TestService_Get_ConcurrentCallersProbeOnce(t *testing.T) { // proberFunc is a tiny adapter letting tests wire a closure as a Prober. type proberFunc struct { - name string fn func() ([]Combo, error) + name string } func (p proberFunc) Service() string { return p.name } -func (p proberFunc) Probe(ctx context.Context, _ aws.Config) ([]Combo, error) { +func (p proberFunc) Probe(ctx context.Context, _ *aws.Config) ([]Combo, error) { return p.fn() } diff --git a/internal/commitmentopts/types.go b/internal/commitmentopts/types.go index abe06e871..89ee2826b 100644 --- a/internal/commitmentopts/types.go +++ b/internal/commitmentopts/types.go @@ -23,8 +23,8 @@ import ( type Combo struct { Provider string Service string - TermYears int Payment string + TermYears int } // Options groups valid combos by provider and service. Shape: @@ -47,7 +47,7 @@ type Prober interface { // small instance type, normalizes the results, and returns unique // Combos. Errors bubble up — the orchestrating Service treats ANY // probe failure as "don't persist" (all-or-nothing). - Probe(ctx context.Context, cfg aws.Config) ([]Combo, error) + Probe(ctx context.Context, cfg *aws.Config) ([]Combo, error) } // Store persists probe results. Implementations must treat the combos table 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..278bd12d7 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -5,7 +5,7 @@ import "time" // DefaultSettings defines the default configuration values for CUDly. // UpdatedAt is the zero time.Time{} for every entry: these are static // compile-time defaults and have never been "updated" by a user. -var DefaultSettings = []ConfigSetting{ +var DefaultSettings = []Setting{ // Purchase Defaults { Key: "purchase_defaults.term", @@ -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,8 +389,8 @@ func GetDefaultValue(key string) any { return nil } -// GetDefaultSetting returns the complete default setting for a given key -func GetDefaultSetting(key string) *ConfigSetting { +// GetDefaultSetting returns the complete default setting for a given key. +func GetDefaultSetting(key string) *Setting { for _, setting := range DefaultSettings { if setting.Key == key { // Return a copy @@ -401,9 +401,9 @@ func GetDefaultSetting(key string) *ConfigSetting { return nil } -// GetDefaultsByCategory returns all default settings for a given category -func GetDefaultsByCategory(category string) []ConfigSetting { - var result []ConfigSetting +// GetDefaultsByCategory returns all default settings for a given category. +func GetDefaultsByCategory(category string) []Setting { + var result []Setting for _, setting := range DefaultSettings { if setting.Category == category { result = append(result, setting) @@ -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/defaults_test.go b/internal/config/defaults_test.go index bcb5da2fe..ed66cf3f3 100644 --- a/internal/config/defaults_test.go +++ b/internal/config/defaults_test.go @@ -50,14 +50,14 @@ func TestDefaultSettings_ExpectedKeys(t *testing.T) { func TestDefaultSettings_PurchaseDefaults(t *testing.T) { tests := []struct { + expectedValue interface{} key string expectedType string - expectedValue interface{} }{ - {"purchase_defaults.term", "int", 3}, - {"purchase_defaults.payment_option", "string", "no-upfront"}, - {"purchase_defaults.coverage", "float", 80.0}, - {"purchase_defaults.ramp_schedule", "string", "immediate"}, + {key: "purchase_defaults.term", expectedType: "int", expectedValue: 3}, + {key: "purchase_defaults.payment_option", expectedType: "string", expectedValue: "no-upfront"}, + {key: "purchase_defaults.coverage", expectedType: "float", expectedValue: 80.0}, + {key: "purchase_defaults.ramp_schedule", expectedType: "string", expectedValue: "immediate"}, } for _, tt := range tests { @@ -73,14 +73,14 @@ func TestDefaultSettings_PurchaseDefaults(t *testing.T) { func TestDefaultSettings_Notification(t *testing.T) { tests := []struct { + expectedValue interface{} key string expectedType string - expectedValue interface{} }{ - {"notification.days_before", "int", 3}, - {"notification.email_enabled", "bool", true}, - {"notification.approval_required", "bool", true}, - {"notification.email_from", "string", "noreply@cudly.io"}, + {key: "notification.days_before", expectedType: "int", expectedValue: 3}, + {key: "notification.email_enabled", expectedType: "bool", expectedValue: true}, + {key: "notification.approval_required", expectedType: "bool", expectedValue: true}, + {key: "notification.email_from", expectedType: "string", expectedValue: "noreply@cudly.io"}, } for _, tt := range tests { @@ -117,17 +117,17 @@ func TestDefaultSettings_Providers(t *testing.T) { func TestDefaultSettings_Security(t *testing.T) { tests := []struct { + expectedValue interface{} key string expectedType string - expectedValue interface{} }{ - {"security.session_duration_hours", "int", 24}, - {"security.lockout_attempts", "int", 5}, - {"security.lockout_duration_minutes", "int", 15}, - {"security.password_min_length", "int", 12}, - {"security.password_require_special", "bool", true}, - {"security.password_require_number", "bool", true}, - {"security.password_require_uppercase", "bool", true}, + {key: "security.session_duration_hours", expectedType: "int", expectedValue: 24}, + {key: "security.lockout_attempts", expectedType: "int", expectedValue: 5}, + {key: "security.lockout_duration_minutes", expectedType: "int", expectedValue: 15}, + {key: "security.password_min_length", expectedType: "int", expectedValue: 12}, + {key: "security.password_require_special", expectedType: "bool", expectedValue: true}, + {key: "security.password_require_number", expectedType: "bool", expectedValue: true}, + {key: "security.password_require_uppercase", expectedType: "bool", expectedValue: true}, } for _, tt := range tests { @@ -143,14 +143,14 @@ func TestDefaultSettings_Security(t *testing.T) { func TestDefaultSettings_Scheduling(t *testing.T) { tests := []struct { + expectedValue interface{} key string expectedType string - expectedValue interface{} }{ - {"scheduling.auto_collect", "bool", false}, - {"scheduling.collect_schedule", "string", "rate(1 day)"}, - {"scheduling.auto_purchase", "bool", false}, - {"scheduling.purchase_schedule", "string", "rate(1 day)"}, + {key: "scheduling.auto_collect", expectedType: "bool", expectedValue: false}, + {key: "scheduling.collect_schedule", expectedType: "string", expectedValue: "rate(1 day)"}, + {key: "scheduling.auto_purchase", expectedType: "bool", expectedValue: false}, + {key: "scheduling.purchase_schedule", expectedType: "string", expectedValue: "rate(1 day)"}, } for _, tt := range tests { @@ -166,17 +166,17 @@ func TestDefaultSettings_Scheduling(t *testing.T) { func TestDefaultSettings_AWS(t *testing.T) { tests := []struct { + expectedValue interface{} key string expectedType string - expectedValue interface{} }{ - {"aws.rds.min_utilization_percent", "float", 50.0}, - {"aws.elasticache.min_utilization_percent", "float", 50.0}, - {"aws.opensearch.min_utilization_percent", "float", 50.0}, - {"aws.ec2.include_convertible", "bool", true}, - {"aws.savings_plans.compute_enabled", "bool", true}, - {"aws.savings_plans.ec2_enabled", "bool", true}, - {"aws.savings_plans.sagemaker_enabled", "bool", true}, + {key: "aws.rds.min_utilization_percent", expectedType: "float", expectedValue: 50.0}, + {key: "aws.elasticache.min_utilization_percent", expectedType: "float", expectedValue: 50.0}, + {key: "aws.opensearch.min_utilization_percent", expectedType: "float", expectedValue: 50.0}, + {key: "aws.ec2.include_convertible", expectedType: "bool", expectedValue: true}, + {key: "aws.savings_plans.compute_enabled", expectedType: "bool", expectedValue: true}, + {key: "aws.savings_plans.ec2_enabled", expectedType: "bool", expectedValue: true}, + {key: "aws.savings_plans.sagemaker_enabled", expectedType: "bool", expectedValue: true}, } for _, tt := range tests { @@ -192,13 +192,13 @@ func TestDefaultSettings_AWS(t *testing.T) { func TestDefaultSettings_Thresholds(t *testing.T) { tests := []struct { + expectedValue interface{} key string expectedType string - expectedValue interface{} }{ - {"thresholds.min_monthly_savings", "float", 10.0}, - {"thresholds.min_savings_percentage", "float", 5.0}, - {"thresholds.max_upfront_cost", "float", 0.0}, + {key: "thresholds.min_monthly_savings", expectedType: "float", expectedValue: 10.0}, + {key: "thresholds.min_savings_percentage", expectedType: "float", expectedValue: 5.0}, + {key: "thresholds.max_upfront_cost", expectedType: "float", expectedValue: 0.0}, } for _, tt := range tests { @@ -235,13 +235,13 @@ func TestDefaultSettings_Retention(t *testing.T) { func TestDefaultSettings_API(t *testing.T) { tests := []struct { + expectedValue interface{} key string expectedType string - expectedValue interface{} }{ - {"api.rate_limit_requests_per_minute", "int", 100}, - {"api.rate_limit_enabled", "bool", true}, - {"api.timeout_seconds", "int", 30}, + {key: "api.rate_limit_requests_per_minute", expectedType: "int", expectedValue: 100}, + {key: "api.rate_limit_enabled", expectedType: "bool", expectedValue: true}, + {key: "api.timeout_seconds", expectedType: "int", expectedValue: 30}, } for _, tt := range tests { @@ -257,13 +257,13 @@ func TestDefaultSettings_API(t *testing.T) { func TestGetDefaultValue(t *testing.T) { tests := []struct { - key string expectedValue interface{} + key string }{ - {"purchase_defaults.term", 3}, - {"purchase_defaults.coverage", 80.0}, - {"notification.email_enabled", true}, - {"nonexistent.key", nil}, + {key: "purchase_defaults.term", expectedValue: 3}, + {key: "purchase_defaults.coverage", expectedValue: 80.0}, + {key: "notification.email_enabled", expectedValue: true}, + {key: "nonexistent.key", expectedValue: nil}, } for _, tt := range tests { diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go index 0ed2cd7c2..52902c527 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" @@ -90,25 +90,27 @@ type StoreInterface interface { // When non-nil the actor is stamped onto transitioned_by + transitioned_at; when nil, // transitioned_by is set to NULL and transitioned_at is still set to NOW() for ordering. TransitionExecutionStatus(ctx context.Context, executionID string, fromStatuses []string, toStatus string, actor *string) (*PurchaseExecution, error) - // CancelExecutionAtomic atomically flips status from pending / notified / - // scheduled to cancelled, setting cancelled_by. The 'scheduled' status - // supports the Gmail-style pre-fire delay revoke path (issue #290). - // Returns (true, "cancelled", nil) on success and (false, currentStatus, - // 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 atomically flips status from pending / notified to + // canceled, setting canceled_by. The 'scheduled' status is NOT handled here + // -- it is canceled via CancelScheduledExecutionAtomic, which surfaces a + // distinct CAS race outcome for the Gmail-style pre-fire delay revoke path. + // Returns (true, currentStatus, nil) on success -- currentStatus is the + // persisted post-cancel DB value -- and (false, currentStatus, 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, canceledBy *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 + // 'canceled', setting canceled_by. Used by the Gmail-style pre-fire delay // revoke path (issue #291 wave-2) to cancel a scheduled execution at $0 before // the scheduler fires the SDK call. The 'pending'/'notified' set accepted by // CancelExecutionAtomic is intentionally not extended here so the two revoke // flows surface distinct CAS race outcomes -- a scheduled row that the // scheduler has already transitioned to 'approved' / 'running' must surface as // a 410 ("window closed") rather than a 409 ("not pending"). Returns - // (true, "cancelled", nil) on success and (false, currentStatus, nil) when + // (true, currentStatus, 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, canceledBy *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 @@ -253,7 +255,7 @@ type StoreInterface interface { // SuccessfulCollect for the per-row semantics. ReplaceRecommendations(ctx context.Context, collectedAt time.Time, recs []RecommendationRecord) error UpsertRecommendations(ctx context.Context, collectedAt time.Time, recs []RecommendationRecord, successfulCollects []SuccessfulCollect) error - ListStoredRecommendations(ctx context.Context, filter RecommendationFilter) ([]RecommendationRecord, error) + ListStoredRecommendations(ctx context.Context, filter *RecommendationFilter) ([]RecommendationRecord, error) GetRecommendationsFreshness(ctx context.Context) (*RecommendationsFreshness, error) SetRecommendationsCollectionError(ctx context.Context, errMsg string) error // MarkCollectionStarted atomically sets last_collection_started_at = now 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..6c8d718e2 100644 --- a/internal/config/recommendation_overrides_test.go +++ b/internal/config/recommendation_overrides_test.go @@ -34,7 +34,9 @@ func (f *fakeAccountConfigReader) GetAccountServiceOverride(_ context.Context, a return f.overrides[accountID+"|"+provider+"|"+service], nil } -func acctRec(account, provider, service string) RecommendationRecord { +func acctRec(account string) RecommendationRecord { + const provider = "aws" + const service = "rds" return RecommendationRecord{ Provider: provider, Service: service, @@ -71,7 +73,7 @@ func TestResolveAccountConfigsForRecs_OverridePresent_ResolvedConfigReflectsOver "acct-A|aws|rds": {Enabled: boolPtr(false), Coverage: float64Ptr(50)}, }, } - recs := []RecommendationRecord{acctRec("acct-A", "aws", "rds")} + recs := []RecommendationRecord{acctRec("acct-A")} got, err := ResolveAccountConfigsForRecs(context.Background(), reader, recs) assert.NoError(t, err) @@ -89,7 +91,7 @@ func TestResolveAccountConfigsForRecs_OverrideAbsent_GlobalReturned(t *testing.T "aws|rds": {Provider: "aws", Service: "rds", Enabled: true, Coverage: 80}, }, } - recs := []RecommendationRecord{acctRec("acct-A", "aws", "rds")} + recs := []RecommendationRecord{acctRec("acct-A")} got, err := ResolveAccountConfigsForRecs(context.Background(), reader, recs) assert.NoError(t, err) @@ -104,7 +106,7 @@ func TestResolveAccountConfigsForRecs_OverrideAbsent_GlobalReturned(t *testing.T // per-account override exists — there is nothing to apply. func TestResolveAccountConfigsForRecs_NoGlobalNoOverride_TripleSkipped(t *testing.T) { reader := &fakeAccountConfigReader{} - recs := []RecommendationRecord{acctRec("acct-A", "aws", "rds")} + recs := []RecommendationRecord{acctRec("acct-A")} got, err := ResolveAccountConfigsForRecs(context.Background(), reader, recs) assert.NoError(t, err) @@ -121,14 +123,14 @@ func TestResolveAccountConfigsForRecs_OverrideWithoutGlobal_OverrideApplied(t *t "acct-A|aws|rds": {Enabled: boolPtr(false), Coverage: float64Ptr(70)}, }, } - recs := []RecommendationRecord{acctRec("acct-A", "aws", "rds")} + recs := []RecommendationRecord{acctRec("acct-A")} got, err := ResolveAccountConfigsForRecs(context.Background(), reader, recs) assert.NoError(t, err) 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") @@ -145,7 +147,7 @@ func TestResolveAccountConfigsForRecs_OverrideWithGlobal_MergesCorrectly(t *test "acct-B|aws|rds": {Coverage: float64Ptr(50)}, }, } - recs := []RecommendationRecord{acctRec("acct-B", "aws", "rds")} + recs := []RecommendationRecord{acctRec("acct-B")} got, err := ResolveAccountConfigsForRecs(context.Background(), reader, recs) assert.NoError(t, err) @@ -164,9 +166,9 @@ func TestResolveAccountConfigsForRecs_DedupesPerTriple(t *testing.T) { }, } recs := []RecommendationRecord{ - acctRec("acct-A", "aws", "rds"), - acctRec("acct-A", "aws", "rds"), // same triple - acctRec("acct-A", "aws", "rds"), + acctRec("acct-A"), + acctRec("acct-A"), // same triple + acctRec("acct-A"), } got, err := ResolveAccountConfigsForRecs(context.Background(), reader, recs) @@ -183,8 +185,8 @@ func TestResolveAccountConfigsForRecs_GlobalCachedAcrossAccounts(t *testing.T) { }, } recs := []RecommendationRecord{ - acctRec("acct-A", "aws", "rds"), - acctRec("acct-B", "aws", "rds"), // same (provider, service), different account + acctRec("acct-A"), + acctRec("acct-B"), // same (provider, service), different account } got, err := ResolveAccountConfigsForRecs(context.Background(), reader, recs) @@ -200,9 +202,9 @@ func TestResolveAccountConfigsForRecs_GlobalAbsentCachedNegative(t *testing.T) { // still run once per account because the override may exist without a global. reader := &fakeAccountConfigReader{} recs := []RecommendationRecord{ - acctRec("acct-A", "aws", "rds"), - acctRec("acct-B", "aws", "rds"), - acctRec("acct-C", "aws", "rds"), + acctRec("acct-A"), + acctRec("acct-B"), + acctRec("acct-C"), } _, err := ResolveAccountConfigsForRecs(context.Background(), reader, recs) @@ -219,9 +221,9 @@ func TestResolveAccountConfigsForRecs_GlobalAbsentCachedNegative(t *testing.T) { func TestResolveAccountConfigsForRecs_NoGlobalNoOverride_DedupedCorrectly(t *testing.T) { reader := &fakeAccountConfigReader{} // no globals, no overrides recs := []RecommendationRecord{ - acctRec("acct-A", "aws", "rds"), - acctRec("acct-A", "aws", "rds"), // duplicate - acctRec("acct-A", "aws", "rds"), // duplicate + acctRec("acct-A"), + acctRec("acct-A"), // duplicate + acctRec("acct-A"), // duplicate } got, err := ResolveAccountConfigsForRecs(context.Background(), reader, recs) @@ -233,7 +235,7 @@ func TestResolveAccountConfigsForRecs_NoGlobalNoOverride_DedupedCorrectly(t *tes func TestResolveAccountConfigsForRecs_GlobalErrorPropagates(t *testing.T) { reader := &fakeAccountConfigReader{globalErr: errors.New("boom")} - recs := []RecommendationRecord{acctRec("acct-A", "aws", "rds")} + recs := []RecommendationRecord{acctRec("acct-A")} got, err := ResolveAccountConfigsForRecs(context.Background(), reader, recs) assert.Error(t, err) @@ -247,7 +249,7 @@ func TestResolveAccountConfigsForRecs_OverrideErrorPropagates(t *testing.T) { }, overrideErr: errors.New("boom"), } - recs := []RecommendationRecord{acctRec("acct-A", "aws", "rds")} + recs := []RecommendationRecord{acctRec("acct-A")} got, err := ResolveAccountConfigsForRecs(context.Background(), reader, recs) assert.Error(t, err) 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..65daaaa2a 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 @@ -874,6 +874,7 @@ func (s *PostgresStore) SavePurchaseExecutionTx(ctx context.Context, tx pgx.Tx, capacityPercent = 100 } + //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql query := ` INSERT INTO purchase_executions ( plan_id, execution_id, status, step_number, scheduled_date, @@ -972,6 +973,7 @@ func (s *PostgresStore) SavePurchaseExecutionTx(ctx context.Context, tx pgx.Tx, // actor is the UUID of the user performing the transition (nil for system-initiated paths); // it is stamped onto transitioned_by and transitioned_at is always set to NOW(). func (s *PostgresStore) TransitionExecutionStatus(ctx context.Context, executionID string, fromStatuses []string, toStatus string, actor *string) (*PurchaseExecution, error) { + //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql query := ` UPDATE purchase_executions SET status = $2, updated_at = NOW(), @@ -1006,7 +1008,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) } @@ -1015,12 +1017,12 @@ func (s *PostgresStore) TransitionExecutionStatus(ctx context.Context, execution } // CancelExecutionAtomic atomically transitions an execution from -// pending or notified to cancelled, setting cancelled_by to the supplied +// pending or notified to canceled (DB status: 'cancelled'), setting canceled_by to the supplied // actor (NULL when actor is nil). The UPDATE is conditional on // status IN ('pending','notified') so a concurrent approve that has // already transitioned the row to 'approved' causes zero rows to be // affected and the method returns (false, currentStatus, nil) with the -// live status fetched via a follow-up SELECT. Returns (true, "cancelled", +// live status fetched via a follow-up SELECT. Returns (true, currentStatus, // nil) on success and (false, "", err) on a real DB error. // // The 'scheduled' status is intentionally NOT accepted here -- the @@ -1034,7 +1036,8 @@ 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, canceledBy *string) (canceled bool, currentStatus string, err error) { + //nolint:misspell // DB schema value 'cancelled'/'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql q := ` UPDATE purchase_executions SET status = 'cancelled', @@ -1044,7 +1047,7 @@ func (s *PostgresStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, ex AND status IN ('pending', 'notified') RETURNING status ` - rows, err := tx.Query(ctx, q, executionID, cancelledBy) + rows, err := tx.Query(ctx, q, executionID, canceledBy) if err != nil { return false, "", fmt.Errorf("failed to cancel execution: %w", err) } @@ -1075,10 +1078,10 @@ func (s *PostgresStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, ex } // CancelScheduledExecutionAtomic atomically transitions an execution from -// 'scheduled' to 'cancelled', setting cancelled_by to the supplied actor +// 'scheduled' to canceled (DB status: 'cancelled'), setting canceled_by to the supplied actor // (NULL when actor is nil). Used by the Gmail-style pre-fire delay revoke // path (issue #290 / #291 wave-2): an approved-but-not-yet-fired execution -// can be revoked at $0 by flipping it to cancelled before the scheduler +// can be revoked at $0 by flipping it to canceled before the scheduler // fires the cloud SDK call. // // The 'scheduled' status is the only accepted source. A concurrent @@ -1088,10 +1091,11 @@ func (s *PostgresStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, ex // ("revocation window has closed") so the frontend can fall through to // the post-execution Azure direct-cancel API path. // -// Returns (true, "cancelled", nil) on success and (false, "", err) on a +// Returns (true, currentStatus, 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, canceledBy *string) (canceled bool, currentStatus string, err error) { + //nolint:misspell // DB schema value 'cancelled'/'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql q := ` UPDATE purchase_executions SET status = 'cancelled', @@ -1101,7 +1105,7 @@ func (s *PostgresStore) CancelScheduledExecutionAtomic(ctx context.Context, tx p AND status = 'scheduled' RETURNING status ` - rows, err := tx.Query(ctx, q, executionID, cancelledBy) + rows, err := tx.Query(ctx, q, executionID, canceledBy) if err != nil { return false, "", fmt.Errorf("failed to cancel scheduled execution: %w", err) } @@ -1146,6 +1150,7 @@ func (s *PostgresStore) GetExecutionsByStatuses(ctx context.Context, statuses [] if limit > MaxListLimit { limit = MaxListLimit } + //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token, recommendations, @@ -1187,6 +1192,7 @@ func (s *PostgresStore) GetPlannedExecutions(ctx context.Context, statuses []str if limit > MaxListLimit { limit = MaxListLimit } + //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token, recommendations, @@ -1212,6 +1218,7 @@ func (s *PostgresStore) GetPlannedExecutions(ctx context.Context, statuses []str // TransitionExecutionStatus) and is not touched again unless the run finalizes, // so it is the age of the strand. Mirrors GetStaleProcessingExchanges. func (s *PostgresStore) GetStaleApprovedExecutions(ctx context.Context, olderThan time.Duration) ([]PurchaseExecution, error) { + //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token, recommendations, @@ -1254,6 +1261,7 @@ func (s *PostgresStore) ListStuckExecutions(ctx context.Context, statuses []stri if olderThan <= 0 { return nil, fmt.Errorf("ListStuckExecutions: olderThan must be > 0, got %s", olderThan) } + //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token, recommendations, @@ -1273,8 +1281,9 @@ 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) { + //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token, recommendations, @@ -1299,6 +1308,7 @@ func (s *PostgresStore) GetPendingExecutions(ctx context.Context) ([]PurchaseExe // duplicate-detection and execution creation atomic, closing the TOCTOU race // in executePurchase (issue #643). func (s *PostgresStore) GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) ([]PurchaseExecution, error) { + //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql const query = ` SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token, recommendations, @@ -1329,6 +1339,7 @@ func (s *PostgresStore) GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) ( // from a real DB failure (any other non-nil error). A nil error guarantees // a non-nil execution (fail-loud contract; issues #976, #1339). func (s *PostgresStore) GetExecutionByID(ctx context.Context, executionID string) (*PurchaseExecution, error) { + //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token, recommendations, @@ -1354,8 +1365,9 @@ 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) { + //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token, recommendations, @@ -1430,7 +1442,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 { @@ -1540,6 +1552,7 @@ func scanExecutionRows(rows pgx.Rows) ([]PurchaseExecution, error) { // Results are ordered oldest-due-first so the scheduler fires them in FIFO order. // Capped at MaxListLimit per sweep to bound the per-tick blast radius. func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]PurchaseExecution, error) { + //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, notification_sent, approval_token, recommendations, @@ -1569,7 +1582,7 @@ func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]Purcha // Two independent cleanup branches, each with its own retention window so // that a row far in one dimension doesn't block cleanup in the other: // -// 1. Terminal-state cleanup: `status IN ('completed', 'cancelled') AND +// 1. Terminal-state cleanup: `status IN ('completed', 'canceled') AND // scheduled_date < NOW() - retention`. Keeps recent completions // visible in the UI for at least `retention` days before purging. // @@ -1591,6 +1604,7 @@ func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]Purcha // NULL `expires_at` is excluded from branch 2 so rows that never had an // expiration deadline are safe from expiry-based cleanup. func (s *PostgresStore) CleanupOldExecutions(ctx context.Context, retentionDays int) (int64, error) { + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql query := ` DELETE FROM purchase_executions WHERE ( @@ -1615,7 +1629,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 +1669,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 +1685,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, @@ -1727,7 +1741,7 @@ func (s *PostgresStore) GetActivePurchaseHistory(ctx context.Context, asOf time. // number, unknown provider) matches account_id with no provider gate. Providers // are sorted for deterministic SQL. The OR is wrapped in parentheses so it // composes with the surrounding AND chain. -func appendAccountPredicate(conds []string, args []any, accountIDs []string, externalIDsByProvider map[string][]string) ([]string, []any) { +func appendAccountPredicate(conds []string, args []any, accountIDs []string, externalIDsByProvider map[string][]string) (outConds []string, outArgs []any) { if len(accountIDs) == 0 && len(externalIDsByProvider) == 0 { return conds, args } @@ -2013,7 +2027,7 @@ func (s *PostgresStore) GetPurchaseHistoryByPurchaseID(ctx context.Context, purc // the purchase_history row identified by purchaseID. The UPDATE is a no-op // when revoked_at is already non-null (idempotency guard). Returns a not-found // error when zero rows are affected and revoked_at was previously NULL. -func (s *PostgresStore) MarkPurchaseRevoked(ctx context.Context, purchaseID string, revokedAt time.Time, revokedVia string, supportCaseID string, calcRefundAmount *float64, calcRefundCurrency string) error { +func (s *PostgresStore) MarkPurchaseRevoked(ctx context.Context, purchaseID string, revokedAt time.Time, revokedVia, supportCaseID string, calcRefundAmount *float64, calcRefundCurrency string) error { var supportCaseIDPtr *string if supportCaseID != "" { supportCaseIDPtr = &supportCaseID @@ -2170,7 +2184,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 +2248,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 +2273,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 +2298,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, @@ -2306,7 +2320,7 @@ func (s *PostgresStore) GetRIExchangeHistory(ctx context.Context, since time.Tim // Uses a single UPDATE...WHERE...RETURNING for atomicity, then diagnoses failure // only if zero rows are returned. // actor is the UUID of the user performing the transition (nil for system-initiated paths). -func (s *PostgresStore) TransitionRIExchangeStatus(ctx context.Context, id string, fromStatus string, toStatus string, actor *string) (*RIExchangeRecord, error) { +func (s *PostgresStore) TransitionRIExchangeStatus(ctx context.Context, id, fromStatus, toStatus string, actor *string) (*RIExchangeRecord, error) { query := ` UPDATE ri_exchange_history SET status = $3, updated_at = NOW(), @@ -2340,7 +2354,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,8 +2366,8 @@ 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 -func (s *PostgresStore) CompleteRIExchange(ctx context.Context, id string, exchangeID string) error { +// CompleteRIExchange marks an RI exchange as completed. +func (s *PostgresStore) CompleteRIExchange(ctx context.Context, id, exchangeID string) error { query := ` UPDATE ri_exchange_history SET status = 'completed', exchange_id = $2, completed_at = NOW() @@ -2376,7 +2390,7 @@ func (s *PostgresStore) CompleteRIExchange(ctx context.Context, id string, excha // (issue #300). Called after CompleteRIExchange when approval came from a // session-authed user. The stamping is best-effort (log + continue on failure // so the exchange itself isn't rolled back just because the audit stamp failed). -func (s *PostgresStore) StampRIExchangeApprovedBy(ctx context.Context, id string, approverEmail string) error { +func (s *PostgresStore) StampRIExchangeApprovedBy(ctx context.Context, id, approverEmail string) error { query := ` UPDATE ri_exchange_history SET approved_by = $2 @@ -2393,8 +2407,8 @@ func (s *PostgresStore) StampRIExchangeApprovedBy(ctx context.Context, id string return nil } -// FailRIExchange marks an RI exchange as failed -func (s *PostgresStore) FailRIExchange(ctx context.Context, id string, errorMsg string) error { +// FailRIExchange marks an RI exchange as failed. +func (s *PostgresStore) FailRIExchange(ctx context.Context, id, errorMsg string) error { query := ` UPDATE ri_exchange_history SET status = 'failed', error = $2 @@ -2413,7 +2427,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,8 +2446,9 @@ 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) { + //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql query := ` UPDATE ri_exchange_history SET status = 'cancelled' @@ -2448,7 +2463,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 +2479,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 +2643,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 +2693,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) @@ -2754,10 +2769,14 @@ func (s *PostgresStore) DeleteCloudAccount(ctx context.Context, id string) error if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } - defer tx.Rollback(ctx) //nolint:errcheck + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + _ = rbErr + } + }() // 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 +2910,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 +2964,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) @@ -3062,10 +3081,14 @@ func (s *PostgresStore) SetPlanAccounts(ctx context.Context, planID string, acco if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } - defer tx.Rollback(ctx) //nolint:errcheck + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + _ = rbErr + } + }() - if err = s.validatePlanAccountProvidersTx(ctx, tx, planID, accountIDs); err != nil { - return err + if valErr := s.validatePlanAccountProvidersTx(ctx, tx, planID, accountIDs); valErr != nil { + return valErr } if _, err = tx.Exec(ctx, `DELETE FROM plan_accounts WHERE plan_id = $1`, planID); err != nil { @@ -3142,7 +3165,7 @@ type planAccountProviderMismatch struct { Provider string } -func (s *PostgresStore) findPlanAccountProviderMismatchesTx(ctx context.Context, tx pgx.Tx, accountIDs []string, expected []string) ([]planAccountProviderMismatch, error) { +func (s *PostgresStore) findPlanAccountProviderMismatchesTx(ctx context.Context, tx pgx.Tx, accountIDs, expected []string) ([]planAccountProviderMismatch, error) { expectedSet := make(map[string]struct{}, len(expected)) for _, provider := range expected { expectedSet[provider] = struct{}{} @@ -3222,7 +3245,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 +3254,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..43823c3e2 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 { @@ -114,8 +114,18 @@ func (s *additionalMockStore) GetExecutionByPlanAndDate(ctx context.Context, pla return &executions[0], nil } -func (s *additionalMockStore) queryPurchaseHistory(ctx context.Context, query string, args ...interface{}) ([]PurchaseHistoryRecord, error) { - rows, err := s.mock.Query(ctx, query, args...) +const purchaseHistoryQuery = ` + SELECT account_id, purchase_id, timestamp, provider, service, region, + resource_type, count, term, payment, upfront_cost, monthly_cost, + estimated_savings, plan_id, plan_name, ramp_step + FROM purchase_history + WHERE account_id = $1 + ORDER BY timestamp DESC + LIMIT $2 + ` + +func (s *additionalMockStore) queryPurchaseHistory(ctx context.Context, args ...interface{}) ([]PurchaseHistoryRecord, error) { + rows, err := s.mock.Query(ctx, purchaseHistoryQuery, args...) if err != nil { return nil, err } @@ -361,16 +371,7 @@ func TestQueryPurchaseHistory_ScanError(t *testing.T) { WithArgs("scan-error-account", 10). WillReturnRows(rows) - query := ` - SELECT account_id, purchase_id, timestamp, provider, service, region, - resource_type, count, term, payment, upfront_cost, monthly_cost, - estimated_savings, plan_id, plan_name, ramp_step - FROM purchase_history - WHERE account_id = $1 - ORDER BY timestamp DESC - LIMIT $2 - ` - _, err = store.queryPurchaseHistory(context.Background(), query, "scan-error-account", 10) + _, err = store.queryPurchaseHistory(context.Background(), "scan-error-account", 10) assert.Error(t, err) mock.ExpectationsWereMet() @@ -398,16 +399,7 @@ func TestQueryPurchaseHistory_RowsError(t *testing.T) { WithArgs("rows-error-account", 10). WillReturnRows(rows) - query := ` - SELECT account_id, purchase_id, timestamp, provider, service, region, - resource_type, count, term, payment, upfront_cost, monthly_cost, - estimated_savings, plan_id, plan_name, ramp_step - FROM purchase_history - WHERE account_id = $1 - ORDER BY timestamp DESC - LIMIT $2 - ` - _, err = store.queryPurchaseHistory(context.Background(), query, "rows-error-account", 10) + _, err = store.queryPurchaseHistory(context.Background(), "rows-error-account", 10) assert.Error(t, err) mock.ExpectationsWereMet() @@ -424,16 +416,7 @@ func TestQueryPurchaseHistory_QueryError(t *testing.T) { WithArgs("query-error-account", 10). WillReturnError(errors.New("database unavailable")) - query := ` - SELECT account_id, purchase_id, timestamp, provider, service, region, - resource_type, count, term, payment, upfront_cost, monthly_cost, - estimated_savings, plan_id, plan_name, ramp_step - FROM purchase_history - WHERE account_id = $1 - ORDER BY timestamp DESC - LIMIT $2 - ` - _, err = store.queryPurchaseHistory(context.Background(), query, "query-error-account", 10) + _, err = store.queryPurchaseHistory(context.Background(), "query-error-account", 10) assert.Error(t, err) assert.Contains(t, err.Error(), "database unavailable") @@ -461,16 +444,7 @@ func TestQueryPurchaseHistory_NullPlanFields(t *testing.T) { WithArgs("null-fields", 10). WillReturnRows(rows) - query := ` - SELECT account_id, purchase_id, timestamp, provider, service, region, - resource_type, count, term, payment, upfront_cost, monthly_cost, - estimated_savings, plan_id, plan_name, ramp_step - FROM purchase_history - WHERE account_id = $1 - ORDER BY timestamp DESC - LIMIT $2 - ` - records, err := store.queryPurchaseHistory(context.Background(), query, "null-fields", 10) + records, err := store.queryPurchaseHistory(context.Background(), "null-fields", 10) require.NoError(t, err) assert.Len(t, records, 1) assert.Empty(t, records[0].PlanID) @@ -788,18 +762,18 @@ func TestPresetRampSchedules_Monthly10PctCoverage(t *testing.T) { // CONFIG SETTING TYPE TESTS // ========================================== -func TestConfigSetting_AllTypes(t *testing.T) { +func TestSetting_AllTypes(t *testing.T) { now := time.Now() tests := []struct { name string - setting ConfigSetting + setting Setting checkType func(interface{}) bool description string }{ { name: "int type", - setting: ConfigSetting{ + setting: Setting{ Key: "test.int", Value: 42, Type: "int", @@ -814,7 +788,7 @@ func TestConfigSetting_AllTypes(t *testing.T) { }, { name: "float type", - setting: ConfigSetting{ + setting: Setting{ Key: "test.float", Value: 3.14159, Type: "float", @@ -829,7 +803,7 @@ func TestConfigSetting_AllTypes(t *testing.T) { }, { name: "bool type", - setting: ConfigSetting{ + setting: Setting{ Key: "test.bool", Value: true, Type: "bool", @@ -844,7 +818,7 @@ func TestConfigSetting_AllTypes(t *testing.T) { }, { name: "string type", - setting: ConfigSetting{ + setting: Setting{ Key: "test.string", Value: "hello world", Type: "string", diff --git a/internal/config/store_postgres_cloud_accounts_test.go b/internal/config/store_postgres_cloud_accounts_test.go index 13f6d9ac1..5ffc0973e 100644 --- a/internal/config/store_postgres_cloud_accounts_test.go +++ b/internal/config/store_postgres_cloud_accounts_test.go @@ -157,26 +157,14 @@ func TestCloudAccountFilter_WithAllFields(t *testing.T) { // ========================================== func TestCloudAccount_CredentialsConfiguredDefaultsFalse(t *testing.T) { - a := CloudAccount{ - ID: "id", - Name: "Test", - Provider: "aws", - ExternalID: "123456789012", - Enabled: true, - } + a := CloudAccount{} assert.False(t, a.CredentialsConfigured) } func TestCloudAccount_AWSFieldsOptional(t *testing.T) { a := CloudAccount{ - ID: "id", - Name: "Test", - Provider: "aws", - ExternalID: "123456789012", - Enabled: true, - AWSAuthMode: "role_arn", - AWSRoleARN: "arn:aws:iam::123456789012:role/CUDly", - AWSIsOrgRoot: false, + AWSAuthMode: "role_arn", + AWSRoleARN: "arn:aws:iam::123456789012:role/CUDly", } assert.Equal(t, "role_arn", a.AWSAuthMode) assert.Equal(t, "arn:aws:iam::123456789012:role/CUDly", a.AWSRoleARN) diff --git a/internal/config/store_postgres_comprehensive_test.go b/internal/config/store_postgres_comprehensive_test.go index dda071e1f..e2dcb09cb 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 } @@ -1697,9 +1697,9 @@ func TestConstants_TokenConstants(t *testing.T) { // CONFIGSETTING TYPE TESTS // ========================================== -func TestConfigSetting_Fields(t *testing.T) { +func TestSetting_Fields(t *testing.T) { now := time.Now() - setting := ConfigSetting{ + setting := Setting{ Key: "test.key", Value: "test-value", Type: "string", @@ -1716,7 +1716,7 @@ func TestConfigSetting_Fields(t *testing.T) { assert.Equal(t, now, setting.UpdatedAt) } -func TestConfigSetting_DifferentValueTypes(t *testing.T) { +func TestSetting_DifferentValueTypes(t *testing.T) { tests := []struct { name string value interface{} @@ -1731,11 +1731,12 @@ func TestConfigSetting_DifferentValueTypes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - setting := ConfigSetting{ + setting := Setting{ Key: "test." + tt.dataType, Value: tt.value, Type: tt.dataType, } + assert.Equal(t, "test."+tt.dataType, setting.Key) assert.Equal(t, tt.value, setting.Value) assert.Equal(t, tt.dataType, setting.Type) }) 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..08a46ee4f 100644 --- a/internal/config/store_postgres_mock_test.go +++ b/internal/config/store_postgres_mock_test.go @@ -15,14 +15,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 +49,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 +129,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,7 +282,7 @@ func (s *testablePostgresStore) GetPurchasePlan(ctx context.Context, planID stri ) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { return nil, errors.New("purchase plan not found") } return nil, err @@ -309,7 +309,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 +333,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 +366,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 +385,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 +422,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 +457,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 +488,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 +525,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 +545,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 +565,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 +605,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 +634,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 +665,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 +690,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 +709,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 +727,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 +746,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 +765,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 +809,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 +829,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 +849,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 +881,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 +913,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_pgxmock_test.go b/internal/config/store_postgres_pgxmock_test.go index cebe69d08..74544b726 100644 --- a/internal/config/store_postgres_pgxmock_test.go +++ b/internal/config/store_postgres_pgxmock_test.go @@ -553,7 +553,7 @@ func TestPGXMock_GetExecutionByID_Success(t *testing.T) { "plan_id", "execution_id", "status", "step_number", "scheduled_date", "notification_sent", "approval_token", "recommendations", "total_upfront_cost", "estimated_savings", "completed_at", "error", "expires_at", - "cloud_account_id", "source", "approved_by", "cancelled_by", "capacity_percent", + "cloud_account_id", "source", "approved_by", "cancelled_by", "capacity_percent", //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql "created_by_user_id", "retry_execution_id", "retry_attempt_n", "approval_token_expires_at", "executed_by_user_id", "executed_at", "pre_approval_skip_reason", @@ -603,7 +603,7 @@ func TestPGXMock_GetExecutionByID_WithTimestamps(t *testing.T) { "plan_id", "execution_id", "status", "step_number", "scheduled_date", "notification_sent", "approval_token", "recommendations", "total_upfront_cost", "estimated_savings", "completed_at", "error", "expires_at", - "cloud_account_id", "source", "approved_by", "cancelled_by", "capacity_percent", + "cloud_account_id", "source", "approved_by", "cancelled_by", "capacity_percent", //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql "created_by_user_id", "retry_execution_id", "retry_attempt_n", "approval_token_expires_at", "executed_by_user_id", "executed_at", "pre_approval_skip_reason", @@ -670,7 +670,7 @@ func TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns(t *testing.T) { "plan_id", "execution_id", "status", "step_number", "scheduled_date", "notification_sent", "approval_token", "recommendations", "total_upfront_cost", "estimated_savings", "completed_at", "error", "expires_at", - "cloud_account_id", "source", "approved_by", "cancelled_by", "capacity_percent", + "cloud_account_id", "source", "approved_by", "cancelled_by", "capacity_percent", //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql "created_by_user_id", "retry_execution_id", "retry_attempt_n", "approval_token_expires_at", "executed_by_user_id", "executed_at", "pre_approval_skip_reason", @@ -2009,7 +2009,7 @@ func stuckExecCols() []string { "plan_id", "execution_id", "status", "step_number", "scheduled_date", "notification_sent", "approval_token", "recommendations", "total_upfront_cost", "estimated_savings", "completed_at", "error", "expires_at", - "cloud_account_id", "source", "approved_by", "cancelled_by", "capacity_percent", + "cloud_account_id", "source", "approved_by", "cancelled_by", "capacity_percent", //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql "created_by_user_id", "retry_execution_id", "retry_attempt_n", "approval_token_expires_at", "executed_by_user_id", "executed_at", "pre_approval_skip_reason", @@ -2203,7 +2203,7 @@ func TestPGXMock_TransitionExecutionStatus_ProbeHardErrorNotMappedToNotFound(t * "plan_id", "execution_id", "status", "step_number", "scheduled_date", "notification_sent", "approval_token", "recommendations", "total_upfront_cost", "estimated_savings", "completed_at", "error", "expires_at", - "cloud_account_id", "source", "approved_by", "cancelled_by", "capacity_percent", + "cloud_account_id", "source", "approved_by", "canceled_by", "capacity_percent", "created_by_user_id", "retry_execution_id", "retry_attempt_n", "approval_token_expires_at", "executed_by_user_id", "executed_at", "pre_approval_skip_reason", diff --git a/internal/config/store_postgres_recommendations.go b/internal/config/store_postgres_recommendations.go index ce3c3a722..8482f3751 100644 --- a/internal/config/store_postgres_recommendations.go +++ b/internal/config/store_postgres_recommendations.go @@ -3,6 +3,7 @@ package config import ( "context" "encoding/json" + "errors" "fmt" "math" "strings" @@ -28,7 +29,11 @@ func (s *PostgresStore) ReplaceRecommendations(ctx context.Context, collectedAt if err != nil { return fmt.Errorf("failed to begin tx: %w", err) } - defer tx.Rollback(ctx) //nolint:errcheck + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + _ = rbErr + } + }() if _, err := tx.Exec(ctx, `DELETE FROM recommendations`); err != nil { return fmt.Errorf("failed to wipe recommendations: %w", err) @@ -75,7 +80,11 @@ func (s *PostgresStore) UpsertRecommendations(ctx context.Context, collectedAt t if err != nil { return fmt.Errorf("failed to begin tx: %w", err) } - defer tx.Rollback(ctx) //nolint:errcheck + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + _ = rbErr + } + }() if err := insertRecommendationsBatched(ctx, tx, collectedAt, recs, true); err != nil { return err @@ -84,7 +93,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 @@ -175,7 +184,8 @@ func insertRecommendationsBatch(ctx context.Context, tx pgx.Tx, collectedAt time args := make([]any, 0, len(recs)*colsPerRow) placeholders := make([]string, 0, len(recs)) - for i, rec := range recs { + for i := range recs { + rec := &recs[i] payload, err := json.Marshal(rec) if err != nil { return fmt.Errorf("failed to marshal recommendation %d: %w", i, err) @@ -231,9 +241,8 @@ func insertRecommendationsBatch(ctx context.Context, tx pgx.Tx, collectedAt time // for ListStoredRecommendations. Extracted to keep the caller below the // gocyclo threshold; also makes the SQL builder testable in isolation if // needed. -func buildRecommendationFilter(filter RecommendationFilter) (string, []any) { +func buildRecommendationFilter(filter *RecommendationFilter) (clause string, args []any) { var conds []string - var args []any add := func(cond string, val any) { conds = append(conds, fmt.Sprintf(cond, len(args)+1)) args = append(args, val) @@ -359,7 +368,10 @@ func recOnDemandBaseline(rec *RecommendationRecord) (float64, bool) { // MinSavingsUSD) are applied in SQL so Postgres prunes the rows; the // MinSavingsPct filter is applied in-process because the on-demand // baseline lives inside the JSONB payload (not a native column). -func (s *PostgresStore) ListStoredRecommendations(ctx context.Context, filter RecommendationFilter) ([]RecommendationRecord, error) { +func (s *PostgresStore) ListStoredRecommendations(ctx context.Context, filter *RecommendationFilter) ([]RecommendationRecord, error) { + if filter == nil { + filter = &RecommendationFilter{} + } whereClause, args := buildRecommendationFilter(filter) rows, err := s.db.Query(ctx, `SELECT payload FROM recommendations`+whereClause, args...) if err != nil { @@ -443,7 +455,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 28502f40f..1f3a6056a 100644 --- a/internal/config/store_postgres_recommendations_test.go +++ b/internal/config/store_postgres_recommendations_test.go @@ -71,7 +71,7 @@ func TestPostgresStore_ReplaceRecommendations(t *testing.T) { } require.NoError(t, store.ReplaceRecommendations(ctx, now, initial)) - got, err := store.ListStoredRecommendations(ctx, config.RecommendationFilter{}) + got, err := store.ListStoredRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) assert.Len(t, got, 2) @@ -81,7 +81,7 @@ func TestPostgresStore_ReplaceRecommendations(t *testing.T) { } require.NoError(t, store.ReplaceRecommendations(ctx, now.Add(time.Minute), replacement)) - got, err = store.ListStoredRecommendations(ctx, config.RecommendationFilter{}) + got, err = store.ListStoredRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, got, 1) assert.Equal(t, "eu-west-1", got[0].Region) @@ -115,7 +115,7 @@ func TestPostgresStore_UpsertRecommendations_PartialCollect(t *testing.T) { {Provider: "aws"}, })) - got, err := store.ListStoredRecommendations(ctx, config.RecommendationFilter{}) + got, err := store.ListStoredRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, got, 2, "azure row should survive partial-aws collect") @@ -159,7 +159,7 @@ func TestPostgresStore_UpsertRecommendations_EvictsStaleInSuccessfulProvider(t * {Provider: "aws"}, })) - got, err := store.ListStoredRecommendations(ctx, config.RecommendationFilter{}) + got, err := store.ListStoredRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, got, 1, "unseen aws row should be evicted") assert.Equal(t, "m5.xlarge", got[0].ResourceType) @@ -180,17 +180,17 @@ func TestPostgresStore_ListStoredRecommendations_FilterPushdown(t *testing.T) { require.NoError(t, store.ReplaceRecommendations(ctx, now, recs)) // Filter by provider. - got, err := store.ListStoredRecommendations(ctx, config.RecommendationFilter{Provider: "aws"}) + got, err := store.ListStoredRecommendations(ctx, &config.RecommendationFilter{Provider: "aws"}) require.NoError(t, err) assert.Len(t, got, 2) // Filter by service. - got, err = store.ListStoredRecommendations(ctx, config.RecommendationFilter{Service: "ec2"}) + got, err = store.ListStoredRecommendations(ctx, &config.RecommendationFilter{Service: "ec2"}) require.NoError(t, err) assert.Len(t, got, 1) // Filter by min savings (dollar floor, pushed down to SQL). - got, err = store.ListStoredRecommendations(ctx, config.RecommendationFilter{MinSavingsUSD: 25}) + got, err = store.ListStoredRecommendations(ctx, &config.RecommendationFilter{MinSavingsUSD: 25}) require.NoError(t, err) assert.Len(t, got, 1) assert.Equal(t, "rds", got[0].Service) @@ -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 @@ -258,7 +258,7 @@ func TestPostgresStore_UpsertRecommendations_StoresAllTermVariants(t *testing.T) {Provider: "azure"}, })) - got, err := store.ListStoredRecommendations(ctx, config.RecommendationFilter{Provider: "azure"}) + got, err := store.ListStoredRecommendations(ctx, &config.RecommendationFilter{Provider: "azure"}) require.NoError(t, err) require.Len(t, got, 3, "all 3 (term, payment) variants must round-trip — pre-fix this would have collapsed to 1") @@ -316,7 +316,7 @@ func TestPostgresStore_UpsertRecommendations_AccountScopedEviction(t *testing.T) // Assert: acct-1's stale rows (D2 + D4 from t0) are evicted; acct-1 // keeps the new D8 row; acct-2's two rows survive. - got, err := store.ListStoredRecommendations(ctx, config.RecommendationFilter{Provider: "azure"}) + got, err := store.ListStoredRecommendations(ctx, &config.RecommendationFilter{Provider: "azure"}) require.NoError(t, err) byAccountAndType := map[string]bool{} @@ -370,7 +370,7 @@ func TestPostgresStore_UpsertRecommendations_AmbientAndRegisteredCoexist(t *test {Provider: "aws", CloudAccountID: ®isteredAcctID}, })) - got, err := store.ListStoredRecommendations(ctx, config.RecommendationFilter{Provider: "aws"}) + got, err := store.ListStoredRecommendations(ctx, &config.RecommendationFilter{Provider: "aws"}) require.NoError(t, err) require.Len(t, got, 2, "ambient row must survive; registered row must be upserted") diff --git a/internal/config/store_postgres_registrations.go b/internal/config/store_postgres_registrations.go index 9b15db9f3..d181c2a78 100644 --- a/internal/config/store_postgres_registrations.go +++ b/internal/config/store_postgres_registrations.go @@ -106,7 +106,6 @@ func (s *PostgresStore) ListAccountRegistrations(ctx context.Context, filter Acc idx, idx, idx, )) args = append(args, "%"+escaped+"%") - idx++ } query := `SELECT ` + registrationColumns() + ` FROM account_registrations` @@ -299,7 +298,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..d02efd297 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) @@ -199,30 +199,34 @@ func TestRecommendationFilter_UnitDistinction(t *testing.T) { func TestBuildRecommendationFilter_MinSavingsUSD(t *testing.T) { t.Run("MinSavingsUSD zero produces no WHERE clause fragment", func(t *testing.T) { - clause, args := buildRecommendationFilter(RecommendationFilter{MinSavingsUSD: 0}) + f := RecommendationFilter{MinSavingsUSD: 0} + clause, args := buildRecommendationFilter(&f) assert.Empty(t, clause) assert.Empty(t, args) }) t.Run("MinSavingsUSD positive includes monthly_savings >= clause", func(t *testing.T) { - clause, args := buildRecommendationFilter(RecommendationFilter{MinSavingsUSD: 50}) + f := RecommendationFilter{MinSavingsUSD: 50} + clause, args := buildRecommendationFilter(&f) assert.Contains(t, clause, "monthly_savings >= $") require.Len(t, args, 1) assert.Equal(t, float64(50), args[0]) }) - t.Run("MinSavingsPct zero is never pushed into SQL (no WHERE fragment)", func(t *testing.T) { + t.Run("MinSavingsPct (e.g. 30) is never pushed into SQL (no WHERE fragment)", func(t *testing.T) { // Pct filter is applied in-process, never in SQL. - clause, args := buildRecommendationFilter(RecommendationFilter{MinSavingsPct: 30}) + f := RecommendationFilter{MinSavingsPct: 30} + clause, args := buildRecommendationFilter(&f) assert.Empty(t, clause, "MinSavingsPct must not appear in the SQL WHERE clause") assert.Empty(t, args) }) t.Run("MinSavingsUSD and MinSavingsPct combined: only USD in SQL", func(t *testing.T) { - clause, args := buildRecommendationFilter(RecommendationFilter{ + f := RecommendationFilter{ MinSavingsUSD: 50, MinSavingsPct: 20, - }) + } + clause, args := buildRecommendationFilter(&f) // Only the dollar floor appears in SQL. assert.Contains(t, clause, "monthly_savings >= $") // Exactly one arg (the dollar value); the pct is handled in-process. diff --git a/internal/config/store_postgres_test.go b/internal/config/store_postgres_test.go index 0ae422030..85c9917e7 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") @@ -307,10 +307,13 @@ 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) + // Use a valid UUID format that does not exist. + // Contract: GetExecutionByID wraps ErrNotFound when no row matches. + // Align with the sibling assertion in store_postgres_db_test.go. + exec, err := store.GetExecutionByID(ctx, "00000000-0000-0000-0000-000000000000") + require.Error(t, err) assert.Contains(t, err.Error(), "not found") + assert.Nil(t, exec) }) t.Run("Get execution by plan and date - not found", func(t *testing.T) { diff --git a/internal/config/store_postgres_unit_test.go b/internal/config/store_postgres_unit_test.go index 3742e2e9e..b2a8646fc 100644 --- a/internal/config/store_postgres_unit_test.go +++ b/internal/config/store_postgres_unit_test.go @@ -9,15 +9,15 @@ 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 { + expected interface{} name string ttl int64 - expected interface{} }{ { name: "zero TTL returns nil", @@ -49,11 +49,11 @@ 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 time time.Time + name string expected int64 }{ { @@ -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..7109bf8ac 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -9,8 +9,8 @@ import ( "github.com/LeanerCloud/CUDly/pkg/ladder" ) -// GlobalConfig represents the global CUDly configuration -type GlobalConfig struct { +// GlobalConfig represents the global CUDly configuration. +type GlobalConfig struct { //nolint:govet // fieldalignment: logical grouping takes priority over size optimisation EnabledProviders []string `json:"enabled_providers" dynamodbav:"enabled_providers"` NotificationEmail *string `json:"notification_email,omitempty" dynamodbav:"notification_email,omitempty"` AutoCollect bool `json:"auto_collect"` @@ -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,65 +148,52 @@ 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"` - Enabled bool `json:"enabled" dynamodbav:"enabled"` - Term int `json:"term" dynamodbav:"term"` Payment string `json:"payment" dynamodbav:"payment"` - Coverage float64 `json:"coverage" dynamodbav:"coverage"` + Service string `json:"service" dynamodbav:"service"` + Provider string `json:"provider" dynamodbav:"provider"` RampSchedule string `json:"ramp_schedule" dynamodbav:"ramp_schedule"` + IncludeRegions []string `json:"include_regions,omitempty" dynamodbav:"include_regions,omitempty"` IncludeEngines []string `json:"include_engines,omitempty" dynamodbav:"include_engines,omitempty"` ExcludeEngines []string `json:"exclude_engines,omitempty" dynamodbav:"exclude_engines,omitempty"` - IncludeRegions []string `json:"include_regions,omitempty" dynamodbav:"include_regions,omitempty"` ExcludeRegions []string `json:"exclude_regions,omitempty" dynamodbav:"exclude_regions,omitempty"` IncludeTypes []string `json:"include_types,omitempty" dynamodbav:"include_types,omitempty"` ExcludeTypes []string `json:"exclude_types,omitempty" dynamodbav:"exclude_types,omitempty"` - // MinCount is the GUI/persisted equivalent of the CLI --min-count flag: - // the minimum instance/node count a recommendation must carry to be - // surfaced. Applied at read time by - // scheduler.filterRecsByResolvedConfigs against the persisted - // RecommendationRecord.Count. 0 (the default) disables the filter, - // matching the CLI flag's 0-no-floor semantics. - MinCount int `json:"min_count,omitempty" dynamodbav:"min_count,omitempty"` + Coverage float64 `json:"coverage" dynamodbav:"coverage"` + Term int `json:"term" dynamodbav:"term"` + MinCount int `json:"min_count,omitempty" dynamodbav:"min_count,omitempty"` + Enabled bool `json:"enabled" dynamodbav:"enabled"` } -// 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"` - Enabled bool `json:"enabled" dynamodbav:"enabled"` - AutoPurchase bool `json:"auto_purchase" dynamodbav:"auto_purchase"` - NotificationDaysBefore int `json:"notification_days_before" dynamodbav:"notification_days_before"` - Services map[string]ServiceConfig `json:"services" dynamodbav:"services"` - RampSchedule RampSchedule `json:"ramp_schedule" dynamodbav:"ramp_schedule"` CreatedAt time.Time `json:"created_at" dynamodbav:"created_at"` UpdatedAt time.Time `json:"updated_at" dynamodbav:"updated_at"` + LastNotificationSent *time.Time `json:"last_notification_sent,omitempty" dynamodbav:"last_notification_sent,omitempty"` NextExecutionDate *time.Time `json:"next_execution_date,omitempty" dynamodbav:"next_execution_date,omitempty"` + Services map[string]ServiceConfig `json:"services" dynamodbav:"services"` LastExecutionDate *time.Time `json:"last_execution_date,omitempty" dynamodbav:"last_execution_date,omitempty"` - LastNotificationSent *time.Time `json:"last_notification_sent,omitempty" dynamodbav:"last_notification_sent,omitempty"` - // Unassigned is true when the plan has zero rows in plan_accounts. - // This can happen for legacy plans created before target_accounts was - // required (issue #743). Such plans are invisible when an account filter - // is active because the normal JOIN excludes them; ListPurchasePlans - // surfaces them alongside filtered results so operators can find and - // re-scope them. The field is omitted (false) in the no-filter case - // where all plans are returned unconditionally. - Unassigned bool `json:"unassigned,omitempty" dynamodbav:"unassigned,omitempty"` + Name string `json:"name" dynamodbav:"name"` + ID string `json:"id" dynamodbav:"id"` + RampSchedule RampSchedule `json:"ramp_schedule" dynamodbav:"ramp_schedule"` + NotificationDaysBefore int `json:"notification_days_before" dynamodbav:"notification_days_before"` + AutoPurchase bool `json:"auto_purchase" dynamodbav:"auto_purchase"` + Enabled bool `json:"enabled" dynamodbav:"enabled"` + 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 + StartDate time.Time `json:"start_date" dynamodbav:"start_date"` + Type string `json:"type" dynamodbav:"type"` PercentPerStep float64 `json:"percent_per_step" dynamodbav:"percent_per_step"` StepIntervalDays int `json:"step_interval_days" dynamodbav:"step_interval_days"` CurrentStep int `json:"current_step" dynamodbav:"current_step"` TotalSteps int `json:"total_steps" dynamodbav:"total_steps"` - 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 +214,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 +226,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,119 +234,47 @@ 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"` - Status string `json:"status" dynamodbav:"status"` // pending, notified, approved, cancelled, completed, failed - StepNumber int `json:"step_number" dynamodbav:"step_number"` - ScheduledDate time.Time `json:"scheduled_date" dynamodbav:"scheduled_date"` - NotificationSent *time.Time `json:"notification_sent,omitempty" dynamodbav:"notification_sent,omitempty"` - ApprovalToken string `json:"approval_token,omitempty" dynamodbav:"approval_token,omitempty"` - Recommendations []RecommendationRecord `json:"recommendations" dynamodbav:"recommendations"` - TotalUpfrontCost float64 `json:"total_upfront_cost" dynamodbav:"total_upfront_cost"` - EstimatedSavings float64 `json:"estimated_savings" dynamodbav:"estimated_savings"` - CompletedAt *time.Time `json:"completed_at,omitempty" dynamodbav:"completed_at,omitempty"` - Error string `json:"error,omitempty" dynamodbav:"error,omitempty"` - TTL int64 `json:"ttl,omitempty" dynamodbav:"ttl,omitempty"` - CloudAccountID *string `json:"cloud_account_id,omitempty" dynamodbav:"cloud_account_id,omitempty"` - // Source identifies the CUDly surface that triggered this execution - // ("cudly-cli" or "cudly-web"). Propagated into PurchaseOptions and - // stamped as a tag/label onto every commitment this execution buys. - Source string `json:"source,omitempty" dynamodbav:"source,omitempty"` - // ApprovedBy / CancelledBy carry the email of the session-authenticated - // user who acted on this execution via the auth-gated deep-link flow - // (frontend /purchases/{action}/:id → login-if-needed → session-authed - // endpoint). Nil on legacy token-only approve/cancel paths — the - // handler / History UI falls back to the notification email as the - // accountable party in that case. Nullable TEXT in Postgres. - ApprovedBy *string `json:"approved_by,omitempty" dynamodbav:"approved_by,omitempty"` - CancelledBy *string `json:"cancelled_by,omitempty" dynamodbav:"cancelled_by,omitempty"` - // CreatedByUserID is the UUID of the session-authenticated user who - // triggered this execution (e.g. clicked Execute on the Recommendations - // page or submitted the bulk-purchase modal). NULL on rows created - // before the column was introduced (migration 000041) and on - // scheduler-driven executions where there is no human creator. Used - // by the session-authed cancel handler to enforce cancel:own_executions - // — a non-admin may cancel only executions they themselves created. - // NULL is treated as "not the current user". - CreatedByUserID *string `json:"created_by_user_id,omitempty" dynamodbav:"created_by_user_id,omitempty"` - // RetryExecutionID points from a *failed* execution to the new - // execution created when the user clicked Retry (issue #47). Set - // only on the original failed row; NULL on every other row including - // the retry itself (the retry's own RetryAttemptN > 0 is the - // "this is a retry" marker). Forms a forward-pointing chain: - // failed_v1.retry_execution_id = failed_v2.execution_id, etc. - // Migration 000042 self-FKs the column ON DELETE SET NULL so a - // cleanup of a successor doesn't cascade-delete its predecessor. - RetryExecutionID *string `json:"retry_execution_id,omitempty" dynamodbav:"retry_execution_id,omitempty"` - // RetryAttemptN is the position of this execution in a retry chain. - // 0 (default) on every fresh execution; 1 on the first retry of any - // failed row; n+1 on the n+1-th retry. The handler reads the - // predecessor's count and stamps n+1 atomically with the new - // INSERT inside the retry transaction. The History UI uses this to - // soft-block retries past a threshold so an obviously-stuck - // configuration doesn't accumulate dozens of dead retry rows. - // Migration 000042 added the column with default 0 so legacy rows - // look exactly like fresh first-retry candidates. - RetryAttemptN int `json:"retry_attempt_n,omitempty" dynamodbav:"retry_attempt_n,omitempty"` - // CapacityPercent records what fraction of the originally-recommended - // counts the user chose when the bulk Purchase flow submitted this - // execution (1..100). Audit-only: the Recommendations slice already - // carries the scaled counts, so backend math is unaffected by this - // field. Defaults to 100 for legacy and scheduler-driven executions. - CapacityPercent int `json:"capacity_percent,omitempty" dynamodbav:"capacity_percent,omitempty"` - // ApprovalTokenExpiresAt is the UTC deadline after which the - // ApprovalToken must be rejected by ApproveExecution and - // loadCancelableExecution (issue #397). Set at execution creation to - // ScheduledDate + ApprovalTokenTTL. NULL on rows created before - // migration 000051 — legacy rows are treated as not-yet-expired - // (backward-compatible: the TTL-checking gate only fires when the - // field is non-nil). Migration 000051 adds the column; new rows - // always carry a non-nil value. - ApprovalTokenExpiresAt *time.Time `json:"approval_token_expires_at,omitempty" dynamodbav:"approval_token_expires_at,omitempty"` - // ExecutedByUserID is the UUID of the session user who triggered a - // direct-execute (issue #289, execute-any/execute-own). NULL on rows - // that went through the normal approval flow. Non-null signals the - // approval step was intentionally skipped by an authorized operator. - // Migration 000058 adds the column. - ExecutedByUserID *string `json:"executed_by_user_id,omitempty" dynamodbav:"executed_by_user_id,omitempty"` - // ExecutedAt is the UTC timestamp when the direct-execute path fired. - // NULL for rows on the normal approval flow. Migration 000058. - ExecutedAt *time.Time `json:"executed_at,omitempty" dynamodbav:"executed_at,omitempty"` - // PreApprovalSkipReason is a human-readable token describing why the - // approval step was skipped. For direct-execute rows it is the literal - // string "direct-execute permission". NULL on every normal-flow row. - // Migration 000058. - PreApprovalSkipReason *string `json:"pre_approval_skip_reason,omitempty" dynamodbav:"pre_approval_skip_reason,omitempty"` - // IdempotencyKey is the stable lineage anchor the per-rec provider - // idempotency token is derived from (issue #1012). Unlike ExecutionID - // it is NOT regenerated on Retry or multi-account fan-out: it is - // generated once at first creation, copied verbatim onto every Retry - // successor, and combined with the account ID to seed each per-account - // fan-out row. This makes DeriveIdempotencyToken reproduce the same - // 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). - 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 - // scheduler fires the actual SDK call when this timestamp is in the past. - // NULL on every immediate-execute row. Migration 000065. - ScheduledExecutionAt *time.Time `json:"scheduled_execution_at,omitempty" dynamodbav:"scheduled_execution_at,omitempty"` + ScheduledDate time.Time `json:"scheduled_date" dynamodbav:"scheduled_date"` + ExecutedByUserID *string `json:"executed_by_user_id,omitempty" dynamodbav:"executed_by_user_id,omitempty"` + PreApprovalSkipReason *string `json:"pre_approval_skip_reason,omitempty" dynamodbav:"pre_approval_skip_reason,omitempty"` + ExecutedAt *time.Time `json:"executed_at,omitempty" dynamodbav:"executed_at,omitempty"` + ScheduledExecutionAt *time.Time `json:"scheduled_execution_at,omitempty" dynamodbav:"scheduled_execution_at,omitempty"` + NotificationSent *time.Time `json:"notification_sent,omitempty" dynamodbav:"notification_sent,omitempty"` + CloudAccountID *string `json:"cloud_account_id,omitempty" dynamodbav:"cloud_account_id,omitempty"` + ApprovalTokenExpiresAt *time.Time `json:"approval_token_expires_at,omitempty" dynamodbav:"approval_token_expires_at,omitempty"` + RetryExecutionID *string `json:"retry_execution_id,omitempty" dynamodbav:"retry_execution_id,omitempty"` + CreatedByUserID *string `json:"created_by_user_id,omitempty" dynamodbav:"created_by_user_id,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty" dynamodbav:"completed_at,omitempty"` + CancelledBy *string `json:"cancelled_by,omitempty" dynamodbav:"cancelled_by,omitempty"` //nolint:misspell // DB schema value 'cancelled_by' -- see migration 000035_add_execution_attribution.up.sql + ApprovedBy *string `json:"approved_by,omitempty" dynamodbav:"approved_by,omitempty"` + ApprovalToken string `json:"approval_token,omitempty" dynamodbav:"approval_token,omitempty"` + ExecutionID string `json:"execution_id" dynamodbav:"execution_id"` + Error string `json:"error,omitempty" dynamodbav:"error,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty" dynamodbav:"idempotency_key,omitempty"` + Status string `json:"status" dynamodbav:"status"` + PlanID string `json:"plan_id" dynamodbav:"plan_id"` + Source string `json:"source,omitempty" dynamodbav:"source,omitempty"` + Recommendations []RecommendationRecord `json:"recommendations" dynamodbav:"recommendations"` + CapacityPercent int `json:"capacity_percent,omitempty" dynamodbav:"capacity_percent,omitempty"` + RetryAttemptN int `json:"retry_attempt_n,omitempty" dynamodbav:"retry_attempt_n,omitempty"` + StepNumber int `json:"step_number" dynamodbav:"step_number"` + TotalUpfrontCost float64 `json:"total_upfront_cost" dynamodbav:"total_upfront_cost"` + EstimatedSavings float64 `json:"estimated_savings" dynamodbav:"estimated_savings"` + TTL int64 `json:"ttl,omitempty" dynamodbav:"ttl,omitempty"` } -// IsCancelable reports whether an execution may still be cancelled. Only the +// IsCancelable reports whether an execution may still be canceled. Only the // pre-purchase states ("pending"/"notified"/"scheduled") qualify: once a row // reaches "approved" or "running" the AWS commitment is being or has been -// created, so cancelling would leave the DB and the cloud out of sync; -// "cancelled", "completed", "failed", "expired", and "paused" are likewise +// created, so canceling would leave the DB and the cloud out of sync; +// "canceled", "completed", "failed", "expired", and "paused" are likewise // non-cancelable. The "scheduled" state is cancellable because the cloud SDK // has not been called yet (issue #291 wave-2). // Both cancel paths (purchase.Manager.CancelExecution on the email-token flow @@ -369,129 +284,35 @@ 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"` - Service string `json:"service" dynamodbav:"service"` - Region string `json:"region" dynamodbav:"region"` - ResourceType string `json:"resource_type" dynamodbav:"resource_type"` - Engine string `json:"engine,omitempty" dynamodbav:"engine,omitempty"` - // Details preserves the full common.ServiceDetails payload from the - // source common.Recommendation so the purchase path can reconstruct - // the correct typed *Details pointer at execute time (issue #453). - // Stored as raw JSON because RecommendationRecord lives in the - // config package, which must NOT import pkg/common (the dependency - // graph is config <- common in callers, never the reverse). The - // scheduler populates this at collection time via - // common.MarshalServiceDetails; the purchase manager reads it via - // common.DecodeServiceDetailsFor when it builds the - // common.Recommendation handed to the cloud service client. - // - // Empty for rows persisted before #453 — DecodeServiceDetailsFor - // returns a zero-valued typed pointer in that case so the cloud - // client's findOfferingID type-assertion still succeeds (the - // service-side buildOfferingFilters substitutes default - // Platform / Tenancy / Scope / AZConfig values). New rows always - // carry the full Details, so non-default platforms (Windows EC2, - // Postgres RDS, etc.) round-trip correctly. - Details json.RawMessage `json:"details,omitempty" dynamodbav:"-"` - Count int `json:"count" dynamodbav:"count"` - // RecommendedCount is the pre-scaling count the collector originally - // recommended, before the bulk-purchase Capacity % slider scaled it down. - // The web execute path stamps it so the backend can verify the - // client-supplied capacity_percent against the scaled Count - // (floor(RecommendedCount*pct/100) must equal Count) rather than trusting - // a decorative audit field that could silently disagree (#647). Optional: - // 0 / absent means "not supplied" (legacy callers, scheduler/CLI rows, - // retry replays) and the consistency check is skipped for that rec. - RecommendedCount int `json:"recommended_count,omitempty" dynamodbav:"recommended_count,omitempty"` - Term int `json:"term" dynamodbav:"term"` - Payment string `json:"payment" dynamodbav:"payment"` - UpfrontCost float64 `json:"upfront_cost" dynamodbav:"upfront_cost"` - // MonthlyCost is nil when the provider API did not return a monthly - // recurring breakdown (rendered as "—" in the UI, not "$0"). - // Backward-compatible with DynamoDB: existing items with a numeric 0 - // attribute unmarshal as a pointer to 0.0; absent attributes unmarshal - // as nil. No migration needed. - MonthlyCost *float64 `json:"monthly_cost" dynamodbav:"monthly_cost"` - Savings float64 `json:"savings" dynamodbav:"savings"` - // OnDemandCost is the canonical on-demand monthly baseline for the - // recommended commitment, sourced directly from the cloud provider - // (Azure `CostWithNoReservedInstances`, AWS Cost Explorer - // `EstimatedMonthlyOnDemandCost`). Persisted via the recommendations - // row's JSONB `payload` column — no DDL needed. - // - // nil means the provider API did not return a baseline; the frontend - // falls back to reconstructing on-demand from `monthly_cost + savings - // + amortized_upfront`. When non-nil, the frontend prefers the raw - // value over reconstruction so anomalies in the reconstructed - // denominator (e.g. Azure all-upfront recs where monthly_cost=$0 - // collapses the denominator) don't inflate the displayed effective - // savings %. See #274. - OnDemandCost *float64 `json:"on_demand_cost,omitempty" dynamodbav:"on_demand_cost,omitempty"` - // SavingsPercentage is the provider-authoritative effective savings % - // reported directly by the cloud provider (AWS Cost Explorer - // `EstimatedMonthlySavingsPercentage`, Azure / GCP converters' computed - // SavingsPercentage). It is the same figure the CLI/reporter prints - // verbatim (internal/reporter/reporter.go); persisting it lets the GUI - // show the identical number instead of re-deriving it client-side from - // savings / on-demand. Persisted via the recommendations row's JSONB - // `payload` column; no DDL needed. - // - // nil means the provider did not report a percentage; the frontend then - // falls back to the client-side reconstruction (effectiveSavingsPct). - // When non-nil, the frontend prefers this value so the displayed % cannot - // drift from the provider's authoritative number and AWS recs missing - // on_demand_cost still render a real % rather than an em-dash (see #323). - SavingsPercentage *float64 `json:"savings_percentage" dynamodbav:"savings_percentage,omitempty"` - Selected bool `json:"selected" dynamodbav:"selected"` - Purchased bool `json:"purchased" dynamodbav:"purchased"` - PurchaseID string `json:"purchase_id,omitempty" dynamodbav:"purchase_id,omitempty"` - Error string `json:"error,omitempty" dynamodbav:"error,omitempty"` - CloudAccountID *string `json:"cloud_account_id,omitempty" dynamodbav:"cloud_account_id,omitempty"` - // SuppressedCount is the cumulative count already committed against - // this recommendation's 6-tuple (account, provider, service, region, - // resource_type, engine) within the active grace window. The - // scheduler subtracts this from Count before returning the rec to - // the frontend; a rec where SuppressedCount ≥ original count is - // dropped entirely. Populated by the scheduler — zero on writes. - SuppressedCount int `json:"suppressed_count,omitempty" dynamodbav:"suppressed_count,omitempty"` - // SuppressionExpiresAt is the earliest expiry across all active - // suppression rows contributing to this tuple. The frontend uses it - // to render "Xd remaining" on the recently-purchased badge. - SuppressionExpiresAt *time.Time `json:"suppression_expires_at,omitempty" dynamodbav:"suppression_expires_at,omitempty"` - // PrimarySuppressionExecutionID identifies the execution whose - // suppression contributed the most to this tuple (ties broken by - // newest created_at). The frontend badge deep-links to Purchase - // History filtered to this execution. - PrimarySuppressionExecutionID *string `json:"primary_suppression_execution_id,omitempty" dynamodbav:"primary_suppression_execution_id,omitempty"` - // UsageHistory is a short time-series of daily RI-coverage percentages - // (0-100) for the last N days of the lookback window, ordered from - // oldest to newest. nil means the collector did not populate it (e.g. - // provider not yet wired); an empty non-nil slice means the collector - // ran but returned no daily data. The frontend renders nil as "—" and - // a non-empty slice as a thumbnail sparkline. Stored inside the - // recommendations JSONB payload — no DDL change needed (closes #239 - // Part 1 for AWS). - UsageHistory []float64 `json:"usage_history,omitempty" dynamodbav:"usage_history,omitempty"` - // VCPU and MemoryGB surface the compute size of the recommended - // instance type so the frontend's Capacity column can render - // " vCPU / GB" without parsing the opaque Details blob - // (#219). They are NOT persisted: the canonical source is the typed - // ComputeDetails nested inside Details (config must stay free of - // pkg/common imports). The api layer decodes Details via - // common.DecodeServiceDetailsFor in buildRecommendationsResponse and - // stamps these top-level fields on the way out, so the API JSON carries - // 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 - // 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. - VCPU *int `json:"vcpu,omitempty" dynamodbav:"-"` - MemoryGB *float64 `json:"memory_gb,omitempty" dynamodbav:"-"` + MonthlyCost *float64 `json:"monthly_cost" dynamodbav:"monthly_cost"` + MemoryGB *float64 `json:"memory_gb,omitempty" dynamodbav:"-"` + VCPU *int `json:"vcpu,omitempty" dynamodbav:"-"` + PrimarySuppressionExecutionID *string `json:"primary_suppression_execution_id,omitempty" dynamodbav:"primary_suppression_execution_id,omitempty"` + SuppressionExpiresAt *time.Time `json:"suppression_expires_at,omitempty" dynamodbav:"suppression_expires_at,omitempty"` + CloudAccountID *string `json:"cloud_account_id,omitempty" dynamodbav:"cloud_account_id,omitempty"` + SavingsPercentage *float64 `json:"savings_percentage" dynamodbav:"savings_percentage,omitempty"` + OnDemandCost *float64 `json:"on_demand_cost,omitempty" dynamodbav:"on_demand_cost,omitempty"` + PurchaseID string `json:"purchase_id,omitempty" dynamodbav:"purchase_id,omitempty"` + Error string `json:"error,omitempty" dynamodbav:"error,omitempty"` + Payment string `json:"payment" dynamodbav:"payment"` + Provider string `json:"provider" dynamodbav:"provider"` + Service string `json:"service" dynamodbav:"service"` + Region string `json:"region" dynamodbav:"region"` + ResourceType string `json:"resource_type" dynamodbav:"resource_type"` + Engine string `json:"engine,omitempty" dynamodbav:"engine,omitempty"` + ID string `json:"id" dynamodbav:"id"` + UsageHistory []float64 `json:"usage_history,omitempty" dynamodbav:"usage_history,omitempty"` + Details json.RawMessage `json:"details,omitempty" dynamodbav:"-"` + SuppressedCount int `json:"suppressed_count,omitempty" dynamodbav:"suppressed_count,omitempty"` + Term int `json:"term" dynamodbav:"term"` + Count int `json:"count" dynamodbav:"count"` + Savings float64 `json:"savings" dynamodbav:"savings"` + RecommendedCount int `json:"recommended_count,omitempty" dynamodbav:"recommended_count,omitempty"` + UpfrontCost float64 `json:"upfront_cost" dynamodbav:"upfront_cost"` + Purchased bool `json:"purchased" dynamodbav:"purchased"` + Selected bool `json:"selected" dynamodbav:"selected"` } // PurchaseSuppression records the per-tuple grace window after a bulk @@ -500,6 +321,8 @@ type RecommendationRecord struct { // insert; deleted inside the same transaction as a cancel/expire status // transition. type PurchaseSuppression struct { + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` ID string `json:"id"` ExecutionID string `json:"execution_id"` AccountID string `json:"account_id"` @@ -509,8 +332,6 @@ type PurchaseSuppression struct { ResourceType string `json:"resource_type"` Engine string `json:"engine"` SuppressedCount int `json:"suppressed_count"` - ExpiresAt time.Time `json:"expires_at"` - CreatedAt time.Time `json:"created_at"` } // RecommendationFilter parameterises ListStoredRecommendations and the @@ -526,13 +347,13 @@ type PurchaseSuppression struct { // query rather than in SQL (avoids a computed column). These two filters are // independent and can be combined. type RecommendationFilter struct { - Provider string // "aws" / "azure" / "gcp" / "" (all) - Service string // "" = all services - Region string // "" = all regions - AccountIDs []string // nil/empty = all accounts - MinSavingsUSD float64 // 0 = no floor on monthly savings dollar amount - MinSavingsPct float64 // 0 = no floor on savings percentage (0–100 scale) - ID string // "" = all ids; non-empty = exact match on the id column + Provider string + Service string + Region string + ID string + AccountIDs []string + MinSavingsUSD float64 + MinSavingsPct float64 } // PurchasePlanFilter parameterises ListPurchasePlans. Zero-value means "no @@ -570,8 +391,8 @@ type RecommendationsFreshness struct { // rows are evicted independently of any registered-account rows under // the same provider. type SuccessfulCollect struct { - Provider string CloudAccountID *string + Provider string } // RIUtilizationCacheEntry is a single cached Cost Explorer @@ -581,10 +402,10 @@ type SuccessfulCollect struct { // TTL freshness is evaluated in the caller (api-layer cache wrapper) // based on FetchedAt vs. a caller-supplied TTL. type RIUtilizationCacheEntry struct { + FetchedAt time.Time Region string - LookbackDays int Payload []byte - FetchedAt time.Time + LookbackDays int } // PurchaseHistoryFilter is the filter set consumed by @@ -593,33 +414,12 @@ type RIUtilizationCacheEntry struct { // GetAllPurchaseHistory). See the implementation docstring for the per-field // semantics and the dual-column account predicate. type PurchaseHistoryFilter struct { - // Provider matches purchase_history.provider exactly. Empty skips the clause. - Provider string - // AccountIDs matches purchase_history.cloud_account_id (the cloud_accounts - // UUID FK) with ANY($). Empty/nil skips this half of the account predicate. - AccountIDs []string - // ExternalIDsByProvider matches purchase_history.account_id (the - // cloud-provider external account number) scoped per provider. The caller - // resolves AccountIDs to their (provider, external_id) pairs and groups the - // external ids by provider, so the predicate matches each external id only - // against rows of its own provider: - // - // (provider = $p AND account_id = ANY($extsForP)) - // - // This keeps the (provider, external_id) pairing intact so a filter for - // aws/123 never pulls azure/123 rows that reuse the same external number. - // The "" provider key means "provider unknown" (legacy raw external number) - // and matches account_id without a provider gate. Empty/nil skips this half - // of the account predicate. ExternalIDsByProvider map[string][]string - // Start/End bound purchase_history.timestamp. nil for both skips the clause; - // nil for either leaves that side open (caller owns any range cap, see - // api.MaxHistoryDateRangeDays). - Start *time.Time - End *time.Time - // Limit caps the row count; clamped to [1, MaxListLimit] with a - // DefaultListLimit fallback when <= 0. - Limit int + Start *time.Time + End *time.Time + Provider string + AccountIDs []string + Limit int } // AzureRevocationWindowDays is the length of the Azure reservation free-cancel @@ -648,266 +448,155 @@ 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. type PurchaseHistoryRecord struct { - AccountID string `json:"account_id" dynamodbav:"account_id"` - PurchaseID string `json:"purchase_id" dynamodbav:"purchase_id"` - Timestamp time.Time `json:"timestamp" dynamodbav:"timestamp"` - Provider string `json:"provider" dynamodbav:"provider"` - Service string `json:"service" dynamodbav:"service"` - Region string `json:"region" dynamodbav:"region"` - ResourceType string `json:"resource_type" dynamodbav:"resource_type"` - Count int `json:"count" dynamodbav:"count"` - Term int `json:"term" dynamodbav:"term"` - Payment string `json:"payment" dynamodbav:"payment"` - UpfrontCost float64 `json:"upfront_cost" dynamodbav:"upfront_cost"` - // MonthlyCost is nil when the provider API did not return a monthly - // recurring breakdown for this commitment (e.g. Azure all-upfront where - // no recurring charge exists at the commitment layer). GCP commitments - // are monthly-billed in this repo, so they always populate MonthlyCost. - // The frontend renders "—" for nil, "$X.XX" when populated. Aggregations - // must skip nil entries rather than treating them as $0 to avoid - // distorting totals. Migration 000063 dropped the NOT NULL constraint so - // new rows can carry NULL; existing rows with 0.0 are preserved as-is - // (those are real zeros from AWS all-upfront commitments). - MonthlyCost *float64 `json:"monthly_cost" dynamodbav:"monthly_cost"` - EstimatedSavings float64 `json:"estimated_savings" dynamodbav:"estimated_savings"` - PlanID string `json:"plan_id,omitempty" dynamodbav:"plan_id,omitempty"` - PlanName string `json:"plan_name,omitempty" dynamodbav:"plan_name,omitempty"` - RampStep int `json:"ramp_step,omitempty" dynamodbav:"ramp_step,omitempty"` - CloudAccountID *string `json:"cloud_account_id,omitempty" dynamodbav:"cloud_account_id,omitempty"` - Status string `json:"status,omitempty" dynamodbav:"-"` - // Approver holds the email address the approval request was sent to (or - // would have been, if SES failed). Set only on pending rows, so the - // History UI can show "awaiting approval from " and the user knows - // exactly whose inbox to check. Excluded from DB persistence. - Approver string `json:"approver,omitempty" dynamodbav:"-"` - Source string `json:"source,omitempty" dynamodbav:"source,omitempty"` - // StatusDescription carries a short human-readable explanation for non- - // completed rows. For "failed", this is the stored Error message (e.g. - // "send failed: Missing domain"). For "expired", a canned reminder that - // the 7-day approval window elapsed. Empty on completed/pending rows — - // those speak for themselves via Status alone. - StatusDescription string `json:"status_description,omitempty" dynamodbav:"-"` - // 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 - // pending/notified rows (executions); empty on completed history - // rows (where the action has already completed). Excluded from DB - // persistence. - CreatedByUserID string `json:"created_by_user_id,omitempty" dynamodbav:"-"` - // RetryExecutionID propagates the originating execution's pointer to - // 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 - // purchase_executions). - RetryExecutionID string `json:"retry_execution_id,omitempty" dynamodbav:"-"` - // RetryAttemptN propagates the originating execution's retry-chain - // position so the History UI can render "↻ Retry of #xyz" inline - // links on retry rows (n > 0) and gate the Retry button against the - // soft-block threshold (n >= 5). Excluded from DB persistence. - RetryAttemptN int `json:"retry_attempt_n,omitempty" dynamodbav:"-"` - // OpsHint is a short operator-actionable message rendered inline in - // place of the Retry button when the failure reason on the row - // matches a known-persistent-misconfiguration pattern (e.g. - // "FROM_EMAIL not configured" → "Set FROM_EMAIL tfvar then retry"). - // Set only on `failed` rows whose Error matches the persistent map; - // 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 - // 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 - // 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). - 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, - // 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 - // persistence (resolved at read time). The UI renders this in the - // Approval Queue "Created by" column instead of the raw UUID. - CreatedByUserEmail string `json:"created_by_user_email,omitempty" dynamodbav:"-"` - - // --- Revocation window fields (issue #290) --- - // - // RevocationWindowClosesAt is set when the purchase_history row is - // written: Timestamp + the provider-specific free-cancel window - // (Azure: 7 days). NULL for AWS EC2 (no direct cancel API) and GCP - // (no free-cancel window). Persisted in purchase_history. + Timestamp time.Time `json:"timestamp" dynamodbav:"timestamp"` + MonthlyCost *float64 `json:"monthly_cost" dynamodbav:"monthly_cost"` + CalcRefundAmount *float64 `json:"calc_refund_amount,omitempty" dynamodbav:"calc_refund_amount,omitempty"` + RevokedAt *time.Time `json:"revoked_at,omitempty" dynamodbav:"revoked_at,omitempty"` RevocationWindowClosesAt *time.Time `json:"revocation_window_closes_at,omitempty" dynamodbav:"revocation_window_closes_at,omitempty"` - // RevokedAt is set by the revoke endpoint when the provider API - // confirmed the cancellation / refund. Persisted. - RevokedAt *time.Time `json:"revoked_at,omitempty" dynamodbav:"revoked_at,omitempty"` - // RevokedVia identifies how the revocation was completed: "direct-api" - // (provider returned 2xx) or "support-case" (AWS Support case filed). - // Persisted. - RevokedVia string `json:"revoked_via,omitempty" dynamodbav:"revoked_via,omitempty"` - // SupportCaseID is non-empty when RevokedVia == "support-case". - // Persisted. - SupportCaseID string `json:"support_case_id,omitempty" dynamodbav:"support_case_id,omitempty"` - - // --- Refund-quote audit fields (issue #290 Finding #4, migration 000071) --- - // - // CalcRefundAmount is the amount Azure quoted at CalculateRefund time, captured - // for audit and TOCTOU-divergence detection in the two-step revoke confirm flow. - // NULL for revocations that predate this feature or where Azure returned no amount. - CalcRefundAmount *float64 `json:"calc_refund_amount,omitempty" dynamodbav:"calc_refund_amount,omitempty"` - // CalcRefundCurrency is the ISO-4217 currency code from the CalculateRefund quote - // (e.g. "USD"). NULL when CalcRefundAmount is NULL. - CalcRefundCurrency string `json:"calc_refund_currency,omitempty" dynamodbav:"calc_refund_currency,omitempty"` - - // --- Partial-success reconciliation (issue #290 Finding #6, migration 000072) --- - // - // RevocationInFlight is set to true immediately before the Azure Return API call - // and cleared (set to false) by a successful MarkPurchaseRevoked. When all DB - // retries fail, the flag stays true so the finalize_revocations scheduled sweep - // can detect and retry the MarkPurchaseRevoked write without re-calling Azure - // (preventing a duplicate-refund error). - RevocationInFlight bool `json:"revocation_in_flight,omitempty" dynamodbav:"revocation_in_flight,omitempty"` + CloudAccountID *string `json:"cloud_account_id,omitempty" dynamodbav:"cloud_account_id,omitempty"` + OpsHint string `json:"ops_hint,omitempty" dynamodbav:"-"` + Source string `json:"source,omitempty" dynamodbav:"source,omitempty"` + CalcRefundCurrency string `json:"calc_refund_currency,omitempty" dynamodbav:"calc_refund_currency,omitempty"` + Payment string `json:"payment" dynamodbav:"payment"` + PurchaseID string `json:"purchase_id" dynamodbav:"purchase_id"` + ResourceType string `json:"resource_type" dynamodbav:"resource_type"` + SupportCaseID string `json:"support_case_id,omitempty" dynamodbav:"support_case_id,omitempty"` + PlanID string `json:"plan_id,omitempty" dynamodbav:"plan_id,omitempty"` + PlanName string `json:"plan_name,omitempty" dynamodbav:"plan_name,omitempty"` + RevokedVia string `json:"revoked_via,omitempty" dynamodbav:"revoked_via,omitempty"` + Region string `json:"region" dynamodbav:"region"` + Status string `json:"status,omitempty" dynamodbav:"-"` + Approver string `json:"approver,omitempty" dynamodbav:"-"` + Provider string `json:"provider" dynamodbav:"provider"` + StatusDescription string `json:"status_description,omitempty" dynamodbav:"-"` + CreatedByUserID string `json:"created_by_user_id,omitempty" dynamodbav:"-"` + RetryExecutionID string `json:"retry_execution_id,omitempty" dynamodbav:"-"` + Service string `json:"service" dynamodbav:"service"` + AccountID string `json:"account_id" dynamodbav:"account_id"` + CreatedByUserEmail string `json:"created_by_user_email,omitempty" dynamodbav:"-"` + RetryAttemptN int `json:"retry_attempt_n,omitempty" dynamodbav:"-"` + Count int `json:"count" dynamodbav:"count"` + RampStep int `json:"ramp_step,omitempty" dynamodbav:"ramp_step,omitempty"` + EstimatedSavings float64 `json:"estimated_savings" dynamodbav:"estimated_savings"` + UpfrontCost float64 `json:"upfront_cost" dynamodbav:"upfront_cost"` + Term int `json:"term" dynamodbav:"term"` + IsAuditGap bool `json:"is_audit_gap,omitempty" dynamodbav:"-"` + 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"` - ExchangeID string `json:"exchange_id"` - Region string `json:"region"` - SourceRIIDs []string `json:"source_ri_ids"` - SourceInstanceType string `json:"source_instance_type"` - SourceCount int `json:"source_count"` - TargetOfferingID string `json:"target_offering_id"` - TargetInstanceType string `json:"target_instance_type"` - TargetCount int `json:"target_count"` - PaymentDue string `json:"payment_due"` - Status string `json:"status"` - ApprovalToken string `json:"approval_token,omitempty"` - Error string `json:"error,omitempty"` - Mode string `json:"mode"` - // CreatedByUserID is the UUID of the session user who submitted the exchange - // (populated for dashboard-initiated exchanges; nil for automated or legacy - // email-link-initiated ones). Exposed to the frontend so the Approve button - // can apply the approve-own ownership check client-side. - CreatedByUserID *string `json:"created_by_user_id,omitempty"` - // ApprovedBy carries the email of the session user who approved the exchange - // via the dashboard Approve button (issue #300). Nil for token-authed approvals. - ApprovedBy *string `json:"approved_by,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - CloudAccountID *string `json:"cloud_account_id,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + CreatedByUserID *string `json:"created_by_user_id,omitempty"` + CloudAccountID *string `json:"cloud_account_id,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + ApprovedBy *string `json:"approved_by,omitempty"` + SourceInstanceType string `json:"source_instance_type"` + Mode string `json:"mode"` + AccountID string `json:"account_id"` + PaymentDue string `json:"payment_due"` + Status string `json:"status"` + ApprovalToken string `json:"approval_token,omitempty"` + Error string `json:"error,omitempty"` + TargetInstanceType string `json:"target_instance_type"` + TargetOfferingID string `json:"target_offering_id"` + ExchangeID string `json:"exchange_id"` + ID string `json:"id"` + Region string `json:"region"` + SourceRIIDs []string `json:"source_ri_ids"` + SourceCount int `json:"source_count"` + TargetCount int `json:"target_count"` } -// ConfigSetting represents a configuration setting for the defaults system -type ConfigSetting struct { - Key string `json:"key"` +// Setting represents a configuration setting for the defaults system. +type Setting struct { + UpdatedAt time.Time `json:"updated_at"` Value any `json:"value"` - Type string `json:"type"` // int, float, bool, string, json + Key string `json:"key"` + Type string `json:"type"` Category string `json:"category"` Description string `json:"description"` - UpdatedAt time.Time `json:"updated_at"` } // CloudAccount represents a single managed cloud account/subscription/project. type CloudAccount struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - ContactEmail string `json:"contact_email,omitempty"` - Enabled bool `json:"enabled"` - Provider string `json:"provider"` - ExternalID string `json:"external_id"` - - // AWS-specific - AWSAuthMode string `json:"aws_auth_mode,omitempty"` - AWSRoleARN string `json:"aws_role_arn,omitempty"` - AWSExternalID string `json:"aws_external_id,omitempty"` - AWSBastionID string `json:"aws_bastion_id,omitempty"` - AWSWebIdentityTokenFile string `json:"aws_web_identity_token_file,omitempty"` - AWSIsOrgRoot bool `json:"aws_is_org_root,omitempty"` - - // Azure-specific - AzureSubscriptionID string `json:"azure_subscription_id,omitempty"` - AzureTenantID string `json:"azure_tenant_id,omitempty"` - AzureClientID string `json:"azure_client_id,omitempty"` - AzureAuthMode string `json:"azure_auth_mode,omitempty"` - - // GCP-specific - GCPProjectID string `json:"gcp_project_id,omitempty"` - GCPClientEmail string `json:"gcp_client_email,omitempty"` - GCPAuthMode string `json:"gcp_auth_mode,omitempty"` - // GCPWIFAudience is the full Workload Identity Pool provider - // resource used as the STS audience when exchanging a CUDly - // KMS-signed JWT for a GCP access token. Only set for accounts - // using the secret-free workload_identity_federation path. - // Shape: //iam.googleapis.com/projects//locations/global/workloadIdentityPools//providers/ - GCPWIFAudience string `json:"gcp_wif_audience,omitempty"` - - // Derived (not stored in DB) - CredentialsConfigured bool `json:"credentials_configured"` - BastionAccountName string `json:"bastion_account_name,omitempty"` - IsSelf bool `json:"is_self,omitempty"` - - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CreatedBy string `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + GCPClientEmail string `json:"gcp_client_email,omitempty"` + AzureSubscriptionID string `json:"azure_subscription_id,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + Provider string `json:"provider"` + ExternalID string `json:"external_id"` + AWSAuthMode string `json:"aws_auth_mode,omitempty"` + AWSRoleARN string `json:"aws_role_arn,omitempty"` + AWSExternalID string `json:"aws_external_id,omitempty"` + AWSBastionID string `json:"aws_bastion_id,omitempty"` + AWSWebIdentityTokenFile string `json:"aws_web_identity_token_file,omitempty"` + Name string `json:"name"` + AzureClientID string `json:"azure_client_id,omitempty"` + ContactEmail string `json:"contact_email,omitempty"` + AzureAuthMode string `json:"azure_auth_mode,omitempty"` + AzureTenantID string `json:"azure_tenant_id,omitempty"` + GCPProjectID string `json:"gcp_project_id,omitempty"` + ID string `json:"id"` + GCPAuthMode string `json:"gcp_auth_mode,omitempty"` + GCPWIFAudience string `json:"gcp_wif_audience,omitempty"` + Description string `json:"description,omitempty"` + BastionAccountName string `json:"bastion_account_name,omitempty"` + IsSelf bool `json:"is_self,omitempty"` + CredentialsConfigured bool `json:"credentials_configured"` + AWSIsOrgRoot bool `json:"aws_is_org_root,omitempty"` + Enabled bool `json:"enabled"` } // CloudAccountFilter for ListCloudAccounts queries. type CloudAccountFilter struct { Provider *string Enabled *bool - Search string // substring match on name or external_id - BastionID *string // return accounts whose aws_bastion_id = *BastionID + BastionID *string + Search string } // AccountServiceOverride is a sparse per-account override on top of the global ServiceConfig. // Nil pointer fields inherit the global value. type AccountServiceOverride struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - Provider string `json:"provider"` - Service string `json:"service"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + RampSchedule *string `json:"ramp_schedule,omitempty"` Enabled *bool `json:"enabled,omitempty"` Term *int `json:"term,omitempty"` Payment *string `json:"payment,omitempty"` Coverage *float64 `json:"coverage,omitempty"` - RampSchedule *string `json:"ramp_schedule,omitempty"` + Provider string `json:"provider"` + ID string `json:"id"` + Service string `json:"service"` + AccountID string `json:"account_id"` IncludeEngines []string `json:"include_engines,omitempty"` - ExcludeEngines []string `json:"exclude_engines,omitempty"` - IncludeRegions []string `json:"include_regions,omitempty"` ExcludeRegions []string `json:"exclude_regions,omitempty"` IncludeTypes []string `json:"include_types,omitempty"` ExcludeTypes []string `json:"exclude_types,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + IncludeRegions []string `json:"include_regions,omitempty"` + ExcludeEngines []string `json:"exclude_engines,omitempty"` } // AccountRegistration represents a self-service registration request from a // target account owner. Submitted via POST /api/register during Terraform apply // of the federation IaC, then approved or rejected by a CUDly admin. type AccountRegistration struct { - ID string `json:"id"` - ReferenceToken string `json:"reference_token"` - Status string `json:"status"` // pending, approved, rejected - Provider string `json:"provider"` - ExternalID string `json:"external_id"` - AccountName string `json:"account_name"` - ContactEmail string `json:"contact_email"` + UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + CloudAccountID *string `json:"cloud_account_id,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + ReviewedBy *string `json:"reviewed_by,omitempty"` + AzureClientID string `json:"azure_client_id,omitempty"` + GCPProjectID string `json:"gcp_project_id,omitempty"` Description string `json:"description,omitempty"` SourceProvider string `json:"source_provider,omitempty"` AWSRoleARN string `json:"aws_role_arn,omitempty"` @@ -915,21 +604,21 @@ type AccountRegistration struct { AWSExternalID string `json:"aws_external_id,omitempty"` AzureSubscriptionID string `json:"azure_subscription_id,omitempty"` AzureTenantID string `json:"azure_tenant_id,omitempty"` - AzureClientID string `json:"azure_client_id,omitempty"` + ID string `json:"id"` AzureAuthMode string `json:"azure_auth_mode,omitempty"` - GCPProjectID string `json:"gcp_project_id,omitempty"` + ContactEmail string `json:"contact_email"` GCPClientEmail string `json:"gcp_client_email,omitempty"` GCPAuthMode string `json:"gcp_auth_mode,omitempty"` - GCPWIFAudience string `json:"gcp_wif_audience,omitempty"` // Full WIF provider resource; only set for federated path. + GCPWIFAudience string `json:"gcp_wif_audience,omitempty"` RegCredentialType string `json:"reg_credential_type,omitempty"` - RegCredentialPayload string `json:"-"` // never returned in API responses (encrypted at rest) - HasCredentials bool `json:"has_credentials,omitempty"` // derived: true when reg_credential_type is set + RegCredentialPayload string `json:"-"` + ReferenceToken string `json:"reference_token"` RejectionReason string `json:"rejection_reason,omitempty"` - CloudAccountID *string `json:"cloud_account_id,omitempty"` - ReviewedBy *string `json:"reviewed_by,omitempty"` - ReviewedAt *time.Time `json:"reviewed_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + AccountName string `json:"account_name"` + ExternalID string `json:"external_id"` + Provider string `json:"provider"` + Status string `json:"status"` + HasCredentials bool `json:"has_credentials,omitempty"` } // AccountRegistrationFilter for ListAccountRegistrations queries. diff --git a/internal/config/types_test.go b/internal/config/types_test.go index dbfe7bf34..2a6379114 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -98,9 +98,9 @@ func TestRampSchedule_GetNextPurchaseDate(t *testing.T) { startDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) tests := []struct { + expected time.Time name string schedule RampSchedule - expected time.Time }{ { name: "zero start date returns now", @@ -280,7 +280,7 @@ func TestPurchasePlan_Defaults(t *testing.T) { } func TestPurchaseExecution_Statuses(t *testing.T) { - validStatuses := []string{"pending", "notified", "approved", "cancelled", "completed", "failed"} + validStatuses := []string{"pending", "notified", "approved", "cancelled", "completed", "failed"} //nolint:misspell // DB schema value 'cancelled' -- see migration 000001_initial_schema.up.sql for _, status := range validStatuses { exec := PurchaseExecution{Status: status} diff --git a/internal/config/validation.go b/internal/config/validation.go index 36f63c23a..014014100 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 == "" { @@ -431,7 +431,8 @@ func (p *PurchasePlan) Validate() error { if p.Enabled && len(p.Services) == 0 { return fmt.Errorf("plan must have at least one service") } - for key, svc := range p.Services { + for key := range p.Services { + svc := p.Services[key] if err := svc.Validate(); err != nil { return fmt.Errorf("invalid service config '%s': %w", key, err) } @@ -440,7 +441,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 +531,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 +547,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..8076d84d5 100644 --- a/internal/config/validation_test.go +++ b/internal/config/validation_test.go @@ -10,9 +10,9 @@ import ( func TestGlobalConfig_Validate(t *testing.T) { tests := []struct { name string + errMsg string config GlobalConfig wantErr bool - errMsg string }{ { name: "valid empty config", @@ -281,9 +281,9 @@ func TestGlobalConfig_Validate(t *testing.T) { func TestServiceConfig_Validate(t *testing.T) { tests := []struct { name string + errMsg string config ServiceConfig wantErr bool - errMsg string }{ { name: "valid config", @@ -560,9 +560,9 @@ func TestServiceConfig_Validate(t *testing.T) { func TestRampSchedule_Validate(t *testing.T) { tests := []struct { name string + errMsg string sched RampSchedule wantErr bool - errMsg string }{ { name: "valid empty schedule", @@ -735,9 +735,9 @@ func TestRampSchedule_Validate(t *testing.T) { func TestPurchasePlan_Validate(t *testing.T) { tests := []struct { name string + errMsg string plan PurchasePlan wantErr bool - errMsg string }{ { name: "valid plan", @@ -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/cipher.go b/internal/credentials/cipher.go index ba5a6b530..d74fe0200 100644 --- a/internal/credentials/cipher.go +++ b/internal/credentials/cipher.go @@ -86,7 +86,7 @@ func DevKey() []byte { return k } -func loadKey(ctx context.Context, resolver secrets.Resolver) ([]byte, string, error) { +func loadKey(ctx context.Context, resolver secrets.Resolver) ([]byte, string, error) { //nolint:gocritic // Detect multiple-set misconfiguration upfront. var set []string for _, name := range []string{EnvSecretARN, EnvSecretName, EnvSecretID, EnvRawKey} { diff --git a/internal/credentials/resolver.go b/internal/credentials/resolver.go index 28555afc6..dcb10b638 100644 --- a/internal/credentials/resolver.go +++ b/internal/credentials/resolver.go @@ -48,7 +48,7 @@ func (c *AWSCredentials) String() string { return "[REDACTED AWS CREDENTIALS]" } // AzureCredentials holds resolved Azure service principal credentials. type AzureCredentials struct { - ClientSecret string + ClientSecret string //nolint:gosec } // String returns a redacted representation. @@ -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) @@ -289,7 +289,7 @@ func ResolveAzureCredentials(ctx context.Context, account *config.CloudAccount, return nil, fmt.Errorf("credentials: no client secret stored for account %s", account.ID) } var payload struct { - ClientSecret string `json:"client_secret"` + ClientSecret string `json:"client_secret"` //nolint:gosec } if err := json.Unmarshal(raw, &payload); err != nil { return nil, fmt.Errorf("credentials: parse azure secret for account %s: %w", account.ID, err) @@ -450,7 +450,7 @@ func loadStoredGCPTokenSource( if raw == nil { return nil, fmt.Errorf("credentials: no gcp credentials stored for account %s", account.ID) } - creds, err := google.CredentialsFromJSON(ctx, raw, gcpCloudPlatformScope) + creds, err := google.CredentialsFromJSON(ctx, raw, gcpCloudPlatformScope) //nolint:staticcheck // SA1019: CredentialsFromJSONWithParams requires google-api-go-client bump; tracked in known-issues if err != nil { return nil, fmt.Errorf("credentials: parse gcp credentials for account %s: %w", account.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..9181333ba 100644 --- a/internal/database/config.go +++ b/internal/database/config.go @@ -7,38 +7,27 @@ import ( "time" ) -// Config holds database configuration +// Config holds database configuration. type Config struct { - // Connection details - Host string - Port int - Database string - User string - - // Password can be direct value or secret reference - Password string // Direct password (local dev only) - PasswordSecret string // Secret ARN/ID/name for cloud secret managers - - // SSL configuration - SSLMode string // disable, require, verify-ca, verify-full - - // Connection pool settings - MaxConnections int + Host string + LogLevel string + Database string + User string + Password string `json:"-"` // excluded from JSON serialization to prevent accidental secret exposure + PasswordSecret string + SSLMode string + MigrationsPath string MinConnections int MaxConnLifetime time.Duration MaxConnIdleTime time.Duration HealthCheckPeriod time.Duration ConnectTimeout time.Duration - - // Migration settings - AutoMigrate bool - MigrationsPath string - - // Logging - LogLevel string // error, warn, info, debug + MaxConnections int + Port int + AutoMigrate bool } -// LoadFromEnv loads database configuration from environment variables +// LoadFromEnv loads database configuration from environment variables. func LoadFromEnv() (*Config, error) { config := &Config{ // Required fields @@ -78,7 +67,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 +78,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 +103,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 +122,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 +153,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 +162,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/config_test.go b/internal/database/config_test.go index 6a3eca130..24531abe7 100644 --- a/internal/database/config_test.go +++ b/internal/database/config_test.go @@ -253,9 +253,9 @@ func TestGetEnvDuration(t *testing.T) { func TestConfigDSN(t *testing.T) { tests := []struct { name string - config Config passwordOverride string expected string + config Config }{ { name: "generates basic DSN", @@ -326,9 +326,9 @@ func TestConfigDSN(t *testing.T) { func TestConfigValidate(t *testing.T) { tests := []struct { name string + errorMsg string config Config expectError bool - errorMsg string }{ { name: "valid config with password", @@ -678,9 +678,9 @@ func TestConfigStruct(t *testing.T) { func TestValidateRequiredFields(t *testing.T) { tests := []struct { name string + errorMsg string config Config expectError bool - errorMsg string }{ { name: "all required fields present", @@ -753,9 +753,9 @@ func TestValidateRequiredFields(t *testing.T) { func TestValidatePoolSettings(t *testing.T) { tests := []struct { name string + errorMsg string config Config expectError bool - errorMsg string }{ { name: "valid pool settings", diff --git a/internal/database/connection.go b/internal/database/connection.go index daf5c042b..4ea49bc14 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 +// NewConnection creates a new database connection pool. +// 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,18 +205,20 @@ 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 - if config.MaxConnections > math.MaxInt32 { - return nil, fmt.Errorf("MaxConnections value %d exceeds int32 max", config.MaxConnections) + maxConns, err := intToInt32("MaxConnections", config.MaxConnections) + if err != nil { + return nil, err } - if config.MinConnections > math.MaxInt32 { - return nil, fmt.Errorf("MinConnections value %d exceeds int32 max", config.MinConnections) + minConns, err := intToInt32("MinConnections", config.MinConnections) + if err != nil { + return nil, err } - poolConfig.MaxConns = int32(config.MaxConnections) - poolConfig.MinConns = int32(config.MinConnections) + poolConfig.MaxConns = maxConns + poolConfig.MinConns = minConns poolConfig.MaxConnLifetime = config.MaxConnLifetime poolConfig.MaxConnIdleTime = config.MaxConnIdleTime poolConfig.HealthCheckPeriod = config.HealthCheckPeriod @@ -238,17 +240,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 +269,47 @@ 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 -func (c *Connection) BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) { +// BeginTx starts a new transaction with options. The opts pointer may be nil +// to use the default transaction options (equivalent to pgx.TxOptions{}). +func (c *Connection) BeginTx(ctx context.Context, opts *pgx.TxOptions) (pgx.Tx, error) { + var txOptions pgx.TxOptions + if opts != nil { + txOptions = *opts + } 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 +368,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 +384,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. @@ -420,3 +427,14 @@ func (l *stdLogger) Log(ctx context.Context, level tracelog.LogLevel, msg string logging.Errorf("%s %v", msg, safeData) } } + +// intToInt32 converts an int connection-pool limit to int32, returning an +// error when the value exceeds math.MaxInt32. This allows the G115 bound +// check to be expressed once in a function body that gosec can verify, rather +// than repeating nolint comments at every call site. +func intToInt32(fieldName string, v int) (int32, error) { + if v < math.MinInt32 || v > math.MaxInt32 { + return 0, fmt.Errorf("%s value %d out of int32 range [%d, %d]", fieldName, v, math.MinInt32, math.MaxInt32) + } + return int32(v), nil +} diff --git a/internal/database/connection_test.go b/internal/database/connection_test.go index dab1431c0..30de32ce6 100644 --- a/internal/database/connection_test.go +++ b/internal/database/connection_test.go @@ -167,10 +167,10 @@ func TestBuildPoolConfig(t *testing.T) { }) } -// MockSecretResolver implements SecretResolver for testing +// MockSecretResolver implements SecretResolver for testing. type MockSecretResolver struct { - SecretValue string SecretError error + SecretValue string GetCalls int CloseCalls int } @@ -342,7 +342,6 @@ func TestConnectionMethods(t *testing.T) { t.Run("Connection struct has expected fields", func(t *testing.T) { conn := &Connection{ - pool: nil, config: &Config{}, } assert.NotNil(t, conn.config) diff --git a/internal/database/coverage_extra_test.go b/internal/database/coverage_extra_test.go index fa395321f..bd6396abc 100644 --- a/internal/database/coverage_extra_test.go +++ b/internal/database/coverage_extra_test.go @@ -6,13 +6,12 @@ import ( "testing" "time" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// Tests for RedactedDSN +// Tests for RedactedDSN. func TestRedactedDSN(t *testing.T) { cfg := &Config{ Host: "db.example.com", @@ -55,7 +54,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 +91,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 +130,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 +184,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 +208,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 +217,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", @@ -395,7 +394,7 @@ func TestConnectionBeginTx_Fails(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - _, err := conn.BeginTx(ctx, pgx.TxOptions{}) + _, err := conn.BeginTx(ctx, nil) assert.Error(t, err) } diff --git a/internal/database/open_from_env.go b/internal/database/open_from_env.go index aa78ffc33..80db4bb99 100644 --- a/internal/database/open_from_env.go +++ b/internal/database/open_from_env.go @@ -28,9 +28,9 @@ func OpenFromEnv(ctx context.Context) (*Connection, error) { var sr SecretResolver if dbConfig.PasswordSecret != "" { secretConfig := secrets.LoadConfigFromEnv() - resolver, err := secrets.NewResolver(ctx, secretConfig) - if err != nil { - return nil, fmt.Errorf("database: create secret resolver: %w", err) + resolver, rerr := secrets.NewResolver(ctx, secretConfig) + if rerr != nil { + return nil, fmt.Errorf("database: create secret resolver: %w", rerr) } // NewConnection resolves the password synchronously before returning, so // the resolver is safe to close immediately after the connection is open. 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..27ee5aa9a 100644 --- a/internal/database/postgres/migrations/migrate.go +++ b/internal/database/postgres/migrations/migrate.go @@ -2,6 +2,8 @@ package migrations import ( "context" + "crypto/tls" + "errors" "fmt" "log" "net/url" @@ -15,7 +17,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 @@ -27,10 +29,33 @@ const bcryptCost = 12 // inside the 000024 migration SQL. 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 -func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath string, adminEmail string, adminPassword string) error { +// closeMigratorWithLog closes m and logs any errors. Extracted so that +// defer closures in RunMigrations / RollbackMigrations do not count their +// conditional branches against the callers' cyclomatic complexity budget. +func closeMigratorWithLog(m *migrate.Migrate, label string) { + if srcErr, dbErr := m.Close(); srcErr != nil || dbErr != nil { + log.Printf("WARNING: error closing migrator%s: source=%v database=%v", label, srcErr, dbErr) + } +} + +// checkMigratorVersion reads the current schema version and returns an error +// if the database is in a dirty state. It treats ErrNilVersion (no migrations +// applied yet) as non-fatal and returns version 0. +func checkMigratorVersion(m *migrate.Migrate) (uint, error) { + version, dirty, err := m.Version() + if err != nil && !errors.Is(err, migrate.ErrNilVersion) { + return 0, fmt.Errorf("failed to get migration version: %w", err) + } + if dirty { + return 0, fmt.Errorf("database is in dirty state at version %d", version) + } + return version, nil +} + +// 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. +func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath, adminEmail, 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 // under the cyclomatic-complexity budget as recovery paths grow. @@ -38,21 +63,16 @@ func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath strin if err != nil { return err } - defer m.Close() + defer closeMigratorWithLog(m, "") // Run migrations - if err := m.Up(); err != nil && 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 { - return fmt.Errorf("failed to get migration version: %w", err) + if upErr := m.Up(); upErr != nil && !errors.Is(upErr, migrate.ErrNoChange) { + return fmt.Errorf("failed to run migrations: %w", upErr) } - if dirty { - return fmt.Errorf("database is in dirty state at version %d", version) + version, err := checkMigratorVersion(m) + if err != nil { + return err } log.Printf("Database migrations completed successfully (version: %d)", version) @@ -86,7 +106,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), @@ -102,7 +122,7 @@ func newMigratorWithRecovery(pool *pgxpool.Pool, migrationsPath string) (*migrat // migration without direct DB access. Remove the env var after the // next successful deploy. if err := maybeForceMigrationVersion(m); err != nil { - m.Close() + closeMigratorWithLog(m, " on force-recovery failure") return nil, err } @@ -111,7 +131,7 @@ func newMigratorWithRecovery(pool *pgxpool.Pool, migrationsPath string) (*migrat // Up() re-applies any pending migrations, letting a cold start self-recover // instead of staying broken until a manual force. if err := maybeAutoHealDirty(m); err != nil { - m.Close() + closeMigratorWithLog(m, " on auto-heal failure") return nil, err } @@ -131,7 +151,7 @@ func newMigratorWithRecovery(pool *pgxpool.Pool, migrationsPath string) (*migrat // // Note: the `role` column was dropped by migration 000057; this INSERT // intentionally omits it (issue #945). -func ensureAdminUser(ctx context.Context, pool *pgxpool.Pool, email string, password string) error { +func ensureAdminUser(ctx context.Context, pool *pgxpool.Pool, email, password string) error { if password != "" { return ensureAdminUserWithPassword(ctx, pool, email, password) } @@ -183,7 +203,7 @@ func ensureAdminUser(ctx context.Context, pool *pgxpool.Pool, email string, pass // // Note: the `role` column was dropped by migration 000057; this INSERT // intentionally omits it (issue #945). -func ensureAdminUserWithPassword(ctx context.Context, pool *pgxpool.Pool, email string, password string) error { +func ensureAdminUserWithPassword(ctx context.Context, pool *pgxpool.Pool, email, password string) error { log.Printf("Ensuring admin user exists with password: %s", email) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) @@ -237,16 +257,16 @@ 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 { +func assignAdminGroupAndWarn(ctx context.Context, pool *pgxpool.Pool, groupID, adminEmail string) error { res, err := pool.Exec(ctx, ` UPDATE users SET group_ids = ARRAY( @@ -372,7 +392,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 } @@ -386,7 +406,11 @@ func maybeAutoHealDirty(m *migrate.Migrate) error { // Force the CURRENT recorded version (never lower -- see the doc comment), // then let the caller's Up() re-apply only the pending tail. log.Printf("Database is DIRTY at version %d: auto-heal forcing the current version %d to clear the dirty flag, then re-applying pending migrations (set CUDLY_MIGRATION_AUTOHEAL=false to disable)", version, version) - if err := m.Force(int(version)); err != nil { + vInt, err := uintToInt(version) + if err != nil { + return fmt.Errorf("auto-heal: migration version %d overflows int: %w", version, err) + } + if err := m.Force(vInt); err != nil { return fmt.Errorf("auto-heal: failed to force version %d to clear dirty flag: %w", version, err) } log.Printf("Auto-heal cleared dirty flag at version %d; proceeding to re-apply pending migrations", version) @@ -412,8 +436,8 @@ func autoHealEnabled() bool { return b } -// RollbackMigrations rolls back N migrations -func RollbackMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath string, steps int) error { +// validateRollbackSteps returns an error if steps is out of the safe range. +func validateRollbackSteps(steps int) error { if steps <= 0 { return fmt.Errorf("rollback steps must be positive, got %d", steps) } @@ -421,32 +445,39 @@ func RollbackMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath if steps > maxRollbackSteps { return fmt.Errorf("refusing to rollback more than %d migrations at once (requested %d); use multiple calls for safety", maxRollbackSteps, steps) } + return nil +} - dsn := buildMigrateDSN(pool.Config(), "") +// RollbackMigrations rolls back N migrations. +func RollbackMigrations(_ context.Context, pool *pgxpool.Pool, migrationsPath string, steps int) error { + if err := validateRollbackSteps(steps); err != nil { + return err + } m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), - dsn, + buildMigrateDSN(pool.Config()), ) if err != nil { return fmt.Errorf("failed to create migrator: %w", err) } - defer m.Close() + defer closeMigratorWithLog(m, "") - log.Printf("Rolling back %d migration(s)...", steps) - - // Rollback steps - if err := m.Steps(-steps); err != nil && err != migrate.ErrNoChange { - return fmt.Errorf("failed to rollback migrations: %w", err) + // Log current version before rollback (best-effort; version info is only for + // logging and rollback failures surface at m.Steps below). + currentVersion, _, vErr := m.Version() + if vErr != nil && !errors.Is(vErr, migrate.ErrNilVersion) { + log.Printf("WARNING: could not read current migration version before rollback: %v", vErr) } + log.Printf("Rolling back %d migration(s) from version %d...", steps, currentVersion) - version, dirty, err := m.Version() - if err != nil && err != migrate.ErrNilVersion { - return fmt.Errorf("failed to get migration version: %w", err) + if stepsErr := m.Steps(-steps); stepsErr != nil && !errors.Is(stepsErr, migrate.ErrNoChange) { + return fmt.Errorf("failed to rollback migrations: %w", stepsErr) } - if dirty { - return fmt.Errorf("database is in dirty state at version %d", version) + version, err := checkMigratorVersion(m) + if err != nil { + return err } log.Printf("Rolled back %d migration(s) (current version: %d)", steps, version) @@ -460,7 +491,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), @@ -469,18 +500,15 @@ func MigrateToVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath st if err != nil { return fmt.Errorf("failed to create migrator: %w", err) } - defer m.Close() + defer closeMigratorWithLog(m, "") - if err := m.Migrate(version); err != nil && err != migrate.ErrNoChange { - return fmt.Errorf("failed to migrate to version %d: %w", version, err) + if migErr := m.Migrate(version); migErr != nil && !errors.Is(migErr, migrate.ErrNoChange) { + return fmt.Errorf("failed to migrate to version %d: %w", version, migErr) } - current, dirty, err := m.Version() + current, err := checkMigratorVersion(m) if err != nil { - return fmt.Errorf("failed to get migration version: %w", err) - } - if dirty { - return fmt.Errorf("database is in dirty state at version %d", current) + return err } if current != version { return fmt.Errorf("expected migration version %d, got %d", version, current) @@ -490,21 +518,25 @@ func MigrateToVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath st return nil } -// GetMigrationVersion returns the current migration version -func GetMigrationVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath string) (uint, bool, error) { - dsn := buildMigrateDSN(pool.Config(), "") +// GetMigrationVersion returns the current migration version. +func GetMigrationVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath string) (version uint, dirty bool, err error) { + dsn := buildMigrateDSN(pool.Config()) - m, err := migrate.New( + m, migrateErr := migrate.New( fmt.Sprintf("file://%s", migrationsPath), dsn, ) - if err != nil { - return 0, false, fmt.Errorf("failed to create migrator: %w", err) + if migrateErr != nil { + return 0, false, fmt.Errorf("failed to create migrator: %w", migrateErr) } - defer m.Close() + defer func() { + if srcErr, dbErr := m.Close(); srcErr != nil || dbErr != nil { + log.Printf("WARNING: error closing migrator: source=%v database=%v", srcErr, dbErr) + } + }() - version, dirty, err := m.Version() - if err != nil && err != migrate.ErrNilVersion { + version, dirty, err = m.Version() + if err != nil && !errors.Is(err, migrate.ErrNilVersion) { return 0, false, fmt.Errorf("failed to get migration version: %w", err) } @@ -512,8 +544,10 @@ 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 { +// The SSL mode is recovered from the parsed TLSConfig so strict modes +// (verify-ca / verify-full) are preserved rather than silently downgraded to +// require. See sslModeFromTLSConfig for the exact mapping. +func buildMigrateDSN(config *pgxpool.Config) string { // Extract connection details from pgx config host := config.ConnConfig.Host port := config.ConnConfig.Port @@ -525,14 +559,7 @@ 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" - } - } + sslMode := sslModeFromTLSConfig(config.ConnConfig.TLSConfig) // Build DSN (golang-migrate uses postgres:// format) // Don't add connection options - RDS Proxy doesn't support them @@ -547,7 +574,52 @@ func buildMigrateDSN(config *pgxpool.Config, sslModeOverride string) string { ) } -// ValidateMigrationsPath checks if migrations directory exists +// sslModeFromTLSConfig recovers the libpq sslmode string that pgx derived from +// the original DSN, so the migration DSN matches the security policy the +// application enforces in normal operation. pgx (v5) maps sslmode -> *tls.Config +// as follows, which we invert here: +// +// - disable -> nil +// - require -> InsecureSkipVerify=true, no custom chain/host verification +// - verify-ca -> InsecureSkipVerify=true + a VerifyPeerCertificate callback +// (verifies the chain but not the hostname) +// - verify-full -> InsecureSkipVerify=false + ServerName set +// (verifies both chain and hostname) +// +// Collapsing every TLS-enabled mode to "require" would silently drop the +// certificate-validation guarantees of verify-ca / verify-full during +// migrations, so we distinguish them explicitly. +func sslModeFromTLSConfig(tlsConfig *tls.Config) string { + if tlsConfig == nil { + return "disable" + } + // verify-full is the only mode where pgx leaves InsecureSkipVerify=false: + // the standard library then verifies both the chain and the hostname. + if !tlsConfig.InsecureSkipVerify { + return "verify-full" + } + // verify-ca skips the standard hostname check but installs a custom + // VerifyPeerCertificate callback to validate the chain; plain require + // installs neither. + if tlsConfig.VerifyPeerCertificate != nil { + return "verify-ca" + } + return "require" +} + +// uintToInt converts a uint migration version to int, returning an error if it +// would overflow. Migration version numbers are always small non-negative +// integers, so overflow is not expected in practice; the check satisfies the +// G115 bounds-check requirement without nolint. +func uintToInt(v uint) (int, error) { + const maxInt = int(^uint(0) >> 1) + if v > uint(maxInt) { + return 0, fmt.Errorf("value %d exceeds maximum int (%d)", v, maxInt) + } + return int(v), nil +} + +// 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/migrations/migrate_security_test.go b/internal/database/postgres/migrations/migrate_security_test.go index da73373ce..f71b67ee5 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, @@ -133,6 +133,27 @@ func TestBuildMigrateDSN_PasswordNotInLogs(t *testing.T) { "buildMigrateDSN must not emit the database password to the log output") } +// TestBuildMigrateDSN_PreservesSSLMode is the CR #1276 regression guard: the +// migration DSN must carry the exact sslmode the application configured, so a +// strict mode (verify-ca / verify-full) is never silently downgraded to +// require. The sslmode round-trips DSN -> pgx *tls.Config -> migration DSN, so +// this also pins the pgx-version-specific TLSConfig mapping in +// sslModeFromTLSConfig. +func TestBuildMigrateDSN_PreservesSSLMode(t *testing.T) { + for _, mode := range []string{"disable", "require", "verify-ca", "verify-full"} { + t.Run(mode, func(t *testing.T) { + rawDSN := fmt.Sprintf("postgres://user:pass@localhost:5432/db?sslmode=%s", mode) + poolCfg, err := pgxpool.ParseConfig(rawDSN) + require.NoError(t, err, "pgxpool.ParseConfig must accept sslmode=%s", mode) + + result := buildMigrateDSN(poolCfg) + + assert.Contains(t, result, "sslmode="+mode, + "buildMigrateDSN must preserve the configured sslmode, not downgrade it") + }) + } +} + // TestMaybeForceVersion_NonNumericError ensures a non-numeric // CUDLY_FORCE_MIGRATION_VERSION produces an error without logging the // bad value to stdout. diff --git a/internal/database/postgres/migrations/migration_transactional_test.go b/internal/database/postgres/migrations/migration_transactional_test.go index 36fda7004..4c7983fcc 100644 --- a/internal/database/postgres/migrations/migration_transactional_test.go +++ b/internal/database/postgres/migrations/migration_transactional_test.go @@ -53,21 +53,21 @@ const noTransactionMarker = "-- migrate:no-transaction" // block. Each is matched case-insensitively against the comment-and-body-stripped // SQL with whitespace tolerance and word boundaries. var forbiddenPatterns = []struct { - name string re *regexp.Regexp + name string }{ - {"CREATE INDEX CONCURRENTLY", regexp.MustCompile(`(?is)\bcreate\s+(?:unique\s+)?index\s+concurrently\b`)}, - {"DROP INDEX CONCURRENTLY", regexp.MustCompile(`(?is)\bdrop\s+index\s+concurrently\b`)}, - {"REINDEX", regexp.MustCompile(`(?is)\breindex\b`)}, - {"VACUUM", regexp.MustCompile(`(?is)\bvacuum\b`)}, - {"ALTER SYSTEM", regexp.MustCompile(`(?is)\balter\s+system\b`)}, - {"CREATE DATABASE", regexp.MustCompile(`(?is)\bcreate\s+database\b`)}, - {"DROP DATABASE", regexp.MustCompile(`(?is)\bdrop\s+database\b`)}, - {"CREATE TABLESPACE", regexp.MustCompile(`(?is)\bcreate\s+tablespace\b`)}, + {name: "CREATE INDEX CONCURRENTLY", re: regexp.MustCompile(`(?is)\bcreate\s+(?:unique\s+)?index\s+concurrently\b`)}, + {name: "DROP INDEX CONCURRENTLY", re: regexp.MustCompile(`(?is)\bdrop\s+index\s+concurrently\b`)}, + {name: "REINDEX", re: regexp.MustCompile(`(?is)\breindex\b`)}, + {name: "VACUUM", re: regexp.MustCompile(`(?is)\bvacuum\b`)}, + {name: "ALTER SYSTEM", re: regexp.MustCompile(`(?is)\balter\s+system\b`)}, + {name: "CREATE DATABASE", re: regexp.MustCompile(`(?is)\bcreate\s+database\b`)}, + {name: "DROP DATABASE", re: regexp.MustCompile(`(?is)\bdrop\s+database\b`)}, + {name: "CREATE TABLESPACE", re: regexp.MustCompile(`(?is)\bcreate\s+tablespace\b`)}, // REFRESH MATERIALIZED VIEW CONCURRENTLY also cannot run in a txn. A bare, // top-level one is a hazard; one inside a function/DO body is fine and is // already stripped before matching, so this only fires at top level. - {"REFRESH MATERIALIZED VIEW CONCURRENTLY", regexp.MustCompile(`(?is)\brefresh\s+materialized\s+view\s+concurrently\b`)}, + {name: "REFRESH MATERIALIZED VIEW CONCURRENTLY", re: regexp.MustCompile(`(?is)\brefresh\s+materialized\s+view\s+concurrently\b`)}, } // lineCommentRe matches a -- comment to end of line. diff --git a/internal/database/postgres/migrations/split_savingsplans_test.go b/internal/database/postgres/migrations/split_savingsplans_test.go index 53d0916ca..0787f2247 100644 --- a/internal/database/postgres/migrations/split_savingsplans_test.go +++ b/internal/database/postgres/migrations/split_savingsplans_test.go @@ -24,11 +24,11 @@ func TestMigration_SplitSavingsPlans(t *testing.T) { type spRow struct { service string - term int payment string - enabled bool - coverage float64 rampSchedule string + coverage float64 + term int + enabled bool } // queryAWSSPRows returns a map keyed by service slug for every 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..98ba8fa30 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) { @@ -166,8 +166,8 @@ func TestSanitizeLogData_ArgsKeyStrippedAtDebugByDefault(t *testing.T) { t.Setenv("DB_LOG_BIND_PARAMETERS", "") tests := []struct { - name string inputData map[string]any + name string level tracelog.LogLevel wantArgsInOut bool }{ 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/docker.go b/internal/deploy/docker.go index 0adce4494..76b7e4311 100644 --- a/internal/deploy/docker.go +++ b/internal/deploy/docker.go @@ -98,15 +98,15 @@ func NewDefaultCommandRunner() *DefaultCommandRunner { // Run runs a command and streams output to stdout/stderr. func (r *DefaultCommandRunner) Run(name string, args ...string) error { - cmd := exec.Command(name, args...) + cmd := exec.Command(name, args...) //nolint:noctx cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } // RunWithStdin runs a command with stdin input and streams output to stdout/stderr. -func (r *DefaultCommandRunner) RunWithStdin(name string, stdin string, args ...string) error { - cmd := exec.Command(name, args...) +func (r *DefaultCommandRunner) RunWithStdin(name, stdin string, args ...string) error { + cmd := exec.Command(name, args...) //nolint:noctx cmd.Stdin = strings.NewReader(stdin) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/internal/deploy/docker_test.go b/internal/deploy/docker_test.go index 6c6d7b0d5..3cdfb788c 100644 --- a/internal/deploy/docker_test.go +++ b/internal/deploy/docker_test.go @@ -283,8 +283,8 @@ func TestDockerService_TagValidation(t *testing.T) { // references (with dots, colons, slashes, hyphens) are accepted. func TestDockerService_TagValidation_ValidRefs(t *testing.T) { cases := []struct { - name string fn func(*DockerService) error + name string }{ { name: "ECR-style tag", diff --git a/internal/deploy/frontend.go b/internal/deploy/frontend.go index 14a1f638b..a5d036760 100644 --- a/internal/deploy/frontend.go +++ b/internal/deploy/frontend.go @@ -134,7 +134,7 @@ func (s *FrontendService) FindFrontendDir() (string, error) { // Check environment variable first for deployed binaries if envPath := os.Getenv("CUDLY_FRONTEND_DIR"); envPath != "" { packageJSON := filepath.Join(envPath, "package.json") - if _, err := os.Stat(packageJSON); err == nil { + if _, err := os.Stat(packageJSON); err == nil { //nolint:gosec return filepath.Abs(envPath) } } @@ -149,7 +149,7 @@ func (s *FrontendService) FindFrontendDir() (string, error) { execDir := filepath.Dir(execPath) paths = append(paths, filepath.Join(execDir, "frontend"), - filepath.Join(execDir, "../frontend"), + filepath.Join(execDir, "../frontend"), //nolint:gocritic ) } @@ -190,7 +190,7 @@ func (s *FrontendService) findDistributionForBucket(ctx context.Context, bucketN continue } - for _, dist := range result.DistributionList.Items { + for _, dist := range result.DistributionList.Items { //nolint:gocritic if distID := s.checkDistributionOrigins(dist, bucketName); distID != "" { return distID, nil } @@ -200,7 +200,7 @@ func (s *FrontendService) findDistributionForBucket(ctx context.Context, bucketN return "", nil } -func (s *FrontendService) checkDistributionOrigins(dist cftypes.DistributionSummary, bucketName string) string { +func (s *FrontendService) checkDistributionOrigins(dist cftypes.DistributionSummary, bucketName string) string { //nolint:gocritic if dist.Origins == nil || dist.Origins.Items == nil { return "" } diff --git a/internal/deploy/mocks.go b/internal/deploy/mocks.go index 948600eb9..4ae1d4bb8 100644 --- a/internal/deploy/mocks.go +++ b/internal/deploy/mocks.go @@ -113,7 +113,7 @@ func (m *MockCommandRunner) Run(name string, args ...string) error { return nil } -func (m *MockCommandRunner) RunWithStdin(name string, stdin string, args ...string) error { +func (m *MockCommandRunner) RunWithStdin(name, stdin string, args ...string) error { cmd := append([]string{name}, args...) m.Commands = append(m.Commands, cmd) if m.RunWithStdinFunc != nil { diff --git a/internal/deploy/profiles.go b/internal/deploy/profiles.go index 400beb008..11a368584 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 @@ -19,28 +19,28 @@ type ProfileConfig struct { Region string `yaml:"region"` AWSProfile string `yaml:"aws_profile"` Email string `yaml:"email"` - Term int `yaml:"term"` PaymentOption string `yaml:"payment_option"` - Coverage float64 `yaml:"coverage"` RampSchedule string `yaml:"ramp_schedule"` - NotifyDays int `yaml:"notify_days"` - EnableDashboard bool `yaml:"enable_dashboard"` DashboardDomain string `yaml:"dashboard_domain,omitempty"` HostedZoneID string `yaml:"hosted_zone_id,omitempty"` Architecture string `yaml:"architecture"` - MemorySize int `yaml:"memory_size"` ImageTag string `yaml:"image_tag,omitempty"` CORSAllowedOrigin string `yaml:"cors_allowed_origin,omitempty"` AdminEmail string `yaml:"admin_email,omitempty"` + Term int `yaml:"term"` + Coverage float64 `yaml:"coverage"` + NotifyDays int `yaml:"notify_days"` + MemorySize int `yaml:"memory_size"` + EnableDashboard bool `yaml:"enable_dashboard"` } -// DeploymentConfig holds all deployment profiles +// DeploymentConfig holds all deployment profiles. type DeploymentConfig struct { - ActiveProfile string `yaml:"active_profile"` Profiles map[string]ProfileConfig `yaml:"profiles"` + ActiveProfile string `yaml:"active_profile"` } -// 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,8 +176,8 @@ func (c *DeploymentConfig) SetActiveProfile(name string) error { return nil } -// AddProfile adds a new profile to the configuration -func (c *DeploymentConfig) AddProfile(name string, profile ProfileConfig) error { +// AddProfile adds a new profile to the configuration. +func (c *DeploymentConfig) AddProfile(name string, profile ProfileConfig) error { //nolint:gocritic if name == "" { return fmt.Errorf("profile name cannot be empty") } @@ -196,8 +196,8 @@ func (c *DeploymentConfig) AddProfile(name string, profile ProfileConfig) error return nil } -// UpdateProfile updates an existing profile -func (c *DeploymentConfig) UpdateProfile(name string, profile ProfileConfig) error { +// UpdateProfile updates an existing profile. +func (c *DeploymentConfig) UpdateProfile(name string, profile ProfileConfig) error { //nolint:gocritic 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/deploy/types.go b/internal/deploy/types.go index eec2de166..a3a15b479 100644 --- a/internal/deploy/types.go +++ b/internal/deploy/types.go @@ -14,23 +14,23 @@ import ( type Config struct { StackName string Email string - Term int PaymentOption string - Coverage float64 RampSchedule string - NotifyDays int - EnableDashboard bool DashboardDomain string HostedZoneID string Architecture string + ImageTag string + CORSAllowedOrigin string + AdminEmail string + Term int + Coverage float64 + NotifyDays int MemorySize int + EnableDashboard bool SkipBuild bool SkipPush bool SkipFrontend bool SkipAdmin bool - ImageTag string - CORSAllowedOrigin string - AdminEmail string } // ECRClient interface for ECR operations. diff --git a/internal/email/coverage_extra_test.go b/internal/email/coverage_extra_test.go index bf9314117..df7e68653 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", @@ -86,7 +86,7 @@ func TestRenderRIExchangePendingApprovalEmail(t *testing.T) { }, } - result, err := RenderRIExchangePendingApprovalEmail(data) + result, err := RenderRIExchangePendingApprovalEmail(&data) require.NoError(t, err) assert.Contains(t, result, "ri-aabbccdd") assert.Contains(t, result, "m5.2xlarge") @@ -115,13 +115,13 @@ func TestRenderRIExchangePendingApprovalEmail_NoSkipped(t *testing.T) { }, } - result, err := RenderRIExchangePendingApprovalEmail(data) + result, err := RenderRIExchangePendingApprovalEmail(&data) require.NoError(t, err) assert.Contains(t, result, "ri-11223344") 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", @@ -140,7 +140,7 @@ func TestRenderRIExchangeCompletedEmail_AutoMode(t *testing.T) { }, } - result, err := RenderRIExchangeCompletedEmail(data) + result, err := RenderRIExchangeCompletedEmail(&data) require.NoError(t, err) assert.Contains(t, result, "automatically") assert.Contains(t, result, "ri-aaaa") @@ -169,7 +169,7 @@ func TestRenderRIExchangeCompletedEmail_ManualMode(t *testing.T) { }, } - result, err := RenderRIExchangeCompletedEmail(data) + result, err := RenderRIExchangeCompletedEmail(&data) require.NoError(t, err) assert.NotContains(t, result, "automatically") assert.Contains(t, result, "ri-bbbb") @@ -194,13 +194,13 @@ func TestRenderRIExchangeCompletedEmail_WithError(t *testing.T) { }, } - result, err := RenderRIExchangeCompletedEmail(data) + result, err := RenderRIExchangeCompletedEmail(&data) require.NoError(t, err) // ri-fail should not appear since it has an error assert.NotContains(t, result, "ri-fail") } -// Tests for RenderPurchaseApprovalRequestEmail +// Tests for RenderPurchaseApprovalRequestEmail. func TestRenderPurchaseApprovalRequestEmail(t *testing.T) { data := NotificationData{ DashboardURL: "https://dashboard.example.com", @@ -220,7 +220,7 @@ func TestRenderPurchaseApprovalRequestEmail(t *testing.T) { }, } - result, err := RenderPurchaseApprovalRequestEmail(data) + result, err := RenderPurchaseApprovalRequestEmail(&data) require.NoError(t, err) assert.Contains(t, result, "Approval Required") assert.Contains(t, result, "4000.00") @@ -250,16 +250,16 @@ func TestRenderPurchaseApprovalRequestEmail_AuthorizedApprovers(t *testing.T) { AuthorizedApprovers: []string{"contact-a@example.com", "contact-b@example.com"}, } - body, err := RenderPurchaseApprovalRequestEmail(data) + 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") } // TestRenderPurchaseApprovalRequestEmail_NoAuthorizedApprovers confirms -// the template omits the authorisation block entirely when no approvers +// the template omits the authorization block entirely when no approvers // are specified (legacy broadcast flow). func TestRenderPurchaseApprovalRequestEmail_NoAuthorizedApprovers(t *testing.T) { data := NotificationData{ @@ -269,14 +269,14 @@ func TestRenderPurchaseApprovalRequestEmail_NoAuthorizedApprovers(t *testing.T) Recommendations: []RecommendationSummary{{Service: "ec2", Count: 1}}, } - body, err := RenderPurchaseApprovalRequestEmail(data) + 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) { @@ -289,9 +289,9 @@ func TestRenderRegistrationReceivedEmail_AdminApprovers(t *testing.T) { AdminApprovers: []string{"admin-a@example.com", "admin-b@example.com"}, } - body, err := RenderRegistrationReceivedEmail(data) + 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") @@ -315,13 +315,13 @@ func TestRenderRegistrationReceivedEmail_NoAdminApprovers(t *testing.T) { DashboardURL: "https://dashboard.example.com", } - body, err := RenderRegistrationReceivedEmail(data) + 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") } -// Tests for SMTPSender RI exchange and approval request methods +// Tests for SMTPSender RI exchange and approval request methods. func TestSMTPSender_SendRIExchangePendingApproval_NoFromEmail(t *testing.T) { sender := &SMTPSender{ @@ -339,7 +339,7 @@ func TestSMTPSender_SendRIExchangePendingApproval_NoFromEmail(t *testing.T) { }, } - err := sender.SendRIExchangePendingApproval(context.Background(), data) + err := sender.SendRIExchangePendingApproval(context.Background(), &data) require.NoError(t, err) } @@ -360,7 +360,7 @@ func TestSMTPSender_SendRIExchangeCompleted_NoFromEmail(t *testing.T) { }, } - err := sender.SendRIExchangeCompleted(context.Background(), data) + err := sender.SendRIExchangeCompleted(context.Background(), &data) require.NoError(t, err) } @@ -383,11 +383,11 @@ func TestSMTPSender_SendPurchaseApprovalRequest_NoRecipient(t *testing.T) { }, } - err := sender.SendPurchaseApprovalRequest(context.Background(), data) + err := sender.SendPurchaseApprovalRequest(context.Background(), &data) 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", @@ -409,14 +409,14 @@ func TestSMTPSender_SendRIExchangePendingApproval_WithNotifyEmail(t *testing.T) } // Will fail with a network error — just verify it's not the "no from email" no-op. - err := sender.SendRIExchangePendingApproval(context.Background(), data) + err := sender.SendRIExchangePendingApproval(context.Background(), &data) // The error will be a connection refused / network error (not nil, not "no from email") if err != nil { assert.NotContains(t, err.Error(), "no from email") } } -// Tests for Sender.SendRIExchangePendingApproval, SendRIExchangeCompleted, SendPurchaseApprovalRequest +// Tests for Sender.SendRIExchangePendingApproval, SendRIExchangeCompleted, SendPurchaseApprovalRequest. // using the mock SNS sender (no SNS topic → no-op path) func TestSender_SendRIExchangePendingApproval_NoRecipient(t *testing.T) { @@ -440,7 +440,7 @@ func TestSender_SendRIExchangePendingApproval_NoRecipient(t *testing.T) { // RecipientEmail intentionally empty } - err := sender.SendRIExchangePendingApproval(context.Background(), data) + err := sender.SendRIExchangePendingApproval(context.Background(), &data) require.ErrorIs(t, err, ErrNoRecipient) } @@ -461,7 +461,7 @@ func TestSender_SendRIExchangeCompleted_NoTopic(t *testing.T) { }, } - err := sender.SendRIExchangeCompleted(context.Background(), data) + err := sender.SendRIExchangeCompleted(context.Background(), &data) require.NoError(t, err) } @@ -485,7 +485,7 @@ func TestSender_SendPurchaseApprovalRequest_NoRecipient(t *testing.T) { }, } - err := sender.SendPurchaseApprovalRequest(context.Background(), data) + err := sender.SendPurchaseApprovalRequest(context.Background(), &data) require.ErrorIs(t, err, ErrNoRecipient) } @@ -507,7 +507,7 @@ func TestSender_SendPurchaseApprovalRequest_NoFromEmail(t *testing.T) { }, } - err := sender.SendPurchaseApprovalRequest(context.Background(), data) + err := sender.SendPurchaseApprovalRequest(context.Background(), &data) require.ErrorIs(t, err, ErrNoFromEmail) } @@ -543,7 +543,7 @@ func TestSender_SendPurchaseApprovalRequest_MalformedFromEmail(t *testing.T) { {Service: "ec2", ResourceType: "m5.large", Region: "us-east-1", Count: 1}, }, } - err := sender.SendPurchaseApprovalRequest(context.Background(), data) + err := sender.SendPurchaseApprovalRequest(context.Background(), &data) require.ErrorIs(t, err, ErrNoFromEmail) require.Equal(t, 0, mockSES.sendEmailCalls, "malformed FROM_EMAIL must not reach SES") }) @@ -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{} @@ -571,7 +571,7 @@ func TestSender_SendPurchaseApprovalRequest_SendsViaSES(t *testing.T) { }, } - err := sender.SendPurchaseApprovalRequest(context.Background(), data) + err := sender.SendPurchaseApprovalRequest(context.Background(), &data) require.NoError(t, err) require.Equal(t, 1, mockSES.sendEmailCalls, "expected 1 SES SendEmail call, got %d", mockSES.sendEmailCalls) require.Equal(t, 0, mockSNS.publishCalls, "SNS Publish must not be used for purchase approvals, got %d calls", mockSNS.publishCalls) @@ -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,20 +611,20 @@ 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) require.NoError(t, err) } -// Test smtpSendBody — can't call without a real client, but we can test +// Test smtpSendBody -- can't call without a real client, but we can test. // that the function exists and has a valid signature by referencing it. // The coverage tool counts the function as covered only when called, so // 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", @@ -634,7 +634,7 @@ func TestSMTPSender_NotifyEmailDefaultsToFromEmail(t *testing.T) { UseTLS: false, } - sender, err := NewSMTPSender(cfg) + sender, err := NewSMTPSender(&cfg) require.NoError(t, err) assert.Equal(t, "from@example.com", sender.notifyEmail) } @@ -648,7 +648,7 @@ func TestSMTPSender_NotifyEmailExplicit(t *testing.T) { UseTLS: false, } - sender, err := NewSMTPSender(cfg) + sender, err := NewSMTPSender(&cfg) require.NoError(t, err) assert.Equal(t, "notify@example.com", sender.notifyEmail) } @@ -674,9 +674,9 @@ func (m *mockSNSPublisher) Publish(ctx context.Context, params *sns.PublishInput // / lastTo / lastFrom let happy-path tests assert the SES wire was exercised // with the right addressing. type mockSESEmailSender struct { - sendEmailCalls int lastTo string lastFrom string + sendEmailCalls int } func (m *mockSESEmailSender) SendEmail(ctx context.Context, params *sesv2.SendEmailInput, optFns ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) { diff --git a/internal/email/coverage_test.go b/internal/email/coverage_test.go index e143d435c..4842cba69 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", @@ -112,26 +112,26 @@ func TestSMTPSender_AllNotificationMethods_NoFromEmail(t *testing.T) { } // All these should render templates successfully but return early due to empty fromEmail - err := sender.SendNewRecommendationsNotification(ctx, data) + err := sender.SendNewRecommendationsNotification(ctx, &data) require.NoError(t, err) - err = sender.SendScheduledPurchaseNotification(ctx, data) + err = sender.SendScheduledPurchaseNotification(ctx, &data) require.NoError(t, err) - err = sender.SendPurchaseConfirmation(ctx, data) + err = sender.SendPurchaseConfirmation(ctx, &data) require.NoError(t, err) - err = sender.SendPurchaseFailedNotification(ctx, data) + err = sender.SendPurchaseFailedNotification(ctx, &data) 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 + errorMsg string cfg SMTPConfig expectError bool - errorMsg string }{ { name: "valid config with all fields", @@ -182,7 +182,7 @@ func TestSMTPSender_ConfigVariations(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - sender, err := NewSMTPSender(tc.cfg) + sender, err := NewSMTPSender(&tc.cfg) if tc.expectError { require.Error(t, err) @@ -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("", "") @@ -215,7 +215,7 @@ func TestRenderFunctions_EdgeCases(t *testing.T) { TotalSavings: 0, Recommendations: nil, } - result, err := RenderNewRecommendationsEmail(data) + result, err := RenderNewRecommendationsEmail(&data) require.NoError(t, err) assert.Contains(t, result, "New Commitment Recommendations") }) @@ -231,7 +231,7 @@ func TestRenderFunctions_EdgeCases(t *testing.T) { PlanName: "", Recommendations: nil, } - result, err := RenderScheduledPurchaseEmail(data) + result, err := RenderScheduledPurchaseEmail(&data) require.NoError(t, err) assert.Contains(t, result, "Scheduled Purchase") }) @@ -242,7 +242,7 @@ func TestRenderFunctions_EdgeCases(t *testing.T) { TotalSavings: 500.00, TotalUpfrontCost: 0, // No upfront cost } - result, err := RenderPurchaseConfirmationEmail(data) + result, err := RenderPurchaseConfirmationEmail(&data) require.NoError(t, err) assert.Contains(t, result, "Purchases Completed") }) @@ -252,13 +252,13 @@ func TestRenderFunctions_EdgeCases(t *testing.T) { DashboardURL: "https://example.com", Recommendations: []RecommendationSummary{}, } - result, err := RenderPurchaseFailedEmail(data) + result, err := RenderPurchaseFailedEmail(&data) require.NoError(t, err) assert.Contains(t, result, "Purchase Failed") }) } -// 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", @@ -282,7 +282,7 @@ func TestRecommendationSummary_AllFields(t *testing.T) { // Test each render function with comprehensive data t.Run("NewRecommendationsEmail", func(t *testing.T) { - result, err := RenderNewRecommendationsEmail(data) + result, err := RenderNewRecommendationsEmail(&data) require.NoError(t, err) assert.Contains(t, result, "db.r5.2xlarge") assert.Contains(t, result, "mysql") @@ -291,7 +291,7 @@ func TestRecommendationSummary_AllFields(t *testing.T) { }) t.Run("ScheduledPurchaseEmail", func(t *testing.T) { - result, err := RenderScheduledPurchaseEmail(data) + result, err := RenderScheduledPurchaseEmail(&data) require.NoError(t, err) assert.Contains(t, result, "Enterprise RDS Plan") assert.Contains(t, result, "March 15, 2024") @@ -299,26 +299,26 @@ func TestRecommendationSummary_AllFields(t *testing.T) { }) t.Run("PurchaseConfirmationEmail", func(t *testing.T) { - result, err := RenderPurchaseConfirmationEmail(data) + result, err := RenderPurchaseConfirmationEmail(&data) require.NoError(t, err) assert.Contains(t, result, "1500.75") assert.Contains(t, result, "6000.00") }) t.Run("PurchaseFailedEmail", func(t *testing.T) { - result, err := RenderPurchaseFailedEmail(data) + result, err := RenderPurchaseFailedEmail(&data) require.NoError(t, err) assert.Contains(t, result, "db.r5.2xlarge") }) } -// 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", @@ -330,7 +330,7 @@ func TestSMTPSender_FieldAccess(t *testing.T) { UseTLS: true, } - sender, err := NewSMTPSender(cfg) + sender, err := NewSMTPSender(&cfg) require.NoError(t, err) assert.Equal(t, "smtp.test.com", sender.host) @@ -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{ @@ -463,7 +463,7 @@ func TestSMTPSender_TLSBehavior(t *testing.T) { FromEmail: "test@example.com", UseTLS: false, // Even when false } - sender, err := NewSMTPSender(cfg) + sender, err := NewSMTPSender(&cfg) require.NoError(t, err) assert.True(t, sender.useTLS) // Should still be true }) @@ -474,7 +474,7 @@ func TestSMTPSender_TLSBehavior(t *testing.T) { Port: 0, // Not set FromEmail: "test@example.com", } - sender, err := NewSMTPSender(cfg) + sender, err := NewSMTPSender(&cfg) require.NoError(t, err) assert.Equal(t, 587, sender.port) }) @@ -486,13 +486,13 @@ func TestSMTPSender_TLSBehavior(t *testing.T) { FromEmail: "test@example.com", UseTLS: false, } - sender, err := NewSMTPSender(cfg) + sender, err := NewSMTPSender(&cfg) require.NoError(t, err) assert.False(t, sender.useTLS) }) } -// 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", @@ -567,25 +567,25 @@ func TestRenderFunctions_MultipleRecommendations(t *testing.T) { } // All render functions should handle multiple recommendations - result, err := RenderNewRecommendationsEmail(data) + result, err := RenderNewRecommendationsEmail(&data) require.NoError(t, err) assert.Contains(t, result, "ec2") assert.Contains(t, result, "rds") assert.Contains(t, result, "elasticache") assert.Contains(t, result, "opensearch") - result, err = RenderPurchaseConfirmationEmail(data) + result, err = RenderPurchaseConfirmationEmail(&data) require.NoError(t, err) assert.Contains(t, result, "m5.2xlarge") assert.Contains(t, result, "db.r5.xlarge") - result, err = RenderPurchaseFailedEmail(data) + result, err = RenderPurchaseFailedEmail(&data) require.NoError(t, err) assert.Contains(t, result, "cache.m5.large") 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", @@ -606,13 +606,13 @@ func TestSMTPSender_MethodsWithNoNetwork(t *testing.T) { assert.NoError(t, sender.SendWelcomeEmail(ctx, "user@example.com", "https://example.com", "user")) data := NotificationData{DashboardURL: "https://example.com"} - assert.NoError(t, sender.SendNewRecommendationsNotification(ctx, data)) - assert.NoError(t, sender.SendScheduledPurchaseNotification(ctx, data)) - assert.NoError(t, sender.SendPurchaseConfirmation(ctx, data)) - assert.NoError(t, sender.SendPurchaseFailedNotification(ctx, data)) + assert.NoError(t, sender.SendNewRecommendationsNotification(ctx, &data)) + assert.NoError(t, sender.SendScheduledPurchaseNotification(ctx, &data)) + assert.NoError(t, sender.SendPurchaseConfirmation(ctx, &data)) + 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", @@ -715,31 +715,31 @@ func TestSMTPSender_AllMethods_ConnectionFails(t *testing.T) { }) t.Run("SendNewRecommendationsNotification fails", func(t *testing.T) { - err := sender.SendNewRecommendationsNotification(ctx, data) + err := sender.SendNewRecommendationsNotification(ctx, &data) require.Error(t, err) assert.Contains(t, err.Error(), "failed to send email via SMTP") }) t.Run("SendScheduledPurchaseNotification fails", func(t *testing.T) { - err := sender.SendScheduledPurchaseNotification(ctx, data) + err := sender.SendScheduledPurchaseNotification(ctx, &data) require.Error(t, err) assert.Contains(t, err.Error(), "failed to send email via SMTP") }) t.Run("SendPurchaseConfirmation fails", func(t *testing.T) { - err := sender.SendPurchaseConfirmation(ctx, data) + err := sender.SendPurchaseConfirmation(ctx, &data) require.Error(t, err) assert.Contains(t, err.Error(), "failed to send email via SMTP") }) t.Run("SendPurchaseFailedNotification fails", func(t *testing.T) { - err := sender.SendPurchaseFailedNotification(ctx, data) + err := sender.SendPurchaseFailedNotification(ctx, &data) require.Error(t, err) assert.Contains(t, err.Error(), "failed to send email via SMTP") }) } -// 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", @@ -909,7 +909,7 @@ func TestSMTPSender_SendMethods_RenderingPaths(t *testing.T) { {Service: "rds", ResourceType: "db.r5.large", Engine: "postgres", Region: "us-east-1", Count: 5, MonthlySavings: 500.0}, }, } - err := sender.SendNewRecommendationsNotification(ctx, data) + err := sender.SendNewRecommendationsNotification(ctx, &data) require.Error(t, err) // Connection will fail }) @@ -921,7 +921,7 @@ func TestSMTPSender_SendMethods_RenderingPaths(t *testing.T) { {Service: "ec2", ResourceType: "m5.xlarge", Engine: "", Region: "us-west-2", Count: 10, MonthlySavings: 789.12}, }, } - err := sender.SendNewRecommendationsNotification(ctx, data) + err := sender.SendNewRecommendationsNotification(ctx, &data) require.Error(t, err) }) @@ -938,7 +938,7 @@ func TestSMTPSender_SendMethods_RenderingPaths(t *testing.T) { {Service: "rds", ResourceType: "db.m5.large", Engine: "mysql", Region: "eu-west-1", Count: 3, MonthlySavings: 300.0}, }, } - err := sender.SendScheduledPurchaseNotification(ctx, data) + err := sender.SendScheduledPurchaseNotification(ctx, &data) require.Error(t, err) }) @@ -953,12 +953,12 @@ func TestSMTPSender_SendMethods_RenderingPaths(t *testing.T) { PlanName: "Dev Plan", Recommendations: nil, } - err := sender.SendScheduledPurchaseNotification(ctx, data) + err := sender.SendScheduledPurchaseNotification(ctx, &data) require.Error(t, err) }) } -// 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) { @@ -1149,7 +1149,7 @@ func TestRenderAllTemplates_FullCoverage(t *testing.T) { {Service: "rds", ResourceType: "db.m5.large", Engine: "mysql", Region: "us-east-1", Count: 1, MonthlySavings: 100.0}, }, } - result, err := RenderNewRecommendationsEmail(data) + result, err := RenderNewRecommendationsEmail(&data) require.NoError(t, err) assert.Contains(t, result, "mysql") }) @@ -1162,7 +1162,7 @@ func TestRenderAllTemplates_FullCoverage(t *testing.T) { {Service: "ec2", ResourceType: "m5.xlarge", Engine: "", Region: "us-west-2", Count: 5, MonthlySavings: 100.0}, }, } - result, err := RenderNewRecommendationsEmail(data) + result, err := RenderNewRecommendationsEmail(&data) require.NoError(t, err) assert.NotContains(t, result, "()") }) @@ -1180,7 +1180,7 @@ func TestRenderAllTemplates_FullCoverage(t *testing.T) { {Service: "rds", ResourceType: "db.r5.xlarge", Engine: "postgres", Region: "eu-west-1", Count: 3, MonthlySavings: 600.0}, }, } - result, err := RenderScheduledPurchaseEmail(data) + result, err := RenderScheduledPurchaseEmail(&data) require.NoError(t, err) assert.Contains(t, result, "token123") assert.Contains(t, result, "Production Plan") @@ -1195,7 +1195,7 @@ func TestRenderAllTemplates_FullCoverage(t *testing.T) { {Service: "elasticache", ResourceType: "cache.m5.large", Engine: "redis", Region: "ap-southeast-1", Count: 2, MonthlySavings: 300.0}, }, } - result, err := RenderPurchaseConfirmationEmail(data) + result, err := RenderPurchaseConfirmationEmail(&data) require.NoError(t, err) assert.Contains(t, result, "6000.00") }) @@ -1208,13 +1208,13 @@ func TestRenderAllTemplates_FullCoverage(t *testing.T) { {Service: "opensearch", ResourceType: "r5.large.search", Engine: "", Region: "us-west-2", Count: 2}, }, } - result, err := RenderPurchaseFailedEmail(data) + result, err := RenderPurchaseFailedEmail(&data) require.NoError(t, err) assert.Contains(t, result, "opensearch") }) } -// 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", @@ -1239,7 +1239,7 @@ func TestSMTPSender_AllNotificationMethods_WithRealData(t *testing.T) { {Service: "ec2", ResourceType: "m5.4xlarge", Engine: "", Region: "us-west-2", Count: 10, MonthlySavings: 5000.75}, }, } - err := sender.SendNewRecommendationsNotification(ctx, data) + err := sender.SendNewRecommendationsNotification(ctx, &data) require.Error(t, err) }) @@ -1257,7 +1257,7 @@ func TestSMTPSender_AllNotificationMethods_WithRealData(t *testing.T) { {Service: "rds", ResourceType: "db.m5.large", Engine: "postgresql", Region: "eu-west-1", Count: 6, MonthlySavings: 1500.0}, }, } - err := sender.SendScheduledPurchaseNotification(ctx, data) + err := sender.SendScheduledPurchaseNotification(ctx, &data) require.Error(t, err) }) @@ -1271,7 +1271,7 @@ func TestSMTPSender_AllNotificationMethods_WithRealData(t *testing.T) { {Service: "opensearch", ResourceType: "r5.xlarge.search", Engine: "", Region: "us-east-1", Count: 2, MonthlySavings: 4500.0}, }, } - err := sender.SendPurchaseConfirmation(ctx, data) + err := sender.SendPurchaseConfirmation(ctx, &data) require.Error(t, err) }) @@ -1282,12 +1282,12 @@ func TestSMTPSender_AllNotificationMethods_WithRealData(t *testing.T) { {Service: "rds", ResourceType: "db.r5.2xlarge", Engine: "oracle-se2", Region: "eu-central-1", Count: 1}, }, } - err := sender.SendPurchaseFailedNotification(ctx, data) + err := sender.SendPurchaseFailedNotification(ctx, &data) require.Error(t, err) }) } -// 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) @@ -1300,7 +1300,7 @@ func TestSender_SendMethods_ErrorPaths(t *testing.T) { // Test that each notification method propagates SNS errors t.Run("NewRecommendations_propagates_error", func(t *testing.T) { - err := sender.SendNewRecommendationsNotification(ctx, NotificationData{ + err := sender.SendNewRecommendationsNotification(ctx, &NotificationData{ DashboardURL: "https://test.com", TotalSavings: 100.0, }) @@ -1310,7 +1310,7 @@ func TestSender_SendMethods_ErrorPaths(t *testing.T) { t.Run("ScheduledPurchase_no_recipient", func(t *testing.T) { // RecipientEmail is empty: must return ErrNoRecipient, not broadcast via SNS. - err := sender.SendScheduledPurchaseNotification(ctx, NotificationData{ + err := sender.SendScheduledPurchaseNotification(ctx, &NotificationData{ DashboardURL: "https://test.com", PlanName: "Test", ApprovalToken: "tok", @@ -1324,7 +1324,7 @@ func TestSender_SendMethods_ErrorPaths(t *testing.T) { // With an empty RecipientEmail it must return ErrNoRecipient, NOT fall // back to the broadcast SendNotification path (which would leak the // revoke link to every alert subscriber -- issue #290 CR). - err := sender.SendPurchaseScheduledNotification(ctx, NotificationData{ + err := sender.SendPurchaseScheduledNotification(ctx, &NotificationData{ DashboardURL: "https://test.com", ExecutionID: "exec-1", RevokeURL: "https://test.com/purchases#history?execution=exec-1", @@ -1334,7 +1334,7 @@ func TestSender_SendMethods_ErrorPaths(t *testing.T) { }) t.Run("PurchaseConfirmation_propagates_error", func(t *testing.T) { - err := sender.SendPurchaseConfirmation(ctx, NotificationData{ + err := sender.SendPurchaseConfirmation(ctx, &NotificationData{ DashboardURL: "https://test.com", }) require.Error(t, err) @@ -1342,7 +1342,7 @@ func TestSender_SendMethods_ErrorPaths(t *testing.T) { }) t.Run("PurchaseFailed_propagates_error", func(t *testing.T) { - err := sender.SendPurchaseFailedNotification(ctx, NotificationData{ + err := sender.SendPurchaseFailedNotification(ctx, &NotificationData{ DashboardURL: "https://test.com", }) require.Error(t, err) @@ -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", @@ -1480,17 +1480,17 @@ func TestAllSMTPNotificationMethods_TemplatePaths(t *testing.T) { } t.Run("recommendations_with_engine", func(t *testing.T) { - err := sender.SendNewRecommendationsNotification(ctx, dataWithEngine) + err := sender.SendNewRecommendationsNotification(ctx, &dataWithEngine) require.Error(t, err) }) t.Run("recommendations_without_engine", func(t *testing.T) { - err := sender.SendNewRecommendationsNotification(ctx, dataWithoutEngine) + err := sender.SendNewRecommendationsNotification(ctx, &dataWithoutEngine) require.Error(t, err) }) t.Run("scheduled_with_upfront", func(t *testing.T) { - err := sender.SendScheduledPurchaseNotification(ctx, dataWithEngine) + err := sender.SendScheduledPurchaseNotification(ctx, &dataWithEngine) require.Error(t, err) }) @@ -1504,22 +1504,22 @@ func TestAllSMTPNotificationMethods_TemplatePaths(t *testing.T) { DaysUntilPurchase: 5, PlanName: "No Upfront Plan", } - err := sender.SendScheduledPurchaseNotification(ctx, data) + err := sender.SendScheduledPurchaseNotification(ctx, &data) require.Error(t, err) }) t.Run("confirmation_with_multiple", func(t *testing.T) { - err := sender.SendPurchaseConfirmation(ctx, dataWithEngine) + err := sender.SendPurchaseConfirmation(ctx, &dataWithEngine) require.Error(t, err) }) t.Run("failed_with_multiple", func(t *testing.T) { - err := sender.SendPurchaseFailedNotification(ctx, dataWithEngine) + err := sender.SendPurchaseFailedNotification(ctx, &dataWithEngine) require.Error(t, err) }) } -// 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..a502427e2 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) @@ -137,7 +137,7 @@ func newGCPSenderFromEnv(ctx context.Context) (SenderInterface, error) { if apiKey == "" { return nil, fmt.Errorf("SENDGRID_API_KEY or SENDGRID_API_KEY_SECRET environment variable required for GCP email") } - return NewSMTPSender(SMTPConfig{ + return NewSMTPSender(&SMTPConfig{ Host: "smtp.sendgrid.net", Port: 587, Username: "apikey", // SendGrid uses literal "apikey" as username @@ -162,7 +162,7 @@ func resolveAzureSMTPCredentials(ctx context.Context) (username, password string usernameSecret := os.Getenv("AZURE_SMTP_USERNAME_SECRET") passwordSecret := os.Getenv("AZURE_SMTP_PASSWORD_SECRET") if usernameSecret == "" || passwordSecret == "" { - return "", "", fmt.Errorf("Azure SMTP credentials required: set AZURE_SMTP_USERNAME/AZURE_SMTP_PASSWORD or AZURE_SMTP_USERNAME_SECRET/AZURE_SMTP_PASSWORD_SECRET") + return "", "", fmt.Errorf("azure SMTP credentials required: set AZURE_SMTP_USERNAME/AZURE_SMTP_PASSWORD or AZURE_SMTP_USERNAME_SECRET/AZURE_SMTP_PASSWORD_SECRET") } resolver, err := secrets.NewResolver(ctx, secrets.LoadConfigFromEnv()) @@ -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 { @@ -193,7 +193,7 @@ func newAzureSenderFromEnv(ctx context.Context) (SenderInterface, error) { if host == "" { host = "smtp.azurecomm.net" } - return NewSMTPSender(SMTPConfig{ + return NewSMTPSender(&SMTPConfig{ Host: host, Port: 587, Username: username, @@ -204,8 +204,11 @@ func newAzureSenderFromEnv(ctx context.Context) (SenderInterface, error) { }) } -// NewSenderWithConfig creates an email sender with explicit configuration -func NewSenderWithConfig(ctx context.Context, cfg FactoryConfig) (SenderInterface, error) { +// NewSenderWithConfig creates an email sender with explicit configuration. +func NewSenderWithConfig(ctx context.Context, cfg *FactoryConfig) (SenderInterface, error) { + if cfg == nil { + return nil, fmt.Errorf("email factory config is nil") + } switch cfg.Provider { case ProviderAWS: return NewSender(SenderConfig{ @@ -218,7 +221,7 @@ func NewSenderWithConfig(ctx context.Context, cfg FactoryConfig) (SenderInterfac if cfg.SendGridAPIKey == "" { return nil, fmt.Errorf("SendGrid API key required for GCP email") } - return NewSMTPSender(SMTPConfig{ + return NewSMTPSender(&SMTPConfig{ Host: "smtp.sendgrid.net", Port: 587, Username: "apikey", @@ -236,7 +239,7 @@ func NewSenderWithConfig(ctx context.Context, cfg FactoryConfig) (SenderInterfac if host == "" { host = "smtp.azurecomm.net" } - return NewSMTPSender(SMTPConfig{ + return NewSMTPSender(&SMTPConfig{ Host: host, Port: 587, Username: cfg.AzureSMTPUsername, diff --git a/internal/email/factory_test.go b/internal/email/factory_test.go index 569d115fd..c2b11fdb4 100644 --- a/internal/email/factory_test.go +++ b/internal/email/factory_test.go @@ -149,7 +149,7 @@ func TestNewSenderFromEnvironment_Azure_MissingCredentials(t *testing.T) { _, err := NewSenderFromEnvironment(ctx) require.Error(t, err) - assert.Contains(t, err.Error(), "Azure SMTP credentials required") + assert.Contains(t, err.Error(), "azure SMTP credentials required") } func TestNewSenderFromEnvironment_Azure_WithCredentials(t *testing.T) { @@ -298,7 +298,7 @@ func TestNewSenderFromEnvironment_EmailEnabled(t *testing.T) { }) } -// Test NewSenderWithConfig +// Test NewSenderWithConfig. func TestNewSenderWithConfig_AWS(t *testing.T) { cfg := FactoryConfig{ Provider: ProviderAWS, @@ -308,7 +308,7 @@ func TestNewSenderWithConfig_AWS(t *testing.T) { } ctx := context.Background() - sender, err := NewSenderWithConfig(ctx, cfg) + sender, err := NewSenderWithConfig(ctx, &cfg) require.NoError(t, err) require.NotNil(t, sender) @@ -325,7 +325,7 @@ func TestNewSenderWithConfig_GCP_MissingAPIKey(t *testing.T) { } ctx := context.Background() - _, err := NewSenderWithConfig(ctx, cfg) + _, err := NewSenderWithConfig(ctx, &cfg) require.Error(t, err) assert.Contains(t, err.Error(), "SendGrid API key required") @@ -339,7 +339,7 @@ func TestNewSenderWithConfig_GCP_WithAPIKey(t *testing.T) { } ctx := context.Background() - sender, err := NewSenderWithConfig(ctx, cfg) + sender, err := NewSenderWithConfig(ctx, &cfg) require.NoError(t, err) require.NotNil(t, sender) @@ -356,7 +356,7 @@ func TestNewSenderWithConfig_Azure_MissingCredentials(t *testing.T) { } ctx := context.Background() - _, err := NewSenderWithConfig(ctx, cfg) + _, err := NewSenderWithConfig(ctx, &cfg) require.Error(t, err) assert.Contains(t, err.Error(), "AzureSMTPUsername and AzureSMTPPassword required") @@ -371,7 +371,7 @@ func TestNewSenderWithConfig_Azure_WithCredentials(t *testing.T) { } ctx := context.Background() - sender, err := NewSenderWithConfig(ctx, cfg) + sender, err := NewSenderWithConfig(ctx, &cfg) require.NoError(t, err) require.NotNil(t, sender) @@ -387,7 +387,7 @@ func TestNewSenderWithConfig_UnsupportedProvider(t *testing.T) { } ctx := context.Background() - _, err := NewSenderWithConfig(ctx, cfg) + _, err := NewSenderWithConfig(ctx, &cfg) require.Error(t, err) assert.Contains(t, err.Error(), "unsupported email provider") diff --git a/internal/email/interfaces.go b/internal/email/interfaces.go index f985ba6ac..1e2825571 100644 --- a/internal/email/interfaces.go +++ b/internal/email/interfaces.go @@ -7,39 +7,39 @@ 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 SendToEmailWithCCMultipart(ctx context.Context, toEmail string, ccEmails []string, subject, textBody, htmlBody string) error - SendNewRecommendationsNotification(ctx context.Context, data NotificationData) error - SendScheduledPurchaseNotification(ctx context.Context, data NotificationData) error - SendPurchaseConfirmation(ctx context.Context, data NotificationData) error - SendPurchaseFailedNotification(ctx context.Context, data NotificationData) error + SendNewRecommendationsNotification(ctx context.Context, data *NotificationData) error + SendScheduledPurchaseNotification(ctx context.Context, data *NotificationData) error + SendPurchaseConfirmation(ctx context.Context, data *NotificationData) error + SendPurchaseFailedNotification(ctx context.Context, data *NotificationData) error SendPasswordResetEmail(ctx context.Context, email, resetURL string) error SendWelcomeEmail(ctx context.Context, email, dashboardURL, role string) error SendUserInviteEmail(ctx context.Context, email, setupURL string) error - SendRIExchangePendingApproval(ctx context.Context, data RIExchangeNotificationData) error - SendRIExchangeCompleted(ctx context.Context, data RIExchangeNotificationData) error - SendPurchaseApprovalRequest(ctx context.Context, data NotificationData) error + SendRIExchangePendingApproval(ctx context.Context, data *RIExchangeNotificationData) error + SendRIExchangeCompleted(ctx context.Context, data *RIExchangeNotificationData) error + SendPurchaseApprovalRequest(ctx context.Context, data *NotificationData) error // SendPurchaseScheduledNotification sends the "approved with delay" email // immediately after an approval when Gmail-style pre-fire delay is configured // (issue #291 wave-2). Notifies the user that the purchase will execute at // RevocationWindowClosesAt and includes a one-click revoke link. - SendPurchaseScheduledNotification(ctx context.Context, data NotificationData) error - SendRegistrationReceivedNotification(ctx context.Context, data RegistrationNotificationData) error - SendRegistrationDecisionNotification(ctx context.Context, toEmail string, data RegistrationDecisionData) error + SendPurchaseScheduledNotification(ctx context.Context, data *NotificationData) error + SendRegistrationReceivedNotification(ctx context.Context, data *RegistrationNotificationData) error + 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/nop_sender.go b/internal/email/nop_sender.go index 8fc3423f2..02ef7ff73 100644 --- a/internal/email/nop_sender.go +++ b/internal/email/nop_sender.go @@ -13,7 +13,7 @@ import ( // // PII hygiene: logs only the method name and (for multi-recipient methods) // the recipient counts. We deliberately avoid logging email addresses, -// subjects, or template-data payloads — even in dev, log files commonly +// subjects, or template-data payloads - even in dev, log files commonly // leak into shared environments (terminal scrollback, screen-shares, // support tickets), and addresses are sufficient identifying information // to require treating them as PII. @@ -40,22 +40,22 @@ func (n *NopSender) SendToEmailWithCCMultipart(_ context.Context, _ string, ccEm return nil } -func (n *NopSender) SendNewRecommendationsNotification(_ context.Context, _ NotificationData) error { +func (n *NopSender) SendNewRecommendationsNotification(_ context.Context, _ *NotificationData) error { logging.Debugf("email/nop: SendNewRecommendationsNotification suppressed") return nil } -func (n *NopSender) SendScheduledPurchaseNotification(_ context.Context, _ NotificationData) error { +func (n *NopSender) SendScheduledPurchaseNotification(_ context.Context, _ *NotificationData) error { logging.Debugf("email/nop: SendScheduledPurchaseNotification suppressed") return nil } -func (n *NopSender) SendPurchaseConfirmation(_ context.Context, _ NotificationData) error { +func (n *NopSender) SendPurchaseConfirmation(_ context.Context, _ *NotificationData) error { logging.Debugf("email/nop: SendPurchaseConfirmation suppressed") return nil } -func (n *NopSender) SendPurchaseFailedNotification(_ context.Context, _ NotificationData) error { +func (n *NopSender) SendPurchaseFailedNotification(_ context.Context, _ *NotificationData) error { logging.Debugf("email/nop: SendPurchaseFailedNotification suppressed") return nil } @@ -75,32 +75,32 @@ func (n *NopSender) SendUserInviteEmail(_ context.Context, _, _ string) error { return nil } -func (n *NopSender) SendRIExchangePendingApproval(_ context.Context, _ RIExchangeNotificationData) error { +func (n *NopSender) SendRIExchangePendingApproval(_ context.Context, _ *RIExchangeNotificationData) error { logging.Debugf("email/nop: SendRIExchangePendingApproval suppressed") return nil } -func (n *NopSender) SendRIExchangeCompleted(_ context.Context, _ RIExchangeNotificationData) error { +func (n *NopSender) SendRIExchangeCompleted(_ context.Context, _ *RIExchangeNotificationData) error { logging.Debugf("email/nop: SendRIExchangeCompleted suppressed") return nil } -func (n *NopSender) SendPurchaseApprovalRequest(_ context.Context, _ NotificationData) error { +func (n *NopSender) SendPurchaseApprovalRequest(_ context.Context, _ *NotificationData) error { logging.Debugf("email/nop: SendPurchaseApprovalRequest suppressed") return nil } -func (n *NopSender) SendPurchaseScheduledNotification(_ context.Context, _ NotificationData) error { +func (n *NopSender) SendPurchaseScheduledNotification(_ context.Context, _ *NotificationData) error { logging.Debugf("email/nop: SendPurchaseScheduledNotification suppressed") return nil } -func (n *NopSender) SendRegistrationReceivedNotification(_ context.Context, _ RegistrationNotificationData) error { +func (n *NopSender) SendRegistrationReceivedNotification(_ context.Context, _ *RegistrationNotificationData) error { logging.Debugf("email/nop: SendRegistrationReceivedNotification suppressed") return nil } -func (n *NopSender) SendRegistrationDecisionNotification(_ context.Context, _ string, _ RegistrationDecisionData) error { +func (n *NopSender) SendRegistrationDecisionNotification(_ context.Context, _ string, _ *RegistrationDecisionData) error { logging.Debugf("email/nop: SendRegistrationDecisionNotification suppressed") return nil } diff --git a/internal/email/nop_sender_test.go b/internal/email/nop_sender_test.go index dfd8d9816..1a4f2589c 100644 --- a/internal/email/nop_sender_test.go +++ b/internal/email/nop_sender_test.go @@ -9,7 +9,7 @@ import ( ) // TestNopSender_AllMethodsReturnNil verifies that every SenderInterface -// method on NopSender returns nil — the contract is "swallow the call, +// method on NopSender returns nil - the contract is "swallow the call, // never error". A future bug that returns an error from any of these // would propagate into the application path that called the sender, // breaking the EMAIL_ENABLED=false promise that no work happens. @@ -23,17 +23,17 @@ func TestNopSender_AllMethodsReturnNil(t *testing.T) { assert.NoError(t, n.SendToEmail(ctx, "to@example.com", "subj", "body")) assert.NoError(t, n.SendToEmailWithCCMultipart(ctx, "to@example.com", []string{"cc1@example.com", "cc2@example.com"}, "subj", "text", "")) - assert.NoError(t, n.SendNewRecommendationsNotification(ctx, NotificationData{})) - assert.NoError(t, n.SendScheduledPurchaseNotification(ctx, NotificationData{})) - assert.NoError(t, n.SendPurchaseConfirmation(ctx, NotificationData{})) - assert.NoError(t, n.SendPurchaseFailedNotification(ctx, NotificationData{})) + assert.NoError(t, n.SendNewRecommendationsNotification(ctx, &NotificationData{})) + assert.NoError(t, n.SendScheduledPurchaseNotification(ctx, &NotificationData{})) + assert.NoError(t, n.SendPurchaseConfirmation(ctx, &NotificationData{})) + assert.NoError(t, n.SendPurchaseFailedNotification(ctx, &NotificationData{})) assert.NoError(t, n.SendPasswordResetEmail(ctx, "user@example.com", "https://example/reset")) assert.NoError(t, n.SendWelcomeEmail(ctx, "user@example.com", "https://example/dashboard", "admin")) - assert.NoError(t, n.SendRIExchangePendingApproval(ctx, RIExchangeNotificationData{})) - assert.NoError(t, n.SendRIExchangeCompleted(ctx, RIExchangeNotificationData{})) - assert.NoError(t, n.SendPurchaseApprovalRequest(ctx, NotificationData{})) - assert.NoError(t, n.SendRegistrationReceivedNotification(ctx, RegistrationNotificationData{})) - assert.NoError(t, n.SendRegistrationDecisionNotification(ctx, "user@example.com", RegistrationDecisionData{})) + assert.NoError(t, n.SendRIExchangePendingApproval(ctx, &RIExchangeNotificationData{})) + assert.NoError(t, n.SendRIExchangeCompleted(ctx, &RIExchangeNotificationData{})) + assert.NoError(t, n.SendPurchaseApprovalRequest(ctx, &NotificationData{})) + assert.NoError(t, n.SendRegistrationReceivedNotification(ctx, &RegistrationNotificationData{})) + assert.NoError(t, n.SendRegistrationDecisionNotification(ctx, "user@example.com", &RegistrationDecisionData{})) } // TestNopSender_NilSafe verifies the no-op tolerates the nil/empty @@ -55,13 +55,13 @@ func TestNopSender_NilSafe(t *testing.T) { func TestNopSender_NilContext(t *testing.T) { n := NewNopSender() - //nolint:staticcheck // SA1012: intentionally nil to verify defensive behavior - assert.NoError(t, n.SendToEmail(nil, "to@example.com", "s", "b")) + var nilCtx context.Context + assert.NoError(t, n.SendToEmail(nilCtx, "to@example.com", "s", "b")) } // TestNopSender_SatisfiesInterface is a runtime echo of the compile-time // guard in nop_sender.go. Useful for catching the case where the compile -// guard is silently removed by a refactor — the test would still fail +// guard is silently removed by a refactor - the test would still fail // because Go interfaces are structurally typed but require the methods // to be defined on the concrete type. func TestNopSender_SatisfiesInterface(t *testing.T) { diff --git a/internal/email/sender.go b/internal/email/sender.go index 553c8603d..a37e6b59a 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) } @@ -73,13 +73,10 @@ func isValidFromEmail(addr string) bool { return false } domain := addr[at+1:] - if !strings.Contains(domain, ".") { - return false - } - return true + return strings.Contains(domain, ".") } -// 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 +92,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 +151,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 +166,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 +183,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") @@ -203,8 +200,8 @@ func (s *Sender) createVerificationRequest(ctx context.Context, email string) er return nil } -// 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 +// 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. func (s *Sender) SendToEmail(ctx context.Context, toEmail, subject, body string) error { return s.SendToEmailWithCC(ctx, toEmail, nil, subject, body) } @@ -249,7 +246,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 +393,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,101 +411,45 @@ 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 - ExecutionID string - // PlanID is the parent purchase plan's UUID. Used by the Pause Plan - // deeplink in scheduledPurchaseTemplate to route the user to the - // Plans tab with the matching plan highlighted. The plan UUID is - // non-sensitive (the user already needs an authenticated session - // cookie to act on the plan), so embedding it in the URL is safe. - PlanID string - TotalSavings float64 - TotalUpfrontCost float64 - Recommendations []RecommendationSummary - PurchaseDate string - DaysUntilPurchase int - PlanName string - // RecipientEmail addresses the individual recipient for flows that target - // a specific user (e.g. purchase approval). Leave empty for broadcast - // flows that go to preconfigured subscribers via SNS. Purchase approvals - // MUST set this — silently broadcasting an approval link to every - // subscriber of an SNS alerts topic would leak the approval token. - 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- - // recipient flows. Purchase approvals use this to keep the global - // notification email informed while directing the approver role at the - // account's contact email. - CCEmails []string - // AuthorizedApprovers carries the email(s) of the parties who are - // 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). - AuthorizedApprovers []string - // RequestedByName is the human-readable display name (or email-local) of - // the user who submitted the purchase. Rendered in the approval-email - // summary block so approvers see who originated the request without - // having to cross-reference the dashboard. Empty falls back to - // RequestedByEmail. - RequestedByName string - // RequestedByEmail is the requester's email address. Used as a fallback - // for RequestedByName and as a context line in the approval-email - // summary. Empty omits the requested-by block entirely. - RequestedByEmail string - // RequestedAt is the ISO-8601 / RFC3339 timestamp the purchase request - // was submitted at. Empty omits the timestamp from the summary. - RequestedAt string - // CancellationWindowNote is the short text (e.g. "limited time after - // approval — see AWS Account & Billing → Refund") rendered below the - // approve/cancel buttons. Empty falls back to a generic note. Per-rec - // AWS cancellation windows differ; the call site is responsible for - // composing the right wording for the rec set. - CancellationWindowNote string - // ArcheraEducationURL is the full URL to the "What is Archera Insurance?" - // page in the CUDly dashboard (DashboardURL + "/archera-insurance"). - // When non-empty the templates append a short Archera Insurance mention - // with the 7-day enrollment window. Empty silently omits the block so - // existing callers that haven't been updated yet are unaffected. - ArcheraEducationURL string - // RevocationWindowClosesAt is the human-readable UTC timestamp when the - // Gmail-style pre-fire revocation window closes (issue #291 wave-2). Used - // by SendPurchaseScheduledNotification to tell the user until when they - // can revoke at zero cost. Empty means "not applicable" (immediate execute). + RequestedAt string + RequestedByName string + ExecutionID string + PlanID string + RevokeURL string RevocationWindowClosesAt string - // RevokeURL is the deep-link URL to revoke the scheduled purchase from the - // dashboard (issue #291 wave-2). Embedded in the scheduled-notification - // email so the user can revoke with one click. - RevokeURL string + ArcheraEducationURL string + PurchaseDate string + PlanName string + ApprovalToken string + CancellationWindowNote string + DashboardURL string + RecipientEmail string + RequestedByEmail string + CCEmails []string + AuthorizedApprovers []string + Recommendations []RecommendationSummary + DaysUntilPurchase int + TotalUpfrontCost float64 + TotalSavings float64 } -// RecommendationSummary is a simplified recommendation for email display +// RecommendationSummary is a simplified recommendation for email display. type RecommendationSummary struct { Service string ResourceType string Engine string Region string + Payment string + AccountLabel string Count int MonthlySavings float64 - // Term in years (1 or 3 for AWS RIs/SPs). Zero falls back to - // the prior shape (template hides the field). - Term int - // Payment is the payment-option string (all-upfront / partial-upfront - // / no-upfront / monthly). Empty falls back to the prior shape. - Payment string - // UpfrontCost is the per-rec upfront in dollars. Zero is rendered as - // "$0" so a no-upfront payment option visibly shows that fact. - UpfrontCost float64 - // AccountLabel is a friendly per-rec account identifier (e.g. - // "AWS 540659244915 (acme-prod)"). Empty omits the line. - AccountLabel string + Term int + UpfrontCost float64 } -// RIExchangeNotificationData holds data for RI exchange email templates +// RIExchangeNotificationData holds data for RI exchange email templates. type RIExchangeNotificationData struct { DashboardURL string Mode string @@ -522,25 +463,25 @@ 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 SourceRIID string SourceInstanceType string TargetInstanceType string - TargetCount int PaymentDue string ExchangeID string - UtilizationPct float64 Error string + TargetCount int + UtilizationPct float64 } -// SkippedExchange represents an exchange that was skipped +// SkippedExchange represents an exchange that was skipped. type SkippedExchange struct { SourceRIID string SourceInstanceType string @@ -548,7 +489,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..adc808fa7 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,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", @@ -219,7 +195,7 @@ func TestTemplates_NewRecommendations(t *testing.T) { // Just test that template parses without error ctx := context.Background() - err := sender.SendNewRecommendationsNotification(ctx, data) + err := sender.SendNewRecommendationsNotification(ctx, &data) // Will fail because snsClient is nil, but we're testing template rendering require.Error(t, err) @@ -252,7 +228,7 @@ func TestTemplates_ScheduledPurchase(t *testing.T) { } ctx := context.Background() - err := sender.SendScheduledPurchaseNotification(ctx, data) + err := sender.SendScheduledPurchaseNotification(ctx, &data) // Will fail because snsClient is nil, but we're testing template rendering require.Error(t, err) @@ -281,7 +257,7 @@ func TestTemplates_PurchaseConfirmation(t *testing.T) { } ctx := context.Background() - err := sender.SendPurchaseConfirmation(ctx, data) + err := sender.SendPurchaseConfirmation(ctx, &data) require.Error(t, err) } @@ -305,7 +281,7 @@ func TestTemplates_PurchaseFailed(t *testing.T) { } ctx := context.Background() - err := sender.SendPurchaseFailedNotification(ctx, data) + err := sender.SendPurchaseFailedNotification(ctx, &data) require.Error(t, err) } @@ -501,7 +477,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 +597,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 +644,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 +709,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, @@ -827,7 +803,7 @@ func TestSender_SendPurchaseApprovalRequest_Multipart_Issue287(t *testing.T) { Recommendations: []RecommendationSummary{{Service: "ec2", ResourceType: "m5.large", Region: "us-east-1", Count: 1, Term: 3, Payment: "all-upfront"}}, } - err := sender.SendPurchaseApprovalRequest(context.Background(), data) + err := sender.SendPurchaseApprovalRequest(context.Background(), &data) require.NoError(t, err) require.NotNil(t, captured, "SendEmail should have been called") @@ -968,7 +944,7 @@ func TestSendScheduledPurchaseNotification_UsesSESNotSNS(t *testing.T) { } ctx := context.Background() - err := sender.SendScheduledPurchaseNotification(ctx, data) + err := sender.SendScheduledPurchaseNotification(ctx, &data) require.NoError(t, err) } @@ -996,7 +972,7 @@ func TestSendScheduledPurchaseNotification_ErrNoRecipientWhenEmpty(t *testing.T) // RecipientEmail intentionally absent } - err := sender.SendScheduledPurchaseNotification(context.Background(), data) + err := sender.SendScheduledPurchaseNotification(context.Background(), &data) require.ErrorIs(t, err, ErrNoRecipient) } @@ -1006,7 +982,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) @@ -1045,7 +1021,7 @@ func TestSendRIExchangePendingApproval_UsesSESNotSNS(t *testing.T) { } ctx := context.Background() - err := sender.SendRIExchangePendingApproval(ctx, data) + err := sender.SendRIExchangePendingApproval(ctx, &data) require.NoError(t, err) } @@ -1075,7 +1051,7 @@ func TestSendRIExchangePendingApproval_ErrNoRecipientWhenEmpty(t *testing.T) { }, } - err := sender.SendRIExchangePendingApproval(context.Background(), data) + err := sender.SendRIExchangePendingApproval(context.Background(), &data) require.ErrorIs(t, err, ErrNoRecipient) } diff --git a/internal/email/smtp_sender.go b/internal/email/smtp_sender.go index 03ecb7c93..dd5851903 100644 --- a/internal/email/smtp_sender.go +++ b/internal/email/smtp_sender.go @@ -14,39 +14,34 @@ 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) - Username string // SMTP username (SendGrid API key or Azure connection username) - Password string // SMTP password - FromEmail string - FromName string - NotifyEmail string // Notification recipient email (defaults to FromEmail if empty) - UseTLS bool // Use STARTTLS (default true) - // AllowInsecure, when true, permits sending with credentials over a - // non-TLS connection. This must never be set in production; it exists - // only for integration tests against a local plaintext SMTP stub. - // When false (the default), dispatchSMTP returns an error if auth is - // configured but UseTLS is false (07-H2). + Host string + Username string + Password string // #nosec G117 -- SMTP credential field, intentional + FromEmail string + FromName string + NotifyEmail string + Port int + UseTLS bool 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 username string password string fromEmail string fromName string notifyEmail string + port int useTLS bool allowInsecure bool } -// NewSMTPSender creates a new SMTP email sender -func NewSMTPSender(cfg SMTPConfig) (*SMTPSender, error) { +// 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 +80,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 +94,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) } @@ -204,26 +199,24 @@ func (s *SMTPSender) buildSMTPMessageMultipart(toEmail string, cc []string, subj if s.fromName != "" { from = fmt.Sprintf("%s <%s>", sanitizeHeader(s.fromName), s.fromEmail) } - headers := fmt.Sprintf("From: %s\r\nTo: %s\r\n", from, toEmail) + var msg strings.Builder + fmt.Fprintf(&msg, "From: %s\r\nTo: %s\r\n", from, toEmail) if len(cc) > 0 { - headers += fmt.Sprintf("Cc: %s\r\n", strings.Join(cc, ", ")) - } - headers += fmt.Sprintf("Subject: %s\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", subject, boundary) - - var body strings.Builder - body.WriteString("--") - body.WriteString(boundary) - body.WriteString("\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n") - body.WriteString(textBody) - body.WriteString("\r\n--") - body.WriteString(boundary) - body.WriteString("\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n") - body.WriteString(htmlBody) - body.WriteString("\r\n--") - body.WriteString(boundary) - body.WriteString("--\r\n") - - return []byte(headers + body.String()) + fmt.Fprintf(&msg, "Cc: %s\r\n", strings.Join(cc, ", ")) + } + fmt.Fprintf(&msg, "Subject: %s\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", subject, boundary) + msg.WriteString("--") + msg.WriteString(boundary) + msg.WriteString("\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n") + msg.WriteString(textBody) + msg.WriteString("\r\n--") + msg.WriteString(boundary) + msg.WriteString("\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n") + msg.WriteString(htmlBody) + msg.WriteString("\r\n--") + msg.WriteString(boundary) + msg.WriteString("--\r\n") + return []byte(msg.String()) } // buildSMTPMessage assembles the RFC-5322 message bytes (headers + blank @@ -234,12 +227,15 @@ func (s *SMTPSender) buildSMTPMessage(toEmail string, cc []string, subject, body if s.fromName != "" { from = fmt.Sprintf("%s <%s>", sanitizeHeader(s.fromName), s.fromEmail) } - headers := fmt.Sprintf("From: %s\r\nTo: %s\r\n", from, toEmail) + var msg strings.Builder + fmt.Fprintf(&msg, "From: %s\r\nTo: %s\r\n", from, toEmail) if len(cc) > 0 { - headers += fmt.Sprintf("Cc: %s\r\n", strings.Join(cc, ", ")) + fmt.Fprintf(&msg, "Cc: %s\r\n", strings.Join(cc, ", ")) } - headers += fmt.Sprintf("Subject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n", subject) - return []byte(headers + body + "\r\n") + fmt.Fprintf(&msg, "Subject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n", subject) + msg.WriteString(body) + msg.WriteString("\r\n") + return []byte(msg.String()) } // dispatchSMTP runs the actual SMTP SendMail call, routing through @@ -308,7 +304,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 { @@ -322,15 +318,15 @@ func (s *SMTPSender) sendMailTLS(addr string, auth smtp.Auth, from string, to [] } // MinVersion guards against TLS 1.0/1.1 negotiation (issue #410). - if err = c.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil { + if err := c.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil { return err } - if err = smtpAuthenticate(c, auth); err != nil { + if err := smtpAuthenticate(c, auth); err != nil { return err } - if err = smtpSendBody(c, from, to, msg); err != nil { + if err := smtpSendBody(c, from, to, msg); err != nil { return err } @@ -368,8 +364,8 @@ func (s *SMTPSender) SendUserInviteEmail(ctx context.Context, email, setupURL st ) } -// SendNewRecommendationsNotification sends a notification about new recommendations -func (s *SMTPSender) SendNewRecommendationsNotification(ctx context.Context, data NotificationData) error { +// 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) if err != nil { @@ -378,8 +374,8 @@ func (s *SMTPSender) SendNewRecommendationsNotification(ctx context.Context, dat return s.SendToEmail(ctx, s.notifyEmail, subject, body) } -// SendScheduledPurchaseNotification sends a notification about scheduled purchase -func (s *SMTPSender) SendScheduledPurchaseNotification(ctx context.Context, data NotificationData) error { +// 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) if err != nil { @@ -388,8 +384,8 @@ func (s *SMTPSender) SendScheduledPurchaseNotification(ctx context.Context, data return s.SendToEmail(ctx, s.notifyEmail, subject, body) } -// SendPurchaseConfirmation sends a confirmation email after successful purchase -func (s *SMTPSender) SendPurchaseConfirmation(ctx context.Context, data NotificationData) error { +// 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) if err != nil { @@ -398,8 +394,8 @@ 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 -func (s *SMTPSender) SendPurchaseFailedNotification(ctx context.Context, data NotificationData) error { +// 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) if err != nil { @@ -408,8 +404,8 @@ 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 -func (s *SMTPSender) SendRIExchangePendingApproval(ctx context.Context, data RIExchangeNotificationData) error { +// 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) if err != nil { @@ -418,8 +414,8 @@ 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 -func (s *SMTPSender) SendRIExchangeCompleted(ctx context.Context, data RIExchangeNotificationData) error { +// 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) if err != nil { @@ -432,7 +428,7 @@ func (s *SMTPSender) SendRIExchangeCompleted(ctx context.Context, data RIExchang // Prefers data.RecipientEmail (the submitter's notification email from app // settings) over the static SMTP-configured s.notifyEmail so the approval token // lands in the right inbox per submitter. -func (s *SMTPSender) SendPurchaseApprovalRequest(ctx context.Context, data NotificationData) error { +func (s *SMTPSender) SendPurchaseApprovalRequest(ctx context.Context, data *NotificationData) error { recipient := data.RecipientEmail if recipient == "" { recipient = s.notifyEmail @@ -445,8 +441,8 @@ 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. -func (s *SMTPSender) SendPurchaseScheduledNotification(ctx context.Context, data NotificationData) error { +// 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 { return fmt.Errorf("failed to render purchase scheduled delay email: %w", err) @@ -465,10 +461,10 @@ 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 { +func (s *SMTPSender) SendRegistrationReceivedNotification(ctx context.Context, data *RegistrationNotificationData) error { // Sanitize user-controlled fields before interpolating into the Subject header // to prevent SMTP header injection (issue #401). subject := fmt.Sprintf("CUDly - New Account Registration: %s (%s)", @@ -485,7 +481,7 @@ func (s *SMTPSender) SendRegistrationReceivedNotification(ctx context.Context, d } // SendRegistrationDecisionNotification sends approval/rejection to the registrant via SMTP. -func (s *SMTPSender) SendRegistrationDecisionNotification(ctx context.Context, toEmail string, data RegistrationDecisionData) error { +func (s *SMTPSender) SendRegistrationDecisionNotification(ctx context.Context, toEmail string, data *RegistrationDecisionData) error { subject := fmt.Sprintf("CUDly - Account Registration %s", data.Decision) body, err := RenderRegistrationDecisionEmail(data) if err != nil { @@ -494,5 +490,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..00d8d6413 100644 --- a/internal/email/smtp_sender_test.go +++ b/internal/email/smtp_sender_test.go @@ -22,7 +22,7 @@ func TestNewSMTPSender_Success(t *testing.T) { UseTLS: true, } - sender, err := NewSMTPSender(cfg) + sender, err := NewSMTPSender(&cfg) require.NoError(t, err) require.NotNil(t, sender) @@ -41,7 +41,7 @@ func TestNewSMTPSender_MissingHost(t *testing.T) { // Host intentionally not set } - _, err := NewSMTPSender(cfg) + _, err := NewSMTPSender(&cfg) require.Error(t, err) assert.Contains(t, err.Error(), "SMTP host is required") @@ -53,7 +53,7 @@ func TestNewSMTPSender_MissingFromEmail(t *testing.T) { // FromEmail intentionally not set } - _, err := NewSMTPSender(cfg) + _, err := NewSMTPSender(&cfg) require.Error(t, err) assert.Contains(t, err.Error(), "from email is required") @@ -66,7 +66,7 @@ func TestNewSMTPSender_DefaultPort(t *testing.T) { // Port intentionally not set - should default to 587 } - sender, err := NewSMTPSender(cfg) + sender, err := NewSMTPSender(&cfg) require.NoError(t, err) assert.Equal(t, 587, sender.port) @@ -81,7 +81,7 @@ func TestNewSMTPSender_CustomPort(t *testing.T) { UseTLS: false, } - sender, err := NewSMTPSender(cfg) + sender, err := NewSMTPSender(&cfg) require.NoError(t, err) assert.Equal(t, 465, sender.port) @@ -96,7 +96,7 @@ func TestNewSMTPSender_Port587AutoTLS(t *testing.T) { UseTLS: false, // Even with UseTLS=false, port 587 should enable TLS } - sender, err := NewSMTPSender(cfg) + sender, err := NewSMTPSender(&cfg) require.NoError(t, err) assert.True(t, sender.useTLS) // TLS should be auto-enabled for port 587 @@ -190,7 +190,7 @@ func TestSMTPSender_SendNewRecommendationsNotification_NoFromEmail(t *testing.T) } ctx := context.Background() - err := sender.SendNewRecommendationsNotification(ctx, data) + err := sender.SendNewRecommendationsNotification(ctx, &data) require.NoError(t, err) } @@ -209,7 +209,7 @@ func TestSMTPSender_SendScheduledPurchaseNotification_NoFromEmail(t *testing.T) } ctx := context.Background() - err := sender.SendScheduledPurchaseNotification(ctx, data) + err := sender.SendScheduledPurchaseNotification(ctx, &data) require.NoError(t, err) } @@ -227,7 +227,7 @@ func TestSMTPSender_SendPurchaseConfirmation_NoFromEmail(t *testing.T) { } ctx := context.Background() - err := sender.SendPurchaseConfirmation(ctx, data) + err := sender.SendPurchaseConfirmation(ctx, &data) require.NoError(t, err) } @@ -244,12 +244,12 @@ func TestSMTPSender_SendPurchaseFailedNotification_NoFromEmail(t *testing.T) { } ctx := context.Background() - err := sender.SendPurchaseFailedNotification(ctx, data) + err := sender.SendPurchaseFailedNotification(ctx, &data) 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) @@ -264,7 +264,7 @@ func TestNewSMTPSender_NoAuth(t *testing.T) { // Username and Password not set - no auth } - sender, err := NewSMTPSender(cfg) + sender, err := NewSMTPSender(&cfg) require.NoError(t, err) require.NotNil(t, sender) @@ -305,14 +305,8 @@ func TestSendRegistrationReceivedNotification_SubjectHeaderInjection(t *testing. injectedProvider := "aws\r\nX-Injected: yes" data := RegistrationNotificationData{ - AccountName: injectedName, - Provider: injectedProvider, - RecipientEmail: "", // will fall back to notifyEmail - } - - s := &SMTPSender{ - fromEmail: "noreply@example.com", - notifyEmail: "admin@example.com", + AccountName: injectedName, + Provider: injectedProvider, } // Build the subject the same way the method does, then verify it is clean. @@ -328,7 +322,6 @@ func TestSendRegistrationReceivedNotification_SubjectHeaderInjection(t *testing. if strings.ContainsAny(cleaned, "\r\n") { t.Errorf("sanitizeHeader did not remove CR/LF from %q; got %q", injectedName, cleaned) } - _ = s } // Regression test for #410: the StartTLS call must use MinVersion: tls.VersionTLS12 @@ -345,7 +338,7 @@ func TestSMTPStartTLS_MinVersionTLS12(t *testing.T) { } // Build the config the same way sendMailTLS does and confirm MinVersion. - cfg := &tls.Config{ServerName: "smtp.example.com", MinVersion: tls.VersionTLS12} + cfg := &tls.Config{MinVersion: tls.VersionTLS12} if cfg.MinVersion != tls.VersionTLS12 { t.Errorf("TLS config MinVersion is %d; want tls.VersionTLS12 (%d) (regression of #410)", cfg.MinVersion, tls.VersionTLS12) diff --git a/internal/email/smtp_server_test.go b/internal/email/smtp_server_test.go index 8061f88fc..7d514ecac 100644 --- a/internal/email/smtp_server_test.go +++ b/internal/email/smtp_server_test.go @@ -15,20 +15,20 @@ 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 - authFail bool - wg sync.WaitGroup receivedMsg string mu sync.Mutex + wg sync.WaitGroup + port int + authFail bool 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") + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") require.NoError(t, err) addr := listener.Addr().(*net.TCPAddr) @@ -42,8 +42,8 @@ func newMockSMTPServer(t *testing.T, authFail bool) *mockSMTPServer { return server } -// start begins accepting connections -func (s *mockSMTPServer) start(t *testing.T) { +// start begins accepting connections. +func (s *mockSMTPServer) start(_ *testing.T) { s.wg.Add(1) go func() { defer s.wg.Done() @@ -68,75 +68,76 @@ func (s *mockSMTPServer) start(t *testing.T) { if err != nil { return } - - line = strings.TrimSpace(line) - - // If in DATA mode, collect the message until we see "." - if s.inData { - s.mu.Lock() - s.receivedMsg += line + "\n" - s.mu.Unlock() - if line == "." { - s.inData = false - fmt.Fprintf(writer, "250 2.0.0 OK: message queued\r\n") - writer.Flush() - } - continue - } - - s.mu.Lock() - s.receivedMsg += line + "\n" - s.mu.Unlock() - - // Determine response based on command - switch { - case strings.HasPrefix(line, "EHLO") || strings.HasPrefix(line, "HELO"): - // Send multi-line EHLO response - fmt.Fprintf(writer, "250-localhost Hello\r\n") - fmt.Fprintf(writer, "250-SIZE 35882577\r\n") - fmt.Fprintf(writer, "250-8BITMIME\r\n") - fmt.Fprintf(writer, "250-AUTH PLAIN LOGIN\r\n") - fmt.Fprintf(writer, "250 OK\r\n") - writer.Flush() - case strings.HasPrefix(line, "AUTH"): - if s.authFail { - fmt.Fprintf(writer, "535 5.7.8 Authentication failed\r\n") - } else { - fmt.Fprintf(writer, "235 2.7.0 Authentication successful\r\n") - } - writer.Flush() - case strings.HasPrefix(line, "MAIL FROM"): - fmt.Fprintf(writer, "250 2.1.0 OK\r\n") - writer.Flush() - case strings.HasPrefix(line, "RCPT TO"): - fmt.Fprintf(writer, "250 2.1.5 OK\r\n") - writer.Flush() - case strings.HasPrefix(line, "DATA"): - s.inData = true - fmt.Fprintf(writer, "354 Start mail input; end with .\r\n") - writer.Flush() - case strings.HasPrefix(line, "QUIT"): - fmt.Fprintf(writer, "221 2.0.0 Bye\r\n") - writer.Flush() + if quit := s.respondToLine(writer, strings.TrimSpace(line)); quit { return - case strings.HasPrefix(line, "RSET"): - fmt.Fprintf(writer, "250 2.0.0 OK\r\n") - writer.Flush() - default: - fmt.Fprintf(writer, "250 OK\r\n") - writer.Flush() } } }() } -// stop closes the server +// respondToLine records the line and writes the stub server's response, +// returning true when the connection should be closed (QUIT). +func (s *mockSMTPServer) respondToLine(writer *bufio.Writer, line string) (quit bool) { + s.mu.Lock() + s.receivedMsg += line + "\n" + s.mu.Unlock() + + // If in DATA mode, collect the message until we see ".". + if s.inData { + if line == "." { + s.inData = false + fmt.Fprintf(writer, "250 2.0.0 OK: message queued\r\n") + writer.Flush() + } + return false + } + + return s.respondToCommand(writer, line) +} + +// respondToCommand writes the response for a single SMTP command line, +// returning true when the connection should be closed (QUIT). +func (s *mockSMTPServer) respondToCommand(writer *bufio.Writer, line string) (quit bool) { + defer writer.Flush() + switch { + case strings.HasPrefix(line, "EHLO") || strings.HasPrefix(line, "HELO"): + // Send multi-line EHLO response + fmt.Fprintf(writer, "250-localhost Hello\r\n") + fmt.Fprintf(writer, "250-SIZE 35882577\r\n") + fmt.Fprintf(writer, "250-8BITMIME\r\n") + fmt.Fprintf(writer, "250-AUTH PLAIN LOGIN\r\n") + fmt.Fprintf(writer, "250 OK\r\n") + case strings.HasPrefix(line, "AUTH"): + if s.authFail { + fmt.Fprintf(writer, "535 5.7.8 Authentication failed\r\n") + } else { + fmt.Fprintf(writer, "235 2.7.0 Authentication successful\r\n") + } + case strings.HasPrefix(line, "MAIL FROM"): + fmt.Fprintf(writer, "250 2.1.0 OK\r\n") + case strings.HasPrefix(line, "RCPT TO"): + fmt.Fprintf(writer, "250 2.1.5 OK\r\n") + case strings.HasPrefix(line, "DATA"): + s.inData = true + fmt.Fprintf(writer, "354 Start mail input; end with .\r\n") + case strings.HasPrefix(line, "QUIT"): + fmt.Fprintf(writer, "221 2.0.0 Bye\r\n") + return true + case strings.HasPrefix(line, "RSET"): + fmt.Fprintf(writer, "250 2.0.0 OK\r\n") + default: + fmt.Fprintf(writer, "250 OK\r\n") + } + return false +} + +// 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 +251,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 +273,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 +295,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) @@ -320,12 +321,12 @@ func TestSMTPSender_SendNewRecommendationsNotification_WithMockServer(t *testing } ctx := context.Background() - err := sender.SendNewRecommendationsNotification(ctx, data) + err := sender.SendNewRecommendationsNotification(ctx, &data) 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) @@ -355,12 +356,12 @@ func TestSMTPSender_SendScheduledPurchaseNotification_WithMockServer(t *testing. } ctx := context.Background() - err := sender.SendScheduledPurchaseNotification(ctx, data) + err := sender.SendScheduledPurchaseNotification(ctx, &data) 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) @@ -386,12 +387,12 @@ func TestSMTPSender_SendPurchaseConfirmation_WithMockServer(t *testing.T) { } ctx := context.Background() - err := sender.SendPurchaseConfirmation(ctx, data) + err := sender.SendPurchaseConfirmation(ctx, &data) 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) @@ -415,12 +416,12 @@ func TestSMTPSender_SendPurchaseFailedNotification_WithMockServer(t *testing.T) } ctx := context.Background() - err := sender.SendPurchaseFailedNotification(ctx, data) + err := sender.SendPurchaseFailedNotification(ctx, &data) 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 +443,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) @@ -475,7 +476,7 @@ func TestSMTPSender_SendToEmail_WithMockServer_LongContent(t *testing.T) { // startFlexSMTPServer starts a mock SMTP server with configurable failure behavior. func startFlexSMTPServer(t *testing.T, behavior string) (string, func()) { t.Helper() - listener, err := net.Listen("tcp", "127.0.0.1:0") + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") require.NoError(t, err) addr := listener.Addr().String() @@ -514,65 +515,89 @@ func handleFlexSMTPConn(conn net.Conn, behavior string) { return } cmd := strings.ToUpper(strings.TrimSpace(line)) + if done := handleFlexSMTPCommand(conn, reader, cmd, behavior); done { + return + } + } +} - switch { - case strings.HasPrefix(cmd, "EHLO") || strings.HasPrefix(cmd, "HELO"): - fmt.Fprintf(conn, "250-localhost Hello\r\n") - fmt.Fprintf(conn, "250-SIZE 10240000\r\n") - fmt.Fprintf(conn, "250 AUTH PLAIN LOGIN\r\n") - - case strings.HasPrefix(cmd, "STARTTLS"): - fmt.Fprintf(conn, "220 Ready to start TLS\r\n") - return // plaintext conn -> client TLS handshake fails - - case strings.HasPrefix(cmd, "AUTH"): - if behavior == "auth_fail_535" { - fmt.Fprintf(conn, "535 5.7.8 Authentication credentials invalid\r\n") - } else if behavior == "auth_fail_other" { - fmt.Fprintf(conn, "454 4.7.0 Temporary authentication failure\r\n") - } else { - fmt.Fprintf(conn, "235 2.7.0 Authentication successful\r\n") - } +// handleFlexSMTPCommand writes the stub response for a single command, +// returning true when the connection should be closed (STARTTLS, QUIT, or a +// read error while draining a DATA body). +func handleFlexSMTPCommand(conn net.Conn, reader *bufio.Reader, cmd, behavior string) (done bool) { + switch { + case strings.HasPrefix(cmd, "EHLO") || strings.HasPrefix(cmd, "HELO"): + fmt.Fprintf(conn, "250-localhost Hello\r\n") + fmt.Fprintf(conn, "250-SIZE 10240000\r\n") + fmt.Fprintf(conn, "250 AUTH PLAIN LOGIN\r\n") - case strings.HasPrefix(cmd, "MAIL FROM:"): - if behavior == "mail_fail" { - fmt.Fprintf(conn, "550 5.1.0 Sender rejected\r\n") - } else { - fmt.Fprintf(conn, "250 2.1.0 OK\r\n") - } + case strings.HasPrefix(cmd, "STARTTLS"): + fmt.Fprintf(conn, "220 Ready to start TLS\r\n") + return true // plaintext conn -> client TLS handshake fails - case strings.HasPrefix(cmd, "RCPT TO:"): - if behavior == "rcpt_fail" { - fmt.Fprintf(conn, "550 5.1.1 Recipient rejected\r\n") - } else { - fmt.Fprintf(conn, "250 2.1.5 OK\r\n") - } + case strings.HasPrefix(cmd, "AUTH"): + handleFlexAuth(conn, behavior) - case strings.HasPrefix(cmd, "DATA"): - if behavior == "data_fail" { - fmt.Fprintf(conn, "554 5.0.0 Transaction failed\r\n") - } else { - fmt.Fprintf(conn, "354 Start mail input; end with .\r\n") - for { - dataLine, err := reader.ReadString('\n') - if err != nil { - return - } - if strings.TrimSpace(dataLine) == "." { - break - } - } - fmt.Fprintf(conn, "250 2.0.0 OK\r\n") - } + case strings.HasPrefix(cmd, "MAIL FROM:"): + writeFlexReply(conn, behavior == "mail_fail", "550 5.1.0 Sender rejected\r\n", "250 2.1.0 OK\r\n") - case strings.HasPrefix(cmd, "QUIT"): - fmt.Fprintf(conn, "221 2.0.0 Bye\r\n") - return + case strings.HasPrefix(cmd, "RCPT TO:"): + writeFlexReply(conn, behavior == "rcpt_fail", "550 5.1.1 Recipient rejected\r\n", "250 2.1.5 OK\r\n") + + case strings.HasPrefix(cmd, "DATA"): + return handleFlexData(conn, reader, behavior) + + case strings.HasPrefix(cmd, "QUIT"): + fmt.Fprintf(conn, "221 2.0.0 Bye\r\n") + return true + + default: + fmt.Fprintf(conn, "500 5.5.1 Command not recognized\r\n") + } + return false +} - default: - fmt.Fprintf(conn, "500 5.5.1 Command not recognized\r\n") +// writeFlexReply writes failMsg when fail is true, otherwise okMsg. +func writeFlexReply(conn net.Conn, fail bool, failMsg, okMsg string) { + if fail { + fmt.Fprintf(conn, "%s", failMsg) + } else { + fmt.Fprintf(conn, "%s", okMsg) + } +} + +// handleFlexAuth writes the AUTH response for the configured behavior. +func handleFlexAuth(conn net.Conn, behavior string) { + switch behavior { + case "auth_fail_535": + fmt.Fprintf(conn, "535 5.7.8 Authentication credentials invalid\r\n") + case "auth_fail_other": + fmt.Fprintf(conn, "454 4.7.0 Temporary authentication failure\r\n") + default: + fmt.Fprintf(conn, "235 2.7.0 Authentication successful\r\n") + } +} + +// handleFlexData handles a DATA command, draining the message body until the +// terminating ".". It returns true when the connection should be closed +// because of a read error mid-body. +func handleFlexData(conn net.Conn, reader *bufio.Reader, behavior string) (done bool) { + if behavior == "data_fail" { + fmt.Fprintf(conn, "554 5.0.0 Transaction failed\r\n") + return false + } + fmt.Fprintf(conn, "354 Start mail input; end with .\r\n") + for { + dataLine, err := reader.ReadString('\n') + if err != nil { + return true + } + if strings.TrimSpace(dataLine) == "." { + break } } + fmt.Fprintf(conn, "250 2.0.0 OK\r\n") + return false } // testFlexAuth implements smtp.Auth for testing without TLS requirement. diff --git a/internal/email/template_renderers.go b/internal/email/template_renderers.go index a72f3d688..8bb488337 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, } @@ -112,32 +112,32 @@ func RenderUserInviteEmailHTML(email, setupURL string) (string, error) { } // RenderNewRecommendationsEmail renders the plain-text new recommendations email template. -func RenderNewRecommendationsEmail(data NotificationData) (string, error) { +func RenderNewRecommendationsEmail(data *NotificationData) (string, error) { return renderTextTemplate("recommendations", newRecommendationsTemplate, data) } // RenderScheduledPurchaseEmail renders the plain-text scheduled purchase email template. -func RenderScheduledPurchaseEmail(data NotificationData) (string, error) { +func RenderScheduledPurchaseEmail(data *NotificationData) (string, error) { return renderTextTemplate("scheduled", scheduledPurchaseTemplate, data) } // RenderPurchaseConfirmationEmail renders the plain-text purchase confirmation email template. -func RenderPurchaseConfirmationEmail(data NotificationData) (string, error) { +func RenderPurchaseConfirmationEmail(data *NotificationData) (string, error) { return renderTextTemplate("confirmation", purchaseConfirmationTemplate, data) } // RenderPurchaseFailedEmail renders the plain-text purchase failed email template. -func RenderPurchaseFailedEmail(data NotificationData) (string, error) { +func RenderPurchaseFailedEmail(data *NotificationData) (string, error) { return renderTextTemplate("failed", purchaseFailedTemplate, data) } // RenderRIExchangePendingApprovalEmail renders the plain-text RI exchange pending approval email template. -func RenderRIExchangePendingApprovalEmail(data RIExchangeNotificationData) (string, error) { +func RenderRIExchangePendingApprovalEmail(data *RIExchangeNotificationData) (string, error) { return renderTextTemplate("ri-exchange-pending", riExchangePendingApprovalTemplate, data) } // RenderRIExchangeCompletedEmail renders the plain-text RI exchange completed email template. -func RenderRIExchangeCompletedEmail(data RIExchangeNotificationData) (string, error) { +func RenderRIExchangeCompletedEmail(data *RIExchangeNotificationData) (string, error) { return renderTextTemplate("ri-exchange-completed", riExchangeCompletedTemplate, data) } @@ -145,7 +145,7 @@ func RenderRIExchangeCompletedEmail(data RIExchangeNotificationData) (string, er // approval request email template. Issue #287: this is the multipart // text/plain half -- pair with RenderPurchaseApprovalRequestEmailHTML // for the styled HTML half. -func RenderPurchaseApprovalRequestEmail(data NotificationData) (string, error) { +func RenderPurchaseApprovalRequestEmail(data *NotificationData) (string, error) { return renderTextTemplate("purchase-approval-request", purchaseApprovalRequestTemplate, data) } @@ -155,16 +155,16 @@ func RenderPurchaseApprovalRequestEmail(data NotificationData) (string, error) { // The plain-text half (RenderPurchaseApprovalRequestEmail) carries the // same content; receiving clients pick whichever they support via the // multipart/alternative wrapper assembled by the sender. -func RenderPurchaseApprovalRequestEmailHTML(data NotificationData) (string, error) { +func RenderPurchaseApprovalRequestEmailHTML(data *NotificationData) (string, error) { return renderTemplate("purchase-approval-request-html", purchaseApprovalRequestHTMLTemplate, data) } // RenderRegistrationReceivedEmail renders the plain-text admin notification for a new registration. -func RenderRegistrationReceivedEmail(data RegistrationNotificationData) (string, error) { +func RenderRegistrationReceivedEmail(data *RegistrationNotificationData) (string, error) { return renderTextTemplate("registration-received", registrationReceivedTemplate, data) } // RenderRegistrationDecisionEmail renders the plain-text registrant notification for approval/rejection. -func RenderRegistrationDecisionEmail(data RegistrationDecisionData) (string, error) { +func RenderRegistrationDecisionEmail(data *RegistrationDecisionData) (string, error) { return renderTextTemplate("registration-decision", registrationDecisionTemplate, data) } diff --git a/internal/email/template_renderers_test.go b/internal/email/template_renderers_test.go index a6827ac6a..15f10401f 100644 --- a/internal/email/template_renderers_test.go +++ b/internal/email/template_renderers_test.go @@ -57,7 +57,7 @@ func TestRenderNewRecommendationsEmail(t *testing.T) { }, } - result, err := RenderNewRecommendationsEmail(data) + result, err := RenderNewRecommendationsEmail(&data) require.NoError(t, err) assert.Contains(t, result, data.DashboardURL) @@ -78,7 +78,7 @@ func TestRenderNewRecommendationsEmail_WithUpfrontCost(t *testing.T) { Recommendations: []RecommendationSummary{}, } - result, err := RenderNewRecommendationsEmail(data) + result, err := RenderNewRecommendationsEmail(&data) require.NoError(t, err) assert.Contains(t, result, "5000.00") @@ -92,7 +92,7 @@ func TestRenderNewRecommendationsEmail_NoRecommendations(t *testing.T) { Recommendations: []RecommendationSummary{}, } - result, err := RenderNewRecommendationsEmail(data) + result, err := RenderNewRecommendationsEmail(&data) require.NoError(t, err) assert.Contains(t, result, data.DashboardURL) @@ -120,7 +120,7 @@ func TestRenderScheduledPurchaseEmail(t *testing.T) { }, } - result, err := RenderScheduledPurchaseEmail(data) + result, err := RenderScheduledPurchaseEmail(&data) require.NoError(t, err) assert.Contains(t, result, data.DashboardURL) @@ -155,7 +155,7 @@ func TestRenderPurchaseConfirmationEmail(t *testing.T) { }, } - result, err := RenderPurchaseConfirmationEmail(data) + result, err := RenderPurchaseConfirmationEmail(&data) require.NoError(t, err) assert.Contains(t, result, data.DashboardURL) @@ -187,7 +187,7 @@ func TestRenderPurchaseFailedEmail(t *testing.T) { }, } - result, err := RenderPurchaseFailedEmail(data) + result, err := RenderPurchaseFailedEmail(&data) require.NoError(t, err) assert.Contains(t, result, data.DashboardURL) @@ -217,7 +217,7 @@ func TestRenderScheduledPurchaseEmail_WithoutEngine(t *testing.T) { }, } - result, err := RenderScheduledPurchaseEmail(data) + result, err := RenderScheduledPurchaseEmail(&data) require.NoError(t, err) assert.Contains(t, result, "m5.large") @@ -232,7 +232,7 @@ func TestRenderPurchaseConfirmationEmail_NoUpfrontCost(t *testing.T) { Recommendations: []RecommendationSummary{}, } - result, err := RenderPurchaseConfirmationEmail(data) + result, err := RenderPurchaseConfirmationEmail(&data) require.NoError(t, err) assert.Contains(t, result, "500.00") @@ -273,7 +273,7 @@ func TestRenderPurchaseApprovalRequestEmail_NewContextFields_Issue287(t *testing }}, } - body, err := RenderPurchaseApprovalRequestEmail(data) + body, err := RenderPurchaseApprovalRequestEmail(&data) require.NoError(t, err) // Per-rec lines carry the new fields. @@ -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") } @@ -322,7 +322,7 @@ func TestRenderPurchaseApprovalRequestEmailHTML_Issue287(t *testing.T) { }}, } - html, err := RenderPurchaseApprovalRequestEmailHTML(data) + html, err := RenderPurchaseApprovalRequestEmailHTML(&data) require.NoError(t, err) // Inline-styled approve + cancel anchors with the right hrefs. @@ -367,7 +367,7 @@ func TestRenderPurchaseApprovalRequestEmail_ArcheraBlock(t *testing.T) { }}, } - body, err := RenderPurchaseApprovalRequestEmail(data) + body, err := RenderPurchaseApprovalRequestEmail(&data) require.NoError(t, err) assert.Contains(t, body, "Archera") @@ -389,7 +389,7 @@ func TestRenderPurchaseApprovalRequestEmail_NoArcheraBlock_WhenURLEmpty(t *testi }}, } - body, err := RenderPurchaseApprovalRequestEmail(data) + body, err := RenderPurchaseApprovalRequestEmail(&data) require.NoError(t, err) assert.NotContains(t, body, "Archera Insurance") @@ -410,7 +410,7 @@ func TestRenderPurchaseApprovalRequestEmailHTML_ArcheraBlock(t *testing.T) { }}, } - html, err := RenderPurchaseApprovalRequestEmailHTML(data) + html, err := RenderPurchaseApprovalRequestEmailHTML(&data) require.NoError(t, err) assert.Contains(t, html, "Archera") @@ -430,7 +430,7 @@ func TestRenderPurchaseConfirmationEmail_ArcheraBlock(t *testing.T) { }}, } - body, err := RenderPurchaseConfirmationEmail(data) + body, err := RenderPurchaseConfirmationEmail(&data) require.NoError(t, err) assert.Contains(t, body, "Archera") @@ -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", @@ -448,9 +448,9 @@ func TestRenderPurchaseApprovalRequestEmailHTML_NoApprovers(t *testing.T) { ExecutionID: "exec-1", Recommendations: []RecommendationSummary{{Service: "ec2", ResourceType: "m5.large", Region: "us-east-1", Count: 1}}, } - html, err := RenderPurchaseApprovalRequestEmailHTML(data) + 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 @@ -529,7 +529,7 @@ func TestPlainTextTemplates_NoHTMLEscaping(t *testing.T) { Decision: "Rejected", RejectionReason: reason, } - body, err := RenderRegistrationDecisionEmail(data) + body, err := RenderRegistrationDecisionEmail(&data) require.NoError(t, err) assert.Contains(t, body, reason, "rejection reason must survive verbatim in plain-text body") assert.NotContains(t, body, "&") @@ -546,7 +546,7 @@ func TestPlainTextTemplates_NoHTMLEscaping(t *testing.T) { RequestedByEmail: email, Recommendations: []RecommendationSummary{{Service: "ec2", ResourceType: "m5.large", Region: "us-east-1", Count: 1}}, } - body, err := RenderPurchaseApprovalRequestEmail(data) + body, err := RenderPurchaseApprovalRequestEmail(&data) require.NoError(t, err) assert.Contains(t, body, name, "RequestedByName with apostrophe must be verbatim in plain-text approval") assert.Contains(t, body, email, "RequestedByEmail with & must be verbatim in plain-text approval") @@ -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{ @@ -565,7 +565,7 @@ func TestPlainTextTemplates_NoHTMLEscaping(t *testing.T) { RequestedByName: xssName, Recommendations: []RecommendationSummary{{Service: "ec2", ResourceType: "m5.large", Region: "us-east-1", Count: 1}}, } - html, err := RenderPurchaseApprovalRequestEmailHTML(data) + html, err := RenderPurchaseApprovalRequestEmailHTML(&data) require.NoError(t, err) assert.NotContains(t, html, xssName, "html/template must escape