389 lines
13 KiB
Go
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")
|
||
|
|
}
|
||
|
|
}
|