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) } }