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 // 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{}, } } 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 } // 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} 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) } } // 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) } }