Files
volumen/internal/config/config_test.go
T
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

389 lines
13 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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")
}
}