Vellum/internal/jellyfin/jellyfin_test.go
mithral 608ac5c6d6 Close the Jellyfin test gap
internal/jellyfin is the most client-visible surface in the tree — real Jellyfin
apps parse its JSON — and it had zero test files. Phase 2 had just moved its
DTOs, so this closes the gap before the next refactor.

Coverage: internal/jellyfin 0.0% -> 35.7%, internal/jellyfin/dto -> 68.9%.

The tests, and why each exists:

- Credential forms. X-Emby-Token, X-MediaBrowser-Token, ?api_key= and
  Authorization: MediaBrowser Token="...". The query form matters most: <video>
  and <audio> elements cannot set headers, so a client that can only use
  ?api_key= must still authenticate.
- Grant scoping. A user with no grants sees zero items, and an item they cannot
  read returns a response BYTE-IDENTICAL to one that does not exist. A 403 would
  confirm the library is there and leak its existence. The test asserts the two
  responses are indistinguishable, not merely both non-200.
- The transcode refusal. A client that disables both direct play and direct
  stream is demanding a transcode, and gets ErrorCode NoCompatibleStream rather
  than a TranscodingUrl that would never resolve. The normal path is asserted to
  advertise SupportsTranscoding FALSE, because a client told otherwise waits
  forever for a stream that never starts.
- Streaming with ONLY ?api_key= plus a Range header returns 206 with a correct
  Content-Range and the right bytes, and a token cannot stream a file it cannot
  read.
- AuthenticateByName mints a token that then authenticates, and returns no token
  for bad credentials.
- DTO mapping: exact JSON key set, RunTimeTicks as ms x 10000, id round-trip,
  ImageTags only with a cover, and golden JSON for both the item and the media
  source.

Both halves are proven able to FAIL, because a test that cannot fail is
decoration:
  - flipping SupportsTranscoding to true fails TestMediaSourceNeverTranscodes
  - disabling the grant check in catalog.LoadFor fails
    TestGrantScopingIsIndistinguishableFromAbsent AND TestStreamRefusesUnreadableItem
Both were restored afterwards and the non-test files verified byte-unchanged.

Process note: the agent for this task failed twice (HTTP 400) and left a test
file that did not compile — a bad slices.Delete call and a helper it never
wrote. I fixed those, corrected one wrong expectation (a video item legitimately
carries MediaType; the test's want-list omitted it), and wrote the handler tests
myself since that was the half that never got reached.

gofmt clean, build exit 0, vet exit 0, 25 packages pass, svelte-check 0/0.
2026-10-11 16:10:58 -04:00

375 lines
13 KiB
Go

package jellyfin
import (
"context"
"database/sql"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mithral/vellum/internal/apitoken"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/jellyfin/dto"
"github.com/mithral/vellum/migrations"
)
// These tests exist because internal/jellyfin is the most client-visible surface
// in the tree — real Jellyfin apps parse its JSON — and it had no tests at all.
// The two that matter most are the grant check (a wrong one leaks another
// user's library) and the transcode flag (a client told transcoding is
// available waits forever for a stream that never starts).
type fixture struct {
srv *httptest.Server
db *sql.DB
itemID int64
fileID int64
libID int64
admin string // token
granted string // token, non-admin, granted the library
none string // token, non-admin, no grants
}
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "jellyfin.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 newFixture(t *testing.T) *fixture {
t.Helper()
db := testDB(t)
ctx := context.Background()
// A real file on disk: the stream handler opens it, so a row alone is not
// enough to prove the streaming path.
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "movie.mp4"), []byte("0123456789abcdefghij"), 0o644); err != nil {
t.Fatalf("write media: %v", err)
}
libID := mustExec(t, db, `INSERT INTO libraries (kind, name, root_path) VALUES ('video', 'Movies', ?)`, root)
itemID := mustExec(t, db, `
INSERT INTO media_items (library_id, kind, path, title, sort_title, year)
VALUES (?, 'video', 'movie.mp4', 'A Movie', 'a movie', 1999)`, libID)
fileID := mustExec(t, db, `
INSERT INTO media_files (item_id, path, size, mtime, container, duration_ms,
vcodec, acodec, width, height)
VALUES (?, 'movie.mp4', 20, '1700000000000000000', 'mp4', 5000, 'h264', 'aac', 640, 360)`, itemID)
// Three users: an admin, a non-admin granted the library, and a non-admin
// with no grants at all.
hash, err := auth.HashPassword("pw")
if err != nil {
t.Fatalf("hash: %v", err)
}
adminID := mustExec(t, db, `INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ('admin','Admin',?,1)`, hash)
grantedID := mustExec(t, db, `INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ('granted','Granted',?,0)`, hash)
noneID := mustExec(t, db, `INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ('none','None',?,0)`, hash)
mustExec(t, db, `INSERT INTO library_grants (user_id, library_id, can_read) VALUES (?, ?, 1)`, grantedID, libID)
token := func(userID int64) string {
secret, err := apitoken.Mint(ctx, db, userID, apitoken.KindJellyfin, "test")
if err != nil {
t.Fatalf("mint: %v", err)
}
return secret
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
authn := auth.NewAuthenticator(db, "vellum_session", time.Hour, false, log)
mux := http.NewServeMux()
NewHandlers(db, log, authn).Register(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return &fixture{
srv: srv, db: db, itemID: itemID, fileID: fileID, libID: libID,
admin: token(adminID), granted: token(grantedID), none: token(noneID),
}
}
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) int64 {
t.Helper()
res, err := db.Exec(q, args...)
if err != nil {
t.Fatalf("exec %s: %v", q, err)
}
id, _ := res.LastInsertId()
return id
}
// do issues a request with an optional token supplied the given way.
func (f *fixture) do(t *testing.T, method, path, headerName, token string) (*http.Response, string) {
t.Helper()
req, err := http.NewRequest(method, f.srv.URL+path, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
if headerName != "" && token != "" {
if headerName == "query" {
req.URL.RawQuery = "api_key=" + token
} else {
req.Header.Set(headerName, token)
}
}
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp, string(body)
}
// ── auth surfaces ───────────────────────────────────────────────────────
// All three credential forms must work. The query form matters most: <video>
// and <audio> elements cannot set headers, so a client that can only use
// ?api_key= must still authenticate.
func TestAuthAcceptsEveryCredentialForm(t *testing.T) {
f := newFixture(t)
for _, tc := range []struct{ name, header, token string }{
{"X-Emby-Token", "X-Emby-Token", f.admin},
{"X-MediaBrowser-Token", "X-MediaBrowser-Token", f.admin},
{"api_key query", "query", f.admin},
{"MediaBrowser header", "Authorization", `MediaBrowser Token="` + f.admin + `"`},
} {
resp, _ := f.do(t, "GET", "/Users/Me", tc.header, tc.token)
if resp.StatusCode != http.StatusOK {
t.Errorf("%s: status = %d, want 200", tc.name, resp.StatusCode)
}
}
}
func TestAuthRejectsMissingAndBadTokens(t *testing.T) {
f := newFixture(t)
for _, tc := range []struct{ name, header, token string }{
{"no token", "", ""},
{"garbage token", "X-Emby-Token", "deadbeef"},
} {
resp, _ := f.do(t, "GET", "/Users/Me", tc.header, tc.token)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s: status = %d, want 401", tc.name, resp.StatusCode)
}
}
}
// ── the security property ───────────────────────────────────────────────
// A user with no grants must see nothing, and an item they cannot read must be
// INDISTINGUISHABLE from one that does not exist. A 403 would confirm the
// library is there, which leaks its existence.
func TestGrantScopingIsIndistinguishableFromAbsent(t *testing.T) {
f := newFixture(t)
// Empty listing for a user with no grants.
resp, body := f.do(t, "GET", "/Items?Recursive=true", "X-Emby-Token", f.none)
if resp.StatusCode != http.StatusOK {
t.Fatalf("no-grant /Items status = %d", resp.StatusCode)
}
var listing struct{ TotalRecordCount int }
if err := json.Unmarshal([]byte(body), &listing); err != nil {
t.Fatalf("decode listing: %v (body %s)", err, body)
}
if listing.TotalRecordCount != 0 {
t.Errorf("no-grant user sees %d items, want 0", listing.TotalRecordCount)
}
// Forbidden item.
unreadable := dto.ItemID(f.itemID)
respForbidden, bodyForbidden := f.do(t, "GET", "/Items/"+unreadable, "X-Emby-Token", f.none)
// Absent item.
respAbsent, bodyAbsent := f.do(t, "GET", "/Items/"+dto.ItemID(999999), "X-Emby-Token", f.none)
if respForbidden.StatusCode != http.StatusNotFound {
t.Errorf("unreadable item status = %d, want 404", respForbidden.StatusCode)
}
if respForbidden.StatusCode != respAbsent.StatusCode {
t.Errorf("unreadable=%d absent=%d: the difference leaks existence",
respForbidden.StatusCode, respAbsent.StatusCode)
}
if bodyForbidden != bodyAbsent {
t.Errorf("bodies differ and leak existence:\n forbidden=%s\n absent=%s",
bodyForbidden, bodyAbsent)
}
// Positive control: the granted user does get it, and so does the admin.
for _, tc := range []struct{ name, token string }{{"granted", f.granted}, {"admin", f.admin}} {
resp, _ := f.do(t, "GET", "/Items/"+unreadable, "X-Emby-Token", tc.token)
if resp.StatusCode != http.StatusOK {
t.Errorf("%s user status = %d, want 200", tc.name, resp.StatusCode)
}
}
}
// ── the transcode refusal ───────────────────────────────────────────────
// Vellum never transcodes. A client that disables BOTH direct play and direct
// stream is asking for a transcode, and must be told so rather than handed a
// TranscodingUrl that will never resolve.
func TestTranscodeDemandIsRefused(t *testing.T) {
f := newFixture(t)
id := dto.ItemID(f.itemID)
resp, body := f.do(t, "GET",
"/Items/"+id+"/PlaybackInfo?EnableDirectPlay=false&EnableDirectStream=false",
"X-Emby-Token", f.admin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var pi struct {
ErrorCode string `json:"ErrorCode"`
MediaSources []struct {
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
SupportsDirectStream bool `json:"SupportsDirectStream"`
SupportsTranscoding bool `json:"SupportsTranscoding"`
} `json:"MediaSources"`
}
if err := json.Unmarshal([]byte(body), &pi); err != nil {
t.Fatalf("decode: %v (body %s)", err, body)
}
if pi.ErrorCode != "NoCompatibleStream" {
t.Errorf("ErrorCode = %q, want NoCompatibleStream", pi.ErrorCode)
}
// And the normal case must advertise direct play with transcoding OFF.
resp, body = f.do(t, "GET", "/Items/"+id+"/PlaybackInfo", "X-Emby-Token", f.admin)
if resp.StatusCode != http.StatusOK {
t.Fatalf("playbackinfo status = %d", resp.StatusCode)
}
if err := json.Unmarshal([]byte(body), &pi); err != nil {
t.Fatalf("decode: %v", err)
}
if len(pi.MediaSources) == 0 {
t.Fatal("no MediaSources")
}
ms := pi.MediaSources[0]
if !ms.SupportsDirectPlay || !ms.SupportsDirectStream {
t.Errorf("direct play/stream = %v/%v, want true/true", ms.SupportsDirectPlay, ms.SupportsDirectStream)
}
if ms.SupportsTranscoding {
t.Error("SupportsTranscoding is TRUE; this server cannot transcode and a client will wait forever")
}
}
// ── streaming ───────────────────────────────────────────────────────────
// The stream must work with ONLY ?api_key= (media elements cannot set headers)
// and must honour Range, or seeking breaks.
func TestStreamWithAPIKeyHonoursRange(t *testing.T) {
f := newFixture(t)
req, err := http.NewRequest("GET", f.srv.URL+"/Videos/"+dto.ItemID(f.itemID)+"/stream?api_key="+f.admin, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Range", "bytes=0-9")
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", resp.StatusCode)
}
if cr := resp.Header.Get("Content-Range"); cr == "" {
t.Error("no Content-Range on a 206")
}
got, _ := io.ReadAll(resp.Body)
if string(got) != "0123456789" {
t.Errorf("body = %q, want the first ten bytes", got)
}
}
// A token must not stream a file it cannot read.
func TestStreamRefusesUnreadableItem(t *testing.T) {
f := newFixture(t)
resp, _ := f.do(t, "GET", "/Videos/"+dto.ItemID(f.itemID)+"/stream", "X-Emby-Token", f.none)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("no-grant stream status = %d, want 404", resp.StatusCode)
}
}
// ── authenticate ────────────────────────────────────────────────────────
func TestAuthenticateByName(t *testing.T) {
f := newFixture(t)
// Good credentials mint a token that then works.
req, _ := http.NewRequest("POST", f.srv.URL+"/Users/AuthenticateByName",
strings.NewReader(`{"Username":"admin","Pw":"pw"}`))
req.Header.Set("Content-Type", "application/json")
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d body=%s", resp.StatusCode, body)
}
var out struct {
AccessToken string
User struct{ Name string }
}
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("decode: %v", err)
}
if out.AccessToken == "" {
t.Fatal("no AccessToken returned")
}
if out.User.Name == "" {
t.Error("no User in the response")
}
if resp, _ := f.do(t, "GET", "/Users/Me", "X-Emby-Token", out.AccessToken); resp.StatusCode != http.StatusOK {
t.Errorf("minted token does not authenticate: %d", resp.StatusCode)
}
// Bad credentials must not mint anything.
req, _ = http.NewRequest("POST", f.srv.URL+"/Users/AuthenticateByName",
strings.NewReader(`{"Username":"admin","Pw":"wrong"}`))
req.Header.Set("Content-Type", "application/json")
resp, err = f.srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
body, _ = io.ReadAll(resp.Body)
_ = resp.Body.Close()
if strings.Contains(string(body), "AccessToken") {
t.Errorf("bad credentials returned a token: %s", body)
}
}
// Discovery must work with no credential at all, or a client cannot find the
// server to log in to.
func TestSystemInfoPublicNeedsNoAuth(t *testing.T) {
f := newFixture(t)
resp, body := f.do(t, "GET", "/System/Info/Public", "", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var info struct{ ServerName, Version, ProductName string }
if err := json.Unmarshal([]byte(body), &info); err != nil {
t.Fatalf("decode: %v", err)
}
if info.ServerName == "" || info.Version == "" {
t.Errorf("incomplete discovery response: %s", body)
}
}