Files
greeneclipseandClaude Fable 5 d03c2a5c57 Auto-derive session cookie domain; AI prompt page = formula builder hub
Session (no env needed):
- SESSION_COOKIE_DOMAIN env removed. Config.SessionCookieParentDomain()
  derives the cookie Domain from WEB_ORIGIN + PUBLIC_API_URL, which the
  API already requires: sibling hosts of one parent (descrybe.io +
  api.descrybe.io) share the parent domain so SvelteKit SSR (/admin
  gate, user switching) receives the session cookie; localhost, IPs,
  same-host, and unrelated hosts stay host-only. Deploying the new build
  is the whole fix — nothing to configure.

AI generation prompt page:
- Each section now embeds its formula editor next to the per-language
  prompt instructions: Title = full title formula builder (preview,
  elements, separator, variable selector, custom variables), Description
  = description formula sections editor (type + instructions + export
  id, drag reorder), Meta = meta title / meta description formula
  fields. One Save writes categories.prompt + title_template +
  description_template together; Assign copies all three to the
  selected categories.
- New $lib/categories/formula-variables.ts loads every usable field for
  the builder: custom variables (/api/variables), company attributes
  (/api/attributes — attribute_key, name, unit, example), and standard
  fields (/api/standard-fields). Used by both the prompt page and the
  title-formula page (which previously ignored attributes).

Verified locally: svelte-check clean for changed files, unit tests pass,
and the full save contract exercised over HTTP as the page does it
(login → load variables/attributes/standard-fields → PATCH prompt +
title-formula + description-formula → round-trip read), then the test
category restored via repair-category-prompts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 01:12:23 +02:00

559 lines
18 KiB
Go

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