2026-08-09 22:47:43 +02:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 17:38:15 +02:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
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")
|
|
|
|
|
}
|
2026-08-16 17:38:15 +02:00
|
|
|
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")
|
|
|
|
|
}
|
2026-08-09 22:47:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-18 01:12:23 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|