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.
478 lines
17 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|