mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-28 22:27:10 +01:00
test(jellyfin): e2e for streaming, sessions, and multi-user access control
This commit is contained in:
parent
6aa0d6a2aa
commit
b10ec9e9c9
5 changed files with 195 additions and 16 deletions
|
|
@ -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}))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}
|
||||
|
|
|
|||
58
server/jellyfin/e2e/multiuser_test.go
Normal file
58
server/jellyfin/e2e/multiuser_test.go
Normal file
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
45
server/jellyfin/e2e/sessions_test.go
Normal file
45
server/jellyfin/e2e/sessions_test.go
Normal file
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
79
server/jellyfin/e2e/streaming_test.go
Normal file
79
server/jellyfin/e2e/streaming_test.go
Normal file
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue