package config import ( "strings" "testing" "time" ) func TestGetenvBoolDefaults(t *testing.T) { t.Setenv("DESC_TEST_BOOL_UNSET", "") if getenvBool("DESC_TEST_BOOL_UNSET", false) != false { t.Fatal("empty should use fallback false") } t.Setenv("DESC_TEST_BOOL_TRUE", "true") if !getenvBool("DESC_TEST_BOOL_TRUE", false) { t.Fatal("expected true") } t.Setenv("DESC_TEST_BOOL_FALSE", "0") if getenvBool("DESC_TEST_BOOL_FALSE", true) { t.Fatal("expected false from 0") } } func TestLoadMaintenanceAndReadOnlyModes(t *testing.T) { t.Setenv("WEB_ORIGIN", "http://localhost:5174") t.Setenv("MAINTENANCE_MODE", "") t.Setenv("READ_ONLY_MODE", "") t.Setenv("HYPERCARE_MODE", "") off, err := Load() if err != nil { t.Fatal(err) } if off.MaintenanceMode || off.ReadOnlyMode || off.HypercareMode { t.Fatalf("defaults want false/false/false, got maint=%v ro=%v hyper=%v", off.MaintenanceMode, off.ReadOnlyMode, off.HypercareMode) } t.Setenv("MAINTENANCE_MODE", "true") t.Setenv("READ_ONLY_MODE", "1") t.Setenv("HYPERCARE_MODE", "true") on, err := Load() if err != nil { t.Fatal(err) } if !on.MaintenanceMode || !on.ReadOnlyMode || !on.HypercareMode { t.Fatalf("want true/true/true, got maint=%v ro=%v hyper=%v", on.MaintenanceMode, on.ReadOnlyMode, on.HypercareMode) } t.Setenv("MAINTENANCE_MODE", "false") t.Setenv("READ_ONLY_MODE", "false") t.Setenv("HYPERCARE_MODE", "false") cleared, err := Load() if err != nil { t.Fatal(err) } if cleared.MaintenanceMode || cleared.ReadOnlyMode || cleared.HypercareMode { t.Fatalf("explicit false want false/false/false, got maint=%v ro=%v hyper=%v", cleared.MaintenanceMode, cleared.ReadOnlyMode, cleared.HypercareMode) } } func TestLoadRateLimitReplicaEnv(t *testing.T) { t.Setenv("WEB_ORIGIN", "http://localhost:5174") t.Setenv("RATE_LIMIT_REPLICAS", "") t.Setenv("RATE_LIMIT_MULTI_REPLICA", "") t.Setenv("RATE_LIMIT_BACKEND", "") defaultCfg, err := Load() if err != nil { t.Fatal(err) } if defaultCfg.RateLimitReplicas != 1 || defaultCfg.RateLimitBackend != "memory" || defaultCfg.ShouldWarnRateLimits() { t.Fatalf("defaults: replicas=%d backend=%q warn=%v", defaultCfg.RateLimitReplicas, defaultCfg.RateLimitBackend, defaultCfg.ShouldWarnRateLimits()) } t.Setenv("RATE_LIMIT_REPLICAS", "4") t.Setenv("RATE_LIMIT_MULTI_REPLICA", "true") t.Setenv("RATE_LIMIT_BACKEND", "redis") multi, err := Load() if err != nil { t.Fatal(err) } if multi.RateLimitReplicas != 4 || multi.RateLimitBackend != "memory" || multi.RateLimitBackendRequested != "redis" { t.Fatalf("got replicas=%d backend=%q requested=%q", multi.RateLimitReplicas, multi.RateLimitBackend, multi.RateLimitBackendRequested) } if !multi.ShouldWarnRateLimits() { t.Fatal("expected warn for multi-replica / unsupported backend") } if !strings.Contains(multi.RateLimitWarningMessage(), "RATE_LIMIT_BACKEND=redis") { t.Fatalf("warning missing redis note: %s", multi.RateLimitWarningMessage()) } t.Setenv("RATE_LIMIT_REPLICAS", "999") t.Setenv("RATE_LIMIT_MULTI_REPLICA", "false") t.Setenv("RATE_LIMIT_BACKEND", "memory") clamped, err := Load() if err != nil { t.Fatal(err) } if clamped.RateLimitReplicas != 128 { t.Fatalf("replicas clamp want 128 got %d", clamped.RateLimitReplicas) } } func TestGetenvDuration(t *testing.T) { t.Setenv("DESC_TEST_DUR", "500ms") if got := getenvDuration("DESC_TEST_DUR", time.Second); got != 500*time.Millisecond { t.Fatalf("got %v", got) } t.Setenv("DESC_TEST_DUR_SEC", "12") if got := getenvDuration("DESC_TEST_DUR_SEC", time.Second); got != 12*time.Second { t.Fatalf("got %v", got) } t.Setenv("DESC_TEST_DUR_ZERO", "0") if got := getenvDurationAllowZero("DESC_TEST_DUR_ZERO", time.Second); got != 0 { t.Fatalf("allow zero got %v", got) } } func TestCORSAllowedOriginsLoopbackTwin(t *testing.T) { got := CORSAllowedOrigins("http://localhost:28472") if len(got) != 2 || got[0] != "http://localhost:28472" || got[1] != "http://127.0.0.1:28472" { t.Fatalf("localhost twin = %#v", got) } got = CORSAllowedOrigins("http://127.0.0.1:28472/") if len(got) != 2 || got[0] != "http://127.0.0.1:28472" || got[1] != "http://localhost:28472" { t.Fatalf("127 twin = %#v", got) } got = CORSAllowedOrigins("https://app.example.com") if len(got) != 1 || got[0] != "https://app.example.com" { t.Fatalf("non-loopback must stay exact: %#v", got) } } func TestValidateWebOrigin(t *testing.T) { if err := validateWebOrigin("http://localhost:5174"); err != nil { t.Fatal(err) } if err := validateWebOrigin("*"); err == nil { t.Fatal("expected * rejected") } if err := validateWebOrigin("https://app.example.com/dashboard"); err == nil { t.Fatal("expected path rejected") } if got := normalizeWebOrigin("https://app.example.com/"); got != "https://app.example.com" { t.Fatalf("normalize = %q", got) } } func TestParseTrustedProxyNets(t *testing.T) { nets, err := ParseTrustedProxyNets([]string{"10.0.0.0/8", "192.0.2.1"}) if err != nil { t.Fatal(err) } if len(nets) != 2 { t.Fatalf("len = %d", len(nets)) } if _, err := ParseTrustedProxyNets([]string{"not-an-ip"}); err == nil { t.Fatal("expected invalid IP rejected") } } func TestProductionValidateFailsClosed(t *testing.T) { cfg := Config{ AppEnv: "production", WebOrigin: "https://app.example.com", SessionSecure: false, AppEncryptionKey: "x", TokenSigningSecret: "y", EmailDryRun: true, // default Load() is true; zero-value false would trip mail checks ProcessingPollInterval: 250 * time.Millisecond, DBMaxConns: 20, DBMinConns: 2, DBMaxConnLifetime: time.Hour, DBMaxConnIdleTime: 5 * time.Minute, DBHealthCheckPeriod: time.Minute, DBStatementTimeout: 30 * time.Second, } if err := cfg.validate(); err == nil { t.Fatal("expected SESSION_SECURE required") } cfg.SessionSecure = true cfg.StripeMock = true if err := cfg.validate(); err == nil { t.Fatal("expected STRIPE_MOCK rejected") } cfg.StripeMock = false cfg.AppEncryptionKey = "" if err := cfg.validate(); err == nil { t.Fatal("expected APP_ENCRYPTION_KEY required") } cfg.AppEncryptionKey = "enc" cfg.TokenSigningSecret = "" if err := cfg.validate(); err == nil { t.Fatal("expected TOKEN_SIGNING_SECRET required") } cfg.TokenSigningSecret = "tok" if err := cfg.validate(); err != nil { t.Fatal(err) } // Stripe keys are optional at boot (admin platform_settings); still reject mock. cfg.StripeSecretKey = "" cfg.StripeWebhookSecret = "" if err := cfg.validate(); err != nil { t.Fatal(err) } cfg.StripeSecretKey = "sk_live_test" cfg.StripeWebhookSecret = "whsec_test" if err := cfg.validate(); err != nil { t.Fatal(err) } cfg.WebOrigin = "https://localhost" if err := cfg.validate(); err == nil { t.Fatal("expected localhost WEB_ORIGIN rejected in production") } cfg.WebOrigin = "https://app.example.com" cfg.TrustedProxies = []string{"bad"} if err := cfg.validate(); err == nil { t.Fatal("expected invalid TRUSTED_PROXIES rejected") } } func TestProductionLoopbackWebOriginEscape(t *testing.T) { base := Config{ AppEnv: "production", WebOrigin: "http://localhost:28472", SessionSecure: false, AppEncryptionKey: "enc", TokenSigningSecret: "tok", ProcessingPollInterval: 250 * time.Millisecond, DBMaxConns: 20, DBMinConns: 2, DBMaxConnLifetime: time.Hour, DBMaxConnIdleTime: 5 * time.Minute, DBHealthCheckPeriod: time.Minute, DBStatementTimeout: 30 * time.Second, } denied := base if err := denied.validate(); err == nil { t.Fatal("expected http loopback WEB_ORIGIN rejected without escape") } else if !strings.Contains(err.Error(), "ALLOW_INSECURE_LOCAL_PRODUCTION") { t.Fatalf("error should mention escape hatch: %v", err) } allowed := base allowed.AllowInsecureLocalProduction = true if err := allowed.validate(); err != nil { t.Fatal(err) } if !allowed.InsecureLocalProductionActive() { t.Fatal("expected insecure local production active") } if allowed.CookieSecure() { t.Fatal("http loopback escape should not force CookieSecure") } httpsLocal := allowed httpsLocal.WebOrigin = "https://localhost:28472" if err := httpsLocal.validate(); err == nil { t.Fatal("expected SESSION_SECURE required for https loopback escape") } httpsLocal.SessionSecure = true if err := httpsLocal.validate(); err != nil { t.Fatal(err) } // Escape must not weaken public (non-loopback) http. publicHTTP := base publicHTTP.AllowInsecureLocalProduction = true publicHTTP.WebOrigin = "http://app.example.com" publicHTTP.SessionSecure = true if err := publicHTTP.validate(); err == nil { t.Fatal("expected public http WEB_ORIGIN rejected even with escape flag") } } func TestValidateSMTPEnabledDoesNotRequireEnvHost(t *testing.T) { // SMTP credentials live in admin platform settings; boot must succeed with SMTP_ENABLED=true and empty host. cfg := Config{ AppEnv: "development", WebOrigin: "http://localhost:5174", SMTPEnabled: true, ProcessingPollInterval: 250 * time.Millisecond, DBMaxConns: 20, DBMinConns: 2, DBMaxConnLifetime: time.Hour, DBMaxConnIdleTime: 5 * time.Minute, DBHealthCheckPeriod: time.Minute, DBStatementTimeout: 30 * time.Second, } if err := cfg.validate(); err != nil { t.Fatal(err) } } func TestProductionEmailDryRunFalseDoesNotRequireEnvDelivery(t *testing.T) { // Live delivery is configured in admin settings; production boot must not require RESEND/SMTP env. cfg := Config{ AppEnv: "production", WebOrigin: "https://app.example.com", SessionSecure: true, AppEncryptionKey: "enc", TokenSigningSecret: "tok", StripeSecretKey: "sk_live_test", StripeWebhookSecret: "whsec_test", EmailDryRun: false, ProcessingPollInterval: 250 * time.Millisecond, DBMaxConns: 20, DBMinConns: 2, DBMaxConnLifetime: time.Hour, DBMaxConnIdleTime: 5 * time.Minute, DBHealthCheckPeriod: time.Minute, DBStatementTimeout: 30 * time.Second, } if err := cfg.validate(); err != nil { t.Fatal(err) } cfg.SMTPEnabled = true cfg.SMTPFrom = "noreply@localhost" if err := cfg.validate(); err == nil { t.Fatal("expected localhost SMTP_FROM rejected in production") } cfg.SMTPFrom = "noreply@example.com" if err := cfg.validate(); err != nil { t.Fatal(err) } } func TestIsProductionEnv(t *testing.T) { t.Setenv("APP_ENV", "production") if !IsProductionEnv() { t.Fatal("expected production") } t.Setenv("APP_ENV", "prod") if !IsProductionEnv() { t.Fatal("expected prod") } t.Setenv("APP_ENV", "development") if IsProductionEnv() { t.Fatal("expected non-production") } } func TestCookieSecure(t *testing.T) { t.Parallel() if (Config{SessionSecure: true}).CookieSecure() != true { t.Fatal("SessionSecure should enable CookieSecure") } if (Config{AppEnv: "production"}).CookieSecure() != true { t.Fatal("production AppEnv should enable CookieSecure") } if (Config{AppEnv: "prod"}).CookieSecure() != true { t.Fatal("prod AppEnv should enable CookieSecure") } if (Config{AppEnv: "development", SessionSecure: false}).CookieSecure() { t.Fatal("development without SessionSecure should not enable CookieSecure") } insecureLocal := Config{ AppEnv: "production", WebOrigin: "http://localhost:28472", AllowInsecureLocalProduction: true, SessionSecure: false, } if insecureLocal.CookieSecure() { t.Fatal("insecure local production http escape should not force CookieSecure") } } func TestLoadSessionSecureDefaultsWithAppEnv(t *testing.T) { t.Setenv("WEB_ORIGIN", "http://localhost:5174") t.Setenv("SESSION_SECURE", "") t.Setenv("APP_ENV", "development") dev, err := Load() if err != nil { t.Fatal(err) } if dev.SessionSecure { t.Fatal("development should default SessionSecure=false when unset") } // Production defaults Secure=true when SESSION_SECURE unset; still needs other prod knobs. t.Setenv("APP_ENV", "production") t.Setenv("WEB_ORIGIN", "https://app.example.com") t.Setenv("APP_ENCRYPTION_KEY", "enc-key") t.Setenv("TOKEN_SIGNING_SECRET", "tok-secret") t.Setenv("STRIPE_MOCK", "false") t.Setenv("STRIPE_SECRET_KEY", "sk_live_test") t.Setenv("STRIPE_WEBHOOK_SECRET", "whsec_test") prod, err := Load() if err != nil { t.Fatal(err) } if !prod.SessionSecure { t.Fatal("production should default SessionSecure=true when SESSION_SECURE unset") } if !prod.CookieSecure() { t.Fatal("production CookieSecure should be true") } } func TestValidateDBPool(t *testing.T) { valid := Config{ DBMaxConns: 20, DBMinConns: 2, DBMaxConnLifetime: time.Hour, DBMaxConnLifetimeJitter: 6 * time.Minute, DBMaxConnIdleTime: 5 * time.Minute, DBHealthCheckPeriod: time.Minute, DBStatementTimeout: 30 * time.Second, } if err := valid.validateDBPool(); err != nil { t.Fatal(err) } bad := valid bad.DBMinConns = 50 if err := bad.validateDBPool(); err == nil { t.Fatal("expected min > max rejected") } bad = valid bad.DBMaxConns = 0 if err := bad.validateDBPool(); err == nil { t.Fatal("expected max < 1 rejected") } bad = valid bad.DBStatementTimeout = -time.Second if err := bad.validateDBPool(); err == nil { t.Fatal("expected negative statement timeout rejected") } bad = valid bad.DBMaxConnLifetimeJitter = -time.Second if err := bad.validateDBPool(); err == nil { t.Fatal("expected negative lifetime jitter rejected") } zeroTimeout := valid zeroTimeout.DBStatementTimeout = 0 if err := zeroTimeout.validateDBPool(); err != nil { t.Fatal(err) } zeroJitter := valid zeroJitter.DBMaxConnLifetimeJitter = 0 if err := zeroJitter.validateDBPool(); err != nil { t.Fatal(err) } } func TestLoadDBPoolDefaults(t *testing.T) { // Ensure unset env uses repo defaults (historical MaxConns/MinConns + idle/timeout). for _, key := range []string{ "DB_MAX_CONNS", "DB_MIN_CONNS", "DB_MAX_CONN_LIFETIME", "DB_MAX_CONN_LIFETIME_JITTER", "DB_MAX_CONN_IDLE_TIME", "DB_HEALTH_CHECK_PERIOD", "DB_STATEMENT_TIMEOUT", } { t.Setenv(key, "") } t.Setenv("WEB_ORIGIN", "http://localhost:5174") cfg, err := Load() if err != nil { t.Fatal(err) } if cfg.DBMaxConns != 20 || cfg.DBMinConns != 2 { t.Fatalf("pool size defaults: max=%d min=%d", cfg.DBMaxConns, cfg.DBMinConns) } if cfg.DBMaxConnLifetime != time.Hour { t.Fatalf("lifetime: %v", cfg.DBMaxConnLifetime) } if cfg.DBMaxConnLifetimeJitter != 6*time.Minute { t.Fatalf("lifetime jitter: %v", cfg.DBMaxConnLifetimeJitter) } if cfg.DBMaxConnIdleTime != 5*time.Minute { t.Fatalf("idle: %v", cfg.DBMaxConnIdleTime) } if cfg.DBHealthCheckPeriod != time.Minute { t.Fatalf("health: %v", cfg.DBHealthCheckPeriod) } if cfg.DBStatementTimeout != 30*time.Second { t.Fatalf("statement timeout: %v", cfg.DBStatementTimeout) } } func TestLoadProcessingPollIntervalDefault(t *testing.T) { t.Setenv("PROCESSING_POLL_INTERVAL", "") t.Setenv("WEB_ORIGIN", "http://localhost:5174") cfg, err := Load() if err != nil { t.Fatal(err) } if cfg.ProcessingPollInterval != 250*time.Millisecond { t.Fatalf("poll interval: %v want 250ms", cfg.ProcessingPollInterval) } } // Default must stay aligned with web DEFAULT_CSRF_COOKIE_NAME / PUBLIC_CSRF_COOKIE_NAME fallback. func TestLoadCSRFCookieNameDefaultAndOverride(t *testing.T) { t.Setenv("WEB_ORIGIN", "http://localhost:5174") t.Setenv("CSRF_COOKIE_NAME", "") cfg, err := Load() if err != nil { t.Fatal(err) } if cfg.CSRFCookieName != "descrybe_csrf" { t.Fatalf("CSRFCookieName default: %q want descrybe_csrf", cfg.CSRFCookieName) } t.Setenv("CSRF_COOKIE_NAME", "custom_csrf") override, err := Load() if err != nil { t.Fatal(err) } if override.CSRFCookieName != "custom_csrf" { t.Fatalf("CSRFCookieName override: %q want custom_csrf", override.CSRFCookieName) } } func TestValidateProcessingPollInterval(t *testing.T) { cfg := Config{ AppEnv: "development", WebOrigin: "http://localhost:5174", ProcessingPollInterval: 250 * time.Millisecond, DBMaxConns: 20, DBMinConns: 2, DBMaxConnLifetime: time.Hour, DBMaxConnIdleTime: 5 * time.Minute, DBHealthCheckPeriod: time.Minute, DBStatementTimeout: 30 * time.Second, } if err := cfg.validate(); err != nil { t.Fatal(err) } cfg.ProcessingPollInterval = 0 if err := cfg.validate(); err == nil { t.Fatal("expected PROCESSING_POLL_INTERVAL > 0") } } func TestSessionCookieParentDomain(t *testing.T) { t.Parallel() cases := []struct { name string web string api string want string }{ {name: "prod_split", web: "https://descrybe.io", api: "https://api.descrybe.io", want: "descrybe.io"}, {name: "prod_split_reversed", web: "https://app.descrybe.io", api: "https://descrybe.io", want: "descrybe.io"}, {name: "sibling_subdomains", web: "https://app.descrybe.io", api: "https://api.descrybe.io", want: "descrybe.io"}, {name: "localhost_ports", web: "http://localhost:28472", api: "http://localhost:28471", want: ""}, {name: "loopback_ip", web: "http://127.0.0.1:28472", api: "http://127.0.0.1:28471", want: ""}, {name: "same_host", web: "https://descrybe.io", api: "https://descrybe.io", want: ""}, {name: "unrelated_hosts", web: "https://descrybe.io", api: "https://example.com", want: ""}, {name: "empty_api", web: "https://descrybe.io", api: "", want: ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() c := Config{WebOrigin: tc.web, PublicAPIURL: tc.api} if got := c.SessionCookieParentDomain(); got != tc.want { t.Fatalf("web=%q api=%q got %q want %q", tc.web, tc.api, got, tc.want) } }) } }