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.
234 lines
8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|