package catalog import ( "context" "database/sql" "errors" "fmt" "io" "log/slog" "path/filepath" "slices" "strings" "testing" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/migrations" ) // testDB builds a migrated database in a temp directory. func testDB(t *testing.T) *sql.DB { t.Helper() db, err := core.OpenDB(filepath.Join(t.TempDir(), "catalog.db")) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { _ = db.Close() }) log := slog.New(slog.NewTextHandler(io.Discard, nil)) if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil { t.Fatalf("migrate: %v", err) } return db } // newLibrary inserts a library and returns its id. func newLibrary(t *testing.T, db *sql.DB, name string) int64 { t.Helper() res, err := db.Exec( `INSERT INTO libraries (kind, name, root_path) VALUES ('ebooks', ?, ?)`, name, "/media/"+name) if err != nil { t.Fatalf("insert library: %v", err) } id, _ := res.LastInsertId() return id } // newUser inserts a user. isAdmin decides whether grants matter. func newUser(t *testing.T, db *sql.DB, name string, admin bool) auth.User { t.Helper() adminFlag := 0 if admin { adminFlag = 1 } res, err := db.Exec( `INSERT INTO users (username, display_name, password_hash, is_admin) VALUES (?, ?, 'x', ?)`, name, name, adminFlag) if err != nil { t.Fatalf("insert user: %v", err) } id, _ := res.LastInsertId() return auth.User{ID: id, Username: name, IsAdmin: admin} } // newItem inserts an item with one file, including codec facts. func newItem(t *testing.T, db *sql.DB, libID int64, title string) int64 { t.Helper() res, err := db.Exec(` INSERT INTO media_items (library_id, kind, path, title, sort_title, year) VALUES (?, 'ebooks', ?, ?, ?, 1999)`, libID, title+".epub", title, title) if err != nil { t.Fatalf("insert item: %v", err) } itemID, _ := res.LastInsertId() if _, err := db.Exec(` INSERT INTO media_files (item_id, path, size, mtime, container, duration_ms, vcodec, acodec, width, height) VALUES (?, ?, 100, '1700000000000000000', 'mp4', 5000, 'h264', 'aac', 640, 360)`, itemID, title+".epub"); err != nil { t.Fatalf("insert file: %v", err) } return itemID } func grant(t *testing.T, db *sql.DB, u auth.User, libID int64) { t.Helper() if _, err := db.Exec( `INSERT INTO library_grants (user_id, library_id, can_read) VALUES (?, ?, 1)`, u.ID, libID); err != nil { t.Fatalf("grant: %v", err) } } // newTVItem writes one row of the television tree exactly as the scanner does: // a series or season carries no file, an episode is kind='video' with both // numbers set, and a zero number is stored as NULL rather than 0. // // Insertion order is the caller's business — the tests insert out of order on // purpose, because a query that returns rows in id order would look right. func newTVItem(t *testing.T, db *sql.DB, libID int64, kind, path, title string, parent int64, season, episode int) int64 { t.Helper() var ( parentArg, seasonArg, episodeArg any ) if parent != 0 { parentArg = parent } if season > 0 { seasonArg = season } if episode > 0 { episodeArg = episode } id := newTVRow(t, db, `INSERT INTO media_items (library_id, kind, path, parent_id, title, sort_title, season_number, episode_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, libID, kind, path, parentArg, title, strings.ToLower(title), seasonArg, episodeArg) if kind == "video" { // An episode has a file; progress rows need one to hang off. if _, err := db.Exec( `INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`, id, path); err != nil { t.Fatalf("insert episode file: %v", err) } } return id } func newTVRow(t *testing.T, db *sql.DB, q string, args ...any) int64 { t.Helper() var id int64 if err := db.QueryRowContext(context.Background(), q, args...).Scan(&id); err != nil { t.Fatalf("query %q: %v", q, err) } return id } // newTVLibrary builds library -> series -> season 1 (four episodes) -> season 2, // inserting every child in a deliberately useless order: season 2 before season // 1, and episodes 3, 1, 10, 2. Anything that sorts by id, by title or by path // comes back wrong, so these rows pin the ORDER and not just the contents. type tvFixture struct { series int64 seasons []int64 // season 1, season 2 ep1 map[int]int64 ep2 map[int]int64 } func newTVLibrary(t *testing.T, db *sql.DB) tvFixture { t.Helper() lib := newTVRow(t, db, `INSERT INTO libraries (kind, name, root_path) VALUES ('video', 'TV', '/media/TV') RETURNING id`) f := tvFixture{ep1: map[int]int64{}, ep2: map[int]int64{}} f.series = newTVItem(t, db, lib, "series", "The Wire", "The Wire", 0, 0, 0) // Season 2 is written first and still has to come back second. s2 := newTVItem(t, db, lib, "season", "The Wire/Season 2", "Season 2", f.series, 2, 0) s1 := newTVItem(t, db, lib, "season", "The Wire/Season 1", "Season 1", f.series, 1, 0) f.seasons = []int64{s1, s2} for _, n := range []int{3, 1, 10, 2} { f.ep1[n] = newTVItem(t, db, lib, "video", fmt.Sprintf("The Wire/Season 1/S01E%02d.mkv", n), fmt.Sprintf("Episode %d", n), s1, 1, n) } f.ep2[1] = newTVItem(t, db, lib, "video", "The Wire/Season 2/S02E01.mkv", "Episode 1", s2, 2, 1) return f } // finish marks episodes watched for a user. It replaces rather than inserts, so // a test can mark progressively without tripping the progress primary key. func (f tvFixture) finish(t *testing.T, db *sql.DB, u auth.User, eps ...int64) { t.Helper() for _, ep := range eps { var fileID int64 if err := db.QueryRow(`SELECT id FROM media_files WHERE item_id = ?`, ep).Scan(&fileID); err != nil { t.Fatalf("file for episode %d: %v", ep, err) } if _, err := db.Exec(`INSERT OR REPLACE INTO progress (user_id, item_id, file_id, kind, percent, finished) VALUES (?, ?, ?, 'video', 1, 1)`, u.ID, ep, fileID); err != nil { t.Fatalf("mark episode %d finished: %v", ep, err) } } } // episodeNumbers projects a list of items down to their episode numbers. func episodeNumbers(items []Item) []int { out := make([]int, len(items)) for i, it := range items { out[i] = it.EpisodeNumber } return out } func seasonNumbers(items []Item) []int { out := make([]int, len(items)) for i, it := range items { out[i] = it.SeasonNumber } return out } func TestLoadReturnsCodecFacts(t *testing.T) { db := testDB(t) lib := newLibrary(t, db, "A") itemID := newItem(t, db, lib, "Thing") it, err := LoadUnchecked(context.Background(), db, itemID) if err != nil { t.Fatal(err) } if it.Title != "Thing" || it.LibraryID != lib || it.Kind != "ebooks" { t.Errorf("unexpected item: %+v", it) } if it.RootPath != "/media/A" { t.Errorf("RootPath = %q", it.RootPath) } if it.AbsPath() != "/media/A/Thing.epub" { t.Errorf("AbsPath = %q", it.AbsPath()) } if it.ModTime.IsZero() { t.Error("ModTime not parsed from the file's mtime") } files, err := Files(context.Background(), db, itemID) if err != nil { t.Fatal(err) } if len(files) != 1 { t.Fatalf("files = %d, want 1", len(files)) } f := files[0] if f.VCodec != "h264" || f.ACodec != "aac" || f.Width != 640 || f.DurationMS != 5000 { t.Errorf("codec facts lost: %+v", f) } if f.Missing { t.Error("a present file reported as missing") } } // The security property: a missing item and a forbidden item must be // INDISTINGUISHABLE, or a 403 would leak the existence of another user's // library. func TestLoadForForbiddenIsIndistinguishableFromAbsent(t *testing.T) { db := testDB(t) ctx := context.Background() lib := newLibrary(t, db, "Secret") itemID := newItem(t, db, lib, "Hidden") outsider := newUser(t, db, "outsider", false) granted := newUser(t, db, "granted", false) grant(t, db, granted, lib) // Forbidden: the item exists but this user has no grant. _, errForbidden := LoadFor(ctx, db, outsider, itemID) // Absent: no such row at all. _, errAbsent := LoadFor(ctx, db, outsider, itemID+9999) if !errors.Is(errForbidden, ErrNotFound) { t.Errorf("forbidden error = %v, want ErrNotFound", errForbidden) } if !errors.Is(errAbsent, ErrNotFound) { t.Errorf("absent error = %v, want ErrNotFound", errAbsent) } if errForbidden.Error() != errAbsent.Error() { t.Errorf("errors differ and would leak existence: %q vs %q", errForbidden, errAbsent) } // Positive control: a granted user does get it. if _, err := LoadFor(ctx, db, granted, itemID); err != nil { t.Errorf("granted user denied: %v", err) } } func TestAdminBypassesGrants(t *testing.T) { db := testDB(t) ctx := context.Background() lib := newLibrary(t, db, "Any") itemID := newItem(t, db, lib, "Thing") admin := newUser(t, db, "root", true) if _, err := LoadFor(ctx, db, admin, itemID); err != nil { t.Errorf("admin denied: %v", err) } } func TestFileByIDIsScopedToItsItem(t *testing.T) { db := testDB(t) ctx := context.Background() lib := newLibrary(t, db, "A") first := newItem(t, db, lib, "One") second := newItem(t, db, lib, "Two") other, err := FirstFile(ctx, db, second) if err != nil { t.Fatal(err) } // Asking for item Two's file through item One must not resolve. if _, err := FileByID(ctx, db, first, other.ID); !errors.Is(err, ErrNotFound) { t.Errorf("file id leaked across items: err = %v, want ErrNotFound", err) } if _, err := FileByID(ctx, db, second, other.ID); err != nil { t.Errorf("legitimate lookup failed: %v", err) } } func TestFilesForRespectsGrants(t *testing.T) { db := testDB(t) ctx := context.Background() lib := newLibrary(t, db, "A") itemID := newItem(t, db, lib, "Thing") outsider := newUser(t, db, "outsider", false) if _, err := FilesFor(ctx, db, outsider, itemID); !errors.Is(err, ErrNotFound) { t.Errorf("FilesFor leaked to an ungranted user: %v", err) } } func TestFileCount(t *testing.T) { db := testDB(t) lib := newLibrary(t, db, "A") itemID := newItem(t, db, lib, "Thing") n, err := FileCount(context.Background(), db, itemID) if err != nil { t.Fatal(err) } if n != 1 { t.Errorf("FileCount = %d, want 1", n) } } // ── the television tree ───────────────────────────────────────────────── // Episodes come back in EPISODE order, not in the order the scanner happened to // insert them and not in the order their paths sort. Both of those are wrong in // a way that only shows up on a real library: a rescan upserts by path, and // "S01E10" sorts before "S01E2" as text. Episode 10 must come after episode 3. func TestEpisodesAreInEpisodeOrderNotInsertionOrder(t *testing.T) { db := testDB(t) ctx := context.Background() f := newTVLibrary(t, db) u := newUser(t, db, "watcher", false) grant(t, db, u, 1) seasons, err := New(db).Seasons(ctx, u, f.series) if err != nil { t.Fatalf("Seasons: %v", err) } if got := seasonNumbers(seasons); len(got) != 2 || got[0] != 1 || got[1] != 2 { t.Errorf("Seasons = %v, want [1 2]", got) } eps, err := New(db).Episodes(ctx, u, f.seasons[0]) if err != nil { t.Fatalf("Episodes: %v", err) } want := []int64{f.ep1[1], f.ep1[2], f.ep1[3], f.ep1[10]} got := make([]int64, len(eps)) for i, ep := range eps { got[i] = ep.ID } if !slices.Equal(got, want) { t.Fatalf("episodes = %v, want %v (episode order, not insertion order)", episodeNumbers(eps), []int{1, 2, 3, 10}) } if slices.IsSorted(got) { t.Fatal("the fixture no longer tests order: the expected order is the insertion order") } if eps[0].SeasonNumber != 1 || eps[0].Path != "The Wire/Season 1/S01E01.mkv" { t.Errorf("first episode = %+v, want S01E01 of season 1", eps[0]) } } // NextEpisode is the first episode the caller has not finished, in order, and // it rolls from a finished season into the next one. func TestNextEpisodeIsTheFirstUnfinished(t *testing.T) { db := testDB(t) ctx := context.Background() f := newTVLibrary(t, db) store := New(db) u := newUser(t, db, "watcher", false) grant(t, db, u, 1) next, err := store.NextEpisode(ctx, u, f.series) if err != nil { t.Fatalf("NextEpisode: %v", err) } if next.EpisodeNumber != 1 || next.Path != "The Wire/Season 1/S01E01.mkv" { t.Fatalf("NextEpisode = %+v, want S01E01", next) } finish := func(eps ...int64) { f.finish(t, db, u, eps...) } // A half-watched episode is still the next one: it is what they are in. finish(f.ep1[1]) if next, err = store.NextEpisode(ctx, u, f.series); err != nil || next.EpisodeNumber != 2 { t.Errorf("NextEpisode after finishing E01 = %+v, %v; want episode 2", next, err) } // Season 1 done, so the next unwatched one is season 2's. finish(f.ep1[2], f.ep1[3], f.ep1[10]) if next, err = store.NextEpisode(ctx, u, f.series); err != nil || next.SeasonNumber != 2 { t.Errorf("NextEpisode after finishing season 1 = %+v, %v; want season 2", next, err) } finish(f.ep2[1]) if _, err := store.NextEpisode(ctx, u, f.series); !errors.Is(err, ErrNotFound) { t.Errorf("a fully watched series: err = %v, want ErrNotFound", err) } } // The security property, for the television tree specifically: these read // CHILDREN of a parent the caller never named, so the grant has to be applied // before the walk. A caller with no grant gets nothing at all, and the error is // byte-identical to the one for a parent that does not exist. func TestTVTreeReadsAreGrantCheckedAndIndistinguishable(t *testing.T) { db := testDB(t) ctx := context.Background() f := newTVLibrary(t, db) store := New(db) outsider := newUser(t, db, "outsider", false) granted := newUser(t, db, "granted", false) grant(t, db, granted, 1) type read struct { name string id int64 fn func(auth.User, int64) error } // list wraps a list read so every read reports the same way: the error, plus // a failure if rows came back at all. list := func(name string, s func(context.Context, auth.User, int64) ([]Item, error)) func(auth.User, int64) error { return func(u auth.User, id int64) error { items, err := s(ctx, u, id) if err == nil && len(items) > 0 { t.Errorf("%s returned %d rows to an ungranted caller", name, len(items)) } return err } } reads := []read{ {"Seasons", f.series, list("Seasons", store.Seasons)}, {"Episodes", f.seasons[0], list("Episodes", store.Episodes)}, {"NextEpisode", f.series, func(u auth.User, id int64) error { _, err := store.NextEpisode(ctx, u, id) return err }}, } for _, r := range reads { // Forbidden: the parent exists, this user has no grant on its library. ferr := r.fn(outsider, r.id) // Absent: no such parent at all. aerr := r.fn(outsider, r.id+9999) if !errors.Is(ferr, ErrNotFound) { t.Errorf("%s forbidden: err = %v, want ErrNotFound", r.name, ferr) } if !errors.Is(aerr, ErrNotFound) { t.Errorf("%s absent: err = %v, want ErrNotFound", r.name, aerr) } if ferr.Error() != aerr.Error() { t.Errorf("%s: errors differ and would leak existence: %q vs %q", r.name, ferr, aerr) } } // Positive control: the granted user reads the same tree. seasons, err := store.Seasons(ctx, granted, f.series) if err != nil || len(seasons) != 2 { t.Fatalf("granted Seasons = %v, %v; want 2 seasons", seasons, err) } if _, err := store.Episodes(ctx, granted, f.seasons[1]); err != nil { t.Errorf("granted Episodes: %v", err) } if _, err := store.NextEpisode(ctx, granted, f.series); err != nil { t.Errorf("granted NextEpisode: %v", err) } }