diff --git a/cmd/cleanup-lambda/main.go b/cmd/cleanup-lambda/main.go index ed3cc8309..3e27338d5 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"` @@ -75,7 +75,9 @@ func deleteExpired(ctx context.Context, db *database.Connection, now time.Time, } defer func() { if err != nil { - _ = tx.Rollback(ctx) + if rbErr := tx.Rollback(ctx); rbErr != nil { + log.Printf("cleanup-lambda: rollback failed: %v", rbErr) + } } }() diff --git a/cmd/configure_azure.go b/cmd/configure_azure.go index 2cafee463..06354ec9a 100644 --- a/cmd/configure_azure.go +++ b/cmd/configure_azure.go @@ -4,7 +4,9 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" + "io" "log" "os" "os/exec" @@ -19,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) @@ -30,21 +32,21 @@ func validateAzureUUID(uuid, fieldName string) error { return 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: field must carry the Azure client secret to authenticate; not logged (String() redacts) 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: field carries the operator-supplied Azure client secret for the configure-azure flow; not logged SubscriptionID string Interactive bool SkipSetup bool @@ -97,7 +99,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 @@ -173,7 +175,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 != "" { @@ -188,7 +190,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, @@ -212,7 +214,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: ") @@ -234,7 +236,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) if err != nil { return fmt.Errorf("failed to read secret: %w", err) } @@ -254,7 +256,69 @@ func promptForAzureCredentialFields(reader *bufio.Reader, creds *AzureCredential return nil } -// runAzureSetupCommands runs the Azure CLI commands interactively +// readLine reads one line from reader, returning the trimmed text. A trailing +// io.EOF is treated as success only when some bytes were actually read (the +// final line of a stream that lacks a newline); an EOF with no data means the +// stream was closed with nothing pending and is surfaced so callers can +// distinguish "got a line" from "stream closed". All other errors propagate. +func readLine(reader *bufio.Reader) (string, error) { + line, err := reader.ReadString('\n') + trimmed := strings.TrimSpace(line) + if err != nil { + if errors.Is(err, io.EOF) && line != "" { + return trimmed, nil + } + return trimmed, err + } + return trimmed, nil +} + +// createAzureServicePrincipal prompts the user to run az ad sp create-for-rbac +// for the given subscription. Extracted to keep runAzureSetupCommands complexity low. +func createAzureServicePrincipal(reader *bufio.Reader, subscriptionID string) error { + // Build the create SP command - run directly without shell to avoid injection + // Using exec.Command directly with proper arguments + fmt.Printf("Command: az ad sp create-for-rbac --name CUDly --role \"Reservations Administrator\" --scopes /subscriptions/%s\n", subscriptionID) + fmt.Println() + fmt.Printf("[R]un, [S]kip? ") + + choice, err := readLine(reader) + if err != nil { + return fmt.Errorf("failed to read choice: %w", err) + } + choice = strings.ToLower(choice) + + if choice != "r" && choice != "run" && choice != "" { + fmt.Println("Skipping Create Service Principal") + return nil + } + + fmt.Println() + fmt.Println(strings.Repeat("-", 60)) + //nolint:gosec // G204: all arguments are hardcoded constants; no external/tainted input reaches exec.CommandContext (no shell) + cmd := exec.CommandContext(context.Background(), "az", "ad", "sp", "create-for-rbac", + "--name", "CUDly", + "--role", "Reservations Administrator", + "--scopes", fmt.Sprintf("/subscriptions/%s", subscriptionID)) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if runErr := cmd.Run(); runErr != nil { + fmt.Printf("Command failed: %v\n", runErr) + fmt.Print("Continue anyway? [y/N]: ") + response, readErr := readLine(reader) + if readErr != nil { + return fmt.Errorf("failed to read response: %w", readErr) + } + if !strings.EqualFold(response, "y") { + return fmt.Errorf("failed to create service principal: %w", runErr) + } + } + fmt.Println(strings.Repeat("-", 60)) + return nil +} + +// runAzureSetupCommands runs the Azure CLI commands interactively. func runAzureSetupCommands(reader *bufio.Reader) error { fmt.Println("Step 1: Azure Login") fmt.Println("-------------------") @@ -277,8 +341,10 @@ func runAzureSetupCommands(reader *bufio.Reader) error { fmt.Println() fmt.Print("Enter your Subscription ID from above: ") - subscriptionID, _ := reader.ReadString('\n') - subscriptionID = strings.TrimSpace(subscriptionID) + subscriptionID, readErr := readLine(reader) + if readErr != nil { + return fmt.Errorf("failed to read subscription ID: %w", readErr) + } if subscriptionID == "" { return fmt.Errorf("subscription ID is required") @@ -295,36 +361,8 @@ func runAzureSetupCommands(reader *bufio.Reader) error { fmt.Println("This creates an Azure Service Principal with Reservation Administrator role.") fmt.Println() - // Build the create SP command - run directly without shell to avoid injection - // Using exec.Command directly with proper arguments - fmt.Printf("Command: az ad sp create-for-rbac --name CUDly --role \"Reservations Administrator\" --scopes /subscriptions/%s\n", subscriptionID) - fmt.Println() - fmt.Printf("[R]un, [S]kip? ") - - choice, _ := reader.ReadString('\n') - choice = strings.ToLower(strings.TrimSpace(choice)) - - if choice == "r" || choice == "run" || choice == "" { - fmt.Println() - fmt.Println(strings.Repeat("-", 60)) - cmd := exec.Command("az", "ad", "sp", "create-for-rbac", - "--name", "CUDly", - "--role", "Reservations Administrator", - "--scopes", fmt.Sprintf("/subscriptions/%s", subscriptionID)) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin - if err := cmd.Run(); err != nil { - fmt.Printf("Command failed: %v\n", err) - fmt.Print("Continue anyway? [y/N]: ") - response, _ := reader.ReadString('\n') - if strings.ToLower(strings.TrimSpace(response)) != "y" { - return fmt.Errorf("failed to create service principal: %w", err) - } - } - fmt.Println(strings.Repeat("-", 60)) - } else { - fmt.Println("Skipping Create Service Principal") + if err := createAzureServicePrincipal(reader, subscriptionID); err != nil { + return err } fmt.Println() @@ -340,17 +378,20 @@ func runAzureSetupCommands(reader *bufio.Reader) error { // 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? ") - choice, _ := reader.ReadString('\n') - choice = strings.ToLower(strings.TrimSpace(choice)) + choice, err := readLine(reader) + if err != nil { + return fmt.Errorf("failed to read choice: %w", err) + } + choice = strings.ToLower(choice) switch choice { case "r", "run", "": - return executeExplicitCommand(displayCmd, program, args...) + return executeExplicitCommand(reader, displayCmd, program, args...) case "s", "skip": fmt.Printf("Skipping %s\n", name) return nil @@ -360,13 +401,15 @@ func promptAndRunExplicitCommand(reader *bufio.Reader, name, displayCmd string, } } -// executeExplicitCommand runs a command with explicit program and arguments -func executeExplicitCommand(displayCmd string, program string, args ...string) error { +// executeExplicitCommand runs a command with explicit program and arguments. +// reader must be the same bufio.Reader the caller used for the preceding +// prompt; reusing it avoids double-buffering scripted/piped stdin. +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.CommandContext(context.Background(), program, args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin @@ -377,9 +420,11 @@ func executeExplicitCommand(displayCmd string, program string, args ...string) e if err != nil { fmt.Printf("Command failed: %v\n", err) fmt.Print("Continue anyway? [y/N]: ") - reader := bufio.NewReader(os.Stdin) - response, _ := reader.ReadString('\n') - if strings.ToLower(strings.TrimSpace(response)) != "y" { + response, readErr := readLine(reader) + if readErr != nil { + return fmt.Errorf("failed to read response: %w", readErr) + } + 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 fbe687016..1ea32ea21 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: field must carry the GCP service-account private key to parse the key file; not logged 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 { @@ -157,11 +157,11 @@ func runConfigureGCP(cmd *cobra.Command, args []string) error { return err } - printGCPConfigurationSuccess(creds) + printGCPConfigurationSuccess(&creds) 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 @@ -177,8 +177,11 @@ func getGCPCredentialsFilePath(reader *bufio.Reader) (string, error) { if credsFile == "" { fmt.Print("Path to GCP service account JSON key file: ") - credsFile, _ = reader.ReadString('\n') - credsFile = strings.TrimSpace(credsFile) + var rfErr error + credsFile, rfErr = readLine(reader) + if rfErr != nil { + return "", fmt.Errorf("failed to read credentials file path: %w", rfErr) + } } if credsFile == "" { @@ -188,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 != "" { @@ -203,18 +206,19 @@ 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) + //nolint:gosec // G703: this is a local CLI tool; the operator supplies the path to their own service-account key file via --credentials-file, so reading it is the intended behavior, not a traversal vector credsData, err := os.ReadFile(expandedPath) 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 { - return GCPCredentials{}, nil, fmt.Errorf("failed to parse credentials file: %w", err) + if unmarshalErr := json.Unmarshal(credsData, &creds); unmarshalErr != nil { + return GCPCredentials{}, nil, fmt.Errorf("failed to parse credentials file: %w", unmarshalErr) } if gcpOpts.ProjectID != "" { @@ -228,7 +232,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 @@ -242,8 +246,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) { log.Printf("GCP credentials stored successfully in Secrets Manager") fmt.Println("\nGCP configuration complete!") fmt.Printf("Service Account: %s\n", creds.ClientEmail) @@ -251,14 +255,30 @@ func printGCPConfigurationSuccess(creds GCPCredentials) { fmt.Println("\nCUDly can now manage GCP Committed Use Discounts.") } -// runGCPSetupCommands runs the GCP CLI commands interactively +// getGCPProjectID reads and validates a GCP project ID from the reader. +func getGCPProjectID(reader *bufio.Reader) (string, error) { + projectID, rfErr := readLine(reader) + if rfErr != nil { + return "", fmt.Errorf("failed to read project ID: %w", rfErr) + } + if projectID == "" { + return "", fmt.Errorf("project ID is required") + } + // Validate project ID to prevent command injection + if err := validateGCPProjectID(projectID); err != nil { + return "", err + } + return projectID, nil +} + +// runGCPSetupCommands runs the GCP CLI commands interactively. func runGCPSetupCommands(reader *bufio.Reader) (string, error) { fmt.Println("Step 1: GCP Login") fmt.Println("-----------------") fmt.Println("This will open a browser window for GCP authentication.") fmt.Println() - if err := promptAndRunGCPCommand(reader, "GCP Login", "gcloud auth login", "gcloud", "auth", "login"); err != nil { + if err := promptAndRunGCPCommand(reader, "GCP Login", "gcloud auth login", "auth", "login"); err != nil { return "", err } @@ -268,32 +288,26 @@ func runGCPSetupCommands(reader *bufio.Reader) (string, error) { fmt.Println("List your GCP projects:") fmt.Println() - if err := promptAndRunGCPCommand(reader, "List Projects", "gcloud projects list", "gcloud", "projects", "list"); err != nil { + if err := promptAndRunGCPCommand(reader, "List Projects", "gcloud projects list", "projects", "list"); err != nil { return "", err } fmt.Println() fmt.Print("Enter your Project ID from above: ") - projectID, _ := reader.ReadString('\n') - projectID = strings.TrimSpace(projectID) - - if projectID == "" { - return "", fmt.Errorf("project ID is required") - } - - // Validate project ID to prevent command injection - if err := validateGCPProjectID(projectID); err != nil { + projectID, err := getGCPProjectID(reader) + if err != nil { 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) + //nolint:gosec // G702: projectID is validated by validateGCPProjectID (strict regex, no shell metacharacters) and passed as a discrete argv element (no shell) + cmd := exec.CommandContext(context.Background(), "gcloud", "config", "set", "project", projectID) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("failed to set project: %w", err) + if errXXX := cmd.Run(); errXXX != nil { + return "", fmt.Errorf("failed to set project: %w", errXXX) } fmt.Println() @@ -305,11 +319,11 @@ 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, - "gcloud", "iam", "service-accounts", "create", saName, + if errXX := promptAndRunGCPCommand(reader, "Create Service Account", createSaDisplay, + "iam", "service-accounts", "create", saName, "--display-name=CUDly Service Account", - "--description=Service account for CUDly commitment management"); err != nil { - return "", err + "--description=Service account for CUDly commitment management"); errXX != nil { + return "", errXX } fmt.Println() @@ -323,11 +337,11 @@ 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, - "gcloud", "projects", "add-iam-policy-binding", projectID, + if errX := promptAndRunGCPCommand(reader, "Grant Compute Admin Role", grantRoleDisplay, + "projects", "add-iam-policy-binding", projectID, fmt.Sprintf("--member=serviceAccount:%s", saEmail), - "--role=roles/compute.admin"); err != nil { - return "", err + "--role=roles/compute.admin"); errX != nil { + return "", errX } fmt.Println() @@ -346,7 +360,7 @@ func runGCPSetupCommands(reader *bufio.Reader) (string, error) { createKeyDisplay := fmt.Sprintf(`gcloud iam service-accounts keys create %s --iam-account=%s`, keyFile, saEmail) if err := promptAndRunGCPCommand(reader, "Create Key File", createKeyDisplay, - "gcloud", "iam", "service-accounts", "keys", "create", keyFile, + "iam", "service-accounts", "keys", "create", keyFile, fmt.Sprintf("--iam-account=%s", saEmail)); err != nil { return "", err } @@ -359,18 +373,22 @@ func runGCPSetupCommands(reader *bufio.Reader) (string, error) { } // 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 { +// The program is always "gcloud"; args are passed as discrete elements (no +// shell) so there is no command-injection surface via string splitting. +func promptAndRunGCPCommand(reader *bufio.Reader, name, displayCmd string, args ...string) error { fmt.Printf("Command: %s\n", displayCmd) fmt.Println() fmt.Printf("[R]un, [S]kip? ") - choice, _ := reader.ReadString('\n') - choice = strings.ToLower(strings.TrimSpace(choice)) + choice, rfErr := readLine(reader) + if rfErr != nil { + return fmt.Errorf("failed to read choice: %w", rfErr) + } + choice = strings.ToLower(choice) switch choice { case "r", "run", "": - return executeGCPCommand(displayCmd, program, args...) + return executeGCPCommand(reader, displayCmd, args...) case "s", "skip": fmt.Printf("Skipping %s\n", name) return nil @@ -380,13 +398,16 @@ func promptAndRunGCPCommand(reader *bufio.Reader, name, displayCmd string, progr } } -// executeGCPCommand runs a gcloud command with explicit program and arguments -func executeGCPCommand(displayCmd string, program string, args ...string) error { +// executeGCPCommand runs a "gcloud" command with explicit arguments. +// reader must be the same bufio.Reader the caller used for the preceding +// prompt; reusing it avoids double-buffering scripted/piped stdin. +func executeGCPCommand(reader *bufio.Reader, displayCmd string, args ...string) error { fmt.Println() fmt.Printf("Executing: %s\n", displayCmd) fmt.Println(strings.Repeat("-", 60)) - cmd := exec.Command(program, args...) + //nolint:gosec // G702: program is the hardcoded "gcloud" and args are hardcoded subcommands plus values validated upstream (validateGCPProjectID); passed as discrete argv elements, no shell + cmd := exec.CommandContext(context.Background(), "gcloud", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin @@ -397,9 +418,11 @@ func executeGCPCommand(displayCmd string, program string, args ...string) error if err != nil { fmt.Printf("Command failed: %v\n", err) fmt.Print("Continue anyway? [y/N]: ") - reader := bufio.NewReader(os.Stdin) - response, _ := reader.ReadString('\n') - if strings.ToLower(strings.TrimSpace(response)) != "y" { + response, rfErr := readLine(reader) + if rfErr != nil { + return fmt.Errorf("failed to read response: %w", rfErr) + } + 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..a7862e5f2 100644 --- a/cmd/configure_test.go +++ b/cmd/configure_test.go @@ -1,16 +1,73 @@ package main import ( + "bufio" "context" "encoding/json" "errors" + "io" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// MockSecretsStore is a mock implementation of SecretsStore for testing +// TestReadLine_EOFHandling is the regression test for the CR finding on +// PR #1265: readLine must not convert a zero-byte io.EOF into a successful +// empty line. A closed/non-interactive stdin (no bytes pending) must surface +// io.EOF so prompt handlers do not fall through to a default action; an EOF +// that follows a final line without a trailing newline is still success. +func TestReadLine_EOFHandling(t *testing.T) { + tests := []struct { + name string + input string + wantText string + wantEOF bool + wantNoErr bool + }{ + { + name: "normal line with newline", + input: "run\n", + wantText: "run", + wantNoErr: true, + }, + { + name: "final line without trailing newline (EOF after data)", + input: "skip", + wantText: "skip", + wantNoErr: true, + }, + { + name: "closed stream with no data surfaces EOF", + input: "", + wantText: "", + wantEOF: true, + }, + { + name: "bare newline is a valid empty line, not a closed stream", + input: "\n", + wantText: "", + wantNoErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := readLine(bufio.NewReader(strings.NewReader(tt.input))) + assert.Equal(t, tt.wantText, got) + switch { + case tt.wantEOF: + require.Error(t, err) + assert.ErrorIs(t, err, io.EOF) + case tt.wantNoErr: + assert.NoError(t, err) + } + }) + } +} + +// 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 +97,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 +112,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 +125,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 +146,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 +165,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 +176,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 +193,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 +313,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 +440,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 +606,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 +622,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/lambda/main_test.go b/cmd/lambda/main_test.go index 6dfbd28e6..8c5588170 100644 --- a/cmd/lambda/main_test.go +++ b/cmd/lambda/main_test.go @@ -6,7 +6,7 @@ import ( "os" "testing" - "github.com/LeanerCloud/CUDly/internal/api" + api "github.com/LeanerCloud/CUDly/internal/api" "github.com/LeanerCloud/CUDly/internal/server" "github.com/LeanerCloud/CUDly/internal/testutil" "github.com/stretchr/testify/assert" @@ -15,7 +15,7 @@ import ( // createTestApp creates a minimal Application for testing with no DB dependency func createTestApp() *server.Application { - apiHandler := api.NewHandler(api.HandlerConfig{}) + apiHandler := api.NewHandler(&api.HandlerConfig{}) return &server.Application{ API: apiHandler, Scheduler: &testutil.MockScheduler{}, diff --git a/cmd/rekey/main.go b/cmd/rekey/main.go index ed61eefea..e5b2506d3 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" @@ -40,9 +38,9 @@ func main() { } ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - - if err := run(ctx); err != nil { + err := run(ctx) + cancel() + if err != nil { log.Fatalf("rekey: %v", err) } } @@ -175,13 +173,15 @@ 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 } if _, err := tx.Exec(ctx, `UPDATE account_credentials SET encrypted_blob = $1 WHERE id = $2`, newBlob, id); err != nil { - _ = tx.Rollback(ctx) + if rbErr := tx.Rollback(ctx); rbErr != nil { + log.Printf("rekey: rollback id=%s: %v", id, rbErr) + } log.Printf("rekey: update id=%s: %v", id, err) return outcomeErrored } diff --git a/internal/analytics/collector_test.go b/internal/analytics/collector_test.go index 5274c1548..db8feff7b 100644 --- a/internal/analytics/collector_test.go +++ b/internal/analytics/collector_test.go @@ -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) { diff --git a/internal/api/analytics_postgres.go b/internal/api/analytics_postgres.go index a66619cf7..26bdae638 100644 --- a/internal/api/analytics_postgres.go +++ b/internal/api/analytics_postgres.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" diff --git a/internal/api/analytics_postgres_test.go b/internal/api/analytics_postgres_test.go index 1baf5aef6..2c1cbf960 100644 --- a/internal/api/analytics_postgres_test.go +++ b/internal/api/analytics_postgres_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/base64_password_guard_test.go b/internal/api/base64_password_guard_test.go index 9e33f8552..06567671a 100644 --- a/internal/api/base64_password_guard_test.go +++ b/internal/api/base64_password_guard_test.go @@ -1,4 +1,4 @@ -// Package api — CI guard for the base64-decode requirement on password-bearing +// Package apihttp — CI guard for the base64-decode requirement on password-bearing // handler functions. // // # Problem (regression class of #356) @@ -31,7 +31,7 @@ // (reads .NewPassword from a json.Unmarshal target but never calls // decodeBase64Password) as a string and asserts that the same AST scanner // detects the violation. This proves the guard would have caught issue #356. -package api +package apihttp import ( "go/ast" diff --git a/internal/api/build_suppressions_test.go b/internal/api/build_suppressions_test.go index c35d3e1b1..bb960e0dc 100644 --- a/internal/api/build_suppressions_test.go +++ b/internal/api/build_suppressions_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "testing" diff --git a/internal/api/coverage_extras_test.go b/internal/api/coverage_extras_test.go index ad0e72a73..3c46877f6 100644 --- a/internal/api/coverage_extras_test.go +++ b/internal/api/coverage_extras_test.go @@ -1,4 +1,4 @@ -package api +package apihttp // coverage_extras_test.go — additional micro-tests for the remaining ~0.6% gap. @@ -218,7 +218,7 @@ func TestApplyOverrideSlices(t *testing.T) { ExcludeTypes: []string{"db.r5.large"}, } - applyOverrideSlices(override, req) + applyOverrideSlices(override, &req) assert.Equal(t, []string{"mysql"}, override.IncludeEngines) assert.Equal(t, []string{"postgres"}, override.ExcludeEngines) @@ -233,7 +233,7 @@ func TestApplyOverrideSlices_NilFields(t *testing.T) { IncludeEngines: []string{"existing"}, } // Nil fields should not overwrite existing values - applyOverrideSlices(override, AccountServiceOverrideRequest{}) + applyOverrideSlices(override, &AccountServiceOverrideRequest{}) assert.Equal(t, []string{"existing"}, override.IncludeEngines) } diff --git a/internal/api/coverage_gaps_test.go b/internal/api/coverage_gaps_test.go index 501f0fee5..d88b4ffd9 100644 --- a/internal/api/coverage_gaps_test.go +++ b/internal/api/coverage_gaps_test.go @@ -1,4 +1,4 @@ -package api +package apihttp // coverage_gaps_test.go — additional tests to push internal/api coverage above 80%. // Targets: parseAccountIDs, redactEmail, mergeServiceConfig, checkRateLimit, diff --git a/internal/api/db_rate_limiter.go b/internal/api/db_rate_limiter.go index 28742eb90..dcc580bc3 100644 --- a/internal/api/db_rate_limiter.go +++ b/internal/api/db_rate_limiter.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" diff --git a/internal/api/db_rate_limiter_integration_test.go b/internal/api/db_rate_limiter_integration_test.go index 48bcf9925..87533273a 100644 --- a/internal/api/db_rate_limiter_integration_test.go +++ b/internal/api/db_rate_limiter_integration_test.go @@ -1,7 +1,7 @@ //go:build integration // +build integration -package api +package apihttp import ( "context" diff --git a/internal/api/db_rate_limiter_test.go b/internal/api/db_rate_limiter_test.go index ab15e52c7..a18f2258b 100644 --- a/internal/api/db_rate_limiter_test.go +++ b/internal/api/db_rate_limiter_test.go @@ -1,4 +1,4 @@ -package api +package apihttp // db_rate_limiter_test.go — tests for DBRateLimiter that don't require a real // database connection. Only nil-pool and in-memory paths are exercised. diff --git a/internal/api/exchange_helpers_test.go b/internal/api/exchange_helpers_test.go index ab7ec3493..c0e25d61c 100644 --- a/internal/api/exchange_helpers_test.go +++ b/internal/api/exchange_helpers_test.go @@ -1,4 +1,4 @@ -package api +package apihttp // exchange_helpers_test.go — tests for checkDailyCap and federation IaC helpers. diff --git a/internal/api/exchange_lookup.go b/internal/api/exchange_lookup.go index 367180e81..100da0c7e 100644 --- a/internal/api/exchange_lookup.go +++ b/internal/api/exchange_lookup.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" @@ -14,7 +14,8 @@ 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) + // A nil filter means "no filter" (match all); see config.StoreInterface. + ListStoredRecommendations(ctx context.Context, filter *config.RecommendationFilter) ([]config.RecommendationRecord, error) } // purchaseRecLookupFromStore builds an exchange.PurchaseRecLookup that @@ -48,7 +49,7 @@ func purchaseRecLookupFromStore(store recsLister, accountID string) exchange.Pur if accountID != "" { filter.AccountIDs = []string{accountID} } - recs, err := store.ListStoredRecommendations(ctx, filter) + recs, err := store.ListStoredRecommendations(ctx, &filter) if err != nil { return nil, err } diff --git a/internal/api/exchange_lookup_test.go b/internal/api/exchange_lookup_test.go index 6a6bd7cc3..2fdb78190 100644 --- a/internal/api/exchange_lookup_test.go +++ b/internal/api/exchange_lookup_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -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 + gotFilter *config.RecommendationFilter calls int out []config.RecommendationRecord err error } -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 diff --git a/internal/api/handler.go b/internal/api/handler.go index 28bd61aec..a3e446e1d 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" @@ -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,14 @@ 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. cfg must be non-nil: a nil config +// would build a Handler with every dependency unset, which only surfaces as a +// confusing nil dereference on the first request. Fail loud at construction +// instead (callers always pass a populated *HandlerConfig). +func NewHandler(cfg *HandlerConfig) *Handler { + if cfg == nil { + panic("apihttp: NewHandler requires a non-nil *HandlerConfig") + } corsOrigin := cfg.CORSAllowedOrigin if corsOrigin == "" { // Security: CORS must be explicitly configured @@ -256,7 +202,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,10 +229,13 @@ 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 { - resp, _ := h.buildResponse(400, h.buildResponseHeaders(), map[string]string{"error": "nil request"}, nil) + resp, err := h.buildResponse(400, h.buildResponseHeaders(), map[string]string{"error": "nil request"}, nil) + if err != nil { + return nil, fmt.Errorf("buildResponse: %w", err) + } return resp, nil } corsHeaders := h.buildResponseHeaders() @@ -309,7 +258,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", @@ -327,18 +276,24 @@ 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 { logging.Warnf("Request body size exceeded: %d bytes", len(req.Body)) - resp, _ := h.buildResponse(413, corsHeaders, map[string]string{"error": "Request body too large"}, nil) + resp, buildErr := h.buildResponse(413, corsHeaders, map[string]string{"error": "Request body too large"}, nil) + if buildErr != nil { + logging.Errorf("buildResponse failed: %v", buildErr) + } return resp } // Validate Content-Type if err := validateContentType(req); err != nil { - resp, _ := h.buildResponse(415, corsHeaders, map[string]string{"error": err.Error()}, nil) + resp, buildErr := h.buildResponse(415, corsHeaders, map[string]string{"error": err.Error()}, nil) + if buildErr != nil { + logging.Errorf("buildResponse failed: %v", buildErr) + } return resp } @@ -350,21 +305,27 @@ 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 } if !h.authenticate(ctx, req) { - resp, _ := h.buildResponse(401, corsHeaders, map[string]string{"error": "Unauthorized"}, nil) + resp, buildErr := h.buildResponse(401, corsHeaders, map[string]string{"error": "Unauthorized"}, nil) + if buildErr != nil { + logging.Errorf("buildResponse failed: %v", buildErr) + } return resp } if h.requiresCSRFValidation(method, path, req) { if err := h.validateCSRF(ctx, req); err != nil { logging.Warnf("CSRF validation failed: %v", err) - resp, _ := h.buildResponse(403, corsHeaders, map[string]string{"error": "CSRF validation failed"}, nil) + resp, buildErr := h.buildResponse(403, corsHeaders, map[string]string{"error": "CSRF validation failed"}, nil) + if buildErr != nil { + logging.Errorf("buildResponse failed: %v", buildErr) + } return resp } } @@ -372,7 +333,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) @@ -384,8 +345,8 @@ func (h *Handler) executeRequest(ctx context.Context, method, path string, req * return h.buildResponse(statusCode, corsHeaders, response, 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) (status int, body any) { if IsNotFoundError(err) { return 404, map[string]string{"error": "Not found"} } @@ -428,7 +389,7 @@ type rawResponse struct { csp string } -// buildResponse creates a Lambda Function URL response +// buildResponse creates a Lambda Function URL response. func (h *Handler) buildResponse(statusCode int, headers map[string]string, body any, err error) (*events.LambdaFunctionURLResponse, error) { if err != nil { return &events.LambdaFunctionURLResponse{ @@ -554,12 +515,17 @@ func (h *Handler) resolveSourceIdentity(ctx context.Context) *sourceIdentity { // resolveSourceIdentity is best-effort and is consumed by // populateSourceAccountID, which fails loud on an empty // AccountID for the AWS-source case. STS errors are already - // logged WARN inside resolveAWSCallerIdentity, so we drop - // the error here explicitly — the consumer's empty-string - // check is the security gate for federation rendering. - // (Reshape uses resolveAWSAccountID directly which DOES - // propagate the error for fail-closed multi-tenant safety.) - id.AccountID, id.Partition, _ = h.resolveAWSCallerIdentity(ctx) + // logged WARN inside resolveAWSCallerIdentity; we log again + // here for traceability and leave AccountID empty so the + // consumer's empty-string check stays the security gate for + // federation rendering. (Reshape uses resolveAWSAccountID + // directly which DOES propagate the error for fail-closed + // multi-tenant safety.) + accountID, partition, err := h.resolveAWSCallerIdentity(ctx) + if err != nil { + logging.Warnf("resolveSourceIdentity: AWS caller identity lookup failed; leaving source AccountID empty (security gate): %v", err) + } + id.AccountID, id.Partition = accountID, partition case "azure": id.ClientID = os.Getenv("AZURE_CLIENT_ID") id.SubscriptionID = os.Getenv("AZURE_SUBSCRIPTION_ID") @@ -630,7 +596,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) (accountID, partition string, err error) { if sourceCloud() != "aws" { // Azure/GCP host: short-circuit before any AWS SDK work. return "", "", nil @@ -644,12 +610,11 @@ func (h *Handler) resolveAWSCallerIdentity(ctx context.Context) (string, string, return "", "", fmt.Errorf("aws sdk config load: %w", h.awsCfgErr) } client := sts.NewFromConfig(h.awsCfg) - identity, err := client.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) - if err != nil { - logging.Warnf("Failed to resolve source account ID via STS: %v", err) - return "", "", fmt.Errorf("sts get-caller-identity: %w", err) + identity, idErr := client.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if idErr != nil { + logging.Warnf("Failed to resolve source account ID via STS: %v", idErr) + return "", "", fmt.Errorf("sts get-caller-identity: %w", idErr) } - var accountID, partition string if identity.Account != nil { accountID = *identity.Account } @@ -661,7 +626,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 095e81cdb..17de92ec2 100644 --- a/internal/api/handler_accounts.go +++ b/internal/api/handler_accounts.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -17,6 +17,7 @@ import ( "github.com/LeanerCloud/CUDly/internal/config" "github.com/LeanerCloud/CUDly/internal/credentials" "github.com/LeanerCloud/CUDly/internal/oidc" + "github.com/LeanerCloud/CUDly/pkg/logging" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/sts" @@ -26,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. @@ -95,13 +93,13 @@ func (h *Handler) listAccounts(ctx context.Context, req *events.LambdaFunctionUR filter := buildAccountFilter(req.QueryStringParameters) - accounts, err := h.config.ListCloudAccounts(ctx, filter) + acctList, err := h.config.ListCloudAccounts(ctx, filter) if err != nil { return nil, fmt.Errorf("accounts: %w", err) } - if accounts == nil { - accounts = []config.CloudAccount{} + if acctList == nil { + acctList = []config.CloudAccount{} } // Filter by allowed accounts if the user has restricted access. @@ -112,19 +110,20 @@ func (h *Handler) listAccounts(ctx context.Context, req *events.LambdaFunctionUR return nil, fmt.Errorf("failed to get allowed accounts: %w", err) } if !auth.IsUnrestrictedAccess(allowedAccounts) { - filtered := accounts[:0] - for _, acct := range accounts { + filtered := acctList[:0] + for i := range acctList { + acct := &acctList[i] if auth.MatchesAccount(allowedAccounts, acct.ID, acct.Name) { - filtered = append(filtered, acct) + filtered = append(filtered, *acct) } } - accounts = filtered + acctList = filtered } // Mark the self-account (the account matching CUDly's own host identity) - h.markSelfAccount(ctx, accounts) + h.markSelfAccount(ctx, acctList) - return accounts, nil + return acctList, nil } // AccountSummary is the minimal-disclosure projection of a cloud account used @@ -158,7 +157,7 @@ func (h *Handler) listAccountsMinimal(ctx context.Context, req *events.LambdaFun filter := buildAccountFilter(req.QueryStringParameters) - accounts, err := h.config.ListCloudAccounts(ctx, filter) + acctList, err := h.config.ListCloudAccounts(ctx, filter) if err != nil { return nil, fmt.Errorf("accounts: %w", err) } @@ -171,9 +170,9 @@ func (h *Handler) listAccountsMinimal(ctx context.Context, req *events.LambdaFun // Build the minimal projection in place, applying allowed_accounts scoping // during the copy so a restricted user only ever sees their entitled rows. - summaries := make([]AccountSummary, 0, len(accounts)) - for i := range accounts { - acct := &accounts[i] + summaries := make([]AccountSummary, 0, len(acctList)) + for i := range acctList { + acct := &acctList[i] if !unrestricted && !auth.MatchesAccount(allowedAccounts, acct.ID, acct.Name) { continue } @@ -189,14 +188,14 @@ 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, acctList []config.CloudAccount) { si := h.resolveSourceIdentity(ctx) if si == nil || si.ExternalID() == "" { return } - for i := range accounts { - if accounts[i].Provider == si.Provider && accounts[i].ExternalID == si.ExternalID() { - accounts[i].IsSelf = true + for i := range acctList { + if acctList[i].Provider == si.Provider && acctList[i].ExternalID == si.ExternalID() { + acctList[i].IsSelf = true } } } @@ -240,12 +239,12 @@ func (h *Handler) createSelfAccount(ctx context.Context, httpReq *events.LambdaF } req := buildSelfAccountRequest(si) - if err := validateCloudAccountRequest(req); err != nil { + if err := validateCloudAccountRequest(&req); err != nil { return nil, err } now := time.Now() - account := cloudAccountFromRequest(req) + account := cloudAccountFromRequest(&req) account.ID = uuid.New().String() account.CreatedAt = now account.UpdatedAt = now @@ -315,12 +314,12 @@ func (h *Handler) createAccount(ctx context.Context, httpReq *events.LambdaFunct return nil, NewClientError(400, "invalid request body") } - if err := validateCloudAccountRequest(req); err != nil { + if err := validateCloudAccountRequest(&req); err != nil { return nil, err } now := time.Now() - account := cloudAccountFromRequest(req) + account := cloudAccountFromRequest(&req) account.ID = uuid.New().String() account.CreatedAt = now account.UpdatedAt = now @@ -350,7 +349,7 @@ var validAccountProviders = map[string]bool{ } // validateCloudAccountRequest checks required fields and allowed values. -func validateCloudAccountRequest(req CloudAccountRequest) error { +func validateCloudAccountRequest(req *CloudAccountRequest) error { if req.Name == "" { return NewClientError(400, "name is required") } @@ -371,7 +370,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 { switch req.Provider { case "aws": return validateAWSAuthMode(req) @@ -411,7 +410,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 { if req.AWSAuthMode != "" && !validAWSAuthModes[req.AWSAuthMode] { return NewClientError(400, "invalid aws_auth_mode") } @@ -442,7 +441,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). @@ -491,7 +490,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 { a := &config.CloudAccount{ Name: req.Name, Description: req.Description, @@ -552,12 +551,12 @@ func (h *Handler) updateAccount(ctx context.Context, httpReq *events.LambdaFunct } var req CloudAccountRequest - if err := json.Unmarshal([]byte(httpReq.Body), &req); err != nil { + if errXXX := json.Unmarshal([]byte(httpReq.Body), &req); errXXX != nil { return nil, NewClientError(400, "invalid request body") } - if err := validateCloudAccountRequest(req); err != nil { - return nil, err + if errXX := validateCloudAccountRequest(&req); errXX != nil { + return nil, errXX } existing, err := h.requireAccountAccess(ctx, session, id) @@ -565,7 +564,7 @@ func (h *Handler) updateAccount(ctx context.Context, httpReq *events.LambdaFunct return nil, err } - account := cloudAccountFromRequest(req) + account := cloudAccountFromRequest(&req) account.ID = id account.CreatedAt = existing.CreatedAt account.CreatedBy = existing.CreatedBy @@ -598,8 +597,8 @@ 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 { - return nil, err + if _, accessErr := h.requireAccountAccess(ctx, session, id); accessErr != nil { + return nil, accessErr } // Preflight: refuse the delete if pending/notified executions still @@ -617,14 +616,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, @@ -643,7 +642,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", }) @@ -692,8 +691,8 @@ 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 { - return nil, err + if _, accessErr := h.requireAccountAccess(ctx, session, id); accessErr != nil { + return nil, accessErr } if h.credStore == nil { @@ -817,7 +816,11 @@ func (h *Handler) canUseGCPFederated(ctx context.Context, acct *config.CloudAcco // If a legacy stored WIF JSON is present, defer to the presence // check so legacy accounts keep reporting the same shape. if h.credStore != nil { - if has, _ := h.credStore.HasCredential(ctx, acct.ID, credentials.CredTypeGCPWIFConfig); has { + has, err := h.credStore.HasCredential(ctx, acct.ID, credentials.CredTypeGCPWIFConfig) + if err != nil { + logging.Warnf("hasCredential check failed for account %s: %v", acct.ID, err) + } + if has { return false } } @@ -845,7 +848,7 @@ func runGCPFederatedTokenExchange(ctx context.Context, ts oauth2.TokenSource) Ac var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { - res, err, retriable := gcpTokenExchangeAttempt(ctx, ts) + res, retriable, err := gcpTokenExchangeAttempt(ctx, ts) if err == nil { return res } @@ -866,9 +869,9 @@ 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) { +// Returns (result, _, nil) on success, (_, true, err) on a retriable +// IAM propagation error, (_, false, err) on any other failure. +func gcpTokenExchangeAttempt(ctx context.Context, ts oauth2.TokenSource) (AccountTestResult, bool, error) { tokCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() tokenChan := make(chan tokenResult, 1) @@ -879,14 +882,14 @@ func gcpTokenExchangeAttempt(ctx context.Context, ts oauth2.TokenSource) (Accoun select { case r := <-tokenChan: if r.err != nil { - return AccountTestResult{}, r.err, isGCPIAMPropagationError(r.err) + return AccountTestResult{}, isGCPIAMPropagationError(r.err), r.err } if r.tok == nil || r.tok.AccessToken == "" { - return AccountTestResult{OK: false, Message: "gcp token exchange returned empty token"}, nil, false + return AccountTestResult{OK: false, Message: "gcp token exchange returned empty token"}, false, nil } - return AccountTestResult{OK: true, Message: "federated credential validated (KMS-signed JWT accepted by GCP STS, SA impersonation succeeded)"}, nil, false + return AccountTestResult{OK: true, Message: "federated credential validated (KMS-signed JWT accepted by GCP STS, SA impersonation succeeded)"}, false, nil case <-tokCtx.Done(): - return AccountTestResult{}, fmt.Errorf("timed out after 15s"), true + return AccountTestResult{}, true, fmt.Errorf("timed out after 15s") } } @@ -1022,8 +1025,8 @@ func (h *Handler) listAccountServiceOverrides(ctx context.Context, req *events.L return nil, err } - if _, err := h.requireAccountAccess(ctx, session, id); err != nil { - return nil, err + if _, accessErr := h.requireAccountAccess(ctx, session, id); accessErr != nil { + return nil, accessErr } overrides, err := h.config.ListAccountServiceOverrides(ctx, id) @@ -1051,12 +1054,12 @@ func (h *Handler) saveAccountServiceOverride(ctx context.Context, httpReq *event return nil, err } - if _, err := h.requireAccountAccess(ctx, session, accountID); err != nil { - return nil, err + if _, accessErr := h.requireAccountAccess(ctx, session, accountID); accessErr != nil { + return nil, accessErr } var req AccountServiceOverrideRequest - if err := json.Unmarshal([]byte(httpReq.Body), &req); err != nil { + if errX := json.Unmarshal([]byte(httpReq.Body), &req); errX != nil { return nil, NewClientError(400, "invalid request body") } @@ -1066,9 +1069,9 @@ func (h *Handler) saveAccountServiceOverride(ctx context.Context, httpReq *event return nil, fmt.Errorf("accounts: get existing override: %w", err) } - override := buildServiceOverride(accountID, provider, service, req, existing, now) + 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. @@ -1089,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 { override := &config.AccountServiceOverride{ AccountID: accountID, Provider: provider, @@ -1111,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) { applyOverrideScalars(o, req) applyOverrideSlices(o, req) } -func applyOverrideScalars(o *config.AccountServiceOverride, req AccountServiceOverrideRequest) { +func applyOverrideScalars(o *config.AccountServiceOverride, req *AccountServiceOverrideRequest) { if req.Enabled != nil { o.Enabled = req.Enabled } @@ -1134,7 +1137,7 @@ func applyOverrideScalars(o *config.AccountServiceOverride, req AccountServiceOv } } -func applyOverrideSlices(o *config.AccountServiceOverride, req AccountServiceOverrideRequest) { +func applyOverrideSlices(o *config.AccountServiceOverride, req *AccountServiceOverrideRequest) { if req.IncludeEngines != nil { o.IncludeEngines = req.IncludeEngines } @@ -1183,8 +1186,8 @@ func (h *Handler) deleteAccountServiceOverride(ctx context.Context, req *events. return nil, err } - if _, err := h.requireAccountAccess(ctx, session, accountID); err != nil { - return nil, err + if _, accessErr := h.requireAccountAccess(ctx, session, accountID); accessErr != nil { + return nil, accessErr } if err := h.config.DeleteAccountServiceOverride(ctx, accountID, provider, service); err != nil { @@ -1340,16 +1343,16 @@ func (h *Handler) listPlanAccounts(ctx context.Context, req *events.LambdaFuncti return nil, err } - accounts, err := h.config.GetPlanAccounts(ctx, id) + acctList, err := h.config.GetPlanAccounts(ctx, id) if err != nil { return nil, fmt.Errorf("accounts: %w", err) } - if accounts == nil { - accounts = []config.CloudAccount{} + if acctList == nil { + acctList = []config.CloudAccount{} } - return accounts, nil + return acctList, nil } // DiscoverOrgRequest is the request body for POST /api/accounts/discover-org. @@ -1398,7 +1401,7 @@ func (h *Handler) discoverOrgAccounts(ctx context.Context, req *events.LambdaFun return nil, err } - disco, err := h.runOrgDiscovery(ctx, cfg) + disco, err := h.runOrgDiscovery(ctx, &cfg) if err != nil { return nil, err } @@ -1470,12 +1473,14 @@ 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) { discoverFn := h.discoverOrgFn if discoverFn == nil { discoverFn = accounts.DiscoverOrgAccounts } - disco, err := discoverFn(ctx, cfg) + // accounts.DiscoverOrgAccounts (and the discoverOrgFn seam) take aws.Config + // by value, matching the AWS SDK convention; deref at that boundary. + 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..4d60706c1 100644 --- a/internal/api/handler_accounts_external_id_test.go +++ b/internal/api/handler_accounts_external_id_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_accounts_router_test.go b/internal/api/handler_accounts_router_test.go index 380723f31..1c913203a 100644 --- a/internal/api/handler_accounts_router_test.go +++ b/internal/api/handler_accounts_router_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_accounts_test.go b/internal/api/handler_accounts_test.go index 7c2fe5d88..72320ea8b 100644 --- a/internal/api/handler_accounts_test.go +++ b/internal/api/handler_accounts_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "bytes" diff --git a/internal/api/handler_analytics.go b/internal/api/handler_analytics.go index 514397de9..08b44ac6b 100644 --- a/internal/api/handler_analytics.go +++ b/internal/api/handler_analytics.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for analytics endpoints. -package api +// Package apihttp provides the HTTP API handlers for analytics endpoints. +package apihttp import ( "context" diff --git a/internal/api/handler_analytics_test.go b/internal/api/handler_analytics_test.go index 9622b55dc..0bd939946 100644 --- a/internal/api/handler_analytics_test.go +++ b/internal/api/handler_analytics_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_apikeys.go b/internal/api/handler_apikeys.go index a3f3695db..116cee464 100644 --- a/internal/api/handler_apikeys.go +++ b/internal/api/handler_apikeys.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_apikeys_test.go b/internal/api/handler_apikeys_test.go index 6b7a2bf4f..a0a0f0648 100644 --- a/internal/api/handler_apikeys_test.go +++ b/internal/api/handler_apikeys_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_auth.go b/internal/api/handler_auth.go index 75300c1ae..bbfce0068 100644 --- a/internal/api/handler_auth.go +++ b/internal/api/handler_auth.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" diff --git a/internal/api/handler_auth_test.go b/internal/api/handler_auth_test.go index 30bf53136..a20887a66 100644 --- a/internal/api/handler_auth_test.go +++ b/internal/api/handler_auth_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_commitment_options.go b/internal/api/handler_commitment_options.go index 62833bf4b..7e905f023 100644 --- a/internal/api/handler_commitment_options.go +++ b/internal/api/handler_commitment_options.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" diff --git a/internal/api/handler_commitment_options_test.go b/internal/api/handler_commitment_options_test.go index d4d3a92fc..5df2e0ec5 100644 --- a/internal/api/handler_commitment_options_test.go +++ b/internal/api/handler_commitment_options_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -72,7 +72,7 @@ func TestNewHandler_CommitmentOptsWired(t *testing.T) { // (via a field rename or accidental removal) would re-introduce the // "endpoint always returns unavailable" bug we already fixed once. stub := &stubCommitmentOpts{} - h := NewHandler(HandlerConfig{CommitmentOpts: stub}) + h := NewHandler(&HandlerConfig{CommitmentOpts: stub}) require.Same(t, stub, h.commitmentOpts) } diff --git a/internal/api/handler_config.go b/internal/api/handler_config.go index 615373d5d..a72ac0378 100644 --- a/internal/api/handler_config.go +++ b/internal/api/handler_config.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" diff --git a/internal/api/handler_config_test.go b/internal/api/handler_config_test.go index 547ed1f21..8746b68f1 100644 --- a/internal/api/handler_config_test.go +++ b/internal/api/handler_config_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_coverage_test.go b/internal/api/handler_coverage_test.go index 1750614de..99c51d3ee 100644 --- a/internal/api/handler_coverage_test.go +++ b/internal/api/handler_coverage_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -819,7 +819,7 @@ func TestNewHandler_WithDependencies(t *testing.T) { CORSAllowedOrigin: "https://example.com", } - handler := NewHandler(cfg) + handler := NewHandler(&cfg) assert.NotNil(t, handler) assert.Equal(t, "https://example.com", handler.corsAllowedOrigin) diff --git a/internal/api/handler_dashboard.go b/internal/api/handler_dashboard.go index a725ac404..87b9658d7 100644 --- a/internal/api/handler_dashboard.go +++ b/internal/api/handler_dashboard.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" @@ -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,8 @@ 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 i := range accounts { + a := &accounts[i] if auth.MatchesAccount(allowed, a.ID, a.Name) { allowedUUIDs = append(allowedUUIDs, a.ID) } @@ -173,13 +174,14 @@ func (h *Handler) filterDashboardRecommendations(ctx context.Context, session *S nameByID := h.resolveAccountNamesByID(ctx) filtered := recs[:0] - for _, rec := range recs { + for i := range recs { + rec := &recs[i] if rec.CloudAccountID == nil { continue } id := *rec.CloudAccountID if auth.MatchesAccount(allowed, id, nameByID[id]) { - filtered = append(filtered, rec) + filtered = append(filtered, *rec) } } return filtered, nil @@ -196,7 +198,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. @@ -221,7 +223,7 @@ func (h *Handler) filterDashboardRecommendations(ctx context.Context, session *S func summarizeRecommendationsWithCoverage( recs []config.RecommendationRecord, coverageByKey map[string]float64, -) (float64, map[string]ServiceSavings) { +) (totalSavings float64, byService map[string]ServiceSavings) { // Dedupe to one representative variant per physical-resource cell BEFORE // summing. After PR #195's per-(term, payment) fan-out, a single physical // resource produces up to 6 rec rows (2 terms x 3 payments). Those rows are @@ -234,11 +236,11 @@ func summarizeRecommendationsWithCoverage( // variant"); this reducer is the backend equivalent. representatives := bestVariantPerCell(recs, coverageByKey) - var total float64 - byService := make(map[string]ServiceSavings) - for _, rep := range representatives { + byService = make(map[string]ServiceSavings) + for i := range representatives { + rep := &representatives[i] scaled := rep.scaled - total += scaled + totalSavings += scaled svc := byService[rep.rec.Service] svc.PotentialSavings += scaled // CurrentSavings is the committed/realized monthly savings for the @@ -254,7 +256,7 @@ func summarizeRecommendationsWithCoverage( svc.CurrentSavings += scaled byService[rep.rec.Service] = svc } - return total, byService + return totalSavings, byService } // cellRepresentative is the chosen variant for one physical-resource cell plus @@ -277,7 +279,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 { account := "" if rec.CloudAccountID != nil { account = *rec.CloudAccountID @@ -303,17 +305,18 @@ func bestVariantPerCell( ) []cellRepresentative { indexByCell := make(map[string]int, len(recs)) reps := make([]cellRepresentative, 0, len(recs)) - for _, rec := range recs { + for i := range recs { + rec := &recs[i] scaled := scaledSavings(rec, coverageByKey) key := recCellKey(rec) if idx, ok := indexByCell[key]; ok { if scaled > reps[idx].scaled { - reps[idx] = cellRepresentative{rec: rec, scaled: scaled} + reps[idx] = cellRepresentative{rec: *rec, scaled: scaled} } continue } indexByCell[key] = len(reps) - reps = append(reps, cellRepresentative{rec: rec, scaled: scaled}) + reps = append(reps, cellRepresentative{rec: *rec, scaled: scaled}) } return reps } @@ -321,7 +324,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 { if rec.CloudAccountID == nil || coverageByKey == nil { return rec.Savings } @@ -341,14 +344,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 @@ -380,7 +383,10 @@ func (h *Handler) resolveCoverageByAccountKey(ctx context.Context, recs []config // resolveTargetCoverage returns the configured default coverage or 80% when // no global config is set or the configured value is zero. func (h *Handler) resolveTargetCoverage(ctx context.Context) float64 { - globalCfg, _ := h.config.GetGlobalConfig(ctx) + globalCfg, err := h.config.GetGlobalConfig(ctx) + if err != nil { + logging.Warnf("resolveTargetCoverage: failed to get global config, using default 80%%: %v", err) + } if globalCfg != nil && globalCfg.DefaultCoverage > 0 { return globalCfg.DefaultCoverage } @@ -392,7 +398,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 @@ -456,9 +462,9 @@ 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 { - provider = svcCfg.Provider - service = svcCfg.Service + for i := range plan.Services { + provider = plan.Services[i].Provider + service = plan.Services[i].Service break } return UpcomingPurchase{ @@ -475,28 +481,11 @@ func upcomingFromExecution(plan *config.PurchasePlan, exec *config.PurchaseExecu } } -// planIntersectsAllowed returns true when any of the plan's associated cloud -// accounts is in the allowed list (matched by ID or display name). Returns -// false when the plan has no account rows — scoped users don't get to see -// unattributed plans. -func (h *Handler) planIntersectsAllowed(ctx context.Context, planID string, allowed []string) (bool, error) { - accounts, err := h.config.GetPlanAccounts(ctx, planID) - if err != nil { - return false, fmt.Errorf("failed to get plan accounts: %w", err) - } - for _, acct := range accounts { - if auth.MatchesAccount(allowed, acct.ID, acct.Name) { - return true, nil - } - } - return false, nil -} - // getPublicInfo returns public information about the CUDly instance (no auth required). // No rate limiting — this is hit by Terraform deployment checks and the frontend on every page load. // Sensitive identifiers (API key secret URL, deployment AWS account ID) are intentionally // 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 { // Check if admin exists adminExists := false if h.auth != nil { @@ -509,14 +498,14 @@ func (h *Handler) getPublicInfo(ctx context.Context, req *events.LambdaFunctionU return &PublicInfoResponse{ Version: "1.0.0", AdminExists: adminExists, - }, nil + } } // getDeploymentInfo returns sensitive deployment identifiers for authenticated callers. // 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 { // Build the AWS Console deep-link to the Secrets Manager secret. var apiKeySecretURL string if h.secretsARN != "" { @@ -535,13 +524,17 @@ func (h *Handler) getDeploymentInfo(ctx context.Context, _ *events.LambdaFunctio // orphan execution whose account was deleted (issue #608). The call // is best-effort: non-AWS deployments and STS transient failures // return "" and the frontend falls back to the "Account deleted" - // warning label, which is safe. - deploymentAWSAccountID, _ := h.resolveAWSAccountID(ctx) + // warning label, which is safe. We log the error for traceability and + // keep the empty-string-safe downstream behavior. + deploymentAWSAccountID, err := h.resolveAWSAccountID(ctx) + if err != nil { + logging.Warnf("deploymentInfo: AWS account ID lookup failed; returning empty DeploymentAWSAccountID (frontend handles it safely): %v", err) + } return &DeploymentInfoResponse{ APIKeySecretURL: apiKeySecretURL, DeploymentAWSAccountID: deploymentAWSAccountID, - }, nil + } } // commitmentExpiry returns the moment a purchase's commitment term ends. @@ -552,7 +545,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 { termDuration := time.Duration(p.Term) * 365 * 24 * time.Hour return p.Timestamp.Add(termDuration) } @@ -560,16 +553,18 @@ 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/cancelled/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 { +// +//nolint:misspell // documents DB status literals incl. 'cancelled' (status CHECK constraint); rename tracked in PR #1277 +func isActiveCommitment(p *config.PurchaseHistoryRecord, now time.Time) bool { // 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", "cancelled", "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" { @@ -585,7 +580,8 @@ 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 i := range purchases { + p := &purchases[i] if !isActiveCommitment(p, now) { continue } @@ -669,7 +665,8 @@ func (h *Handler) calculateCommitmentMetrics(ctx context.Context, accountUUIDs [ committedMonthly += v } - for _, p := range purchases { + for i := range purchases { + p := &purchases[i] if !isActiveCommitment(p, currentTime) { continue } @@ -694,7 +691,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..95346905d 100644 --- a/internal/api/handler_dashboard_test.go +++ b/internal/api/handler_dashboard_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -703,8 +703,7 @@ func TestHandler_getPublicInfo(t *testing.T) { secretsARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:api-key-abc123", } - result, err := handler.getPublicInfo(ctx, createMockLambdaRequest("192.168.1.1")) - require.NoError(t, err) + result := handler.getPublicInfo(ctx, createMockLambdaRequest("192.168.1.1")) assert.Equal(t, "1.0.0", result.Version) assert.True(t, result.AdminExists) @@ -718,8 +717,7 @@ func TestHandler_getPublicInfo(t *testing.T) { auth: mockAuth, } - result, err := handler.getPublicInfo(ctx, createMockLambdaRequest("192.168.1.1")) - require.NoError(t, err) + result := handler.getPublicInfo(ctx, createMockLambdaRequest("192.168.1.1")) assert.False(t, result.AdminExists) }) @@ -732,8 +730,7 @@ func TestHandler_getPublicInfo(t *testing.T) { auth: mockAuth, } - result, err := handler.getPublicInfo(ctx, createMockLambdaRequest("192.168.1.1")) - require.NoError(t, err) + result := handler.getPublicInfo(ctx, createMockLambdaRequest("192.168.1.1")) // Error should be swallowed, adminExists defaults to false assert.False(t, result.AdminExists) @@ -742,8 +739,7 @@ func TestHandler_getPublicInfo(t *testing.T) { t.Run("without auth service", func(t *testing.T) { handler := &Handler{} - result, err := handler.getPublicInfo(ctx, createMockLambdaRequest("192.168.1.1")) - require.NoError(t, err) + result := handler.getPublicInfo(ctx, createMockLambdaRequest("192.168.1.1")) assert.False(t, result.AdminExists) }) @@ -759,8 +755,7 @@ func TestHandler_getPublicInfo(t *testing.T) { rateLimiter: mockRateLimiter, } - result, err := handler.getPublicInfo(ctx, createMockLambdaRequest("192.168.1.1")) - require.NoError(t, err) + result := handler.getPublicInfo(ctx, createMockLambdaRequest("192.168.1.1")) assert.True(t, result.AdminExists) }) @@ -775,8 +770,7 @@ func TestHandler_getPublicInfo(t *testing.T) { secretsARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:api-key-abc123", } - result, err := handler.getPublicInfo(ctx, createMockLambdaRequest("10.0.0.1")) - require.NoError(t, err) + result := handler.getPublicInfo(ctx, createMockLambdaRequest("10.0.0.1")) // PublicInfoResponse no longer carries these fields — the struct itself is // the compile-time guard. The JSON assertion catches any future re-addition @@ -799,8 +793,7 @@ func TestHandler_getDeploymentInfo(t *testing.T) { secretsARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:api-key-abc123", } - result, err := handler.getDeploymentInfo(ctx, createMockLambdaRequest("10.0.0.1")) - require.NoError(t, err) + result := handler.getDeploymentInfo(ctx, createMockLambdaRequest("10.0.0.1")) assert.Contains(t, result.APIKeySecretURL, "us-east-1") assert.Contains(t, result.APIKeySecretURL, "secretsmanager") @@ -811,8 +804,7 @@ func TestHandler_getDeploymentInfo(t *testing.T) { secretsARN: "arn:aws:secretsmanager:eu-west-1:987654321098:secret:my-secret-xyz789", } - result, err := handler.getDeploymentInfo(ctx, createMockLambdaRequest("10.0.0.1")) - require.NoError(t, err) + result := handler.getDeploymentInfo(ctx, createMockLambdaRequest("10.0.0.1")) assert.Contains(t, result.APIKeySecretURL, "eu-west-1") }) @@ -822,8 +814,7 @@ func TestHandler_getDeploymentInfo(t *testing.T) { secretsARN: "invalid-arn", } - result, err := handler.getDeploymentInfo(ctx, createMockLambdaRequest("10.0.0.1")) - require.NoError(t, err) + result := handler.getDeploymentInfo(ctx, createMockLambdaRequest("10.0.0.1")) assert.Empty(t, result.APIKeySecretURL) }) @@ -831,8 +822,7 @@ func TestHandler_getDeploymentInfo(t *testing.T) { t.Run("empty secretsARN returns empty URL", func(t *testing.T) { handler := &Handler{} - result, err := handler.getDeploymentInfo(ctx, createMockLambdaRequest("10.0.0.1")) - require.NoError(t, err) + result := handler.getDeploymentInfo(ctx, createMockLambdaRequest("10.0.0.1")) assert.Empty(t, result.APIKeySecretURL) }) diff --git a/internal/api/handler_docs.go b/internal/api/handler_docs.go index 2018bd397..724be6bb1 100644 --- a/internal/api/handler_docs.go +++ b/internal/api/handler_docs.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_docs_test.go b/internal/api/handler_docs_test.go index 948bcb9d7..65e3145e7 100644 --- a/internal/api/handler_docs_test.go +++ b/internal/api/handler_docs_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_federation.go b/internal/api/handler_federation.go index f349e276c..fbfab18a7 100644 --- a/internal/api/handler_federation.go +++ b/internal/api/handler_federation.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "archive/zip" diff --git a/internal/api/handler_federation_test.go b/internal/api/handler_federation_test.go index 10896294c..7f3437b46 100644 --- a/internal/api/handler_federation_test.go +++ b/internal/api/handler_federation_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "archive/zip" @@ -40,7 +40,7 @@ func federationHandler() *Handler { Email: "admin@example.com", }, nil) mockAuth.grantAdmin() - h := NewHandler(HandlerConfig{ConfigStore: new(MockConfigStore), AuthService: mockAuth}) + h := NewHandler(&HandlerConfig{ConfigStore: new(MockConfigStore), AuthService: mockAuth}) mockSourceIdentity(h, defaultTestSourceIdentity()) return h } @@ -683,7 +683,7 @@ func TestGetFederationIaC_PreservesPlusInSessionEmail(t *testing.T) { Email: "user+tag@example.com", }, nil) mockAuth.grantAdmin() - h := NewHandler(HandlerConfig{ConfigStore: new(MockConfigStore), AuthService: mockAuth}) + h := NewHandler(&HandlerConfig{ConfigStore: new(MockConfigStore), AuthService: mockAuth}) // Pre-warm the source identity to satisfy validateSourceIdentity (#41). mockSourceIdentity(h, defaultTestSourceIdentity()) @@ -710,7 +710,7 @@ func TestGetFederationIaC_NoSessionEmail_ShipsBundleWithEmptyContact(t *testing. Email: "", // empty — admin API key path }, nil) mockAuth.grantAdmin() - h := NewHandler(HandlerConfig{ConfigStore: new(MockConfigStore), AuthService: mockAuth}) + h := NewHandler(&HandlerConfig{ConfigStore: new(MockConfigStore), AuthService: mockAuth}) // Pre-warm the source identity to satisfy validateSourceIdentity (#41). mockSourceIdentity(h, defaultTestSourceIdentity()) diff --git a/internal/api/handler_groups.go b/internal/api/handler_groups.go index eb964f0df..c7ebeaac8 100644 --- a/internal/api/handler_groups.go +++ b/internal/api/handler_groups.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" diff --git a/internal/api/handler_groups_test.go b/internal/api/handler_groups_test.go index 1a49d4969..07d7614d7 100644 --- a/internal/api/handler_groups_test.go +++ b/internal/api/handler_groups_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_history.go b/internal/api/handler_history.go index fa1344d16..b80f48419 100644 --- a/internal/api/handler_history.go +++ b/internal/api/handler_history.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" diff --git a/internal/api/handler_history_test.go b/internal/api/handler_history_test.go index 253fbca4b..7d1ff7451 100644 --- a/internal/api/handler_history_test.go +++ b/internal/api/handler_history_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -1821,8 +1821,12 @@ 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 + // After: same rows plus one canceled execution (the pending that got + // canceled). Build on a fresh slice so the append never aliases baseline's + // backing array (which the before-snapshot above still reflects). + withCancelled := make([]config.PurchaseHistoryRecord, 0, len(baseline)+1) + withCancelled = append(withCancelled, baseline...) + withCancelled = append(withCancelled, config.PurchaseHistoryRecord{ Status: "cancelled", UpfrontCost: 999.0, EstimatedSavings: 99.0, diff --git a/internal/api/handler_inventory.go b/internal/api/handler_inventory.go index 8d1f99878..e42d4f438 100644 --- a/internal/api/handler_inventory.go +++ b/internal/api/handler_inventory.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" @@ -52,11 +52,12 @@ func (h *Handler) listActiveCommitments(ctx context.Context, req *events.LambdaF now := time.Now() commitments := make([]InventoryCommitment, 0, len(purchases)) - for _, p := range purchases { + for i := range purchases { + p := &purchases[i] if !isActiveCommitment(p, now) { continue } - commitments = append(commitments, buildInventoryCommitment(p, nameByID[p.AccountID])) + commitments = append(commitments, buildInventoryCommitment(*p, nameByID[p.AccountID])) } // Soonest-expiring first. The dashboard framing is "what do I need to @@ -136,7 +137,7 @@ func buildInventoryCommitment(p config.PurchaseHistoryRecord, accountName string TermYears: p.Term, PaymentOption: p.Payment, StartDate: p.Timestamp, - EndDate: commitmentExpiry(p), + EndDate: commitmentExpiry(&p), UpfrontCost: p.UpfrontCost, MonthlyCost: p.MonthlyCost, EstimatedSavings: p.EstimatedSavings, @@ -184,11 +185,12 @@ func (h *Handler) getCoverageBreakdown(ctx context.Context, req *events.LambdaFu // 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 i := range purchases { + p := &purchases[i] if !isActiveCommitment(p, now) { continue } - coveredByKey[p.Provider+":"+p.Service] += commitmentCoveredMonthly(p) + coveredByKey[p.Provider+":"+p.Service] += commitmentCoveredMonthly(*p) } // --- on-demand gap: recommendations ------------------------------------- @@ -227,8 +229,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} } diff --git a/internal/api/handler_inventory_test.go b/internal/api/handler_inventory_test.go index 68d9e2764..941398ed1 100644 --- a/internal/api/handler_inventory_test.go +++ b/internal/api/handler_inventory_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -313,14 +313,14 @@ func TestHandler_isActiveCommitment_Predicate(t *testing.T) { // on the calendar day, so on a leap-year boundary the predicate // returns true (active) — we accept that; the dashboard's aggregate // uses the same arithmetic. - assert.True(t, isActiveCommitment(p, now.Add(-time.Hour)), + assert.True(t, isActiveCommitment(&p, now.Add(-time.Hour)), "a commitment one hour before its expiry must still be active") expired := config.PurchaseHistoryRecord{ Timestamp: now.AddDate(-2, 0, 0), Term: 1, } - assert.False(t, isActiveCommitment(expired, now), + assert.False(t, isActiveCommitment(&expired, now), "a commitment whose term ended a year ago must be inactive") } @@ -447,7 +447,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, &config.RecommendationFilter{}).Return(recs, nil) mockAuth, req := adminInventoryReq(ctx) handler := &Handler{auth: mockAuth, config: mockStore, scheduler: mockScheduler} @@ -541,7 +541,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) @@ -650,7 +650,7 @@ func TestHandler_getCoverageBreakdown_AzureAllUpfrontConsistency(t *testing.T) { // No Azure on-demand recommendations: the only signal for Azure is the // covered commitment. Pre-fix this yields nil/zero coverage; post-fix the // amortised upfront makes Azure 100% covered for compute. - mockScheduler.On("ListRecommendations", ctx, config.RecommendationFilter{}).Return([]config.RecommendationRecord{}, nil) + mockScheduler.On("ListRecommendations", ctx, &config.RecommendationFilter{}).Return([]config.RecommendationRecord{}, nil) mockAuth, req := adminInventoryReq(ctx) handler := &Handler{auth: mockAuth, config: mockStore, scheduler: mockScheduler} diff --git a/internal/api/handler_oidc.go b/internal/api/handler_oidc.go index fea1604bb..99e67c89e 100644 --- a/internal/api/handler_oidc.go +++ b/internal/api/handler_oidc.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_oidc_test.go b/internal/api/handler_oidc_test.go index 50fe8800d..d9ad8540d 100644 --- a/internal/api/handler_oidc_test.go +++ b/internal/api/handler_oidc_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_per_account_perms_test.go b/internal/api/handler_per_account_perms_test.go index 1090a9dff..6b48a005f 100644 --- a/internal/api/handler_per_account_perms_test.go +++ b/internal/api/handler_per_account_perms_test.go @@ -1,4 +1,4 @@ -package api +package apihttp // TestPerAccountPerms is the comprehensive regression suite for per-account // permission scoping across every endpoint that returns or accepts @@ -838,7 +838,7 @@ func TestPerAccountPerms_CoverageBreakdown_RecsFilteredByAllowedAccounts(t *test } mockSched := new(MockScheduler) - mockSched.On("ListRecommendations", ctx, config.RecommendationFilter{}). + mockSched.On("ListRecommendations", ctx, &config.RecommendationFilter{}). 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, &config.RecommendationFilter{}). 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..86a210eb5 100644 --- a/internal/api/handler_plans.go +++ b/internal/api/handler_plans.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" diff --git a/internal/api/handler_plans_test.go b/internal/api/handler_plans_test.go index 8cf2366f8..e95217a71 100644 --- a/internal/api/handler_plans_test.go +++ b/internal/api/handler_plans_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index fd6e99fe7..50044fd0a 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" diff --git a/internal/api/handler_purchases_guards_test.go b/internal/api/handler_purchases_guards_test.go index eb8c09ecf..53fbb5a63 100644 --- a/internal/api/handler_purchases_guards_test.go +++ b/internal/api/handler_purchases_guards_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_purchases_revoke.go b/internal/api/handler_purchases_revoke.go index f0f614196..a710d2c73 100644 --- a/internal/api/handler_purchases_revoke.go +++ b/internal/api/handler_purchases_revoke.go @@ -1,4 +1,4 @@ -package api +package apihttp // handler_purchases_revoke.go implements POST /api/purchases/{purchaseId}/revoke // which lets a session-authenticated user revoke a completed purchase while it diff --git a/internal/api/handler_purchases_revoke_test.go b/internal/api/handler_purchases_revoke_test.go index 678b3ba02..701f4e29a 100644 --- a/internal/api/handler_purchases_revoke_test.go +++ b/internal/api/handler_purchases_revoke_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_purchases_test.go b/internal/api/handler_purchases_test.go index 115b48cf0..721d6a8ad 100644 --- a/internal/api/handler_purchases_test.go +++ b/internal/api/handler_purchases_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_recommendations.go b/internal/api/handler_recommendations.go index 943cc6782..49f5b3113 100644 --- a/internal/api/handler_recommendations.go +++ b/internal/api/handler_recommendations.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" @@ -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"], diff --git a/internal/api/handler_recommendations_refresh.go b/internal/api/handler_recommendations_refresh.go index ed5fd58ef..800764244 100644 --- a/internal/api/handler_recommendations_refresh.go +++ b/internal/api/handler_recommendations_refresh.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -8,6 +8,7 @@ import ( "time" "github.com/LeanerCloud/CUDly/internal/config" + "github.com/LeanerCloud/CUDly/pkg/logging" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -58,12 +59,6 @@ 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 { - return nil, fmt.Errorf("failed to read freshness: %w", err) - } - // Atomically mark collection as started. Returns false (409) if another // collection is already in flight (started_at set within the last 5 minutes). ok, err := h.config.MarkCollectionStarted(ctx) @@ -74,7 +69,9 @@ 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) + // runMarkedCollection re-reads freshness after the trigger, so the value + // used for the 202 body reflects the started_at this caller just recorded. + freshness, err := h.runMarkedCollection(ctx) if err != nil { return nil, err } @@ -107,7 +104,9 @@ func (h *Handler) runMarkedCollection(ctx context.Context) (*config.Recommendati schedulerARN := os.Getenv("SCHEDULER_LAMBDA_ARN") if schedulerARN != "" { if invokeErr := h.asyncInvokeSelf(ctx, schedulerARN); invokeErr != nil { - _ = h.config.ClearCollectionStarted(ctx) + if clearErr := h.config.ClearCollectionStarted(ctx); clearErr != nil { + logging.Warnf("runMarkedCollection: failed to clear collection started marker: %v", clearErr) + } return nil, fmt.Errorf("failed to trigger async collection: %w", invokeErr) } freshness, err := h.config.GetRecommendationsFreshness(ctx) @@ -130,7 +129,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 { @@ -156,10 +155,14 @@ func (h *Handler) asyncInvokeSelf(ctx context.Context, functionARN string) error // internal/server/lambda.go (which checks Source == "aws.events" || // Action != "") classifies this consistently with EventBridge cron // deliveries that already exercise this code path. - payload, _ := json.Marshal(map[string]string{ + payload, marshalErr := json.Marshal(map[string]string{ "source": "aws.events", "action": "collect_recommendations", }) + if marshalErr != nil { + // This is a programming error: a const map[string]string cannot fail to marshal. + return fmt.Errorf("asyncInvokeSelf: marshal payload: %w", marshalErr) + } _, err = invoker.Invoke(ctx, &lambda.InvokeInput{ FunctionName: aws.String(functionARN), @@ -187,44 +190,3 @@ func (h *Handler) getLambdaInvoker(ctx context.Context) (LambdaInvokerInterface, } return lambda.NewFromConfig(h.awsCfg), nil } - -// triggerColdStartCollect is the GET /api/recommendations cold-start path. -// It is called from ListRecommendations when last_collected_at is nil AND -// last_collection_started_at is nil (no collection running). It fires an -// async self-invoke (Lambda mode) or a synchronous collect (HTTP mode) and -// returns the freshness state after the trigger so the caller can return an -// empty list to the user with the correct "collecting" indicator. -// -// The returned freshness may have LastCollectionStartedAt set (async) or -// LastCollectedAt set (sync). Callers should treat a non-nil -// LastCollectionStartedAt as "collection in progress". -func (h *Handler) triggerColdStartCollect(ctx context.Context) (*config.RecommendationsFreshness, error) { - schedulerARN := os.Getenv("SCHEDULER_LAMBDA_ARN") - if schedulerARN != "" { - // Atomic mark. ok=false means another caller already marked it — we - // MUST NOT trigger a second async invoke or call ClearCollectionStarted - // (that would wipe the other caller's in-flight marker). Returning the - // current freshness lets the caller see the in-flight collection and - // poll for completion. - ok, err := h.config.MarkCollectionStarted(ctx) - if err != nil { - return nil, fmt.Errorf("failed to mark cold-start collection: %w", err) - } - if !ok { - return h.config.GetRecommendationsFreshness(ctx) - } - if invokeErr := h.asyncInvokeSelf(ctx, schedulerARN); invokeErr != nil { - // Roll back ONLY because we own the marker (ok==true above). - _ = h.config.ClearCollectionStarted(ctx) - return nil, fmt.Errorf("failed to trigger cold-start collect: %w", invokeErr) - } - // Re-read freshness to return the started_at value. - return h.config.GetRecommendationsFreshness(ctx) - } - - // HTTP / non-Lambda mode: synchronous collect. - if _, err := h.scheduler.CollectRecommendations(ctx); err != nil { - return nil, fmt.Errorf("cold-start collect failed: %w", err) - } - return h.config.GetRecommendationsFreshness(ctx) -} diff --git a/internal/api/handler_recommendations_test.go b/internal/api/handler_recommendations_test.go index 8fabe8521..397711d74 100644 --- a/internal/api/handler_recommendations_test.go +++ b/internal/api/handler_recommendations_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -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) }) diff --git a/internal/api/handler_registrations.go b/internal/api/handler_registrations.go index 7969cf1fb..ee382cea5 100644 --- a/internal/api/handler_registrations.go +++ b/internal/api/handler_registrations.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -201,7 +201,10 @@ func (h *Handler) getPendingRegistration(ctx context.Context, id string) (*confi func (h *Handler) setReviewMetadata(ctx context.Context, reg *config.AccountRegistration, httpReq *events.LambdaFunctionURLRequest) { reviewedAt := time.Now() reg.ReviewedAt = &reviewedAt - session, _ := h.requireAdmin(ctx, httpReq) + session, err := h.requireAdmin(ctx, httpReq) + if err != nil { + logging.Warnf("setReviewMetadata: failed to get admin session for reviewer attribution: %v", err) + } if session != nil { reg.ReviewedBy = &session.UserID } @@ -234,11 +237,17 @@ 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) { if h.emailNotifier == nil || reg.ContactEmail == "" { return } - if err := h.emailNotifier.SendRegistrationDecisionNotification(context.Background(), reg.ContactEmail, data); err != nil { + // Best-effort, fire-after-commit notification: the registration state has + // already changed, so bound the synchronous send with its own timeout + // (not the request ctx, which may already be done) so a stalled notifier + // can never hold the approval/rejection path open indefinitely. + notifyCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := h.emailNotifier.SendRegistrationDecisionNotification(notifyCtx, reg.ContactEmail, *data); err != nil { logging.Warnf("failed to send registration decision notification: %v", err) } } @@ -256,7 +265,7 @@ func (h *Handler) approveRegistration(ctx context.Context, httpReq *events.Lambd if err := json.Unmarshal([]byte(httpReq.Body), &acctReq); err != nil { return nil, NewClientError(400, "invalid account request body") } - if err := validateCloudAccountRequest(acctReq); err != nil { + if err := validateCloudAccountRequest(&acctReq); err != nil { return nil, err } @@ -273,7 +282,7 @@ func (h *Handler) approveRegistration(ctx context.Context, httpReq *events.Lambd // Create the cloud account (only one request reaches here). now := time.Now() - account := cloudAccountFromRequest(acctReq) + account := cloudAccountFromRequest(&acctReq) account.ID = uuid.New().String() // Auto-enable when the operator either embedded a credential in // the registration (legacy key-based flow) OR when the account @@ -300,7 +309,7 @@ func (h *Handler) approveRegistration(ctx context.Context, httpReq *events.Lambd logging.Warnf("registration %s approved but failed to link cloud_account_id: %v", reg.ID, err) } - h.notifyRegistrant(reg, email.RegistrationDecisionData{ + h.notifyRegistrant(reg, &email.RegistrationDecisionData{ AccountName: reg.AccountName, Provider: reg.Provider, ExternalID: reg.ExternalID, Decision: "approved", }) @@ -361,7 +370,7 @@ func (h *Handler) rejectRegistration(ctx context.Context, httpReq *events.Lambda return nil, fmt.Errorf("registrations: transition: %w", err) } - h.notifyRegistrant(reg, email.RegistrationDecisionData{ + h.notifyRegistrant(reg, &email.RegistrationDecisionData{ AccountName: reg.AccountName, Provider: reg.Provider, ExternalID: reg.ExternalID, Decision: "rejected", RejectionReason: body.Reason, @@ -455,7 +464,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 @@ -464,7 +473,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) @@ -494,7 +503,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..62a7955c0 100644 --- a/internal/api/handler_registrations_autoenable_test.go +++ b/internal/api/handler_registrations_autoenable_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "testing" diff --git a/internal/api/handler_registrations_recipients_test.go b/internal/api/handler_registrations_recipients_test.go index 35de95b54..e1af885b7 100644 --- a/internal/api/handler_registrations_recipients_test.go +++ b/internal/api/handler_registrations_recipients_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_registrations_validate_test.go b/internal/api/handler_registrations_validate_test.go index 8237431d4..73cad1fdf 100644 --- a/internal/api/handler_registrations_validate_test.go +++ b/internal/api/handler_registrations_validate_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "strings" diff --git a/internal/api/handler_ri_exchange.go b/internal/api/handler_ri_exchange.go index 2f4d72250..0266091fd 100644 --- a/internal/api/handler_ri_exchange.go +++ b/internal/api/handler_ri_exchange.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_ri_exchange_integration_test.go b/internal/api/handler_ri_exchange_integration_test.go index 865ddb666..23161279c 100644 --- a/internal/api/handler_ri_exchange_integration_test.go +++ b/internal/api/handler_ri_exchange_integration_test.go @@ -1,7 +1,7 @@ //go:build integration // +build integration -package api +package apihttp import ( "context" diff --git a/internal/api/handler_ri_exchange_test.go b/internal/api/handler_ri_exchange_test.go index 250180c49..8bd1382d7 100644 --- a/internal/api/handler_ri_exchange_test.go +++ b/internal/api/handler_ri_exchange_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -615,11 +615,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{ diff --git a/internal/api/handler_router.go b/internal/api/handler_router.go index d114662de..302dc5e49 100644 --- a/internal/api/handler_router.go +++ b/internal/api/handler_router.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_router_test.go b/internal/api/handler_router_test.go index ffa655d58..e88a03e5b 100644 --- a/internal/api/handler_router_test.go +++ b/internal/api/handler_router_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_security_test.go b/internal/api/handler_security_test.go index bf96f9b4a..555ad1c9d 100644 --- a/internal/api/handler_security_test.go +++ b/internal/api/handler_security_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index e81896455..ad22136b0 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -37,21 +37,30 @@ func TestNewHandler(t *testing.T) { EnableDashboard: true, } - handler := NewHandler(cfg) + handler := NewHandler(&cfg) assert.NotNil(t, handler) } +func TestNewHandler_NilConfigPanics(t *testing.T) { + // A nil config is a programming error: building a Handler with every + // dependency unset would only surface as a confusing nil deref on the + // first request, so NewHandler fails loud at construction. + assert.PanicsWithValue(t, + "apihttp: NewHandler requires a non-nil *HandlerConfig", + func() { NewHandler(nil) }) +} + func TestNewHandler_CORSDefault(t *testing.T) { // Test that empty CORS origin defaults to empty (no CORS headers) - handler := NewHandler(HandlerConfig{}) + handler := NewHandler(&HandlerConfig{}) assert.Equal(t, "", handler.corsAllowedOrigin) } func TestNewHandler_CORSCustom(t *testing.T) { // Test that custom CORS origin is used customOrigin := "https://myapp.example.com" - handler := NewHandler(HandlerConfig{ + handler := NewHandler(&HandlerConfig{ CORSAllowedOrigin: customOrigin, }) assert.Equal(t, customOrigin, handler.corsAllowedOrigin) diff --git a/internal/api/handler_users.go b/internal/api/handler_users.go index 36befda8a..5127389d7 100644 --- a/internal/api/handler_users.go +++ b/internal/api/handler_users.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" diff --git a/internal/api/handler_users_test.go b/internal/api/handler_users_test.go index b170735f6..320da6b60 100644 --- a/internal/api/handler_users_test.go +++ b/internal/api/handler_users_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_version.go b/internal/api/handler_version.go index fdc256d34..482eb31c0 100644 --- a/internal/api/handler_version.go +++ b/internal/api/handler_version.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/handler_version_test.go b/internal/api/handler_version_test.go index 16f74a23e..ef11ced75 100644 --- a/internal/api/handler_version_test.go +++ b/internal/api/handler_version_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/health.go b/internal/api/health.go index f112df25f..8af647ab5 100644 --- a/internal/api/health.go +++ b/internal/api/health.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/health_test.go b/internal/api/health_test.go index 191b174f8..832ba475f 100644 --- a/internal/api/health_test.go +++ b/internal/api/health_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/inmemory_rate_limiter.go b/internal/api/inmemory_rate_limiter.go index 7a36f7eef..8e8420714 100644 --- a/internal/api/inmemory_rate_limiter.go +++ b/internal/api/inmemory_rate_limiter.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" diff --git a/internal/api/inmemory_rate_limiter_test.go b/internal/api/inmemory_rate_limiter_test.go index 3e39a9de7..5c8be5d31 100644 --- a/internal/api/inmemory_rate_limiter_test.go +++ b/internal/api/inmemory_rate_limiter_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/middleware.go b/internal/api/middleware.go index c89797d9c..17d340b79 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 9b4e7312f..6a2017bad 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/mocks_test.go b/internal/api/mocks_test.go index 372bb9f09..6a37c3986 100644 --- a/internal/api/mocks_test.go +++ b/internal/api/mocks_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -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) diff --git a/internal/api/rate_limiter.go b/internal/api/rate_limiter.go index 0fda787f6..9cfe45775 100644 --- a/internal/api/rate_limiter.go +++ b/internal/api/rate_limiter.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "time" diff --git a/internal/api/rate_limiter_test.go b/internal/api/rate_limiter_test.go index 85242a1a6..182fbae7f 100644 --- a/internal/api/rate_limiter_test.go +++ b/internal/api/rate_limiter_test.go @@ -1,4 +1,4 @@ -package api +package apihttp // Legacy RateLimiter/newRateLimiter/rateLimitEntry were removed (02-N1). // Tests for getDefaultRateLimits and assertRateLimitKeysKnown live in diff --git a/internal/api/ri_utilization_cache.go b/internal/api/ri_utilization_cache.go index e77a22276..efc736325 100644 --- a/internal/api/ri_utilization_cache.go +++ b/internal/api/ri_utilization_cache.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -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() { @@ -155,18 +155,24 @@ func (c *riUtilizationCache) kickBackgroundRefresh(key, region string, lookbackD } }() - _, _, _ = c.sf.Do(key, func() (any, error) { + // The callback logs and swallows its own errors (returning nil), so + // the singleflight result carries no actionable error. Capture the + // outer error anyway and log defensively rather than blank-discarding + // it, so a future callback change that returns an error is not lost. + if _, err, _ := c.sf.Do(key, func() (any, error) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() data, err := fetch(ctx, lookbackDays) if err != nil { logging.Warnf("ri_utilization_cache: background refresh failed (key=%s): %v", key, err) - return nil, err + return nil, nil } c.storePayload(ctx, region, lookbackDays, data) return nil, nil - }) + }); err != nil { + logging.Warnf("ri_utilization_cache: background refresh singleflight error (key=%s): %v", key, err) + } }() } diff --git a/internal/api/ri_utilization_cache_integration_test.go b/internal/api/ri_utilization_cache_integration_test.go index 28001143a..b3f91712e 100644 --- a/internal/api/ri_utilization_cache_integration_test.go +++ b/internal/api/ri_utilization_cache_integration_test.go @@ -1,7 +1,7 @@ //go:build integration // +build integration -package api +package apihttp import ( "context" diff --git a/internal/api/ri_utilization_cache_test.go b/internal/api/ri_utilization_cache_test.go index c0aaf811f..b70ccf3e1 100644 --- a/internal/api/ri_utilization_cache_test.go +++ b/internal/api/ri_utilization_cache_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/router.go b/internal/api/router.go index e41f9e406..26e2e56a0 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" @@ -724,7 +724,7 @@ func (r *Router) healthCheckHandler(ctx context.Context, req *events.LambdaFunct } func (r *Router) getPublicInfoHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { - return r.h.getPublicInfo(ctx, req) + return r.h.getPublicInfo(ctx, req), nil } func (r *Router) getVersionHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { @@ -732,7 +732,7 @@ func (r *Router) getVersionHandler(ctx context.Context, req *events.LambdaFuncti } func (r *Router) getDeploymentInfoHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { - return r.h.getDeploymentInfo(ctx, req) + return r.h.getDeploymentInfo(ctx, req), nil } func (r *Router) docsHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { diff --git a/internal/api/router_660_permission_flips_test.go b/internal/api/router_660_permission_flips_test.go index c56a0a51a..b29fd7a8e 100644 --- a/internal/api/router_660_permission_flips_test.go +++ b/internal/api/router_660_permission_flips_test.go @@ -1,4 +1,4 @@ -package api +package apihttp // Tests for PR-A of issue #660: mutating-route gate flip from AuthAdmin to // AuthUser + handler-level requirePermission as the real gate. diff --git a/internal/api/router_auth_test.go b/internal/api/router_auth_test.go index df1c99730..b1092ab4c 100644 --- a/internal/api/router_auth_test.go +++ b/internal/api/router_auth_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "testing" diff --git a/internal/api/router_authuser_test.go b/internal/api/router_authuser_test.go index 7e5571a85..0d3352311 100644 --- a/internal/api/router_authuser_test.go +++ b/internal/api/router_authuser_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/router_handlers_test.go b/internal/api/router_handlers_test.go index a0d326b69..4565b1ae6 100644 --- a/internal/api/router_handlers_test.go +++ b/internal/api/router_handlers_test.go @@ -1,4 +1,4 @@ -package api +package apihttp // router_handlers_test.go — tests for the thin router wrapper methods and // additional handler functions to push coverage to ≥ 80%. diff --git a/internal/api/scoping.go b/internal/api/scoping.go index 41edd8936..7335379e6 100644 --- a/internal/api/scoping.go +++ b/internal/api/scoping.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "context" diff --git a/internal/api/types.go b/internal/api/types.go index e88590339..0e29500e4 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "context" @@ -160,7 +160,8 @@ type PurchaseManagerInterface interface { // 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) + // A nil filter means "no filter" (match all); see config.StoreInterface. + 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 diff --git a/internal/api/types_apikeys.go b/internal/api/types_apikeys.go index c4c78c1e7..973ddd814 100644 --- a/internal/api/types_apikeys.go +++ b/internal/api/types_apikeys.go @@ -1,4 +1,4 @@ -package api +package apihttp import "time" diff --git a/internal/api/types_test.go b/internal/api/types_test.go index cbcf4330d..d662d6fa7 100644 --- a/internal/api/types_test.go +++ b/internal/api/types_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "testing" diff --git a/internal/api/validation.go b/internal/api/validation.go index a538db92e..e14f0227e 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -1,5 +1,5 @@ -// Package api provides the HTTP API handlers for the CUDly dashboard. -package api +// Package apihttp provides the HTTP API handlers for the CUDly dashboard. +package apihttp import ( "encoding/base64" @@ -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") @@ -204,7 +204,7 @@ func validateCredentialPayload(credentialType string, payload map[string]interfa if err := validateFlatPayload(credentialType, payload, gcpServiceAccountRequired, gcpServiceAccountOptional); err != nil { return err } - if t, _ := payload["type"].(string); t != "service_account" { + if !payloadTypeMatches(payload, "service_account") { return NewClientError(400, "gcp_service_account payload must have type=\"service_account\"") } return nil @@ -212,7 +212,7 @@ func validateCredentialPayload(credentialType string, payload map[string]interfa if err := validateGCPWIFPayload(payload); err != nil { return err } - if t, _ := payload["type"].(string); t != "external_account" { + if !payloadTypeMatches(payload, "external_account") { return NewClientError(400, "gcp_workload_identity_config payload must have type=\"external_account\"") } return nil @@ -222,6 +222,13 @@ func validateCredentialPayload(credentialType string, payload map[string]interfa return NewClientError(400, fmt.Sprintf("no payload schema defined for credential_type %q", credentialType)) } +// payloadTypeMatches returns true when payload["type"] is a non-empty string equal to want. +// A missing or non-string "type" field returns false. +func payloadTypeMatches(payload map[string]interface{}, want string) bool { + t, ok := payload["type"].(string) + return ok && t == want +} + // validateFlatPayload checks that every required key is present as a non-empty // string and that no key falls outside required+optional. func validateFlatPayload(credentialType string, payload map[string]interface{}, required, optional []string) error { @@ -304,18 +311,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 + maxDepth := 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 > maxDepth { + maxDepth = d } } } - return max + return maxDepth } -// 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 == "" { @@ -334,7 +341,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 == "" { @@ -353,7 +360,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, "..") { @@ -379,7 +386,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") @@ -398,7 +405,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 @@ -432,7 +439,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)) @@ -608,7 +615,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..044cf4f4a 100644 --- a/internal/api/validation_test.go +++ b/internal/api/validation_test.go @@ -1,4 +1,4 @@ -package api +package apihttp import ( "strings" diff --git a/internal/auth/service_apikeys.go b/internal/auth/service_apikeys.go index fcaced207..409b56859 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,8 +265,8 @@ func (s *Service) ValidateUserAPIKey(ctx context.Context, apiKey string) (*UserA return nil, nil, fmt.Errorf("invalid API key") } - if err := validateAPIKeyStatus(key); err != nil { - return nil, nil, err + if errX := validateAPIKeyStatus(key); errX != nil { + return nil, nil, errX } user, err := s.lookupAPIKeyUser(ctx, key.UserID) @@ -281,20 +281,25 @@ func (s *Service) ValidateUserAPIKey(ctx context.Context, apiKey string) (*UserA // unbounded number of goroutines (DoS amplifier on a revoked key). keyID := key.ID go func() { - _, _, _ = s.lastUsedSFG.Do(keyID, func() (any, error) { + // The callback logs and swallows its own error (returning nil), so the + // singleflight result carries no actionable error. Capture the outer + // error anyway and log defensively rather than blank-discarding it. + if _, err, _ := s.lastUsedSFG.Do(keyID, func() (any, error) { updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := s.UpdateLastUsed(updateCtx, keyID); err != nil { logging.Debugf("Failed to update API key last used timestamp for key %s: %v", keyID, err) } return nil, nil - }) + }); err != nil { + logging.Debugf("API key last-used singleflight error for key %s: %v", keyID, err) + } }() 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) } @@ -305,7 +310,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_mfa.go b/internal/auth/service_mfa.go index 2dc34d0c7..5f6b57e8e 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 // G505: HMAC-SHA1 is the RFC 6238 TOTP default algorithm, not used for a collision-sensitive purpose "crypto/subtle" "encoding/base32" "fmt" @@ -13,6 +13,7 @@ import ( "time" "github.com/LeanerCloud/CUDly/internal/config" + "github.com/LeanerCloud/CUDly/pkg/logging" "golang.org/x/crypto/bcrypt" ) @@ -85,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) @@ -265,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 // G117: field carries the freshly-generated TOTP secret returned to the enrolling user; transient, not persisted in plaintext or logged 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 — @@ -346,7 +347,9 @@ func (s *Service) validatePendingMFAEnrollment(ctx context.Context, user *User, if time.Now().After(*user.MFAPendingSecretExpiresAt) { user.MFAPendingSecret = "" user.MFAPendingSecretExpiresAt = nil - _ = s.store.UpdateUser(ctx, user) + if updateErr := s.store.UpdateUser(ctx, user); updateErr != nil { + logging.Warnf("service_mfa: failed to clear expired MFA enrollment for user: %v", updateErr) + } return fmt.Errorf("%w", ErrMFAEnrollmentExpired) } if !verifyTOTP(user.MFAPendingSecret, code) { @@ -377,8 +380,8 @@ 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 { - return nil, err + if errX := s.validatePendingMFAEnrollment(ctx, user, code); errX != nil { + return nil, errX } plaintext, hashes, err := s.generateAndHashRecoveryCodes() @@ -422,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/test_helpers.go b/internal/auth/test_helpers.go index 974d66c04..b4819a34e 100644 --- a/internal/auth/test_helpers.go +++ b/internal/auth/test_helpers.go @@ -10,7 +10,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 } @@ -20,7 +20,11 @@ func (m *MockStore) GetUserByID(ctx context.Context, userID string) (*User, erro if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*User), args.Error(1) + val, ok := args.Get(0).(*User) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockStore) GetUserByEmail(ctx context.Context, email string) (*User, error) { @@ -28,7 +32,11 @@ func (m *MockStore) GetUserByEmail(ctx context.Context, email string) (*User, er if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*User), args.Error(1) + val, ok := args.Get(0).(*User) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockStore) CreateUser(ctx context.Context, user *User) error { @@ -51,7 +59,11 @@ func (m *MockStore) ListUsers(ctx context.Context) ([]User, error) { if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]User), args.Error(1) + val, ok := args.Get(0).([]User) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockStore) GetUserByResetToken(ctx context.Context, token string) (*User, error) { @@ -59,7 +71,11 @@ func (m *MockStore) GetUserByResetToken(ctx context.Context, token string) (*Use if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*User), args.Error(1) + val, ok := args.Get(0).(*User) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockStore) AdminExists(ctx context.Context) (bool, error) { @@ -77,7 +93,11 @@ func (m *MockStore) GetGroup(ctx context.Context, groupID string) (*Group, error if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*Group), args.Error(1) + val, ok := args.Get(0).(*Group) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockStore) CreateGroup(ctx context.Context, group *Group) error { @@ -100,7 +120,11 @@ func (m *MockStore) ListGroups(ctx context.Context) ([]Group, error) { if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]Group), args.Error(1) + val, ok := args.Get(0).([]Group) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockStore) CountGroupMembers(ctx context.Context, groupID string) (int, error) { @@ -118,7 +142,11 @@ func (m *MockStore) GetSession(ctx context.Context, token string) (*Session, err if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*Session), args.Error(1) + val, ok := args.Get(0).(*Session) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockStore) DeleteSession(ctx context.Context, token string) error { @@ -136,7 +164,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) @@ -147,7 +175,11 @@ func (m *MockStore) GetAPIKeyByID(ctx context.Context, keyID string) (*UserAPIKe if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*UserAPIKey), args.Error(1) + val, ok := args.Get(0).(*UserAPIKey) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockStore) GetAPIKeyByHash(ctx context.Context, keyHash string) (*UserAPIKey, error) { @@ -155,7 +187,11 @@ func (m *MockStore) GetAPIKeyByHash(ctx context.Context, keyHash string) (*UserA if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*UserAPIKey), args.Error(1) + val, ok := args.Get(0).(*UserAPIKey) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockStore) ListAPIKeysByUser(ctx context.Context, userID string) ([]*UserAPIKey, error) { @@ -163,7 +199,11 @@ func (m *MockStore) ListAPIKeysByUser(ctx context.Context, userID string) ([]*Us if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]*UserAPIKey), args.Error(1) + val, ok := args.Get(0).([]*UserAPIKey) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockStore) UpdateAPIKey(ctx context.Context, key *UserAPIKey) error { @@ -186,7 +226,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 } @@ -206,10 +246,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 @@ -245,7 +285,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, @@ -257,7 +297,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/commitmentopts/store_postgres.go b/internal/commitmentopts/store_postgres.go index 9d1666092..b6925250c 100644 --- a/internal/commitmentopts/store_postgres.go +++ b/internal/commitmentopts/store_postgres.go @@ -2,9 +2,11 @@ package commitmentopts import ( "context" + "errors" "fmt" "github.com/LeanerCloud/CUDly/internal/database" + "github.com/LeanerCloud/CUDly/pkg/logging" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) @@ -95,8 +97,12 @@ func (s *PostgresStore) Save(ctx context.Context, combos []Combo, sourceAccountI if err != nil { return fmt.Errorf("begin tx: %w", err) } - // Rollback is a no-op after a successful Commit. - defer func() { _ = tx.Rollback(ctx) }() + // Rollback is a no-op after a successful Commit (pgx returns ErrTxClosed). + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + logging.Warnf("commitmentopts: rollback failed: %v", rbErr) + } + }() if _, err := tx.Exec(ctx, `INSERT INTO commitment_options_probe_runs (singleton, probed_at, source_account_id) diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go index a6ba6e43d..51f149e0e 100644 --- a/internal/config/interfaces.go +++ b/internal/config/interfaces.go @@ -245,7 +245,11 @@ 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 returns the stored recommendations matching + // filter. A nil filter means "no filter" (match every stored row), the + // same as passing &RecommendationFilter{}; implementations must accept nil + // without panicking. + 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/store_postgres.go b/internal/config/store_postgres.go index b078ad0de..31fdcb8e9 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -11,6 +11,7 @@ import ( "time" "github.com/LeanerCloud/CUDly/internal/database" + "github.com/LeanerCloud/CUDly/pkg/logging" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" @@ -25,24 +26,24 @@ 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) // ========================================== // GLOBAL CONFIGURATION // ========================================== -// GetGlobalConfig retrieves the global configuration +// GetGlobalConfig retrieves the global configuration. func (s *PostgresStore) GetGlobalConfig(ctx context.Context) (*GlobalConfig, error) { query := ` SELECT enabled_providers, notification_email, approval_required, @@ -85,7 +86,7 @@ func (s *PostgresStore) GetGlobalConfig(ctx context.Context) (*GlobalConfig, err ) if err != nil { - if err == pgx.ErrNoRows { + if errors.Is(err, pgx.ErrNoRows) { // Return default config if none exists. // Values must align with DefaultSettings in defaults.go and DB DEFAULT clauses. return &GlobalConfig{ @@ -120,7 +121,7 @@ func (s *PostgresStore) GetGlobalConfig(ctx context.Context) (*GlobalConfig, err return &config, nil } -// SaveGlobalConfig saves the global configuration +// SaveGlobalConfig saves the global configuration. func (s *PostgresStore) SaveGlobalConfig(ctx context.Context, config *GlobalConfig) error { // Ensure EnabledProviders is never nil (empty slice is ok, nil is not) if config.EnabledProviders == nil { @@ -228,7 +229,7 @@ func (s *PostgresStore) SaveGlobalConfig(ctx context.Context, config *GlobalConf // 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, @@ -259,7 +260,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) @@ -276,7 +277,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 ( @@ -330,7 +331,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 := ` @@ -390,7 +391,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 == "" { @@ -494,7 +495,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)) @@ -512,7 +513,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) @@ -622,7 +623,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` @@ -754,7 +755,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 @@ -771,6 +772,8 @@ func (s *PostgresStore) SavePurchaseExecution(ctx context.Context, execution *Pu // SavePurchaseExecution. Used from handlers that need to bundle the // execution insert with other writes (e.g. purchase_suppressions rows) // in a single atomic transaction. +// +//nolint:misspell // DB status 'cancelled' (status CHECK) and column cancelled_by (migration 000035); rename tracked in PR #1277 func (s *PostgresStore) SavePurchaseExecutionTx(ctx context.Context, tx pgx.Tx, execution *PurchaseExecution) error { // Generate execution ID if not provided if execution.ExecutionID == "" { @@ -897,6 +900,8 @@ func (s *PostgresStore) SavePurchaseExecutionTx(ctx context.Context, tx pgx.Tx, // the execution was not found or not in an allowed status. // 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(). +// +//nolint:misspell // DB status 'cancelled' (status CHECK) and column cancelled_by (migration 000035); rename tracked in PR #1277 func (s *PostgresStore) TransitionExecutionStatus(ctx context.Context, executionID string, fromStatuses []string, toStatus string, actor *string) (*PurchaseExecution, error) { query := ` UPDATE purchase_executions @@ -927,7 +932,7 @@ func (s *PostgresStore) TransitionExecutionStatus(ctx context.Context, execution return nil, fmt.Errorf("%w: execution %s", ErrNotFound, executionID) } // 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) } @@ -955,6 +960,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. +// +//nolint:misspell // DB status 'cancelled' (status CHECK) and column cancelled_by (migration 000035); rename tracked in PR #1277 func (s *PostgresStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (cancelled bool, currentStatus string, err error) { q := ` UPDATE purchase_executions @@ -1015,6 +1022,8 @@ func (s *PostgresStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, ex // Returns (true, "cancelled", nil) on success and (false, "", err) on a // real DB error. Must be called inside a WithTx block so the suppression // cleanup commits atomically with the status flip. +// +//nolint:misspell // DB status 'cancelled' (status CHECK) and column cancelled_by (migration 000035); rename tracked in PR #1277 func (s *PostgresStore) CancelScheduledExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (cancelled bool, currentStatus string, err error) { q := ` UPDATE purchase_executions @@ -1063,6 +1072,8 @@ func (s *PostgresStore) CancelScheduledExecutionAtomic(ctx context.Context, tx p // handler to merge pending/failed/expired rows alongside completed purchases // without changing the narrower GetPendingExecutions contract the scheduler // depends on. +// +//nolint:misspell // DB status 'cancelled' (status CHECK) and column cancelled_by (migration 000035); rename tracked in PR #1277 func (s *PostgresStore) GetExecutionsByStatuses(ctx context.Context, statuses []string, limit int) ([]PurchaseExecution, error) { if len(statuses) == 0 { return nil, nil @@ -1104,6 +1115,8 @@ func (s *PostgresStore) GetExecutionsByStatuses(ctx context.Context, statuses [] // scheduled_date. NULLS LAST is defensive: the schema makes scheduled_date // NOT NULL today, but the clause guards against a future relaxation silently // hiding rows at the top of the list. +// +//nolint:misspell // DB status 'cancelled' (status CHECK) and column cancelled_by (migration 000035); rename tracked in PR #1277 func (s *PostgresStore) GetPlannedExecutions(ctx context.Context, statuses []string, limit int) ([]PurchaseExecution, error) { if len(statuses) == 0 { return nil, nil @@ -1138,6 +1151,8 @@ func (s *PostgresStore) GetPlannedExecutions(ctx context.Context, statuses []str // updated_at is stamped to NOW() at the moment of the approved transition (see // TransitionExecutionStatus) and is not touched again unless the run finalizes, // so it is the age of the strand. Mirrors GetStaleProcessingExchanges. +// +//nolint:misspell // DB status 'cancelled' (status CHECK) and column cancelled_by (migration 000035); rename tracked in PR #1277 func (s *PostgresStore) GetStaleApprovedExecutions(ctx context.Context, olderThan time.Duration) ([]PurchaseExecution, error) { query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, @@ -1174,6 +1189,8 @@ func (s *PostgresStore) GetStaleApprovedExecutions(ctx context.Context, olderTha // olderThan is passed as a Postgres interval (seconds) so the comparison // happens server-side against NOW() — keeping the cutoff in the DB clock // avoids any drift between the API process and the database. +// +//nolint:misspell // DB status 'cancelled' (status CHECK) and column cancelled_by (migration 000035); rename tracked in PR #1277 func (s *PostgresStore) ListStuckExecutions(ctx context.Context, statuses []string, olderThan time.Duration) ([]PurchaseExecution, error) { if len(statuses) == 0 { return nil, nil @@ -1200,7 +1217,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. +// +//nolint:misspell // DB status 'cancelled' (status CHECK) and column cancelled_by (migration 000035); rename tracked in PR #1277 func (s *PostgresStore) GetPendingExecutions(ctx context.Context) ([]PurchaseExecution, error) { query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, @@ -1225,6 +1244,8 @@ func (s *PostgresStore) GetPendingExecutions(ctx context.Context) ([]PurchaseExe // Running the read inside the same transaction as the subsequent insert makes // duplicate-detection and execution creation atomic, closing the TOCTOU race // in executePurchase (issue #643). +// +//nolint:misspell // DB status 'cancelled' (status CHECK) and column cancelled_by (migration 000035); rename tracked in PR #1277 func (s *PostgresStore) GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) ([]PurchaseExecution, error) { const query = ` SELECT plan_id, execution_id, status, step_number, scheduled_date, @@ -1255,6 +1276,8 @@ func (s *PostgresStore) GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) ( // distinguish "not found" (nil execution, nil error) from a real DB failure // (nil execution, non-nil error). All callers must check the execution for // nil before use (closes issue #976). +// +//nolint:misspell // DB status 'cancelled' (status CHECK) and column cancelled_by (migration 000035); rename tracked in PR #1277 func (s *PostgresStore) GetExecutionByID(ctx context.Context, executionID string) (*PurchaseExecution, error) { query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, @@ -1281,7 +1304,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. +// +//nolint:misspell // DB status 'cancelled' (status CHECK) and column cancelled_by (migration 000035); rename tracked in PR #1277 func (s *PostgresStore) GetExecutionByPlanAndDate(ctx context.Context, planID string, scheduledDate time.Time) (*PurchaseExecution, error) { query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, @@ -1357,7 +1382,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 { @@ -1466,6 +1491,8 @@ func scanExecutionRows(rows pgx.Rows) ([]PurchaseExecution, error) { // Used by the Gmail-style pre-fire delay scheduler tick (issue #291 wave-2). // 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. +// +//nolint:misspell // DB status 'cancelled' (status CHECK) and column cancelled_by (migration 000035); rename tracked in PR #1277 func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]PurchaseExecution, error) { query := ` SELECT plan_id, execution_id, status, step_number, scheduled_date, @@ -1542,7 +1569,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 ( @@ -1582,7 +1609,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, @@ -1598,7 +1625,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, @@ -1654,7 +1681,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 } @@ -1940,7 +1967,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 @@ -2097,7 +2124,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() @@ -2161,7 +2188,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, @@ -2186,7 +2213,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, @@ -2211,7 +2238,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, @@ -2233,7 +2260,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(), @@ -2267,7 +2294,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 { @@ -2279,8 +2306,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() @@ -2303,7 +2330,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 @@ -2320,8 +2347,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 @@ -2340,7 +2367,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 @@ -2359,7 +2386,7 @@ func (s *PostgresStore) GetRIExchangeDailySpend(ctx context.Context, date time.T return total, nil } -// CancelAllPendingExchanges cancels all pending RI exchange records +// CancelAllPendingExchanges cancels all pending RI exchange records. func (s *PostgresStore) CancelAllPendingExchanges(ctx context.Context) (int64, error) { query := ` UPDATE ri_exchange_history @@ -2375,7 +2402,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, @@ -2391,7 +2418,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 { @@ -2555,7 +2582,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) @@ -2605,7 +2632,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) @@ -2681,10 +2708,15 @@ 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 + // Rollback is a no-op after a successful Commit (pgx returns ErrTxClosed). + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + logging.Warnf("config: DeleteCloudAccount rollback failed: %v", 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', @@ -2818,7 +2850,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) @@ -2872,7 +2904,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) @@ -2989,10 +3021,15 @@ 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 + // Rollback is a no-op after a successful Commit (pgx returns ErrTxClosed). + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + logging.Warnf("config: SetPlanAccounts rollback failed: %v", rbErr) + } + }() - if err = s.validatePlanAccountProvidersTx(ctx, tx, planID, accountIDs); err != nil { - return err + if errX := s.validatePlanAccountProvidersTx(ctx, tx, planID, accountIDs); errX != nil { + return errX } if _, err = tx.Exec(ctx, `DELETE FROM plan_accounts WHERE plan_id = $1`, planID); err != nil { @@ -3069,7 +3106,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{}{} @@ -3149,7 +3186,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 @@ -3158,12 +3195,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_recommendations.go b/internal/config/store_postgres_recommendations.go index ce3c3a722..901153445 100644 --- a/internal/config/store_postgres_recommendations.go +++ b/internal/config/store_postgres_recommendations.go @@ -3,11 +3,13 @@ package config import ( "context" "encoding/json" + "errors" "fmt" "math" "strings" "time" + "github.com/LeanerCloud/CUDly/pkg/logging" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) @@ -28,7 +30,12 @@ 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 + // Rollback is a no-op after a successful Commit (pgx returns ErrTxClosed). + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + logging.Warnf("config: ReplaceRecommendations rollback failed: %v", rbErr) + } + }() if _, err := tx.Exec(ctx, `DELETE FROM recommendations`); err != nil { return fmt.Errorf("failed to wipe recommendations: %w", err) @@ -75,7 +82,12 @@ 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 + // Rollback is a no-op after a successful Commit (pgx returns ErrTxClosed). + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + logging.Warnf("config: UpsertRecommendations rollback failed: %v", rbErr) + } + }() if err := insertRecommendationsBatched(ctx, tx, collectedAt, recs, true); err != nil { return err @@ -84,7 +96,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 +187,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 +244,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) (whereClause 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 +371,17 @@ 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) { +// +// A nil filter is treated as the empty filter (no conditions -> every stored +// recommendation), identical to passing &RecommendationFilter{}. This is not a +// security boundary: per-caller account scoping is applied separately at the +// handler layer (filterRecommendationsByAllowedAccounts), so an unfiltered read +// here cannot widen access. Normalizing once up front keeps the whole method +// (the SQL builder and the in-process MinSavingsPct check) nil-safe. +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 +465,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 eb0c57f6e..cb25095f2 100644 --- a/internal/config/store_postgres_recommendations_test.go +++ b/internal/config/store_postgres_recommendations_test.go @@ -55,7 +55,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) @@ -65,12 +65,38 @@ 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) } +// TestPostgresStore_ListStoredRecommendations_NilFilter guards the documented +// contract that a nil *RecommendationFilter is treated as the empty filter +// (match every stored row), identical to passing &RecommendationFilter{}, and +// never panics. This is the regression test for the nil deref the pointer-ized +// signature introduced. +func TestPostgresStore_ListStoredRecommendations_NilFilter(t *testing.T) { + ctx := context.Background() + store, cleanup := setupRecommendationsStore(ctx, t) + defer cleanup() + + now := time.Now().UTC().Truncate(time.Second) + rows := []config.RecommendationRecord{ + awsRec("a1", "ec2", "us-east-1", "m5.large", 10), + awsRec("a2", "rds", "us-east-1", "db.r5.large", 20), + } + require.NoError(t, store.ReplaceRecommendations(ctx, now, rows)) + + gotNil, err := store.ListStoredRecommendations(ctx, nil) + require.NoError(t, err) + gotEmpty, err := store.ListStoredRecommendations(ctx, &config.RecommendationFilter{}) + require.NoError(t, err) + + assert.Len(t, gotNil, len(rows), "nil filter must match all stored rows") + assert.ElementsMatch(t, gotEmpty, gotNil, "nil filter must behave identically to &RecommendationFilter{}") +} + func TestPostgresStore_UpsertRecommendations_PartialCollect(t *testing.T) { ctx := context.Background() store, cleanup := setupRecommendationsStore(ctx, t) @@ -99,7 +125,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") @@ -143,7 +169,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) @@ -164,17 +190,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) @@ -242,7 +268,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") @@ -296,7 +322,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{} @@ -347,7 +373,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_savings_filter_test.go b/internal/config/store_postgres_savings_filter_test.go index 13edfc6b3..ad93bb34d 100644 --- a/internal/config/store_postgres_savings_filter_test.go +++ b/internal/config/store_postgres_savings_filter_test.go @@ -199,13 +199,13 @@ 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}) + clause, args := buildRecommendationFilter(&RecommendationFilter{MinSavingsUSD: 0}) 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}) + clause, args := buildRecommendationFilter(&RecommendationFilter{MinSavingsUSD: 50}) assert.Contains(t, clause, "monthly_savings >= $") require.Len(t, args, 1) assert.Equal(t, float64(50), args[0]) @@ -213,13 +213,13 @@ func TestBuildRecommendationFilter_MinSavingsUSD(t *testing.T) { t.Run("MinSavingsPct zero 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}) + clause, args := buildRecommendationFilter(&RecommendationFilter{MinSavingsPct: 30}) 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{ + clause, args := buildRecommendationFilter(&RecommendationFilter{ MinSavingsUSD: 50, MinSavingsPct: 20, }) diff --git a/internal/credentials/cipher.go b/internal/credentials/cipher.go index ceeb7dda0..d4e3a5786 100644 --- a/internal/credentials/cipher.go +++ b/internal/credentials/cipher.go @@ -78,11 +78,15 @@ func LoadKey(ctx context.Context, resolver secrets.Resolver) (key []byte, source // migration command (cmd/rekey) which needs to detect rows encrypted under // it without going through the LoadKey env-var path. func DevKey() []byte { - k, _ := decodeHexKey(devKeyHex) + k, err := decodeHexKey(devKeyHex) + if err != nil { + // devKeyHex is a compile-time constant; this is a programming error. + panic(fmt.Sprintf("credentials: devKeyHex is invalid: %v", err)) + } return k } -func loadKey(ctx context.Context, resolver secrets.Resolver) ([]byte, string, error) { +func loadKey(ctx context.Context, resolver secrets.Resolver) (key []byte, source string, err error) { // 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 cd1c8cd48..05f1ce01a 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 // G117: field must carry the resolved Azure client secret to build the token credential; String() redacts it (never logged) } // 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 // G117: local unmarshal target for the stored Azure client secret; transient, not logged } if err := json.Unmarshal(raw, &payload); err != nil { return nil, fmt.Errorf("credentials: parse azure secret for account %s: %w", account.ID, err) @@ -405,10 +405,18 @@ func resolveGCPWIFCredential( opts GCPResolveOptions, ) (oauth2.TokenSource, error) { // Peek at the stored WIF JSON first — absent means this is a - // federated (secret-free) account. + // federated (secret-free) account. LoadRaw already maps not-found to + // (nil, nil), so any non-nil error here is a real store failure (DB + // connectivity, decrypt) that must be surfaced rather than silently + // collapsed to "no credential": otherwise a transient failure would + // silently fall back to the federated path and mask the outage. var raw []byte if store != nil { - raw, _ = store.LoadRaw(ctx, account.ID, CredTypeGCPWIFConfig) + var loadErr error + raw, loadErr = store.LoadRaw(ctx, account.ID, CredTypeGCPWIFConfig) + if loadErr != nil { + return nil, fmt.Errorf("credentials: load GCP WIF config for account %s: %w", account.ID, loadErr) + } } issuer := opts.IssuerURL @@ -443,9 +451,44 @@ 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) + // Detect the credential type from the JSON's "type" discriminator and load + // it with the type-pinned google loader. Both a service-account key + // ("service_account") and a workload-identity-federation config + // ("external_account") are accepted, since loadStoredGCPTokenSource serves + // both the gcp_service_account and legacy WIF credential paths. + credKind, err := detectGCPCredentialsType(raw) + if err != nil { + return nil, fmt.Errorf("credentials: detect gcp credential type for account %s: %w", account.ID, err) + } + creds, err := google.CredentialsFromJSONWithTypeAndParams(ctx, raw, credKind, google.CredentialsParams{ + Scopes: []string{gcpCloudPlatformScope}, + }) if err != nil { return nil, fmt.Errorf("credentials: parse gcp credentials for account %s: %w", account.ID, err) } return creds.TokenSource, nil } + +// detectGCPCredentialsType reads the "type" discriminator from a GCP credential +// JSON blob and maps it to the google.CredentialsType the type-pinned loader +// expects. Only the two kinds CUDly stores are accepted: a service-account key +// ("service_account") and a workload-identity-federation config +// ("external_account"). Any other type is rejected so an unexpected credential +// shape can never be loaded unintentionally. +func detectGCPCredentialsType(raw []byte) (google.CredentialsType, error) { + var header struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &header); err != nil { + return "", fmt.Errorf("parse credential type field: %w", err) + } + switch header.Type { + case string(google.ServiceAccount): + return google.ServiceAccount, nil + case string(google.ExternalAccount): + return google.ExternalAccount, nil + default: + return "", fmt.Errorf("unsupported gcp credential type %q (want %q or %q)", + header.Type, google.ServiceAccount, google.ExternalAccount) + } +} diff --git a/internal/credentials/resolver_extra_test.go b/internal/credentials/resolver_extra_test.go index 195094ab3..fd0757be2 100644 --- a/internal/credentials/resolver_extra_test.go +++ b/internal/credentials/resolver_extra_test.go @@ -271,7 +271,8 @@ func TestResolveGCPTokenSource_InvalidJSON(t *testing.T) { } _, err := ResolveGCPTokenSource(context.Background(), account, store) assert.Error(t, err) - assert.Contains(t, err.Error(), "parse gcp credentials") + // Invalid JSON is now rejected at the credential-type detection step. + assert.Contains(t, err.Error(), "detect gcp credential type") } // --------------------------------------------------------------------------- @@ -385,8 +386,9 @@ func TestResolveAzureTokenCredential_ManagedIdentity(t *testing.T) { func TestResolveGCPTokenSource_WIF_WithStoredConfig(t *testing.T) { // Exercises the WIF branch where CredTypeGCPWIFConfig is used as the key. - // The JSON is intentionally invalid so google.CredentialsFromJSON fails, - // but we cover the credType selection branch. + // The JSON is intentionally invalid so the loader rejects it at the + // credential-type detection step, but we still cover the credType + // selection branch. store := newMockStore() store.data["acct1/gcp_workload_identity_config"] = []byte("not valid json") @@ -396,7 +398,7 @@ func TestResolveGCPTokenSource_WIF_WithStoredConfig(t *testing.T) { } _, err := ResolveGCPTokenSource(context.Background(), account, store) assert.Error(t, err) - assert.Contains(t, err.Error(), "parse gcp credentials") + assert.Contains(t, err.Error(), "detect gcp credential type") } func TestResolveGCPTokenSource_ServiceAccountKey_ValidJSON(t *testing.T) { diff --git a/internal/credentials/resolver_test.go b/internal/credentials/resolver_test.go index 4256a2422..57252d76a 100644 --- a/internal/credentials/resolver_test.go +++ b/internal/credentials/resolver_test.go @@ -2,6 +2,7 @@ package credentials import ( "context" + "crypto/rsa" "encoding/json" "errors" "testing" @@ -362,7 +363,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", @@ -402,6 +403,54 @@ func TestResolveGCPCredentials_Success(t *testing.T) { assert.Equal(t, saJSON, result) } +// stubSigner is a minimal oidc.Signer used only to populate +// GCPResolveOptions.Signer so the federated path is reachable. Its methods are +// never invoked during externalaccount.NewTokenSource construction (token +// minting is lazy), so they can return sentinel errors. +type stubSigner struct{} + +func (stubSigner) Sign(_ context.Context, _ []byte) ([]byte, error) { + return nil, errors.New("stubSigner: Sign not implemented") +} + +func (stubSigner) PublicKey(_ context.Context) (*rsa.PublicKey, error) { + return nil, errors.New("stubSigner: PublicKey not implemented") +} + +func (stubSigner) KeyID(_ context.Context) (string, error) { + return "stub-kid", nil +} + +// TestResolveGCPWIF_LoadRawError_Surfaces is the regression test for the CR +// finding on PR #1265: resolveGCPWIFCredential previously swallowed any +// LoadRaw error by setting raw=nil and falling back to the federated path. +// Since LoadRaw maps not-found to (nil, nil), a non-nil error is a real store +// failure (DB connectivity, decrypt) that must be surfaced, not masked. +// +// The federated path is fully wired (signer + issuer + audience + email) so +// that the pre-fix code would route to BuildGCPFederatedCredential and return +// a non-nil TokenSource with a nil error, hiding the store outage. With the +// fix, the store error is surfaced before that branch is reached. +func TestResolveGCPWIF_LoadRawError_Surfaces(t *testing.T) { + store := newMockStore() + store.err = errors.New("postgres: connection refused") + + acct := &config.CloudAccount{ + ID: "acct1", + GCPAuthMode: "workload_identity_federation", + GCPWIFAudience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/p/providers/pr", + GCPClientEmail: "cudly@my-project.iam.gserviceaccount.com", + } + opts := GCPResolveOptions{ + Signer: stubSigner{}, + IssuerURL: "https://cudly.example.com", + } + + _, err := ResolveGCPTokenSourceWithOpts(context.Background(), acct, store, opts) + require.Error(t, err, "a real LoadRaw store error must be surfaced, not swallowed into the federated fallback") + assert.Contains(t, err.Error(), "connection refused") +} + func TestAWSCredentials_String_IsRedacted(t *testing.T) { c := &AWSCredentials{AccessKeyID: "AKIAIOSFODNN7EXAMPLE", SecretAccessKey: "secret"} assert.NotContains(t, c.String(), "AKIAIOSFODNN7EXAMPLE") diff --git a/internal/database/connection.go b/internal/database/connection.go index d4177d11c..ee69b6b93 100644 --- a/internal/database/connection.go +++ b/internal/database/connection.go @@ -17,7 +17,7 @@ import ( "github.com/jackc/pgx/v5/tracelog" ) -// Connection wraps a PostgreSQL connection pool +// Connection wraps a PostgreSQL connection pool. type Connection struct { pool *pgxpool.Pool config *Config @@ -27,14 +27,14 @@ type Connection struct { lockedConns sync.Map // map[int64]*pgxpool.Conn } -// SecretResolver interface for retrieving secrets from cloud providers +// SecretResolver interface for retrieving secrets from cloud providers. type SecretResolver interface { GetSecret(ctx context.Context, secretID string) (string, error) Close() error } // NewConnection creates a new database connection pool -// If secretResolver is provided and config.PasswordSecret is set, password will be retrieved from secret manager +// If secretResolver is provided and config.PasswordSecret is set, password will be retrieved from secret manager. func NewConnection(ctx context.Context, config *Config, secretResolver SecretResolver) (*Connection, error) { // Check if secret resolver is needed but not provided if config.PasswordSecret != "" && secretResolver == nil { @@ -190,7 +190,7 @@ func createConnectionPoolWithRetry(ctx context.Context, poolConfig *pgxpool.Conf return pool, nil } -// buildPoolConfig creates a pgxpool.Config from our Config +// buildPoolConfig creates a pgxpool.Config from our Config. func buildPoolConfig(config *Config, password string) (*pgxpool.Config, error) { // Parse a redacted DSN so that any pgxpool.ParseConfig error never // echoes the plaintext password into the error chain (pgconn.parseConfig @@ -205,18 +205,19 @@ 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) + // Set pool configuration. Bound both ends of the int32 range before the + // narrowing conversion so a misconfigured value can never silently wrap. + if config.MaxConnections < 0 || config.MaxConnections > math.MaxInt32 { + return nil, fmt.Errorf("MaxConnections value %d out of int32 range", config.MaxConnections) } - if config.MinConnections > math.MaxInt32 { - return nil, fmt.Errorf("MinConnections value %d exceeds int32 max", config.MinConnections) + if config.MinConnections < 0 || config.MinConnections > math.MaxInt32 { + return nil, fmt.Errorf("MinConnections value %d out of int32 range", config.MinConnections) } - poolConfig.MaxConns = int32(config.MaxConnections) - poolConfig.MinConns = int32(config.MinConnections) + poolConfig.MaxConns = int32(config.MaxConnections) //nolint:gosec // G115: bounded to [0, MaxInt32] by the guard immediately above + poolConfig.MinConns = int32(config.MinConnections) //nolint:gosec // G115: bounded to [0, MaxInt32] by the guard immediately above poolConfig.MaxConnLifetime = config.MaxConnLifetime poolConfig.MaxConnIdleTime = config.MaxConnIdleTime poolConfig.HealthCheckPeriod = config.HealthCheckPeriod @@ -238,17 +239,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 +268,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) } @@ -346,7 +352,11 @@ func (c *Connection) ReleaseAdvisoryLock(ctx context.Context, lockID int64) { logging.Warnf("ReleaseAdvisoryLock called for lock %d but no pinned connection found", lockID) return } - conn := val.(*pgxpool.Conn) + conn, ok := val.(*pgxpool.Conn) + if !ok { + logging.Warnf("ReleaseAdvisoryLock: unexpected value type in lockedConns for lock %d", lockID) + return + } defer conn.Release() var released bool @@ -357,7 +367,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": @@ -373,7 +383,7 @@ func parseLogLevel(level string) tracelog.LogLevel { } } -// stdLogger implements pgx tracelog.Logger using the logging package +// stdLogger implements pgx tracelog.Logger using the logging package. type stdLogger struct{} // isSensitiveKey reports whether a pgx data-map key should always be redacted. diff --git a/internal/database/coverage_extra_test.go b/internal/database/coverage_extra_test.go index fa395321f..42a0c2177 100644 --- a/internal/database/coverage_extra_test.go +++ b/internal/database/coverage_extra_test.go @@ -6,7 +6,6 @@ import ( "testing" "time" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -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/postgres/migrations/migrate.go b/internal/database/postgres/migrations/migrate.go index ea2cf3728..5624cb0ab 100644 --- a/internal/database/postgres/migrations/migrate.go +++ b/internal/database/postgres/migrations/migrate.go @@ -2,8 +2,10 @@ package migrations import ( "context" + "errors" "fmt" "log" + "math" "net/url" "os" "strconv" @@ -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 @@ -29,8 +31,8 @@ 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 { +// 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. @@ -41,13 +43,13 @@ func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath strin defer m.Close() // Run migrations - if err := m.Up(); err != nil && err != migrate.ErrNoChange { - return fmt.Errorf("failed to run migrations: %w", err) + if upErr := m.Up(); upErr != nil && !errors.Is(upErr, migrate.ErrNoChange) { + return fmt.Errorf("failed to run migrations: %w", upErr) } // Get current version version, dirty, err := m.Version() - if err != nil && err != migrate.ErrNilVersion { + if err != nil && !errors.Is(err, migrate.ErrNilVersion) { return fmt.Errorf("failed to get migration version: %w", err) } @@ -86,7 +88,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), @@ -131,7 +133,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 +185,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 +239,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 +374,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 +388,10 @@ 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 { + if version > math.MaxInt32 { + return fmt.Errorf("auto-heal: migration version %d exceeds the supported range", version) + } + if err := m.Force(int(version)); err != nil { //nolint:gosec // G115: version bounded to [0, MaxInt32] by the guard immediately above 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) @@ -413,6 +418,16 @@ func autoHealEnabled() bool { } // RollbackMigrations rolls back N migrations +// logMigrateVersion reads the current migration version and logs it before a rollback. +// ErrNilVersion (no migrations applied yet) is silently ignored. +func logMigrateVersion(m *migrate.Migrate, steps int) { + currentVersion, _, verErr := m.Version() + if verErr != nil && !errors.Is(verErr, migrate.ErrNilVersion) { + log.Printf("Warning: failed to read current migration version: %v", verErr) + } + log.Printf("Rolling back %d migration(s) from version %d...", steps, currentVersion) +} + func RollbackMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath string, steps int) error { if steps <= 0 { return fmt.Errorf("rollback steps must be positive, got %d", steps) @@ -422,7 +437,7 @@ func RollbackMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath return fmt.Errorf("refusing to rollback more than %d migrations at once (requested %d); use multiple calls for safety", maxRollbackSteps, steps) } - dsn := buildMigrateDSN(pool.Config(), "") + dsn := buildMigrateDSN(pool.Config()) m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), @@ -433,17 +448,15 @@ func RollbackMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath } defer m.Close() - // Log current version before rollback - currentVersion, _, _ := m.Version() - log.Printf("Rolling back %d migration(s) from version %d...", steps, currentVersion) + logMigrateVersion(m, steps) // Rollback steps - if err := m.Steps(-steps); err != nil && err != migrate.ErrNoChange { - return fmt.Errorf("failed to rollback migrations: %w", err) + if stepErr := m.Steps(-steps); stepErr != nil && !errors.Is(stepErr, migrate.ErrNoChange) { + return fmt.Errorf("failed to rollback migrations: %w", stepErr) } version, dirty, err := m.Version() - if err != nil && err != migrate.ErrNilVersion { + if err != nil && !errors.Is(err, migrate.ErrNilVersion) { return fmt.Errorf("failed to get migration version: %w", err) } @@ -462,7 +475,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), @@ -473,8 +486,8 @@ func MigrateToVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath st } defer m.Close() - 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() @@ -492,21 +505,21 @@ 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, newErr := migrate.New( fmt.Sprintf("file://%s", migrationsPath), dsn, ) - if err != nil { - return 0, false, fmt.Errorf("failed to create migrator: %w", err) + if newErr != nil { + return 0, false, fmt.Errorf("failed to create migrator: %w", newErr) } defer m.Close() - 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) } @@ -514,8 +527,9 @@ func GetMigrationVersion(ctx context.Context, pool *pgxpool.Pool, migrationsPath } // buildMigrateDSN builds a connection string for golang-migrate from pgx config. -// sslModeOverride, if non-empty, is used instead of inferring from TLSConfig. -func buildMigrateDSN(config *pgxpool.Config, sslModeOverride string) string { +// sslmode is inferred from the pgx TLS config: "require" when a TLS config is +// present, "disable" otherwise. +func buildMigrateDSN(config *pgxpool.Config) string { // Extract connection details from pgx config host := config.ConnConfig.Host port := config.ConnConfig.Port @@ -527,13 +541,10 @@ func buildMigrateDSN(config *pgxpool.Config, sslModeOverride string) string { encodedUser := url.QueryEscape(user) encodedPassword := url.QueryEscape(password) - // Use explicit sslmode if provided, otherwise infer from TLS config - sslMode := sslModeOverride - if sslMode == "" { - sslMode = "require" - if config.ConnConfig.TLSConfig == nil { - sslMode = "disable" - } + // Infer sslmode from the TLS config. + sslMode := "require" + if config.ConnConfig.TLSConfig == nil { + sslMode = "disable" } // Build DSN (golang-migrate uses postgres:// format) @@ -549,7 +560,7 @@ func buildMigrateDSN(config *pgxpool.Config, sslModeOverride string) string { ) } -// ValidateMigrationsPath checks if migrations directory exists +// ValidateMigrationsPath checks if migrations directory exists. func ValidateMigrationsPath(path string) error { info, err := os.Stat(path) if err != nil { diff --git a/internal/database/postgres/migrations/migrate_security_test.go b/internal/database/postgres/migrations/migrate_security_test.go index da73373ce..e67a20b27 100644 --- a/internal/database/postgres/migrations/migrate_security_test.go +++ b/internal/database/postgres/migrations/migrate_security_test.go @@ -122,7 +122,7 @@ func TestBuildMigrateDSN_PasswordNotInLogs(t *testing.T) { require.NoError(t, err, "pgxpool.ParseConfig must accept the sentinel DSN") // Call the function under test. - result := buildMigrateDSN(poolCfg, "") + result := buildMigrateDSN(poolCfg) // The sentinel must appear in the returned DSN (proves the function embeds it). assert.Contains(t, result, sentinelPassword, diff --git a/internal/database/postgres/testhelpers/postgres.go b/internal/database/postgres/testhelpers/postgres.go index 190e3781f..d13c5a014 100644 --- a/internal/database/postgres/testhelpers/postgres.go +++ b/internal/database/postgres/testhelpers/postgres.go @@ -13,14 +13,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() @@ -72,7 +72,9 @@ func SetupPostgresContainer(ctx context.Context, t *testing.T) (*PostgresContain // Create database connection db, err := database.NewConnection(ctx, config, nil) if err != nil { - postgresContainer.Terminate(ctx) + if termErr := postgresContainer.Terminate(ctx); termErr != nil { + t.Logf("failed to terminate postgres container after connection error: %v", termErr) + } return nil, fmt.Errorf("failed to connect to database: %w", err) } @@ -83,7 +85,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() @@ -94,7 +96,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 @@ -107,7 +109,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/email/coverage_test.go b/internal/email/coverage_test.go index e143d435c..bf15304b9 100644 --- a/internal/email/coverage_test.go +++ b/internal/email/coverage_test.go @@ -1022,7 +1022,7 @@ func TestSMTPSender_Port25NoTLS(t *testing.T) { // TestSender_VerificationPathWithEmailIdentityError tests the isEmailVerified error path in SendToEmail func TestSender_SendToEmail_GetEmailIdentityError(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) // Return sandbox mode mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: false}, nil) @@ -1289,7 +1289,7 @@ func TestSMTPSender_AllNotificationMethods_WithRealData(t *testing.T) { // TestSender_SendMethods_ErrorPaths tests error paths in Sender template methods func TestSender_SendMethods_ErrorPaths(t *testing.T) { - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.Anything).Return(nil, assert.AnError) sender := NewSenderWithClients(mockSNS, nil, SenderConfig{ @@ -1352,7 +1352,7 @@ func TestSender_SendMethods_ErrorPaths(t *testing.T) { // TestSender_SendToEmail_EmailVerificationCheck tests email verification check path func TestSender_SendToEmail_EmailVerificationCheck(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) // Test case: sandbox mode, verification check fails but we still try to verify mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). diff --git a/internal/email/sender_test.go b/internal/email/sender_test.go index 261b6dc48..378f6325c 100644 --- a/internal/email/sender_test.go +++ b/internal/email/sender_test.go @@ -12,12 +12,12 @@ import ( "github.com/stretchr/testify/require" ) -// MockSNSClient is a mock implementation of SNS client -type MockSNSClient struct { +// SNSClient is a mock implementation of SNS client. +type SNSClient struct { mock.Mock } -func (m *MockSNSClient) Publish(ctx context.Context, input *sns.PublishInput, opts ...func(*sns.Options)) (*sns.PublishOutput, error) { +func (m *SNSClient) Publish(ctx context.Context, input *sns.PublishInput, opts ...func(*sns.Options)) (*sns.PublishOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) @@ -25,12 +25,12 @@ 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 -type MockSESClient struct { +// SESClient is a mock implementation of SES client. +type SESClient struct { mock.Mock } -func (m *MockSESClient) SendEmail(ctx context.Context, input *sesv2.SendEmailInput, opts ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) { +func (m *SESClient) SendEmail(ctx context.Context, input *sesv2.SendEmailInput, opts ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) @@ -38,7 +38,7 @@ func (m *MockSESClient) SendEmail(ctx context.Context, input *sesv2.SendEmailInp return args.Get(0).(*sesv2.SendEmailOutput), args.Error(1) } -func (m *MockSESClient) GetAccount(ctx context.Context, input *sesv2.GetAccountInput, opts ...func(*sesv2.Options)) (*sesv2.GetAccountOutput, error) { +func (m *SESClient) GetAccount(ctx context.Context, input *sesv2.GetAccountInput, opts ...func(*sesv2.Options)) (*sesv2.GetAccountOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) @@ -46,7 +46,7 @@ func (m *MockSESClient) GetAccount(ctx context.Context, input *sesv2.GetAccountI return args.Get(0).(*sesv2.GetAccountOutput), args.Error(1) } -func (m *MockSESClient) GetEmailIdentity(ctx context.Context, input *sesv2.GetEmailIdentityInput, opts ...func(*sesv2.Options)) (*sesv2.GetEmailIdentityOutput, error) { +func (m *SESClient) GetEmailIdentity(ctx context.Context, input *sesv2.GetEmailIdentityInput, opts ...func(*sesv2.Options)) (*sesv2.GetEmailIdentityOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) @@ -54,7 +54,7 @@ func (m *MockSESClient) GetEmailIdentity(ctx context.Context, input *sesv2.GetEm return args.Get(0).(*sesv2.GetEmailIdentityOutput), args.Error(1) } -func (m *MockSESClient) CreateEmailIdentity(ctx context.Context, input *sesv2.CreateEmailIdentityInput, opts ...func(*sesv2.Options)) (*sesv2.CreateEmailIdentityOutput, error) { +func (m *SESClient) CreateEmailIdentity(ctx context.Context, input *sesv2.CreateEmailIdentityInput, opts ...func(*sesv2.Options)) (*sesv2.CreateEmailIdentityOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) @@ -65,13 +65,13 @@ func (m *MockSESClient) CreateEmailIdentity(ctx context.Context, input *sesv2.Cr // testSender creates a sender with mock clients for testing type testSender struct { *Sender - mockSNS *MockSNSClient - mockSES *MockSESClient + mockSNS *SNSClient + mockSES *SESClient } func newTestSender(topicARN, fromEmail string) *testSender { - mockSNS := new(MockSNSClient) - mockSES := new(MockSESClient) + mockSNS := new(SNSClient) + mockSES := new(SESClient) return &testSender{ Sender: &Sender{ @@ -208,7 +208,7 @@ func TestTemplates_NewRecommendations(t *testing.T) { } // Create sender with mock that expects the call - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). Return(&sns.PublishOutput{MessageId: aws.String("msg-123")}, nil).Once() @@ -369,7 +369,7 @@ func TestTemplateContents(t *testing.T) { } func TestSender_SendNotification_Success(t *testing.T) { - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). Return(&sns.PublishOutput{MessageId: aws.String("msg-123")}, nil) @@ -398,7 +398,7 @@ func TestSender_SendNotification_NilClient(t *testing.T) { } func TestSender_SendNotification_Error(t *testing.T) { - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). Return(nil, assert.AnError) @@ -414,7 +414,7 @@ func TestSender_SendNotification_Error(t *testing.T) { } func TestSender_SendToEmail_Success(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) // GetAccount is called to check sandbox mode - return production mode (not sandbox) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) @@ -446,7 +446,7 @@ func TestSender_SendToEmail_NilClient(t *testing.T) { } func TestSender_SendToEmail_Error(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) // GetAccount is called first - return production mode (not sandbox) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) @@ -465,8 +465,8 @@ func TestSender_SendToEmail_Error(t *testing.T) { } func TestNewSenderWithClients(t *testing.T) { - mockSNS := new(MockSNSClient) - mockSES := new(MockSESClient) + mockSNS := new(SNSClient) + mockSES := new(SESClient) cfg := SenderConfig{ TopicARN: "arn:aws:sns:us-east-1:123456789012:topic", @@ -503,7 +503,7 @@ func TestNewSender_Success(t *testing.T) { // Test SendToEmail sandbox mode flows func TestSender_SendToEmail_SandboxModeVerified(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) // GetAccount returns sandbox mode (ProductionAccessEnabled = false) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: false}, nil) @@ -525,7 +525,7 @@ func TestSender_SendToEmail_SandboxModeVerified(t *testing.T) { } func TestSender_SendToEmail_SandboxModeNotVerified(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) // GetAccount returns sandbox mode mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: false}, nil) @@ -550,7 +550,7 @@ func TestSender_SendToEmail_SandboxModeNotVerified(t *testing.T) { } func TestSender_SendToEmail_SandboxCheckError(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) // GetAccount fails mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(nil, assert.AnError) @@ -571,7 +571,7 @@ func TestSender_SendToEmail_SandboxCheckError(t *testing.T) { } func TestSender_SendToEmail_EmailIdentityNotFound(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) // GetAccount returns sandbox mode mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: false}, nil) @@ -597,7 +597,7 @@ func TestSender_SendToEmail_EmailIdentityNotFound(t *testing.T) { } func TestSender_SendToEmail_CreateVerificationError(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) // GetAccount returns sandbox mode mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: false}, nil) @@ -635,7 +635,7 @@ func TestSender_isInSandbox_NilClient(t *testing.T) { } func TestSender_isInSandbox_ProductionMode(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) @@ -652,7 +652,7 @@ func TestSender_isInSandbox_ProductionMode(t *testing.T) { } func TestSender_isInSandbox_SandboxMode(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: false}, nil) @@ -682,7 +682,7 @@ func TestSender_isEmailVerified_NilClient(t *testing.T) { } func TestSender_isEmailVerified_Verified(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("GetEmailIdentity", mock.Anything, mock.AnythingOfType("*sesv2.GetEmailIdentityInput")). Return(&sesv2.GetEmailIdentityOutput{VerifiedForSendingStatus: true}, nil) @@ -699,7 +699,7 @@ func TestSender_isEmailVerified_Verified(t *testing.T) { } func TestSender_isEmailVerified_NotVerified(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("GetEmailIdentity", mock.Anything, mock.AnythingOfType("*sesv2.GetEmailIdentityInput")). Return(&sesv2.GetEmailIdentityOutput{VerifiedForSendingStatus: false}, nil) @@ -716,7 +716,7 @@ func TestSender_isEmailVerified_NotVerified(t *testing.T) { } func TestSender_isEmailVerified_NotFound(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("GetEmailIdentity", mock.Anything, mock.AnythingOfType("*sesv2.GetEmailIdentityInput")). Return(nil, assert.AnError) @@ -747,7 +747,7 @@ func TestSender_createVerificationRequest_NilClient(t *testing.T) { } func TestSender_createVerificationRequest_Success(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("CreateEmailIdentity", mock.Anything, mock.AnythingOfType("*sesv2.CreateEmailIdentityInput")). Return(&sesv2.CreateEmailIdentityOutput{}, nil) @@ -763,7 +763,7 @@ func TestSender_createVerificationRequest_Success(t *testing.T) { } func TestSender_createVerificationRequest_Error(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("CreateEmailIdentity", mock.Anything, mock.AnythingOfType("*sesv2.CreateEmailIdentityInput")). Return(nil, assert.AnError) @@ -803,7 +803,7 @@ func TestNewSenderWithContext_Success(t *testing.T) { // are populated with the expected substrings, and the From address is // the configured FROM_EMAIL (not a hardcoded literal). func TestSender_SendPurchaseApprovalRequest_Multipart_Issue287(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) @@ -860,7 +860,7 @@ func TestSender_SendPurchaseApprovalRequest_Multipart_Issue287(t *testing.T) { // to single-part text — the existing code path stays valid for callers // that haven't been upgraded. func TestSender_SendToEmailWithCCMultipart_FallsBackWhenHTMLEmpty_Issue287(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) var captured *sesv2.SendEmailInput @@ -889,7 +889,7 @@ func TestSender_SendToEmailWithCCMultipart_FallsBackWhenHTMLEmpty_Issue287(t *te // broadcast topic, regardless of how it got there. func TestSendNotification_RejectsTokenBearingBody(t *testing.T) { t.Parallel() - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) // No Publish calls expected — the guard fires before the client is touched. t.Cleanup(func() { mockSNS.AssertExpectations(t) }) @@ -919,7 +919,7 @@ func TestSendNotification_RejectsTokenBearingBody(t *testing.T) { // broadcast body that contains no token reaches SNS normally. func TestSendNotification_AllowsTokenFreeBody(t *testing.T) { t.Parallel() - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). Return(&sns.PublishOutput{MessageId: aws.String("msg-ok")}, nil).Once() t.Cleanup(func() { mockSNS.AssertExpectations(t) }) @@ -942,11 +942,11 @@ func TestSendNotification_AllowsTokenFreeBody(t *testing.T) { // SNS subscriber. func TestSendScheduledPurchaseNotification_UsesSESNotSNS(t *testing.T) { t.Parallel() - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) // SNS Publish must NOT be called. t.Cleanup(func() { mockSNS.AssertExpectations(t) }) - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil).Once() mockSES.On("SendEmail", mock.Anything, mock.AnythingOfType("*sesv2.SendEmailInput")). @@ -976,8 +976,8 @@ func TestSendScheduledPurchaseNotification_UsesSESNotSNS(t *testing.T) { // omitting RecipientEmail returns ErrNoRecipient, not a silent broadcast. func TestSendScheduledPurchaseNotification_ErrNoRecipientWhenEmpty(t *testing.T) { t.Parallel() - mockSNS := new(MockSNSClient) - mockSES := new(MockSESClient) + mockSNS := new(SNSClient) + mockSES := new(SESClient) // Neither SNS Publish nor SES SendEmail should be called. t.Cleanup(func() { mockSNS.AssertExpectations(t) @@ -1009,11 +1009,11 @@ func TestSendScheduledPurchaseNotification_ErrNoRecipientWhenEmpty(t *testing.T) // every SNS subscriber, allowing unauthorised spend approval. func TestSendRIExchangePendingApproval_UsesSESNotSNS(t *testing.T) { t.Parallel() - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) // SNS Publish must NOT be called. t.Cleanup(func() { mockSNS.AssertExpectations(t) }) - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil).Once() mockSES.On("SendEmail", mock.Anything, mock.AnythingOfType("*sesv2.SendEmailInput")). @@ -1053,8 +1053,8 @@ func TestSendRIExchangePendingApproval_UsesSESNotSNS(t *testing.T) { // omitting RecipientEmail returns ErrNoRecipient, not a silent broadcast. func TestSendRIExchangePendingApproval_ErrNoRecipientWhenEmpty(t *testing.T) { t.Parallel() - mockSNS := new(MockSNSClient) - mockSES := new(MockSESClient) + mockSNS := new(SNSClient) + mockSES := new(SESClient) // Neither SNS Publish nor SES SendEmail should be called. t.Cleanup(func() { mockSNS.AssertExpectations(t) @@ -1088,7 +1088,7 @@ func TestSendNotification_SubjectSanitizedAndTruncated(t *testing.T) { t.Run("newline_stripped", func(t *testing.T) { var captured *sns.PublishInput - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.MatchedBy(func(in *sns.PublishInput) bool { captured = in return true @@ -1109,7 +1109,7 @@ func TestSendNotification_SubjectSanitizedAndTruncated(t *testing.T) { t.Run("long_subject_truncated", func(t *testing.T) { const longSubject = "AAAAAAAAAA AAAAAAAAAA AAAAAAAAAA AAAAAAAAAA AAAAAAAAAA AAAAAAAAAA AAAAAAAAAA AAAAAAAAAA AAAAAAAAAA AAAAAAAAAA" var captured *sns.PublishInput - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.MatchedBy(func(in *sns.PublishInput) bool { captured = in return true diff --git a/internal/email/templates_test.go b/internal/email/templates_test.go index 1ba02b4a5..8411537ca 100644 --- a/internal/email/templates_test.go +++ b/internal/email/templates_test.go @@ -13,7 +13,7 @@ import ( ) func TestSender_SendNewRecommendationsNotification_Success(t *testing.T) { - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). Return(&sns.PublishOutput{MessageId: aws.String("msg-123")}, nil) @@ -46,7 +46,7 @@ func TestSender_SendNewRecommendationsNotification_Success(t *testing.T) { func TestSender_SendScheduledPurchaseNotification_Success(t *testing.T) { // Scheduled purchase notifications carry approval tokens and must be // delivered via targeted SES, not the SNS broadcast topic. - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) mockSES.On("SendEmail", mock.Anything, mock.AnythingOfType("*sesv2.SendEmailInput")). @@ -75,7 +75,7 @@ func TestSender_SendScheduledPurchaseNotification_Success(t *testing.T) { } func TestSender_SendPurchaseConfirmation_Success(t *testing.T) { - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). Return(&sns.PublishOutput{MessageId: aws.String("msg-123")}, nil) @@ -98,7 +98,7 @@ func TestSender_SendPurchaseConfirmation_Success(t *testing.T) { } func TestSender_SendPurchaseFailedNotification_Success(t *testing.T) { - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). Return(&sns.PublishOutput{MessageId: aws.String("msg-123")}, nil) @@ -121,7 +121,7 @@ func TestSender_SendPurchaseFailedNotification_Success(t *testing.T) { } func TestSender_SendPasswordResetEmail_Success(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) // GetAccount is called to check sandbox mode - return production mode (not sandbox) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) @@ -140,7 +140,7 @@ func TestSender_SendPasswordResetEmail_Success(t *testing.T) { } func TestSender_SendWelcomeEmail_Success(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) // GetAccount is called to check sandbox mode - return production mode (not sandbox) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) @@ -160,7 +160,7 @@ func TestSender_SendWelcomeEmail_Success(t *testing.T) { // Test template success paths with no recommendations (edge case) func TestSender_SendNewRecommendationsNotification_EmptyRecommendations(t *testing.T) { - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). Return(&sns.PublishOutput{MessageId: aws.String("msg-123")}, nil) @@ -271,7 +271,7 @@ func TestSender_SendWelcomeEmail_NoFromEmail(t *testing.T) { // Test error cases for template functions func TestSender_SendNewRecommendationsNotification_SNSError(t *testing.T) { - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) sender := &Sender{ snsClient: mockSNS, topicARN: "arn:aws:sns:us-east-1:123456789:topic", @@ -294,7 +294,7 @@ func TestSender_SendNewRecommendationsNotification_SNSError(t *testing.T) { func TestSender_SendScheduledPurchaseNotification_SESError(t *testing.T) { // SES send error must propagate to the caller. - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) mockSES.On("SendEmail", mock.Anything, mock.AnythingOfType("*sesv2.SendEmailInput")). @@ -320,7 +320,7 @@ func TestSender_SendScheduledPurchaseNotification_SESError(t *testing.T) { } func TestSender_SendPurchaseConfirmation_SNSError(t *testing.T) { - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) sender := &Sender{ snsClient: mockSNS, topicARN: "arn:aws:sns:us-east-1:123456789:topic", @@ -338,7 +338,7 @@ func TestSender_SendPurchaseConfirmation_SNSError(t *testing.T) { } func TestSender_SendPurchaseFailedNotification_SNSError(t *testing.T) { - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) sender := &Sender{ snsClient: mockSNS, topicARN: "arn:aws:sns:us-east-1:123456789:topic", @@ -356,7 +356,7 @@ func TestSender_SendPurchaseFailedNotification_SNSError(t *testing.T) { } func TestSender_SendPasswordResetEmail_SESError(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) sender := &Sender{ sesClient: mockSES, fromEmail: "noreply@example.com", @@ -373,7 +373,7 @@ func TestSender_SendPasswordResetEmail_SESError(t *testing.T) { } func TestSender_SendWelcomeEmail_SESError(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) sender := &Sender{ sesClient: mockSES, fromEmail: "noreply@example.com", @@ -391,7 +391,7 @@ func TestSender_SendWelcomeEmail_SESError(t *testing.T) { // Test multiple recommendations in templates func TestSender_SendNewRecommendationsNotification_MultipleRecommendations(t *testing.T) { - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). Return(&sns.PublishOutput{MessageId: aws.String("msg-123")}, nil) @@ -439,7 +439,7 @@ func TestSender_SendNewRecommendationsNotification_MultipleRecommendations(t *te func TestSender_SendScheduledPurchaseNotification_WithUpfrontCost(t *testing.T) { // Verify that a well-formed data payload with RecipientEmail succeeds via SES. - mockSES := new(MockSESClient) + mockSES := new(SESClient) mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) mockSES.On("SendEmail", mock.Anything, mock.AnythingOfType("*sesv2.SendEmailInput")). @@ -478,7 +478,7 @@ func TestSender_SendScheduledPurchaseNotification_WithUpfrontCost(t *testing.T) } func TestSender_SendPurchaseConfirmation_WithMultipleRecommendations(t *testing.T) { - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). Return(&sns.PublishOutput{MessageId: aws.String("msg-123")}, nil) @@ -518,7 +518,7 @@ func TestSender_SendPurchaseConfirmation_WithMultipleRecommendations(t *testing. } func TestSender_SendPurchaseFailedNotification_MultipleFailures(t *testing.T) { - mockSNS := new(MockSNSClient) + mockSNS := new(SNSClient) mockSNS.On("Publish", mock.Anything, mock.AnythingOfType("*sns.PublishInput")). Return(&sns.PublishOutput{MessageId: aws.String("msg-123")}, nil) @@ -642,7 +642,7 @@ func containsAnyStr(s string, subs ...string) bool { // endpoint) must be stripped before the subject reaches the SES SendEmail API, // so it cannot inject additional email headers. Mirrors the SMTP-path test. func TestSender_SendRegistrationReceivedNotification_SubjectHeaderInjection(t *testing.T) { - mockSES := new(MockSESClient) + mockSES := new(SESClient) // Production mode so the send proceeds straight to SendEmail. mockSES.On("GetAccount", mock.Anything, mock.AnythingOfType("*sesv2.GetAccountInput")). Return(&sesv2.GetAccountOutput{ProductionAccessEnabled: true}, nil) diff --git a/internal/mocks/secretsmanager.go b/internal/mocks/secretsmanager.go index b9c40e854..62a9cb0f2 100644 --- a/internal/mocks/secretsmanager.go +++ b/internal/mocks/secretsmanager.go @@ -7,44 +7,56 @@ import ( "github.com/stretchr/testify/mock" ) -// MockSecretsManagerClient is a mock implementation of Secrets Manager client +// MockSecretsManagerClient is a mock implementation of Secrets Manager client. type MockSecretsManagerClient struct { mock.Mock } -// GetSecretValue mocks the GetSecretValue operation +// GetSecretValue mocks the GetSecretValue operation. func (m *MockSecretsManagerClient) GetSecretValue(ctx context.Context, input *secretsmanager.GetSecretValueInput, opts ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*secretsmanager.GetSecretValueOutput), args.Error(1) + val, ok := args.Get(0).(*secretsmanager.GetSecretValueOutput) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// CreateSecret mocks the CreateSecret operation +// CreateSecret mocks the CreateSecret operation. func (m *MockSecretsManagerClient) CreateSecret(ctx context.Context, input *secretsmanager.CreateSecretInput, opts ...func(*secretsmanager.Options)) (*secretsmanager.CreateSecretOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*secretsmanager.CreateSecretOutput), args.Error(1) + val, ok := args.Get(0).(*secretsmanager.CreateSecretOutput) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// UpdateSecret mocks the UpdateSecret operation +// UpdateSecret mocks the UpdateSecret operation. func (m *MockSecretsManagerClient) UpdateSecret(ctx context.Context, input *secretsmanager.UpdateSecretInput, opts ...func(*secretsmanager.Options)) (*secretsmanager.UpdateSecretOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*secretsmanager.UpdateSecretOutput), args.Error(1) + val, ok := args.Get(0).(*secretsmanager.UpdateSecretOutput) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// SecretsManagerAPI defines the interface for Secrets Manager operations used by our code +// SecretsManagerAPI defines the interface for Secrets Manager operations used by our code. type SecretsManagerAPI interface { GetSecretValue(ctx context.Context, input *secretsmanager.GetSecretValueInput, opts ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) CreateSecret(ctx context.Context, input *secretsmanager.CreateSecretInput, opts ...func(*secretsmanager.Options)) (*secretsmanager.CreateSecretOutput, error) UpdateSecret(ctx context.Context, input *secretsmanager.UpdateSecretInput, opts ...func(*secretsmanager.Options)) (*secretsmanager.UpdateSecretOutput, error) } -// Ensure MockSecretsManagerClient implements SecretsManagerAPI +// Ensure MockSecretsManagerClient implements SecretsManagerAPI. var _ SecretsManagerAPI = (*MockSecretsManagerClient)(nil) diff --git a/internal/mocks/ses.go b/internal/mocks/ses.go index 9712776a9..fd25e7e63 100644 --- a/internal/mocks/ses.go +++ b/internal/mocks/ses.go @@ -7,24 +7,28 @@ import ( "github.com/stretchr/testify/mock" ) -// MockSESClient is a mock implementation of SES client -type MockSESClient struct { +// SESClient is a mock implementation of SES client. +type SESClient struct { mock.Mock } -// SendEmail mocks the SendEmail operation -func (m *MockSESClient) SendEmail(ctx context.Context, input *sesv2.SendEmailInput, opts ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) { +// SendEmail mocks the SendEmail operation. +func (m *SESClient) SendEmail(ctx context.Context, input *sesv2.SendEmailInput, opts ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*sesv2.SendEmailOutput), args.Error(1) + val, ok := args.Get(0).(*sesv2.SendEmailOutput) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// SESAPI defines the interface for SES operations used by our code +// SESAPI defines the interface for SES operations used by our code. type SESAPI interface { SendEmail(ctx context.Context, input *sesv2.SendEmailInput, opts ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) } -// Ensure MockSESClient implements SESAPI -var _ SESAPI = (*MockSESClient)(nil) +// Ensure SESClient implements SESAPI. +var _ SESAPI = (*SESClient)(nil) diff --git a/internal/mocks/sns.go b/internal/mocks/sns.go index 5bae0d212..1f0693e0e 100644 --- a/internal/mocks/sns.go +++ b/internal/mocks/sns.go @@ -7,24 +7,28 @@ import ( "github.com/stretchr/testify/mock" ) -// MockSNSClient is a mock implementation of SNS client -type MockSNSClient struct { +// SNSClient is a mock implementation of SNS client. +type SNSClient struct { mock.Mock } -// Publish mocks the Publish operation -func (m *MockSNSClient) Publish(ctx context.Context, input *sns.PublishInput, opts ...func(*sns.Options)) (*sns.PublishOutput, error) { +// Publish mocks the Publish operation. +func (m *SNSClient) Publish(ctx context.Context, input *sns.PublishInput, opts ...func(*sns.Options)) (*sns.PublishOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*sns.PublishOutput), args.Error(1) + val, ok := args.Get(0).(*sns.PublishOutput) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// SNSAPI defines the interface for SNS operations used by our code +// SNSAPI defines the interface for SNS operations used by our code. type SNSAPI interface { Publish(ctx context.Context, input *sns.PublishInput, opts ...func(*sns.Options)) (*sns.PublishOutput, error) } -// Ensure MockSNSClient implements SNSAPI -var _ SNSAPI = (*MockSNSClient)(nil) +// Ensure SNSClient implements SNSAPI. +var _ SNSAPI = (*SNSClient)(nil) diff --git a/internal/mocks/stores.go b/internal/mocks/stores.go index e6b32b4e6..529d986e7 100644 --- a/internal/mocks/stores.go +++ b/internal/mocks/stores.go @@ -15,41 +15,28 @@ import ( // Most methods dispatch through m.Called only when an expectation has been // registered via .On(). Methods that pre-existing tests call implicitly // (without expectations) default to sensible zero-values so those tests -// keep working without changes. The "default or dispatch" behaviour is +// keep working without changes. The "default or dispatch" behavior is // controlled by the isExpected helper at the bottom of this file. // -// Fn-override fields allow tests to inject behaviour without registering +// Fn-override fields allow tests to inject behavior without registering // testify expectations. The precedence order for every overridable method is: // 1. FnField (non-nil closure wins first) // 2. Registered .On() expectation (dispatches through m.Called) // 3. Hardcoded default (zero-value / sensible stub) type MockConfigStore struct { - mock.Mock - - // GetCloudAccountFn overrides GetCloudAccount when non-nil. - GetCloudAccountFn func(ctx context.Context, id string) (*config.CloudAccount, error) - // GetCloudAccountByExternalIDFn overrides GetCloudAccountByExternalID when non-nil. - GetCloudAccountByExternalIDFn func(ctx context.Context, provider, externalID string) (*config.CloudAccount, error) - // DeleteCloudAccountFn overrides DeleteCloudAccount when non-nil. - DeleteCloudAccountFn func(ctx context.Context, id string) error - // ListCloudAccountsFn overrides ListCloudAccounts when non-nil. - ListCloudAccountsFn func(ctx context.Context, filter config.CloudAccountFilter) ([]config.CloudAccount, error) - // CreateCloudAccountFn overrides CreateCloudAccount when non-nil. - CreateCloudAccountFn func(ctx context.Context, account *config.CloudAccount) error - // GetPurchasePlanFn overrides GetPurchasePlan when non-nil. - GetPurchasePlanFn func(ctx context.Context, planID string) (*config.PurchasePlan, error) - // SetPlanAccountsFn overrides SetPlanAccounts when non-nil. - SetPlanAccountsFn func(ctx context.Context, planID string, accountIDs []string) error - // GetPlanAccountsFn overrides GetPlanAccounts when non-nil. - GetPlanAccountsFn func(ctx context.Context, planID string) ([]config.CloudAccount, error) - // SaveAccountServiceOverrideFn overrides SaveAccountServiceOverride when non-nil. - SaveAccountServiceOverrideFn func(ctx context.Context, override *config.AccountServiceOverride) error - // CountPendingExecutionsForAccountFn overrides CountPendingExecutionsForAccount when non-nil. - CountPendingExecutionsForAccountFn func(ctx context.Context, accountID string) (int, error) - // ListPendingExecutionIDsForAccountFn overrides ListPendingExecutionIDsForAccount when non-nil. + GetPurchasePlanFn func(ctx context.Context, planID string) (*config.PurchasePlan, error) + GetCloudAccountFn func(ctx context.Context, id string) (*config.CloudAccount, error) + GetCloudAccountByExternalIDFn func(ctx context.Context, provider, externalID string) (*config.CloudAccount, error) + DeleteCloudAccountFn func(ctx context.Context, id string) error + ListCloudAccountsFn func(ctx context.Context, filter config.CloudAccountFilter) ([]config.CloudAccount, error) + CreateCloudAccountFn func(ctx context.Context, account *config.CloudAccount) error + SetPlanAccountsFn func(ctx context.Context, planID string, accountIDs []string) error + GetPlanAccountsFn func(ctx context.Context, planID string) ([]config.CloudAccount, error) + SaveAccountServiceOverrideFn func(ctx context.Context, override *config.AccountServiceOverride) error + CountPendingExecutionsForAccountFn func(ctx context.Context, accountID string) (int, error) ListPendingExecutionIDsForAccountFn func(ctx context.Context, accountID string) ([]string, error) - // SavePurchaseExecutionFn overrides SavePurchaseExecution when non-nil. - SavePurchaseExecutionFn func(ctx context.Context, exec *config.PurchaseExecution) error + SavePurchaseExecutionFn func(ctx context.Context, exec *config.PurchaseExecution) error + mock.Mock } // GetGlobalConfig mocks the GetGlobalConfig operation. Returns an empty @@ -63,40 +50,52 @@ func (m *MockConfigStore) GetGlobalConfig(ctx context.Context) (*config.GlobalCo if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.GlobalConfig), args.Error(1) + val, ok := args.Get(0).(*config.GlobalConfig) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// SaveGlobalConfig mocks the SaveGlobalConfig operation +// SaveGlobalConfig mocks the SaveGlobalConfig operation. func (m *MockConfigStore) SaveGlobalConfig(ctx context.Context, cfg *config.GlobalConfig) error { args := m.Called(ctx, cfg) return args.Error(0) } -// GetServiceConfig mocks the GetServiceConfig operation +// GetServiceConfig mocks the GetServiceConfig operation. func (m *MockConfigStore) GetServiceConfig(ctx context.Context, provider, service string) (*config.ServiceConfig, error) { args := m.Called(ctx, provider, service) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.ServiceConfig), args.Error(1) + val, ok := args.Get(0).(*config.ServiceConfig) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// SaveServiceConfig mocks the SaveServiceConfig operation +// SaveServiceConfig mocks the SaveServiceConfig operation. func (m *MockConfigStore) SaveServiceConfig(ctx context.Context, cfg *config.ServiceConfig) error { args := m.Called(ctx, cfg) return args.Error(0) } -// ListServiceConfigs mocks the ListServiceConfigs operation +// ListServiceConfigs mocks the ListServiceConfigs operation. func (m *MockConfigStore) ListServiceConfigs(ctx context.Context) ([]config.ServiceConfig, error) { args := m.Called(ctx) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.ServiceConfig), args.Error(1) + val, ok := args.Get(0).([]config.ServiceConfig) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// CreatePurchasePlan mocks the CreatePurchasePlan operation +// CreatePurchasePlan mocks the CreatePurchasePlan operation. func (m *MockConfigStore) CreatePurchasePlan(ctx context.Context, plan *config.PurchasePlan) error { args := m.Called(ctx, plan) return args.Error(0) @@ -117,10 +116,14 @@ func (m *MockConfigStore) GetPurchasePlan(ctx context.Context, planID string) (* if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.PurchasePlan), args.Error(1) + val, ok := args.Get(0).(*config.PurchasePlan) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// UpdatePurchasePlan mocks the UpdatePurchasePlan operation +// UpdatePurchasePlan mocks the UpdatePurchasePlan operation. func (m *MockConfigStore) UpdatePurchasePlan(ctx context.Context, plan *config.PurchasePlan) error { args := m.Called(ctx, plan) return args.Error(0) @@ -144,19 +147,23 @@ func (m *MockConfigStore) UpdatePurchasePlanTx(ctx context.Context, tx pgx.Tx, p return args.Error(0) } -// DeletePurchasePlan mocks the DeletePurchasePlan operation +// DeletePurchasePlan mocks the DeletePurchasePlan operation. func (m *MockConfigStore) DeletePurchasePlan(ctx context.Context, planID string) error { args := m.Called(ctx, planID) return args.Error(0) } -// ListPurchasePlans mocks the ListPurchasePlans operation +// ListPurchasePlans mocks the ListPurchasePlans operation. func (m *MockConfigStore) ListPurchasePlans(ctx context.Context, filter config.PurchasePlanFilter) ([]config.PurchasePlan, error) { args := m.Called(ctx, filter) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.PurchasePlan), args.Error(1) + val, ok := args.Get(0).([]config.PurchasePlan) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // SavePurchaseExecution mocks the SavePurchaseExecution operation. @@ -169,13 +176,17 @@ func (m *MockConfigStore) SavePurchaseExecution(ctx context.Context, exec *confi return args.Error(0) } -// TransitionExecutionStatus mocks the TransitionExecutionStatus operation +// TransitionExecutionStatus mocks the TransitionExecutionStatus operation. func (m *MockConfigStore) TransitionExecutionStatus(ctx context.Context, executionID string, fromStatuses []string, toStatus string, actor *string) (*config.PurchaseExecution, error) { args := m.Called(ctx, executionID, fromStatuses, toStatus, actor) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.PurchaseExecution), args.Error(1) + val, ok := args.Get(0).(*config.PurchaseExecution) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // CancelExecutionAtomic mocks the CancelExecutionAtomic operation. @@ -183,7 +194,9 @@ func (m *MockConfigStore) TransitionExecutionStatus(ctx context.Context, executi // so tests that only need the happy path don't require explicit mock setup. // Tests exercising the CAS-race path (zero rows affected) register an // expectation that returns (false, , nil). -func (m *MockConfigStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (bool, string, error) { +// +//nolint:misspell // mock returns DB status literal 'cancelled' to match the real store (status CHECK constraint); rename tracked in PR #1277 +func (m *MockConfigStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (ok bool, status string, err error) { if !isExpected(&m.Mock, "CancelExecutionAtomic") { return true, "cancelled", nil } @@ -198,7 +211,9 @@ func (m *MockConfigStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, // Tests exercising the CAS-race path (scheduler tick already fired) register // an expectation that returns (false, , nil), typically // (false, "approved", nil) to simulate the scheduler winning the race. -func (m *MockConfigStore) CancelScheduledExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (bool, string, error) { +// +//nolint:misspell // mock returns DB status literal 'cancelled' to match the real store (status CHECK constraint); rename tracked in PR #1277 +func (m *MockConfigStore) CancelScheduledExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (ok bool, status string, err error) { if !isExpected(&m.Mock, "CancelScheduledExecutionAtomic") { return true, "cancelled", nil } @@ -206,31 +221,43 @@ func (m *MockConfigStore) CancelScheduledExecutionAtomic(ctx context.Context, tx return args.Bool(0), args.String(1), args.Error(2) } -// GetPendingExecutions mocks the GetPendingExecutions operation +// GetPendingExecutions mocks the GetPendingExecutions operation. func (m *MockConfigStore) GetPendingExecutions(ctx context.Context) ([]config.PurchaseExecution, error) { args := m.Called(ctx) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.PurchaseExecution), args.Error(1) + val, ok := args.Get(0).([]config.PurchaseExecution) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// GetExecutionByID mocks the GetExecutionByID operation +// GetExecutionByID mocks the GetExecutionByID operation. func (m *MockConfigStore) GetExecutionByID(ctx context.Context, executionID string) (*config.PurchaseExecution, error) { args := m.Called(ctx, executionID) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.PurchaseExecution), args.Error(1) + val, ok := args.Get(0).(*config.PurchaseExecution) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// GetExecutionByPlanAndDate mocks the GetExecutionByPlanAndDate operation +// GetExecutionByPlanAndDate mocks the GetExecutionByPlanAndDate operation. func (m *MockConfigStore) GetExecutionByPlanAndDate(ctx context.Context, planID string, scheduledDate time.Time) (*config.PurchaseExecution, error) { args := m.Called(ctx, planID, scheduledDate) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.PurchaseExecution), args.Error(1) + val, ok := args.Get(0).(*config.PurchaseExecution) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // CountPendingExecutionsForAccount mocks the CountPendingExecutionsForAccount operation. @@ -259,40 +286,56 @@ func (m *MockConfigStore) ListPendingExecutionIDsForAccount(ctx context.Context, if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]string), args.Error(1) + val, ok := args.Get(0).([]string) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// SavePurchaseHistory mocks the SavePurchaseHistory operation +// SavePurchaseHistory mocks the SavePurchaseHistory operation. func (m *MockConfigStore) SavePurchaseHistory(ctx context.Context, record *config.PurchaseHistoryRecord) error { args := m.Called(ctx, record) return args.Error(0) } -// GetPurchaseHistory mocks the GetPurchaseHistory operation +// GetPurchaseHistory mocks the GetPurchaseHistory operation. func (m *MockConfigStore) GetPurchaseHistory(ctx context.Context, accountID string, limit int) ([]config.PurchaseHistoryRecord, error) { args := m.Called(ctx, accountID, limit) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.PurchaseHistoryRecord), args.Error(1) + val, ok := args.Get(0).([]config.PurchaseHistoryRecord) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// GetAllPurchaseHistory mocks the GetAllPurchaseHistory operation +// GetAllPurchaseHistory mocks the GetAllPurchaseHistory operation. func (m *MockConfigStore) GetAllPurchaseHistory(ctx context.Context, limit int) ([]config.PurchaseHistoryRecord, error) { args := m.Called(ctx, limit) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.PurchaseHistoryRecord), args.Error(1) + val, ok := args.Get(0).([]config.PurchaseHistoryRecord) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// GetActivePurchaseHistory mocks the GetActivePurchaseHistory operation +// GetActivePurchaseHistory mocks the GetActivePurchaseHistory operation. func (m *MockConfigStore) GetActivePurchaseHistory(ctx context.Context, asOf time.Time) ([]config.PurchaseHistoryRecord, error) { args := m.Called(ctx, asOf) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.PurchaseHistoryRecord), args.Error(1) + val, ok := args.Get(0).([]config.PurchaseHistoryRecord) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // GetPurchaseHistoryFiltered mocks the GetPurchaseHistoryFiltered operation (issue #701). @@ -301,7 +344,11 @@ func (m *MockConfigStore) GetPurchaseHistoryFiltered(ctx context.Context, filter if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.PurchaseHistoryRecord), args.Error(1) + val, ok := args.Get(0).([]config.PurchaseHistoryRecord) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // GetPurchaseHistoryByPurchaseID mocks the GetPurchaseHistoryByPurchaseID operation (issue #290). @@ -310,11 +357,15 @@ func (m *MockConfigStore) GetPurchaseHistoryByPurchaseID(ctx context.Context, pu if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.PurchaseHistoryRecord), args.Error(1) + val, ok := args.Get(0).(*config.PurchaseHistoryRecord) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // MarkPurchaseRevoked mocks the MarkPurchaseRevoked operation (issue #290). -func (m *MockConfigStore) MarkPurchaseRevoked(ctx context.Context, purchaseID string, revokedAt time.Time, revokedVia string, supportCaseID string, calcRefundAmount *float64, calcRefundCurrency string) error { +func (m *MockConfigStore) MarkPurchaseRevoked(ctx context.Context, purchaseID string, revokedAt time.Time, revokedVia, supportCaseID string, calcRefundAmount *float64, calcRefundCurrency string) error { args := m.Called(ctx, purchaseID, revokedAt, revokedVia, supportCaseID, calcRefundAmount, calcRefundCurrency) return args.Error(0) } @@ -347,7 +398,11 @@ func (m *MockConfigStore) GetPurchaseHistoryInFlight(ctx context.Context) ([]*co if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]*config.PurchaseHistoryRecord), args.Error(1) + val, ok := args.Get(0).([]*config.PurchaseHistoryRecord) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) SaveRIExchangeRecord(ctx context.Context, record *config.RIExchangeRecord) error { @@ -360,7 +415,11 @@ func (m *MockConfigStore) GetRIExchangeRecord(ctx context.Context, id string) (* if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.RIExchangeRecord), args.Error(1) + val, ok := args.Get(0).(*config.RIExchangeRecord) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) GetRIExchangeRecordByToken(ctx context.Context, token string) (*config.RIExchangeRecord, error) { @@ -368,7 +427,11 @@ func (m *MockConfigStore) GetRIExchangeRecordByToken(ctx context.Context, token if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.RIExchangeRecord), args.Error(1) + val, ok := args.Get(0).(*config.RIExchangeRecord) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) GetRIExchangeHistory(ctx context.Context, since time.Time, limit int) ([]config.RIExchangeRecord, error) { @@ -376,23 +439,31 @@ func (m *MockConfigStore) GetRIExchangeHistory(ctx context.Context, since time.T if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.RIExchangeRecord), args.Error(1) + val, ok := args.Get(0).([]config.RIExchangeRecord) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -func (m *MockConfigStore) TransitionRIExchangeStatus(ctx context.Context, id string, fromStatus string, toStatus string, actor *string) (*config.RIExchangeRecord, error) { +func (m *MockConfigStore) TransitionRIExchangeStatus(ctx context.Context, id, fromStatus, toStatus string, actor *string) (*config.RIExchangeRecord, error) { args := m.Called(ctx, id, fromStatus, toStatus, actor) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.RIExchangeRecord), args.Error(1) + val, ok := args.Get(0).(*config.RIExchangeRecord) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -func (m *MockConfigStore) CompleteRIExchange(ctx context.Context, id string, exchangeID string) error { +func (m *MockConfigStore) CompleteRIExchange(ctx context.Context, id, exchangeID string) error { args := m.Called(ctx, id, exchangeID) return args.Error(0) } -func (m *MockConfigStore) FailRIExchange(ctx context.Context, id string, errorMsg string) error { +func (m *MockConfigStore) FailRIExchange(ctx context.Context, id, errorMsg string) error { args := m.Called(ctx, id, errorMsg) return args.Error(0) } @@ -404,7 +475,11 @@ func (m *MockConfigStore) GetRIExchangeDailySpend(ctx context.Context, date time func (m *MockConfigStore) CancelAllPendingExchanges(ctx context.Context) (int64, error) { args := m.Called(ctx) - return args.Get(0).(int64), args.Error(1) + val, ok := args.Get(0).(int64) + if !ok { + return 0, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) GetStaleProcessingExchanges(ctx context.Context, olderThan time.Duration) ([]config.RIExchangeRecord, error) { @@ -412,150 +487,182 @@ func (m *MockConfigStore) GetStaleProcessingExchanges(ctx context.Context, older if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.RIExchangeRecord), args.Error(1) + val, ok := args.Get(0).([]config.RIExchangeRecord) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// MockAuthStore is a mock implementation of auth.Store +// MockAuthStore is a mock implementation of auth.Store. type MockAuthStore struct { mock.Mock } -// GetUserByID mocks the GetUserByID operation +// GetUserByID mocks the GetUserByID operation. func (m *MockAuthStore) GetUserByID(ctx context.Context, userID string) (*auth.User, error) { args := m.Called(ctx, userID) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*auth.User), args.Error(1) + val, ok := args.Get(0).(*auth.User) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// GetUserByEmail mocks the GetUserByEmail operation +// GetUserByEmail mocks the GetUserByEmail operation. func (m *MockAuthStore) GetUserByEmail(ctx context.Context, email string) (*auth.User, error) { args := m.Called(ctx, email) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*auth.User), args.Error(1) + val, ok := args.Get(0).(*auth.User) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// CreateUser mocks the CreateUser operation +// CreateUser mocks the CreateUser operation. func (m *MockAuthStore) CreateUser(ctx context.Context, user *auth.User) error { args := m.Called(ctx, user) return args.Error(0) } -// UpdateUser mocks the UpdateUser operation +// UpdateUser mocks the UpdateUser operation. func (m *MockAuthStore) UpdateUser(ctx context.Context, user *auth.User) error { args := m.Called(ctx, user) return args.Error(0) } -// DeleteUser mocks the DeleteUser operation +// DeleteUser mocks the DeleteUser operation. func (m *MockAuthStore) DeleteUser(ctx context.Context, userID string) error { args := m.Called(ctx, userID) return args.Error(0) } -// ListUsers mocks the ListUsers operation +// ListUsers mocks the ListUsers operation. func (m *MockAuthStore) ListUsers(ctx context.Context) ([]auth.User, error) { args := m.Called(ctx) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]auth.User), args.Error(1) + val, ok := args.Get(0).([]auth.User) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// GetUserByResetToken mocks the GetUserByResetToken operation +// GetUserByResetToken mocks the GetUserByResetToken operation. func (m *MockAuthStore) GetUserByResetToken(ctx context.Context, token string) (*auth.User, error) { args := m.Called(ctx, token) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*auth.User), args.Error(1) + val, ok := args.Get(0).(*auth.User) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// AdminExists mocks the AdminExists operation +// AdminExists mocks the AdminExists operation. func (m *MockAuthStore) AdminExists(ctx context.Context) (bool, error) { args := m.Called(ctx) return args.Bool(0), args.Error(1) } -// CreateAdminIfNone mocks the CreateAdminIfNone operation +// CreateAdminIfNone mocks the CreateAdminIfNone operation. func (m *MockAuthStore) CreateAdminIfNone(ctx context.Context, user *auth.User) (bool, error) { args := m.Called(ctx, user) return args.Bool(0), args.Error(1) } -// GetGroup mocks the GetGroup operation +// GetGroup mocks the GetGroup operation. func (m *MockAuthStore) GetGroup(ctx context.Context, groupID string) (*auth.Group, error) { args := m.Called(ctx, groupID) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*auth.Group), args.Error(1) + val, ok := args.Get(0).(*auth.Group) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// CreateGroup mocks the CreateGroup operation +// CreateGroup mocks the CreateGroup operation. func (m *MockAuthStore) CreateGroup(ctx context.Context, group *auth.Group) error { args := m.Called(ctx, group) return args.Error(0) } -// UpdateGroup mocks the UpdateGroup operation +// UpdateGroup mocks the UpdateGroup operation. func (m *MockAuthStore) UpdateGroup(ctx context.Context, group *auth.Group) error { args := m.Called(ctx, group) return args.Error(0) } -// DeleteGroup mocks the DeleteGroup operation +// DeleteGroup mocks the DeleteGroup operation. func (m *MockAuthStore) DeleteGroup(ctx context.Context, groupID string) error { args := m.Called(ctx, groupID) return args.Error(0) } -// ListGroups mocks the ListGroups operation +// ListGroups mocks the ListGroups operation. func (m *MockAuthStore) ListGroups(ctx context.Context) ([]auth.Group, error) { args := m.Called(ctx) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]auth.Group), args.Error(1) + val, ok := args.Get(0).([]auth.Group) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// CountGroupMembers mocks the CountGroupMembers operation +// CountGroupMembers mocks the CountGroupMembers operation. func (m *MockAuthStore) CountGroupMembers(ctx context.Context, groupID string) (int, error) { args := m.Called(ctx, groupID) return args.Int(0), args.Error(1) } -// CreateSession mocks the CreateSession operation +// CreateSession mocks the CreateSession operation. func (m *MockAuthStore) CreateSession(ctx context.Context, session *auth.Session) error { args := m.Called(ctx, session) return args.Error(0) } -// GetSession mocks the GetSession operation +// GetSession mocks the GetSession operation. func (m *MockAuthStore) GetSession(ctx context.Context, token string) (*auth.Session, error) { args := m.Called(ctx, token) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*auth.Session), args.Error(1) + val, ok := args.Get(0).(*auth.Session) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// DeleteSession mocks the DeleteSession operation +// DeleteSession mocks the DeleteSession operation. func (m *MockAuthStore) DeleteSession(ctx context.Context, token string) error { args := m.Called(ctx, token) return args.Error(0) } -// DeleteUserSessions mocks the DeleteUserSessions operation +// DeleteUserSessions mocks the DeleteUserSessions operation. func (m *MockAuthStore) DeleteUserSessions(ctx context.Context, userID string) error { args := m.Called(ctx, userID) return args.Error(0) } -// CleanupExpiredSessions mocks the CleanupExpiredSessions operation +// CleanupExpiredSessions mocks the CleanupExpiredSessions operation. func (m *MockAuthStore) CleanupExpiredSessions(ctx context.Context) error { args := m.Called(ctx) return args.Error(0) @@ -563,58 +670,70 @@ func (m *MockAuthStore) CleanupExpiredSessions(ctx context.Context) error { // API Key operations -// CreateAPIKey mocks the CreateAPIKey operation +// CreateAPIKey mocks the CreateAPIKey operation. func (m *MockAuthStore) CreateAPIKey(ctx context.Context, key *auth.UserAPIKey) error { args := m.Called(ctx, key) return args.Error(0) } -// GetAPIKeyByID mocks the GetAPIKeyByID operation +// GetAPIKeyByID mocks the GetAPIKeyByID operation. func (m *MockAuthStore) GetAPIKeyByID(ctx context.Context, keyID string) (*auth.UserAPIKey, error) { args := m.Called(ctx, keyID) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*auth.UserAPIKey), args.Error(1) + val, ok := args.Get(0).(*auth.UserAPIKey) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// GetAPIKeyByHash mocks the GetAPIKeyByHash operation +// GetAPIKeyByHash mocks the GetAPIKeyByHash operation. func (m *MockAuthStore) GetAPIKeyByHash(ctx context.Context, keyHash string) (*auth.UserAPIKey, error) { args := m.Called(ctx, keyHash) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*auth.UserAPIKey), args.Error(1) + val, ok := args.Get(0).(*auth.UserAPIKey) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// ListAPIKeysByUser mocks the ListAPIKeysByUser operation +// ListAPIKeysByUser mocks the ListAPIKeysByUser operation. func (m *MockAuthStore) ListAPIKeysByUser(ctx context.Context, userID string) ([]*auth.UserAPIKey, error) { args := m.Called(ctx, userID) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]*auth.UserAPIKey), args.Error(1) + val, ok := args.Get(0).([]*auth.UserAPIKey) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// UpdateAPIKey mocks the UpdateAPIKey operation +// UpdateAPIKey mocks the UpdateAPIKey operation. func (m *MockAuthStore) UpdateAPIKey(ctx context.Context, key *auth.UserAPIKey) error { args := m.Called(ctx, key) return args.Error(0) } -// UpdateAPIKeyLastUsed mocks the UpdateAPIKeyLastUsed operation +// UpdateAPIKeyLastUsed mocks the UpdateAPIKeyLastUsed operation. func (m *MockAuthStore) UpdateAPIKeyLastUsed(ctx context.Context, keyID string) error { args := m.Called(ctx, keyID) return args.Error(0) } -// DeleteAPIKey mocks the DeleteAPIKey operation +// DeleteAPIKey mocks the DeleteAPIKey operation. func (m *MockAuthStore) DeleteAPIKey(ctx context.Context, keyID string) error { args := m.Called(ctx, keyID) return args.Error(0) } -// Ping mocks the Ping operation +// Ping mocks the Ping operation. func (m *MockAuthStore) Ping(ctx context.Context) error { args := m.Called(ctx) return args.Error(0) @@ -647,7 +766,11 @@ func (m *MockConfigStore) GetCloudAccount(ctx context.Context, id string) (*conf if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.CloudAccount), args.Error(1) + val, ok := args.Get(0).(*config.CloudAccount) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) GetCloudAccountByExternalID(ctx context.Context, provider, externalID string) (*config.CloudAccount, error) { @@ -661,7 +784,11 @@ func (m *MockConfigStore) GetCloudAccountByExternalID(ctx context.Context, provi if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.CloudAccount), args.Error(1) + val, ok := args.Get(0).(*config.CloudAccount) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) UpdateCloudAccount(ctx context.Context, account *config.CloudAccount) error { @@ -694,7 +821,11 @@ func (m *MockConfigStore) ListCloudAccounts(ctx context.Context, filter config.C if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.CloudAccount), args.Error(1) + val, ok := args.Get(0).([]config.CloudAccount) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // Account credentials @@ -741,7 +872,11 @@ func (m *MockConfigStore) GetAccountServiceOverride(ctx context.Context, account if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.AccountServiceOverride), args.Error(1) + val, ok := args.Get(0).(*config.AccountServiceOverride) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) SaveAccountServiceOverride(ctx context.Context, override *config.AccountServiceOverride) error { @@ -771,7 +906,11 @@ func (m *MockConfigStore) ListAccountServiceOverrides(ctx context.Context, accou if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.AccountServiceOverride), args.Error(1) + val, ok := args.Get(0).([]config.AccountServiceOverride) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // Plan ↔ account association @@ -798,13 +937,21 @@ func (m *MockConfigStore) GetPlanAccounts(ctx context.Context, planID string) ([ if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.CloudAccount), args.Error(1) + val, ok := args.Get(0).([]config.CloudAccount) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } -// CleanupOldExecutions mocks the CleanupOldExecutions operation +// CleanupOldExecutions mocks the CleanupOldExecutions operation. func (m *MockConfigStore) CleanupOldExecutions(ctx context.Context, retentionDays int) (int64, error) { args := m.Called(ctx, retentionDays) - return args.Get(0).(int64), args.Error(1) + val, ok := args.Get(0).(int64) + if !ok { + return 0, args.Error(1) + } + return val, args.Error(1) } // Recommendations cache @@ -829,7 +976,7 @@ func (m *MockConfigStore) UpsertRecommendations(ctx context.Context, collectedAt return args.Error(0) } -func (m *MockConfigStore) ListStoredRecommendations(ctx context.Context, filter config.RecommendationFilter) ([]config.RecommendationRecord, error) { +func (m *MockConfigStore) ListStoredRecommendations(ctx context.Context, filter *config.RecommendationFilter) ([]config.RecommendationRecord, error) { if !isExpected(&m.Mock, "ListStoredRecommendations") { return nil, nil } @@ -837,7 +984,11 @@ func (m *MockConfigStore) ListStoredRecommendations(ctx context.Context, filter if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.RecommendationRecord), args.Error(1) + val, ok := args.Get(0).([]config.RecommendationRecord) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) GetRecommendationsFreshness(ctx context.Context) (*config.RecommendationsFreshness, error) { @@ -848,7 +999,11 @@ func (m *MockConfigStore) GetRecommendationsFreshness(ctx context.Context) (*con if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.RecommendationsFreshness), args.Error(1) + val, ok := args.Get(0).(*config.RecommendationsFreshness) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) SetRecommendationsCollectionError(ctx context.Context, errMsg string) error { @@ -867,7 +1022,11 @@ func (m *MockConfigStore) GetRIUtilizationCache(ctx context.Context, region stri if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.RIUtilizationCacheEntry), args.Error(1) + val, ok := args.Get(0).(*config.RIUtilizationCacheEntry) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) UpsertRIUtilizationCache(ctx context.Context, region string, lookbackDays int, payload []byte, fetchedAt time.Time) error { @@ -888,7 +1047,11 @@ func (m *MockConfigStore) GetAccountRegistration(ctx context.Context, id string) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.AccountRegistration), args.Error(1) + val, ok := args.Get(0).(*config.AccountRegistration) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) GetAccountRegistrationByToken(ctx context.Context, token string) (*config.AccountRegistration, error) { @@ -896,7 +1059,11 @@ func (m *MockConfigStore) GetAccountRegistrationByToken(ctx context.Context, tok if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*config.AccountRegistration), args.Error(1) + val, ok := args.Get(0).(*config.AccountRegistration) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) ListAccountRegistrations(ctx context.Context, filter config.AccountRegistrationFilter) ([]config.AccountRegistration, error) { @@ -904,7 +1071,11 @@ func (m *MockConfigStore) ListAccountRegistrations(ctx context.Context, filter c if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.AccountRegistration), args.Error(1) + val, ok := args.Get(0).([]config.AccountRegistration) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) UpdateAccountRegistration(ctx context.Context, reg *config.AccountRegistration) error { @@ -966,7 +1137,11 @@ func (m *MockConfigStore) ListActiveSuppressions(ctx context.Context) ([]config. if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.PurchaseSuppression), args.Error(1) + val, ok := args.Get(0).([]config.PurchaseSuppression) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // GetPendingExecutionsTx mocks the GetPendingExecutionsTx operation. @@ -981,7 +1156,11 @@ func (m *MockConfigStore) GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.PurchaseExecution), args.Error(1) + val, ok := args.Get(0).([]config.PurchaseExecution) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } func (m *MockConfigStore) SavePurchaseExecutionTx(ctx context.Context, tx pgx.Tx, execution *config.PurchaseExecution) error { @@ -1009,7 +1188,11 @@ func (m *MockConfigStore) GetExecutionsByStatuses(ctx context.Context, statuses if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.PurchaseExecution), args.Error(1) + val, ok := args.Get(0).([]config.PurchaseExecution) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // GetPlannedExecutions mocks the GetPlannedExecutions operation. @@ -1018,7 +1201,11 @@ func (m *MockConfigStore) GetPlannedExecutions(ctx context.Context, statuses []s if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.PurchaseExecution), args.Error(1) + val, ok := args.Get(0).([]config.PurchaseExecution) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // GetStaleApprovedExecutions mocks the GetStaleApprovedExecutions operation. @@ -1027,7 +1214,11 @@ func (m *MockConfigStore) GetStaleApprovedExecutions(ctx context.Context, olderT if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.PurchaseExecution), args.Error(1) + val, ok := args.Get(0).([]config.PurchaseExecution) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // ListStuckExecutions mocks the ListStuckExecutions operation. @@ -1036,7 +1227,11 @@ func (m *MockConfigStore) ListStuckExecutions(ctx context.Context, statuses []st if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.PurchaseExecution), args.Error(1) + val, ok := args.Get(0).([]config.PurchaseExecution) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // GetScheduledExecutionsDue mocks the GetScheduledExecutionsDue operation. @@ -1050,7 +1245,11 @@ func (m *MockConfigStore) GetScheduledExecutionsDue(ctx context.Context) ([]conf if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]config.PurchaseExecution), args.Error(1) + val, ok := args.Get(0).([]config.PurchaseExecution) + if !ok { + return nil, args.Error(1) + } + return val, args.Error(1) } // MarkCollectionStarted mocks the MarkCollectionStarted operation. @@ -1073,14 +1272,14 @@ func (m *MockConfigStore) ClearCollectionStarted(ctx context.Context) error { } // StampRIExchangeApprovedBy mocks the StampRIExchangeApprovedBy operation. -func (m *MockConfigStore) StampRIExchangeApprovedBy(ctx context.Context, id string, approverEmail string) error { +func (m *MockConfigStore) StampRIExchangeApprovedBy(ctx context.Context, id, approverEmail string) error { args := m.Called(ctx, id, approverEmail) return args.Error(0) } // isExpected reports whether mock has any .On() expectation for method. -func isExpected(mock *mock.Mock, method string) bool { - for _, call := range mock.ExpectedCalls { +func isExpected(m *mock.Mock, method string) bool { + for _, call := range m.ExpectedCalls { if call.Method == method { return true } @@ -1088,6 +1287,6 @@ func isExpected(mock *mock.Mock, method string) bool { return false } -// Compile-time interface compliance checks +// Compile-time interface compliance checks. var _ config.StoreInterface = (*MockConfigStore)(nil) var _ auth.StoreInterface = (*MockAuthStore)(nil) diff --git a/internal/purchase/coverage_extra_test.go b/internal/purchase/coverage_extra_test.go index 078145fc4..68b3b7487 100644 --- a/internal/purchase/coverage_extra_test.go +++ b/internal/purchase/coverage_extra_test.go @@ -109,7 +109,7 @@ func TestResolveAccountProvider_UnknownProvider(t *testing.T) { ID: "acc-1", Provider: "unknown-cloud", } - result, err := m.resolveAccountProvider(context.Background(), account) + result, err := m.resolveAccountProvider(context.Background(), &account) assert.Error(t, err) assert.Contains(t, err.Error(), "unknown cloud provider") assert.Nil(t, result) @@ -126,7 +126,7 @@ func TestResolveAWSProvider_NoSTS(t *testing.T) { AWSAuthMode: "assume_role", AWSRoleARN: "arn:aws:iam::123456789012:role/testrole", } - result, err := m.resolveAWSProvider(context.Background(), account) + result, err := m.resolveAWSProvider(context.Background(), &account) // Without STS, returns error (not silent nil) assert.Error(t, err) assert.Nil(t, result) @@ -142,7 +142,7 @@ func TestResolveAzureProvider_NoCredStoreNoManagedIdentity(t *testing.T) { Provider: "azure", AzureAuthMode: "service_principal", // not managed_identity } - result, err := m.resolveAzureProvider(context.Background(), account) + result, err := m.resolveAzureProvider(context.Background(), &account) assert.Error(t, err) assert.Nil(t, result) } @@ -157,7 +157,7 @@ func TestResolveGCPProvider_NoCredStoreNoADC(t *testing.T) { Provider: "gcp", GCPAuthMode: "service_account_key", // not application_default } - result, err := m.resolveGCPProvider(context.Background(), account) + result, err := m.resolveGCPProvider(context.Background(), &account) assert.Error(t, err) assert.Nil(t, result) } @@ -174,7 +174,7 @@ func TestResolveGCPProvider_ApplicationDefault(t *testing.T) { GCPProjectID: "my-project", } // application_default → returns (nil, nil) since ADC is ambient - result, err := m.resolveGCPProvider(context.Background(), account) + result, err := m.resolveGCPProvider(context.Background(), &account) assert.NoError(t, err) assert.Nil(t, result) } @@ -607,7 +607,7 @@ func TestManager_ExecuteSinglePurchase_ProviderError(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) assert.Error(t, err) assert.Contains(t, err.Error(), "some purchases failed") assert.Equal(t, "failed to create aws provider: provider unavailable", exec.Recommendations[0].Error) @@ -656,7 +656,7 @@ func TestManager_ExecuteSinglePurchase_ServiceClientError(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) assert.Error(t, err) assert.Contains(t, err.Error(), "some purchases failed") assert.Contains(t, exec.Recommendations[0].Error, "failed to get service client") @@ -708,7 +708,7 @@ func TestManager_ExecuteSinglePurchase_PurchaseNotSuccessful(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) assert.Error(t, err) assert.Contains(t, err.Error(), "some purchases failed") assert.Contains(t, exec.Recommendations[0].Error, "purchase was not successful") @@ -761,7 +761,7 @@ func TestManager_ExecuteSinglePurchase_PurchaseNotSuccessful_WithError(t *testin dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) assert.Error(t, err) assert.Contains(t, exec.Recommendations[0].Error, "capacity limit exceeded") } @@ -817,7 +817,7 @@ func TestManager_ExecuteSinglePurchase_WithEngine(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) assert.True(t, exec.Recommendations[0].Purchased) assert.Equal(t, "ri-engine-001", exec.Recommendations[0].PurchaseID) @@ -875,7 +875,7 @@ func TestManager_SavePurchaseHistory_Error(t *testing.T) { } // Should succeed even though history save failed - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) assert.True(t, exec.Recommendations[0].Purchased) } @@ -924,7 +924,7 @@ func TestManager_SavePurchaseHistory_RevocationWindow(t *testing.T) { } result := common.PurchaseResult{Success: true, CommitmentID: "commit-rev-001"} - err := manager.savePurchaseHistory(ctx, exec, plan, rec, result, "acct-1") + err := manager.savePurchaseHistory(ctx, exec, plan, &rec, &result, "acct-1") require.NoError(t, err) require.NotNil(t, captured) @@ -1183,7 +1183,7 @@ func TestManager_ExecuteSinglePurchase_DetailsByService(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err = manager.executePurchase(ctx, exec) + err = manager.executePurchase(ctx, exec) require.NoError(t, err, "purchase should not return the regression error 'invalid service details for '") assert.True(t, exec.Recommendations[0].Purchased, "rec should be marked purchased") assert.Empty(t, exec.Recommendations[0].Error, "rec error should be empty") @@ -1321,7 +1321,7 @@ func TestManager_ExecuteSinglePurchase_LegacyEmptyDetails(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err, "legacy empty-Details rec must still purchase cleanly") require.NotNil(t, capturedRec.Details, "rec.Details handed to the cloud client must be non-nil even for legacy rows") tc.assertDetails(t, capturedRec.Details) diff --git a/internal/purchase/execution.go b/internal/purchase/execution.go index a5a43b890..d9414422a 100644 --- a/internal/purchase/execution.go +++ b/internal/purchase/execution.go @@ -27,15 +27,16 @@ import ( // PurchaseExecution record tagged with cloud_account_id. // If no accounts are configured or no credential store is available, it falls back // to single-account execution using ambient credentials. -// executePurchase runs the purchase for a single execution. Returns wasMultiAccount=true when -// fan-out was used (per-account records are already saved; caller should skip root record save). -func (m *Manager) executePurchase(ctx context.Context, exec *config.PurchaseExecution) (wasMultiAccount bool, err error) { +// executePurchase runs the purchase for a single execution. It fans out across +// the plan's accounts when they are configured (each account saves its own +// per-account record), otherwise it runs the legacy single-account path. +func (m *Manager) executePurchase(ctx context.Context, exec *config.PurchaseExecution) error { logging.Infof("Executing purchase for plan %q, step %d", exec.PlanID, exec.StepNumber) // Direct-execute purchases (Opportunities "Purchase" button) arrive // with no associated plan. PlanID is empty and the Postgres UUID // column rejects "" with SQLSTATE 22P02, so skip the plan/accounts - // fetch entirely and synthesise a placeholder plan whose Name is the + // fetch entirely and synthesize a placeholder plan whose Name is the // only field downstream history/notification code reads. By // definition direct-execute purchases target a single account, so // fall straight through to the legacy single-account path. @@ -43,28 +44,29 @@ func (m *Manager) executePurchase(ctx context.Context, exec *config.PurchaseExec if exec.PlanID == "" { plan = &config.PurchasePlan{Name: "Direct purchase"} } else { + var err error plan, err = m.config.GetPurchasePlan(ctx, exec.PlanID) if err != nil { - return false, fmt.Errorf("failed to get plan: %w", err) + return fmt.Errorf("failed to get plan: %w", err) } if plan == nil { - return false, fmt.Errorf("plan not found: %s", exec.PlanID) + return fmt.Errorf("plan not found: %s", exec.PlanID) } // Fan out across plan accounts when accounts are configured. if exec.CloudAccountID == nil { accounts, err := m.config.GetPlanAccounts(ctx, exec.PlanID) if err != nil { - return false, fmt.Errorf("failed to load plan accounts for plan %s: %w", exec.PlanID, err) + return fmt.Errorf("failed to load plan accounts for plan %s: %w", exec.PlanID, err) } if len(accounts) > 0 { - return true, m.executeMultiAccount(ctx, exec, plan, accounts) + return m.executeMultiAccount(ctx, exec, plan, accounts) } } } // Single-account (legacy) path. - return false, m.executeSingleAccount(ctx, exec, plan) + return m.executeSingleAccount(ctx, exec, plan) } // executeSingleAccount runs the legacy single-account purchase path: resolve @@ -138,16 +140,16 @@ func anyRecPurchased(recs []config.RecommendationRecord) bool { // multiAccountPartialError is the sentinel returned by executeMultiAccount when // at least one account committed a real purchase while one or more others -// failed (issue #1014). It is the multi-account analogue of partialPurchaseError: +// failed (issue #1014). It is the multi-account analog of partialPurchaseError: // the executor entry points must NOT treat this as a flat failure — the // per-account rows already own their authoritative status (partially_completed / // completed / failed) and real commitments exist, so an SQS/cron caller must ACK // the message (not redeliver) to avoid re-running the fan-out and double-buying // the accounts that already succeeded (which #1012's stable key would otherwise -// dedupe, but the contract should not depend on that second line of defence). +// dedupe, but the contract should not depend on that second line of defense). type multiAccountPartialError struct { - committed int errors []string + committed int } func (e *multiAccountPartialError) Error() string { @@ -168,7 +170,7 @@ var errAllAccountsFailed = errors.New("multi-account execution: all accounts fai // (wrapping the per-account errors) when no account committed anything. func (m *Manager) executeMultiAccount(ctx context.Context, baseExec *config.PurchaseExecution, plan *config.PurchasePlan, accounts []config.CloudAccount) error { results := execution.RunForAccountsWithConcurrency(ctx, accounts, func(ctx context.Context, account config.CloudAccount) (bool, error) { - return m.executeForAccount(ctx, baseExec, plan, account) + return m.executeForAccount(ctx, baseExec, plan, &account) }, getMaxAccountParallelism()) committed := 0 @@ -205,7 +207,7 @@ func (m *Manager) executeMultiAccount(ctx context.Context, baseExec *config.Purc // right sentinel (issue #1014). The returned error is non-nil whenever any rec // failed, but the authoritative per-account row has already been saved with the // correct status (partially_completed / failed) before it surfaces. -func (m *Manager) executeForAccount(ctx context.Context, baseExec *config.PurchaseExecution, plan *config.PurchasePlan, account config.CloudAccount) (committed bool, err error) { +func (m *Manager) executeForAccount(ctx context.Context, baseExec *config.PurchaseExecution, plan *config.PurchasePlan, account *config.CloudAccount) (committed bool, err error) { // Create a per-account copy of the execution record with an independent // Recommendations slice so concurrent goroutines don't race on writes. acctID := account.ID @@ -229,7 +231,7 @@ func (m *Manager) executeForAccount(ctx context.Context, baseExec *config.Purcha if err != nil { acctExec.Status = "failed" acctExec.Error = err.Error() - _ = m.config.SavePurchaseExecution(ctx, &acctExec) + m.saveExecWithLog(ctx, &acctExec, account.ID) return false, fmt.Errorf("credential resolution failed for account %s: %w", account.ID, err) } @@ -296,7 +298,7 @@ func (m *Manager) executeForAccount(ctx context.Context, baseExec *config.Purcha // The second return value is the resolved target account's ExternalID — the // provider-appropriate account identifier (AWS account number, Azure // subscription, GCP project) that the caller stamps onto purchase_history -// (#646). It is "" when no target account could be identified, signalling the +// (#646). It is "" when no target account could be identified, signaling the // caller to fall back to the ambient AWS STS identity. // // The account is taken from exec.CloudAccountID when set (plan-with-single- @@ -337,7 +339,7 @@ func (m *Manager) resolveSingleAccountProvider(ctx context.Context, exec *config // fallback once a target account ID is known. return nil, "", fmt.Errorf("credential resolution failed for account %s: account not found", *cloudAccountID) } - provCfg, err := m.resolveAccountProvider(ctx, *account) + provCfg, err := m.resolveAccountProvider(ctx, account) if err != nil { return nil, "", fmt.Errorf("credential resolution failed for account %s: %w", *cloudAccountID, err) } @@ -361,7 +363,7 @@ func (e *partialPurchaseError) Error() string { // resolveAccountProvider returns a *ProviderConfig with a pre-authenticated provider // for the given account. Returns an error if credential resolution fails -- callers // must NOT fall back to ambient credentials on error. -func (m *Manager) resolveAccountProvider(ctx context.Context, account config.CloudAccount) (*provider.ProviderConfig, error) { +func (m *Manager) resolveAccountProvider(ctx context.Context, account *config.CloudAccount) (*provider.ProviderConfig, error) { t0 := time.Now() logging.Infof("purchase[resolveAccountProvider]: resolving credentials for provider=%s account=%s", account.Provider, account.ID) @@ -387,14 +389,14 @@ func (m *Manager) resolveAccountProvider(ctx context.Context, account config.Clo return cfg, nil } -func (m *Manager) resolveAWSProvider(ctx context.Context, account config.CloudAccount) (*provider.ProviderConfig, error) { +func (m *Manager) resolveAWSProvider(ctx context.Context, account *config.CloudAccount) (*provider.ProviderConfig, error) { t0 := time.Now() logging.Infof("purchase[resolveAWSProvider]: resolving AWS credentials for account=%s authMode=%s", account.ID, account.AWSAuthMode) if account.AWSAuthMode != "access_keys" && m.assumeRoleSTS == nil { return nil, fmt.Errorf("credentials: STS client not configured for non-access_keys mode (account %s)", account.ID) } - awsCreds, err := credentials.ResolveAWSCredentialProviderWithOpts(ctx, &account, m.credStore, m.assumeRoleSTS, + awsCreds, err := credentials.ResolveAWSCredentialProviderWithOpts(ctx, account, m.credStore, m.assumeRoleSTS, credentials.AWSResolveOptions{AmbientProvider: m.ambientAWSCreds}) if err != nil { logging.Errorf("purchase[resolveAWSProvider]: failed for account=%s after %s: %v", @@ -406,14 +408,14 @@ func (m *Manager) resolveAWSProvider(ctx context.Context, account config.CloudAc return &provider.ProviderConfig{Name: "aws", AWSCredentialsProvider: awsCreds}, nil } -func (m *Manager) resolveAzureProvider(ctx context.Context, account config.CloudAccount) (*provider.ProviderConfig, error) { +func (m *Manager) resolveAzureProvider(ctx context.Context, account *config.CloudAccount) (*provider.ProviderConfig, error) { t0 := time.Now() logging.Infof("purchase[resolveAzureProvider]: resolving Azure credentials for account=%s authMode=%s", account.ID, account.AzureAuthMode) if account.AzureAuthMode != "managed_identity" && m.credStore == nil { return nil, fmt.Errorf("credentials: credential store required for non-managed_identity Azure account %s", account.ID) } - azCred, err := credentials.ResolveAzureTokenCredentialWithOpts(ctx, &account, m.credStore, credentials.AzureResolveOptions{ + azCred, err := credentials.ResolveAzureTokenCredentialWithOpts(ctx, account, m.credStore, credentials.AzureResolveOptions{ Signer: m.oidcSigner, IssuerURL: m.oidcIssuerURL, }) @@ -434,14 +436,14 @@ func (m *Manager) resolveAzureProvider(ctx context.Context, account config.Cloud return &provider.ProviderConfig{ProviderOverride: azProv}, nil } -func (m *Manager) resolveGCPProvider(ctx context.Context, account config.CloudAccount) (*provider.ProviderConfig, error) { +func (m *Manager) resolveGCPProvider(ctx context.Context, account *config.CloudAccount) (*provider.ProviderConfig, error) { t0 := time.Now() logging.Infof("purchase[resolveGCPProvider]: resolving GCP credentials for account=%s authMode=%s", account.ID, account.GCPAuthMode) if account.GCPAuthMode != "application_default" && m.credStore == nil { return nil, fmt.Errorf("credentials: credential store required for non-ADC GCP account %s", account.ID) } - gcpTS, err := credentials.ResolveGCPTokenSourceWithOpts(ctx, &account, m.credStore, credentials.GCPResolveOptions{ + gcpTS, err := credentials.ResolveGCPTokenSourceWithOpts(ctx, account, m.credStore, credentials.GCPResolveOptions{ Signer: m.oidcSigner, IssuerURL: m.oidcIssuerURL, }) @@ -464,7 +466,7 @@ func (m *Manager) resolveGCPProvider(ctx context.Context, account config.CloudAc // getMaxAccountParallelism is a thin alias over the shared // execution.ConcurrencyFromEnv so the purchase manager and the scheduler -// both honour the same CUDLY_MAX_ACCOUNT_PARALLELISM override. +// both honor the same CUDLY_MAX_ACCOUNT_PARALLELISM override. func getMaxAccountParallelism() int { return execution.ConcurrencyFromEnv() } @@ -474,12 +476,12 @@ func getMaxAccountParallelism() int { // savePurchaseHistory from a single goroutine (no concurrent map / slice // mutation). The index field is the position in exec.Recommendations. type recPurchaseOutcome struct { - index int purchase common.PurchaseResult err error + index int } -func (m *Manager) processPurchaseRecommendations(ctx context.Context, exec *config.PurchaseExecution, plan *config.PurchasePlan, accountID string, provCfg *provider.ProviderConfig) (float64, float64, []string) { +func (m *Manager) processPurchaseRecommendations(ctx context.Context, exec *config.PurchaseExecution, plan *config.PurchasePlan, accountID string, provCfg *provider.ProviderConfig) (totalSavings, totalUpfront float64, purchaseErrors []string) { // ExecutionID is carried into PurchaseOptions so executeSinglePurchase // can tag every per-rec log line with the owning exec UUID. Without // this, CloudWatch filtering by exec ID returns zero hits and a stuck @@ -524,7 +526,7 @@ func (m *Manager) processPurchaseRecommendations(ctx context.Context, exec *conf if i < 0 || i >= len(exec.Recommendations) { return recPurchaseOutcome{}, fmt.Errorf("fan-out index %d out of range for %d recommendations", i, len(exec.Recommendations)) } - rec := exec.Recommendations[i] + rec := &exec.Recommendations[i] logging.Infof("Purchasing: %dx %s in %s (%s/%s)", rec.Count, rec.ResourceType, rec.Region, rec.Provider, rec.Service) // Derive a deterministic per-rec idempotency token from the // execution's STABLE lineage key (not its mutable ExecutionID) @@ -556,9 +558,7 @@ func (m *Manager) processPurchaseRecommendations(ctx context.Context, exec *conf // so the aggregation logic is single-threaded — no concurrent writes to // totals, purchaseErrors, or exec.Recommendations[i] regardless of how // many recs ran in parallel. -func (m *Manager) aggregatePurchaseOutcomes(ctx context.Context, exec *config.PurchaseExecution, plan *config.PurchasePlan, accountID string, results []execution.Result[recPurchaseOutcome]) (float64, float64, []string) { - var totalSavings, totalUpfront float64 - var purchaseErrors []string +func (m *Manager) aggregatePurchaseOutcomes(ctx context.Context, exec *config.PurchaseExecution, plan *config.PurchasePlan, accountID string, results []execution.Result[recPurchaseOutcome]) (totalSavings, totalUpfront float64, purchaseErrors []string) { for _, r := range results { // Closure-level error (parse / bounds / framework). r.Value is the // zero recPurchaseOutcome here — its index is 0 and would mis-target @@ -571,7 +571,7 @@ func (m *Manager) aggregatePurchaseOutcomes(ctx context.Context, exec *config.Pu } v := r.Value i := v.index - // Defence-in-depth: even with the closure's bounds check, never + // Defense-in-depth: even with the closure's bounds check, never // index past exec.Recommendations here (a future refactor that // mutates the slice between fan-out and aggregation would corrupt // this otherwise). @@ -580,7 +580,7 @@ func (m *Manager) aggregatePurchaseOutcomes(ctx context.Context, exec *config.Pu purchaseErrors = append(purchaseErrors, fmt.Sprintf("aggregator index %d out of range", i)) continue } - rec := exec.Recommendations[i] + rec := &exec.Recommendations[i] if v.err != nil { logging.Errorf("Failed to purchase %s: %v", rec.ResourceType, v.err) exec.Recommendations[i].Error = v.err.Error() @@ -591,7 +591,7 @@ func (m *Manager) aggregatePurchaseOutcomes(ctx context.Context, exec *config.Pu exec.Recommendations[i].PurchaseID = v.purchase.CommitmentID totalSavings += rec.Savings totalUpfront += rec.UpfrontCost - if histErr := m.savePurchaseHistory(ctx, exec, plan, rec, v.purchase, accountID); histErr != nil { + if histErr := m.savePurchaseHistory(ctx, exec, plan, rec, &v.purchase, accountID); histErr != nil { // The purchase SUCCEEDED but its purchase_history row failed to // persist. Do NOT add this to purchaseErrors — that would flip the // execution to "failed" and tempt the user to re-approve a purchase @@ -608,7 +608,7 @@ func (m *Manager) aggregatePurchaseOutcomes(ctx context.Context, exec *config.Pu // recordHistoryAuditGap stamps exec.Error with a note that a successful // purchase's history record could not be saved (issue #621). The execution // keeps a successful status; the marker is what makes the row visible in the -// History view (which synthesises completed executions that carry an Error). +// History view (which synthesizes completed executions that carry an Error). // Appends rather than overwrites so multiple failed history writes within one // execution are all recorded. // historyAuditGapPrefix is the structured prefix stamped on exec.Error by @@ -639,7 +639,7 @@ func recordHistoryAuditGap(exec *config.PurchaseExecution, commitmentID string, // token and the provider dedupes the purchase. It falls back to ExecutionID // only for legacy rows persisted before migration 000066 (IdempotencyKey == ""); // for a single un-retried execution that fallback is identical to the pre-fix -// behaviour, and such legacy rows never gain a retry successor that could +// behavior, and such legacy rows never gain a retry successor that could // diverge (the retry handler seeds the successor's key from the predecessor's // ExecutionID in that case, preserving the match). func idempotencyLineageKey(exec *config.PurchaseExecution) string { @@ -665,7 +665,7 @@ func appendErrNote(existing, note string) string { } // normalizePurchaseSource canonicalizes exec.Source for downstream tag -// stamping. Defence-in-depth: NormalizeSource rejects anything outside +// stamping. Defense-in-depth: NormalizeSource rejects anything outside // the allowed whitelist; an unexpected value (DB tampering, future // code path) is dropped to "" rather than fed onto a cloud commitment // where it would be expensive to retract. @@ -674,7 +674,7 @@ func appendErrNote(existing, note string) string { // failing the rec over a tag-only field would abort a successful cloud // purchase, which is a worse outcome than a missing tag. Input // validation at the API write boundary (exec.Source on save) is the -// correct gate; this fallback is last-resort defence-in-depth. +// correct gate; this fallback is last-resort defense-in-depth. func (m *Manager) normalizePurchaseSource(exec *config.PurchaseExecution) string { source := exec.Source if source == "" { @@ -693,8 +693,8 @@ func (m *Manager) normalizePurchaseSource(exec *config.PurchaseExecution) string // results writes back to exec.Recommendations deterministically. func selectedIndices(recs []config.RecommendationRecord) []int { out := make([]int, 0, len(recs)) - for i, rec := range recs { - if rec.Selected { + for i := range recs { + if recs[i].Selected { out = append(out, i) } } @@ -737,7 +737,16 @@ func singleCloudAccountIDFromRecs(recs []config.RecommendationRecord) *string { // caller can record an audit gap on the execution: a swallowed failure here // used to leave the execution silently "completed" with no purchase_history // row, making the purchase invisible in the History view (issue #621). -func (m *Manager) savePurchaseHistory(ctx context.Context, exec *config.PurchaseExecution, plan *config.PurchasePlan, rec config.RecommendationRecord, result common.PurchaseResult, accountID string) error { +// saveExecWithLog saves a purchase execution record and logs a non-fatal error if it fails. +// The caller is responsible for continuing or returning after this call, since persistence +// failures on the early-error path are logged but should not mask the original error. +func (m *Manager) saveExecWithLog(ctx context.Context, exec *config.PurchaseExecution, accountID string) { + if err := m.config.SavePurchaseExecution(ctx, exec); err != nil { + logging.Errorf("execution: failed to persist purchase execution status for account %s: %v", accountID, err) + } +} + +func (m *Manager) savePurchaseHistory(ctx context.Context, exec *config.PurchaseExecution, plan *config.PurchasePlan, rec *config.RecommendationRecord, result *common.PurchaseResult, accountID string) error { purchasedAt := time.Now() historyRecord := &config.PurchaseHistoryRecord{ AccountID: accountID, @@ -786,7 +795,8 @@ func (m *Manager) buildPurchaseConfirmationData(exec *config.PurchaseExecution, data.ArcheraEducationURL = dashboardBase + "/archera-insurance" } - for _, rec := range exec.Recommendations { + for i := range exec.Recommendations { + rec := &exec.Recommendations[i] if rec.Purchased { data.Recommendations = append(data.Recommendations, email.RecommendationSummary{ Service: rec.Service, @@ -803,7 +813,7 @@ func (m *Manager) buildPurchaseConfirmationData(exec *config.PurchaseExecution, } // logRecCtxErr emits a diagnostic log line when a per-recommendation context has -// been cancelled or timed out. It distinguishes DeadlineExceeded (the 30s per-rec +// been canceled or timed out. It distinguishes DeadlineExceeded (the 30s per-rec // budget fired) from Canceled (a parent context stopped the execution) so that // CloudWatch filters can tell the two apart without parsing error strings. // It is a no-op when recCtxErr is nil. @@ -824,7 +834,7 @@ func logRecCtxErr(executionID, recTuple string, elapsed time.Duration, recCtxErr // provCfg carries optional per-account credentials; pass nil to use ambient credentials. // opts carries execution-level metadata (the source surface) that providers stamp // onto the commitment they create. -func (m *Manager) executeSinglePurchase(ctx context.Context, rec config.RecommendationRecord, provCfg *provider.ProviderConfig, opts common.PurchaseOptions) (common.PurchaseResult, error) { +func (m *Manager) executeSinglePurchase(ctx context.Context, rec *config.RecommendationRecord, provCfg *provider.ProviderConfig, opts common.PurchaseOptions) (common.PurchaseResult, error) { // Per-purchase Info logs tagged with the owning execution ID so a // CloudWatch filter on the execution UUID surfaces every step of the // purchase attempt -- provider construction, service-client lookup, @@ -949,7 +959,7 @@ func (m *Manager) executeSinglePurchase(ctx context.Context, rec config.Recommen // mapServiceType maps a service string to common.ServiceType. Both the // canonical hyphenated slugs (compute, relational-db, cache, search, // data-warehouse) and the legacy AWS-only slugs (ec2, rds, elasticache, -// opensearch, redshift, memorydb) are recognised; everything else passes +// opensearch, redshift, memorydb) are recognized; everything else passes // through verbatim. Savings Plans slugs are normalised by mapSavingsPlansSlug. func (m *Manager) mapServiceType(service string) common.ServiceType { if svc, ok := mapSavingsPlansSlug(service); ok { diff --git a/internal/purchase/execution_test.go b/internal/purchase/execution_test.go index 6eb8ad387..c60a995d0 100644 --- a/internal/purchase/execution_test.go +++ b/internal/purchase/execution_test.go @@ -98,7 +98,7 @@ func TestManager_ExecutePurchase(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) // Verify that only selected recommendation was purchased @@ -164,7 +164,7 @@ func TestManager_ExecutePurchase_WebSourcePropagates(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) mockServiceClient.AssertExpectations(t) } @@ -220,7 +220,7 @@ func TestManager_ExecutePurchase_InvalidSourceFallsBackUntagged(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) mockServiceClient.AssertExpectations(t) } @@ -244,7 +244,7 @@ func TestManager_ExecutePurchase_PlanNotFound(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) assert.Error(t, err) assert.Contains(t, err.Error(), "plan not found") @@ -270,7 +270,7 @@ func TestManager_ExecutePurchase_GetPlanError(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to get plan") @@ -308,7 +308,7 @@ func TestManager_ExecutePurchase_NoRecommendations(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) mockStore.AssertExpectations(t) @@ -532,7 +532,7 @@ func TestManager_ExecutePurchase_MultiAccount(t *testing.T) { // assumeRoleSTS is nil → access_keys path, no role assumption needed } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) // The original exec record should be unchanged (fan-out creates per-account copies). @@ -643,7 +643,7 @@ func TestExecuteForAccount_CredentialFailure_MarksFailed(t *testing.T) { credStore: credStore, } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) // Must surface the credential failure — no ambient fallback. require.Error(t, err) @@ -775,7 +775,7 @@ func TestExecuteMultiAccount_PartialFailure_IsolatesAccounts(t *testing.T) { credStore: credStore, } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) // The call must return an error (account-I's failure is aggregated). // This proves the errgroup collected the failure rather than discarding it. @@ -942,7 +942,7 @@ func TestExecuteMultiAccount_RunsAccountsInParallel(t *testing.T) { } start := time.Now() - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) elapsed := time.Since(start) require.NoError(t, err, "both accounts have valid credentials and should succeed") @@ -1061,7 +1061,7 @@ func TestExecutePurchase_SingleAccount_AzureUsesResolvedCreds(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) mockStore.AssertExpectations(t) @@ -1180,7 +1180,7 @@ func TestExecutePurchase_AzureCanonicalServiceTypes(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) mockStore.AssertExpectations(t) @@ -1225,7 +1225,7 @@ func TestExecutePurchase_SingleAccount_CredResolutionError(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.Error(t, err) assert.Contains(t, err.Error(), "credential resolution failed for account "+acctID) assert.Contains(t, err.Error(), "connection refused") @@ -1268,7 +1268,7 @@ func TestExecutePurchase_SingleAccount_AccountNotFound(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.Error(t, err) assert.Contains(t, err.Error(), "credential resolution failed for account "+acctID) assert.Contains(t, err.Error(), "account not found") @@ -1556,7 +1556,7 @@ func TestExecuteForAccount_PartialSuccess(t *testing.T) { dashboardURL: "https://dashboard.example.com", } - committed, err := manager.executeForAccount(ctx, baseExec, plan, account) + committed, err := manager.executeForAccount(ctx, baseExec, plan, &account) require.Error(t, err, "the per-rec failure must surface to the aggregator") assert.True(t, committed, "a partial run committed at least one rec, so committed must be true (issue #1014)") @@ -1692,7 +1692,7 @@ func TestManager_ExecutePurchase_SingleAccount_StampsTargetAccount(t *testing.T) dashboardURL: "https://dashboard.example.com", } - _, err := manager.executePurchase(ctx, exec) + err := manager.executePurchase(ctx, exec) require.NoError(t, err) assert.Equal(t, tc.externalID, stampedAccount, diff --git a/internal/purchase/manager.go b/internal/purchase/manager.go index c6879109c..e430c4dcc 100644 --- a/internal/purchase/manager.go +++ b/internal/purchase/manager.go @@ -227,7 +227,7 @@ func isMultiAccountAckable(execErr error) bool { // claimAndExecute claims the root to "running" first (issue #1013), would strand // the root row in "running" until the reaper failed it. func (m *Manager) executeAndFinalize(ctx context.Context, exec *config.PurchaseExecution) error { - _, execErr := m.executePurchase(ctx, exec) + execErr := m.executePurchase(ctx, exec) m.finalizeExecution(exec, execErr) if execErr != nil { logging.Errorf("Failed to execute purchase %s: %v", exec.ExecutionID, execErr) diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 203a6241a..1dcf5c25a 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -38,36 +38,19 @@ type STSClient interface { GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) } -// SchedulerConfig holds configuration for the scheduler -type SchedulerConfig struct { +// Config holds configuration for the scheduler. +type Config struct { ConfigStore config.StoreInterface PurchaseManager ManagerInterface EmailSender email.SenderInterface - DashboardURL string - // Provider factory for creating cloud providers (allows injection for testing) ProviderFactory provider.FactoryInterface - // Per-account credential resolution (mirrors purchase manager) CredentialStore credentials.CredentialStore OIDCSigner oidc.Signer - OIDCIssuerURL string AssumeRoleSTS credentials.STSClient - - // STSClient is the runtime AWS STS client used to discover the - // Lambda's own AWS account ID on the ambient collection path. When - // the discovered ID matches a registered cloud_accounts row (by - // external_id), the ambient path stamps that account's UUID onto - // every rec it returns so the approve modal shows the registered - // name instead of `(ambient)`. Optional — when nil, the ambient - // path keeps its pre-fix behaviour (CloudAccountID = nil), which - // preserves the truly-orphan case. - STSClient STSClient - - // IsLambda gates the stale-while-revalidate background goroutine. - // On Lambda, goroutines freeze between invocations — firing one from - // a request handler would corrupt state — so we fall back to the - // scheduled cron + manual refresh. Cloud Run / Container Apps run - // long-lived processes where the goroutine is safe. - IsLambda bool + STSClient STSClient + DashboardURL string + OIDCIssuerURL string + IsLambda bool } // CollectResult holds the result of collecting recommendations. @@ -77,13 +60,13 @@ type SchedulerConfig struct { // clause to providers that actually ran, and the frontend banner can // surface the specific failures. type CollectResult struct { + FailedProviders map[string]string `json:"failed_providers,omitempty"` + SuccessfulProviders []string `json:"successful_providers,omitempty"` Recommendations int `json:"recommendations"` TotalSavings float64 `json:"total_savings"` - SuccessfulProviders []string `json:"successful_providers,omitempty"` - FailedProviders map[string]string `json:"failed_providers,omitempty"` } -// ManagerInterface defines the purchase manager methods used by scheduler +// ManagerInterface defines the purchase manager methods used by scheduler. type ManagerInterface interface { ProcessScheduledPurchases(ctx context.Context) (*purchase.ProcessResult, error) SendUpcomingPurchaseNotifications(ctx context.Context) (*purchase.NotificationResult, error) @@ -93,31 +76,21 @@ type ManagerInterface interface { FireScheduledDelayedPurchases(ctx context.Context) (*purchase.FireResult, error) } -// Scheduler handles scheduled tasks +// Scheduler handles scheduled tasks. type Scheduler struct { - config config.StoreInterface + stsClient STSClient purchase ManagerInterface email email.SenderInterface - dashboardURL string providerFactory provider.FactoryInterface credStore credentials.CredentialStore oidcSigner oidc.Signer - oidcIssuerURL string assumeRoleSTS credentials.STSClient - stsClient STSClient - - // isLambda gates the stale-while-revalidate background goroutine. See - // SchedulerConfig.IsLambda for the rationale. - isLambda bool - - // cacheTTL is the age past which opportunistic background refresh kicks - // in on non-Lambda runtimes. Parsed from CUDLY_RECOMMENDATION_CACHE_TTL - // at NewScheduler time; defaults to 6h. - cacheTTL time.Duration - - // collecting is a single-flight guard so N concurrent stale reads only - // trigger ONE background refresh. - collecting atomic.Bool + config config.StoreInterface + dashboardURL string + oidcIssuerURL string + cacheTTL time.Duration + collecting atomic.Bool + isLambda bool } // defaultCacheTTL is the fallback when CUDLY_RECOMMENDATION_CACHE_TTL is @@ -125,8 +98,14 @@ type Scheduler struct { // opportunistic refresh closes the gap when users are active. const defaultCacheTTL = 6 * time.Hour -// NewScheduler creates a new scheduler -func NewScheduler(cfg SchedulerConfig) *Scheduler { +// NewScheduler creates a new scheduler. cfg must be non-nil: a nil config +// would build a Scheduler with every dependency unset, surfacing only as a +// confusing nil dereference later. Fail loud at construction instead (callers +// always pass a populated *Config). +func NewScheduler(cfg *Config) *Scheduler { + if cfg == nil { + panic("scheduler: NewScheduler requires a non-nil *Config") + } factory := cfg.ProviderFactory if factory == nil { factory = &provider.DefaultFactory{} @@ -220,16 +199,21 @@ func (s *Scheduler) CollectRecommendations(ctx context.Context) (*CollectResult, // // Provider-level fan-out under errgroup. Each goroutine returns nil to the // group so a single provider's failure does not cancel siblings — matches - // the previous loop's `continue`-on-error behaviour. Per-provider results + // the previous loop's `continue`-on-error behavior. Per-provider results // are written into a map under a single mutex; the merge then walks // EnabledProviders in config order so successfulProviders ordering is // deterministic regardless of goroutine completion order. After Wait, ctx // cancellation is propagated. No concurrency cap — the universe is at // most 3 providers. - allRecommendations, totalSavings, successfulProviders, successfulCollects, failedProviders, err := s.collectAllProviders(ctx, globalCfg) + collected, err := s.collectAllProviders(ctx, globalCfg) if err != nil { return nil, err } + allRecommendations := collected.allRecommendations + totalSavings := collected.totalSavings + successfulProviders := collected.successfulProviders + successfulCollects := collected.successfulCollects + failedProviders := collected.failedProviders logging.Infof("Collected %d recommendations with $%.2f/month potential savings", len(allRecommendations), totalSavings) @@ -247,10 +231,11 @@ func (s *Scheduler) CollectRecommendations(ctx context.Context) (*CollectResult, DashboardURL: s.dashboardURL, TotalSavings: totalSavings, } - for _, rec := range allRecommendations { + for i := range allRecommendations { if len(data.Recommendations) >= 10 { // Limit to top 10 in email break } + rec := &allRecommendations[i] data.Recommendations = append(data.Recommendations, email.RecommendationSummary{ Service: rec.Service, ResourceType: rec.ResourceType, @@ -295,9 +280,9 @@ func (s *Scheduler) CollectRecommendations(ctx context.Context) (*CollectResult, // succeeded (possibly partially — partial-account-failure semantics live in // fanOutPerAccount, not here). type providerOutcome struct { + err error recs []config.RecommendationRecord succeededAccountIDs []string - err error } // collectAllProviders fans out provider collection (AWS / Azure / GCP) under @@ -312,15 +297,20 @@ type providerOutcome struct { // Extracted from CollectRecommendations to keep that function under the // project's gocyclo gate (.golangci.yml min-complexity: 15) after the // errgroup + post-Wait ctx.Err() block was added. -func (s *Scheduler) collectAllProviders(ctx context.Context, globalCfg *config.GlobalConfig) ( - allRecommendations []config.RecommendationRecord, - totalSavings float64, - successfulProviders []string, - successfulCollects []config.SuccessfulCollect, - failedProviders map[string]string, - err error, -) { - failedProviders = map[string]string{} +// +// collectAllResult bundles the per-provider aggregate outputs so +// collectAllProviders returns a single value (avoids the >5-result signature). +type collectAllResult struct { + failedProviders map[string]string + allRecommendations []config.RecommendationRecord + successfulProviders []string + successfulCollects []config.SuccessfulCollect + totalSavings float64 +} + +func (s *Scheduler) collectAllProviders(ctx context.Context, globalCfg *config.GlobalConfig) (collectAllResult, error) { + var out collectAllResult + out.failedProviders = map[string]string{} var ( mu sync.Mutex @@ -345,36 +335,41 @@ func (s *Scheduler) collectAllProviders(ctx context.Context, globalCfg *config.G }) } - // Wait for all goroutines. g.Wait() always returns nil because every - // goroutine returns nil. After Wait, propagate ctx cancellation so - // callers can distinguish "all providers completed" from "the parent - // ctx was canceled mid-fan-out". - _ = g.Wait() + // Wait for all goroutines. Each goroutine returns nil to isolate per-provider + // failures (stored in outcomes map), so g.Wait() should normally be nil; a + // non-nil result means a goroutine returned an unexpected hard error, which + // must be surfaced rather than logged-and-merged (a partial/incorrect + // aggregate reported as success). g.Wait() does not surface parent ctx + // cancellation when every goroutine returns nil, so check ctx.Err() + // separately afterwards. + if waitErr := g.Wait(); waitErr != nil { + return collectAllResult{}, fmt.Errorf("scheduler: collectAllProviders wait failed: %w", waitErr) + } if cerr := ctx.Err(); cerr != nil { - return nil, 0, nil, nil, nil, cerr + return collectAllResult{}, cerr } // Deterministic merge: walk EnabledProviders in config order so // successfulProviders ordering is independent of goroutine completion // order — keeps existing tests stable. for _, providerName := range globalCfg.EnabledProviders { - out, ok := outcomes[providerName] + oc, ok := outcomes[providerName] if !ok { continue } - if out.err != nil { - logging.Errorf("Failed to collect %s recommendations: %v", providerName, out.err) - failedProviders[providerName] = out.err.Error() + if oc.err != nil { + logging.Errorf("Failed to collect %s recommendations: %v", providerName, oc.err) + out.failedProviders[providerName] = oc.err.Error() continue } - successfulProviders = append(successfulProviders, providerName) - successfulCollects = append(successfulCollects, expandSuccessfulCollects(providerName, out.succeededAccountIDs)...) - for _, rec := range out.recs { - totalSavings += rec.Savings + out.successfulProviders = append(out.successfulProviders, providerName) + out.successfulCollects = append(out.successfulCollects, expandSuccessfulCollects(providerName, oc.succeededAccountIDs)...) + for i := range oc.recs { + out.totalSavings += oc.recs[i].Savings } - allRecommendations = append(allRecommendations, out.recs...) + out.allRecommendations = append(out.allRecommendations, oc.recs...) } - return allRecommendations, totalSavings, successfulProviders, successfulCollects, failedProviders, nil + return out, nil } // clearCollectionStartedBestEffort clears last_collection_started_at on the @@ -457,7 +452,7 @@ func expandSuccessfulCollects(providerName string, accountIDs []string) []config // If no accounts are registered and CUDly runs on AWS, it falls back to // ambient credentials (backward compatibility with single-account setups). // Returns the merged recommendations + the IDs of accounts that succeeded -// (or [""] for the ambient path so the caller can synthesise a nil +// (or [""] for the ambient path so the caller can synthesize a nil // CloudAccountID for eviction). func (s *Scheduler) collectAWSRecommendations(ctx context.Context, globalCfg *config.GlobalConfig) ([]config.RecommendationRecord, []string, error) { accounts := s.enabledAccounts(ctx, "aws") @@ -481,7 +476,7 @@ func (s *Scheduler) collectAWSRecommendations(ctx context.Context, globalCfg *co return recs, []string{""}, nil } - recs, outcome := fanOutPerAccount(ctx, "AWS", accounts, func(ctx context.Context, acct config.CloudAccount) ([]config.RecommendationRecord, error) { + recs, outcome := fanOutPerAccount(ctx, "AWS", accounts, func(ctx context.Context, acct *config.CloudAccount) ([]config.RecommendationRecord, error) { return s.collectAWSForAccount(ctx, globalCfg, acct) }) if outcome.FailedCount == len(accounts) && len(accounts) > 0 { @@ -506,10 +501,10 @@ func (s *Scheduler) collectAWSRecommendations(ctx context.Context, globalCfg *co // account-scoped eviction. Order is not preserved — the eviction // query treats it as a set. type accountOutcome struct { + LastErr string + SucceededAccountIDs []string SucceededCount int FailedCount int - LastErr string // most-recent per-account error message, for surfacing in the banner - SucceededAccountIDs []string // IDs of accounts that succeeded this run } // fanOutPerAccount runs fn concurrently across accounts, bounded by the @@ -526,7 +521,7 @@ func fanOutPerAccount( ctx context.Context, providerLabel string, accounts []config.CloudAccount, - fn func(ctx context.Context, acct config.CloudAccount) ([]config.RecommendationRecord, error), + fn func(ctx context.Context, acct *config.CloudAccount) ([]config.RecommendationRecord, error), ) ([]config.RecommendationRecord, accountOutcome) { g, gctx := errgroup.WithContext(ctx) g.SetLimit(execution.ConcurrencyFromEnv()) @@ -537,8 +532,8 @@ func fanOutPerAccount( var all []config.RecommendationRecord var outcome accountOutcome - for _, acct := range accounts { - acct := acct // capture + for i := range accounts { + acct := &accounts[i] // per-iteration pointer for the goroutine closure g.Go(func() error { recs, err := fn(gctx, acct) if err != nil { @@ -567,7 +562,16 @@ func fanOutPerAccount( return nil }) } - _ = g.Wait() // errs are always nil (swallowed above) + if waitErr := g.Wait(); waitErr != nil { + // Goroutines swallow per-account errors above and return nil, so this + // only fires on an unexpected hard error. Surface it through the + // outcome (the function has no error return) so the caller's + // all-accounts-failed detection treats it as a real failure rather + // than silently merging a partial result as success. + logging.Errorf("scheduler: unexpected fanOut g.Wait error: %v", waitErr) + outcome.FailedCount++ + outcome.LastErr = fmt.Sprintf("collection wait failed: %v", waitErr) + } return all, outcome } @@ -580,7 +584,7 @@ func fanOutPerAccount( // // Currently dispatches per provider: // - "GCP": gcpprovider.IsPermissionError (HTTP 403 / gRPC PermissionDenied) -// - other providers: false (existing ERROR behaviour preserved until +// - other providers: false (existing ERROR behavior preserved until // analogous predicates are added for AWS/Azure) func isAccountPermissionError(providerLabel string, err error) bool { if err == nil { @@ -652,17 +656,17 @@ func (s *Scheduler) resolveAmbientHostAccountID(ctx context.Context) string { // resolveAmbientHostAccountID: given the host's external identifier (subscription // ID for Azure, project ID for GCP) it checks whether a registered cloud_accounts // row exists for (provider, externalID) and returns its UUID. Returns "" on any -// error or when no row matches, preserving the pre-fix nil-tagging behaviour so +// error or when no row matches, preserving the pre-fix nil-tagging behavior so // truly-orphan deployments are unaffected. All errors are intentionally swallowed // (logged at warn) — this is a best-effort UX improvement on the ambient path // and must not break the collection. -func (s *Scheduler) resolveAmbientAccountID(ctx context.Context, provider, externalID string) string { +func (s *Scheduler) resolveAmbientAccountID(ctx context.Context, providerName, externalID string) string { if externalID == "" { return "" } - acct, err := s.config.GetCloudAccountByExternalID(ctx, provider, externalID) + acct, err := s.config.GetCloudAccountByExternalID(ctx, providerName, externalID) if err != nil { - logging.Warnf("ambient host-account lookup: GetCloudAccountByExternalID(%s,%s) failed: %v", provider, externalID, err) + logging.Warnf("ambient host-account lookup: GetCloudAccountByExternalID(%s,%s) failed: %v", providerName, externalID, err) return "" } if acct == nil { @@ -714,7 +718,7 @@ func (s *Scheduler) collectGCPAmbient(ctx context.Context) ([]config.Recommendat return s.convertRecommendations(recs, "gcp"), nil } -func (s *Scheduler) collectAWSForAccount(ctx context.Context, globalCfg *config.GlobalConfig, acct config.CloudAccount) ([]config.RecommendationRecord, error) { +func (s *Scheduler) collectAWSForAccount(ctx context.Context, globalCfg *config.GlobalConfig, acct *config.CloudAccount) ([]config.RecommendationRecord, error) { // Self-account (role_arn with no role ARN) or ambient modes use ambient credentials if acct.AWSRoleARN == "" { prov, err := s.providerFactory.CreateAndValidateProvider(ctx, "aws", nil) @@ -723,7 +727,7 @@ func (s *Scheduler) collectAWSForAccount(ctx context.Context, globalCfg *config. } return s.fetchAndConvert(ctx, prov, "aws", &acct.ID, globalCfg) } - awsCreds, err := credentials.ResolveAWSCredentialProvider(ctx, &acct, s.credStore, s.assumeRoleSTS) + awsCreds, err := credentials.ResolveAWSCredentialProvider(ctx, acct, s.credStore, s.assumeRoleSTS) if err != nil { return nil, fmt.Errorf("resolve credentials: %w", err) } @@ -771,8 +775,8 @@ func (s *Scheduler) collectAzureRecommendations(ctx context.Context, _ *config.G return recs, outcome.SucceededAccountIDs, nil } -func (s *Scheduler) collectAzureForAccount(ctx context.Context, acct config.CloudAccount) ([]config.RecommendationRecord, error) { - azCred, err := credentials.ResolveAzureTokenCredentialWithOpts(ctx, &acct, s.credStore, credentials.AzureResolveOptions{ +func (s *Scheduler) collectAzureForAccount(ctx context.Context, acct *config.CloudAccount) ([]config.RecommendationRecord, error) { + azCred, err := credentials.ResolveAzureTokenCredentialWithOpts(ctx, acct, s.credStore, credentials.AzureResolveOptions{ Signer: s.oidcSigner, IssuerURL: s.oidcIssuerURL, }) @@ -832,8 +836,8 @@ func (s *Scheduler) collectGCPRecommendations(ctx context.Context, _ *config.Glo return recs, outcome.SucceededAccountIDs, nil } -func (s *Scheduler) collectGCPForAccount(ctx context.Context, acct config.CloudAccount) ([]config.RecommendationRecord, error) { - gcpTS, err := credentials.ResolveGCPTokenSourceWithOpts(ctx, &acct, s.credStore, credentials.GCPResolveOptions{ +func (s *Scheduler) collectGCPForAccount(ctx context.Context, acct *config.CloudAccount) ([]config.RecommendationRecord, error) { + gcpTS, err := credentials.ResolveGCPTokenSourceWithOpts(ctx, acct, s.credStore, credentials.GCPResolveOptions{ Signer: s.oidcSigner, IssuerURL: s.oidcIssuerURL, }) @@ -846,9 +850,9 @@ func (s *Scheduler) collectGCPForAccount(ctx context.Context, acct config.CloudA prov = gcpprovider.NewProviderWithCredentials(ctx, acct.GCPProjectID, gcpTS) } else { // ADC mode (application_default): use ambient credentials - created, err := s.providerFactory.CreateAndValidateProvider(ctx, "gcp", nil) - if err != nil { - return nil, fmt.Errorf("create ambient GCP provider: %w", err) + created, errX := s.providerFactory.CreateAndValidateProvider(ctx, "gcp", nil) + if errX != nil { + return nil, fmt.Errorf("create ambient GCP provider: %w", errX) } prov = created } @@ -898,7 +902,11 @@ func (s *Scheduler) fetchAndConvert(ctx context.Context, prov provider.Provider, PaymentOption: globalCfg.DefaultPayment, LookbackPeriod: fmt.Sprintf("%dd", lookbackDays), } - recs, _ = recClient.GetRecommendations(ctx, params) + var getErr error + recs, getErr = recClient.GetRecommendations(ctx, params) + if getErr != nil { + logging.Warnf("scheduler: failed to get recommendations for %s (continuing without account-level recs): %v", providerName, getErr) + } } result := s.convertRecommendations(recs, providerName) if accountID != nil { @@ -930,7 +938,7 @@ func (s *Scheduler) tagAccount(recs []config.RecommendationRecord, accountID str // aren't on Lambda, kick off a background CollectRecommendations so // the NEXT read sees fresh data. Lambda skips this (goroutines freeze // between invocations); the scheduled cron is Lambda's refresh path. -func (s *Scheduler) ListRecommendations(ctx context.Context, filter config.RecommendationFilter) ([]config.RecommendationRecord, error) { +func (s *Scheduler) ListRecommendations(ctx context.Context, filter *config.RecommendationFilter) ([]config.RecommendationRecord, error) { logging.Info("Reading recommendations from cache...") freshness, err := s.config.GetRecommendationsFreshness(ctx) @@ -940,8 +948,8 @@ func (s *Scheduler) ListRecommendations(ctx context.Context, filter config.Recom if freshness.LastCollectedAt == nil { logging.Info("Recommendations cache is empty; performing synchronous cold-start collect") - if _, err := s.CollectRecommendations(ctx); err != nil { - return nil, fmt.Errorf("cold-start collect failed: %w", err) + if _, collectErr := s.CollectRecommendations(ctx); collectErr != nil { + return nil, fmt.Errorf("cold-start collect failed: %w", collectErr) } } @@ -995,7 +1003,7 @@ func (s *Scheduler) ListRecommendations(ctx context.Context, filter config.Recom // // Returns (nil, nil, nil) when the rec is genuinely absent or fully suppressed. func (s *Scheduler) GetRecommendationByID(ctx context.Context, id string) (rec *config.RecommendationRecord, hiddenBy []string, err error) { - recs, err := s.config.ListStoredRecommendations(ctx, config.RecommendationFilter{ID: id}) + recs, err := s.config.ListStoredRecommendations(ctx, &config.RecommendationFilter{ID: id}) if err != nil { return nil, nil, fmt.Errorf("GetRecommendationByID: store lookup: %w", err) } @@ -1085,10 +1093,10 @@ type suppressionKey struct { // "Xd remaining"), and the execution whose suppression contributed // the most (drives the badge deep-link). type suppressionAgg struct { - suppressedCount int earliestExpiresAt time.Time - primaryExecutionID string primaryExecutionCreated time.Time + primaryExecutionID string + suppressedCount int primaryExecutionContrib int } @@ -1148,7 +1156,8 @@ func applySuppressionIndex(recs []config.RecommendationRecord, index map[suppres // Allocate a fresh backing array so callers that hold a reference to // the original recs slice do not see mutations (05-M1). out := make([]config.RecommendationRecord, 0, len(recs)) - for _, rec := range recs { + for i := range recs { + rec := recs[i] // explicit working copy: this loop mutates rec before appending accountID := "" if rec.CloudAccountID != nil { accountID = *rec.CloudAccountID @@ -1269,7 +1278,7 @@ func extractEngine(details common.ServiceDetails) string { // without Details and falls through to the graceful-degradation path in // common.DecodeServiceDetailsFor. Extracted from convertRecommendations // to keep that function under the gocyclo budget. -func marshalRecDetails(rec common.Recommendation, providerName string) []byte { +func marshalRecDetails(rec *common.Recommendation, providerName string) []byte { blob, err := common.MarshalServiceDetails(rec.Details) if err != nil { logging.Warnf("Failed to marshal service details for %s/%s rec (%s): %v — persisting without Details", @@ -1279,13 +1288,14 @@ func marshalRecDetails(rec common.Recommendation, providerName string) []byte { return blob } -// convertRecommendations converts common.Recommendation slice to config.RecommendationRecord slice +// convertRecommendations converts common.Recommendation slice to config.RecommendationRecord slice. func (s *Scheduler) convertRecommendations(recs []common.Recommendation, providerName string) []config.RecommendationRecord { records := make([]config.RecommendationRecord, 0, len(recs)) - for _, rec := range recs { + for i := range recs { + rec := recs[i] // explicit working copy: this loop mutates rec.PaymentOption below engine := extractEngine(rec.Details) - detailsBlob := marshalRecDetails(rec, providerName) + detailsBlob := marshalRecDetails(&rec, providerName) // Canonicalize PaymentOption at the emission boundary so a downstream // plan-validator round-trip never sees a cross-provider/AWS-style diff --git a/internal/scheduler/scheduler_overrides_test.go b/internal/scheduler/scheduler_overrides_test.go index c01cec255..31e05b409 100644 --- a/internal/scheduler/scheduler_overrides_test.go +++ b/internal/scheduler/scheduler_overrides_test.go @@ -24,7 +24,7 @@ type mockOverrideStore struct { getOverrideErr error } -func (m *mockOverrideStore) ListStoredRecommendations(_ context.Context, filter config.RecommendationFilter) ([]config.RecommendationRecord, error) { +func (m *mockOverrideStore) ListStoredRecommendations(_ context.Context, filter *config.RecommendationFilter) ([]config.RecommendationRecord, error) { if filter.ID == "" { return m.recs, nil } @@ -106,7 +106,7 @@ func TestApplyAccountOverrides_DisabledOverride_DropsAccountSvcRecs(t *testing.T } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, recs, 1, "acct-A's rec dropped; acct-B's kept") assert.Equal(t, "acct-B", *recs[0].CloudAccountID) @@ -125,7 +125,7 @@ func TestApplyAccountOverrides_GlobalDisabled_DropsAllRecsForService(t *testing. } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) assert.Empty(t, recs, "global Enabled=false drops all per-account recs for the service") } @@ -138,7 +138,7 @@ func TestApplyAccountOverrides_NoGlobalConfig_RecsPassThrough(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) assert.Len(t, recs, 1, "no global config -> no per-account policy applies -> rec passes through") } @@ -158,7 +158,7 @@ func TestApplyAccountOverrides_NilCloudAccountID_PassesThrough(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) assert.Len(t, recs, 1, "nil CloudAccountID recs are not subject to per-account override policy") } @@ -179,7 +179,7 @@ func TestApplyAccountOverrides_IncludeEngineMatch(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, recs, 1) assert.Equal(t, "mysql", recs[0].Engine, "non-matching engine filtered out") @@ -201,7 +201,7 @@ func TestApplyAccountOverrides_ExcludeEngine(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, recs, 1) assert.Equal(t, "mysql", recs[0].Engine) @@ -234,7 +234,7 @@ func TestApplyAccountOverrides_RegionAndTypeFilters(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, recs, 1) assert.Equal(t, "us-east-1", recs[0].Region) @@ -265,7 +265,7 @@ func TestApplyAccountOverrides_MinCount_DropsRecsBelowFloor(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, recs, 2, "count=1 must be dropped; count>=2 kept") counts := []int{recs[0].Count, recs[1].Count} @@ -287,7 +287,7 @@ func TestApplyAccountOverrides_MinCountZero_KeepsAll(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, recs, 1, "MinCount=0 disables the floor") } @@ -317,7 +317,7 @@ func TestApplyAccountOverrides_EmptyEngine_NotFilteredByIncludeEngines(t *testin } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) assert.Len(t, recs, 1, "engine-less rec not filtered by IncludeEngines") } @@ -332,7 +332,7 @@ func TestApplyAccountOverrides_LookupError_PassesThrough(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err, "ListRecommendations swallows the override-resolver error") assert.Len(t, recs, 1, "un-filtered list returned on lookup failure") } @@ -388,7 +388,7 @@ func TestApplyAccountOverrides_OverrideLookupError_PassesThrough(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err, "ListRecommendations swallows the override-resolver error") assert.Len(t, recs, 1, "un-filtered list returned on override lookup failure") } @@ -413,7 +413,7 @@ func TestApplyAccountOverrides_AcceptanceCriterion_Issue196(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, recs, 1, "acct-A's recs hidden by the override") assert.Equal(t, "acct-B", *recs[0].CloudAccountID) diff --git a/internal/scheduler/scheduler_suppressions_test.go b/internal/scheduler/scheduler_suppressions_test.go index 9d64411bc..164bbe49e 100644 --- a/internal/scheduler/scheduler_suppressions_test.go +++ b/internal/scheduler/scheduler_suppressions_test.go @@ -21,7 +21,7 @@ type mockSuppressionStore struct { sups []config.PurchaseSuppression } -func (m *mockSuppressionStore) ListStoredRecommendations(_ context.Context, _ config.RecommendationFilter) ([]config.RecommendationRecord, error) { +func (m *mockSuppressionStore) ListStoredRecommendations(_ context.Context, _ *config.RecommendationFilter) ([]config.RecommendationRecord, error) { return m.recs, nil } func (m *mockSuppressionStore) ListActiveSuppressions(_ context.Context) ([]config.PurchaseSuppression, error) { @@ -78,7 +78,7 @@ func TestApplySuppressions_SubtractsCount(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, recs, 1) assert.Equal(t, 2, recs[0].Count, "5 - 3 = 2") @@ -106,7 +106,7 @@ func TestApplySuppressions_DropsFullyCoveredRecs(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) assert.Empty(t, recs, "rec fully covered by suppression should be dropped") } @@ -132,7 +132,7 @@ func TestApplySuppressions_CumulativeAcrossExecutions(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, recs, 1) assert.Equal(t, 3, recs[0].Count, "10 - 3 - 4 = 3") @@ -165,7 +165,7 @@ func TestApplySuppressions_EngineDifferentiates(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, recs, 1) assert.Equal(t, 5, recs[0].Count, "engine mismatch → no subtraction") @@ -191,7 +191,7 @@ func TestApplySuppressions_NilAccountIDNormalised(t *testing.T) { } s := &Scheduler{config: store} - recs, err := s.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := s.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) require.Len(t, recs, 1) assert.Equal(t, 3, recs[0].Count, "5 - 2 = 3 (nil account matched to empty-string suppression)") diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 2a380201d..4628efb56 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -176,7 +176,7 @@ func TestSchedulerConfig(t *testing.T) { mockPurchase := new(MockPurchaseManager) mockEmail := new(MockEmailSender) - cfg := SchedulerConfig{ + cfg := Config{ ConfigStore: mockStore, PurchaseManager: nil, // We'd use mockPurchase but types don't match in test EmailSender: nil, // We'd use mockEmail but types don't match in test @@ -194,17 +194,26 @@ func TestSchedulerConfig(t *testing.T) { func TestNewScheduler(t *testing.T) { mockStore := new(MockConfigStore) - cfg := SchedulerConfig{ + cfg := Config{ ConfigStore: mockStore, DashboardURL: "https://dashboard.example.com", } - scheduler := NewScheduler(cfg) + scheduler := NewScheduler(&cfg) assert.NotNil(t, scheduler) assert.Equal(t, "https://dashboard.example.com", scheduler.dashboardURL) } +func TestNewScheduler_NilConfigPanics(t *testing.T) { + // A nil config is a programming error: building a Scheduler with every + // dependency unset would only surface as a confusing nil deref later, so + // NewScheduler fails loud at construction. + assert.PanicsWithValue(t, + "scheduler: NewScheduler requires a non-nil *Config", + func() { NewScheduler(nil) }) +} + func TestScheduler_CollectRecommendations_NoProviders(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -565,12 +574,12 @@ func TestScheduler_CollectRecommendations_WithNotification(t *testing.T) { func TestScheduler_Interface(t *testing.T) { mockStore := new(MockConfigStore) - cfg := SchedulerConfig{ + cfg := Config{ ConfigStore: mockStore, DashboardURL: "https://test.example.com", } - scheduler := NewScheduler(cfg) + scheduler := NewScheduler(&cfg) // Verify scheduler has required fields assert.NotNil(t, scheduler.config) @@ -747,7 +756,7 @@ func TestScheduler_ListRecommendations(t *testing.T) { scheduler := &Scheduler{config: mockStore} - recs, err := scheduler.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := scheduler.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) assert.Len(t, recs, 2) } @@ -776,7 +785,7 @@ func TestScheduler_ListRecommendations_StaleHoursZeroDisablesBackgroundRefresh(t scheduler := &Scheduler{config: mockStore} - recs, err := scheduler.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := scheduler.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) assert.Len(t, recs, 1) @@ -800,7 +809,7 @@ func TestScheduler_ListRecommendations_PassesFilterToStore(t *testing.T) { mockStore.On("GetRecommendationsFreshness", ctx). Return(&config.RecommendationsFreshness{LastCollectedAt: &now}, nil) - expected := config.RecommendationFilter{ + expected := &config.RecommendationFilter{ Provider: "aws", Service: "ec2", Region: "us-east-1", @@ -815,7 +824,7 @@ func TestScheduler_ListRecommendations_PassesFilterToStore(t *testing.T) { scheduler := &Scheduler{config: mockStore} - _, err := scheduler.ListRecommendations(ctx, config.RecommendationFilter{ + _, err := scheduler.ListRecommendations(ctx, &config.RecommendationFilter{ Provider: "aws", Service: "ec2", Region: "us-east-1", @@ -833,7 +842,7 @@ func TestScheduler_ListRecommendations_FreshnessError(t *testing.T) { mockStore.On("GetRecommendationsFreshness", ctx).Return(nil, assert.AnError) scheduler := &Scheduler{config: mockStore} - recs, err := scheduler.ListRecommendations(ctx, config.RecommendationFilter{}) + recs, err := scheduler.ListRecommendations(ctx, &config.RecommendationFilter{}) require.Error(t, err) assert.Nil(t, recs) } @@ -857,7 +866,7 @@ func TestScheduler_ListRecommendations_LambdaSkipsBackgroundRefresh(t *testing.T cacheTTL: time.Nanosecond, } - _, err := scheduler.ListRecommendations(ctx, config.RecommendationFilter{}) + _, err := scheduler.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) // Give any (wrongly-spawned) goroutine time to hit the store; none @@ -914,7 +923,7 @@ func TestScheduler_ListRecommendations_ColdStartSync(t *testing.T) { scheduler := &Scheduler{config: mockStore} - _, err := scheduler.ListRecommendations(ctx, config.RecommendationFilter{}) + _, err := scheduler.ListRecommendations(ctx, &config.RecommendationFilter{}) require.NoError(t, err) // Assert the cold-start path ran: GetGlobalConfig is only called by @@ -948,7 +957,7 @@ func TestFanOutPerAccount_RespectsParallelismLimit(t *testing.T) { } } - fn := func(ctx context.Context, acct config.CloudAccount) ([]config.RecommendationRecord, error) { + fn := func(ctx context.Context, acct *config.CloudAccount) ([]config.RecommendationRecord, error) { cur := inflight.Add(1) updatePeak(cur) // Small sleep so concurrent workers genuinely overlap. @@ -976,7 +985,7 @@ func TestFanOutPerAccount_AllAccountsFail(t *testing.T) { {ID: "acct-2", Name: "acct-2", ExternalID: "ext-2"}, {ID: "acct-3", Name: "acct-3", ExternalID: "ext-3"}, } - fn := func(ctx context.Context, acct config.CloudAccount) ([]config.RecommendationRecord, error) { + fn := func(ctx context.Context, acct *config.CloudAccount) ([]config.RecommendationRecord, error) { return nil, fmt.Errorf("cred error for %s", acct.ID) } @@ -1000,7 +1009,7 @@ func TestFanOutPerAccount_PartialSuccess(t *testing.T) { {ID: "acct-ok-2", Name: "acct-ok-2", ExternalID: "e2"}, {ID: "acct-bad", Name: "acct-bad", ExternalID: "ebad"}, } - fn := func(ctx context.Context, acct config.CloudAccount) ([]config.RecommendationRecord, error) { + fn := func(ctx context.Context, acct *config.CloudAccount) ([]config.RecommendationRecord, error) { if acct.ID == "acct-bad" { return nil, fmt.Errorf("transient") } @@ -1020,7 +1029,7 @@ func TestFanOutPerAccount_PartialSuccess(t *testing.T) { // correctly skips the all-failed error path. func TestFanOutPerAccount_ZeroAccounts(t *testing.T) { recs, outcome := fanOutPerAccount(context.Background(), "Test", nil, - func(ctx context.Context, acct config.CloudAccount) ([]config.RecommendationRecord, error) { + func(ctx context.Context, acct *config.CloudAccount) ([]config.RecommendationRecord, error) { t.Fatalf("fn must not be called for zero-accounts input") return nil, nil }) @@ -1276,8 +1285,8 @@ func TestScheduler_ConvertRecommendations_IDUniqueness(t *testing.T) { // "only Details.Engine differs" property holds at every level // (Service / ResourceType already match across the pair). cases := []struct { - name string recs func() (common.Recommendation, common.Recommendation) + name string }{ { name: "term: 1yr vs 3yr (issue #188 — AWS 1yr recs were vanishing)", @@ -1447,8 +1456,15 @@ func TestScheduler_CollectAWSRecommendations_GetRecsError(t *testing.T) { assert.Nil(t, recs) } -// Test successful Azure recommendations -func TestScheduler_CollectAzureRecommendations_Success(t *testing.T) { +// TestScheduler_CollectAzureRecommendations_AllAccountsFail pins the +// fail-loud contract introduced by the recent provider changes: when every +// enabled Azure account fails (here: DefaultAzureCredential is unavailable +// in the test environment), collectAzureRecommendations must surface an +// "all accounts failed" error rather than silently returning a partial / +// empty result that would be merged into the aggregate as success. +// The test validates the per-account loop runs without crashing AND that +// the aggregate error contract holds. +func TestScheduler_CollectAzureRecommendations_AllAccountsFail(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -1469,17 +1485,17 @@ func TestScheduler_CollectAzureRecommendations_Success(t *testing.T) { } mockStore.On("ListCloudAccounts", mock.Anything, mock.Anything).Return(azureAccounts, nil) - // The managed_identity path will try DefaultAzureCredential which will - // fail in tests, so we expect an error log but no crash. + // The managed_identity path will try DefaultAzureCredential which fails + // in tests; with 1 enabled account, FailedCount == len(accounts) so the + // fan-out must return errAllAccountsFailed (no silent partial success). scheduler := &Scheduler{ config: mockStore, } recs, _, err := scheduler.collectAzureRecommendations(ctx, globalCfg) - require.NoError(t, err) - // In test environment without Azure credentials, 0 recommendations is expected - // (the error is logged and skipped). The test validates the per-account loop runs. - _ = recs + require.Error(t, err) + assert.Contains(t, err.Error(), "all 1 accounts failed") + assert.Nil(t, recs) } // Test GCP recommendations with no accounts — should skip gracefully @@ -1606,8 +1622,8 @@ func TestScheduler_CollectAWSRecommendations_FallbackToFiltered(t *testing.T) { // fields are set by each test case to drive the GetCallerIdentity response // shape (success with an account ID, or an error). type fakeSTSClient struct { - accountID string err error + accountID string } func (f *fakeSTSClient) GetCallerIdentity(ctx context.Context, _ *sts.GetCallerIdentityInput, _ ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { diff --git a/internal/server/adapter_test.go b/internal/server/adapter_test.go index 1e8b54dbb..b454437b9 100644 --- a/internal/server/adapter_test.go +++ b/internal/server/adapter_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/LeanerCloud/CUDly/internal/api" + api "github.com/LeanerCloud/CUDly/internal/api" "github.com/LeanerCloud/CUDly/internal/auth" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" diff --git a/internal/server/app.go b/internal/server/app.go index 39b7b227c..317c4807e 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -15,7 +15,7 @@ import ( "time" "github.com/LeanerCloud/CUDly/internal/analytics" - "github.com/LeanerCloud/CUDly/internal/api" + api "github.com/LeanerCloud/CUDly/internal/api" "github.com/LeanerCloud/CUDly/internal/auth" "github.com/LeanerCloud/CUDly/internal/commitmentopts" "github.com/LeanerCloud/CUDly/internal/config" @@ -447,7 +447,7 @@ func NewApplicationFromDeps(ctx context.Context, cfg ApplicationConfig, deps Ext }) // Initialize scheduler - sched := scheduler.NewScheduler(scheduler.SchedulerConfig{ + sched := scheduler.NewScheduler(&scheduler.Config{ ConfigStore: deps.ConfigStore, PurchaseManager: purchaseManager, EmailSender: deps.EmailSender, @@ -481,7 +481,7 @@ func NewApplicationFromDeps(ctx context.Context, cfg ApplicationConfig, deps Ext } // Initialize API handler - apiHandler := api.NewHandler(api.HandlerConfig{ + apiHandler := api.NewHandler(&api.HandlerConfig{ ConfigStore: deps.ConfigStore, PurchaseManager: purchaseManager, Scheduler: sched, @@ -788,7 +788,7 @@ func (app *Application) reinitializeAfterConnect(ctx context.Context, dbConn *da log.Println("Re-initialized purchase manager with credential store and cross-account STS") // Re-initialize scheduler with per-account credential resolution. - app.Scheduler = scheduler.NewScheduler(scheduler.SchedulerConfig{ + app.Scheduler = scheduler.NewScheduler(&scheduler.Config{ ConfigStore: app.Config, PurchaseManager: app.Purchase, EmailSender: app.Email, @@ -835,7 +835,7 @@ func (app *Application) reinitializeAfterConnect(ctx context.Context, dbConn *da // AnalyticsClient is Postgres-backed (see api.NewPostgresAnalyticsClient) — // it aggregates purchase_history on demand so the History UI charts work // without requiring a separate S3/Athena deployment. - app.API = api.NewHandler(api.HandlerConfig{ + app.API = api.NewHandler(&api.HandlerConfig{ ConfigStore: app.Config, CredentialStore: credStore, PurchaseManager: app.Purchase, diff --git a/internal/server/app_test.go b/internal/server/app_test.go index 481918df2..a959f8053 100644 --- a/internal/server/app_test.go +++ b/internal/server/app_test.go @@ -14,7 +14,7 @@ import ( "testing" "time" - "github.com/LeanerCloud/CUDly/internal/api" + api "github.com/LeanerCloud/CUDly/internal/api" "github.com/LeanerCloud/CUDly/internal/database" "github.com/LeanerCloud/CUDly/internal/email" "github.com/LeanerCloud/CUDly/internal/purchase" @@ -144,7 +144,7 @@ func TestLambdaResponseToHTTP_InvalidBase64(t *testing.T) { func TestHandleHTTPRequest(t *testing.T) { app := &Application{ - API: api.NewHandler(api.HandlerConfig{}), + API: api.NewHandler(&api.HandlerConfig{}), } req := httptest.NewRequest("GET", "/api/health", nil) @@ -158,7 +158,7 @@ func TestHandleHTTPRequest(t *testing.T) { func TestHandleHTTPRequest_WithBody(t *testing.T) { app := &Application{ - API: api.NewHandler(api.HandlerConfig{}), + API: api.NewHandler(&api.HandlerConfig{}), } body := bytes.NewReader([]byte(`{"test":"data"}`)) @@ -619,7 +619,7 @@ func TestInitConfigStore(t *testing.T) { func TestHandleHTTPRequest_EnsureDBError(t *testing.T) { app := &Application{ - API: api.NewHandler(api.HandlerConfig{}), + API: api.NewHandler(&api.HandlerConfig{}), dbConfig: &database.Config{Host: "unreachable"}, dbErr: fmt.Errorf("connection failed"), } @@ -634,7 +634,7 @@ func TestHandleHTTPRequest_EnsureDBError(t *testing.T) { func TestHandleLambdaEvent_EnsureDBError(t *testing.T) { app := &Application{ - API: api.NewHandler(api.HandlerConfig{}), + API: api.NewHandler(&api.HandlerConfig{}), dbConfig: &database.Config{Host: "unreachable"}, dbErr: fmt.Errorf("connection failed"), } diff --git a/internal/server/http.go b/internal/server/http.go index 45d349665..cfb06b5ea 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -14,7 +14,7 @@ import ( "syscall" "time" - "github.com/LeanerCloud/CUDly/internal/api" + api "github.com/LeanerCloud/CUDly/internal/api" "github.com/aws/aws-lambda-go/events" ) @@ -121,7 +121,7 @@ func (app *Application) handleOIDCHTTP(w http.ResponseWriter, r *http.Request) { lambdaReq := httpToLambdaRequest(r) resp, handled := app.API.HandleOIDC(ctx, lambdaReq) if !handled { - // Path matched /oidc/ prefix but is not a recognised OIDC endpoint. + // Path matched /oidc/ prefix but is not a recognized OIDC endpoint. http.NotFound(w, r) return } @@ -152,7 +152,7 @@ func securityHeaders(next http.Handler) http.Handler { }) } -// handleHTTPRequest converts standard HTTP requests to Lambda Function URL format +// handleHTTPRequest converts standard HTTP requests to Lambda Function URL format. func (app *Application) handleHTTPRequest(w http.ResponseWriter, r *http.Request) { // Add request timeout to prevent hanging requests ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) @@ -263,7 +263,7 @@ func (app *Application) handleScheduledHTTP(w http.ResponseWriter, r *http.Reque } } -// httpToLambdaRequest converts a standard HTTP request to Lambda Function URL request format +// httpToLambdaRequest converts a standard HTTP request to Lambda Function URL request format. func httpToLambdaRequest(r *http.Request) *events.LambdaFunctionURLRequest { // Read body with size limit to prevent memory exhaustion body := "" @@ -354,12 +354,12 @@ var safeHeaderNames = map[string]bool{ "permissions-policy": true, } -// isSafeHeaderValue checks that a header value doesn't contain CRLF injection characters +// isSafeHeaderValue checks that a header value doesn't contain CRLF injection characters. func isSafeHeaderValue(value string) bool { return !strings.ContainsAny(value, "\r\n") } -// lambdaResponseToHTTP converts a Lambda Function URL response to standard HTTP response +// lambdaResponseToHTTP converts a Lambda Function URL response to standard HTTP response. func lambdaResponseToHTTP(w http.ResponseWriter, lambdaResp *events.LambdaFunctionURLResponse) { // Decode body before writing headers/status to avoid double WriteHeader on error var body []byte @@ -398,7 +398,13 @@ func lambdaResponseToHTTP(w http.ResponseWriter, lambdaResp *events.LambdaFuncti w.Header().Add("Set-Cookie", cookie) } - // Set status code and write body + // Set status code and write body. The body is the already-rendered Lambda + // response (JSON, or HTML pre-escaped at the handler layer per the + // escapeHtml convention) and its Content-Type travels through the + // validated-headers loop above; this adapter only relays it verbatim, so + // re-escaping here would corrupt legitimate JSON/binary payloads. w.WriteHeader(lambdaResp.StatusCode) - w.Write(body) + if _, err := w.Write(body); err != nil { //nolint:gosec // G705: body is produced/escaped by the upstream handler; this Lambda->HTTP adapter relays it unchanged + log.Printf("http: failed to write response body: %v", err) + } } diff --git a/internal/server/http_test.go b/internal/server/http_test.go index f039861f8..a10a2c41d 100644 --- a/internal/server/http_test.go +++ b/internal/server/http_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/LeanerCloud/CUDly/internal/api" + api "github.com/LeanerCloud/CUDly/internal/api" "github.com/LeanerCloud/CUDly/internal/scheduler" "github.com/LeanerCloud/CUDly/internal/server/scheduledauth" "github.com/LeanerCloud/CUDly/internal/testutil" @@ -334,7 +334,7 @@ func TestHandleScheduledHTTP(t *testing.T) { func TestCreateHTTPServer(t *testing.T) { app := &Application{ - API: api.NewHandler(api.HandlerConfig{}), + API: api.NewHandler(&api.HandlerConfig{}), } t.Run("server addr and timeouts", func(t *testing.T) { @@ -350,7 +350,7 @@ func TestCreateHTTPServer(t *testing.T) { t.Run("routes respond", func(t *testing.T) { // Give app enough state for health check to pass healthyApp := &Application{ - API: api.NewHandler(api.HandlerConfig{}), + API: api.NewHandler(&api.HandlerConfig{}), Config: &mockConfigStoreForHealth{}, Auth: createHealthyAuthService(), Version: "test", @@ -390,7 +390,7 @@ func TestHTTPTransportServesOIDCEndpoints(t *testing.T) { // A nil signer means HandleOIDC returns a 404 JSON body rather than the // real discovery document -- but critically it is *JSON* and the path is // *handled* rather than falling through to the SPA/404 fallback. - apiHandler := api.NewHandler(api.HandlerConfig{}) + apiHandler := api.NewHandler(&api.HandlerConfig{}) app := &Application{ API: apiHandler, } @@ -426,7 +426,7 @@ func TestHTTPTransportServesOIDCEndpoints(t *testing.T) { // api.Handler.HandleOIDC and returns JSON (not HTML). func TestHandleOIDCHTTP(t *testing.T) { app := &Application{ - API: api.NewHandler(api.HandlerConfig{}), + API: api.NewHandler(&api.HandlerConfig{}), } req := httptest.NewRequest(http.MethodGet, "/oidc/.well-known/openid-configuration", nil) diff --git a/internal/server/interfaces.go b/internal/server/interfaces.go index da764d463..672987e5f 100644 --- a/internal/server/interfaces.go +++ b/internal/server/interfaces.go @@ -12,7 +12,8 @@ import ( // SchedulerInterface defines the methods required for the scheduler component type SchedulerInterface interface { CollectRecommendations(ctx context.Context) (*scheduler.CollectResult, error) - ListRecommendations(ctx context.Context, filter config.RecommendationFilter) ([]config.RecommendationRecord, error) + // A nil filter means "no filter" (match all); see config.StoreInterface. + ListRecommendations(ctx context.Context, filter *config.RecommendationFilter) ([]config.RecommendationRecord, error) // GetRecommendationByID fetches a single rec by application-level id, // bypassing account-override filtering. hiddenBy is non-nil when the rec // exists but would be dropped by the override filter. Returns nil, nil, diff --git a/internal/server/lambda_coverage_test.go b/internal/server/lambda_coverage_test.go index baadf2994..4cd3d1c12 100644 --- a/internal/server/lambda_coverage_test.go +++ b/internal/server/lambda_coverage_test.go @@ -5,7 +5,7 @@ import ( "encoding/json" "testing" - "github.com/LeanerCloud/CUDly/internal/api" + api "github.com/LeanerCloud/CUDly/internal/api" "github.com/LeanerCloud/CUDly/internal/testutil" ) @@ -92,7 +92,7 @@ func TestHandleLambdaHTTPEvent_StaticPath(t *testing.T) { dir := makeStaticDir(t, map[string]string{"index.html": "spa"}) app := &Application{ - API: api.NewHandler(api.HandlerConfig{}), + API: api.NewHandler(&api.HandlerConfig{}), staticDir: dir, } @@ -112,7 +112,7 @@ func TestHandleLambdaEvent_UnknownEventRouteToScheduled(t *testing.T) { // "unknown" event type routes to handleLambdaScheduledEvent, which // needs a parseable action. Empty object will fail ParseScheduledEvent. app := &Application{ - API: api.NewHandler(api.HandlerConfig{}), + API: api.NewHandler(&api.HandlerConfig{}), } rawEvent := json.RawMessage(`{"random_key": "random_value"}`) diff --git a/internal/server/lambda_test.go b/internal/server/lambda_test.go index 0d96e1c31..df632fbff 100644 --- a/internal/server/lambda_test.go +++ b/internal/server/lambda_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/LeanerCloud/CUDly/internal/api" + api "github.com/LeanerCloud/CUDly/internal/api" "github.com/LeanerCloud/CUDly/internal/scheduler" "github.com/LeanerCloud/CUDly/internal/testutil" ) @@ -115,7 +115,7 @@ func TestHandleLambdaHTTPEvent(t *testing.T) { // Create minimal app with mocked API handler app := &Application{ - API: api.NewHandler(api.HandlerConfig{}), + API: api.NewHandler(&api.HandlerConfig{}), } resp, err := app.handleLambdaHTTPEvent(ctx, json.RawMessage(tt.rawEvent)) @@ -278,7 +278,7 @@ func TestHandleLambdaEvent_UnknownEventReturnsError(t *testing.T) { ctx := testutil.TestContext(t) app := &Application{ - API: api.NewHandler(api.HandlerConfig{}), + API: api.NewHandler(&api.HandlerConfig{}), } _, err := app.HandleLambdaEvent(ctx, json.RawMessage(`{"unknown": "event"}`)) @@ -342,7 +342,7 @@ func TestHandleLambdaEvent(t *testing.T) { ctx := testutil.TestContext(t) app := &Application{ - API: api.NewHandler(api.HandlerConfig{}), + API: api.NewHandler(&api.HandlerConfig{}), } if tt.setupApp != nil { tt.setupApp(app) diff --git a/internal/server/test_helpers_test.go b/internal/server/test_helpers_test.go index 5400f2d47..9e095e85b 100644 --- a/internal/server/test_helpers_test.go +++ b/internal/server/test_helpers_test.go @@ -248,7 +248,7 @@ func (m *mockConfigStoreForHealth) ReplaceRecommendations(_ context.Context, _ t func (m *mockConfigStoreForHealth) UpsertRecommendations(_ context.Context, _ time.Time, _ []config.RecommendationRecord, _ []config.SuccessfulCollect) error { return nil } -func (m *mockConfigStoreForHealth) ListStoredRecommendations(_ context.Context, _ config.RecommendationFilter) ([]config.RecommendationRecord, error) { +func (m *mockConfigStoreForHealth) ListStoredRecommendations(_ context.Context, _ *config.RecommendationFilter) ([]config.RecommendationRecord, error) { return nil, nil } func (m *mockConfigStoreForHealth) GetRecommendationsFreshness(_ context.Context) (*config.RecommendationsFreshness, error) { diff --git a/internal/testutil/mocks.go b/internal/testutil/mocks.go index 0ab05e2c1..f076e096c 100644 --- a/internal/testutil/mocks.go +++ b/internal/testutil/mocks.go @@ -12,7 +12,7 @@ import ( // MockScheduler is a mock implementation of server.SchedulerInterface type MockScheduler struct { CollectRecommendationsFunc func(ctx context.Context) (*scheduler.CollectResult, error) - ListRecommendationsFunc func(ctx context.Context, filter config.RecommendationFilter) ([]config.RecommendationRecord, error) + ListRecommendationsFunc func(ctx context.Context, filter *config.RecommendationFilter) ([]config.RecommendationRecord, error) GetRecommendationByIDFunc func(ctx context.Context, id string) (*config.RecommendationRecord, []string, error) } @@ -23,7 +23,7 @@ func (m *MockScheduler) CollectRecommendations(ctx context.Context) (*scheduler. return &scheduler.CollectResult{}, nil } -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) { if m.ListRecommendationsFunc != nil { return m.ListRecommendationsFunc(ctx, filter) } diff --git a/providers/azure/recommendations.go b/providers/azure/recommendations.go index 8fe3719cd..41e12e2a7 100644 --- a/providers/azure/recommendations.go +++ b/providers/azure/recommendations.go @@ -97,8 +97,17 @@ func (r *RecommendationsClientAdapter) GetRecommendations(ctx context.Context, p }) } + // Record which services the params filter lets through so the merge can + // distinguish "skipped by filter" from "attempted and succeeded with zero + // recommendations" when applying the all-attempted-failed guard. + includeCompute := shouldIncludeService(params, common.ServiceCompute) + includeDB := shouldIncludeService(params, common.ServiceRelationalDB) + includeCache := shouldIncludeService(params, common.ServiceCache) + includeCosmos := shouldIncludeService(params, common.ServiceNoSQL) + includeSP := shouldIncludeService(params, common.ServiceSavingsPlans) + // Compute (VM) recommendations — subscription-wide. - if shouldIncludeService(params, common.ServiceCompute) { + if includeCompute { goService(&computeErr, func() { computeClient := compute.NewClient(r.cred, r.subscriptionID, "") computeRecs, computeErr = computeClient.GetRecommendations(gctx, params) @@ -106,7 +115,7 @@ func (r *RecommendationsClientAdapter) GetRecommendations(ctx context.Context, p } // Database (SQL) recommendations — subscription-wide. - if shouldIncludeService(params, common.ServiceRelationalDB) { + if includeDB { goService(&dbErr, func() { dbClient := database.NewClient(r.cred, r.subscriptionID, "") dbRecs, dbErr = dbClient.GetRecommendations(gctx, params) @@ -114,7 +123,7 @@ func (r *RecommendationsClientAdapter) GetRecommendations(ctx context.Context, p } // Cache (Redis) recommendations — subscription-wide. - if shouldIncludeService(params, common.ServiceCache) { + if includeCache { goService(&cacheErr, func() { cacheClient := cache.NewClient(r.cred, r.subscriptionID, "") cacheRecs, cacheErr = cacheClient.GetRecommendations(gctx, params) @@ -122,7 +131,7 @@ func (r *RecommendationsClientAdapter) GetRecommendations(ctx context.Context, p } // CosmosDB (NoSQL) recommendations — subscription-wide. - if shouldIncludeService(params, common.ServiceNoSQL) { + if includeCosmos { goService(&cosmosErr, func() { cosmosClient := cosmosdb.NewClient(r.cred, r.subscriptionID, "") cosmosRecs, cosmosErr = cosmosClient.GetRecommendations(gctx, params) @@ -134,7 +143,7 @@ func (r *RecommendationsClientAdapter) GetRecommendations(ctx context.Context, p // The call returns an empty slice so the service appears in the fan-out // and will start returning data once the API stabilises without requiring // a scheduler change. - if shouldIncludeService(params, common.ServiceSavingsPlans) { + if includeSP { goService(&spErr, func() { spClient := savingsplans.NewClient(r.cred, r.subscriptionID, "") spRecs, spErr = spClient.GetRecommendations(gctx, params) @@ -160,12 +169,28 @@ func (r *RecommendationsClientAdapter) GetRecommendations(ctx context.Context, p return nil, err } - return mergeServiceResults(serviceResult{"compute", computeRecs, computeErr}, - serviceResult{"database", dbRecs, dbErr}, - serviceResult{"cache", cacheRecs, cacheErr}, - serviceResult{"cosmosdb", cosmosRecs, cosmosErr}, - serviceResult{"savingsplans", spRecs, spErr}, - serviceResult{"advisor", advisorRecs, advisorErr}), nil + return mergeServiceResults(serviceResult{"compute", computeRecs, computeErr, includeCompute}, + serviceResult{"database", dbRecs, dbErr, includeDB}, + serviceResult{"cache", cacheRecs, cacheErr, includeCache}, + serviceResult{"cosmosdb", cosmosRecs, cosmosErr, includeCosmos}, + // The savingsplans client is a stub that unconditionally returns + // ([], nil) until the Benefits Recommendations API stabilises (see + // services/savingsplans Client.GetRecommendations). Counting its + // built-in success as an attempted service would keep the + // all-attempted-failed guard from ever firing on a total provider + // failure (expired credential, subscription-wide throttle), so it is + // excluded from the guard until it makes real API calls. + serviceResult{"savingsplans", spRecs, spErr, false}, + // The Advisor client is excluded from the all-attempted-failed guard + // for the same reason: getAdvisorRecommendations swallows pagination + // errors (the auth failure from an expired credential surfaces as a + // 401 during pager.NextPage, not during client construction) and + // always returns (recs, nil). Counting its unconditional success as + // an attempted service would keep the guard from firing on a total + // credential failure -- the same hazard the savingsplans exclusion + // prevents. When getAdvisorRecommendations is changed to propagate + // hard errors, flip this flag back to true. + serviceResult{"advisor", advisorRecs, advisorErr, false}) } // serviceResult bundles a per-service collection outcome for the deterministic @@ -176,6 +201,12 @@ type serviceResult struct { name string recs []common.Recommendation err error + // attempted records whether the service call was actually launched + // (i.e. not skipped by the params service filter). Skipped services + // carry nil recs and nil err, which is indistinguishable from a + // successful zero-recommendation call, so the all-attempted-failed + // guard below needs this flag to avoid counting skips as successes. + attempted bool } // mergeServiceResults logs per-service errors (matches the previous sequential @@ -184,10 +215,31 @@ type serviceResult struct { // the canonical compute → database → cache → cosmosdb → savingsplans → advisor // order so that order-sensitive consumers remain stable. The advisor entry's // error is logged via logging.Errorf to match the pre-parallelisation severity. -func mergeServiceResults(results ...serviceResult) []common.Recommendation { +// +// Partial failure is tolerated: as long as at least one attempted service +// succeeded, the successful services' recommendations are returned with a nil +// error. But when EVERY attempted service errored (e.g. an expired federated +// credential or a subscription-wide throttle), the merge returns a wrapped +// error instead of an empty-but-nil-error result, porting the AWS 08-H4 guard +// from providers/aws/recommendations/client.go. Returning (recs, nil) on a +// total failure makes a broken run indistinguishable from "no savings +// available": the scheduler would count the account as succeeded, evict its +// previously collected rows, and clear last_collection_error (COR-03). +func mergeServiceResults(results ...serviceResult) ([]common.Recommendation, error) { total := 0 + attempted := 0 + failures := 0 + var lastErr error for _, r := range results { total += len(r.recs) + if !r.attempted { + continue + } + attempted++ + if r.err != nil { + failures++ + lastErr = r.err + } } out := make([]common.Recommendation, 0, total) for _, r := range results { @@ -201,7 +253,10 @@ func mergeServiceResults(results ...serviceResult) []common.Recommendation { } out = append(out, r.recs...) } - return out + if failures > 0 && failures == attempted { + return nil, fmt.Errorf("all %d Azure recommendation services failed: %w", failures, lastErr) + } + return out, nil } // GetRecommendationsForService retrieves Azure reservation recommendations for a specific service diff --git a/providers/azure/recommendations_test.go b/providers/azure/recommendations_test.go index efdca18dc..250a5aaa0 100644 --- a/providers/azure/recommendations_test.go +++ b/providers/azure/recommendations_test.go @@ -2,6 +2,7 @@ package azure import ( "context" + "errors" "strings" "testing" @@ -435,15 +436,16 @@ func TestMergeServiceResults_OrderIsStable(t *testing.T) { advisorRec := mkRec(common.ServiceCompute, "advisor") // Advisor produces Compute recs // Replicate the exact call order from GetRecommendations. - result := mergeServiceResults( - serviceResult{"compute", []common.Recommendation{computeRec}, nil}, - serviceResult{"database", []common.Recommendation{dbRec}, nil}, - serviceResult{"cache", []common.Recommendation{cacheRec}, nil}, - serviceResult{"cosmosdb", []common.Recommendation{cosmosRec}, nil}, - serviceResult{"savingsplans", []common.Recommendation{spRec}, nil}, - serviceResult{"advisor", []common.Recommendation{advisorRec}, nil}, + result, err := mergeServiceResults( + serviceResult{"compute", []common.Recommendation{computeRec}, nil, true}, + serviceResult{"database", []common.Recommendation{dbRec}, nil, true}, + serviceResult{"cache", []common.Recommendation{cacheRec}, nil, true}, + serviceResult{"cosmosdb", []common.Recommendation{cosmosRec}, nil, true}, + serviceResult{"savingsplans", []common.Recommendation{spRec}, nil, true}, + serviceResult{"advisor", []common.Recommendation{advisorRec}, nil, true}, ) + require.NoError(t, err, "all-success merge must not error") require.Len(t, result, 6, "all six services must be represented") assert.Equal(t, "compute", result[0].ResourceType, "first must be compute") assert.Equal(t, "database", result[1].ResourceType, "second must be database") @@ -452,3 +454,106 @@ func TestMergeServiceResults_OrderIsStable(t *testing.T) { assert.Equal(t, "savingsplans", result[4].ResourceType, "fifth must be savingsplans") assert.Equal(t, "advisor", result[5].ResourceType, "sixth must be advisor (compute-type)") } + +// TestMergeServiceResults_AllAttemptedFailed is the COR-03 regression test: +// when EVERY attempted service errors (the shape produced by an expired +// federated credential or a subscription-wide throttle), the merge must return +// a non-nil error instead of (empty, nil). Pre-fix, mergeServiceResults +// returned only []common.Recommendation, so a total failure surfaced as an +// empty successful collection: the scheduler counted the account in +// SucceededAccountIDs, UpsertRecommendations evicted all previously collected +// rows for it, and last_collection_error was cleared. +// +// savingsplans and advisor are both attempted=false because GetRecommendations +// excludes them from the guard: savingsplans is a stub that makes no API call +// and always returns ([], nil); advisor's getAdvisorRecommendations swallows +// pagination errors and therefore also always returns (recs, nil). Counting +// either as an attempted service would keep the guard from firing on a real +// total credential failure. +func TestMergeServiceResults_AllAttemptedFailed(t *testing.T) { + authErr := errors.New("DefaultAzureCredential: federated token expired") + + recs, err := mergeServiceResults( + serviceResult{"compute", nil, authErr, true}, + serviceResult{"database", nil, authErr, true}, + serviceResult{"cache", nil, authErr, true}, + serviceResult{"cosmosdb", nil, authErr, true}, + serviceResult{"savingsplans", nil, nil, false}, + serviceResult{"advisor", nil, nil, false}, + ) + + require.Error(t, err, "all-attempted-failed merge must fail loud, not return (empty, nil)") + assert.ErrorIs(t, err, authErr, "the underlying service error must be wrapped") + assert.Contains(t, err.Error(), "all 4 Azure recommendation services failed") + assert.Nil(t, recs) +} + +// TestMergeServiceResults_SkippedServicesDoNotMaskTotalFailure asserts that +// services skipped by the params filter (attempted == false, nil err) are not +// counted as successes: when every ATTEMPTED service failed, the merge must +// still error even though skipped entries carry a nil err. +// savingsplans and advisor are always attempted=false in production (see +// TestMergeServiceResults_AllAttemptedFailed), so the scenario here models +// a service-filter run where only compute is requested and it fails. +func TestMergeServiceResults_SkippedServicesDoNotMaskTotalFailure(t *testing.T) { + throttleErr := errors.New("429 too many requests") + + _, err := mergeServiceResults( + serviceResult{"compute", nil, throttleErr, true}, + serviceResult{"database", nil, nil, false}, + serviceResult{"cache", nil, nil, false}, + serviceResult{"cosmosdb", nil, nil, false}, + serviceResult{"savingsplans", nil, nil, false}, + serviceResult{"advisor", nil, nil, false}, + ) + + require.Error(t, err, "skipped services must not count as successes in the all-failed guard") + assert.ErrorIs(t, err, throttleErr) +} + +// TestMergeServiceResults_PartialFailureStillSucceeds pins the tolerated +// partial-failure behaviour: one service succeeding is enough for the merge +// to return its recommendations with a nil error. +func TestMergeServiceResults_PartialFailureStillSucceeds(t *testing.T) { + svcErr := errors.New("reservation API unavailable") + computeRec := common.Recommendation{Service: common.ServiceCompute, Provider: common.ProviderAzure} + + recs, err := mergeServiceResults( + serviceResult{"compute", []common.Recommendation{computeRec}, nil, true}, + serviceResult{"database", nil, svcErr, true}, + serviceResult{"cache", nil, svcErr, true}, + serviceResult{"cosmosdb", nil, svcErr, true}, + serviceResult{"savingsplans", nil, nil, false}, + serviceResult{"advisor", nil, nil, false}, + ) + + require.NoError(t, err, "partial failure must still return the successful services' recs") + require.Len(t, recs, 1) + assert.Equal(t, common.ServiceCompute, recs[0].Service) +} + +// TestMergeServiceResults_StubsDoNotMaskTotalFailure replicates the exact +// production shape of a total Azure provider failure (e.g. an expired +// federated credential): the four real services all error, while both +// non-API-making entries -- the savingsplans stub (always ([], nil)) and +// advisor (getAdvisorRecommendations swallows pagination auth errors and +// always returns (recs, nil)) -- succeed. GetRecommendations passes +// attempted=false for both; the merge must still fail loud rather than +// counting either stub's unconditional success and returning (empty, nil). +func TestMergeServiceResults_StubsDoNotMaskTotalFailure(t *testing.T) { + authErr := errors.New("DefaultAzureCredential: federated token expired") + + recs, err := mergeServiceResults( + serviceResult{"compute", nil, authErr, true}, + serviceResult{"database", nil, authErr, true}, + serviceResult{"cache", nil, authErr, true}, + serviceResult{"cosmosdb", nil, authErr, true}, + serviceResult{"savingsplans", []common.Recommendation{}, nil, false}, + serviceResult{"advisor", []common.Recommendation{}, nil, false}, + ) + + require.Error(t, err, "stubs that swallow errors must not mask a total provider failure") + assert.ErrorIs(t, err, authErr) + assert.Contains(t, err.Error(), "all 4 Azure recommendation services failed") + assert.Nil(t, recs) +}