// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package config import ( "os" "path/filepath" "slices" "strings" "testing" ) func writableConfig(t *testing.T) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "config.toml") content := filepath.Join(dir, "posts") users := filepath.Join(dir, "users.toml") body := "content_dir = \"" + content + "\"\n" + "users_file = \"" + users + "\"\n" + "\n[server]\n" + "host = \"::1\"\n" + "port = 8080\n" + "env = \"production\"\n" + "trust_proxy = true\n" + "\n[site]\n" + "base_url = \"https://site.example\"\n" + "language = \"cs\"\n" if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write config: %v", err) } return path } func TestLoadMergesDefaults(t *testing.T) { cfg, err := Load(writableConfig(t), Overrides{Port: -1}) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Server.Host != "::1" || cfg.Server.Port != 8080 || cfg.Server.Env != EnvProduction { t.Fatalf("server section wrong: %s %d %s", cfg.Server.Host, cfg.Server.Port, cfg.Server.Env) } if !cfg.Server.TrustProxy { t.Fatal("trust_proxy not loaded") } if cfg.Site.Title != DefaultSiteTitle { t.Fatalf("default title missing: %q", cfg.Site.Title) } if cfg.Site.Language != "cs" { t.Fatalf("language = %q", cfg.Site.Language) } if cfg.Admin.SessionTTL != DefaultSessionTTL { t.Fatalf("session_ttl = %d", cfg.Admin.SessionTTL) } if cfg.RevisionLimit != DefaultRevisionLimit { t.Fatalf("revision_limit = %d", cfg.RevisionLimit) } } func TestLoadMissingFileUsesDefaults(t *testing.T) { cfg, err := Load(filepath.Join(t.TempDir(), "nope.toml"), Overrides{Port: -1}) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Server.Host != DefaultHost || cfg.Server.Port != DefaultPort { t.Fatalf("defaults not applied: %s %d", cfg.Server.Host, cfg.Server.Port) } } func TestLoadRejectsInvalidToml(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte("not = valid = toml"), 0o600); err != nil { t.Fatalf("write: %v", err) } if _, err := Load(path, Overrides{Port: -1}); err == nil { t.Fatal("want parse error") } } // A root key written below [server] belongs to that table, where nothing // reads it. The loader names the key and the fix rather than silently // falling back to the default path. func TestLoadRejectsAFoldedRootKey(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") body := "[server]\nhost = \"::1\"\ncontent_dir = \"" + filepath.Join(dir, "posts") + "\"\n" if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write: %v", err) } _, err := Load(path, Overrides{Port: -1}) if err == nil { t.Fatal("want an error for a root key below a table header") } if !strings.Contains(err.Error(), "content_dir") || !strings.Contains(err.Error(), "[table] header") { t.Fatalf("error does not name the key and the fix: %v", err) } } // The shipped template is a valid configuration as written: loading it // and validating it (with the data paths pointed at a writable directory) // is what every deployment does after copying config.toml.example. func TestShippedTemplateLoads(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") if err := os.WriteFile(path, []byte(Template), 0o600); err != nil { t.Fatalf("write template: %v", err) } cfg, err := Load(path, Overrides{ Port: -1, ContentDir: filepath.Join(dir, "posts"), UsersFile: filepath.Join(dir, "users.toml"), }) if err != nil { t.Fatalf("Load: %v", err) } if err := cfg.Validate(); err != nil { t.Fatalf("Validate: %v", err) } // The template's site block must carry the same defaults the code // serves, so a deployment with no config file and one that copies the // example present the same site. if cfg.Site.Title != DefaultSiteTitle || cfg.Site.Description != DefaultSiteDescription { t.Fatalf("template defaults = %q, %q; want %q, %q", cfg.Site.Title, cfg.Site.Description, DefaultSiteTitle, DefaultSiteDescription) } // A fresh template leaves the session key empty: the server generates // its own secret rather than the config carrying one. if cfg.Admin.SessionKey != "" { t.Fatalf("template session_key should default empty, got %q", cfg.Admin.SessionKey) } } // A missing configuration file moves the state under the user's home: // that is what lets a plain `volumen serve` on a fresh machine run // without root, and the overrides still win over it. func TestMissingFileUsesUserStatePaths(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_DATA_HOME", "") t.Setenv("XDG_CONFIG_HOME", "") cfg, err := Load(filepath.Join(home, "nope", "config.toml"), Overrides{Port: -1}) if err != nil { t.Fatalf("Load: %v", err) } want := filepath.Join(home, ".local", "share", "volumen") if cfg.ContentDir != filepath.Join(want, "posts") || cfg.UsersFile != filepath.Join(want, "users.toml") { t.Fatalf("paths = %q %q, want under %q", cfg.ContentDir, cfg.UsersFile, want) } // XDG_DATA_HOME wins over ~/.local/share when set. data := t.TempDir() t.Setenv("XDG_DATA_HOME", data) cfg, err = Load(filepath.Join(home, "nope", "config.toml"), Overrides{Port: -1}) if err != nil { t.Fatalf("Load: %v", err) } if cfg.ContentDir != filepath.Join(data, "volumen", "posts") { t.Fatalf("XDG_DATA_HOME ignored: %q", cfg.ContentDir) } // An explicit override beats the user default. cfg, err = Load(filepath.Join(home, "nope", "config.toml"), Overrides{ Port: -1, ContentDir: "/srv/posts", UsersFile: "/srv/users.toml", }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.ContentDir != "/srv/posts" || cfg.UsersFile != "/srv/users.toml" { t.Fatalf("override lost: %q %q", cfg.ContentDir, cfg.UsersFile) } } // Without --config the file is chosen in order: /etc, then the per-user // path, then /etc again so a fresh machine loads the defaults. func TestResolveConfigPathOrder(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", "") userCfg := filepath.Join(home, ".config", "volumen", "config.toml") // Nothing exists: the system path is named so Load reports defaults. if got := ResolveConfigPath(); got != DefaultPath { t.Fatalf("with no files = %q, want %q", got, DefaultPath) } if err := os.MkdirAll(filepath.Dir(userCfg), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(userCfg, []byte("port = 9091\n"), 0o600); err != nil { t.Fatalf("write: %v", err) } if got := ResolveConfigPath(); got != userCfg { t.Fatalf("user config not found: %q", got) } } func TestOverrides(t *testing.T) { cfg, err := Load(filepath.Join(t.TempDir(), "x.toml"), Overrides{ Host: "127.0.0.1", Port: 1234, ContentDir: "/srv/posts", UsersFile: "/srv/users.toml", }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Server.Host != "127.0.0.1" || cfg.Server.Port != 1234 { t.Fatalf("host/port override failed: %s %d", cfg.Server.Host, cfg.Server.Port) } if cfg.ContentDir != "/srv/posts" || cfg.UsersFile != "/srv/users.toml" { t.Fatal("path overrides failed") } } func TestValidateOK(t *testing.T) { cfg, err := Load(writableConfig(t), Overrides{Port: -1}) if err != nil { t.Fatalf("Load: %v", err) } if err := cfg.Validate(); err != nil { t.Fatalf("Validate: %v", err) } } func TestValidateFailures(t *testing.T) { dir := t.TempDir() base := func(mutate func(*Config)) *Config { cfg, err := Load(filepath.Join(dir, "none.toml"), Overrides{Port: -1}) if err != nil { t.Fatalf("Load: %v", err) } if mutate != nil { mutate(cfg) } return cfg } cases := []struct { name string cfg *Config frag string }{ {"host", base(func(c *Config) { c.Server.Host = "" }), "[server].host"}, {"host_not_an_address", base(func(c *Config) { c.Server.Host = "localhost" }), "[server].host"}, {"port", base(func(c *Config) { c.Server.Port = 0 }), "[server].port"}, {"env", base(func(c *Config) { c.Server.Env = "staging" }), "[server].env"}, {"log_format", base(func(c *Config) { c.Server.LogFormat = "xml" }), "log_format"}, {"trusted_proxies", base(func(c *Config) { c.Server.TrustedProxies = []string{"not a prefix"} }), "trusted_proxies"}, {"base_url", base(func(c *Config) { c.Site.BaseURL = "notaurl" }), "base_url"}, {"fediverse", base(func(c *Config) { c.Site.FediverseCreator = "nope" }), "fediverse_creator"}, {"webhook", base(func(c *Config) { c.Webhooks = []Webhook{{URL: "ftp://x"}} }), "webhooks"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := tc.cfg.Validate() if err == nil { t.Fatal("want error") } if !strings.Contains(err.Error(), tc.frag) { t.Fatalf("error %q does not mention %q", err, tc.frag) } }) } t.Run("session_ttl", func(t *testing.T) { cfg := base(func(c *Config) { c.Admin.SessionTTL = 0 }) if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "session_ttl") { t.Fatalf("err = %v", err) } }) t.Run("password lengths", func(t *testing.T) { cfg := base(func(c *Config) { c.Admin.MinPasswordLength = 20 c.Admin.MaxPasswordLength = 10 }) if err := cfg.Validate(); err == nil { t.Fatal("want error") } }) t.Run("rate limit", func(t *testing.T) { cfg := base(func(c *Config) { c.API.RateLimit = -1 }) if err := cfg.Validate(); err == nil { t.Fatal("want error") } }) } // The config file an operator writes for a reverse proxy decodes its // trusted proxy list, empty list included: this is the shape documented // in the template and what a production deployment relies on. func TestTrustedProxiesParse(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") body := "content_dir = \"" + filepath.Join(dir, "posts") + "\"\n" + "users_file = \"" + filepath.Join(dir, "users.toml") + "\"\n" + "\n[server]\n" + "host = \"::1\"\n" + "trust_proxy = true\n" + "trusted_proxies = [\"::1\", \"127.0.0.1\"]\n" if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write: %v", err) } cfg, err := Load(path, Overrides{Port: PortUnset}) if err != nil { t.Fatalf("Load: %v", err) } want := []string{"::1", "127.0.0.1"} if !slices.Equal(cfg.Server.TrustedProxies, want) { t.Fatalf("trusted_proxies = %v, want %v", cfg.Server.TrustedProxies, want) } emptyPath := filepath.Join(dir, "empty.toml") emptyBody := "content_dir = \"" + filepath.Join(dir, "posts") + "\"\n" + "users_file = \"" + filepath.Join(dir, "users.toml") + "\"\n" + "\n[server]\n" + "host = \"::1\"\n" + "trusted_proxies = []\n" if err := os.WriteFile(emptyPath, []byte(emptyBody), 0o600); err != nil { t.Fatalf("write: %v", err) } empty, err := Load(emptyPath, Overrides{Port: PortUnset}) if err != nil { t.Fatalf("Load empty: %v", err) } if len(empty.Server.TrustedProxies) != 0 { t.Fatalf("empty list parsed as %v", empty.Server.TrustedProxies) } } // The example shipped in the repository is the embedded template, so the // two cannot drift: the documentation points at both. func TestExampleMatchesTemplate(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "..", "config.toml.example")) if err != nil { t.Fatalf("read config.toml.example: %v", err) } if string(raw) != Template { t.Fatal("config.toml.example differs from config.Template") } } func TestAuditLogPathAndScheduler(t *testing.T) { cfg, err := Load(filepath.Join(t.TempDir(), "x.toml"), Overrides{Port: -1}) if err != nil { t.Fatalf("Load: %v", err) } if cfg.AuditLog != "" { t.Fatal("audit log should default to disabled") } if cfg.Scheduler.Enabled || cfg.Scheduler.Interval != DefaultSchedulerInterval { t.Fatal("scheduler defaults wrong") } cfg.AuditLog = "/var/log/volumen-audit.log" cfg.Scheduler = Scheduler{Enabled: true, Interval: 60} if cfg.AuditLog != "/var/log/volumen-audit.log" { t.Fatalf("audit log = %q", cfg.AuditLog) } if !cfg.Scheduler.Enabled || cfg.Scheduler.Interval != 60 { t.Fatal("scheduler config wrong") } } // A hook is on unless the file turns it off, which is the reason the // field is a pointer: an omitted key must not read as false. func TestWebhookDefaults(t *testing.T) { path := filepath.Join(t.TempDir(), "hooks.toml") body := "[[webhooks]]\nurl = \"https://example.com/one\"\n\n" + "[[webhooks]]\nurl = \"https://example.com/two\"\nenabled = false\n" if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write: %v", err) } cfg, err := Load(path, Overrides{Port: -1}) if err != nil { t.Fatalf("Load: %v", err) } if len(cfg.Webhooks) != 2 { t.Fatalf("webhooks = %v", cfg.Webhooks) } if !cfg.Webhooks[0].Delivers() { t.Fatal("an omitted enabled key disabled the hook") } if cfg.Webhooks[1].Delivers() { t.Fatal("an explicit false left the hook enabled") } }