🏡 index : quine-global/quintodrome.git

package jellyfin

import (
	"context"
	"net/http"
	"net/http/httptest"
	"sync"
	"sync/atomic"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/navidrome/navidrome/core/auth"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("authenticate middleware", func() {
	var ctx context.Context
	var api *Router
	var ds *tests.MockDataStore
	BeforeEach(func() {
		ctx = GinkgoT().Context()
		ds = &tests.MockDataStore{}
		auth.Init(ds)
		ur := ds.User().(*tests.MockedUserRepo)
		Expect(ur.Put(ctx, &model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed())
		api = &Router{ds: ds}
	})

	tokenFor := func(name string) string {
		t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: name})
		Expect(err).ToNot(HaveOccurred())
		return t
	}

	It("passes with a valid X-Emby-Token and injects the user", func() {
		var gotUser model.User
		next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			gotUser, _ = request.UserFrom(r.Context())
			w.WriteHeader(http.StatusOK)
		})
		w := httptest.NewRecorder()
		r := httptest.NewRequest("GET", "/Items", nil)
		r.Header.Set("X-Emby-Token", tokenFor("alice"))
		api.authenticate(next).ServeHTTP(w, r)
		Expect(w.Code).To(Equal(http.StatusOK))
		Expect(gotUser.UserName).To(Equal("alice"))
	})

	It("passes with the recommended Authorization: MediaBrowser scheme and injects the user", func() {
		var gotUser model.User
		next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			gotUser, _ = request.UserFrom(r.Context())
			w.WriteHeader(http.StatusOK)
		})
		w := httptest.NewRecorder()
		r := httptest.NewRequest("GET", "/Items", nil)
		r.Header.Set("Authorization", `MediaBrowser Token="`+tokenFor("alice")+`", Client="Test", DeviceId="dev1"`)
		api.authenticate(next).ServeHTTP(w, r)
		Expect(w.Code).To(Equal(http.StatusOK))
		Expect(gotUser.UserName).To(Equal("alice"))
	})

	It("rejects a missing token with 401", func() {
		next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
		w := httptest.NewRecorder()
		r := httptest.NewRequest("GET", "/Items", nil)
		api.authenticate(next).ServeHTTP(w, r)
		Expect(w.Code).To(Equal(http.StatusUnauthorized))
	})

	It("rejects a garbage token with 401 and does not call next", func() {
		nextCalled := false
		next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			nextCalled = true
			w.WriteHeader(http.StatusOK)
		})
		w := httptest.NewRecorder()
		r := httptest.NewRequest("GET", "/Items", nil)
		r.Header.Set("X-Emby-Token", "not-a-jwt")
		api.authenticate(next).ServeHTTP(w, r)
		Expect(w.Code).To(Equal(http.StatusUnauthorized))
		Expect(nextCalled).To(BeFalse())
	})

	It("rejects a valid token whose subject user does not exist with 401", func() {
		next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
		t, err := auth.CreateToken(&model.User{ID: "x", UserName: "ghost"})
		Expect(err).ToNot(HaveOccurred())

		w := httptest.NewRecorder()
		r := httptest.NewRequest("GET", "/Items", nil)
		r.Header.Set("X-Emby-Token", t)
		api.authenticate(next).ServeHTTP(w, r)
		Expect(w.Code).To(Equal(http.StatusUnauthorized))
	})

	Context("token scoping and revocation", func() {
		var usr *model.User

		BeforeEach(func() {
			ur := ds.User().(*tests.MockedUserRepo)
			usr = &model.User{ID: testID("u2"), UserName: "bob", NewPassword: "secret", TokenEpoch: 3}
			Expect(ur.Put(ctx, usr)).To(Succeed())
		})

		serve := func(token string) *httptest.ResponseRecorder {
			next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				w.WriteHeader(http.StatusOK)
			})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items", nil)
			r.Header.Set("X-Emby-Token", token)
			api.authenticate(next).ServeHTTP(w, r)
			return w
		}

		It("accepts a jellyfin-scoped token with the current epoch", func() {
			tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
			Expect(err).ToNot(HaveOccurred())
			Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
		})

		It("rejects a token whose epoch is stale", func() {
			tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
			Expect(err).ToNot(HaveOccurred())
			usr.TokenEpoch = 4
			Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
		})

		It("rejects a token minted for another API", func() {
			tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceNative)
			Expect(err).ToNot(HaveOccurred())
			Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
		})

		It("still accepts an unscoped session token", func() {
			tokenStr, err := auth.CreateToken(usr)
			Expect(err).ToNot(HaveOccurred())
			Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
		})
	})
})

