Vellum/internal/media/transcodes_test.go
mithral 5ea7ffe6c6 Serve transcoded streams over HLS
Third step of ADR-008. Two routes inside the item family: the playlist starts
the session lazily and serves what ffmpeg has written so far, so playback can
begin while the rest still encodes; the segment route serves one .ts.

Segment names are validated against the exact shape ffmpeg writes before being
used as a path. Verified red-green: removing the check serves a file from
outside the session directory with a 200.

Two corrections made in review.

1. The manager was being built inside NewHandlers, which meant nothing could
   ever shut it down. Its reaper would run for the process lifetime and a
   running ffmpeg would outlive the server that started it. It is now created
   in main.go beside the job queue and deferred, and passed in - the same
   ownership the queue already has.

2. A playlist request could 404 before ffmpeg wrote the file, which a native
   HLS player treats as fatal rather than retrying. The playlist handler now
   waits up to four seconds for the first write. Segments are never waited for.

Verified end-to-end against a running server with real hardware: a 30-second
file produced a valid EVENT playlist in about a second (the wait doing its
job - no 404), a 658 KB segment with Content-Type video/mp2t, a 404 for a
traversal attempt, and a 401 unauthenticated.

Also confirmed at boot: all four metadata providers report their disabled
state and name their env var, including the new adult one.
2026-10-12 12:00:20 -04:00

234 lines
8 KiB
Go

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)
}
}
}