Files
descrybe/apps/api/internal/config/config_test.go
T

467 lines
14 KiB
Go
Raw Normal View History

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 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")
}
}
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")
}
}