* fix(progress): filter continue-watching by viewer access scope
The continue-watching list (GET /progress) only filtered by library when an
explicit library_id query param was passed. The global call passes none, so a
restricted profile received progress rows for items outside its scope (e.g. an
XXX title, or a title above the profile's content-rating cap). The web client
then fans out a per-item GET /catalog/items/{id} detail fetch for each row, and
the inaccessible ones return 404 — surfacing as a dead Continue Watching tile
and stray 404s.
Always apply the viewer's access scope to the progress list. Adds
LibraryItemRepository.FilterAccessibleContentIDs, a batched, episode-aware
mirror of the detail endpoint's access predicate (library membership +
content-rating ceiling), and wires it into HandleListProgress for restricted
profiles only (unrestricted viewers are unaffected). ExcludedMediaTypes is
omitted intentionally: the viewer access.Scope does not carry it and the
request path never sets it.
* fix(progress): gate continue-watching episode access on the parent series
FilterAccessibleContentIDs keyed episode access off episode_libraries and
required a media_item_libraries membership even for rating-only viewers, both
of which diverge from the detail/watch path that masks inaccessible items
(DetailService.GetItemDetail → EnsureAccessible(episode.SeriesID)). For shows
whose episodes span multiple library folders this reintroduced the dead tile /
out-of-scope leak this filter exists to prevent, and rating-only profiles
could lose membership-less items the detail endpoint still serves.
Rewrite the predicate to mirror EnsureAccessible exactly: base the lookup on
media_items, join media_item_libraries only when the viewer is
library-restricted, and resolve episodes through their parent series. Extract a
pure buildFilterAccessibleContentIDsSQL so the query shape (placeholder
numbering, the parent-series join, the optional rating predicate) is
unit-tested without a database, and de-duplicate the two progress filter
helpers via progressContentIDs/keepAccessibleEntries.
AI-use disclosure: implemented with AI assistance (Claude).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
112 lines
3.3 KiB
Go
112 lines
3.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// fakeProgressLookup records the arguments it was called with and returns a
|
|
// fixed accessibility map.
|
|
type fakeProgressLookup struct {
|
|
accessible map[string]bool
|
|
|
|
gotContentIDs []string
|
|
gotAllowed []int
|
|
gotDisabled []int
|
|
gotRating string
|
|
}
|
|
|
|
func (f *fakeProgressLookup) GetItemsInFolder(context.Context, []string, int) (map[string]bool, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeProgressLookup) FilterAccessibleContentIDs(
|
|
_ context.Context, contentIDs []string, allowedFolderIDs, disabledFolderIDs []int, maxContentRating string,
|
|
) (map[string]bool, error) {
|
|
f.gotContentIDs = contentIDs
|
|
f.gotAllowed = allowedFolderIDs
|
|
f.gotDisabled = disabledFolderIDs
|
|
f.gotRating = maxContentRating
|
|
return f.accessible, nil
|
|
}
|
|
|
|
func entries(ids ...string) []userstore.WatchProgress {
|
|
out := make([]userstore.WatchProgress, 0, len(ids))
|
|
for _, id := range ids {
|
|
out = append(out, userstore.WatchProgress{MediaItemID: id})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func ids(entries []userstore.WatchProgress) []string {
|
|
out := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
out = append(out, e.MediaItemID)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestFilterProgressEntriesByAccess(t *testing.T) {
|
|
// access.Scope sets DisabledLibraryIDs only when AllowedLibraryIDs is nil
|
|
// (see access.Scope docs), so the two restriction shapes are tested
|
|
// separately rather than as one impossible combined scope.
|
|
cases := []struct {
|
|
name string
|
|
scope access.Scope
|
|
}{
|
|
{
|
|
name: "allowed libraries + rating",
|
|
scope: access.Scope{AllowedLibraryIDs: []int{1, 2}, MaxContentRating: "PG-13"},
|
|
},
|
|
{
|
|
name: "disabled libraries + rating",
|
|
scope: access.Scope{DisabledLibraryIDs: []int{9}, MaxContentRating: "PG-13"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
lookup := &fakeProgressLookup{accessible: map[string]bool{"a": true, "c": true}}
|
|
|
|
got, err := filterProgressEntriesByAccess(context.Background(), entries("a", "b", "c"), tc.scope, lookup)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
want := []string{"a", "c"}
|
|
if g := ids(got); len(g) != len(want) || g[0] != want[0] || g[1] != want[1] {
|
|
t.Fatalf("filtered entries = %v, want %v", g, want)
|
|
}
|
|
|
|
// Scope must be forwarded verbatim to the lookup.
|
|
if !slices.Equal(lookup.gotAllowed, tc.scope.AllowedLibraryIDs) {
|
|
t.Errorf("allowed folders = %v, want %v", lookup.gotAllowed, tc.scope.AllowedLibraryIDs)
|
|
}
|
|
if !slices.Equal(lookup.gotDisabled, tc.scope.DisabledLibraryIDs) {
|
|
t.Errorf("disabled folders = %v, want %v", lookup.gotDisabled, tc.scope.DisabledLibraryIDs)
|
|
}
|
|
if len(lookup.gotContentIDs) != 3 {
|
|
t.Errorf("content ids = %v, want 3 entries", lookup.gotContentIDs)
|
|
}
|
|
if lookup.gotRating != tc.scope.MaxContentRating {
|
|
t.Errorf("max content rating = %q, want %q", lookup.gotRating, tc.scope.MaxContentRating)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFilterProgressEntriesByAccessEmpty(t *testing.T) {
|
|
lookup := &fakeProgressLookup{accessible: map[string]bool{}}
|
|
got, err := filterProgressEntriesByAccess(context.Background(), nil, access.Scope{}, lookup)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("expected no entries, got %v", ids(got))
|
|
}
|
|
}
|