mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
On a multi-library instance, a few reads and writes built their own queries without the per-user library filter that every other media read applies. A user granted only some libraries could see, and store, tracks from libraries they had no access to. - getBookmarks now filters the query. It has to be the query and not the result: the loop below it pre-sizes the response from the bookmark count, so a row dropped afterwards would emit an empty bookmark entry. - createBookmark rejects an id the caller cannot read, returning error 70 to match getSong. Stored rows are left alone rather than purged, so a temporary revoke does not lose saved playback positions. - playlistTrackRepository Read, Count and GetAlbumIDs get the filter their siblings CountAll and GetMediaFileIDs already had. Read is the one that mattered most: its id is the integer playlist position, so it needed no track id at all. - Playlist track writes are filtered in playlistRepository.addTracks, the only writer of playlist_tracks rows apart from smart playlists, so Add, Insert, AddAlbums/AddArtists/AddDiscs and a full replace through Put all go through it. Insert reserves a slot per requested id, so when the filter drops one it renumbers to close the hole. - playTracker.GetNowPlaying honours its context instead of discarding it. The cache is process-global, so the filter belongs in the tracker rather than in the Subsonic handler, and any future caller inherits it. Admins and single-library installs are unaffected: applyLibraryFilter and HasLibraryAccess both short-circuit for them. Scanner playlist sync runs as admin, and M3U and CLI imports already resolve tracks through FindByPaths as the same user, so neither changes.
314 lines
11 KiB
Go
314 lines
11 KiB
Go
package e2e
|
|
|
|
import (
|
|
"fmt"
|
|
"testing/fstest"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/core/artwork"
|
|
"github.com/navidrome/navidrome/core/metrics"
|
|
"github.com/navidrome/navidrome/core/playlists"
|
|
"github.com/navidrome/navidrome/core/storage/storagetest"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/scanner"
|
|
"github.com/navidrome/navidrome/server/events"
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Multi-Library Support", Ordered, func() {
|
|
var lib2 model.Library
|
|
var adminWithLibs model.User // admin reloaded with both libraries
|
|
var userLib1Only model.User // non-admin with lib1 access only
|
|
|
|
BeforeAll(func() {
|
|
conf.Server.EnableSharing = true
|
|
setupTestDB()
|
|
|
|
// Create a second FakeFS with Classical music content
|
|
classical := template(_t{
|
|
"albumartist": "Ludwig van Beethoven",
|
|
"artist": "Ludwig van Beethoven",
|
|
"album": "Symphony No. 9",
|
|
"year": 1824,
|
|
"genre": "Classical",
|
|
})
|
|
classicalFS := storagetest.FakeFS{}
|
|
classicalFS.SetFiles(fstest.MapFS{
|
|
"Classical/Beethoven/Symphony No. 9/01 - Allegro ma non troppo.mp3": classical(track(1, "Allegro ma non troppo")),
|
|
"Classical/Beethoven/Symphony No. 9/02 - Ode to Joy.mp3": classical(track(2, "Ode to Joy")),
|
|
})
|
|
storagetest.Register("fake2", &classicalFS)
|
|
|
|
// Create the second library in the DB (Put auto-assigns admin users)
|
|
lib2 = model.Library{ID: 2, Name: "Classical Library", Path: "fake2:///classical"}
|
|
Expect(ds.Library(ctx).Put(&lib2)).To(Succeed())
|
|
|
|
// Reload admin user to get both libraries in the Libraries field
|
|
loadedAdmin, err := ds.User(ctx).FindByUsername(adminUser.UserName)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
adminWithLibs = *loadedAdmin
|
|
|
|
// Run incremental scan to import lib2 content (lib1 files unchanged → skipped)
|
|
s := scanner.New(ctx, ds, events.NoopBroker(),
|
|
playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance())
|
|
_, err = s.ScanAll(ctx, false)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Create a non-admin user with access only to lib1
|
|
userLib1Only = model.User{
|
|
ID: "multilib-user-1",
|
|
UserName: "lib1user",
|
|
Name: "Lib1 User",
|
|
IsAdmin: false,
|
|
NewPassword: "password",
|
|
}
|
|
Expect(ds.User(ctx).Put(&userLib1Only)).To(Succeed())
|
|
Expect(ds.User(ctx).SetUserLibraries(userLib1Only.ID, []int{lib.ID})).To(Succeed())
|
|
|
|
loadedUser, err := ds.User(ctx).FindByUsername(userLib1Only.UserName)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
userLib1Only.Libraries = loadedUser.Libraries
|
|
})
|
|
|
|
Describe("getMusicFolders", func() {
|
|
It("returns both libraries for admin user", func() {
|
|
resp := doReqWithUser(adminWithLibs, "getMusicFolders")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.MusicFolders.Folders).To(HaveLen(2))
|
|
|
|
names := make([]string, len(resp.MusicFolders.Folders))
|
|
for i, f := range resp.MusicFolders.Folders {
|
|
names[i] = f.Name
|
|
}
|
|
Expect(names).To(ConsistOf("Music Library", "Classical Library"))
|
|
})
|
|
})
|
|
|
|
Describe("getArtists - library filtering", func() {
|
|
It("returns only lib1 artists when musicFolderId=1", func() {
|
|
resp := doReqWithUser(adminWithLibs, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib.ID))
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Artist).ToNot(BeNil())
|
|
|
|
var artistNames []string
|
|
for _, idx := range resp.Artist.Index {
|
|
for _, a := range idx.Artists {
|
|
artistNames = append(artistNames, a.Name)
|
|
}
|
|
}
|
|
Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis"))
|
|
Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven"))
|
|
})
|
|
|
|
It("returns only lib2 artists when musicFolderId=2", func() {
|
|
resp := doReqWithUser(adminWithLibs, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Artist).ToNot(BeNil())
|
|
|
|
var artistNames []string
|
|
for _, idx := range resp.Artist.Index {
|
|
for _, a := range idx.Artists {
|
|
artistNames = append(artistNames, a.Name)
|
|
}
|
|
}
|
|
Expect(artistNames).To(ContainElement("Ludwig van Beethoven"))
|
|
Expect(artistNames).ToNot(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis"))
|
|
})
|
|
|
|
It("returns artists from all libraries when no musicFolderId is specified", func() {
|
|
resp := doReqWithUser(adminWithLibs, "getArtists")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
var artistNames []string
|
|
for _, idx := range resp.Artist.Index {
|
|
for _, a := range idx.Artists {
|
|
artistNames = append(artistNames, a.Name)
|
|
}
|
|
}
|
|
Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Ludwig van Beethoven"))
|
|
})
|
|
})
|
|
|
|
Describe("getAlbumList - library filtering", func() {
|
|
It("returns only lib1 albums when musicFolderId=1", func() {
|
|
resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID))
|
|
|
|
Expect(resp.AlbumList).ToNot(BeNil())
|
|
Expect(resp.AlbumList.Album).To(HaveLen(8))
|
|
for _, a := range resp.AlbumList.Album {
|
|
Expect(a.Title).ToNot(Equal("Symphony No. 9"))
|
|
}
|
|
})
|
|
|
|
It("returns only lib2 albums when musicFolderId=2", func() {
|
|
resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
|
|
|
|
Expect(resp.AlbumList).ToNot(BeNil())
|
|
Expect(resp.AlbumList.Album).To(HaveLen(1))
|
|
Expect(resp.AlbumList.Album[0].Title).To(Equal("Symphony No. 9"))
|
|
})
|
|
})
|
|
|
|
Describe("search3 - library filtering", func() {
|
|
It("does not find lib1 content when searching in lib2 only", func() {
|
|
resp := doReqWithUser(adminWithLibs, "search3", "query", "Beatles", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
|
|
|
|
Expect(resp.SearchResult3).ToNot(BeNil())
|
|
Expect(resp.SearchResult3.Artist).To(BeEmpty())
|
|
Expect(resp.SearchResult3.Album).To(BeEmpty())
|
|
Expect(resp.SearchResult3.Song).To(BeEmpty())
|
|
})
|
|
|
|
It("finds lib2 content when searching in lib2", func() {
|
|
resp := doReqWithUser(adminWithLibs, "search3", "query", "Beethoven", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
|
|
|
|
Expect(resp.SearchResult3).ToNot(BeNil())
|
|
Expect(resp.SearchResult3.Artist).ToNot(BeEmpty())
|
|
Expect(resp.SearchResult3.Artist[0].Name).To(Equal("Ludwig van Beethoven"))
|
|
})
|
|
})
|
|
|
|
Describe("Cross-library playlists", Ordered, func() {
|
|
var playlistID string
|
|
var lib1SongID, lib2SongID string
|
|
|
|
BeforeAll(func() {
|
|
// Look up one song from each library
|
|
lib1Songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
|
Filters: squirrel.Eq{"media_file.library_id": lib.ID},
|
|
Max: 1, Sort: "title",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(lib1Songs).ToNot(BeEmpty())
|
|
lib1SongID = lib1Songs[0].ID
|
|
|
|
lib2Songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
|
Filters: squirrel.Eq{"media_file.library_id": lib2.ID},
|
|
Max: 1, Sort: "title",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(lib2Songs).ToNot(BeEmpty())
|
|
lib2SongID = lib2Songs[0].ID
|
|
})
|
|
|
|
It("admin creates a playlist with songs from both libraries", func() {
|
|
resp := doReqWithUser(adminWithLibs, "createPlaylist",
|
|
"name", "Cross-Library Playlist", "songId", lib1SongID, "songId", lib2SongID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Playlist).ToNot(BeNil())
|
|
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
|
Expect(resp.Playlist.Entry).To(HaveLen(2))
|
|
playlistID = resp.Playlist.Id
|
|
})
|
|
|
|
It("admin makes the playlist public", func() {
|
|
resp := doReqWithUser(adminWithLibs, "updatePlaylist",
|
|
"playlistId", playlistID, "public", "true")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
})
|
|
|
|
It("non-admin user with lib1 only sees only lib1 tracks in the playlist", func() {
|
|
resp := doReqWithUser(userLib1Only, "getPlaylist", "id", playlistID)
|
|
|
|
Expect(resp.Playlist).ToNot(BeNil())
|
|
// The playlist has 2 songs total, but the non-admin user only has access to lib1
|
|
Expect(resp.Playlist.Entry).To(HaveLen(1))
|
|
Expect(resp.Playlist.Entry[0].Id).To(Equal(lib1SongID))
|
|
})
|
|
|
|
It("non-admin user cannot store a song from another library through createPlaylist", func() {
|
|
resp := doReqWithUser(userLib1Only, "createPlaylist",
|
|
"name", "Restricted Playlist", "songId", lib1SongID, "songId", lib2SongID)
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
ownID := resp.Playlist.Id
|
|
|
|
stored := doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID)
|
|
Expect(stored.Playlist.Entry).To(HaveLen(1), "the lib2 song must not be persisted")
|
|
Expect(stored.Playlist.Entry[0].Id).To(Equal(lib1SongID))
|
|
|
|
By("replacing the tracks of the same playlist")
|
|
resp = doReqWithUser(userLib1Only, "createPlaylist", "playlistId", ownID, "songId", lib2SongID)
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
stored = doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID)
|
|
Expect(stored.Playlist.Entry).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("Cross-library shares", Ordered, func() {
|
|
var lib2AlbumID string
|
|
|
|
BeforeAll(func() {
|
|
lib2Albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
|
|
Filters: squirrel.Eq{"album.library_id": lib2.ID},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(lib2Albums).ToNot(BeEmpty())
|
|
lib2AlbumID = lib2Albums[0].ID
|
|
})
|
|
|
|
It("admin creates a share for a lib2 album", func() {
|
|
resp := doReqWithUser(adminWithLibs, "createShare",
|
|
"id", lib2AlbumID, "description", "Classical album share")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Shares).ToNot(BeNil())
|
|
Expect(resp.Shares.Share).To(HaveLen(1))
|
|
|
|
share := resp.Shares.Share[0]
|
|
Expect(share.Description).To(Equal("Classical album share"))
|
|
Expect(share.Entry).ToNot(BeEmpty())
|
|
Expect(share.Entry[0].Title).To(Equal("Symphony No. 9"))
|
|
})
|
|
})
|
|
|
|
Describe("Library access control", func() {
|
|
It("returns error when non-admin user requests inaccessible library", func() {
|
|
resp := doReqWithUser(userLib1Only, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("non-admin user sees only their library's content without musicFolderId", func() {
|
|
resp := doReqWithUser(userLib1Only, "getArtists")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
var artistNames []string
|
|
for _, idx := range resp.Artist.Index {
|
|
for _, a := range idx.Artists {
|
|
artistNames = append(artistNames, a.Name)
|
|
}
|
|
}
|
|
Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis"))
|
|
Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven"))
|
|
})
|
|
|
|
It("non-admin user search returns only their library's content", func() {
|
|
resp := doReqWithUser(userLib1Only, "search3", "query", "Beethoven")
|
|
|
|
Expect(resp.SearchResult3).ToNot(BeNil())
|
|
Expect(resp.SearchResult3.Artist).To(BeEmpty(), "userLib1Only should not see Beethoven (lib2)")
|
|
Expect(resp.SearchResult3.Album).To(BeEmpty())
|
|
Expect(resp.SearchResult3.Song).To(BeEmpty())
|
|
})
|
|
|
|
It("non-admin user search finds content from their library", func() {
|
|
resp := doReqWithUser(userLib1Only, "search3", "query", "Beatles")
|
|
|
|
Expect(resp.SearchResult3).ToNot(BeNil())
|
|
Expect(resp.SearchResult3.Artist).ToNot(BeEmpty(), "userLib1Only should find Beatles (lib1)")
|
|
})
|
|
})
|
|
})
|