navidrome/persistence/share_repository_test.go
Deluan Quintão 1a8463f7de
Merge commit from fork
* fix(share): always assign the authenticated user as share owner

A share's UserID was taken from the request body and only defaulted when
empty, so any authenticated user could create a share attributed to
another user. For playlist shares the contents are resolved in the
owner's library-access context, turning the spoofed owner into an
access-escalation vector in multi-library setups.

Force the owner from the request context at both the service boundary
and the persistence layer, ignoring any client-supplied UserID.

* fix(plugins): block SSRF to private IPs resolved from hostnames

The HTTP host client only checked the literal host string, so a symbolic
hostname (or a trailing-dot "localhost.") resolving to a private/loopback
address bypassed the SSRF guard when a plugin declared no requiredHosts.

Enforce the check at dial time via net.Dialer.Control on the resolved IP,
which also covers redirect hops and DNS rebinding. When an explicit
requiredHosts allowlist is set, defer to it as the operator's trust decision.

* fix(plugins): gate private IPs on explicit IP/CIDR allowlist entries

Following review feedback: an allowlisted hostname authorizes the external
service, not whatever private IP it may resolve or rebind to. Enforce the
resolved-IP guard even when requiredHosts is set, permitting a private
address only when a literal IP or CIDR entry explicitly covers it. This
keeps "reach this external API" and "reach my internal network" as two
separate, explicit operator decisions.

* fix(plugins): treat unspecified addresses as private in the SSRF guard

Dialing 0.0.0.0 or :: reaches the local host, so they bypassed the
private/loopback check.

* fix(plugins): let a bare "*" allowlist reach private addresses

Plugins such as AudioMuse-AI declare requiredHosts ["*"] to reach a
user-configured service on the LAN, whose address the manifest cannot
know. Requiring a literal IP/CIDR entry broke them. Named hosts and
subdomain wildcards still cannot resolve to private addresses.

* refactor(plugins): simplify the SSRF-guarded HTTP client and release its pool

Build the client directly around the guarded transport instead of
replacing a throwaway one, fail closed on an unparseable dial address,
and close the per-plugin transport's idle connections when the plugin
unloads. Trim stale comments.

* fix(plugins): stop enabling extism's unguarded http_request host function

Passing requiredHosts as the extism manifest's AllowedHosts enabled
extism's own http_request (pdk.NewHTTPRequest), which only glob-matches
the hostname and follows redirects without re-checking, bypassing the
resolved-IP SSRF guard. Plugins must use host.HTTPSend.

* fix(plugins): move bundled Rust examples to the host HTTP service

Extism's built-in http_request is now disabled, so the webhook and
Discord examples switch to nd_pdk::host::http::send. Update the README
to say host.HTTPSend is the only supported way to make HTTP requests.

* fix(plugins): move the Python example to the host HTTP service

coverartarchive-py used extism's built-in Http.request, which is now
disabled. Call Navidrome's http_send host function instead. The plugin
can no longer run under the standalone extism CLI, so drop the CLI test
targets and instructions.
2026-09-12 13:38:08 -04:00

619 lines
26 KiB
Go

