package media import ( "database/sql" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/mithral/vellum/internal/auth" ) // subtitleFixture builds a library rooted at a real temp dir, so a sidecar // subtitle can be written to disk and read back through the route, plus a second // item in a library the caller may not read. type subtitleFixture struct { h *Handlers db *sql.DB root string user int64 item int64 forbidden int64 } func newSubtitleFixture(t *testing.T) subtitleFixture { t.Helper() db := testDB(t) root := t.TempDir() lib := newLibrary(t, db, "Video", "video", root) other := newLibrary(t, db, "Other", "video", t.TempDir()) f := subtitleFixture{db: db, root: root} 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) return f } // addTrack writes a sidecar file and the subtitles row that points at it. func (f subtitleFixture) addTrack(t *testing.T, name, content, format, lang string) int64 { t.Helper() if err := os.WriteFile(filepath.Join(f.root, name), []byte(content), 0o640); err != nil { t.Fatalf("write %s: %v", name, err) } return mustQueryID(t, f.db, ` INSERT INTO subtitles (item_id, source, path, stream_index, format, language, label) VALUES (?, 'sidecar', ?, -1, ?, ?, ?) RETURNING id`, f.item, name, format, lang, name) } func listSubtitleTracks(t *testing.T, h *Handlers, u auth.User, itemID int64) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/v1/items/1/subtitles", nil) req.SetPathValue("id", itoa(itemID)) rec := httptest.NewRecorder() h.handleSubtitles(rec, req, u) return rec } func getSubtitleTrack(t *testing.T, h *Handlers, u auth.User, itemID, trackID int64) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/v1/items/1/subtitles/1", nil) req.SetPathValue("id", itoa(itemID)) req.SetPathValue("trackID", itoa(trackID)) rec := httptest.NewRecorder() h.handleSubtitleTrack(rec, req, u) return rec } // TestSubtitleSRTConvertsToVTT pins the SubRip rewrite: the WebVTT header, a dot // in every timestamp, and the subtitle text left alone. func TestSubtitleSRTConvertsToVTT(t *testing.T) { f := newSubtitleFixture(t) srt := "1\n00:00:01,000 --> 00:00:04,000\nHello, world\n\n2\n00:00:05,500 --> 00:00:06,000\nBye\n" id := f.addTrack(t, "movie.en.srt", srt, "srt", "en") rec := getSubtitleTrack(t, f.h, auth.User{ID: f.user}, f.item, id) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } if ct := rec.Header().Get("Content-Type"); ct != "text/vtt; charset=utf-8" { t.Errorf("content-type = %q, want text/vtt; charset=utf-8", ct) } body := rec.Body.String() if !strings.HasPrefix(body, "WEBVTT\n\n") { t.Errorf("body does not start with WEBVTT header: %q", body) } if !strings.Contains(body, "00:00:01.000 --> 00:00:04.000") || !strings.Contains(body, "00:00:05.500 --> 00:00:06.000") { t.Errorf("timestamps not dot-separated: %q", body) } if strings.Contains(body, ",000") || strings.Contains(body, ",500") { t.Errorf("a comma survived in a timestamp: %q", body) } if !strings.Contains(body, "Hello, world") { t.Errorf("a comma in the subtitle text was rewritten: %q", body) } } // TestSubtitleVTTPassesThrough pins that an already-WebVTT sidecar is served // byte for byte, with no header prepended and no timestamp touched. func TestSubtitleVTTPassesThrough(t *testing.T) { f := newSubtitleFixture(t) vtt := "WEBVTT\n\n00:00:01.000 --> 00:00:04.000\nHi there\n" id := f.addTrack(t, "movie.en.vtt", vtt, "vtt", "en") rec := getSubtitleTrack(t, f.h, auth.User{ID: f.user}, f.item, id) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } if got := rec.Body.String(); got != vtt { t.Errorf("body = %q, want unchanged %q", got, vtt) } if ct := rec.Header().Get("Content-Type"); ct != "text/vtt; charset=utf-8" { t.Errorf("content-type = %q, want text/vtt; charset=utf-8", ct) } } // TestSubtitleRoutesAreGrantScoped pins the access chokepoint on both routes: an // item in a library the caller cannot read must answer with the same 404, byte // for byte, as an item that does not exist. func TestSubtitleRoutesAreGrantScoped(t *testing.T) { f := newSubtitleFixture(t) u := auth.User{ID: f.user} // The readable item lists, empty, with a 200. if rec := listSubtitleTracks(t, f.h, u, f.item); rec.Code != http.StatusOK { t.Errorf("readable list status = %d, want 200 (body %s)", rec.Code, rec.Body.String()) } forbidden := listSubtitleTracks(t, f.h, u, f.forbidden) absent := listSubtitleTracks(t, f.h, u, 999999) if forbidden.Code != http.StatusNotFound || absent.Code != http.StatusNotFound { t.Fatalf("list statuses = %d/%d, want 404/404", forbidden.Code, absent.Code) } if forbidden.Body.String() != absent.Body.String() { t.Errorf("forbidden body %q != absent body %q", forbidden.Body.String(), absent.Body.String()) } // The track route leaks nothing either: the item is rejected before the // track id is ever looked at. fb := getSubtitleTrack(t, f.h, u, f.forbidden, 1) ab := getSubtitleTrack(t, f.h, u, 999999, 1) if fb.Code != http.StatusNotFound || ab.Code != http.StatusNotFound { t.Fatalf("track statuses = %d/%d, want 404/404", fb.Code, ab.Code) } if fb.Body.String() != ab.Body.String() { t.Errorf("track forbidden body %q != absent body %q", fb.Body.String(), ab.Body.String()) } // A user with no grants at all gets the same 404 on the readable item. none := listSubtitleTracks(t, f.h, auth.User{ID: newUser(t, f.db, "nobody")}, f.item) if none.Code != http.StatusNotFound { t.Errorf("no-grant list status = %d, want 404", none.Code) } }