package media import ( "database/sql" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "github.com/mithral/vellum/internal/auth" ) // These cover the television tree on the canonical API: /api/v1/items/{id}/ // seasons, /episodes and /next. The grant half matters most — these are the only // routes that read rows the caller never named. type tvTree struct { h *Handlers db *sql.DB user int64 outsider int64 series int64 seasons []int64 // season 1, season 2 ep1 map[int]int64 ep2 int64 } // newTVTree writes library -> series -> season 1 (episodes) -> season 2, with the // children inserted in a useless order, so an ordering bug cannot hide behind a // lucky insertion sequence. func newTVTree(t *testing.T) tvTree { t.Helper() db := testDB(t) f := tvTree{db: db, ep1: map[int]int64{}} lib := newLibrary(t, db, "TV", "video", "/tv") f.user = newUser(t, db, "tv-watcher") f.outsider = newUser(t, db, "tv-outsider") grantRead(t, db, f.user, lib) // row writes one node of the tree the way the scanner writes it: a series or // a season carries no file, an episode is kind 'video' with both numbers, // and a zero number is stored as NULL. row := func(kind, path, title string, parent int64, season, episode int) int64 { var parentArg, seasonArg, episodeArg any if parent != 0 { parentArg = parent } if season > 0 { seasonArg = season } if episode > 0 { episodeArg = episode } id := mustQueryID(t, db, ` INSERT INTO media_items (library_id, kind, path, parent_id, title, sort_title, season_number, episode_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, lib, kind, path, parentArg, title, title, seasonArg, episodeArg) if kind == "video" { mustExec(t, db, `INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`, id, path) } return id } f.series = row("series", "The Wire", "The Wire", 0, 0, 0) // Season 2 is written first and still has to come back second. s2 := row("season", "The Wire/Season 2", "Season 2", f.series, 2, 0) s1 := row("season", "The Wire/Season 1", "Season 1", f.series, 1, 0) f.seasons = []int64{s1, s2} for _, n := range []int{3, 1, 10, 2} { path := fmt.Sprintf("The Wire/Season 1/S01E%02d.mkv", n) f.ep1[n] = row("video", path, fmt.Sprintf("Episode %d", n), s1, 1, n) } f.ep2 = row("video", "The Wire/Season 2/S02E01.mkv", "Episode 1", s2, 2, 1) f.h = mediaHandlers(t, db) return f } // get drives one of the three television routes. PathValue is normally filled by // the ServeMux; the test sets it explicitly, as getItem does. func (f tvTree) get(t *testing.T, u auth.User, id int64, route string) *httptest.ResponseRecorder { t.Helper() path := "/api/v1/items/" + itoa(id) + "/" + route req := httptest.NewRequest(http.MethodGet, path, nil) req.SetPathValue("id", itoa(id)) rec := httptest.NewRecorder() switch route { case "seasons": f.h.handleSeasons(rec, req, u) case "episodes": f.h.handleEpisodes(rec, req, u) case "next": f.h.handleNextEpisode(rec, req, u) default: t.Fatalf("unknown route %q", route) } return rec } // finish marks an episode watched for a user. func (f tvTree) finish(t *testing.T, itemID int64) { t.Helper() mustExec(t, f.db, `INSERT OR REPLACE INTO progress (user_id, item_id, file_id, kind, percent, finished) VALUES (?, ?, (SELECT id FROM media_files WHERE item_id = ?), 'video', 1, 1)`, f.user, itemID, itemID) } func decode(t *testing.T, rec *httptest.ResponseRecorder, key string, into *[]itemRow) { t.Helper() if rec.Code != http.StatusOK { t.Fatalf("status = %d (body %s)", rec.Code, rec.Body.String()) } envelope := map[string][]itemRow{key: nil} if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode %s: %v (body %s)", key, err, rec.Body.String()) } *into = envelope[key] } // A season's episodes come back in EPISODE order. They were inserted 3, 1, 10, 2, // so returning rows by id puts episode 3 first and returning them by path puts // episode 10 before episode 2. Only the episode NUMBER is right. func TestEpisodesRouteIsInEpisodeOrder(t *testing.T) { f := newTVTree(t) u := auth.User{ID: f.user} var seasons []itemRow decode(t, f.get(t, u, f.series, "seasons"), "seasons", &seasons) if len(seasons) != 2 { t.Fatalf("seasons = %d, want 2", len(seasons)) } if seasons[0].ID != f.seasons[0] || seasons[1].ID != f.seasons[1] { t.Fatalf("seasons = %v, want season 1 (%d) then season 2 (%d)", idsOf(seasons), f.seasons[0], f.seasons[1]) } if seasons[0].SeasonNumber != 1 || seasons[0].Kind != "season" { t.Errorf("season row = %+v, want kind season numbered 1", seasons[0]) } var eps []itemRow decode(t, f.get(t, u, f.seasons[0], "episodes"), "episodes", &eps) want := []int64{f.ep1[1], f.ep1[2], f.ep1[3], f.ep1[10]} if got := idsOf(eps); len(got) != len(want) { t.Fatalf("episodes = %v, want %v", got, want) } else { for i := range want { if got[i] != want[i] { t.Fatalf("episodes = %v, want %v (episode order, not insertion order)", got, want) } } } for _, ep := range eps { if ep.Kind != "video" || ep.SeasonNumber != 1 || ep.EpisodeNumber == 0 { t.Errorf("episode row = %+v, want a season-1 video numbered by episode", ep) } } } // The next unwatched episode is the first one, moves on as episodes finish, and // rolls into season 2 when season 1 is done — then reports nothing rather than // inventing a row. func TestNextEpisodeRoute(t *testing.T) { f := newTVTree(t) u := auth.User{ID: f.user} next := func() *itemRow { t.Helper() rec := f.get(t, u, f.series, "next") if rec.Code != http.StatusOK { t.Fatalf("next status = %d (body %s)", rec.Code, rec.Body.String()) } var out struct { Episode *itemRow `json:"episode"` } if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode next: %v (body %s)", err, rec.Body.String()) } return out.Episode } if ep := next(); ep == nil || ep.ID != f.ep1[1] { t.Fatalf("first next = %+v, want episode 1", ep) } f.finish(t, f.ep1[1]) if ep := next(); ep == nil || ep.ID != f.ep1[2] { t.Errorf("next after finishing episode 1 = %+v, want episode 2", ep) } for _, n := range []int{2, 3, 10} { f.finish(t, f.ep1[n]) } if ep := next(); ep == nil || ep.ID != f.ep2 || ep.SeasonNumber != 2 { t.Errorf("next with season 1 finished = %+v, want season 2's episode", ep) } f.finish(t, f.ep2) if ep := next(); ep != nil { t.Errorf("a fully watched series returned %+v, want a null episode", ep) } } // The security property on this surface: a caller with no grant gets NOTHING, and // the response is byte-identical to the one for an item that does not exist — a // 404 that says "item not found", never a 403 that would confirm somebody else's // series is there. func TestTVRoutesAreGrantScoped(t *testing.T) { f := newTVTree(t) outsider := auth.User{ID: f.outsider} for _, tc := range []struct { name string id int64 }{ {"series", f.series}, {"season", f.seasons[0]}, } { for _, route := range []string{"seasons", "episodes", "next"} { forbidden := f.get(t, outsider, tc.id, route) absent := f.get(t, outsider, tc.id+9999, route) if forbidden.Code != http.StatusNotFound { t.Errorf("%s/%s for an ungranted caller: status = %d, want 404", tc.name, route, forbidden.Code) } if forbidden.Body.String() != absent.Body.String() { t.Errorf("%s/%s: responses differ and leak existence:\n forbidden=%s\n absent=%s", tc.name, route, forbidden.Body.String(), absent.Body.String()) } } } // Positive control: the granted user reads the same tree. granted := auth.User{ID: f.user} if rec := f.get(t, granted, f.series, "seasons"); rec.Code != http.StatusOK { t.Errorf("granted seasons status = %d, want 200", rec.Code) } if rec := f.get(t, granted, f.seasons[1], "episodes"); rec.Code != http.StatusOK { t.Errorf("granted episodes status = %d, want 200", rec.Code) } if rec := f.get(t, granted, f.series, "next"); rec.Code != http.StatusOK { t.Errorf("granted next status = %d, want 200", rec.Code) } } func idsOf(rows []itemRow) []int64 { out := make([]int64, len(rows)) for i, r := range rows { out[i] = r.ID } return out }