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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+638
View File
@@ -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
}
+466
View File
@@ -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")
}
}
+95
View File
@@ -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()
}
+61
View File
@@ -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)
}
}