package playback import ( "context" "database/sql" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strconv" "strings" "testing" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/internal/engine" "github.com/mithral/vellum/migrations" ) func testDB(t *testing.T) *sql.DB { t.Helper() db, err := core.OpenDB(filepath.Join(t.TempDir(), "playback.db")) if err != nil { t.Fatalf("OpenDB: %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 } func mustExec(t *testing.T, db *sql.DB, q string, args ...any) { t.Helper() if _, err := db.ExecContext(context.Background(), q, args...); err != nil { t.Fatalf("exec %q: %v", q, err) } } func mustID(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 } func newHandlers(t *testing.T, db *sql.DB) *Handlers { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) a := auth.NewAuthenticator(db, "vellum_session", 0, false, log) return NewHandlers(db, log, a) } // fixture builds two libraries with one item each and a user granted only the // first. The item in the second library must be invisible. type fixture struct { db *sql.DB libA int64 libB int64 userA int64 itemA int64 // mp4 h264 aac, readable itemB int64 // mp4 hevc aac, NOT readable } func setup(t *testing.T) fixture { t.Helper() db := testDB(t) f := fixture{db: db} f.libA = mustID(t, db, `INSERT INTO libraries (name, kind, root_path) VALUES ('A','video','/a') RETURNING id`) f.libB = mustID(t, db, `INSERT INTO libraries (name, kind, root_path) VALUES ('B','video','/b') RETURNING id`) f.userA = mustID(t, db, `INSERT INTO users (username, password_hash) VALUES ('alice','x') RETURNING id`) mustExec(t, db, `INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`, f.userA, f.libA) f.itemA = mustID(t, db, `INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'video', 'a.mp4', 'A') RETURNING id`, f.libA) mustExec(t, db, `INSERT INTO media_files (item_id, path, size, container, vcodec, acodec) VALUES (?, 'a.mp4', 1, 'mp4', 'h264', 'aac')`, f.itemA) f.itemB = mustID(t, db, `INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'video', 'b.mp4', 'B') RETURNING id`, f.libB) mustExec(t, db, `INSERT INTO media_files (item_id, path, size, container, vcodec, acodec) VALUES (?, 'b.mp4', 1, 'mp4', 'hevc', 'aac')`, f.itemB) return f } func resolve(t *testing.T, h *Handlers, u auth.User, body string) (int, engine.Resolution) { t.Helper() req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/resolve", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.handleResolve(rec, req, u) var res engine.Resolution if rec.Code == http.StatusOK { if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil { t.Fatalf("decode resolution: %v (body %s)", err, rec.Body.String()) } } return rec.Code, res } func TestEnginesEndpoint(t *testing.T) { f := setup(t) h := newHandlers(t, f.db) req := httptest.NewRequest(http.MethodGet, "/api/v1/playback/engines", nil) rec := httptest.NewRecorder() h.handleEngines(rec, req, auth.User{ID: f.userA}) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String()) } var body struct { Engines []engine.Engine `json:"engines"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode engines: %v", err) } got := map[string]bool{} for _, e := range body.Engines { got[e.ID] = true } for _, id := range []string{"html5", "mpv", "vlc", "mdk"} { if !got[id] { t.Errorf("engines response is missing %q: %s", id, rec.Body.String()) } } } func TestResolvePlayableAndNot(t *testing.T) { f := setup(t) h := newHandlers(t, f.db) u := auth.User{ID: f.userA} code, res := resolve(t, h, u, `{"item_id":`+itoa(f.itemA)+`,"engines":["html5"]}`) if code != http.StatusOK || !res.Playable || res.Engine == nil || *res.Engine != "html5" { t.Fatalf("h264 resolve = %d %+v, want playable html5", code, res) } // The readable item's facts are h264, so to test HEVC we point at itemB but // grant it first — scoping is tested separately. mustExec(t, f.db, `INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`, f.userA, f.libB) code, res = resolve(t, h, u, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5"]}`) if code != http.StatusOK || res.Playable { t.Fatalf("hevc resolve = %d %+v, want not playable", code, res) } if res.Reason != "vcodec:hevc" { t.Errorf("reason = %q, want vcodec:hevc", res.Reason) } if !strings.Contains(res.Message, "HEVC") { t.Errorf("message = %q, want it to name HEVC", res.Message) } // A native engine changes the answer. code, res = resolve(t, h, u, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5","mpv"]}`) if code != http.StatusOK || !res.Playable || res.Engine == nil || *res.Engine != "mpv" { t.Fatalf("hevc+mpv resolve = %d %+v, want playable mpv", code, res) } } // TestResolveGrantScoping pins the chokepoint: an item in a library the caller // cannot read must be a 404, indistinguishable from a missing item. func TestResolveGrantScoping(t *testing.T) { f := setup(t) h := newHandlers(t, f.db) // userA may read libA only; itemB lives in libB. code, _ := resolve(t, h, auth.User{ID: f.userA}, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5"]}`) if code != http.StatusNotFound { t.Errorf("inaccessible item status = %d, want 404", code) } // A user with no grants gets the same 404 for a readable-by-someone item. none := mustID(t, f.db, `INSERT INTO users (username, password_hash) VALUES ('carol','x') RETURNING id`) code, _ = resolve(t, h, auth.User{ID: none}, `{"item_id":`+itoa(f.itemA)+`,"engines":["html5"]}`) if code != http.StatusNotFound { t.Errorf("no-grant item status = %d, want 404", code) } // A nonexistent item is also a 404, not a leak. code, _ = resolve(t, h, auth.User{ID: f.userA}, `{"item_id":99999,"engines":["html5"]}`) if code != http.StatusNotFound { t.Errorf("missing item status = %d, want 404", code) } } func TestResolveValidation(t *testing.T) { f := setup(t) h := newHandlers(t, f.db) u := auth.User{ID: f.userA} for _, body := range []string{ `{"item_id":0,"engines":["html5"]}`, `{"item_id":-3,"engines":["html5"]}`, } { if code, _ := resolve(t, h, u, body); code != http.StatusBadRequest { t.Errorf("body %s status = %d, want 400", body, code) } } // Missing engines is allowed: the handler falls back to the html5 baseline. if code, res := resolve(t, h, u, `{"item_id":`+itoa(f.itemA)+`}`); code != http.StatusOK || !res.Playable { t.Errorf("missing engines = %d %+v, want playable fallback", code, res) } } // TestRegisterRequiresAuth proves the routes are mounted behind the auth // middleware: an unauthenticated request is rejected with 401. func TestRegisterRequiresAuth(t *testing.T) { f := setup(t) h := newHandlers(t, f.db) mux := http.NewServeMux() h.Register(mux) for _, tc := range []struct{ method, path, body string }{ {http.MethodGet, "/api/v1/playback/engines", ""}, {http.MethodPost, "/api/v1/playback/resolve", `{"item_id":1,"engines":["html5"]}`}, } { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) if tc.body != "" { req.Header.Set("Content-Type", "application/json") } rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("%s %s status = %d, want 401", tc.method, tc.path, rec.Code) } } } func itoa(n int64) string { return strconv.FormatInt(n, 10) }