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,638 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// AppEnv is development|staging|production. Production fails closed on insecure knobs.
|
||||
AppEnv string
|
||||
DatabaseURL string
|
||||
HTTPAddr string
|
||||
WebOrigin string
|
||||
// TrustedProxies lists reverse-proxy CIDRs/IPs allowed to set client IP
|
||||
// headers (X-Forwarded-For, X-Real-IP, True-Client-IP). Empty (default)
|
||||
// ignores those headers — safe for local and direct exposure.
|
||||
TrustedProxies []string
|
||||
// RateLimitReplicas divides HTTP middleware caps in httpapi/ratelimit.go (ceil)
|
||||
// so aggregate under even load approximates documented RPM. Default 1.
|
||||
// Does not affect login lockout, StartLimiter, AIRateLimiter, or email limiters.
|
||||
// Not a shared store — multi-replica hard global caps still need edge/WAF cutover.
|
||||
// Env: RATE_LIMIT_REPLICAS.
|
||||
RateLimitReplicas int
|
||||
// RateLimitMultiReplica is an ops acknowledgment that multiple API replicas run
|
||||
// without a shared limiter store. Boots with a warning when true or replicas > 1.
|
||||
// Env: RATE_LIMIT_MULTI_REPLICA.
|
||||
RateLimitMultiReplica bool
|
||||
// RateLimitBackend is the effective limiter store (always "memory" today).
|
||||
RateLimitBackend string
|
||||
// RateLimitBackendRequested is the raw RATE_LIMIT_BACKEND value when unsupported
|
||||
// (e.g. redis/postgres) so boot can warn that memory was forced.
|
||||
RateLimitBackendRequested string
|
||||
SessionCookieName string
|
||||
SessionSecure bool
|
||||
CSRFCookieName string
|
||||
PublicAPIURL string
|
||||
MigrateMySQLDSN string
|
||||
// MaintenanceMode rejects all non-health traffic with 503 (cutover freeze / emergency).
|
||||
MaintenanceMode bool
|
||||
// ReadOnlyMode rejects mutating methods (POST/PUT/PATCH/DELETE) with 503; GETs still work.
|
||||
ReadOnlyMode bool
|
||||
// HypercareMode shows the tenant “report missing/wrong data” CTA (P1-17); clear to end the window.
|
||||
HypercareMode bool
|
||||
SessionIdleHours int
|
||||
LowCreditsThreshold int
|
||||
SMTPEnabled bool
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPassword string
|
||||
SMTPFrom string
|
||||
TokenSigningSecret string
|
||||
|
||||
// AI processing (worker). Prefer admin platform settings (DB); env is optional bootstrap fallback.
|
||||
OpenAIAPIKey string
|
||||
OpenAIBaseURL string
|
||||
OpenAIModel string
|
||||
// Optional embeddings bootstrap for admin AI role "vectorization".
|
||||
// Empty key/base fall back to OpenAIAPIKey / OpenAIBaseURL at resolve time.
|
||||
OpenAIEmbeddingAPIKey string
|
||||
OpenAIEmbeddingBaseURL string
|
||||
OpenAIEmbeddingModel string
|
||||
ProcessingRPM int
|
||||
ProcessingMaxRetries int
|
||||
ProcessingBatchSize int
|
||||
ProcessingPollInterval time.Duration // worker ClaimNext/Fill idle tick
|
||||
PineconeAPIKey string
|
||||
PineconeHost string
|
||||
PineconeNamespace string
|
||||
UploadDir string
|
||||
// CredentialsEncryptionKey encrypts WooCommerce consumer secrets at rest.
|
||||
// Prefer APP_ENCRYPTION_KEY, else CREDENTIALS_ENCRYPTION_KEY; falls back to TokenSigningSecret / DATABASE_URL.
|
||||
CredentialsEncryptionKey string
|
||||
// AppEncryptionKey encrypts tenant email provider secrets (Resend/SMTP) at rest.
|
||||
// Prefer APP_ENCRYPTION_KEY; falls back to CredentialsEncryptionKey then TokenSigningSecret.
|
||||
AppEncryptionKey string
|
||||
// Marketing email send rate limits (per company, in-process).
|
||||
EmailSendRPM int
|
||||
EmailSendRPH int
|
||||
// EmailDryRun forces transactional + campaign/provider sends to log-only (also forced for Free plan).
|
||||
// Default true when EMAIL_DRY_RUN unset (safe). Preferred source of truth is admin
|
||||
// platform settings (smtp.email_dry_run / mail.email_dry_run); env is bootstrap fallback.
|
||||
EmailDryRun bool
|
||||
// EmailDryRunSet is true when EMAIL_DRY_RUN was explicitly present in the process env.
|
||||
EmailDryRunSet bool
|
||||
// ResendAPIKey is an optional platform-level Resend key used when a company has no key.
|
||||
// Prefer admin platform settings; env is bootstrap fallback only.
|
||||
ResendAPIKey string
|
||||
|
||||
// EPREL public energy-label enrichment during product processing.
|
||||
EPRELEnabled bool
|
||||
EPRELBaseURL string
|
||||
EPRELTimeout time.Duration
|
||||
EPRELFicheLanguage string
|
||||
EPRELAPIKey string // optional; never log
|
||||
|
||||
// Stripe billing (Checkout + Customer Portal + webhooks). Empty secret → mock mode.
|
||||
StripeSecretKey string
|
||||
StripeWebhookSecret string
|
||||
StripeMock bool
|
||||
StripePriceIDs map[string]string // "starter:monthly" → price_…
|
||||
|
||||
// MetricsPublic exposes GET /metrics beyond loopback in production (METRICS_PUBLIC=1).
|
||||
// Non-production always allows scrapes. Production without this flag: loopback only.
|
||||
MetricsPublic bool
|
||||
|
||||
// Postgres pgx pool (api + worker). Defaults preserve historical NewPool hardcodes
|
||||
// and add idle recycle + statement_timeout for multi-tenant churn.
|
||||
// See docs/ops-runtime.md § Postgres pgx pool and db.PoolOptions comments.
|
||||
DBMaxConns int
|
||||
DBMinConns int
|
||||
DBMaxConnLifetime time.Duration
|
||||
DBMaxConnLifetimeJitter time.Duration
|
||||
DBMaxConnIdleTime time.Duration
|
||||
DBHealthCheckPeriod time.Duration
|
||||
DBStatementTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
// Monorepo root .env is the single local source of truth (see loadDotEnv).
|
||||
loadDotEnv()
|
||||
appEnv := getenv("APP_ENV", "development")
|
||||
cfg := Config{
|
||||
AppEnv: appEnv,
|
||||
DatabaseURL: getenv("DATABASE_URL", "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"),
|
||||
HTTPAddr: getenv("HTTP_ADDR", ":28471"),
|
||||
WebOrigin: getenv("WEB_ORIGIN", "http://localhost:28472"),
|
||||
TrustedProxies: parseCSVList(os.Getenv("TRUSTED_PROXIES")),
|
||||
RateLimitReplicas: getenvInt("RATE_LIMIT_REPLICAS", 1),
|
||||
RateLimitMultiReplica: getenvBool("RATE_LIMIT_MULTI_REPLICA", false),
|
||||
RateLimitBackend: "memory",
|
||||
SessionCookieName: getenv("SESSION_COOKIE_NAME", "descrybe_session"),
|
||||
// Default Secure=true when APP_ENV is production|prod so cookies are HTTPS-only
|
||||
// even if SESSION_SECURE is unset; explicit false still fails closed in validate.
|
||||
SessionSecure: getenvBool("SESSION_SECURE", isProductionEnvValue(appEnv)),
|
||||
CSRFCookieName: getenv("CSRF_COOKIE_NAME", "descrybe_csrf"),
|
||||
PublicAPIURL: getenv("PUBLIC_API_URL", "http://localhost:28471"),
|
||||
MigrateMySQLDSN: os.Getenv("MIGRATE_MYSQL_DSN"),
|
||||
MaintenanceMode: getenvBool("MAINTENANCE_MODE", false),
|
||||
ReadOnlyMode: getenvBool("READ_ONLY_MODE", false),
|
||||
HypercareMode: getenvBool("HYPERCARE_MODE", false),
|
||||
SessionIdleHours: getenvInt("SESSION_IDLE_HOURS", 24),
|
||||
LowCreditsThreshold: getenvInt("LOW_CREDITS_THRESHOLD", 100),
|
||||
SMTPEnabled: getenvBool("SMTP_ENABLED", false),
|
||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||
SMTPPort: getenv("SMTP_PORT", "587"),
|
||||
SMTPUser: os.Getenv("SMTP_USER"),
|
||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||
SMTPFrom: getenv("SMTP_FROM", "noreply@localhost"),
|
||||
TokenSigningSecret: os.Getenv("TOKEN_SIGNING_SECRET"),
|
||||
OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"),
|
||||
OpenAIBaseURL: getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
|
||||
OpenAIModel: getenv("OPENAI_MODEL", "gpt-4o-mini"),
|
||||
OpenAIEmbeddingAPIKey: os.Getenv("OPENAI_EMBEDDING_API_KEY"),
|
||||
OpenAIEmbeddingBaseURL: os.Getenv("OPENAI_EMBEDDING_BASE_URL"),
|
||||
OpenAIEmbeddingModel: getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"),
|
||||
ProcessingRPM: getenvInt("PROCESSING_RPM", 60),
|
||||
ProcessingMaxRetries: getenvInt("PROCESSING_MAX_RETRIES", 3),
|
||||
ProcessingBatchSize: getenvInt("PROCESSING_BATCH_SIZE", 100),
|
||||
ProcessingPollInterval: getenvDuration("PROCESSING_POLL_INTERVAL", 250*time.Millisecond),
|
||||
PineconeAPIKey: os.Getenv("PINECONE_API_KEY"),
|
||||
PineconeHost: os.Getenv("PINECONE_HOST"),
|
||||
PineconeNamespace: getenv("PINECONE_NAMESPACE", ""),
|
||||
UploadDir: getenv("UPLOAD_DIR", "data/uploads"),
|
||||
CredentialsEncryptionKey: firstEnv("APP_ENCRYPTION_KEY", "CREDENTIALS_ENCRYPTION_KEY"),
|
||||
AppEncryptionKey: firstEnv("APP_ENCRYPTION_KEY", "CREDENTIALS_ENCRYPTION_KEY"),
|
||||
EmailDryRun: getenvBool("EMAIL_DRY_RUN", true),
|
||||
EmailDryRunSet: strings.TrimSpace(os.Getenv("EMAIL_DRY_RUN")) != "",
|
||||
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||
EmailSendRPM: getenvInt("EMAIL_SEND_RPM", 30),
|
||||
EmailSendRPH: getenvInt("EMAIL_SEND_RPH", 500),
|
||||
EPRELEnabled: getenvBool("EPREL_ENABLED", true),
|
||||
EPRELBaseURL: getenv("EPREL_BASE_URL", "https://eprel.ec.europa.eu/api"),
|
||||
EPRELTimeout: getenvDuration("EPREL_TIMEOUT", 10*time.Second),
|
||||
EPRELFicheLanguage: getenv("EPREL_FICHE_LANGUAGE", "EN"),
|
||||
EPRELAPIKey: os.Getenv("EPREL_API_KEY"),
|
||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
StripeMock: getenvBool("STRIPE_MOCK", false),
|
||||
StripePriceIDs: loadStripePriceIDs(),
|
||||
MetricsPublic: getenvBool("METRICS_PUBLIC", false),
|
||||
DBMaxConns: getenvInt("DB_MAX_CONNS", 20),
|
||||
DBMinConns: getenvInt("DB_MIN_CONNS", 2),
|
||||
DBMaxConnLifetime: getenvDuration("DB_MAX_CONN_LIFETIME", time.Hour),
|
||||
DBMaxConnLifetimeJitter: getenvDurationAllowZero("DB_MAX_CONN_LIFETIME_JITTER", 6*time.Minute),
|
||||
DBMaxConnIdleTime: getenvDuration("DB_MAX_CONN_IDLE_TIME", 5*time.Minute),
|
||||
DBHealthCheckPeriod: getenvDuration("DB_HEALTH_CHECK_PERIOD", time.Minute),
|
||||
DBStatementTimeout: getenvDurationAllowZero("DB_STATEMENT_TIMEOUT", 30*time.Second),
|
||||
}
|
||||
if strings.TrimSpace(cfg.DatabaseURL) == "" {
|
||||
return Config{}, fmt.Errorf("DATABASE_URL is required")
|
||||
}
|
||||
if cfg.RateLimitReplicas < 1 {
|
||||
cfg.RateLimitReplicas = 1
|
||||
}
|
||||
if cfg.RateLimitReplicas > 128 {
|
||||
cfg.RateLimitReplicas = 128
|
||||
}
|
||||
if requested := strings.ToLower(strings.TrimSpace(os.Getenv("RATE_LIMIT_BACKEND"))); requested != "" && requested != "memory" {
|
||||
cfg.RateLimitBackendRequested = requested
|
||||
}
|
||||
cfg.RateLimitBackend = "memory"
|
||||
if cfg.EPRELEnabled {
|
||||
if err := validatePublicHTTPBaseURL(cfg.EPRELBaseURL, "EPREL_BASE_URL"); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.WebOrigin = normalizeWebOrigin(cfg.WebOrigin)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// IsProduction reports whether APP_ENV is production (or prod).
|
||||
func (c Config) IsProduction() bool {
|
||||
return isProductionEnvValue(c.AppEnv)
|
||||
}
|
||||
|
||||
// CookieSecure is true when session/CSRF cookies must carry the Secure flag.
|
||||
// Prefer SessionSecure; also force Secure when APP_ENV is production (defense in depth).
|
||||
func (c Config) CookieSecure() bool {
|
||||
return c.SessionSecure || c.IsProduction()
|
||||
}
|
||||
|
||||
// ShouldWarnRateLimits reports whether operators opted into multi-replica rate-limit
|
||||
// awareness or requested an unsupported shared backend.
|
||||
func (c Config) ShouldWarnRateLimits() bool {
|
||||
return c.RateLimitMultiReplica || c.RateLimitReplicas > 1 || c.RateLimitBackendRequested != ""
|
||||
}
|
||||
|
||||
// RateLimitWarningMessage is a stable ops-facing explanation for in-process limits.
|
||||
func (c Config) RateLimitWarningMessage() string {
|
||||
msg := "HTTP rate limits are in-process only (no Redis/shared store); multi-replica hard caps need edge/WAF; optional RATE_LIMIT_REPLICAS divides HTTP middleware caps only (not lockout/StartLimiter/AI/email)"
|
||||
if c.RateLimitBackendRequested != "" {
|
||||
msg += "; RATE_LIMIT_BACKEND=" + c.RateLimitBackendRequested + " is not implemented — using memory"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// IsProductionEnv reports whether the live APP_ENV is production or prod.
|
||||
// Used by credential crypto helpers that do not hold a Config value.
|
||||
func IsProductionEnv() bool {
|
||||
return isProductionEnvValue(os.Getenv("APP_ENV"))
|
||||
}
|
||||
|
||||
func isProductionEnvValue(e string) bool {
|
||||
e = strings.ToLower(strings.TrimSpace(e))
|
||||
return e == "production" || e == "prod"
|
||||
}
|
||||
|
||||
func (c Config) validate() error {
|
||||
if err := validateWebOrigin(c.WebOrigin); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ParseTrustedProxyNets(c.TrustedProxies); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.validateDBPool(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.ProcessingPollInterval <= 0 {
|
||||
return fmt.Errorf("PROCESSING_POLL_INTERVAL must be > 0")
|
||||
}
|
||||
if err := c.validateSMTPConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.IsProduction() {
|
||||
return nil
|
||||
}
|
||||
if !c.SessionSecure {
|
||||
return fmt.Errorf("SESSION_SECURE=true is required when APP_ENV=production")
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.WebOrigin)), "https://") {
|
||||
return fmt.Errorf("WEB_ORIGIN must be https in production")
|
||||
}
|
||||
if isLoopbackWebOriginHost(c.WebOrigin) {
|
||||
return fmt.Errorf("WEB_ORIGIN must not be localhost/loopback in production")
|
||||
}
|
||||
if strings.TrimSpace(c.AppEncryptionKey) == "" {
|
||||
return fmt.Errorf("APP_ENCRYPTION_KEY is required in production")
|
||||
}
|
||||
if strings.TrimSpace(c.TokenSigningSecret) == "" {
|
||||
return fmt.Errorf("TOKEN_SIGNING_SECRET is required in production")
|
||||
}
|
||||
if c.StripeMock {
|
||||
return fmt.Errorf("STRIPE_MOCK must be false in production")
|
||||
}
|
||||
// Stripe secret/webhook keys may live in admin platform_settings; boot does not
|
||||
// require env STRIPE_* (checkout/webhooks fail closed until configured).
|
||||
if err := c.validateProductionMail(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSMTPConfig no longer fails closed at boot: SMTP credentials live in
|
||||
// admin platform settings (with optional env fallback). Incomplete SMTP_ENABLED
|
||||
// env is ignored until admin configures delivery.
|
||||
func (c Config) validateSMTPConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateProductionMail no longer requires RESEND_API_KEY / SMTP_HOST in env.
|
||||
// Live delivery is gated at send time via platform settings + tenant providers.
|
||||
// Still rejects localhost From when SMTP_ENABLED env is true (misconfig hint).
|
||||
func (c Config) validateProductionMail() error {
|
||||
if c.SMTPEnabled {
|
||||
from := strings.ToLower(strings.TrimSpace(c.SMTPFrom))
|
||||
if from != "" && strings.HasSuffix(from, "@localhost") {
|
||||
return fmt.Errorf("SMTP_FROM must not be a localhost address in production when SMTP_ENABLED=true")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) validateDBPool() error {
|
||||
if c.DBMaxConns < 1 {
|
||||
return fmt.Errorf("DB_MAX_CONNS must be >= 1")
|
||||
}
|
||||
if c.DBMinConns < 0 {
|
||||
return fmt.Errorf("DB_MIN_CONNS must be >= 0")
|
||||
}
|
||||
if c.DBMinConns > c.DBMaxConns {
|
||||
return fmt.Errorf("DB_MIN_CONNS must be <= DB_MAX_CONNS")
|
||||
}
|
||||
if c.DBMaxConnLifetime <= 0 {
|
||||
return fmt.Errorf("DB_MAX_CONN_LIFETIME must be > 0")
|
||||
}
|
||||
if c.DBMaxConnLifetimeJitter < 0 {
|
||||
return fmt.Errorf("DB_MAX_CONN_LIFETIME_JITTER must be >= 0")
|
||||
}
|
||||
if c.DBMaxConnIdleTime <= 0 {
|
||||
return fmt.Errorf("DB_MAX_CONN_IDLE_TIME must be > 0")
|
||||
}
|
||||
if c.DBHealthCheckPeriod <= 0 {
|
||||
return fmt.Errorf("DB_HEALTH_CHECK_PERIOD must be > 0")
|
||||
}
|
||||
// Statement timeout may be 0 to disable the GUC.
|
||||
if c.DBStatementTimeout < 0 {
|
||||
return fmt.Errorf("DB_STATEMENT_TIMEOUT must be >= 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWebOrigin(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("WEB_ORIGIN is required")
|
||||
}
|
||||
if raw == "*" {
|
||||
return fmt.Errorf("WEB_ORIGIN must not be * (credentials CORS)")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("WEB_ORIGIN is invalid")
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return fmt.Errorf("WEB_ORIGIN must be an absolute http(s) origin")
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("WEB_ORIGIN host is required")
|
||||
}
|
||||
if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
|
||||
return fmt.Errorf("WEB_ORIGIN must be an origin only (no path)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeWebOrigin returns scheme://host (no trailing slash/path) for CORS exact match.
|
||||
func normalizeWebOrigin(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
return strings.ToLower(u.Scheme) + "://" + u.Host
|
||||
}
|
||||
|
||||
// CORSAllowedOrigins returns WEB_ORIGIN plus the localhost↔127.0.0.1 twin when the
|
||||
// configured origin is already loopback. Browsers treat those hostnames as distinct
|
||||
// origins; without the twin, Vite opened via the other hostname fails credentialed CORS.
|
||||
// Production rejects loopback WEB_ORIGIN, so this never widens a real deploy origin.
|
||||
func CORSAllowedOrigins(webOrigin string) []string {
|
||||
origin := normalizeWebOrigin(webOrigin)
|
||||
if origin == "" {
|
||||
return nil
|
||||
}
|
||||
out := []string{origin}
|
||||
if twin := loopbackOriginTwin(origin); twin != "" && twin != origin {
|
||||
out = append(out, twin)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loopbackOriginTwin(origin string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(origin))
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
var twinHost string
|
||||
switch host {
|
||||
case "localhost":
|
||||
twinHost = "127.0.0.1"
|
||||
case "127.0.0.1":
|
||||
twinHost = "localhost"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if port := u.Port(); port != "" {
|
||||
return scheme + "://" + twinHost + ":" + port
|
||||
}
|
||||
return scheme + "://" + twinHost
|
||||
}
|
||||
|
||||
func isLoopbackWebOriginHost(raw string) bool {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1" || strings.HasSuffix(host, ".localhost")
|
||||
}
|
||||
|
||||
// ParseTrustedProxyNets parses TRUSTED_PROXIES entries (CIDR or single IP).
|
||||
func ParseTrustedProxyNets(entries []string) ([]*net.IPNet, error) {
|
||||
out := make([]*net.IPNet, 0, len(entries))
|
||||
for _, raw := range entries {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(raw, "/") {
|
||||
_, n, err := net.ParseCIDR(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TRUSTED_PROXIES invalid CIDR %q", raw)
|
||||
}
|
||||
out = append(out, n)
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(raw)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("TRUSTED_PROXIES invalid IP %q", raw)
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
out = append(out, &net.IPNet{IP: v4, Mask: net.CIDRMask(32, 32)})
|
||||
continue
|
||||
}
|
||||
out = append(out, &net.IPNet{IP: ip, Mask: net.CIDRMask(128, 128)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseCSVList(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validatePublicHTTPBaseURL(raw, name string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("%s is required when enabled", name)
|
||||
}
|
||||
// Lazy import avoided — parse manually for scheme/host only.
|
||||
lower := strings.ToLower(raw)
|
||||
if !strings.HasPrefix(lower, "https://") && !strings.HasPrefix(lower, "http://") {
|
||||
return fmt.Errorf("%s must be http(s)", name)
|
||||
}
|
||||
without := raw
|
||||
if i := strings.Index(without, "://"); i >= 0 {
|
||||
without = without[i+3:]
|
||||
}
|
||||
hostport := without
|
||||
if i := strings.IndexAny(hostport, "/?#"); i >= 0 {
|
||||
hostport = hostport[:i]
|
||||
}
|
||||
host := hostport
|
||||
if i := strings.LastIndex(hostport, ":"); i >= 0 {
|
||||
// strip port; handle IPv6 [::1]:port lightly by rejecting brackets for now
|
||||
if !strings.HasPrefix(hostport, "[") {
|
||||
host = hostport[:i]
|
||||
}
|
||||
}
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") {
|
||||
return fmt.Errorf("%s host is not allowed (SSRF)", name)
|
||||
}
|
||||
// Literal private IPs only (hostname DNS rebinding is operator-controlled for this official API URL).
|
||||
if isLiteralPrivateHost(host) {
|
||||
return fmt.Errorf("%s must not point at a private IP", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLiteralPrivateHost(host string) bool {
|
||||
// Minimal check without importing net into every path — cover common private literals.
|
||||
if host == "127.0.0.1" || host == "0.0.0.0" || host == "::1" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(host, "10.") || strings.HasPrefix(host, "192.168.") || strings.HasPrefix(host, "169.254.") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(host, "172.") {
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) >= 2 {
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &n); err == nil && n >= 16 && n <= 31 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loadStripePriceIDs() map[string]string {
|
||||
out := map[string]string{}
|
||||
pairs := []struct {
|
||||
key string
|
||||
env string
|
||||
}{
|
||||
{"starter:monthly", "STRIPE_PRICE_STARTER_MONTHLY"},
|
||||
{"starter:yearly", "STRIPE_PRICE_STARTER_YEARLY"},
|
||||
{"plus:monthly", "STRIPE_PRICE_PLUS_MONTHLY"},
|
||||
{"plus:yearly", "STRIPE_PRICE_PLUS_YEARLY"},
|
||||
{"growth:monthly", "STRIPE_PRICE_GROWTH_MONTHLY"},
|
||||
{"growth:yearly", "STRIPE_PRICE_GROWTH_YEARLY"},
|
||||
{"business:monthly", "STRIPE_PRICE_BUSINESS_MONTHLY"},
|
||||
{"business:yearly", "STRIPE_PRICE_BUSINESS_YEARLY"},
|
||||
{"scale:monthly", "STRIPE_PRICE_SCALE_MONTHLY"},
|
||||
{"scale:yearly", "STRIPE_PRICE_SCALE_YEARLY"},
|
||||
// Credit packs — keep IDs aligned with billing.DefaultCreditPacks.
|
||||
{"pack:tiny", "STRIPE_PRICE_PACK_TINY"},
|
||||
{"pack:small", "STRIPE_PRICE_PACK_SMALL"},
|
||||
{"pack:medium", "STRIPE_PRICE_PACK_MEDIUM"},
|
||||
{"pack:large", "STRIPE_PRICE_PACK_LARGE"},
|
||||
{"pack:xl", "STRIPE_PRICE_PACK_XL"},
|
||||
{"pack:xxl", "STRIPE_PRICE_PACK_XXL"},
|
||||
{"pack:mega", "STRIPE_PRICE_PACK_MEGA"},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if v := strings.TrimSpace(os.Getenv(p.env)); v != "" {
|
||||
out[p.key] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// firstEnv returns the first non-empty process env among keys (no fallback default).
|
||||
func firstEnv(keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getenvBool(key string, fallback bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func getenvInt(key string, fallback int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// getenvDuration accepts Go durations ("10s", "500ms") or integer seconds ("10").
|
||||
func getenvDuration(key string, fallback time.Duration) time.Duration {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// getenvDurationAllowZero is like getenvDuration but accepts 0 (e.g. disable statement_timeout).
|
||||
func getenvDurationAllowZero(key string, fallback time.Duration) time.Duration {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
|
||||
return d
|
||||
}
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// loadDotEnv loads the monorepo-root .env into the process environment.
|
||||
// Existing variables (including empty ones set by tests) are never overridden.
|
||||
// Missing file is a no-op — production typically injects env without a file.
|
||||
func loadDotEnv() {
|
||||
if path := strings.TrimSpace(os.Getenv("DOTENV_PATH")); path != "" {
|
||||
_ = applyEnvFile(path)
|
||||
return
|
||||
}
|
||||
if root, ok := findMonorepoRoot(); ok {
|
||||
_ = applyEnvFile(filepath.Join(root, ".env"))
|
||||
}
|
||||
}
|
||||
|
||||
func findMonorepoRoot() (string, bool) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
dir := cwd
|
||||
for {
|
||||
if isMonorepoRoot(dir) {
|
||||
return dir, true
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", false
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func isMonorepoRoot(dir string) bool {
|
||||
api := filepath.Join(dir, "apps", "api")
|
||||
web := filepath.Join(dir, "apps", "web")
|
||||
if st, err := os.Stat(api); err != nil || !st.IsDir() {
|
||||
return false
|
||||
}
|
||||
if st, err := os.Stat(web); err != nil || !st.IsDir() {
|
||||
return false
|
||||
}
|
||||
// Prefer package.json workspaces marker when present.
|
||||
if _, err := os.Stat(filepath.Join(dir, "package.json")); err == nil {
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func applyEnvFile(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
// Allow long values (keys, DSNs).
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "export ") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := os.LookupEnv(key); exists {
|
||||
continue
|
||||
}
|
||||
val = strings.TrimSpace(val)
|
||||
if len(val) >= 2 {
|
||||
if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') {
|
||||
val = val[1 : len(val)-1]
|
||||
}
|
||||
}
|
||||
_ = os.Setenv(key, val)
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyEnvFileDoesNotOverrideExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(path, []byte("DOTENV_TEST_KEY=fromfile\nDOTENV_ONLY_FILE=only\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DOTENV_TEST_KEY", "fromprocess")
|
||||
t.Setenv("DOTENV_ONLY_FILE", "")
|
||||
// Empty existing must still block override (matches tests that clear keys).
|
||||
_ = os.Unsetenv("DOTENV_ONLY_FILE")
|
||||
|
||||
if err := applyEnvFile(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := os.Getenv("DOTENV_TEST_KEY"); got != "fromprocess" {
|
||||
t.Fatalf("override: got %q", got)
|
||||
}
|
||||
if got := os.Getenv("DOTENV_ONLY_FILE"); got != "only" {
|
||||
t.Fatalf("missing fill: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMonorepoRootFromAPIDir(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// This test file lives under apps/api/internal/config — walk should find repo root.
|
||||
root, ok := findMonorepoRoot()
|
||||
if !ok {
|
||||
t.Fatalf("findMonorepoRoot from cwd %s", cwd)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "apps", "api")); err != nil {
|
||||
t.Fatalf("root %s missing apps/api: %v", root, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "apps", "web")); err != nil {
|
||||
t.Fatalf("root %s missing apps/web: %v", root, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDotEnvPathOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "custom.env")
|
||||
if err := os.WriteFile(path, []byte("DOTENV_PATH_ONLY=yes\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DOTENV_PATH", path)
|
||||
_ = os.Unsetenv("DOTENV_PATH_ONLY")
|
||||
loadDotEnv()
|
||||
if got := os.Getenv("DOTENV_PATH_ONLY"); got != "yes" {
|
||||
t.Fatalf("DOTENV_PATH load: got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user