package media import ( "database/sql" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/transcode" ) // fakeSessions stands in for *transcode.Manager: it records Start calls and // hands back a session rooted at a directory the test controls, with no // hardware and no ffmpeg anywhere. type fakeSessions struct { dir string startErr error starts int } func (f *fakeSessions) Start(itemID int64, inputPath, cacheDir string) (transcode.Session, error) { f.starts++ if f.startErr != nil { return nil, f.startErr } return fakeSession{dir: f.dir}, nil } type fakeSession struct{ dir string } func (s fakeSession) PlaylistPath() string { return filepath.Join(s.dir, "playlist.m3u8") } func (s fakeSession) Dir() string { return s.dir } func (s fakeSession) Close() error { return nil } // transcodeFixture is a readable video item, an item in a library the caller may // not read, and a granted user. type transcodeFixture struct { h *Handlers db *sql.DB base string fake *fakeSessions user int64 item int64 forbidden int64 } func newTranscodeFixture(t *testing.T) transcodeFixture { t.Helper() db := testDB(t) lib := newLibrary(t, db, "Video", "video", t.TempDir()) other := newLibrary(t, db, "Other", "video", t.TempDir()) // The session dir sits two levels below base, so "../../etc/passwd" from it // resolves to base/etc/passwd — a file a traversal test can actually create. base := t.TempDir() dir := filepath.Join(base, "transcode", "1-1") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir session: %v", err) } f := transcodeFixture{db: db, base: base, fake: &fakeSessions{dir: dir}} f.user = newUser(t, db, "watcher") grantRead(t, db, f.user, lib) const at = "2026-01-01T00:00:00.000Z" f.item = newItem(t, db, lib, "video", "movie.mkv", "Movie", "movie", false, at) f.forbidden = newItem(t, db, other, "video", "secret.mkv", "Secret", "secret", false, at) f.h = mediaHandlers(t, db) f.h.sessions = f.fake return f } func getPlaylist(t *testing.T, h *Handlers, u auth.User, itemID int64) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/v1/items/1/transcode/playlist.m3u8", nil) req.SetPathValue("id", itoa(itemID)) rec := httptest.NewRecorder() h.handleTranscodePlaylist(rec, req, u) return rec } func getSegment(t *testing.T, h *Handlers, u auth.User, itemID int64, segment string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/v1/items/1/transcode/"+segment, nil) req.SetPathValue("id", itoa(itemID)) req.SetPathValue("segment", segment) rec := httptest.NewRecorder() h.handleTranscodeSegment(rec, req, u) return rec } // TestTranscodePlaylistStartsAndServes pins the lazy start: the request begins // the session and returns the playlist bytes exactly as they are on disk. func TestTranscodePlaylistStartsAndServes(t *testing.T) { f := newTranscodeFixture(t) const playlist = "#EXTM3U\n#EXT-X-VERSION:3\n#EXTINF:6.0,\nseg00001.ts\n" if err := os.WriteFile(filepath.Join(f.fake.dir, "playlist.m3u8"), []byte(playlist), 0o640); err != nil { t.Fatalf("write playlist: %v", err) } rec := getPlaylist(t, f.h, auth.User{ID: f.user}, f.item) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } if ct := rec.Header().Get("Content-Type"); ct != "application/vnd.apple.mpegurl" { t.Errorf("content-type = %q, want application/vnd.apple.mpegurl", ct) } if got := rec.Body.String(); got != playlist { t.Errorf("body = %q, want %q", got, playlist) } if f.fake.starts != 1 { t.Errorf("Start calls = %d, want 1", f.fake.starts) } // A second request touches the session again, so the idle reaper leaves a // stream being watched alone. _ = getPlaylist(t, f.h, auth.User{ID: f.user}, f.item) if f.fake.starts != 2 { t.Errorf("Start calls after second request = %d, want 2", f.fake.starts) } } // TestTranscodeSegmentServedAndTraversalRefused pins the segment name check: the // exact shape ffmpeg writes is served, and everything else — a traversal above // all — is a 404. func TestTranscodeSegmentServedAndTraversalRefused(t *testing.T) { f := newTranscodeFixture(t) const segment = "\x47@\x00\x10payload" if err := os.WriteFile(filepath.Join(f.fake.dir, "seg00001.ts"), []byte(segment), 0o640); err != nil { t.Fatalf("write segment: %v", err) } rec := getSegment(t, f.h, auth.User{ID: f.user}, f.item, "seg00001.ts") if rec.Code != http.StatusOK { t.Fatalf("valid segment status = %d, body=%s", rec.Code, rec.Body.String()) } if ct := rec.Header().Get("Content-Type"); ct != "video/mp2t" { t.Errorf("content-type = %q, want video/mp2t", ct) } if got := rec.Body.String(); got != segment { t.Errorf("body = %q, want %q", got, segment) } for _, name := range []string{ "../../etc/passwd", "..%2f..%2fetc%2fpasswd", "seg1.ts", "seg0001.ts", "seg000001.ts", "seg00001.ts.bak", "playlist.m3u8", "", } { if rec := getSegment(t, f.h, auth.User{ID: f.user}, f.item, name); rec.Code != http.StatusNotFound { t.Errorf("segment %q status = %d, want 404", name, rec.Code) } } } // TestTranscodeSegmentTraversalCannotEscapeOnce pins the traversal case with a // file that genuinely exists at the joined path: without the name check the // handler would serve it, so a green run proves the check is load-bearing. func TestTranscodeSegmentTraversalCannotEscapeOnce(t *testing.T) { f := newTranscodeFixture(t) if err := os.MkdirAll(filepath.Join(f.base, "etc"), 0o755); err != nil { t.Fatalf("mkdir etc: %v", err) } if err := os.WriteFile(filepath.Join(f.base, "etc", "passwd"), []byte("SECRET"), 0o640); err != nil { t.Fatalf("write passwd: %v", err) } rec := getSegment(t, f.h, auth.User{ID: f.user}, f.item, "../../etc/passwd") if rec.Code != http.StatusNotFound { t.Fatalf("traversal status = %d, want 404 (body=%s)", rec.Code, rec.Body.String()) } if rec.Body.String() == "SECRET" { t.Fatal("traversal served a file outside the session directory") } } // TestTranscodeRoutesAreGrantScoped pins the access chokepoint on both routes: // an item in a library the caller cannot read answers with the same 404, byte // for byte, as an item that does not exist — and does not start an encoder. func TestTranscodeRoutesAreGrantScoped(t *testing.T) { f := newTranscodeFixture(t) u := auth.User{ID: f.user} fb := getPlaylist(t, f.h, u, f.forbidden) ab := getPlaylist(t, f.h, u, 999999) if fb.Code != http.StatusNotFound || ab.Code != http.StatusNotFound { t.Fatalf("playlist statuses = %d/%d, want 404/404", fb.Code, ab.Code) } if fb.Body.String() != ab.Body.String() { t.Errorf("playlist forbidden body %q != absent body %q", fb.Body.String(), ab.Body.String()) } fs := getSegment(t, f.h, u, f.forbidden, "seg00001.ts") as := getSegment(t, f.h, u, 999999, "seg00001.ts") if fs.Code != http.StatusNotFound || as.Code != http.StatusNotFound { t.Fatalf("segment statuses = %d/%d, want 404/404", fs.Code, as.Code) } if fs.Body.String() != as.Body.String() { t.Errorf("segment forbidden body %q != absent body %q", fs.Body.String(), as.Body.String()) } // The item is rejected before Start, so a forbidden request never pins the // GPU. if f.fake.starts != 0 { t.Errorf("Start calls on forbidden requests = %d, want 0", f.fake.starts) } none := getPlaylist(t, f.h, auth.User{ID: newUser(t, f.db, "nobody")}, f.item) if none.Code != http.StatusNotFound { t.Errorf("no-grant status = %d, want 404", none.Code) } } // TestTranscodeRefusalsAreNot500 pins that a host with no encoder and a full // server both get a clear 503 rather than a 500. func TestTranscodeRefusalsAreNot500(t *testing.T) { for _, err := range []error{transcode.ErrUnavailable, transcode.ErrAtCapacity, transcode.ErrShutdown} { f := newTranscodeFixture(t) f.fake.startErr = err rec := getPlaylist(t, f.h, auth.User{ID: f.user}, f.item) if rec.Code != http.StatusServiceUnavailable { t.Errorf("start error %v status = %d, want 503", err, rec.Code) } } }