var _ = Describe("withPlayer middleware", func() {
	var api *Router
	var players *fakePlayers

	BeforeEach(func() {
		players = &fakePlayers{}
		api = &Router{ds: &tests.MockDataStore{}, players: players}
	})

	callWith := func() (model.Player, model.Transcoding, bool) {
		var gotPlayer model.Player
		var gotTrc model.Transcoding
		var hasTrc bool
		next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			gotPlayer, _ = request.PlayerFrom(r.Context())
			gotTrc, hasTrc = request.TranscodingFrom(r.Context())
			w.WriteHeader(http.StatusOK)
		})
		w := httptest.NewRecorder()
		r := httptest.NewRequest("GET", "/Audio/s1/stream", nil)
		r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`)
		api.withPlayer(next).ServeHTTP(w, r)
		return gotPlayer, gotTrc, hasTrc
	}

	It("injects the registered player into the context", func() {
		player, _, hasTrc := callWith()
		Expect(player.ID).To(Equal("dev1"))
		Expect(hasTrc).To(BeFalse())
	})

	It("injects the player's server-forced transcoding into the context", func() {
		players.trc = &model.Transcoding{ID: testID("t1"), TargetFormat: "opus"}
		_, trc, hasTrc := callWith()
		Expect(hasTrc).To(BeTrue())
		Expect(trc.TargetFormat).To(Equal("opus"))
	})
})

var _ = Describe("tokenFromRequest", func() {
	It("accepts the recommended Authorization: MediaBrowser scheme", func() {
		r := httptest.NewRequest("GET", "/Items", nil)
		r.Header.Set("Authorization", `MediaBrowser Token="tok123", Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`)
		Expect(tokenFromRequest(r)).To(Equal("tok123"))
	})

	It("prefers the Authorization scheme token over deprecated token headers", func() {
		r := httptest.NewRequest("GET", "/Items", nil)
		r.Header.Set("Authorization", `MediaBrowser Token="scheme-token"`)
		r.Header.Set("X-Emby-Token", "legacy-token")
		Expect(tokenFromRequest(r)).To(Equal("scheme-token"))
	})

	It("accepts the lowercase api_key query param", func() {
		r := httptest.NewRequest("GET", "/Items/s1/File?api_key=tok123", nil)
		Expect(tokenFromRequest(r)).To(Equal("tok123"))
	})

	It("accepts a PascalCase ApiKey query param once normalizeQueryKeys has folded it", func() {
		r := httptest.NewRequest("GET", "/Items/s1/File?ApiKey=tok123", nil)
		var got string
		invoke(func(_ http.ResponseWriter, r *http.Request) { got = tokenFromRequest(r) }, httptest.NewRecorder(), r)
		Expect(got).To(Equal("tok123"))
	})
})

var _ = Describe("parseMediaBrowserAuth", func() {
	authFor := func(header string) mediaBrowserAuth {
		r := httptest.NewRequest("GET", "/", nil)
		r.Header.Set("X-Emby-Authorization", header)
		return parseMediaBrowserAuth(r)
	}

	It("reads Finamp's raw (unencoded) field values", func() {
		a := authFor(`MediaBrowser Client="Finamp", Device="Pixel 8 Pro", DeviceId="dev1", Version="1.0", Token="tok"`)
		Expect(a.Client).To(Equal("Finamp"))
		Expect(a.Device).To(Equal("Pixel 8 Pro"))
		Expect(a.DeviceId).To(Equal("dev1"))
	})

	It("percent-decodes Jellify's URL-encoded field values", func() {
		a := authFor(`MediaBrowser Client="Jellify", Device="Pixel%208%20Pro", DeviceId="dev1", Version="1.0", Token="tok"`)
		Expect(a.Client).To(Equal("Jellify"))
		Expect(a.Device).To(Equal("Pixel 8 Pro"))
	})

	It("keeps a literal '%' that isn't valid percent-encoding", func() {
		a := authFor(`MediaBrowser Client="100% Player", Device="d"`)
		Expect(a.Client).To(Equal("100% Player"))
	})

	It("prefers the recommended Authorization header over the deprecated X-Emby-Authorization", func() {
		r := httptest.NewRequest("GET", "/", nil)
		r.Header.Set("Authorization", `MediaBrowser Client="New", DeviceId="dev-new"`)
		r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Old", DeviceId="dev-old"`)
		a := parseMediaBrowserAuth(r)
		Expect(a.Client).To(Equal("New"))
		Expect(a.DeviceId).To(Equal("dev-new"))
	})

	It("falls back to X-Emby-Authorization when Authorization carries a foreign scheme", func() {
		// A reverse proxy may inject Basic/Digest credentials; the client's MediaBrowser data must
		// still be honored.
		r := httptest.NewRequest("GET", "/", nil)
		r.Header.Set("Authorization", `Digest username="proxy", realm="site"`)
		r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", DeviceId="dev1", Token="tok"`)
		a := parseMediaBrowserAuth(r)
		Expect(a.Client).To(Equal("Finamp"))
		Expect(a.Token).To(Equal("tok"))
	})

	It("rejects a foreign scheme even when its parameters mimic MediaBrowser fields", func() {
		r := httptest.NewRequest("GET", "/", nil)
		r.Header.Set("Authorization", `Custom Token="not-for-us"`)
		Expect(parseMediaBrowserAuth(r).Token).To(BeEmpty())
	})

	It("accepts the legacy Emby scheme spelling, like real Jellyfin", func() {
		a := authFor(`Emby Client="OldClient", DeviceId="dev1", Token="tok"`)
		Expect(a.Client).To(Equal("OldClient"))
		Expect(a.Token).To(Equal("tok"))
	})

	It("matches the scheme case-insensitively (HTTP auth schemes are)", func() {
		a := authFor(`mediabrowser Token="tok"`)
		Expect(a.Token).To(Equal("tok"))
	})
})

