diff --git a/internal/database/config.go b/internal/database/config.go index b22238302..cd66e98c7 100644 --- a/internal/database/config.go +++ b/internal/database/config.go @@ -147,14 +147,10 @@ func (c *Config) validatePoolSettings() error { return nil } -// DSN generates a PostgreSQL connection string -// If passwordOverride is provided, it's used instead of config.Password -func (c *Config) DSN(passwordOverride string) string { - password := c.Password - if passwordOverride != "" { - password = passwordOverride - } - +// dsn formats a PostgreSQL connection string with the supplied password. It is +// the single source of the DSN field layout so DSN and RedactedDSN can never +// drift (a field added here applies to both the real and the redacted form). +func (c *Config) dsn(password string) string { return fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s connect_timeout=%d", c.Host, @@ -167,17 +163,19 @@ func (c *Config) DSN(passwordOverride string) string { ) } +// DSN generates a PostgreSQL connection string +// If passwordOverride is provided, it's used instead of config.Password +func (c *Config) DSN(passwordOverride string) string { + password := c.Password + if passwordOverride != "" { + password = passwordOverride + } + return c.dsn(password) +} + // RedactedDSN returns a DSN string with the password masked, safe for logging func (c *Config) RedactedDSN() string { - return fmt.Sprintf( - "host=%s port=%d user=%s password=***** dbname=%s sslmode=%s connect_timeout=%d", - c.Host, - c.Port, - c.User, - c.Database, - c.SSLMode, - int(c.ConnectTimeout.Seconds()), - ) + return c.dsn("*****") } // Helper functions for environment variable parsing diff --git a/internal/database/coverage_extra_test.go b/internal/database/coverage_extra_test.go index 5fa493679..fa395321f 100644 --- a/internal/database/coverage_extra_test.go +++ b/internal/database/coverage_extra_test.go @@ -2,6 +2,7 @@ package database import ( "context" + "strings" "testing" "time" @@ -32,6 +33,28 @@ func TestRedactedDSN(t *testing.T) { assert.NotContains(t, dsn, "supersecret") } +// TestRedactedDSN_SharesLayoutWithDSN guards the 06-N2 dedup: RedactedDSN and +// DSN both derive from the single dsn() formatter, so the redacted string must +// equal the real DSN with only the password swapped for "*****". If a future +// field is added to dsn(), this stays green; if someone re-forks the format +// string for only one of the two, it fails. +func TestRedactedDSN_SharesLayoutWithDSN(t *testing.T) { + cfg := &Config{ + Host: "db.example.com", + Port: 5432, + User: "admin", + Password: "supersecret", + Database: "cudly", + SSLMode: "require", + ConnectTimeout: 10 * time.Second, + } + + real := cfg.DSN("") + redacted := cfg.RedactedDSN() + expected := strings.Replace(real, "password=supersecret", "password=*****", 1) + assert.Equal(t, expected, redacted) +} + // Tests for extractPasswordFromSecret func TestExtractPasswordFromSecret_JSONWithPassword(t *testing.T) { secret := `{"username":"admin","password":"db-pass-123","host":"db.example.com"}` diff --git a/pkg/common/tokens.go b/pkg/common/tokens.go index 51754bd8c..28ded0faf 100644 --- a/pkg/common/tokens.go +++ b/pkg/common/tokens.go @@ -48,14 +48,16 @@ func DeriveIdempotencyToken(executionID string, recIndex int) string { // keeps just enough of the prefix to correlate log lines for a single purchase // while avoiding emitting the whole caller-supplied token into persistent logs // (a stable per-execution identifier that should not leak verbatim). An empty -// token yields "(none)"; a token of 8 chars or fewer is returned unchanged -// since there is nothing left to redact. +// token yields "(none)". A token of 8 chars or fewer is fully redacted to +// "(redacted)" rather than echoed: an 8-char prefix of an 8-char input is the +// whole value, so for short inputs (e.g. a short secret a future caller might +// pass) nothing of the token is emitted. func MaskToken(token string) string { if token == "" { return "(none)" } if len(token) <= 8 { - return token + return "(redacted)" } return token[:8] + "..." } diff --git a/pkg/common/tokens_test.go b/pkg/common/tokens_test.go index 00fab00d6..a03fb91fd 100644 --- a/pkg/common/tokens_test.go +++ b/pkg/common/tokens_test.go @@ -81,8 +81,11 @@ func TestMaskToken_NeverEmitsFullToken(t *testing.T) { func TestMaskToken_EmptyAndShort(t *testing.T) { assert.Equal(t, "(none)", MaskToken(""), "empty token must be reported as (none)") - assert.Equal(t, "abc", MaskToken("abc"), "tokens of <=8 chars have nothing to redact") - assert.Equal(t, "12345678", MaskToken("12345678"), "exactly 8 chars is returned unchanged") + // Short inputs (<=8 chars) are fully redacted, never echoed: an 8-char prefix + // of an 8-char value would leak the whole secret (CodeRabbit 10-L6). + assert.Equal(t, "(redacted)", MaskToken("abc"), "short tokens must be fully redacted, not echoed") + assert.Equal(t, "(redacted)", MaskToken("12345678"), "exactly 8 chars is fully redacted") + assert.NotContains(t, MaskToken("secret77"), "secret", "no part of a short secret may appear in the masked form") assert.Equal(t, "12345678...", MaskToken("123456789"), "9 chars is truncated to 8 + ellipsis") } diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 23284d2f8..bcb9bd1ee 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -1,4 +1,14 @@ // Package errors provides custom error types for CUDly. +// +// Type-level Is matching: every error type in this package implements Is by +// matching purely on the dynamic type of the target, ignoring the target's +// struct fields. That makes the zero-value pointer of each type a usable +// sentinel, e.g. errors.Is(err, &NotFoundError{}) reports whether err is (or +// wraps) any *NotFoundError. The corollary is that the target's fields are +// decorative for comparison: errors.Is(someNotFound, &NotFoundError{ID: "x"}) +// is true regardless of whether someNotFound.ID == "x". To assert on specific +// fields, use errors.As to extract the concrete value and inspect it directly, +// or one of the Is*Error helpers below (which also use errors.As). package errors import ( @@ -24,7 +34,8 @@ func (e *NotFoundError) Error() string { return fmt.Sprintf("%s not found", e.Resource) } -// Is implements error comparison +// Is reports whether target is the same error type (field-insensitive; see +// the package doc on type-level Is matching). func (e *NotFoundError) Is(target error) bool { _, ok := target.(*NotFoundError) return ok @@ -65,7 +76,8 @@ func (e *ValidationError) Error() string { return fmt.Sprintf("validation error: %s", e.Message) } -// Is implements error comparison +// Is reports whether target is the same error type (field-insensitive; see +// the package doc on type-level Is matching). func (e *ValidationError) Is(target error) bool { _, ok := target.(*ValidationError) return ok @@ -98,7 +110,8 @@ func (e *AuthenticationError) Error() string { return "authentication failed" } -// Is implements error comparison +// Is reports whether target is the same error type (field-insensitive; see +// the package doc on type-level Is matching). func (e *AuthenticationError) Is(target error) bool { _, ok := target.(*AuthenticationError) return ok @@ -131,7 +144,8 @@ func (e *AuthorizationError) Error() string { return "not authorized" } -// Is implements error comparison +// Is reports whether target is the same error type (field-insensitive; see +// the package doc on type-level Is matching). func (e *AuthorizationError) Is(target error) bool { _, ok := target.(*AuthorizationError) return ok @@ -169,7 +183,8 @@ func (e *ConflictError) Error() string { return fmt.Sprintf("%s already exists", e.Resource) } -// Is implements error comparison +// Is reports whether target is the same error type (field-insensitive; see +// the package doc on type-level Is matching). func (e *ConflictError) Is(target error) bool { _, ok := target.(*ConflictError) return ok @@ -202,7 +217,8 @@ func (e *RateLimitError) Error() string { return "rate limit exceeded" } -// Is implements error comparison +// Is reports whether target is the same error type (field-insensitive; see +// the package doc on type-level Is matching). func (e *RateLimitError) Is(target error) bool { _, ok := target.(*RateLimitError) return ok @@ -240,7 +256,8 @@ func (e *ServiceError) Unwrap() error { return e.Err } -// Is implements error comparison +// Is reports whether target is the same error type (field-insensitive; see +// the package doc on type-level Is matching). func (e *ServiceError) Is(target error) bool { _, ok := target.(*ServiceError) return ok diff --git a/pkg/errors/errors_test.go b/pkg/errors/errors_test.go index b8c59de42..2a5a1e971 100644 --- a/pkg/errors/errors_test.go +++ b/pkg/errors/errors_test.go @@ -36,6 +36,18 @@ func TestNotFoundError(t *testing.T) { assert.True(t, errors.Is(err, target)) }) + t.Run("Is is field-insensitive (type-level matching, 10-N4)", func(t *testing.T) { + t.Parallel() + // Documented contract: Is matches on type only, ignoring the target's + // fields. A target with a mismatching ID still reports true. + err := NewNotFoundError("User", "123") + assert.True(t, errors.Is(err, &NotFoundError{ID: "different"}), + "Is must match on type alone, regardless of target fields") + assert.True(t, errors.Is(err, &NotFoundError{Resource: "Other", ID: "x", Message: "y"})) + // A different type must not match. + assert.False(t, errors.Is(err, &ValidationError{})) + }) + t.Run("IsNotFoundError helper", func(t *testing.T) { t.Parallel() err := NewNotFoundError("User", "123") diff --git a/pkg/logging/logger.go b/pkg/logging/logger.go index 6ade2dff5..f4ca31d7e 100644 --- a/pkg/logging/logger.go +++ b/pkg/logging/logger.go @@ -10,6 +10,7 @@ import ( "os" "sort" "strings" + "sync/atomic" ) // Level represents a logging level @@ -26,14 +27,29 @@ const ( LevelError ) -// Logger provides structured logging capabilities +// Logger provides structured logging capabilities. +// +// level is stored as an atomic.Int32 so that SetLevel/SetLevelValue (called at +// runtime, sometimes after worker goroutines have launched) and the level reads +// performed by every Debug/Info/Warn/Error call are race-free under the +// concurrent fan-out, which logs from many goroutines. type Logger struct { - level Level + level atomic.Int32 logger *log.Logger prefix string metadata map[string]interface{} } +// getLevel returns the logger's current level, read atomically. +func (l *Logger) getLevel() Level { + return Level(l.level.Load()) +} + +// setLevel stores the logger's level atomically. +func (l *Logger) setLevel(level Level) { + l.level.Store(int32(level)) +} + // Config holds logger configuration type Config struct { Level string @@ -79,37 +95,38 @@ func New(cfg Config) *Logger { flags = 0 // Use custom time format } - return &Logger{ - level: ParseLevel(cfg.Level), + l := &Logger{ logger: log.New(output, cfg.Prefix, flags), prefix: cfg.Prefix, metadata: make(map[string]interface{}), } + l.setLevel(ParseLevel(cfg.Level)) + return l } // SetLevel sets the logging level func SetLevel(level string) { - defaultLogger.level = ParseLevel(level) + defaultLogger.setLevel(ParseLevel(level)) } // SetLevelValue sets the logging level using a Level value func SetLevelValue(level Level) { - defaultLogger.level = level + defaultLogger.setLevel(level) } // GetLevel returns the current log level func GetLevel() Level { - return defaultLogger.level + return defaultLogger.getLevel() } // With creates a new logger with additional metadata func (l *Logger) With(key string, value interface{}) *Logger { newLogger := &Logger{ - level: l.level, logger: l.logger, prefix: l.prefix, metadata: make(map[string]interface{}), } + newLogger.setLevel(l.getLevel()) for k, v := range l.metadata { newLogger.metadata[k] = v } @@ -138,56 +155,56 @@ func (l *Logger) formatMessage(msg string) string { // Debug logs a debug message func (l *Logger) Debug(msg string) { - if l.level <= LevelDebug { + if l.getLevel() <= LevelDebug { l.logger.Printf("[DEBUG] %s", l.formatMessage(msg)) } } // Debugf logs a formatted debug message func (l *Logger) Debugf(format string, args ...interface{}) { - if l.level <= LevelDebug { + if l.getLevel() <= LevelDebug { l.logger.Printf("[DEBUG] %s", l.formatMessage(fmt.Sprintf(format, args...))) } } // Info logs an info message func (l *Logger) Info(msg string) { - if l.level <= LevelInfo { + if l.getLevel() <= LevelInfo { l.logger.Printf("[INFO] %s", l.formatMessage(msg)) } } // Infof logs a formatted info message func (l *Logger) Infof(format string, args ...interface{}) { - if l.level <= LevelInfo { + if l.getLevel() <= LevelInfo { l.logger.Printf("[INFO] %s", l.formatMessage(fmt.Sprintf(format, args...))) } } // Warn logs a warning message func (l *Logger) Warn(msg string) { - if l.level <= LevelWarn { + if l.getLevel() <= LevelWarn { l.logger.Printf("[WARN] %s", l.formatMessage(msg)) } } // Warnf logs a formatted warning message func (l *Logger) Warnf(format string, args ...interface{}) { - if l.level <= LevelWarn { + if l.getLevel() <= LevelWarn { l.logger.Printf("[WARN] %s", l.formatMessage(fmt.Sprintf(format, args...))) } } // Error logs an error message func (l *Logger) Error(msg string) { - if l.level <= LevelError { + if l.getLevel() <= LevelError { l.logger.Printf("[ERROR] %s", l.formatMessage(msg)) } } // Errorf logs a formatted error message func (l *Logger) Errorf(format string, args ...interface{}) { - if l.level <= LevelError { + if l.getLevel() <= LevelError { l.logger.Printf("[ERROR] %s", l.formatMessage(fmt.Sprintf(format, args...))) } } diff --git a/pkg/logging/logger_test.go b/pkg/logging/logger_test.go index 2deffc3e0..ed14cdab3 100644 --- a/pkg/logging/logger_test.go +++ b/pkg/logging/logger_test.go @@ -2,7 +2,9 @@ package logging import ( "bytes" + "io" "strings" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -286,3 +288,59 @@ func TestWith_ChainedCalls(t *testing.T) { assert.Contains(t, output, "b=2") assert.Contains(t, output, "c=3") } + +// TestSetLevel_NoDataRace exercises the concurrent SetLevelValue / level-read +// path that triggered the 10-L5 data race: before the atomic.Int32 fix, +// defaultLogger.level was a plain int read by every log call and written by +// SetLevelValue with no synchronization. Run with `go test -race` to catch a +// regression. Mutates the package-level defaultLogger global, so it is SERIAL +// (no t.Parallel) per the file-top note. +func TestSetLevel_NoDataRace(t *testing.T) { + oldLogger := defaultLogger + defer func() { defaultLogger = oldLogger }() + + // Discard output so a concurrent bytes.Buffer doesn't confound the race + // detector; the contended state under test is the level field, not output. + defaultLogger = New(Config{Level: "info", Output: io.Discard}) + + const goroutines = 8 + const iterations = 200 + + var wg sync.WaitGroup + wg.Add(goroutines * 3) + + // Writers flip the level concurrently. + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + if j%2 == 0 { + SetLevelValue(LevelDebug) + } else { + SetLevelValue(LevelError) + } + } + }() + } + // Readers log (each call reads the level) concurrently. + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + Info("concurrent log line") + Debug("concurrent debug line") + } + }() + } + // Readers also read the level directly via GetLevel. + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + _ = GetLevel() + } + }() + } + + wg.Wait() +} diff --git a/pkg/provider/registry.go b/pkg/provider/registry.go index 75e6f6374..a3ed1770a 100644 --- a/pkg/provider/registry.go +++ b/pkg/provider/registry.go @@ -58,10 +58,15 @@ func (r *Registry) Register(name string, factory ProviderFactory) error { // Callers can distinguish the two cases via the returned error message; previously // both cases returned nil and callers had no way to surface the factory failure. func (r *Registry) GetProvider(name string) (Provider, error) { + // Look up the factory under the lock, then release it before invoking the + // factory. Factories may perform arbitrary work, including network I/O (the + // GCP factory walks Projects.List() when no project ID is configured); calling + // them while holding r.mu would block every other registry reader and any + // writer (e.g. Unregister) for the duration of that I/O. r.mu.RLock() - defer r.mu.RUnlock() - factory, exists := r.providers[name] + r.mu.RUnlock() + if !exists { return nil, fmt.Errorf("provider %s not registered", name) } @@ -75,10 +80,11 @@ func (r *Registry) GetProvider(name string) (Provider, error) { // GetProviderWithConfig creates a provider instance with custom config func (r *Registry) GetProviderWithConfig(name string, config *ProviderConfig) (Provider, error) { + // Snapshot the factory under the lock, call it lock-free (see GetProvider). r.mu.RLock() - defer r.mu.RUnlock() - factory, exists := r.providers[name] + r.mu.RUnlock() + if !exists { return nil, fmt.Errorf("provider %s not registered", name) } @@ -88,11 +94,19 @@ func (r *Registry) GetProviderWithConfig(name string, config *ProviderConfig) (P // GetAllProviders returns instances of all registered providers func (r *Registry) GetAllProviders() []Provider { + // Copy the name->factory map under the lock, then release it and construct + // the providers lock-free. Factories may do network I/O (see GetProvider); + // running them under r.mu would serialize every provider's network init and + // block other registry users for the whole fan-out. r.mu.RLock() - defer r.mu.RUnlock() - - providers := make([]Provider, 0, len(r.providers)) + factories := make(map[string]ProviderFactory, len(r.providers)) for name, factory := range r.providers { + factories[name] = factory + } + r.mu.RUnlock() + + providers := make([]Provider, 0, len(factories)) + for name, factory := range factories { provider, err := factory(&ProviderConfig{Name: name}) if err != nil { log.Printf("provider %q factory error: %v", name, err) diff --git a/pkg/provider/registry_test.go b/pkg/provider/registry_test.go index 5daa9cb21..11bbd2597 100644 --- a/pkg/provider/registry_test.go +++ b/pkg/provider/registry_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -270,3 +271,45 @@ func TestRegisterProvider(t *testing.T) { // Clean up GetRegistry().Unregister(testName) } + +// TestRegistry_FactoryRunsOutsideLock proves the 10-H4 fix: GetProvider / +// GetProviderWithConfig / GetAllProviders must NOT hold r.mu while invoking the +// factory. A factory that does its own registry I/O (here, it takes the write +// lock via Unregister) would deadlock if the read lock were still held during +// the factory call. Pre-fix this test hangs (caught by the 5s timeout); +// post-fix it returns promptly. Done channel + timeout instead of time.Sleep. +func TestRegistry_FactoryRunsOutsideLock(t *testing.T) { + t.Parallel() + + run := func(name string, call func(r *Registry)) { + r := NewRegistry() + factory := func(config *ProviderConfig) (Provider, error) { + // Acquire the write lock from inside the factory. Only safe if the + // caller released the read lock before invoking us. + r.Unregister(config.Name) + return &MockProvider{name: config.Name}, nil + } + require.NoError(t, r.Register(name, factory)) + + done := make(chan struct{}) + go func() { + defer close(done) + call(r) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("%s held the registry lock across the factory call (deadlock)", name) + } + } + + run("getprovider", func(r *Registry) { + _, _ = r.GetProvider("getprovider") + }) + run("getproviderwithconfig", func(r *Registry) { + _, _ = r.GetProviderWithConfig("getproviderwithconfig", &ProviderConfig{Name: "getproviderwithconfig"}) + }) + run("getallproviders", func(r *Registry) { + _ = r.GetAllProviders() + }) +} diff --git a/providers/gcp/provider.go b/providers/gcp/provider.go index ad3f77ca9..c285d52d3 100644 --- a/providers/gcp/provider.go +++ b/providers/gcp/provider.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "cloud.google.com/go/compute/apiv1" "cloud.google.com/go/compute/apiv1/computepb" @@ -266,24 +267,67 @@ func (p *GCPProvider) ValidateCredentials(ctx context.Context) error { return nil } -// GetCredentials returns the current GCP credentials information -func (p *GCPProvider) GetCredentials() (provider.Credentials, error) { - if !p.IsConfigured() { - return nil, fmt.Errorf("GCP is not configured") +// detectCredentialSource reports which credential source GCP would use, based on +// purely local inspection (env vars, the gcloud ADC file location, and whether a +// project is configured). It performs NO network I/O. The bool is false only +// when nothing locally indicates a usable credential source. +// +// GCP credentials can come from: +// - GOOGLE_APPLICATION_CREDENTIALS env var (service account JSON file) +// - the gcloud-managed Application Default Credentials file (gcloud auth +// application-default login) +// - Compute Engine/GKE/Cloud Shell metadata service (the ADC fallback) +func (p *GCPProvider) detectCredentialSource() (provider.CredentialSource, bool) { + if os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") != "" { + return provider.CredentialSourceFile, true + } + if adcWellKnownFileExists() { + return provider.CredentialSourceCLI, true + } + // No explicit local credential file. ADC can still resolve via the metadata + // server on GCE/GKE/Cloud Shell, but only if a project is configured to act + // against. Treat "project configured" as the signal that ADC is usable. + if p.projectID != "" { + return provider.CredentialSourceADC, true + } + return provider.CredentialSourceADC, false +} + +// adcWellKnownFileExists reports whether the gcloud Application Default +// Credentials file is present, checked locally without any network call. The +// path mirrors the Google client libraries' well-known location: +// $CLOUDSDK_CONFIG/application_default_credentials.json, defaulting to +// ~/.config/gcloud/application_default_credentials.json (and the Windows +// %APPDATA%\gcloud equivalent). +func adcWellKnownFileExists() bool { + const adcFile = "application_default_credentials.json" + if dir := os.Getenv("CLOUDSDK_CONFIG"); dir != "" { + _, err := os.Stat(filepath.Join(dir, adcFile)) + return err == nil + } + if appData := os.Getenv("APPDATA"); appData != "" { + if _, err := os.Stat(filepath.Join(appData, "gcloud", adcFile)); err == nil { + return true + } } + home, err := os.UserHomeDir() + if err != nil { + return false + } + _, err = os.Stat(filepath.Join(home, ".config", "gcloud", adcFile)) + return err == nil +} - // GCP uses Application Default Credentials (ADC) - // The actual credentials could come from: - // - GOOGLE_APPLICATION_CREDENTIALS env var (service account JSON file) - // - gcloud CLI configuration - // - Compute Engine/GKE metadata service - // - Cloud Shell - - credType := provider.CredentialSourceADC // Application Default Credentials - - // Try to determine the source more specifically - if _, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS"); ok { - credType = provider.CredentialSourceFile +// GetCredentials returns the current GCP credentials information. +// +// This is credential *introspection* (which source is in use), not *validation* +// (do the credentials work). It deliberately performs NO network call: the +// source is determined from local inspection only. To verify the credentials +// actually work, call ValidateCredentials, which issues the GetProject RPC. +func (p *GCPProvider) GetCredentials() (provider.Credentials, error) { + credType, found := p.detectCredentialSource() + if !found { + return nil, fmt.Errorf("GCP is not configured") } return &provider.BaseCredentials{ diff --git a/providers/gcp/provider_test.go b/providers/gcp/provider_test.go index d6b4f90d4..979e75586 100644 --- a/providers/gcp/provider_test.go +++ b/providers/gcp/provider_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "path/filepath" "testing" "cloud.google.com/go/compute/apiv1/computepb" @@ -403,28 +404,34 @@ func TestNewProvider_NilConfig(t *testing.T) { } func TestGCPProvider_GetCredentials_WithEnvVar(t *testing.T) { - // Test GetCredentials when GOOGLE_APPLICATION_CREDENTIALS env var is set - // We just test the logic, not actual credential retrieval - p := &GCPProvider{ - projectID: "test-project", - } + // GOOGLE_APPLICATION_CREDENTIALS set -> source is File, no network call. + clearGCPCredEnv(t) + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/path/to/creds.json") + + p := &GCPProvider{projectID: "test-project"} + + creds, err := p.GetCredentials() + require.NoError(t, err) + baseCreds, ok := creds.(*provider.BaseCredentials) + require.True(t, ok) + assert.Equal(t, provider.CredentialSourceFile, baseCreds.Source) +} - // Save and restore env var - origVal := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") +func TestGCPProvider_GetCredentials_ADCFileSource(t *testing.T) { + // gcloud ADC file present (via CLOUDSDK_CONFIG) -> source is CLI, no env var. + clearGCPCredEnv(t) + cfgDir := t.TempDir() + t.Setenv("CLOUDSDK_CONFIG", cfgDir) + adcPath := filepath.Join(cfgDir, "application_default_credentials.json") + require.NoError(t, os.WriteFile(adcPath, []byte(`{"type":"authorized_user"}`), 0o600)) - // Test with env var set - os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/path/to/creds.json") - defer func() { - if origVal == "" { - os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS") - } else { - os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", origVal) - } - }() + p := &GCPProvider{projectID: "test-project"} - // GetCredentials will still fail without real credentials, - // but we're testing the code path - _, _ = p.GetCredentials() + creds, err := p.GetCredentials() + require.NoError(t, err) + baseCreds, ok := creds.(*provider.BaseCredentials) + require.True(t, ok) + assert.Equal(t, provider.CredentialSourceCLI, baseCreds.Source) } func TestGCPProvider_SetterMethods(t *testing.T) { @@ -707,64 +714,67 @@ func TestGCPProvider_GetRegions_Error(t *testing.T) { assert.Contains(t, err.Error(), "failed to list regions") } +// clearGCPCredEnv makes credential-source detection deterministic by clearing +// the credential env vars and pointing CLOUDSDK_CONFIG at an empty temp dir so +// no real gcloud ADC file on the test machine is picked up. t.Setenv auto- +// restores after the test. +func clearGCPCredEnv(t *testing.T) { + t.Helper() + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") + os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS") + t.Setenv("CLOUDSDK_CONFIG", t.TempDir()) + t.Setenv("APPDATA", t.TempDir()) +} + func TestGCPProvider_GetCredentials_NotConfigured(t *testing.T) { - p := &GCPProvider{ - projectID: "", - } + clearGCPCredEnv(t) - // Set up mock that returns error to simulate not configured + // No credential env var, no ADC file, and no project ID: detection finds + // no usable source. GetCredentials reports this WITHOUT any network call + // (10-H3) -- the projectsClient mock must never be invoked. mockClient := &MockProjectsClient{ - err: errors.New("not configured"), + err: errors.New("network call must not happen"), } + p := &GCPProvider{projectID: ""} p.SetProjectsClient(mockClient) _, err := p.GetCredentials() assert.Error(t, err) assert.Contains(t, err.Error(), "GCP is not configured") + assert.False(t, mockClient.closed, "GetCredentials must not open/close a network client (no RPC)") } func TestGCPProvider_GetCredentials_Configured(t *testing.T) { - p := &GCPProvider{ - projectID: "test-project", - } + clearGCPCredEnv(t) + // A configured project ID is enough for ADC (metadata-server fallback) to be + // the reported source, with no network call required. mockClient := &MockProjectsClient{ - project: &resourcemanagerpb.Project{ - Name: "projects/test-project", - State: resourcemanagerpb.Project_ACTIVE, - }, + err: errors.New("network call must not happen"), } + p := &GCPProvider{projectID: "test-project"} p.SetProjectsClient(mockClient) creds, err := p.GetCredentials() require.NoError(t, err) require.NotNil(t, creds) + + baseCreds, ok := creds.(*provider.BaseCredentials) + require.True(t, ok) + assert.Equal(t, provider.CredentialSourceADC, baseCreds.Source) + assert.False(t, mockClient.closed, "GetCredentials must not issue a GetProject RPC") } func TestGCPProvider_GetCredentials_WithFileSource(t *testing.T) { - p := &GCPProvider{ - projectID: "test-project", - } + clearGCPCredEnv(t) + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/path/to/creds.json") mockClient := &MockProjectsClient{ - project: &resourcemanagerpb.Project{ - Name: "projects/test-project", - State: resourcemanagerpb.Project_ACTIVE, - }, + err: errors.New("network call must not happen"), } + p := &GCPProvider{projectID: "test-project"} p.SetProjectsClient(mockClient) - // Save and restore env var - origVal := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") - os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/path/to/creds.json") - defer func() { - if origVal == "" { - os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS") - } else { - os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", origVal) - } - }() - creds, err := p.GetCredentials() require.NoError(t, err) require.NotNil(t, creds) @@ -772,4 +782,26 @@ func TestGCPProvider_GetCredentials_WithFileSource(t *testing.T) { baseCreds, ok := creds.(*provider.BaseCredentials) require.True(t, ok) assert.Equal(t, provider.CredentialSourceFile, baseCreds.Source) + assert.False(t, mockClient.closed, "GetCredentials must not issue a GetProject RPC") +} + +func TestGCPProvider_GetCredentials_EmptyEnvVarNotFile(t *testing.T) { + // GOOGLE_APPLICATION_CREDENTIALS set to an empty string must NOT be detected as + // CredentialSourceFile. os.LookupEnv returns ok=true for an empty string, so the + // check must use os.Getenv(...) != "" instead. + clearGCPCredEnv(t) + // Explicitly set the variable to an empty string (clearGCPCredEnv unsets it, so + // restore it as empty to reproduce the bug scenario). + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") + + p := &GCPProvider{projectID: "test-project"} + + creds, err := p.GetCredentials() + require.NoError(t, err) + baseCreds, ok := creds.(*provider.BaseCredentials) + require.True(t, ok) + // An empty path is not a file credential; ADC (metadata fallback via projectID) + // is the expected source. + assert.Equal(t, provider.CredentialSourceADC, baseCreds.Source, + "empty GOOGLE_APPLICATION_CREDENTIALS must not be detected as CredentialSourceFile") }