package config import ( "os" "path/filepath" "strings" "testing" "time" ) func cleanEnv(t *testing.T) { t.Helper() for _, v := range os.Environ() { key, _, _ := strings.Cut(v, "=") if strings.HasPrefix(key, Prefix) { t.Setenv(key, "") os.Unsetenv(key) } } } func configFile(t *testing.T, text string) string { t.Helper() p := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(p, []byte(text), 0600); err != nil { t.Fatal(err) } return p } func TestPrecedence(t *testing.T) { cleanEnv(t) path := configFile(t, "[topics]\ndevice_id='file-device'\n[logging]\nlevel='debug'\n[mqtt]\nclean_start=false\n") c, err := Load(path, nil) if err != nil { t.Fatal(err) } if c.Topics.DeviceID != "file-device" || c.Logging.Level != "debug" || c.MQTT.CleanStart { t.Fatalf("TOML/default precedence failed") } t.Setenv(Prefix+"TOPICS__DEVICE_ID", "env-device") t.Setenv(Prefix+"MQTT__SESSION_EXPIRY", "10m") t.Setenv(Prefix+"MQTT__QOS", "0") t.Setenv(Prefix+"MQTT__CLEAN_START", "true") c, err = Load(path, nil) if err != nil { t.Fatal(err) } if c.Topics.DeviceID != "env-device" || c.MQTT.SessionExpiry != 10*time.Minute || c.MQTT.QoS != 0 || !c.MQTT.CleanStart { t.Fatal("environment conversion failed") } c, err = Load(path, map[string]string{"topics.device_id": "cli-device"}) if err != nil { t.Fatal(err) } if c.Topics.DeviceID != "cli-device" || c.Logging.Level != "debug" { t.Fatal("explicit overrides failed") } } func TestValidationAndSafeErrors(t *testing.T) { cleanEnv(t) cases := []struct{ name, body, key string }{ {"unknown", "[mqtt]\npasswrod='SUPER_SECRET'", "mqtt.passwrod"}, {"empty table", "[unexpected]", "unexpected"}, {"numeric duration", "[mqtt]\nkeep_alive=30", "mqtt.keep_alive"}, {"unitless duration", "[mqtt]\nkeep_alive='0'", "mqtt.keep_alive"}, {"overflow duration", "[mqtt]\nsession_expiry='999999999999999999999h'", "mqtt.session_expiry"}, {"fraction session", "[mqtt]\nsession_expiry='1.5s'", "mqtt.session_expiry"}, {"session max", "[mqtt]\nsession_expiry='4294967296s'", "mqtt.session_expiry"}, {"keepalive max", "[mqtt]\nkeep_alive='65536s'", "mqtt.keep_alive"}, {"negative", "[deadlines]\nconnect='-1s'", "deadlines.connect"}, {"qos", "[mqtt]\nqos=2", "mqtt.qos"}, {"fraction integer", "[mqtt]\nqos=0.5", "mqtt.qos"}, {"type mismatch", "[mqtt]\npassword=123", "mqtt.password"}, {"plain MQTT", "[mqtt]\nurl='tcp://localhost:1883'", "mqtt.url"}, {"userinfo", "[mqtt]\nurl='tls://user:SUPER_SECRET@localhost:8883'", "mqtt.url"}, {"port", "[mqtt]\nurl='tls://localhost:0'", "mqtt.url"}, {"wildcard", "[topics]\ndevice_id='sim/+'", "topics.device_id"}, {"production topic", "[topics]\nnamespace='device'", "topics.namespace"}, {"cert pair", "[tls]\nclient_key='SUPER_SECRET'", "tls.client_cert"}, {"secret source conflict", "[mqtt]\npassword='SUPER_SECRET'\npassword_file='secrets/password'", "mqtt.password_file"}, {"missing CA", "[tls]\nca_file='/missing/SUPER_SECRET'", "tls.ca_file"}, {"queue", "[limits]\ninbound_queue=0", "limits.inbound_queue"}, {"preview", "[limits]\npreview_bytes=20000", "limits.preview_bytes"}, {"retention", "[limits]\ndedup_retention='10s'", "limits.dedup_retention"}, {"command cap", "[deadlines]\ncommand='61s'", "deadlines.command"}, {"backoff", "[mqtt]\nreconnect_min='31s'", "mqtt.reconnect_max"}, {"public web", "[web]\naddress='0.0.0.0:8080'", "web.address"}, {"log level", "[logging]\nlevel='SUPER_SECRET'", "logging.level"}, {"syntax", "[mqtt]\npassword='SUPER_SECRET", "invalid TOML"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := Load(configFile(t, tc.body), nil) if err == nil || !strings.Contains(err.Error(), tc.key) { t.Fatalf("expected %s error, got %v", tc.key, err) } if strings.Contains(err.Error(), "SUPER_SECRET") { t.Fatal("secret leaked") } }) } } func TestEmptyEnvAndUnknown(t *testing.T) { cleanEnv(t) path := configFile(t, "[mqtt]\npassword='file-secret'\n") t.Setenv(Prefix+"MQTT__PASSWORD", "") c, err := Load(path, nil) if err != nil { t.Fatal(err) } if c.MQTT.Password != "" { t.Fatal("empty env must clear password") } t.Setenv(Prefix+"MQTT__CLEAN_START", "1") if _, err = Load(path, nil); err == nil { t.Fatal("numeric boolean accepted") } os.Unsetenv(Prefix + "MQTT__CLEAN_START") t.Setenv(Prefix+"UNKNOWN", "secret") if _, err = Load(path, nil); err == nil { t.Fatal("unknown env accepted") } } func TestFilesAndBounds(t *testing.T) { cleanEnv(t) if _, err := Load("/missing/config", nil); err == nil { t.Fatal("missing config accepted") } c := Defaults() c.MQTT.SessionExpiry = 4294967295 * time.Second c.MQTT.KeepAlive = 65535 * time.Second if err := c.Validate(); err != nil { t.Fatal(err) } secret := configFile(t, "test-password") c.MQTT.PasswordFile = secret if err := c.Validate(); err != nil { t.Fatal(err) } c.MQTT.PasswordFile = t.TempDir() if err := c.Validate(); err == nil { t.Fatal("directory accepted as secret") } } func TestPlaintextRequiresOptIn(t *testing.T) { cfg := Defaults() cfg.MQTT.URL = "mqtt://localhost:1884" if cfg.Validate() == nil { t.Fatal("plaintext accepted without opt-in") } cfg.MQTT.AllowPlaintext = true if err := cfg.Validate(); err != nil { t.Fatal(err) } cfg.MQTT.URL = "mqtt://user:secret@localhost:1884" if cfg.Validate() == nil { t.Fatal("embedded credentials accepted") } cfg.MQTT.URL = "tls://localhost:8884" cfg.MQTT.AllowPlaintext = false if err := cfg.Validate(); err != nil { t.Fatal(err) } } func TestRoleDefaultsRemainBelowConfiguration(t *testing.T) { defaults := Defaults() defaults.MQTT.ClientID = "role-default" file := filepath.Join(t.TempDir(), "role.toml") if err := os.WriteFile(file, []byte("[mqtt]\nclient_id='file-client'\n"), 0600); err != nil { t.Fatal(err) } cfg, err := LoadWithDefaults(file, nil, defaults) if err != nil || cfg.MQTT.ClientID != "file-client" { t.Fatal("role defaults overrode TOML") } cfg, err = LoadWithDefaults(file, map[string]string{"mqtt.client_id": "flag-client"}, defaults) if err != nil || cfg.MQTT.ClientID != "flag-client" { t.Fatal("explicit flags lost precedence") } }