var _ = Describe("normalizeQueryKeys", func() {
	// keyFor runs a request through normalizeQueryKeys and reports the value the handler sees for
	// the given (lowercase) key — i.e. what a case-insensitive read would find.
	keyFor := func(rawQuery, key string) string {
		r := httptest.NewRequest("GET", "/Items?"+rawQuery, nil)
		var got string
		invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query().Get(key) }, httptest.NewRecorder(), r)
		return got
	}

	It("folds PascalCase (Finamp) and camelCase (Jellify) keys to lowercase", func() {
		Expect(keyFor("ParentId=abc", "parentid")).To(Equal("abc"))
		Expect(keyFor("parentId=abc", "parentid")).To(Equal("abc"))
	})

	It("leaves values untouched", func() {
		Expect(keyFor("IncludeItemTypes=MusicAlbum,Audio", "includeitemtypes")).To(Equal("MusicAlbum,Audio"))
	})

	It("passes already-lowercase keys through unchanged", func() {
		Expect(keyFor("container=mp3", "container")).To(Equal("mp3"))
	})

	It("merges values when two keys fold to the same name instead of dropping one", func() {
		r := httptest.NewRequest("GET", "/Items?Ids=aaa&ids=bbb", nil)
		var got []string
		invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query()["ids"] }, httptest.NewRecorder(), r)
		Expect(got).To(ConsistOf("aaa", "bbb"))
	})
})

