Vellum/internal/discovery/discovery_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

166 lines
4.6 KiB
Go

package discovery
import (
"slices"
"testing"
"github.com/mithral/vellum/internal/core"
)
// baseConfig is a config with discovery on and a port that parses.
func baseConfig() core.Config {
return core.Config{Discovery: true, Listen: ":7788"}
}
func TestDefinitionsAdvertisesThreeServiceTypes(t *testing.T) {
defs, err := Definitions(baseConfig(), "1.2.3", []string{"music", "ebooks"})
if err != nil {
t.Fatalf("Definitions: %v", err)
}
want := []string{"_vellum._tcp", "_jellyfin._tcp", "_subsonic._tcp"}
got := make([]string, 0, len(defs))
for _, d := range defs {
got = append(got, d.Service)
if d.Instance != "Vellum" {
t.Errorf("%s Instance = %q, want Vellum", d.Service, d.Instance)
}
}
if !slices.Equal(got, want) {
t.Errorf("service types = %v, want %v", got, want)
}
}
func TestDefinitionsTXTRecords(t *testing.T) {
cfg := baseConfig()
cfg.PublicURL = "https://media.example.com"
defs, err := Definitions(cfg, "1.2.3", []string{"music", "ebooks"})
if err != nil {
t.Fatalf("Definitions: %v", err)
}
want := map[string]string{
"api": "v1",
"ver": "1.2.3",
"proto": "vellum,jellyfin,subsonic",
"types": "music,ebooks",
"tls": "true",
"pub": "https://media.example.com",
}
// Every record carries the same TXT: a client resolves one service type and
// then needs to know what else this server answers.
for _, d := range defs {
if len(d.TXT) != len(want) {
t.Fatalf("%s TXT = %v, want %d keys", d.Service, d.TXT, len(want))
}
for k, v := range want {
if d.TXT[k] != v {
t.Errorf("%s TXT[%q] = %q, want %q", d.Service, k, d.TXT[k], v)
}
}
}
}
func TestDefinitionsOmitsPubWithoutPublicURL(t *testing.T) {
defs, err := Definitions(baseConfig(), "dev", nil)
if err != nil {
t.Fatalf("Definitions: %v", err)
}
d := defs[0]
if _, ok := d.TXT["pub"]; ok {
t.Errorf("TXT has pub=%q, want it absent when VELLUM_PUBLIC_URL is unset", d.TXT["pub"])
}
if d.TXT["tls"] != "false" {
t.Errorf("tls = %q, want false with no https origin", d.TXT["tls"])
}
if d.TXT["types"] != "" {
t.Errorf("types = %q, want empty with no media kinds", d.TXT["types"])
}
}
func TestDefinitionsUsesListenPort(t *testing.T) {
cfg := baseConfig()
cfg.Listen = "127.0.0.1:9999"
defs, err := Definitions(cfg, "dev", nil)
if err != nil {
t.Fatalf("Definitions: %v", err)
}
for _, d := range defs {
if d.Port != 9999 {
t.Errorf("%s Port = %d, want 9999", d.Service, d.Port)
}
}
}
func TestDefinitionsDisabled(t *testing.T) {
cfg := baseConfig()
cfg.Discovery = false
defs, err := Definitions(cfg, "dev", []string{"music"})
if err != nil {
t.Fatalf("Definitions: %v", err)
}
if defs != nil {
t.Errorf("Definitions = %v, want nil when VELLUM_DISCOVERY is off", defs)
}
}
func TestDefinitionsSkipsLoopbackListen(t *testing.T) {
// Nothing on the LAN can reach a loopback bind, which is the normal shape
// behind a reverse proxy. Advertising it would put a port in the browse
// list that refuses every client that dials it.
for _, listen := range []string{"127.0.0.1:7788", "[::1]:7788"} {
cfg := baseConfig()
cfg.Listen = listen
defs, err := Definitions(cfg, "dev", []string{"music"})
if err != nil {
t.Fatalf("Definitions(%s): %v", listen, err)
}
if defs != nil {
t.Errorf("Definitions(%s) = %v, want nil: loopback is unreachable from the LAN", listen, defs)
}
}
// The other side of the boundary: a wildcard bind is reachable, so it must
// still advertise.
for _, listen := range []string{":7788", "0.0.0.0:7788"} {
cfg := baseConfig()
cfg.Listen = listen
defs, err := Definitions(cfg, "dev", []string{"music"})
if err != nil {
t.Fatalf("Definitions(%s): %v", listen, err)
}
if len(defs) == 0 {
t.Errorf("Definitions(%s) advertised nothing; a wildcard bind is reachable", listen)
}
}
}
func TestDefinitionsRejectsListenWithoutPort(t *testing.T) {
cfg := baseConfig()
cfg.Listen = "7788" // no colon: not a host:port pair
// Advertising port 0 produces a record no client can connect to, so this is
// an error rather than a silently empty advertisement.
if _, err := Definitions(cfg, "dev", nil); err == nil {
t.Fatal("expected an error for a listen address with no port")
}
cfg.Listen = ":0" // kernel-assigned port, unknowable at registration time
if _, err := Definitions(cfg, "dev", nil); err == nil {
t.Fatal("expected an error for a kernel-assigned listen port")
}
}
func TestTXTRecordsAreSorted(t *testing.T) {
got := txtRecords(map[string]string{"ver": "1", "api": "v1", "tls": "false"})
want := []string{"api=v1", "tls=false", "ver=1"}
if !slices.Equal(got, want) {
t.Errorf("txtRecords = %v, want %v", got, want)
}
}