package config import ( "os" "path/filepath" "testing" ) func TestDefaultConfigIncludesStorageControls(t *testing.T) { cfg := defaultConfig() if !cfg.Storage.CaptureRequestBody { t.Fatal("expected request body capture to be enabled by default") } if !cfg.Storage.CaptureResponseBody { t.Fatal("expected response body capture to be enabled by default") } if cfg.Storage.MetadataOnly { t.Fatal("expected metadata-only mode to be disabled by default") } if cfg.Storage.RetentionDays != 0 { t.Fatalf("expected retention to be disabled by default, got %d", cfg.Storage.RetentionDays) } if len(cfg.Storage.RedactedFields) == 0 { t.Fatal("expected default redacted field list to be populated") } } func TestLoadFromFileParsesStorageControls(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.yaml") yaml := ` storage: db_path: /tmp/claude.db capture_request_body: false capture_response_body: false metadata_only: true retention_days: 7 redacted_fields: - api_key - secret ` if err := os.WriteFile(configPath, []byte(yaml), 0o600); err != nil { t.Fatalf("WriteFile() error = %v", err) } cfg := defaultConfig() if err := cfg.loadFromFile(configPath); err != nil { t.Fatalf("loadFromFile() error = %v", err) } if cfg.Storage.DBPath != "/tmp/claude.db" { t.Fatalf("unexpected db path %q", cfg.Storage.DBPath) } if !cfg.Storage.MetadataOnly { t.Fatal("expected metadata-only mode to load from file") } if cfg.Storage.CaptureRequestBody { t.Fatal("expected request body capture to load as disabled") } if cfg.Storage.CaptureResponseBody { t.Fatal("expected response body capture to load as disabled") } if cfg.Storage.RetentionDays != 7 { t.Fatalf("unexpected retention days %d", cfg.Storage.RetentionDays) } if len(cfg.Storage.RedactedFields) != 2 { t.Fatalf("unexpected redacted field count %d", len(cfg.Storage.RedactedFields)) } } func TestLoadFirstAvailableConfigReturnsParseError(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.yaml") if err := os.WriteFile(configPath, []byte("server: ["), 0o600); err != nil { t.Fatalf("WriteFile() error = %v", err) } cfg := &Config{} if err := loadFirstAvailableConfig(cfg, []string{configPath}); err == nil { t.Fatal("expected parse error, got nil") } } func TestLoadFirstAvailableConfigSkipsMissingFiles(t *testing.T) { cfg := &Config{} if err := loadFirstAvailableConfig(cfg, []string{ filepath.Join(t.TempDir(), "missing.yaml"), }); err != nil { t.Fatalf("expected nil error for missing config, got %v", err) } } func TestDefaultConfigUsesPublicBindAndWildcardCors(t *testing.T) { cfg := defaultConfig() if cfg.Server.Host != "0.0.0.0" { t.Fatalf("expected 0.0.0.0 host, got %q", cfg.Server.Host) } if cfg.Auth.Enabled { t.Fatal("expected auth to be disabled by default") } if len(cfg.CORS.AllowedOrigins) != 1 || cfg.CORS.AllowedOrigins[0] != "*" { t.Fatalf("expected default CORS origins to be [*], got %v", cfg.CORS.AllowedOrigins) } } func TestValidateSecurityRejectsPublicBindWithoutAuthOrTrustProxy(t *testing.T) { cfg := defaultConfig() cfg.Server.Host = "0.0.0.0" cfg.Auth.Enabled = false cfg.Auth.TrustProxy = false if err := validateSecurity(cfg); err == nil { t.Fatal("expected validation error for public bind without auth or trust_proxy") } } func TestValidateSecurityAllowsPublicBindWithAuthToken(t *testing.T) { cfg := defaultConfig() cfg.Server.Host = "0.0.0.0" cfg.Auth.Enabled = true cfg.Auth.Token = "secret" if err := validateSecurity(cfg); err != nil { t.Fatalf("expected public bind with auth token to be allowed, got %v", err) } } func TestValidateSecurityAllowsPublicBindWithTrustProxy(t *testing.T) { cfg := defaultConfig() cfg.Server.Host = "0.0.0.0" cfg.Auth.Enabled = false cfg.Auth.TrustProxy = true if err := validateSecurity(cfg); err != nil { t.Fatalf("expected public bind with trust_proxy to be allowed, got %v", err) } } func TestEnvBool(t *testing.T) { tests := []struct { name string value string want bool }{ {"true lowercase", "true", true}, {"true uppercase", "TRUE", true}, {"true mixed case", "True", true}, {"one", "1", true}, {"yes lowercase", "yes", true}, {"yes uppercase", "YES", true}, {"false", "false", false}, {"zero", "0", false}, {"no", "no", false}, {"empty", "", false}, {"random string", "maybe", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { key := "TEST_ENV_BOOL_" + tt.name if tt.value != "" { os.Setenv(key, tt.value) defer os.Unsetenv(key) } else { os.Unsetenv(key) } got := envBool(key) if got != tt.want { t.Errorf("envBool(%q) with value %q = %v, want %v", key, tt.value, got, tt.want) } }) } } func TestCORSEnvOverride(t *testing.T) { tests := []struct { name string envKey string envVal string check func(*Config) bool desc string }{ { name: "origins override", envKey: "CORS_ALLOWED_ORIGINS", envVal: "https://example.com, https://other.com", check: func(c *Config) bool { return len(c.CORS.AllowedOrigins) == 2 && c.CORS.AllowedOrigins[0] == "https://example.com" && c.CORS.AllowedOrigins[1] == "https://other.com" }, desc: "should split and trim comma-separated origins", }, { name: "methods override", envKey: "CORS_ALLOWED_METHODS", envVal: "GET,POST", check: func(c *Config) bool { return len(c.CORS.AllowedMethods) == 2 && c.CORS.AllowedMethods[0] == "GET" && c.CORS.AllowedMethods[1] == "POST" }, desc: "should split comma-separated methods", }, { name: "headers override", envKey: "CORS_ALLOWED_HEADERS", envVal: "Authorization", check: func(c *Config) bool { return len(c.CORS.AllowedHeaders) == 1 && c.CORS.AllowedHeaders[0] == "Authorization" }, desc: "should accept single header value", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Set the env var under test; clear all others to avoid cross-talk os.Setenv(tt.envKey, tt.envVal) defer os.Unsetenv(tt.envKey) // Also set TRUST_PROXY so validateSecurity doesn't reject default config os.Setenv("TRUST_PROXY", "true") defer os.Unsetenv("TRUST_PROXY") cfg, err := Load() if err != nil { t.Fatalf("Load() error = %v", err) } if !tt.check(cfg) { t.Fatalf("%s: check failed; origins=%v methods=%v headers=%v", tt.desc, cfg.CORS.AllowedOrigins, cfg.CORS.AllowedMethods, cfg.CORS.AllowedHeaders) } }) } } func TestBoolEnvOverrides(t *testing.T) { tests := []struct { name string envKey string envVal string check func(*Config) bool }{ { name: "AUTH_ENABLED true", envKey: "AUTH_ENABLED", envVal: "true", check: func(c *Config) bool { return c.Auth.Enabled }, }, { name: "AUTH_ENABLED yes", envKey: "AUTH_ENABLED", envVal: "yes", check: func(c *Config) bool { return c.Auth.Enabled }, }, { name: "AUTH_ENABLED 1", envKey: "AUTH_ENABLED", envVal: "1", check: func(c *Config) bool { return c.Auth.Enabled }, }, { name: "TRUST_PROXY true", envKey: "TRUST_PROXY", envVal: "true", check: func(c *Config) bool { return c.Auth.TrustProxy }, }, { name: "STORAGE_METADATA_ONLY true", envKey: "STORAGE_METADATA_ONLY", envVal: "true", check: func(c *Config) bool { return c.Storage.MetadataOnly }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { os.Setenv(tt.envKey, tt.envVal) defer os.Unsetenv(tt.envKey) // Need TRUST_PROXY or AUTH_TOKEN for security validation when AUTH_ENABLED if tt.envKey == "AUTH_ENABLED" { os.Setenv("AUTH_TOKEN", "test-token") defer os.Unsetenv("AUTH_TOKEN") } if tt.envKey != "TRUST_PROXY" { os.Setenv("TRUST_PROXY", "true") defer os.Unsetenv("TRUST_PROXY") } cfg, err := Load() if err != nil { t.Fatalf("Load() error = %v", err) } if !tt.check(cfg) { t.Fatalf("expected %s=%s to set config field to true", tt.envKey, tt.envVal) } }) } } func TestSplitAndTrim(t *testing.T) { tests := []struct { input string want []string }{ {"a,b,c", []string{"a", "b", "c"}}, {" a , b , c ", []string{"a", "b", "c"}}, {"single", []string{"single"}}, {"a,,b", []string{"a", "b"}}, {"", []string{}}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := splitAndTrim(tt.input) if len(got) != len(tt.want) { t.Fatalf("splitAndTrim(%q) = %v, want %v", tt.input, got, tt.want) } for i := range got { if got[i] != tt.want[i] { t.Fatalf("splitAndTrim(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) } } }) } } func TestNoAnthropicConfigField(t *testing.T) { // Verify the top-level Anthropic field was removed from Config. // The canonical location is cfg.Providers.Anthropic. cfg := defaultConfig() if cfg.Providers.Anthropic.BaseURL == "" { t.Fatal("expected Providers.Anthropic.BaseURL to have a default value") } }