var _ = Describe("throttleStreams", func() {
	// serve fires n concurrent requests through the middleware and reports the highest number that
	// were ever inside the handler at once.
	serve := func(limit, n int) int32 {
		var inFlight, peak int32
		release := make(chan struct{})
		h := throttleStreams(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			cur := atomic.AddInt32(&inFlight, 1)
			for {
				old := atomic.LoadInt32(&peak)
				if cur <= old || atomic.CompareAndSwapInt32(&peak, old, cur) {
					break
				}
			}
			<-release // hold the slot until every request has had a chance to enter
			atomic.AddInt32(&inFlight, -1)
		}))

		var wg sync.WaitGroup
		for range n {
			wg.Add(1)
			go func() {
				defer wg.Done()
				h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil))
			}()
		}
		// Give the admitted requests time to pile up before letting them finish.
		time.Sleep(100 * time.Millisecond)
		close(release)
		wg.Wait()
		return atomic.LoadInt32(&peak)
	}

	It("admits no more than the limit at once", func() {
		Expect(serve(2, 8)).To(Equal(int32(2)))
	})

	It("queues the excess rather than rejecting it", func() {
		// All 8 still complete — they wait for a slot instead of getting a 429.
		var served int32
		release := make(chan struct{})
		h := throttleStreams(2)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			<-release
			atomic.AddInt32(&served, 1)
		}))
		var wg sync.WaitGroup
		for range 8 {
			wg.Add(1)
			go func() {
				defer wg.Done()
				h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil))
			}()
		}
		close(release)
		wg.Wait()
		Expect(served).To(Equal(int32(8)))
	})

	// chi's ThrottleBacklog panics on a non-positive limit, so a user disabling the cap must not
	// crash the server at startup.
	It("is disabled, not panicking, when the limit is zero", func() {
		Expect(func() { serve(0, 4) }).ToNot(Panic())
		Expect(serve(0, 4)).To(BeNumerically(">", int32(1)))
	})
})

var _ = Describe("caseInsensitivePaths", func() {
	var handler http.Handler
	var gotID, gotContainer string

	BeforeEach(func() {
		gotID, gotContainer = "", ""
		r := chi.NewRouter()
		// Routes are registered lowercase, mirroring the real router.
		r.Get("/foo/{id}/bar", func(w http.ResponseWriter, req *http.Request) {
			gotID = chi.URLParam(req, "id")
			w.WriteHeader(http.StatusOK)
		})
		r.Get("/audio/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) {
			gotContainer = chi.URLParam(req, "container")
			w.WriteHeader(http.StatusOK)
		})
		// A second route reusing the "bar" segment name at a different position.
		r.Get("/bar/{id}", func(w http.ResponseWriter, req *http.Request) {
			w.WriteHeader(http.StatusOK)
		})
		handler = caseInsensitivePaths(r)
	})

	serve := func(path string) *httptest.ResponseRecorder {
		w := httptest.NewRecorder()
		handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
		return w
	}

	It("routes a mixed-case request to its lowercase-registered route", func() {
		Expect(serve("/FOO/abc/BAR").Code).To(Equal(http.StatusOK))
	})

	It("routes both routes that share a segment name, regardless of casing", func() {
		Expect(serve("/Foo/abc/Bar").Code).To(Equal(http.StatusOK))
		Expect(serve("/BAR/abc").Code).To(Equal(http.StatusOK))
	})

	It("lowercases the mixed literal.extension segment so the route and container match", func() {
		w := serve("/Audio/abc/STREAM.MP3")
		Expect(w.Code).To(Equal(http.StatusOK))
		Expect(gotContainer).To(Equal("mp3"))
	})

	It("lowercases id/param segments (safe: Jellyfin ids are lowercase hex)", func() {
		serve("/foo/DEADBEEF/bar")
		Expect(gotID).To(Equal("deadbeef"))
	})

	It("normalizes the RoutePath branch when mounted under a parent", func() {
		parent := chi.NewRouter()
		parent.Mount("/jellyfin", handler)
		w := httptest.NewRecorder()
		parent.ServeHTTP(w, httptest.NewRequest("GET", "/jellyfin/FOO/abc/BAR", nil))
		Expect(w.Code).To(Equal(http.StatusOK))
	})
})