package persistence
import (
"context"
"time"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ShareRepository", func() {
var repo model.ShareRepository
var ctx context.Context
var adminUser = model.User{ID: "admin", UserName: "admin", IsAdmin: true}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
repo = NewShareRepository(ctx, GetDBXBuilder())
// Insert the admin user into the database (required for foreign key constraint)
ur := NewUserRepository(ctx, GetDBXBuilder())
err := ur.Put(&adminUser)
Expect(err).ToNot(HaveOccurred())
// Clean up shares
db := GetDBXBuilder()
_, err = db.NewQuery("DELETE FROM share").Execute()
Expect(err).ToNot(HaveOccurred())
})
Describe("Headless Access", func() {
Context("Repository creation and basic operations", func() {
It("should create repository successfully with no user context", func() {
// Create repository with no user context (headless)
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
Expect(headlessRepo).ToNot(BeNil())
})
It("should handle GetAll for headless processes", func() {
// Create a simple share directly in database
shareID := "headless-test-share"
_, err := GetDBXBuilder().NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]any{
"id": shareID,
"user": adminUser.ID,
"desc": "Headless Test Share",
"type": "song",
"ids": "song-1",
"created": time.Now(),
"updated": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
// Headless process should see all shares
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
shares, err := headlessRepo.GetAll()
Expect(err).ToNot(HaveOccurred())
found := false
for _, s := range shares {
if s.ID == shareID {
found = true
break
}
}
Expect(found).To(BeTrue(), "Headless process should see all shares")
})
It("should handle individual share retrieval for headless processes", func() {
// Create a simple share
shareID := "headless-get-share"
_, err := GetDBXBuilder().NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]any{
"id": shareID,
"user": adminUser.ID,
"desc": "Headless Get Share",
"type": "song",
"ids": "song-2",
"created": time.Now(),
"updated": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
// Headless process should be able to get the share
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
share, err := headlessRepo.Get(shareID)
Expect(err).ToNot(HaveOccurred())
Expect(share.ID).To(Equal(shareID))
Expect(share.Description).To(Equal("Headless Get Share"))
})
})
})
Describe("SQL ambiguity fix verification", func() {
It("should handle share operations without SQL ambiguity errors", func() {
// This test verifies that the loadMedia function doesn't cause SQL ambiguity
// The key fix was using "album.id" instead of "id" in the album query filters
// Create a share that would trigger the loadMedia function
shareID := "sql-test-share"
_, err := GetDBXBuilder().NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]any{
"id": shareID,
"user": adminUser.ID,
"desc": "SQL Test Share",
"type": "album",
"ids": "non-existent-album", // Won't find albums, but shouldn't cause SQL errors
"created": time.Now(),
"updated": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
// The Get operation should work without SQL ambiguity errors
// even if no albums are found
share, err := repo.Get(shareID)
Expect(err).ToNot(HaveOccurred())
Expect(share.ID).To(Equal(shareID))
// Albums array should be empty since we used non-existent album ID
Expect(share.Albums).To(BeEmpty())
})
})
Describe("Playlist share library scoping", func() {
var otherLib model.Library
var owner model.User
var plsID string
BeforeEach(func() {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
// A second library the owner has no access to, plus a track in it
lr := NewLibraryRepository(adminCtx, GetDBXBuilder())
otherLib = model.Library{ID: 0, Name: "Share Other Library", Path: "/share/other/lib"}
Expect(lr.Put(&otherLib)).To(Succeed())
mr := NewMediaFileRepository(adminCtx, GetDBXBuilder())
Expect(mr.Put(&model.MediaFile{ID: "share-other", LibraryID: otherLib.ID, Path: "s/other.mp3", Title: "ShareOther"})).To(Succeed())
Expect(mr.Put(&model.MediaFile{ID: "share-ok", LibraryID: 1, Path: "s/ok.mp3", Title: "ShareOK"})).To(Succeed())
// Non-admin owner with access to library 1 only
owner = createUserWithLibraries("share-owner", []int{1})
ur := NewUserRepository(adminCtx, GetDBXBuilder())
Expect(ur.Put(&owner)).To(Succeed())
Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed())
// Owner-owned playlist containing tracks from both libraries
plsID = "share-scope-pls"
ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), owner)
pr := NewPlaylistRepository(ownerCtx, GetDBXBuilder())
pls := &model.Playlist{ID: plsID, Name: "Scope Test", OwnerID: owner.ID}
pls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}, {ID: "share-other"}})
Expect(pr.Put(pls)).To(Succeed())
// Share row owned by the non-admin owner
_, err := GetDBXBuilder().NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]any{
"id": "share-scope", "user": owner.ID, "desc": "Scope test share",
"type": "playlist", "ids": plsID, "created": time.Now(), "updated": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
b := GetDBXBuilder()
_, _ = b.NewQuery(`DELETE FROM share WHERE id = 'share-scope'`).Execute()
pr := NewPlaylistRepository(adminCtx, b)
_ = pr.Delete(plsID)
mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository)
_, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"share-other", "share-ok"}}))
lr := NewLibraryRepository(adminCtx, b).(*libraryRepository)
_ = lr.delete(squirrel.Eq{"id": otherLib.ID})
_ = NewUserRepository(adminCtx, b).Delete(owner.ID)
})
It("excludes tracks the owner cannot access from the shared playlist", func() {
// Read the share as admin (mimics the public-share render path, which uses
// the share repository's own context). loadMedia must scope to the owner.
adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder())
share, err := adminRepo.Get("share-scope")
Expect(err).ToNot(HaveOccurred())
Expect(share.Tracks).To(ContainElement(HaveField("ID", "share-ok")))
Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "share-other")),
"a track outside the owner's libraries must not appear in the share")
})
It("returns no tracks when the playlist is not visible to the owner", func() {
// A private playlist owned by someone else: the share owner can no longer
// see it, so Tracks() returns nil. The share must render with no tracks
// instead of panicking.
privatePlsID := "private-pls"
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
pr := NewPlaylistRepository(adminCtx, GetDBXBuilder())
privatePls := &model.Playlist{ID: privatePlsID, Name: "Private", OwnerID: adminUser.ID, Public: false}
privatePls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}})
Expect(pr.Put(privatePls)).To(Succeed())
DeferCleanup(func() { _ = pr.Delete(privatePlsID) })
_, err := GetDBXBuilder().NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]any{
"id": "share-private", "user": owner.ID, "desc": "Private share",
"type": "playlist", "ids": privatePlsID, "created": time.Now(), "updated": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { _, _ = GetDBXBuilder().NewQuery(`DELETE FROM share WHERE id = 'share-private'`).Execute() })
adminRepo := NewShareRepository(adminCtx, GetDBXBuilder())
share, err := adminRepo.Get("share-private")
Expect(err).ToNot(HaveOccurred())
Expect(share.Tracks).To(BeEmpty())
})
})
Describe("Artist, album and media file share library scoping", func() {
var otherLib model.Library
var owner model.User
const primaryID = "share-aa-primary"
const secondaryID = "share-aa-secondary"
BeforeEach(func() {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
b := GetDBXBuilder()
// A second library the owner has no access to
lr := NewLibraryRepository(adminCtx, b)
otherLib = model.Library{ID: 0, Name: "Artist Share Other Library", Path: "/share/artist/other"}
Expect(lr.Put(&otherLib)).To(Succeed())
ar := NewArtistRepository(adminCtx, b)
Expect(createArtistWithLibrary(ar, &model.Artist{ID: primaryID, Name: "AA Primary", OrderArtistName: "aa primary"}, 1)).To(Succeed())
Expect(createArtistWithLibrary(ar, &model.Artist{ID: secondaryID, Name: "AA Secondary", OrderArtistName: "aa secondary"}, 1)).To(Succeed())
// Secondary is a co-album-artist (not the first): album_artist_id points at
// primary, so the legacy-column filter would miss both tracks.
aaParticipants := model.Participants{model.RoleAlbumArtist: {
{Artist: model.Artist{ID: primaryID, Name: "AA Primary"}},
{Artist: model.Artist{ID: secondaryID, Name: "AA Secondary"}},
}}
alr := NewAlbumRepository(adminCtx, b)
Expect(alr.Put(&model.Album{ID: "art-album-ok", LibraryID: 1, Name: "Art Album OK", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed())
Expect(alr.Put(&model.Album{ID: "art-album-other", LibraryID: otherLib.ID, Name: "Art Album Other", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed())
mr := NewMediaFileRepository(adminCtx, b)
Expect(mr.Put(&model.MediaFile{ID: "art-ok", LibraryID: 1, AlbumID: "art-album-ok", Path: "a/ok.mp3", Title: "ArtOK", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed())
Expect(mr.Put(&model.MediaFile{ID: "art-other", LibraryID: otherLib.ID, AlbumID: "art-album-other", Path: "a/other.mp3", Title: "ArtOther", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed())
// Non-admin owner with access to library 1 only
owner = createUserWithLibraries("artist-share-owner", []int{1})
ur := NewUserRepository(adminCtx, b)
Expect(ur.Put(&owner)).To(Succeed())
Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed())
for _, s := range []struct{ id, typ, ids string }{
{"art-share", "artist", secondaryID},
{"art-album-share", "album", "art-album-ok,art-album-other"},
{"art-mf-share", "media_file", "art-ok,art-other"},
} {
_, err := b.NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]any{
"id": s.id, "user": owner.ID, "desc": "Scope share",
"type": s.typ, "ids": s.ids, "created": time.Now(), "updated": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
}
})
AfterEach(func() {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
b := GetDBXBuilder()
_, _ = b.NewQuery(`DELETE FROM share WHERE id IN ('art-share', 'art-album-share', 'art-mf-share')`).Execute()
mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository)
_, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}}))
alr := NewAlbumRepository(adminCtx, b).(*albumRepository)
_, _ = alr.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"art-album-ok", "art-album-other"}}))
ar := NewArtistRepository(adminCtx, b).(*artistRepository)
_, _ = ar.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": []string{primaryID, secondaryID}}))
lr := NewLibraryRepository(adminCtx, b).(*libraryRepository)
_ = lr.delete(squirrel.Eq{"id": otherLib.ID})
_ = NewUserRepository(adminCtx, b).Delete(owner.ID)
})
It("includes co-album-artist tracks the owner can access and excludes those they cannot", func() {
// Read as admin (mimics the public-share render path); loadMedia must still
// scope to the owner's libraries.
adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder())
share, err := adminRepo.Get("art-share")
Expect(err).ToNot(HaveOccurred())
Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok")),
"a co-album-artist track (not matched by album_artist_id) must be included")
Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other")),
"a track outside the owner's libraries must not appear in the share")
Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok")),
"a co-album-artist album must be included")
Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other")),
"an album outside the owner's libraries must not appear in the share")
})
It("excludes albums and their tracks outside the owner's libraries from an album share", func() {
// Public share rendering has no user in the context.
share, err := NewShareRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("art-album-share")
Expect(err).ToNot(HaveOccurred())
Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok")))
Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other")))
Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok")))
Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other")))
})
It("excludes tracks outside the owner's libraries from a media file share", func() {
share, err := NewShareRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("art-mf-share")
Expect(err).ToNot(HaveOccurred())
Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok")))
Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other")))
})
})
Describe("Ownership Checks", func() {
var ownerUser = model.User{ID: "2222", UserName: "regular-user"}
var otherUser = model.User{ID: "3333", UserName: "third-user"}
insertShare := func(shareID, userID string) {
_, err := GetDBXBuilder().NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]any{
"id": shareID,
"user": userID,
"desc": "Test Share",
"type": "media_file",
"ids": "1001",
"created": time.Now(),
"updated": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
}
Describe("Delete", func() {
It("allows a non-admin user to delete their own share", func() {
insertShare("own-share-del", ownerUser.ID)
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
repo := NewShareRepository(ctx, GetDBXBuilder())
err := repo.(rest.Persistable).Delete("own-share-del")
Expect(err).ToNot(HaveOccurred())
})
It("denies a non-admin user from deleting another user's share", func() {
insertShare("other-share-del", ownerUser.ID)
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser)
repo := NewShareRepository(ctx, GetDBXBuilder())
err := repo.(rest.Persistable).Delete("other-share-del")
Expect(err).To(Equal(rest.ErrPermissionDenied))
// The share was not deleted: the owner can still read it.
ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
ownerRepo := NewShareRepository(ownerCtx, GetDBXBuilder())
_, err = ownerRepo.(rest.Repository).Read("other-share-del")
Expect(err).ToNot(HaveOccurred())
})
It("allows an admin to delete any user's share", func() {
insertShare("admin-del-share", ownerUser.ID)
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
repo := NewShareRepository(ctx, GetDBXBuilder())
err := repo.(rest.Persistable).Delete("admin-del-share")
Expect(err).ToNot(HaveOccurred())
})
It("allows headless context (no user) to delete a share", func() {
insertShare("headless-del-share", ownerUser.ID)
repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
err := repo.(rest.Persistable).Delete("headless-del-share")
Expect(err).ToNot(HaveOccurred())
})
})
Describe("Save", func() {
It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() {
ur := NewUserRepository(ctx, GetDBXBuilder())
Expect(ur.Put(&ownerUser)).To(Succeed())
Expect(ur.Put(&otherUser)).To(Succeed())
attackerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
attackerRepo := NewShareRepository(attackerCtx, GetDBXBuilder()).(rest.Persistable)
id, err := attackerRepo.Save(&model.Share{
ID: "spoof-save-share", UserID: otherUser.ID,
ResourceType: "media_file", ResourceIDs: "1001",
})
Expect(err).ToNot(HaveOccurred())
adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder())
got, err := adminRepo.Get(id)
Expect(err).ToNot(HaveOccurred())
Expect(got.UserID).To(Equal(ownerUser.ID))
})
})
Describe("Update", func() {
It("allows a non-admin user to update their own share", func() {
insertShare("own-share-upd", ownerUser.ID)
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
repo := NewShareRepository(ctx, GetDBXBuilder())
err := repo.(rest.Persistable).Update("own-share-upd", &model.Share{Description: "Updated"}, "description")
Expect(err).ToNot(HaveOccurred())
})
It("denies a non-admin user from updating another user's share", func() {
insertShare("other-share-upd", ownerUser.ID)
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser)
repo := NewShareRepository(ctx, GetDBXBuilder())
err := repo.(rest.Persistable).Update("other-share-upd", &model.Share{Description: "Hacked"}, "description")
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
It("allows an admin to update any user's share", func() {
insertShare("admin-upd-share", ownerUser.ID)
ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
repo := NewShareRepository(ctx, GetDBXBuilder())
err := repo.(rest.Persistable).Update("admin-upd-share", &model.Share{Description: "Admin Updated"}, "description")
Expect(err).ToNot(HaveOccurred())
})
It("allows headless context (no user) to update a share", func() {
insertShare("headless-upd-share", ownerUser.ID)
repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description")
Expect(err).ToNot(HaveOccurred())
})
It("returns not found when updating a nonexistent share", func() {
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
repo := NewShareRepository(ctx, GetDBXBuilder())
err := repo.(rest.Persistable).Update("does-not-exist", &model.Share{Description: "Ghost"}, "description")
Expect(err).To(Equal(rest.ErrNotFound))
})
It("updates all columns when no specific columns are given", func() {
insertShare("all-cols-share", ownerUser.ID)
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
repo := NewShareRepository(ctx, GetDBXBuilder())
// No cols: the update must write every column, not just updated_at.
err := repo.(rest.Persistable).Update("all-cols-share",
&model.Share{Description: "All Updated", MaxBitRate: 192, ResourceType: "album", ResourceIDs: "2002"})
Expect(err).ToNot(HaveOccurred())
got, err := repo.(rest.Repository).Read("all-cols-share")
Expect(err).ToNot(HaveOccurred())
share := got.(*model.Share)
Expect(share.Description).To(Equal("All Updated"))
Expect(share.MaxBitRate).To(Equal(192))
Expect(share.ResourceType).To(Equal("album"))
})
It("does not let an owner reassign their share to another user", func() {
insertShare("reassign-share", ownerUser.ID)
ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser)
repo := NewShareRepository(ctx, GetDBXBuilder())
err := repo.(rest.Persistable).Update("reassign-share",
&model.Share{UserID: otherUser.ID, Description: "Given away"}, "user_id", "description")
Expect(err).ToNot(HaveOccurred())
// Ownership must not have moved, even though user_id was passed in the body and cols.
got, err := repo.(rest.Repository).Read("reassign-share")
Expect(err).ToNot(HaveOccurred())
Expect(got.(*model.Share).UserID).To(Equal(ownerUser.ID))
})
})
Describe("Read scoping", func() {
BeforeEach(func() {
// Persist owner/other users so the JOIN in selectShare resolves.
ur := NewUserRepository(ctx, GetDBXBuilder())
Expect(ur.Put(&ownerUser)).To(Succeed())
Expect(ur.Put(&otherUser)).To(Succeed())
insertShare("share-owner-1", ownerUser.ID)
insertShare("share-owner-2", ownerUser.ID)
insertShare("share-other-1", otherUser.ID)
})
Context("non-admin user", func() {
var nonAdminRepo model.ShareRepository
var nonAdminRest rest.Repository
BeforeEach(func() {
nonAdminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
nonAdminRepo = NewShareRepository(nonAdminCtx, GetDBXBuilder())
nonAdminRest = nonAdminRepo.(rest.Repository)
})
It("GetAll returns only own shares", func() {
shares, err := nonAdminRepo.GetAll()
Expect(err).ToNot(HaveOccurred())
ids := make([]string, len(shares))
for i, s := range shares {
ids[i] = s.ID
}
Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2"))
})
It("ReadAll returns only own shares", func() {
res, err := nonAdminRest.ReadAll()
Expect(err).ToNot(HaveOccurred())
shares := res.(model.Shares)
ids := make([]string, len(shares))
for i, s := range shares {
ids[i] = s.ID
}
Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2"))
})
It("Get returns own share", func() {
s, err := nonAdminRepo.Get("share-owner-1")
Expect(err).ToNot(HaveOccurred())
Expect(s.ID).To(Equal("share-owner-1"))
})
It("Get returns ErrNotFound for another user's share", func() {
_, err := nonAdminRepo.Get("share-other-1")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("Read returns ErrNotFound for another user's share", func() {
_, err := nonAdminRest.Read("share-other-1")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("Exists returns true for own share", func() {
exists, err := nonAdminRepo.Exists("share-owner-1")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
})
It("Exists returns false for another user's share", func() {
exists, err := nonAdminRepo.Exists("share-other-1")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeFalse())
})
It("CountAll counts only own shares", func() {
count, err := nonAdminRepo.CountAll()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(BeNumerically("==", 2))
})
It("Count (rest) counts only own shares", func() {
count, err := nonAdminRest.Count()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(BeNumerically("==", 2))
})
})
Context("admin user", func() {
It("GetAll returns all shares", func() {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
adminRepo := NewShareRepository(adminCtx, GetDBXBuilder())
shares, err := adminRepo.GetAll()
Expect(err).ToNot(HaveOccurred())
ids := make([]string, len(shares))
for i, s := range shares {
ids[i] = s.ID
}
Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2", "share-other-1"))
})
It("CountAll counts all shares", func() {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
adminRepo := NewShareRepository(adminCtx, GetDBXBuilder())
count, err := adminRepo.CountAll()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(BeNumerically("==", 3))
})
})
Context("headless context (public share route)", func() {
It("GetAll returns all shares", func() {
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
shares, err := headlessRepo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(shares).To(HaveLen(3))
})
It("Get returns another user's share", func() {
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
s, err := headlessRepo.Get("share-other-1")
Expect(err).ToNot(HaveOccurred())
Expect(s.ID).To(Equal("share-other-1"))
})
It("Exists returns true for any share", func() {
headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder())
exists, err := headlessRepo.Exists("share-other-1")
Expect(err).ToNot(HaveOccurred())
Expect(exists).To(BeTrue())
})
})
})
})
})