`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>
252 lines
7.4 KiB
Go
252 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|