Load .env.<APP_ENV>, validate config at boot, keep secrets out of the image
`main.go` only ever loaded `.env`; the `APP_ENV` switch described in `.env.local` / `.env.production` did not exist, and a missing variable surfaced one restart at a time as a log.Fatalf inside db.Connect. config.Load now picks `.env.<APP_ENV>` (default local) then `.env`, with real environment winning, reads every setting into one typed Config and reports everything missing in one message. Production insists on a POS signing secret; local warns when DB_HOST is not a local address. db, redis and the image store take the Config instead of reading env themselves. Also: - livehub read MQTT_USERNAME while everything else uses MQTT_USER, so the console stream connected to the broker unauthenticated. Both accepted. - .dockerignore: `COPY . .` was baking .env.production into the image. Dockerfile sets APP_ENV=production. - Drop utils/config.go (dead viper loader) and create_table.go (unused, hardcoded production DSN); go mod tidy removes viper. - .env.example lists every variable the code reads; docs/ENVIRONMENT.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
360
config/config.go
360
config/config.go
@@ -1,49 +1,361 @@
|
||||
// Package config is the one place the process reads its environment.
|
||||
//
|
||||
// Two jobs, in order:
|
||||
//
|
||||
// 1. Pick the right `.env` file for the environment we are in and load it.
|
||||
// 2. Read every setting into a typed Config and refuse to start if anything
|
||||
// required is missing — all of it, in one message, before a single
|
||||
// connection is attempted.
|
||||
//
|
||||
// Before this, `main.go` loaded `.env` and nothing else. `.env.local` and
|
||||
// `.env.production` described an `APP_ENV` switch that did not exist, so the
|
||||
// only way to run against production was to overwrite `.env` by hand, and the
|
||||
// only way to find out a variable was missing was a `log.Fatalf` from inside
|
||||
// `db.Connect()` — one variable per restart.
|
||||
//
|
||||
// # Which file loads
|
||||
//
|
||||
// `APP_ENV` names the environment and defaults to "local":
|
||||
//
|
||||
// go run . → .env.local, then .env
|
||||
// APP_ENV=production go run . → .env.production, then .env
|
||||
//
|
||||
// `.env` is a shared base loaded after the environment file. godotenv never
|
||||
// overwrites a variable that is already set, so the order of precedence is:
|
||||
//
|
||||
// real environment > .env.<APP_ENV> > .env
|
||||
//
|
||||
// Neither file has to exist. On the deployed host every value comes from the
|
||||
// platform's environment settings (Dokploy today, ConfigMaps/Secrets under
|
||||
// Kubernetes) and there is no file at all — which is exactly why the
|
||||
// Dockerfile's `ENV APP_ENV=production` and the .dockerignore matter: the
|
||||
// image carries no `.env.*`, so it cannot fall back to localhost values that
|
||||
// happen to be lying around in the build context.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Environment names. Anything else is accepted (a staging file works the same
|
||||
// way) but only these two change behaviour.
|
||||
const (
|
||||
EnvLocal = "local"
|
||||
EnvProduction = "production"
|
||||
)
|
||||
|
||||
// Config is everything the process reads from its environment.
|
||||
//
|
||||
// A few settings are still read directly with os.Getenv at the point of use,
|
||||
// because they are consulted per request or per connection rather than once
|
||||
// at boot: POS_TOKEN_SECRET (utils/postoken.go), POS_AUTH_REQUIRED
|
||||
// (middleware/posauth.go), GEOCODER_API_KEY (utils/geocode.go), and the MQTT_*
|
||||
// and POS_* settings in package messaging. They are listed and validated here
|
||||
// so that a misconfiguration is still caught at startup.
|
||||
type Config struct {
|
||||
Env string
|
||||
Port string
|
||||
DBName string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBPort string
|
||||
DBHost string
|
||||
UserContextKey string
|
||||
// AppEnv is the value of APP_ENV: "local" or "production".
|
||||
AppEnv string
|
||||
// Port the API listens on. APP_PORT, default 1122 (production sets 1009).
|
||||
Port string
|
||||
|
||||
DB DBConfig
|
||||
Catalogue DBConfig // Host empty → catalogue endpoints disabled.
|
||||
Redis RedisConfig
|
||||
S3 S3Config
|
||||
MQTT MQTTConfig
|
||||
Embedding EmbeddingConfig
|
||||
|
||||
// POSTokenSecret signs terminal sessions. Falls back to JWTSecret when
|
||||
// unset, matching utils/postoken.go.
|
||||
POSTokenSecret string
|
||||
JWTSecret string
|
||||
UserContextKey string
|
||||
GeocoderAPIKey string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
// DBConfig is one Postgres connection.
|
||||
type DBConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Name string
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
|
||||
// Enabled reports whether a host was configured at all. Only meaningful for
|
||||
// the optional catalogue connection; the main database is required.
|
||||
func (d DBConfig) Enabled() bool { return d.Host != "" }
|
||||
|
||||
// RedisConfig is the shared Redis used for POS terminal presence. Optional:
|
||||
// Host empty means presence is disabled and bills still commit.
|
||||
type RedisConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
|
||||
func (r RedisConfig) Enabled() bool { return r.Host != "" }
|
||||
|
||||
// S3Config is the DigitalOcean Spaces bucket holding catalogue product images.
|
||||
type S3Config struct {
|
||||
Enabled bool // USE_S3=true
|
||||
Endpoint string
|
||||
Bucket string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Region string
|
||||
}
|
||||
|
||||
// MQTTConfig is the broker the in-store tills publish to. Optional: URL empty
|
||||
// means the MQTT ingest and the console live stream stay quiet.
|
||||
type MQTTConfig struct {
|
||||
URL string
|
||||
User string
|
||||
Password string
|
||||
ClientID string
|
||||
}
|
||||
|
||||
func (m MQTTConfig) Enabled() bool { return m.URL != "" }
|
||||
|
||||
// EmbeddingConfig is the text-embedding model behind the scan-to-product
|
||||
// search (services/scanService.go). It MUST be the model that filled the
|
||||
// catalogue's `embedding` column — vectors from two different models are not
|
||||
// comparable, and pgvector will happily rank garbage. Optional: with no
|
||||
// provider the search falls back to plain text matching.
|
||||
type EmbeddingConfig struct {
|
||||
Provider string // "openai" (any OpenAI-compatible endpoint) or "gemini"
|
||||
Model string
|
||||
APIKey string
|
||||
BaseURL string // OpenAI-compatible only; default https://api.openai.com/v1
|
||||
Dimensions int // 0 = the model's default
|
||||
}
|
||||
|
||||
func (e EmbeddingConfig) Enabled() bool { return e.Provider != "" }
|
||||
|
||||
// IsProduction is true under APP_ENV=production.
|
||||
func (c *Config) IsProduction() bool { return c.AppEnv == EnvProduction }
|
||||
|
||||
// Load picks and loads the environment files, reads every setting and
|
||||
// validates them. The returned error lists every problem at once.
|
||||
func Load() (*Config, error) {
|
||||
loadEnvFiles()
|
||||
|
||||
cfg := &Config{
|
||||
Env: getEnv("ENV", "production"),
|
||||
Port: getEnv("APP_PORT", "1009"),
|
||||
AppEnv: env("APP_ENV", EnvLocal),
|
||||
Port: env("APP_PORT", "1122"),
|
||||
|
||||
// ✅ STANDARDIZED DB ENV KEYS
|
||||
DBName: getEnv("DB_NAME", ""),
|
||||
DBUser: getEnv("DB_USER", ""),
|
||||
DBPassword: getEnv("DB_PASSWORD", ""),
|
||||
DBPort: getEnv("DB_PORT", "5432"),
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DB: DBConfig{
|
||||
Host: env("DB_HOST", ""),
|
||||
Port: env("DB_PORT", "5433"),
|
||||
Name: env("DB_NAME", ""),
|
||||
User: env("DB_USER", ""),
|
||||
Password: env("DB_PASSWORD", ""),
|
||||
},
|
||||
Catalogue: DBConfig{
|
||||
Host: env("CATALOGUE_DB_HOST", ""),
|
||||
Port: env("CATALOGUE_DB_PORT", "5432"),
|
||||
Name: env("CATALOGUE_DB_NAME", ""),
|
||||
User: env("CATALOGUE_DB_USER", ""),
|
||||
Password: env("CATALOGUE_DB_PASSWORD", ""),
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
Host: env("REDIS_HOST", ""),
|
||||
Port: env("REDIS_PORT", "6379"),
|
||||
User: env("REDIS_USER", "default"),
|
||||
Password: env("REDIS_PASSWORD", ""),
|
||||
},
|
||||
S3: S3Config{
|
||||
Enabled: strings.EqualFold(env("USE_S3", ""), "true"),
|
||||
Endpoint: env("S3_ENDPOINT", ""),
|
||||
Bucket: env("S3_BUCKET", ""),
|
||||
AccessKey: env("S3_ACCESS_KEY", ""),
|
||||
SecretKey: env("S3_SECRET_KEY", ""),
|
||||
Region: env("S3_REGION", ""),
|
||||
},
|
||||
MQTT: MQTTConfig{
|
||||
URL: env("MQTT_URL", ""),
|
||||
// MQTT_USERNAME is accepted because livehub.go read that name for
|
||||
// a while, so an existing deployment may still set it.
|
||||
User: env("MQTT_USER", env("MQTT_USERNAME", "")),
|
||||
Password: env("MQTT_PASSWORD", ""),
|
||||
ClientID: env("MQTT_CLIENT_ID", ""),
|
||||
},
|
||||
|
||||
UserContextKey: getEnv("USER_CONTEXT_KEY", "nearle"),
|
||||
JWTSecret: getEnv("JWT_SECRET_KEY", ""),
|
||||
Embedding: EmbeddingConfig{
|
||||
Provider: strings.ToLower(env("EMBEDDING_PROVIDER", "")),
|
||||
Model: env("EMBEDDING_MODEL", ""),
|
||||
APIKey: env("EMBEDDING_API_KEY", ""),
|
||||
BaseURL: env("EMBEDDING_BASE_URL", ""),
|
||||
},
|
||||
|
||||
POSTokenSecret: env("POS_TOKEN_SECRET", ""),
|
||||
JWTSecret: env("JWT_SECRET_KEY", ""),
|
||||
UserContextKey: env("USER_CONTEXT_KEY", "nearle"),
|
||||
GeocoderAPIKey: env("GEOCODER_API_KEY", ""),
|
||||
}
|
||||
|
||||
// ✅ Correct validation
|
||||
if cfg.DBPassword == "" {
|
||||
log.Println("Warning: DB_PASSWORD is not set")
|
||||
if db, err := strconv.Atoi(env("REDIS_DB", "0")); err == nil {
|
||||
cfg.Redis.DB = db
|
||||
} else {
|
||||
return nil, fmt.Errorf("REDIS_DB must be a number, got %q", env("REDIS_DB", ""))
|
||||
}
|
||||
|
||||
if dims := env("EMBEDDING_DIMENSIONS", "0"); dims != "0" {
|
||||
n, err := strconv.Atoi(dims)
|
||||
if err != nil || n < 0 {
|
||||
return nil, fmt.Errorf("EMBEDDING_DIMENSIONS must be a number, got %q", dims)
|
||||
}
|
||||
cfg.Embedding.Dimensions = n
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// MustLoad is Load for main(): every problem is printed and the process exits.
|
||||
func MustLoad() *Config {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
log.Fatalf("❌ configuration is not usable:\n%v\n\nSee .env.example for every setting.", err)
|
||||
}
|
||||
log.Printf("config: APP_ENV=%s, listening on :%s, database %s@%s:%s/%s",
|
||||
cfg.AppEnv, cfg.Port, cfg.DB.User, cfg.DB.Host, cfg.DB.Port, cfg.DB.Name)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
// validate collects every problem rather than stopping at the first, so one
|
||||
// restart is enough to learn everything that is wrong.
|
||||
func (c *Config) validate() error {
|
||||
var problems []string
|
||||
missing := func(key string) { problems = append(problems, " - "+key+" is required") }
|
||||
|
||||
if c.DB.Host == "" {
|
||||
missing("DB_HOST")
|
||||
}
|
||||
if c.DB.User == "" {
|
||||
missing("DB_USER")
|
||||
}
|
||||
if c.DB.Password == "" {
|
||||
missing("DB_PASSWORD")
|
||||
}
|
||||
if c.DB.Name == "" {
|
||||
missing("DB_NAME")
|
||||
}
|
||||
|
||||
// Optional subsystems are either fully configured or absent. Half a
|
||||
// configuration used to be skipped with a warning, which reads as "fine"
|
||||
// in a log and turns into "why are there no images" a week later.
|
||||
if c.Catalogue.Enabled() {
|
||||
if c.Catalogue.User == "" {
|
||||
missing("CATALOGUE_DB_USER (CATALOGUE_DB_HOST is set)")
|
||||
}
|
||||
if c.Catalogue.Password == "" {
|
||||
missing("CATALOGUE_DB_PASSWORD (CATALOGUE_DB_HOST is set)")
|
||||
}
|
||||
if c.Catalogue.Name == "" {
|
||||
missing("CATALOGUE_DB_NAME (CATALOGUE_DB_HOST is set)")
|
||||
}
|
||||
}
|
||||
if c.S3.Enabled {
|
||||
if c.S3.Endpoint == "" {
|
||||
missing("S3_ENDPOINT (USE_S3=true)")
|
||||
}
|
||||
if c.S3.Bucket == "" {
|
||||
missing("S3_BUCKET (USE_S3=true)")
|
||||
}
|
||||
if c.S3.AccessKey == "" {
|
||||
missing("S3_ACCESS_KEY (USE_S3=true)")
|
||||
}
|
||||
if c.S3.SecretKey == "" {
|
||||
missing("S3_SECRET_KEY (USE_S3=true)")
|
||||
}
|
||||
if c.S3.Region == "" {
|
||||
missing("S3_REGION (USE_S3=true)")
|
||||
}
|
||||
}
|
||||
|
||||
if c.Embedding.Enabled() {
|
||||
switch c.Embedding.Provider {
|
||||
case "openai", "gemini":
|
||||
default:
|
||||
problems = append(problems, " - EMBEDDING_PROVIDER must be openai or gemini, got "+c.Embedding.Provider)
|
||||
}
|
||||
if c.Embedding.Model == "" {
|
||||
missing("EMBEDDING_MODEL (EMBEDDING_PROVIDER is set)")
|
||||
}
|
||||
if c.Embedding.APIKey == "" {
|
||||
missing("EMBEDDING_API_KEY (EMBEDDING_PROVIDER is set)")
|
||||
}
|
||||
}
|
||||
|
||||
if c.IsProduction() {
|
||||
// utils/postoken.go refuses to sign with a short secret at request
|
||||
// time; catching it here means the first till login is not the first
|
||||
// anyone hears of it.
|
||||
secret := c.POSTokenSecret
|
||||
if secret == "" {
|
||||
secret = c.JWTSecret
|
||||
}
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
missing("POS_TOKEN_SECRET (production; JWT_SECRET_KEY is accepted as a fallback)")
|
||||
} else if len(strings.TrimSpace(secret)) < 16 {
|
||||
problems = append(problems, " - POS_TOKEN_SECRET must be at least 16 characters")
|
||||
}
|
||||
} else if c.DB.Host != "" && !isLocalHost(c.DB.Host) {
|
||||
// Not fatal: a dump restored on another machine on the LAN is a valid
|
||||
// local setup. But `.env.local` pointing at the live host is the
|
||||
// mistake every comment in that file warns about, so say it out loud.
|
||||
log.Printf("⚠️ APP_ENV=%s but DB_HOST=%s is not a local address — every write goes to that database for real",
|
||||
c.AppEnv, c.DB.Host)
|
||||
}
|
||||
|
||||
if len(problems) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errors.New(strings.Join(problems, "\n"))
|
||||
}
|
||||
|
||||
// loadEnvFiles loads `.env.<APP_ENV>` and then `.env`, each only if present.
|
||||
//
|
||||
// APP_ENV is read from the real environment before any file, so a file cannot
|
||||
// change which environment it is loaded for.
|
||||
func loadEnvFiles() {
|
||||
appEnv := env("APP_ENV", EnvLocal)
|
||||
|
||||
for _, name := range []string{".env." + appEnv, ".env"} {
|
||||
if _, err := os.Stat(name); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := godotenv.Load(name); err != nil {
|
||||
log.Printf("config: could not read %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("config: loaded %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func isLocalHost(host string) bool {
|
||||
switch strings.ToLower(host) {
|
||||
case "localhost", "127.0.0.1", "::1", "host.docker.internal":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(host, "127.")
|
||||
}
|
||||
|
||||
251
config/config_test.go
Normal file
251
config/config_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every key Load reads, so a test starts from nothing rather than from
|
||||
// whatever the developer's shell happens to export.
|
||||
var allKeys = []string{
|
||||
"APP_ENV", "APP_PORT",
|
||||
"DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASSWORD",
|
||||
"CATALOGUE_DB_HOST", "CATALOGUE_DB_PORT", "CATALOGUE_DB_NAME", "CATALOGUE_DB_USER", "CATALOGUE_DB_PASSWORD",
|
||||
"REDIS_HOST", "REDIS_PORT", "REDIS_USER", "REDIS_PASSWORD", "REDIS_DB",
|
||||
"USE_S3", "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION",
|
||||
"MQTT_URL", "MQTT_USER", "MQTT_USERNAME", "MQTT_PASSWORD", "MQTT_CLIENT_ID",
|
||||
"POS_TOKEN_SECRET", "JWT_SECRET_KEY", "USER_CONTEXT_KEY", "GEOCODER_API_KEY",
|
||||
"EMBEDDING_PROVIDER", "EMBEDDING_MODEL", "EMBEDDING_API_KEY", "EMBEDDING_BASE_URL", "EMBEDDING_DIMENSIONS",
|
||||
}
|
||||
|
||||
// cleanEnv clears every setting and moves into an empty directory so no
|
||||
// `.env` file is picked up by accident. t.Setenv registers the restore; the
|
||||
// Unsetenv after it matters because godotenv treats a variable that is present
|
||||
// but empty as set and will not fill it from a file.
|
||||
func cleanEnv(t *testing.T) string {
|
||||
t.Helper()
|
||||
unsetAll(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func unsetAll(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range allKeys {
|
||||
t.Setenv(k, "")
|
||||
os.Unsetenv(k)
|
||||
}
|
||||
}
|
||||
|
||||
func setMainDB(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("DB_HOST", "localhost")
|
||||
t.Setenv("DB_USER", "nearle")
|
||||
t.Setenv("DB_PASSWORD", "localdev")
|
||||
t.Setenv("DB_NAME", "nearledb")
|
||||
}
|
||||
|
||||
func write(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReportsEveryMissingRequiredKeyAtOnce(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error with no database configured")
|
||||
}
|
||||
for _, key := range []string{"DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME"} {
|
||||
if !strings.Contains(err.Error(), key) {
|
||||
t.Errorf("error should name %s, got:\n%s", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.AppEnv != EnvLocal {
|
||||
t.Errorf("AppEnv = %q, want local", cfg.AppEnv)
|
||||
}
|
||||
if cfg.IsProduction() {
|
||||
t.Error("IsProduction should be false by default")
|
||||
}
|
||||
if cfg.Port != "1122" {
|
||||
t.Errorf("Port = %q, want 1122", cfg.Port)
|
||||
}
|
||||
if cfg.DB.Port != "5433" {
|
||||
t.Errorf("DB.Port = %q, want 5433 (the port production and the local compose share)", cfg.DB.Port)
|
||||
}
|
||||
if cfg.Catalogue.Enabled() || cfg.Redis.Enabled() || cfg.S3.Enabled || cfg.MQTT.Enabled() {
|
||||
t.Error("optional subsystems should be off when unset")
|
||||
}
|
||||
if cfg.Redis.User != "default" || cfg.Redis.Port != "6379" || cfg.Redis.DB != 0 {
|
||||
t.Errorf("redis defaults wrong: %+v", cfg.Redis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppEnvSelectsTheEnvFile(t *testing.T) {
|
||||
dir := cleanEnv(t)
|
||||
write(t, dir, ".env.local", "DB_HOST=localhost\nDB_USER=local\nDB_PASSWORD=x\nDB_NAME=nearledb\nAPP_PORT=1122\n")
|
||||
write(t, dir, ".env.production", "DB_HOST=db.internal\nDB_USER=prod\nDB_PASSWORD=x\nDB_NAME=nearledb\nAPP_PORT=1009\nPOS_TOKEN_SECRET=0123456789abcdef\n")
|
||||
// The shared base: only fills in what the environment file left unset.
|
||||
write(t, dir, ".env", "DB_USER=base\nUSER_CONTEXT_KEY=from-base\n")
|
||||
|
||||
t.Run("default is local", func(t *testing.T) {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DB.User != "local" || cfg.Port != "1122" {
|
||||
t.Errorf("expected .env.local values, got user=%s port=%s", cfg.DB.User, cfg.Port)
|
||||
}
|
||||
if cfg.UserContextKey != "from-base" {
|
||||
t.Errorf(".env should fill in what .env.local left unset, got %q", cfg.UserContextKey)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("APP_ENV=production", func(t *testing.T) {
|
||||
// Clears what godotenv loaded in the sibling above; restored on return.
|
||||
unsetAll(t)
|
||||
t.Setenv("APP_ENV", EnvProduction)
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.IsProduction() || cfg.DB.User != "prod" || cfg.Port != "1009" {
|
||||
t.Errorf("expected .env.production values, got env=%s user=%s port=%s", cfg.AppEnv, cfg.DB.User, cfg.Port)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRealEnvironmentBeatsTheFile(t *testing.T) {
|
||||
dir := cleanEnv(t)
|
||||
write(t, dir, ".env.local", "DB_HOST=localhost\nDB_USER=file\nDB_PASSWORD=x\nDB_NAME=nearledb\n")
|
||||
t.Setenv("DB_USER", "shell")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DB.User != "shell" {
|
||||
t.Errorf("a variable already in the environment must not be overwritten by the file, got %q", cfg.DB.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionRequiresASigningSecret(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
t.Setenv("APP_ENV", EnvProduction)
|
||||
|
||||
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "POS_TOKEN_SECRET") {
|
||||
t.Fatalf("production without a secret should fail naming POS_TOKEN_SECRET, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("POS_TOKEN_SECRET", "short")
|
||||
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "16 characters") {
|
||||
t.Fatalf("a short secret should be refused, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("POS_TOKEN_SECRET", "")
|
||||
t.Setenv("JWT_SECRET_KEY", "a-long-enough-fallback-secret")
|
||||
if _, err := Load(); err != nil {
|
||||
t.Fatalf("JWT_SECRET_KEY should be accepted as the fallback, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHalfConfiguredSubsystemsAreRefused(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
|
||||
t.Setenv("CATALOGUE_DB_HOST", "localhost")
|
||||
t.Setenv("USE_S3", "true")
|
||||
t.Setenv("S3_BUCKET", "nearle")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
for _, want := range []string{"CATALOGUE_DB_USER", "CATALOGUE_DB_PASSWORD", "CATALOGUE_DB_NAME", "S3_ENDPOINT", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error should name %s, got:\n%s", want, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "S3_BUCKET") {
|
||||
t.Error("S3_BUCKET was set and must not be reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTUsernameFallback(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
t.Setenv("MQTT_URL", "tcp://broker:1883")
|
||||
t.Setenv("MQTT_USERNAME", "legacy")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.MQTT.User != "legacy" {
|
||||
t.Errorf("MQTT_USERNAME should still be honoured, got %q", cfg.MQTT.User)
|
||||
}
|
||||
|
||||
t.Setenv("MQTT_USER", "current")
|
||||
cfg, err = Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.MQTT.User != "current" {
|
||||
t.Errorf("MQTT_USER should win over MQTT_USERNAME, got %q", cfg.MQTT.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisDBMustBeNumeric(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
t.Setenv("REDIS_DB", "zero")
|
||||
|
||||
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "REDIS_DB") {
|
||||
t.Fatalf("expected REDIS_DB error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddingProviderNeedsModelAndKey(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
t.Setenv("EMBEDDING_PROVIDER", "openai")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil || !strings.Contains(err.Error(), "EMBEDDING_MODEL") || !strings.Contains(err.Error(), "EMBEDDING_API_KEY") {
|
||||
t.Fatalf("a provider without model and key should be refused naming both, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("EMBEDDING_PROVIDER", "cohere")
|
||||
t.Setenv("EMBEDDING_MODEL", "x")
|
||||
t.Setenv("EMBEDDING_API_KEY", "y")
|
||||
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "EMBEDDING_PROVIDER") {
|
||||
t.Fatalf("an unknown provider should be refused, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("EMBEDDING_PROVIDER", "Gemini")
|
||||
t.Setenv("EMBEDDING_DIMENSIONS", "768")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Embedding.Provider != "gemini" || cfg.Embedding.Dimensions != 768 || !cfg.Embedding.Enabled() {
|
||||
t.Fatalf("unexpected embedding config: %+v", cfg.Embedding)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user