Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user