feat: add read-only guards for plugin-managed playlists

Plugin playlists (PluginID != "") are now treated as read-only, like
smart playlists. Track mutations (add/remove/reorder) and track
replacement via Create are blocked with ErrNotAuthorized.
This commit is contained in:
Deluan 2026-03-05 07:49:12 -05:00
parent 4600ed7c28
commit bd0af7fd38
2 changed files with 48 additions and 2 deletions

View File

@ -112,7 +112,7 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string,
if err != nil {
return err
}
if pls.IsSmartPlaylist() {
if pls.IsSmartPlaylist() || pls.IsPluginPlaylist() {
return model.ErrNotAuthorized
}
if !usr.IsAdmin && pls.OwnerID != usr.ID {
@ -218,7 +218,7 @@ func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string)
if err != nil {
return nil, err
}
if pls.IsSmartPlaylist() {
if pls.IsSmartPlaylist() || pls.IsPluginPlaylist() {
return nil, model.ErrNotAuthorized
}
return pls, nil

View File

@ -80,6 +80,8 @@ var _ = Describe("Playlists", func() {
"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
"pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1",
PluginID: "test-plugin", PluginPlaylistID: "daily-mix"},
}
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
})
@ -125,6 +127,12 @@ var _ = Describe("Playlists", func() {
_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies replacing tracks on a plugin playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.Create(ctx, "pls-plugin", "", []string{"song-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
})
Describe("Update", func() {
@ -137,6 +145,8 @@ var _ = Describe("Playlists", func() {
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
"pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1",
PluginID: "test-plugin", PluginPlaylistID: "daily-mix"},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
@ -182,6 +192,18 @@ var _ = Describe("Playlists", func() {
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies adding tracks to a plugin playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Update(ctx, "pls-plugin", nil, nil, nil, []string{"song-1"}, nil)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies removing tracks from a plugin playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Update(ctx, "pls-plugin", nil, nil, nil, nil, []int{0})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("allows metadata updates on a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Smart"
@ -199,6 +221,8 @@ var _ = Describe("Playlists", func() {
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
"pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1",
PluginID: "test-plugin", PluginPlaylistID: "daily-mix"},
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
}
mockPlsRepo.TracksRepo = mockTracks
@ -232,6 +256,12 @@ var _ = Describe("Playlists", func() {
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies editing plugin playlists", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.AddTracks(ctx, "pls-plugin", []string{"song-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"})
@ -248,6 +278,8 @@ var _ = Describe("Playlists", func() {
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
"pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1",
PluginID: "test-plugin", PluginPlaylistID: "daily-mix"},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
@ -266,6 +298,12 @@ var _ = Describe("Playlists", func() {
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies on plugin playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.RemoveTracks(ctx, "pls-plugin", []string{"track-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies non-owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
@ -282,6 +320,8 @@ var _ = Describe("Playlists", func() {
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
"pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1",
PluginID: "test-plugin", PluginPlaylistID: "daily-mix"},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
@ -299,6 +339,12 @@ var _ = Describe("Playlists", func() {
err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies on plugin playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.ReorderTrack(ctx, "pls-plugin", 1, 3)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
})
Describe("SetImage", func() {