Share repository read methods (Get, GetAll, Read, ReadAll, Exists, Count, CountAll) did not apply an owner filter, so non-admin users saw shares belonging to other users. The write paths already enforced per-user ownership; this brings reads in line with them. Add an addRestriction()/ownerFilter() based scope to share reads, keeping admins and the headless public-share resolution path unrestricted. Route share and player Delete through a new base-repo deleteOwned() primitive that applies the ownership predicate in the DELETE's WHERE clause (atomic, no select-then- delete window) and classifies a zero-row result as permission-denied vs not-found, mirroring updateOwned. The addRestriction helper and the write-miss classifier are hoisted onto the base repository so player and share share one implementation. Also map rest.ErrPermissionDenied and rest.ErrNotFound in the Subsonic error handler so ownership/not-found failures from the rest-backed repositories return the proper Subsonic codes (50 / 70) instead of a generic error. Covered by unit tests (persistence, subsonic error mapping) and an end-to-end cross-user sharing isolation test.
206 lines
6.2 KiB
Go
206 lines
6.2 KiB
Go
package e2e
|
|
|
|
import (
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Sharing Endpoints", Ordered, func() {
|
|
var shareID string
|
|
var albumID string
|
|
var songID string
|
|
|
|
BeforeAll(func() {
|
|
conf.Server.EnableSharing = true
|
|
setupTestDB()
|
|
|
|
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
|
|
Filters: squirrel.Eq{"album.name": "Abbey Road"},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(albums).ToNot(BeEmpty())
|
|
albumID = albums[0].ID
|
|
|
|
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
|
Filters: squirrel.Eq{"title": "Come Together"},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).ToNot(BeEmpty())
|
|
songID = songs[0].ID
|
|
})
|
|
|
|
It("getShares returns empty initially", func() {
|
|
resp := doReq("getShares")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Shares).ToNot(BeNil())
|
|
Expect(resp.Shares.Share).To(BeEmpty())
|
|
})
|
|
|
|
It("createShare creates a share for an album", func() {
|
|
resp := doReq("createShare", "id", albumID, "description", "Check out this album")
|
|
|
|
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.ID).ToNot(BeEmpty())
|
|
Expect(share.Description).To(Equal("Check out this album"))
|
|
Expect(share.Username).To(Equal(adminUser.UserName))
|
|
shareID = share.ID
|
|
})
|
|
|
|
It("getShares returns the created share", func() {
|
|
resp := doReq("getShares")
|
|
|
|
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.ID).To(Equal(shareID))
|
|
Expect(share.Description).To(Equal("Check out this album"))
|
|
Expect(share.Username).To(Equal(adminUser.UserName))
|
|
Expect(share.Entry).ToNot(BeEmpty())
|
|
})
|
|
|
|
It("updateShare modifies the description", func() {
|
|
resp := doReq("updateShare", "id", shareID, "description", "Updated description")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
|
|
// Verify update
|
|
resp = doReq("getShares")
|
|
Expect(resp.Shares.Share).To(HaveLen(1))
|
|
Expect(resp.Shares.Share[0].Description).To(Equal("Updated description"))
|
|
})
|
|
|
|
It("deleteShare removes it", func() {
|
|
resp := doReq("deleteShare", "id", shareID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
})
|
|
|
|
It("getShares returns empty after deletion", func() {
|
|
resp := doReq("getShares")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Shares).ToNot(BeNil())
|
|
Expect(resp.Shares.Share).To(BeEmpty())
|
|
})
|
|
|
|
It("createShare works with a song ID", func() {
|
|
resp := doReq("createShare", "id", songID, "description", "Great song")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Shares).ToNot(BeNil())
|
|
Expect(resp.Shares.Share).To(HaveLen(1))
|
|
Expect(resp.Shares.Share[0].Description).To(Equal("Great song"))
|
|
Expect(resp.Shares.Share[0].Entry).To(HaveLen(1))
|
|
})
|
|
|
|
It("createShare returns error when id parameter is missing", func() {
|
|
resp := doReq("createShare")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("updateShare returns error when id parameter is missing", func() {
|
|
resp := doReq("updateShare")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("deleteShare returns error when id parameter is missing", func() {
|
|
resp := doReq("deleteShare")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("Sharing Cross-User Isolation", Ordered, func() {
|
|
var userA, userB model.User
|
|
var shareID string
|
|
var albumID string
|
|
|
|
BeforeAll(func() {
|
|
conf.Server.EnableSharing = true
|
|
setupTestDB()
|
|
|
|
userA = createUser("share-user-a", "share-user-a", "Share User A", false)
|
|
userB = createUser("share-user-b", "share-user-b", "Share User B", false)
|
|
|
|
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
|
|
Filters: squirrel.Eq{"album.name": "Abbey Road"},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(albums).ToNot(BeEmpty())
|
|
albumID = albums[0].ID
|
|
|
|
resp := doReqWithUser(userA, "createShare", "id", albumID, "description", "User A's share")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Shares.Share).To(HaveLen(1))
|
|
shareID = resp.Shares.Share[0].ID
|
|
Expect(resp.Shares.Share[0].Username).To(Equal(userA.UserName))
|
|
})
|
|
|
|
It("userB's getShares does not leak userA's share", func() {
|
|
resp := doReqWithUser(userB, "getShares")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Shares).ToNot(BeNil())
|
|
Expect(resp.Shares.Share).To(BeEmpty())
|
|
})
|
|
|
|
It("userA still sees own share", func() {
|
|
resp := doReqWithUser(userA, "getShares")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.Shares.Share).To(HaveLen(1))
|
|
Expect(resp.Shares.Share[0].ID).To(Equal(shareID))
|
|
Expect(resp.Shares.Share[0].Description).To(Equal("User A's share"))
|
|
})
|
|
|
|
It("admin sees userA's share", func() {
|
|
resp := doReqWithUser(adminUser, "getShares")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
ids := make([]string, len(resp.Shares.Share))
|
|
for i, s := range resp.Shares.Share {
|
|
ids[i] = s.ID
|
|
}
|
|
Expect(ids).To(ContainElement(shareID))
|
|
})
|
|
|
|
It("userB cannot updateShare on userA's share", func() {
|
|
resp := doReqWithUser(userB, "updateShare", "id", shareID, "description", "hijacked")
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
|
|
// Confirm description unchanged for userA.
|
|
check := doReqWithUser(userA, "getShares")
|
|
Expect(check.Shares.Share).To(HaveLen(1))
|
|
Expect(check.Shares.Share[0].Description).To(Equal("User A's share"))
|
|
})
|
|
|
|
It("userB cannot deleteShare on userA's share", func() {
|
|
resp := doReqWithUser(userB, "deleteShare", "id", shareID)
|
|
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
|
|
// Confirm share still present for userA.
|
|
check := doReqWithUser(userA, "getShares")
|
|
Expect(check.Shares.Share).To(HaveLen(1))
|
|
Expect(check.Shares.Share[0].ID).To(Equal(shareID))
|
|
})
|
|
})
|