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.
166 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|