Vellum/internal/jellyfin/jellyfin_test.go
mithral 2bdfc6d139 Serve the Jellyfin surface under /emby as well as at the root
Many Jellyfin clients probe GET /emby/System/Info/Public during their
handshake. There was no /emby prefix anywhere in the tree, so that 404ed
and the client abandoned discovery and fell back to its manual "enter your
server URL" screen - the exact experience mDNS exists to remove.

Register installs every pattern twice through a mount helper, with the same
handler value both times, so the alias cannot drift from the root surface.
The JSON 404 fallbacks get /emby forms too, plus one subtree handler,
because static.go's isAPIPath knows only /healthz and /api/ and an unknown
/emby/... path would otherwise be served the SPA shell.

No new endpoint, no handler behaviour changed, and the bare root paths are
untouched: Jellyfin's own clients use them.
2026-10-12 01:04:33 -04:00

478 lines
17 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 ────────────────────────────────────────────────────────
// postLogin calls /Users/AuthenticateByName and returns the status code.
func (f *fixture) postLogin(t *testing.T, username, pw string) int {
t.Helper()
req, err := http.NewRequest("POST", f.srv.URL+"/Users/AuthenticateByName",
strings.NewReader(`{"Username":"`+username+`","Pw":"`+pw+`"}`))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := f.srv.Client().Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
_ = resp.Body.Close()
return resp.StatusCode
}
// This surface must share the login limiter with the web login, or a Jellyfin
// client is an unthrottled way to guess the same password.
func TestAuthenticateByNameIsRateLimited(t *testing.T) {
f := newFixture(t)
for i := 1; i <= 10; i++ {
if code := f.postLogin(t, "admin", "wrong"); code != http.StatusUnauthorized {
t.Fatalf("attempt %d: status = %d, want 401", i, code)
}
}
if code := f.postLogin(t, "admin", "wrong"); code != http.StatusTooManyRequests {
t.Errorf("11th attempt: status = %d, want 429", code)
}
// The window is per ip and username: another account still logs in.
if code := f.postLogin(t, "granted", "pw"); code != http.StatusOK {
t.Errorf("another user: status = %d, want 200", code)
}
}
// A correct password clears the window, so a user who fumbled a few guesses is
// not locked out of their own account for the rest of it.
func TestAuthenticateByNameSuccessResetsTheWindow(t *testing.T) {
f := newFixture(t)
for i := 1; i <= 5; i++ {
if code := f.postLogin(t, "admin", "wrong"); code != http.StatusUnauthorized {
t.Fatalf("attempt %d: status = %d, want 401", i, code)
}
}
if code := f.postLogin(t, "admin", "pw"); code != http.StatusOK {
t.Fatalf("login after failures: status = %d, want 200", code)
}
// Had the success not cleared the counter, the sixth of these would be 429.
for i := 1; i <= 10; i++ {
if code := f.postLogin(t, "admin", "wrong"); code != http.StatusUnauthorized {
t.Fatalf("post-reset attempt %d: status = %d, want 401", i, code)
}
}
if code := f.postLogin(t, "admin", "wrong"); code != http.StatusTooManyRequests {
t.Errorf("11th post-reset attempt: status = %d, want 429", code)
}
}
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)
}
}
// ── the /emby alias ──────────────────────────────────────────────────────
// Many Jellyfin apps probe GET /emby/System/Info/Public during handshake and
// abandon discovery when it 404s, dropping the user on a manual "enter your
// server URL" screen. The alias is the same handler behind a second path, so
// the body must be identical - not merely similar.
func TestEmbyPrefixMirrorsRoot(t *testing.T) {
f := newFixture(t)
rootResp, rootBody := f.do(t, "GET", "/System/Info/Public", "", "")
aliasResp, aliasBody := f.do(t, "GET", "/emby/System/Info/Public", "", "")
if aliasResp.StatusCode != rootResp.StatusCode {
t.Fatalf("alias status = %d, root = %d", aliasResp.StatusCode, rootResp.StatusCode)
}
if aliasBody != rootBody {
t.Errorf("alias body:\n%s\nroot body:\n%s", aliasBody, rootBody)
}
// The authenticated routes must be aliased too, not just the public one:
// discovery that succeeds and then a 401 on the first real call is the same
// broken handshake.
if resp, _ := f.do(t, "GET", "/emby/Users/Me", "X-Emby-Token", f.admin); resp.StatusCode != http.StatusOK {
t.Errorf("/emby/Users/Me status = %d, want 200", resp.StatusCode)
}
}
// An unknown /emby/... path must be JSON. static.go's isAPIPath knows nothing
// about /emby, so without a 404 subtree here it would fall through to the SPA
// shell and the client would try to parse index.html as a discovery response.
func TestEmbyUnknownPathIsJSONNotShell(t *testing.T) {
f := newFixture(t)
for _, path := range []string{"/emby/Items/1/Nope", "/emby/Nope"} {
resp, body := f.do(t, "GET", path, "X-Emby-Token", f.admin)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s: status = %d, want 404", path, resp.StatusCode)
}
var out map[string]any
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Errorf("%s: body is not JSON (%s): %s", path, resp.Header.Get("Content-Type"), body)
}
}
}