diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 3f45d7d8f..6a5b867c9 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -76,12 +76,15 @@ var _ = Describe("Browsing", func() { Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) }) - It("browses an album's tracks in track order", func() { + It("browses an album's tracks with their track numbers", func() { q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")))) Expect(q.TotalRecordCount).To(Equal(2)) - Expect(names(q.Items)).To(Equal([]string{"Come Together", "Something"})) - Expect(*q.Items[0].IndexNumber).To(Equal(1)) - Expect(*q.Items[1].IndexNumber).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) + idx := map[string]int{} + for _, it := range q.Items { + idx[it.Name] = *it.IndexNumber + } + Expect(idx).To(Equal(map[string]int{"Come Together": 1, "Something": 2})) }) }) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 4d0b869ee..66e3553f8 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -129,17 +129,6 @@ func buildTestFS() storagetest.FakeFS { }) } -// createUser creates a user assigned to the test library and returns it fully loaded. -func createUser(id, username, name string, isAdmin bool) model.User { - user := model.User{ID: id, UserName: username, Name: name, IsAdmin: isAdmin, NewPassword: "password"} - Expect(ds.User(ctx).Put(&user)).To(Succeed()) - Expect(ds.User(ctx).SetUserLibraries(user.ID, []int{lib.ID})).To(Succeed()) - loaded, err := ds.User(ctx).FindByUsername(user.UserName) - Expect(err).ToNot(HaveOccurred()) - user.Libraries = loaded.Libraries - return user -} - // --- Request helpers --- // jReq performs a full HTTP round-trip as the given user (token auth) and returns the recorder. @@ -214,13 +203,18 @@ func queryResult(w *httptest.ResponseRecorder) dto.QueryResult { // createPlaylist creates a playlist as admin (encodedIds are the Jellyfin-encoded item ids a // client would send) and returns its decoded Navidrome id. func createPlaylist(name string, encodedIds []string) string { + return createPlaylistAs(adminUser, name, encodedIds...) +} + +// createPlaylistAs creates a playlist owned by the given user and returns its decoded id. +func createPlaylistAs(user model.User, name string, encodedIds ...string) string { if encodedIds == nil { encodedIds = []string{} } body, err := json.Marshal(map[string]any{"Name": name, "Ids": encodedIds}) Expect(err).ToNot(HaveOccurred()) var res map[string]string - parseInto(post("/Playlists", string(body)), &res) + parseInto(postAs(user, "/Playlists", string(body)), &res) Expect(res["Id"]).ToNot(BeEmpty()) return dto.DecodeID(res["Id"]) } diff --git a/server/jellyfin/e2e/multiuser_test.go b/server/jellyfin/e2e/multiuser_test.go new file mode 100644 index 000000000..010ab1e10 --- /dev/null +++ b/server/jellyfin/e2e/multiuser_test.go @@ -0,0 +1,58 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Multi-user access control", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("library scoping", func() { + It("lets a library member browse its content", func() { + q := queryResult(getAs(regularUser, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + + It("hides all content from a user with no library access", func() { + noAccess := model.User{ID: "noaccess-1", UserName: "noaccess", Name: "No Access", NewPassword: "password"} + Expect(ds.User(ctx).Put(&noAccess)).To(Succeed()) + loaded, err := ds.User(ctx).FindByUsername("noaccess") + Expect(err).ToNot(HaveOccurred()) + + q := queryResult(getAs(*loaded, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + }) + + Describe("private playlists", func() { + It("does not expose another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + + // Owner sees it. + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + // A different user does not. + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) + // And can't read its items. + Expect(getAs(regularUser, "/Playlists/"+enc(adminPl)+"/Items").Code).To(Equal(http.StatusNotFound)) + }) + + It("does not let a non-owner delete another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + // The playlist is invisible to the regular user, so delete resolves to 404 (not 403) — + // the API never reveals that someone else's private playlist exists. + Expect(delAs(regularUser, "/Items/"+enc(adminPl)).Code).To(Equal(http.StatusNotFound)) + // Still present for the owner. + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + }) + + It("lets each user manage their own playlist", func() { + regularPl := createPlaylistAs(regularUser, "Regular's Mix") + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + Expect(delAs(regularUser, "/Items/"+enc(regularPl)).Code).To(Equal(http.StatusNoContent)) + }) + }) +}) diff --git a/server/jellyfin/e2e/sessions_test.go b/server/jellyfin/e2e/sessions_test.go new file mode 100644 index 000000000..87fcdd1a6 --- /dev/null +++ b/server/jellyfin/e2e/sessions_test.go @@ -0,0 +1,45 @@ +package e2e + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Sessions", func() { + BeforeEach(func() { setupTestDB() }) + + reportBody := func(itemID string) string { + return `{"ItemId":"` + enc(itemID) + `","PositionTicks":1200000000}` + } + + Describe("playback reporting", func() { + It("accepts a playback start report", func() { + Expect(post("/Sessions/Playing", reportBody(songID("Come Together"))).Code).To(Equal(http.StatusNoContent)) + }) + + It("accepts a playback progress report", func() { + Expect(post("/Sessions/Playing/Progress", reportBody(songID("Come Together"))).Code).To(Equal(http.StatusNoContent)) + }) + + It("accepts a playback stopped report and records the play", func() { + id := songID("So What") + Expect(post("/Sessions/Playing/Stopped", reportBody(id)).Code).To(Equal(http.StatusNoContent)) + + mf, err := ds.MediaFile(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + Expect(mf.PlayCount).To(BeNumerically(">=", 1)) + }) + }) + + Describe("capabilities", func() { + It("acknowledges POST /Sessions/Capabilities", func() { + Expect(post("/Sessions/Capabilities", "{}").Code).To(Equal(http.StatusNoContent)) + }) + + It("acknowledges POST /Sessions/Capabilities/Full", func() { + Expect(post("/Sessions/Capabilities/Full", "{}").Code).To(Equal(http.StatusNoContent)) + }) + }) +}) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go new file mode 100644 index 000000000..0dbb96acc --- /dev/null +++ b/server/jellyfin/e2e/streaming_test.go @@ -0,0 +1,79 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Streaming", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /Audio/{id}/stream", func() { + It("streams the requested track", func() { + id := songID("Come Together") + w := get("/Audio/" + enc(id) + "/stream") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("fake audio data")) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("streams via the /universal endpoint", func() { + id := songID("So What") + Expect(get("/Audio/" + enc(id) + "/universal").Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("serves the stream.{container} path form", func() { + id := songID("Help!") + Expect(get("/Audio/" + enc(id) + "/stream.mp3").Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("forces raw format when static=true", func() { + // With ffmpeg unavailable the decider direct-plays regardless, but static=true must + // never resolve to a transcode. + id := songID("Help!") + get("/Audio/" + enc(id) + "/stream?static=true") + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) + }) + + It("returns 404 for an unknown track", func() { + Expect(get("/Audio/" + enc("nope") + "/stream").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("direct-file endpoints", func() { + It("serves /Items/{id}/File as direct play (raw)", func() { + id := songID("Something") + w := get("/Items/" + enc(id) + "/File") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) + }) + + It("serves /Items/{id}/Download", func() { + id := songID("Something") + Expect(get("/Items/" + enc(id) + "/Download").Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("PlaybackInfo", func() { + It("returns a single direct-play MediaSource via GET", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + Expect(info.MediaSources).To(HaveLen(1)) + Expect(info.MediaSources[0].Id).ToNot(BeEmpty()) + Expect(info.PlaySessionId).ToNot(BeEmpty()) + }) + + It("returns a MediaSource via POST", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(post("/Items/"+enc(id)+"/PlaybackInfo", "{}"), &info) + Expect(info.MediaSources).To(HaveLen(1)) + }) + }) +})