mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-09 02:47:29 +02:00
* refactor(persistence): adopt generic deluan/rest repository API Pin deluan/rest to the refactor branch. REST-facing repository methods take a context and return typed values. Drop DataStore.Resource and ResourceRepository; the native API names typed repositories directly through a per-request adapter that later commits remove. * refactor(persistence): base repository helpers take a context * refactor(persistence): LibraryRepository takes a context per call * refactor(persistence): PropertyRepository takes a context per call * refactor(persistence): UserPropsRepository takes a context per call * refactor(persistence): TranscodingRepository takes a context per call * refactor(persistence): ShareRepository takes a context per call * refactor(persistence): PlayerRepository takes a context per call * refactor(persistence): RadioRepository takes a context per call * refactor(persistence): PlayQueueRepository takes a context per call * refactor(persistence): Tag and Genre repositories take a context per call * refactor(persistence): PluginRepository takes a context per call * refactor(persistence): Scrobble repositories take a context per call * refactor(persistence): FolderRepository takes a context per call * refactor(persistence): Artwork repositories take a context per call * refactor(persistence): UserRepository takes a context per call * refactor(persistence): ArtistRepository takes a context per call ReadAll no longer rewrites the shared sort mappings for the role filter; it works on a per-call copy. * test(persistence): assert artist role sort sanitization in ReadAll * refactor(persistence): AlbumRepository takes a context per call * test(persistence): pass the test context to album repository helpers * refactor(persistence): MediaFileRepository takes a context per call * refactor(persistence): Playlist repositories take a context per call * refactor(persistence): build all repositories once per store * refactor(core): REST repository wrappers are built once * refactor(persistence): repositories are stateless Remove the context field from the base repository and the per-request REST adapter. Enable the containedctx linter so no repository can hold a request context again. * chore(lint): skip containedctx in test files * refactor: share simplifications from the stateless repositories sweep Add deleteOwnedAll on sqlRepository and use it in player/share Delete to remove the duplicated bulk-delete loop; have Share.Repository() return model.ShareRepository so subsonic sharing.go drops its repeated type assertions. * chore(core): assert REST wrappers implement Persistable * chore: reformat imports * perf(persistence): build repositories on first use Each transaction store used to construct all 21 repositories up front, paying for filter and sort mapping setup the block never touched. Fields are now sync.OnceValue thunks, so a store only builds what it uses. * fix(persistence): clean plugin references per deleted user A bulk user delete that fails on a later id had already removed the earlier rows but skipped their plugin cleanup. Cleanup now runs right after each successful delete. * fix(core): unload disabled plugins even when a user delete fails A bulk delete can fail on a later id after earlier users were removed and their plugins auto-disabled. The wrapper returned before unloading, leaving those plugins running until the next successful delete or a restart. * chore(deps): pin deluan/rest to v1.0.1 Replaces the pseudo-version of the refactor branch with the tagged release. REST error messages now name the bare type (Artist, not model.Artist). * test: use the spec context instead of context.Background() Replace the context.Background()/context.TODO() calls this branch added to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so repository calls are bound to the running spec's lifetime. * test: declare the spec context once per Describe Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
631 lines
26 KiB
Go
631 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(GetDBXBuilder())
|
|
|
|
// Insert the admin user into the database (required for foreign key constraint)
|
|
ur := NewUserRepository(GetDBXBuilder())
|
|
err := ur.Put(ctx, &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() {
|
|
var headlessCtx context.Context
|
|
|
|
BeforeEach(func() {
|
|
headlessCtx = GinkgoT().Context()
|
|
})
|
|
|
|
It("should create repository successfully with no user context", func() {
|
|
// Create repository with no user context (headless)
|
|
headlessRepo := NewShareRepository(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(GetDBXBuilder())
|
|
shares, err := headlessRepo.GetAll(headlessCtx)
|
|
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(GetDBXBuilder())
|
|
share, err := headlessRepo.Get(headlessCtx, 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(ctx, 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(GetDBXBuilder())
|
|
otherLib = model.Library{ID: 0, Name: "Share Other Library", Path: "/share/other/lib"}
|
|
Expect(lr.Put(adminCtx, &otherLib)).To(Succeed())
|
|
mr := NewMediaFileRepository(GetDBXBuilder())
|
|
Expect(mr.Put(adminCtx, &model.MediaFile{ID: "share-other", LibraryID: otherLib.ID, Path: "s/other.mp3", Title: "ShareOther"})).To(Succeed())
|
|
Expect(mr.Put(adminCtx, &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(GetDBXBuilder())
|
|
Expect(ur.Put(adminCtx, &owner)).To(Succeed())
|
|
Expect(ur.SetUserLibraries(adminCtx, 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(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(ownerCtx, 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(b)
|
|
_ = pr.Delete(adminCtx, plsID)
|
|
mr := NewMediaFileRepository(b).(*mediaFileRepository)
|
|
_, _ = mr.executeSQL(adminCtx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"share-other", "share-ok"}}))
|
|
lr := NewLibraryRepository(b).(*libraryRepository)
|
|
_ = lr.delete(adminCtx, squirrel.Eq{"id": otherLib.ID})
|
|
_ = NewUserRepository(b).Delete(adminCtx, 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.
|
|
adminCtx := request.WithUser(ctx, adminUser)
|
|
adminRepo := NewShareRepository(GetDBXBuilder())
|
|
share, err := adminRepo.Get(adminCtx, "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(GetDBXBuilder())
|
|
privatePls := &model.Playlist{ID: privatePlsID, Name: "Private", OwnerID: adminUser.ID, Public: false}
|
|
privatePls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}})
|
|
Expect(pr.Put(adminCtx, privatePls)).To(Succeed())
|
|
DeferCleanup(func() { _ = pr.Delete(adminCtx, 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(GetDBXBuilder())
|
|
share, err := adminRepo.Get(adminCtx, "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(b)
|
|
otherLib = model.Library{ID: 0, Name: "Artist Share Other Library", Path: "/share/artist/other"}
|
|
Expect(lr.Put(adminCtx, &otherLib)).To(Succeed())
|
|
|
|
ar := NewArtistRepository(b)
|
|
Expect(createArtistWithLibrary(adminCtx, ar, &model.Artist{ID: primaryID, Name: "AA Primary", OrderArtistName: "aa primary"}, 1)).To(Succeed())
|
|
Expect(createArtistWithLibrary(adminCtx, 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(b)
|
|
Expect(alr.Put(ctx, &model.Album{ID: "art-album-ok", LibraryID: 1, Name: "Art Album OK", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed())
|
|
Expect(alr.Put(ctx, &model.Album{ID: "art-album-other", LibraryID: otherLib.ID, Name: "Art Album Other", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed())
|
|
|
|
mr := NewMediaFileRepository(b)
|
|
Expect(mr.Put(adminCtx, &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(adminCtx, &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(b)
|
|
Expect(ur.Put(adminCtx, &owner)).To(Succeed())
|
|
Expect(ur.SetUserLibraries(adminCtx, 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(b).(*mediaFileRepository)
|
|
_, _ = mr.executeSQL(adminCtx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}}))
|
|
alr := NewAlbumRepository(b).(*albumRepository)
|
|
_, _ = alr.executeSQL(adminCtx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"art-album-ok", "art-album-other"}}))
|
|
ar := NewArtistRepository(b).(*artistRepository)
|
|
_, _ = ar.executeSQL(adminCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": []string{primaryID, secondaryID}}))
|
|
lr := NewLibraryRepository(b).(*libraryRepository)
|
|
_ = lr.delete(adminCtx, squirrel.Eq{"id": otherLib.ID})
|
|
_ = NewUserRepository(b).Delete(adminCtx, 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.
|
|
adminCtx := request.WithUser(ctx, adminUser)
|
|
adminRepo := NewShareRepository(GetDBXBuilder())
|
|
share, err := adminRepo.Get(adminCtx, "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(GetDBXBuilder()).Get(log.NewContext(GinkgoT().Context()), "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(GetDBXBuilder()).Get(log.NewContext(GinkgoT().Context()), "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(GetDBXBuilder())
|
|
err := repo.Delete(ctx, "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(GetDBXBuilder())
|
|
err := repo.Delete(ctx, "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(GetDBXBuilder())
|
|
_, err = ownerRepo.Read(ownerCtx, "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(GetDBXBuilder())
|
|
err := repo.Delete(ctx, "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(GetDBXBuilder())
|
|
err := repo.Delete(GinkgoT().Context(), "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(GetDBXBuilder())
|
|
Expect(ur.Put(ctx, &ownerUser)).To(Succeed())
|
|
Expect(ur.Put(ctx, &otherUser)).To(Succeed())
|
|
|
|
attackerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser)
|
|
attackerRepo := NewShareRepository(GetDBXBuilder())
|
|
|
|
id, err := attackerRepo.Save(attackerCtx, &model.Share{
|
|
ID: "spoof-save-share", UserID: otherUser.ID,
|
|
ResourceType: "media_file", ResourceIDs: "1001",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
adminCtx := request.WithUser(ctx, adminUser)
|
|
adminRepo := NewShareRepository(GetDBXBuilder())
|
|
got, err := adminRepo.Get(adminCtx, 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(GetDBXBuilder())
|
|
err := repo.Update(ctx, "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(GetDBXBuilder())
|
|
err := repo.Update(ctx, "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(GetDBXBuilder())
|
|
err := repo.Update(ctx, "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(GetDBXBuilder())
|
|
err := repo.Update(GinkgoT().Context(), "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(GetDBXBuilder())
|
|
err := repo.Update(ctx, "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(GetDBXBuilder())
|
|
// No cols: the update must write every column, not just updated_at.
|
|
err := repo.Update(ctx, "all-cols-share",
|
|
model.Share{Description: "All Updated", MaxBitRate: 192, ResourceType: "album", ResourceIDs: "2002"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
share, err := repo.Read(ctx, "all-cols-share")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
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(GetDBXBuilder())
|
|
err := repo.Update(ctx, "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.Read(ctx, "reassign-share")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.UserID).To(Equal(ownerUser.ID))
|
|
})
|
|
})
|
|
|
|
Describe("Read scoping", func() {
|
|
BeforeEach(func() {
|
|
// Persist owner/other users so the JOIN in selectShare resolves.
|
|
ur := NewUserRepository(GetDBXBuilder())
|
|
Expect(ur.Put(ctx, &ownerUser)).To(Succeed())
|
|
Expect(ur.Put(ctx, &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 nonAdminCtx context.Context
|
|
|
|
BeforeEach(func() {
|
|
nonAdminCtx = request.WithUser(ctx, ownerUser)
|
|
nonAdminRepo = NewShareRepository(GetDBXBuilder())
|
|
})
|
|
|
|
It("GetAll returns only own shares", func() {
|
|
shares, err := nonAdminRepo.GetAll(nonAdminCtx)
|
|
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() {
|
|
shares, err := nonAdminRepo.ReadAll(nonAdminCtx)
|
|
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("Get returns own share", func() {
|
|
s, err := nonAdminRepo.Get(nonAdminCtx, "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(nonAdminCtx, "share-other-1")
|
|
Expect(err).To(MatchError(model.ErrNotFound))
|
|
})
|
|
|
|
It("Read returns ErrNotFound for another user's share", func() {
|
|
_, err := nonAdminRepo.Read(nonAdminCtx, "share-other-1")
|
|
Expect(err).To(MatchError(model.ErrNotFound))
|
|
})
|
|
|
|
It("Exists returns true for own share", func() {
|
|
exists, err := nonAdminRepo.Exists(nonAdminCtx, "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(nonAdminCtx, "share-other-1")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeFalse())
|
|
})
|
|
|
|
It("CountAll counts only own shares", func() {
|
|
count, err := nonAdminRepo.CountAll(nonAdminCtx)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(count).To(BeNumerically("==", 2))
|
|
})
|
|
|
|
It("Count (rest) counts only own shares", func() {
|
|
count, err := nonAdminRepo.Count(nonAdminCtx)
|
|
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(GetDBXBuilder())
|
|
shares, err := adminRepo.GetAll(adminCtx)
|
|
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(GetDBXBuilder())
|
|
count, err := adminRepo.CountAll(adminCtx)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(count).To(BeNumerically("==", 3))
|
|
})
|
|
})
|
|
|
|
Context("headless context (public share route)", func() {
|
|
var headlessCtx context.Context
|
|
|
|
BeforeEach(func() {
|
|
headlessCtx = GinkgoT().Context()
|
|
})
|
|
|
|
It("GetAll returns all shares", func() {
|
|
headlessRepo := NewShareRepository(GetDBXBuilder())
|
|
shares, err := headlessRepo.GetAll(headlessCtx)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(shares).To(HaveLen(3))
|
|
})
|
|
|
|
It("Get returns another user's share", func() {
|
|
headlessRepo := NewShareRepository(GetDBXBuilder())
|
|
s, err := headlessRepo.Get(headlessCtx, "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(GetDBXBuilder())
|
|
exists, err := headlessRepo.Exists(headlessCtx, "share-other-1")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeTrue())
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|