The scanner builds library -> series -> season -> episode; this makes it
reachable. Three reads, all grant-checked through catalog.Store (ADR-005):
a series' seasons, a season's episodes, and the next unwatched episode.
Each loads the parent through ItemFor first, so forbidden and absent are the
same ErrNotFound, and the child query is scoped to the parent's own
library_id so a corrupt parent_id cannot cross a library boundary. Verified
red-green: swapping ItemFor for Item returns two rows to an ungranted
caller.
Episodes come back in episode order, not insertion order, and the ordering
is verified red-green too - dropping the clause yields [3 1 10 2].
Routes stay inside the existing family: /api/v1/items/{id}/seasons,
/episodes and /next. No parallel route surface.
Jellyfin gains SeriesDto, SeasonDto and EpisodeDto. Jellyfin's wire type is
BaseItemDto, so these carry the folder/file split plus IndexNumber and
ParentIndexNumber. ItemType now takes the item rather than the kind, because
an episode IS kind video - that is the one signature change, and the dto
tests were updated with it.
Verified end-to-end against a running server: a library holding S01E02,
1x02, a bare-number file and a film produced two series, three seasons and
six episodes, with the film left alone; the routes returned the seasons in
order, the episodes in episode order, and the next unwatched episode.
Note for later: Subsonic and the web UI are untouched, so a series browses
there as it did before. The scanner also has no separate 'tv' kind, so a
video library mixing films and shows builds both shapes side by side.
299 lines
9.8 KiB
Go
299 lines
9.8 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mithral/vellum/internal/auth"
|
|
)
|
|
|
|
// This file exists to keep the Store seam honest. An interface that no one can
|
|
// substitute is decoration, so fakeStore implements Store entirely in memory —
|
|
// no database, no SQL — and the test drives a caller through it. If a method is
|
|
// added to Store later, this file stops compiling, which is the intended alarm.
|
|
//
|
|
// What is NOT tested here: the query text. That is the SQL implementation's job
|
|
// and catalog_test.go covers it against a real database.
|
|
|
|
// fakeStore is an in-memory Store. Its authorization model mirrors auth's: an
|
|
// admin sees everything, everyone else sees only granted libraries.
|
|
type fakeStore struct {
|
|
items map[int64]Item
|
|
files map[int64][]File
|
|
granted map[int64][]int64 // user id -> library ids
|
|
admins map[int64]bool
|
|
|
|
// children is the television tree: parent id -> its ordered seasons or
|
|
// episodes. Ordering is the SQL implementation's business (catalog_test.go
|
|
// pins it against a real database), so the fake just hands back what it was
|
|
// given — what it must reproduce is the GRANT, which is the part of the
|
|
// contract that is a security property.
|
|
children map[int64][]Item
|
|
|
|
// calls records which methods ran, so the test can prove the caller went
|
|
// through the interface rather than reaching for a database.
|
|
calls []string
|
|
}
|
|
|
|
var _ Store = (*fakeStore)(nil)
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{
|
|
items: map[int64]Item{},
|
|
files: map[int64][]File{},
|
|
granted: map[int64][]int64{},
|
|
admins: map[int64]bool{},
|
|
children: map[int64][]Item{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeStore) allowed(u auth.User, libID int64) bool {
|
|
if f.admins[u.ID] {
|
|
return true
|
|
}
|
|
for _, id := range f.granted[u.ID] {
|
|
if id == libID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (f *fakeStore) Item(_ context.Context, id int64) (Item, error) {
|
|
f.calls = append(f.calls, "Item")
|
|
it, ok := f.items[id]
|
|
if !ok {
|
|
return Item{}, ErrNotFound
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
func (f *fakeStore) ItemFor(_ context.Context, u auth.User, id int64) (Item, error) {
|
|
f.calls = append(f.calls, "ItemFor")
|
|
it, ok := f.items[id]
|
|
if !ok || !f.allowed(u, it.LibraryID) {
|
|
// The whole point: absent and forbidden are the same error.
|
|
return Item{}, ErrNotFound
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
func (f *fakeStore) Files(_ context.Context, itemID int64) ([]File, error) {
|
|
f.calls = append(f.calls, "Files")
|
|
return f.files[itemID], nil
|
|
}
|
|
|
|
func (f *fakeStore) FilesFor(ctx context.Context, u auth.User, itemID int64) ([]File, error) {
|
|
f.calls = append(f.calls, "FilesFor")
|
|
if _, err := f.ItemFor(ctx, u, itemID); err != nil {
|
|
return nil, err
|
|
}
|
|
return f.Files(ctx, itemID)
|
|
}
|
|
|
|
func (f *fakeStore) FileByID(_ context.Context, itemID, fileID int64) (File, error) {
|
|
f.calls = append(f.calls, "FileByID")
|
|
for _, fl := range f.files[itemID] { // scoped to the item, like the SQL
|
|
if fl.ID == fileID {
|
|
return fl, nil
|
|
}
|
|
}
|
|
return File{}, ErrNotFound
|
|
}
|
|
|
|
func (f *fakeStore) FirstFile(_ context.Context, itemID int64) (File, error) {
|
|
f.calls = append(f.calls, "FirstFile")
|
|
fs := f.files[itemID]
|
|
if len(fs) == 0 {
|
|
return File{}, ErrNotFound
|
|
}
|
|
return fs[0], nil
|
|
}
|
|
|
|
func (f *fakeStore) FileCount(_ context.Context, itemID int64) (int, error) {
|
|
f.calls = append(f.calls, "FileCount")
|
|
return len(f.files[itemID]), nil
|
|
}
|
|
|
|
// tree applies the same rule ItemFor applies — the parent must exist and be in a
|
|
// library the caller may read, and absent and forbidden are the same error —
|
|
// then returns its children.
|
|
func (f *fakeStore) tree(u auth.User, method string, parentID int64) ([]Item, error) {
|
|
f.calls = append(f.calls, method)
|
|
parent, ok := f.items[parentID]
|
|
if !ok || !f.allowed(u, parent.LibraryID) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return f.children[parentID], nil
|
|
}
|
|
|
|
func (f *fakeStore) Seasons(_ context.Context, u auth.User, seriesID int64) ([]Item, error) {
|
|
return f.tree(u, "Seasons", seriesID)
|
|
}
|
|
|
|
func (f *fakeStore) Episodes(_ context.Context, u auth.User, seasonID int64) ([]Item, error) {
|
|
return f.tree(u, "Episodes", seasonID)
|
|
}
|
|
|
|
// NextEpisode walks one level deeper than Episodes — the SQL reaches the
|
|
// episodes through the seasons' ids — and takes the first one, since the fake
|
|
// has no progress rows.
|
|
func (f *fakeStore) NextEpisode(_ context.Context, u auth.User, seriesID int64) (Item, error) {
|
|
seasons, err := f.tree(u, "NextEpisode", seriesID)
|
|
if err != nil {
|
|
return Item{}, err
|
|
}
|
|
for _, season := range seasons {
|
|
for _, ep := range f.children[season.ID] {
|
|
if ep.EpisodeNumber > 0 {
|
|
return ep, nil
|
|
}
|
|
}
|
|
}
|
|
return Item{}, ErrNotFound
|
|
}
|
|
|
|
// serveItem is a stand-in for a real caller (media.playback, jellyfin.playback)
|
|
// written against the Store interface instead of *sql.DB. It is here because
|
|
// that is what the seam is FOR: this function cannot be told it needs SQLite.
|
|
func serveItem(ctx context.Context, s Store, u auth.User, id int64) (Item, File, error) {
|
|
it, err := s.ItemFor(ctx, u, id)
|
|
if err != nil {
|
|
return Item{}, File{}, err
|
|
}
|
|
f, err := s.FirstFile(ctx, id)
|
|
if err != nil {
|
|
return Item{}, File{}, err
|
|
}
|
|
return it, f, nil
|
|
}
|
|
|
|
func fixture() (*fakeStore, auth.User, auth.User, int64) {
|
|
s := newFakeStore()
|
|
const libID = 42
|
|
s.items[7] = Item{ID: 7, LibraryID: libID, Kind: "ebooks", Path: "Thing.epub",
|
|
Title: "Thing", Year: 1999, RootPath: "/media/A",
|
|
ModTime: time.Unix(1700000000, 0).UTC()}
|
|
s.files[7] = []File{{ID: 700, Path: "Thing.epub", Size: 100, Container: "mp4"}}
|
|
s.files[8] = []File{{ID: 800, Path: "Other.epub", Size: 100}}
|
|
|
|
granted := auth.User{ID: 1, Username: "granted"}
|
|
outsider := auth.User{ID: 2, Username: "outsider"}
|
|
s.granted[granted.ID] = []int64{libID}
|
|
|
|
// A television tree in the same library: series -> season -> two episodes.
|
|
s.items[9] = Item{ID: 9, LibraryID: libID, Kind: "series", Title: "The Wire"}
|
|
s.items[10] = Item{ID: 10, LibraryID: libID, Kind: "season", Title: "Season 1", SeasonNumber: 1}
|
|
s.items[11] = Item{ID: 11, LibraryID: libID, Kind: "video", Title: "Pilot", SeasonNumber: 1, EpisodeNumber: 1}
|
|
s.items[12] = Item{ID: 12, LibraryID: libID, Kind: "video", Title: "Cleanup", SeasonNumber: 1, EpisodeNumber: 2}
|
|
s.children[9] = []Item{s.items[10]}
|
|
s.children[10] = []Item{s.items[11], s.items[12]}
|
|
|
|
return s, granted, outsider, 7
|
|
}
|
|
|
|
func TestStoreIsSubstitutableWithoutADatabase(t *testing.T) {
|
|
s, granted, _, itemID := fixture()
|
|
it, f, err := serveItem(context.Background(), s, granted, itemID)
|
|
if err != nil {
|
|
t.Fatalf("in-memory Store rejected a granted user: %v", err)
|
|
}
|
|
if it.Title != "Thing" || it.AbsPath() != "/media/A/Thing.epub" {
|
|
t.Errorf("item lost in translation: %+v", it)
|
|
}
|
|
if f.ID != 700 {
|
|
t.Errorf("FirstFile = %d, want 700", f.ID)
|
|
}
|
|
if want := []string{"ItemFor", "FirstFile"}; len(s.calls) != 2 ||
|
|
s.calls[0] != want[0] || s.calls[1] != want[1] {
|
|
t.Errorf("calls = %v, want %v", s.calls, want)
|
|
}
|
|
}
|
|
|
|
// The security property has to survive the seam: a caller that could tell a
|
|
// forbidden item from an absent one would leak another user's library.
|
|
func TestStoreKeepsForbiddenIndistinguishableFromAbsent(t *testing.T) {
|
|
s, _, outsider, itemID := fixture()
|
|
ctx := context.Background()
|
|
|
|
_, _, ferr := serveItem(ctx, s, outsider, itemID)
|
|
_, _, aerr := serveItem(ctx, s, outsider, itemID+9999)
|
|
if !errors.Is(ferr, ErrNotFound) || !errors.Is(aerr, ErrNotFound) {
|
|
t.Fatalf("errors = %v / %v, want ErrNotFound for both", ferr, aerr)
|
|
}
|
|
if ferr.Error() != aerr.Error() {
|
|
t.Errorf("errors differ and would leak existence: %q vs %q", ferr, aerr)
|
|
}
|
|
}
|
|
|
|
func TestStoreAdminSeesEverythingWithoutAGrant(t *testing.T) {
|
|
s, _, _, itemID := fixture()
|
|
admin := auth.User{ID: 3, Username: "root", IsAdmin: true}
|
|
s.admins[admin.ID] = true
|
|
|
|
if _, err := s.ItemFor(context.Background(), admin, itemID); err != nil {
|
|
t.Errorf("admin denied: %v", err)
|
|
}
|
|
if _, err := s.FilesFor(context.Background(), admin, itemID); err != nil {
|
|
t.Errorf("admin FilesFor denied: %v", err)
|
|
}
|
|
if n, err := s.FileCount(context.Background(), itemID); err != nil || n != 1 {
|
|
t.Errorf("FileCount = %d, %v; want 1, nil", n, err)
|
|
}
|
|
}
|
|
|
|
// The television tree reads children of a parent the caller has not yet been
|
|
// checked for, so the grant has to travel with the query or the seam is a way
|
|
// around it.
|
|
func TestStoreTreeReadIsGrantChecked(t *testing.T) {
|
|
s, granted, outsider, _ := fixture()
|
|
ctx := context.Background()
|
|
|
|
eps, err := s.Episodes(ctx, granted, 10)
|
|
if err != nil {
|
|
t.Fatalf("granted Episodes: %v", err)
|
|
}
|
|
if len(eps) != 2 || eps[0].EpisodeNumber != 1 {
|
|
t.Fatalf("Episodes = %v, want two episodes starting at 1", eps)
|
|
}
|
|
next, err := s.NextEpisode(ctx, granted, 9)
|
|
if err != nil || next.ID != 11 {
|
|
t.Errorf("NextEpisode = %+v, %v; want item 11, nil", next, err)
|
|
}
|
|
|
|
forbidden, ferr := s.Episodes(ctx, outsider, 10)
|
|
_, aerr := s.Episodes(ctx, outsider, 9999)
|
|
if len(forbidden) != 0 || forbidden != nil {
|
|
t.Errorf("an ungranted caller got episodes back: %v", forbidden)
|
|
}
|
|
if !errors.Is(ferr, ErrNotFound) || !errors.Is(aerr, ErrNotFound) ||
|
|
ferr.Error() != aerr.Error() {
|
|
t.Errorf("errors = %q / %q, want the same ErrNotFound for both", ferr, aerr)
|
|
}
|
|
if _, err := s.Seasons(ctx, outsider, 9); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("Seasons leaked to an ungranted caller: %v", err)
|
|
}
|
|
if _, err := s.NextEpisode(ctx, outsider, 9); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("NextEpisode leaked to an ungranted caller: %v", err)
|
|
}
|
|
}
|
|
|
|
// FileByID must stay scoped to its item through the interface too, or the
|
|
// in-memory Store would be laxer than the SQL one it stands in for.
|
|
func TestStoreFileByIDIsScopedToItsItem(t *testing.T) {
|
|
s, _, _, itemID := fixture()
|
|
ctx := context.Background()
|
|
|
|
other, err := s.FirstFile(ctx, itemID+1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.FileByID(ctx, itemID, other.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("file id leaked across items: %v", err)
|
|
}
|
|
if _, err := s.FileByID(ctx, itemID+1, other.ID); err != nil {
|
|
t.Errorf("legitimate lookup failed: %v", err)
|
|
}
|
|
}
|