Vellum/internal/core/config_test.go
mithral 795b77b709 Advertise Vellum over mDNS so clients find it without a typed URL
internal/discovery registers three service types on the listen port:
_vellum._tcp for native clients, and _jellyfin._tcp / _subsonic._tcp
because those ecosystems already have clients that browse the LAN. That
last part is the point: Infuse, Jellyfin Media Player and Symfonium can
find Vellum with no client-side work, because they are already looking for
those names.

TXT records carry api, ver, proto, types, tls and (when set) pub, so a
client that resolves one can tell what it found before connecting.

Definitions is a pure function of config and Start only registers what it
returns, so what is advertised is testable without a socket.

Two behaviours worth knowing:
- VELLUM_DISCOVERY=off stops advertising. It accepts off/no/false/0;
  strconv.ParseBool would have silently ignored "off".
- A loopback-only listen address advertises nothing. That is the normal
  shape behind a reverse proxy, and advertising it would put a port in the
  LAN browse list that refuses every client that dials it. Verified
  red-green: defeating the guard fails TestDefinitionsSkipsLoopbackListen.

zeroconf is the only new dependency. It logs to the stdlib logger, so a
host with no multicast-capable interface prints to stderr outside slog.

Not verified: real mDNS on a real LAN. There is no multicast here, so the
wire behaviour of zeroconf is untested - only the definitions it is handed.
2026-10-12 01:04:33 -04:00

175 lines
5.1 KiB
Go

package core
import (
"net"
"path/filepath"
"strings"
"testing"
)
func TestLoadConfigDefaults(t *testing.T) {
// No environment set: every field should have a usable default.
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Listen != ":7788" {
t.Errorf("Listen = %q, want :7788", cfg.Listen)
}
if !filepath.IsAbs(cfg.DataDir) {
t.Errorf("DataDir = %q, want absolute", cfg.DataDir)
}
if cfg.DBPath != filepath.Join(cfg.DataDir, "vellum.db") {
t.Errorf("DBPath = %q, want <DataDir>/vellum.db", cfg.DBPath)
}
if cfg.LogLevel != "info" {
t.Errorf("LogLevel = %q, want info", cfg.LogLevel)
}
}
func TestLoadConfigFromEnv(t *testing.T) {
t.Setenv("VELLUM_LISTEN", "127.0.0.1:9999")
t.Setenv("VELLUM_DATA_DIR", "/tmp/vellum-data")
t.Setenv("VELLUM_MEDIA_DIRS", "/media/a:/media/b")
t.Setenv("VELLUM_SESSION_TTL", "48h")
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Listen != "127.0.0.1:9999" {
t.Errorf("Listen = %q", cfg.Listen)
}
if cfg.DataDir != "/tmp/vellum-data" {
t.Errorf("DataDir = %q", cfg.DataDir)
}
if len(cfg.MediaDirs) != 2 || cfg.MediaDirs[0] != "/media/a" {
t.Errorf("MediaDirs = %v", cfg.MediaDirs)
}
if cfg.SessionTTL.Hours() != 48 {
t.Errorf("SessionTTL = %v, want 48h", cfg.SessionTTL)
}
}
func TestLoadConfigTrustedProxies(t *testing.T) {
t.Setenv("VELLUM_TRUSTED_PROXIES", "172.16.0.0/12, 127.0.0.1/32")
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if len(cfg.TrustedProxies) != 2 {
t.Fatalf("TrustedProxies = %v, want 2 entries", cfg.TrustedProxies)
}
if !cfg.TrustedProxies[0].Contains(net.ParseIP("172.20.0.5")) {
t.Errorf("TrustedProxies[0] = %v, want it to contain 172.20.0.5", cfg.TrustedProxies[0])
}
if cfg.TrustedProxies[1].Contains(net.ParseIP("172.20.0.5")) {
t.Errorf("TrustedProxies[1] = %v, want it to contain only 127.0.0.1", cfg.TrustedProxies[1])
}
}
func TestLoadConfigRejectsBadTrustedProxy(t *testing.T) {
// A malformed entry must fail the boot: silently dropping it would quietly
// narrow the trust boundary and the operator would never know.
t.Setenv("VELLUM_TRUSTED_PROXIES", "172.16.0.0/12, not-a-cidr")
_, err := LoadConfig()
if err == nil {
t.Fatal("expected error for malformed VELLUM_TRUSTED_PROXIES")
}
if !strings.Contains(err.Error(), "not-a-cidr") {
t.Errorf("error %q should name the offending entry", err)
}
}
func TestCookieSecureDefaultsFromPublicURL(t *testing.T) {
tests := []struct {
name string
public string
cookie string // empty means unset
want bool
}{
{name: "https origin secures the cookie", public: "https://media.example.com", want: true},
{name: "http origin leaves it off", public: "http://media.example.com", want: false},
{name: "unset origin leaves it off", want: false},
{name: "explicit false overrides https", public: "https://media.example.com", cookie: "false", want: false},
{name: "explicit true overrides http", public: "http://media.example.com", cookie: "true", want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("VELLUM_PUBLIC_URL", tc.public)
// An empty value reads as unset to the env helpers, and t.Setenv
// restores the ambient value either way.
t.Setenv("VELLUM_COOKIE_SECURE", tc.cookie)
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.PublicURL != tc.public {
t.Errorf("PublicURL = %q, want %q", cfg.PublicURL, tc.public)
}
if cfg.CookieSecure != tc.want {
t.Errorf("CookieSecure = %v, want %v", cfg.CookieSecure, tc.want)
}
})
}
}
func TestValidateRejectsMissingMediaDir(t *testing.T) {
cfg := Config{
DataDir: t.TempDir(),
ConfigDir: t.TempDir(),
MediaDirs: []string{"/definitely/not/a/real/path"},
}
err := cfg.Validate()
if err == nil {
t.Fatal("expected error for missing media dir")
}
if !strings.Contains(err.Error(), "/definitely/not/a/real/path") {
t.Errorf("error %q should name the offending path", err)
}
}
func TestValidateCreatesDirs(t *testing.T) {
base := t.TempDir()
cfg := Config{
DataDir: filepath.Join(base, "data"),
ConfigDir: filepath.Join(base, "config"),
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
}
func TestDiscoveryDefaultAndOff(t *testing.T) {
// Advertising is on by default: a server nobody can find on the LAN is the
// common case this feature exists to remove.
tests := []struct {
value string // empty means unset
want bool
}{
{value: "", want: true},
{value: "off", want: false},
{value: "OFF", want: false},
{value: "false", want: false},
{value: "0", want: false},
{value: "no", want: false},
{value: "on", want: true},
{value: "garbage", want: true}, // unrecognised values leave it on
}
for _, tc := range tests {
t.Run("VELLUM_DISCOVERY="+tc.value, func(t *testing.T) {
t.Setenv("VELLUM_DISCOVERY", tc.value)
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Discovery != tc.want {
t.Errorf("Discovery = %v, want %v", cfg.Discovery, tc.want)
}
})
}
}