navidrome/server/jellyfin/system_test.go
Deluan Quintão aa0824e03b
feat(jellyfin): add System/Endpoint so Finamp's connection test passes (#5955)
Finamp's connection test GETs /System/Endpoint and treats anything other
than a 200 carrying an IsInNetwork key as "not a Jellyfin server", so the
test failed against Navidrome and dual-connection setups could never
switch to the local address.

IsInNetwork mirrors Jellyfin's default LAN set (NetworkManager with no
LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7
and fe80::/10. Notably that set omits 169.254.0.0/16, so Go's
IsLinkLocalUnicast is deliberately restricted to its IPv6 half.

IsLocal mirrors HttpContext.IsLocal(): the caller shares the connection's
local address, not merely "is loopback". It falls back to the loopback
check when the local address is unavailable.
2026-08-14 09:17:00 -04:00

214 lines
7.8 KiB
Go

package jellyfin
import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("System", func() {
var api *Router
BeforeEach(func() { api = &Router{} })
It("returns public system info without auth", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Jellyfin.ServerName = ""
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Info/Public", nil)
api.getPublicSystemInfo(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json"))
var info dto.PublicSystemInfo
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
Expect(info.Id).ToNot(BeEmpty())
Expect(info.Version).To(Equal(jellyfinVersion))
Expect(info.ProductName).To(Equal("Jellyfin Server"))
Expect(info.ServerName).To(HavePrefix("Navidrome"))
})
It("returns authenticated system info with the public fields plus library monitor support", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Jellyfin.ServerName = ""
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Info", nil)
api.getSystemInfo(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json"))
var info dto.SystemInfo
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
Expect(info.Id).ToNot(BeEmpty())
Expect(info.Version).To(Equal(jellyfinVersion))
Expect(info.ProductName).To(Equal("Jellyfin Server"))
Expect(info.ServerName).To(HavePrefix("Navidrome"))
Expect(info.SupportsLibraryMonitor).To(BeTrue())
Expect(info.HasPendingRestart).To(BeFalse())
Expect(info.IsShuttingDown).To(BeFalse())
})
It("advertises a LocalAddress with the request scheme, host and Jellyfin base path", func() {
DeferCleanup(configtest.SetupConfig())
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Info/Public", nil)
r.Host = "music.example.com:4599"
api.getPublicSystemInfo(w, r)
var info dto.PublicSystemInfo
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
// Jellify connecting over HTTP sets its server base URL from LocalAddress; without it the
// SDK `api` is undefined and sign-in crashes. It must include the /jellyfin mount path.
Expect(info.LocalAddress).To(Equal("http://music.example.com:4599/jellyfin"))
})
It("responds to ping with the server name as plain text", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Jellyfin.ServerName = ""
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Ping", nil)
api.ping(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("text/plain"))
// Plain text, not a JSON-quoted string: Jellyfin clients expect the bare server name.
Expect(w.Body.String()).To(HavePrefix("Navidrome"))
})
DescribeTable("reports the caller's network location on /System/Endpoint",
func(remoteAddr string, isLocal, isInNetwork bool) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Endpoint", nil)
r.RemoteAddr = remoteAddr
api.getEndpointInfo(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
// Finamp's connection test only probes for the key's presence, so it must always be emitted.
Expect(w.Body.String()).To(ContainSubstring(`"IsInNetwork"`))
var info dto.EndPointInfo
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
Expect(info.IsLocal).To(Equal(isLocal))
Expect(info.IsInNetwork).To(Equal(isInNetwork))
},
Entry("loopback", "127.0.0.1:12345", true, true),
Entry("IPv6 loopback", "[::1]:12345", true, true),
Entry("LAN address", "192.168.1.20:54321", false, true),
Entry("bare IP, as left by the RealIP middleware", "10.0.0.5", false, true),
Entry("IPv4-mapped IPv6 LAN address", "[::ffff:172.16.0.9]:80", false, true),
Entry("IPv6 link-local", "[fe80::1]:80", false, true),
Entry("IPv6 unique-local", "[fd00::1]:80", false, true),
// Jellyfin's default LAN set omits 169.254.0.0/16, so we do too.
Entry("IPv4 link-local", "169.254.1.1:80", false, false),
Entry("public address", "8.8.8.8:443", false, false),
Entry("unparseable address", "not-an-ip", false, false),
)
It("reports IsLocal when the caller shares the connection's local address", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Endpoint", nil)
r.RemoteAddr = "192.168.1.20:54321"
ctx := context.WithValue(r.Context(), http.LocalAddrContextKey,
&net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533})
api.getEndpointInfo(w, r.WithContext(ctx))
var info dto.EndPointInfo
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
Expect(info.IsLocal).To(BeTrue())
})
It("does not report IsLocal for a different host on the same LAN", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Endpoint", nil)
r.RemoteAddr = "192.168.1.99:54321"
ctx := context.WithValue(r.Context(), http.LocalAddrContextKey,
&net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533})
api.getEndpointInfo(w, r.WithContext(ctx))
var info dto.EndPointInfo
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
Expect(info.IsLocal).To(BeFalse())
Expect(info.IsInNetwork).To(BeTrue())
})
It("reports quick connect as disabled", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/QuickConnect/Enabled", nil)
api.quickConnectEnabled(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var enabled bool
Expect(json.Unmarshal(w.Body.Bytes(), &enabled)).To(Succeed())
Expect(enabled).To(BeFalse())
})
Context("serverID with a real DataStore", func() {
var ctx context.Context
var ds *tests.MockDataStore
BeforeEach(func() {
ctx = context.Background()
ds = &tests.MockDataStore{}
})
It("persists the generated id so it can be read back by another Router sharing the same DataStore", func() {
first := &Router{ds: ds}
id := first.serverID(ctx)
Expect(id).ToNot(BeEmpty())
second := &Router{ds: ds}
Expect(second.serverID(ctx)).To(Equal(id))
})
It("memoizes the id across repeated calls on the same Router", func() {
r := &Router{ds: ds}
id := r.serverID(ctx)
Expect(r.serverID(ctx)).To(Equal(id))
Expect(r.serverID(ctx)).To(Equal(id))
})
It("does not overwrite or pin over a stored id when the property read fails transiently", func() {
Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "6ba7b8109dad11d180b400c04fd430c8")).To(Succeed())
r := &Router{ds: ds}
props := ds.Property(ctx).(*tests.MockedPropertyRepo)
props.Error = errors.New("database is locked")
degraded := r.serverID(ctx)
Expect(degraded).ToNot(BeEmpty())
Expect(degraded).ToNot(Equal("6ba7b8109dad11d180b400c04fd430c8")) // temporary value, not the (unreadable) stored one
props.Error = nil
// Once the DB recovers, the stored id is intact and served again.
Expect(r.serverID(ctx)).To(Equal("6ba7b8109dad11d180b400c04fd430c8"))
stored, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey)
Expect(err).ToNot(HaveOccurred())
Expect(stored).To(Equal("6ba7b8109dad11d180b400c04fd430c8"))
})
It("returns Jellyfin's no-dash GUID form", func() {
r := &Router{ds: ds}
Expect(r.serverID(ctx)).To(MatchRegexp("^[0-9a-f]{32}$"))
})
It("strips dashes from an already-persisted id", func() {
Expect(ds.Property(ctx).Put(
consts.JellyfinServerIDKey, "1b4e28ba-2fa1-11d2-883f-0016d3cca427")).To(Succeed())
r := &Router{ds: ds}
Expect(r.serverID(ctx)).To(Equal("1b4e28ba2fa111d2883f0016d3cca427"))
})
})
})