package config import ( "os" "path/filepath" "strings" "testing" "time" ) // writeConfig writes body to /config.toml and returns its path. func writeConfig(t *testing.T, body string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } return path } // TOML: top-level keys must precede any [table], and a table may be defined // only once — fixtures are therefore assembled as top + ... + redisStatic. const ( top = "domains_cdb = \"/tmp/rspamd_domains_db\"\nauth_token = \"t\"\n" redisStatic = "[redis]\nwrite_addr = \"127.0.0.1:6379\"\n" minimal = top + redisStatic ) func mustLoad(t *testing.T, body string) *Config { t.Helper() cfg, err := Load(writeConfig(t, body)) if err != nil { t.Fatalf("Load: %v\n--- config ---\n%s", err, body) } return cfg } func TestLoad_Defaults(t *testing.T) { cfg := mustLoad(t, minimal) checks := []struct { name string got any want any }{ {"addr", cfg.Addr, ":5555"}, {"admin_addr", cfg.AdminAddr, ":9095"}, {"auth_token", cfg.AuthToken, "t"}, {"queue_size", cfg.QueueSize, 10000}, {"workers", cfg.Workers, 8}, {"max_cached_domains", cfg.MaxCachedDomains, 20000}, {"count_junk", cfg.CountJunk, false}, {"read_header_timeout", cfg.ReadHeaderTimeout, 5}, {"write_timeout", cfg.WriteTimeout, 30}, {"idle_timeout", cfg.IdleTimeout, 60}, {"handler_timeout", cfg.HandlerTimeout, 4}, {"logging.output", cfg.Logging.Output, "stderr"}, {"logging.format", cfg.Logging.Format, "console"}, {"logging.level", cfg.Logging.Level, "info"}, {"logging.counting_output", cfg.Logging.CountingOutput, "stderr"}, {"logging.counting_format", cfg.Logging.CountingFormat, "text"}, {"redis.db", cfg.Redis.DB, 0}, {"redis.pool_size", cfg.Redis.PoolSize, 32}, {"redis.timeout_ms", cfg.Redis.TimeoutMS, 500}, {"redis.sentinel_mode", cfg.Redis.SentinelMode(), false}, {"notify.timeout_seconds", cfg.Notify.TimeoutSeconds, 5}, {"redis timeout", cfg.Redis.Timeout(), 500 * time.Millisecond}, {"notify timeout", cfg.Notify.Timeout(), 5 * time.Second}, {"handler timeout", cfg.HandlerTimeoutDuration(), 4 * time.Second}, } for _, c := range checks { if c.got != c.want { t.Errorf("%s = %v, want %v", c.name, c.got, c.want) } } if got := cfg.Redis.ReadAddrs; len(got) != 1 || got[0] != "127.0.0.1:6379" { t.Errorf("read_addrs = %v, want [write_addr]", got) } if got := cfg.ExemptSet(); len(got) != 2 || !got["support@migadu.com"] || !got["contact@migadu.com"] { t.Errorf("ExemptSet() = %v, want support@ and contact@", got) } if len(cfg.AllowedPrefixes()) != 0 { t.Errorf("AllowedPrefixes() = %v, want none", cfg.AllowedPrefixes()) } if !cfg.Logging.CountingEnabled() { t.Error("counting log should be enabled by default") } } // The shipped example must always load, validate and produce no warnings. func TestLoad_Example(t *testing.T) { cfg, err := Load("../../config.toml.example") if err != nil { t.Fatalf("Load(config.toml.example): %v", err) } if !cfg.Redis.SentinelMode() || cfg.Redis.MasterName != "mymaster" || len(cfg.Redis.Sentinels) != 3 { t.Errorf("example should be in sentinel mode with 3 sentinels: %+v", cfg.Redis) } if cfg.Redis.DB != 0 { t.Errorf("example redis.db = %d, must be 0", cfg.Redis.DB) } if cfg.Notify.OrgURL == "" || cfg.Notify.AddressURL == "" { t.Error("example should set org_url and address_url") } if cfg.Notify.DomainURL != "" { t.Error("example should leave domain_url empty (web app D branch is a TODO)") } for _, w := range cfg.Warnings() { t.Errorf("example produced warning: %s", w) } } func TestLoad_MissingFile(t *testing.T) { if _, err := Load(filepath.Join(t.TempDir(), "nope.toml")); err == nil { t.Fatal("expected error") } } func TestLoad_BadTOML(t *testing.T) { if _, err := Load(writeConfig(t, "addr = [unterminated")); err == nil { t.Fatal("expected error") } } func TestLoad_RelativeDomainsCDBAnchoredToConfigDir(t *testing.T) { path := writeConfig(t, "domains_cdb = \"db/rspamd_domains_db\"\nauth_token = \"t\"\n"+redisStatic) cfg, err := Load(path) if err != nil { t.Fatal(err) } want := filepath.Join(filepath.Dir(path), "db/rspamd_domains_db") if cfg.DomainsCDB != want { t.Errorf("domains_cdb = %q, want %q", cfg.DomainsCDB, want) } } func TestLoad_EnvOverrides(t *testing.T) { t.Setenv("MIGADU_COUNTERS_AUTH_TOKEN", "env-token") t.Setenv("MIGADU_COUNTERS_REDIS_PASSWORD", "env-redis") t.Setenv("MIGADU_COUNTERS_NOTIFY_PASSWORD", "env-notify") cfg := mustLoad(t, "domains_cdb = \"/tmp/x\"\nauth_token = \"file-token\"\n"+redisStatic+"password = \"file-redis\"\n[notify]\npassword = \"file-notify\"\n") if cfg.AuthToken != "env-token" || cfg.Redis.Password != "env-redis" || cfg.Notify.Password != "env-notify" { t.Errorf("env overrides not applied: %q %q %q", cfg.AuthToken, cfg.Redis.Password, cfg.Notify.Password) } } func TestLoad_ExemptNormalized(t *testing.T) { cfg := mustLoad(t, top+"exempt = [\" Support@Migadu.com \", \"\", \"x@y.z\"]\n"+redisStatic) got := cfg.ExemptSet() if len(got) != 2 || !got["support@migadu.com"] || !got["x@y.z"] { t.Errorf("ExemptSet() = %v, want only the configured entries, normalised", got) } // An explicitly empty list disables the exemption entirely. if got := mustLoad(t, top+"exempt = []\n"+redisStatic).ExemptSet(); len(got) != 0 { t.Errorf("exempt = [] gave %v, want none", got) } } func TestLoad_AllowedCIDRs(t *testing.T) { cfg := mustLoad(t, top+"allowed_cidrs = [\"10.0.0.0/8\", \"192.168.1.5\", \"::1\"]\n"+redisStatic) p := cfg.AllowedPrefixes() if len(p) != 3 || p[0].Bits() != 8 || p[1].Bits() != 32 || p[2].Bits() != 128 { t.Errorf("AllowedPrefixes() = %v", p) } } func TestLoad_SentinelMode(t *testing.T) { cfg := mustLoad(t, top+"[redis]\nmaster_name = \"mymaster\"\nsentinels = [\"10.0.0.1:26379\", \"10.0.0.2:26379\"]\n") if !cfg.Redis.SentinelMode() || len(cfg.Redis.ReadAddrs) != 0 || cfg.Redis.WriteAddr != "" { t.Errorf("sentinel mode config = %+v", cfg.Redis) } } func TestValidate_Errors(t *testing.T) { tests := []struct { name, toml, wantErr string }{ {"redis db must be 0", top + redisStatic + "db = 1\n", "redis.db"}, {"domains_cdb required", "auth_token = \"t\"\n" + redisStatic, "domains_cdb"}, {"auth_token required", "domains_cdb = \"/tmp/x\"\n" + redisStatic, "auth_token is required"}, {"auth_token blank", "domains_cdb = \"/tmp/x\"\nauth_token = \" \"\n" + redisStatic, "auth_token is required"}, {"typo auth-token", "domains_cdb = \"/tmp/x\"\nauth-token = \"s3cret\"\n" + redisStatic, "unknown config key auth-token"}, {"typo allowed_cidr", top + "allowed_cidr = [\"10.0.0.0/8\"]\n" + redisStatic, "unknown config key allowed_cidr"}, {"typo in table", top + redisStatic + "pool = 3\n", "unknown config key redis.pool"}, {"queue_size", top + "queue_size = 0\n" + redisStatic, "queue_size"}, {"workers", top + "workers = 0\n" + redisStatic, "workers"}, {"max_cached_domains", top + "max_cached_domains = -1\n" + redisStatic, "max_cached_domains"}, {"sentinel needs master_name", top + "[redis]\nsentinels = [\"10.0.0.1:26379\"]\n", "master_name"}, {"sentinel bad address", top + "[redis]\nmaster_name = \"m\"\nsentinels = [\"10.0.0.1\"]\n", "sentinels"}, {"static needs write_addr", top, "write_addr"}, {"static bad read_addr", top + redisStatic + "read_addrs = [\"nohost\"]\n", "read_addrs"}, {"bad cidr mask", top + "allowed_cidrs = [\"10.0.0.0/33\"]\n" + redisStatic, "allowed_cidrs"}, {"bad cidr text", top + "allowed_cidrs = [\"nonsense\"]\n" + redisStatic, "allowed_cidrs"}, {"notify scheme", minimal + "[notify]\norg_url = \"ftp://x/limits\"\n", "org_url"}, {"notify no scheme", minimal + "[notify]\naddress_url = \"not a url\"\n", "address_url"}, {"notify no host", minimal + "[notify]\ndomain_url = \"https:///path\"\n", "domain_url"}, {"logging format", minimal + "[logging]\nformat = \"xml\"\n", "logging.format"}, {"logging level", minimal + "[logging]\nlevel = \"loud\"\n", "logging.level"}, {"counting format", minimal + "[logging]\ncounting_format = \"csv\"\n", "counting_format"}, {"pool size", top + redisStatic + "pool_size = -1\n", "pool_size"}, {"timeout_ms", top + redisStatic + "timeout_ms = 0\n", "timeout_ms"}, {"handler_timeout", top + "handler_timeout = 0\n" + redisStatic, "handler_timeout"}, {"addr", top + "addr = \"no-port\"\n" + redisStatic, "addr"}, {"admin_addr", top + "admin_addr = \"no-port\"\n" + redisStatic, "admin_addr"}, {"notify timeout", minimal + "[notify]\ntimeout_seconds = 0\n", "timeout_seconds"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := Load(writeConfig(t, tt.toml)) if err == nil { t.Fatalf("expected error mentioning %q", tt.wantErr) } if !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("error %q does not mention %q", err, tt.wantErr) } }) } } func TestLogging_CountingOutput(t *testing.T) { tests := []struct { toml string wantOut string wantEnabled bool }{ {"", "stderr", true}, {"[logging]\noutput = \"/var/log/a.log\"\n", "/var/log/a.log", true}, // follows output {"[logging]\noutput = \"/var/log/a.log\"\ncounting_output = \"/var/log/c.log\"\n", "/var/log/c.log", true}, {"[logging]\ncounting_output = \"off\"\n", "off", false}, {"[logging]\ncounting_output = \"OFF\"\n", "off", false}, } for _, tt := range tests { cfg := mustLoad(t, minimal+tt.toml) if cfg.Logging.CountingOutput != tt.wantOut || cfg.Logging.CountingEnabled() != tt.wantEnabled { t.Errorf("%q: counting_output=%q enabled=%v, want %q %v", tt.toml, cfg.Logging.CountingOutput, cfg.Logging.CountingEnabled(), tt.wantOut, tt.wantEnabled) } } } func TestWarnings_MissingPlaceholders(t *testing.T) { cfg := mustLoad(t, top+redisStatic+` [notify] org_url = "https://admin.example/api/limits?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS" address_url = "https://admin.example/api/limits?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT" `) w := cfg.Warnings() if len(w) != 1 || !strings.Contains(w[0], "org_url") || !strings.Contains(w[0], "$COUNT") { t.Errorf("Warnings() = %v, want exactly one about org_url missing $COUNT", w) } } // The env var satisfies the mandatory token like the file does; a typo in the // key never starts the daemon unauthenticated. func TestLoad_TokenFromEnvOnly(t *testing.T) { t.Setenv("MIGADU_COUNTERS_AUTH_TOKEN", "env-token") cfg := mustLoad(t, "domains_cdb = \"/tmp/x\"\n"+redisStatic) if cfg.AuthToken != "env-token" { t.Errorf("auth_token = %q", cfg.AuthToken) } if _, err := Load(writeConfig(t, "domains_cdb = \"/tmp/x\"\nauth-token = \"x\"\n"+redisStatic)); err == nil { t.Error("typo key must be fatal even when the env var supplies a token") } } func TestLoad_QueueAndCache(t *testing.T) { cfg := mustLoad(t, top+"queue_size = 50\nworkers = 2\nmax_cached_domains = 0\n"+redisStatic) if cfg.QueueSize != 50 || cfg.Workers != 2 || cfg.MaxCachedDomains != 0 { t.Errorf("queue/cache = %d %d %d", cfg.QueueSize, cfg.Workers, cfg.MaxCachedDomains) } } func TestDiscover(t *testing.T) { if got, err := Discover("/explicit/path.toml"); err != nil || got != "/explicit/path.toml" { t.Errorf("Discover(explicit) = %q, %v", got, err) } // With no default file present on the test machine, Discover must explain where it looked. paths := DefaultPaths DefaultPaths = []string{filepath.Join(t.TempDir(), "missing.toml")} defer func() { DefaultPaths = paths }() if _, err := Discover(""); err == nil || !strings.Contains(err.Error(), "missing.toml") { t.Errorf("Discover(\"\") err = %v, want one listing the searched paths", err) } found := writeConfig(t, minimal) DefaultPaths = []string{DefaultPaths[0], found} if got, err := Discover(""); err != nil || got != found { t.Errorf("Discover(\"\") = %q, %v; want %q", got, err, found) } }