Vellum/internal/jellyfin/jellyfin_test.go
mithral 2c424ae67c Expose the television tree: catalog reads, API routes, Jellyfin shapes
The scanner builds library -> series -> season -> episode; this makes it
reachable. Three reads, all grant-checked through catalog.Store (ADR-005):
a series' seasons, a season's episodes, and the next unwatched episode.

Each loads the parent through ItemFor first, so forbidden and absent are the
same ErrNotFound, and the child query is scoped to the parent's own
library_id so a corrupt parent_id cannot cross a library boundary. Verified
red-green: swapping ItemFor for Item returns two rows to an ungranted
caller.

Episodes come back in episode order, not insertion order, and the ordering
is verified red-green too - dropping the clause yields [3 1 10 2].

Routes stay inside the existing family: /api/v1/items/{id}/seasons,
/episodes and /next. No parallel route surface.

Jellyfin gains SeriesDto, SeasonDto and EpisodeDto. Jellyfin's wire type is
BaseItemDto, so these carry the folder/file split plus IndexNumber and
ParentIndexNumber. ItemType now takes the item rather than the kind, because
an episode IS kind video - that is the one signature change, and the dto
tests were updated with it.

Verified end-to-end against a running server: a library holding S01E02,
1x02, a bare-number file and a film produced two series, three seasons and
six episodes, with the film left alone; the routes returned the seasons in
order, the episodes in episode order, and the next unwatched episode.

Note for later: Subsonic and the web UI are untouched, so a series browses
there as it did before. The scanner also has no separate 'tv' kind, so a
video library mixing films and shows builds both shapes side by side.
2026-10-12 01:41:05 -04:00

614 lines
23 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
series int64
season int64
episode 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)
// A television tree in the same library, written the way the scanner writes
// it: the series and the season are fileless, and the episode is kind
// 'video' with both numbers set.
seriesID := mustExec(t, db, `
INSERT INTO media_items (library_id, kind, path, title, sort_title)
VALUES (?, 'series', 'The Wire', 'The Wire', 'the wire')`, libID)
seasonID := mustExec(t, db, `
INSERT INTO media_items (library_id, kind, path, parent_id, title, sort_title, season_number)
VALUES (?, 'season', 'The Wire/Season 1', ?, 'Season 1', 'season 1', 1)`, libID, seriesID)
episodeID := mustExec(t, db, `
INSERT INTO media_items (library_id, kind, path, parent_id, title, sort_title,
season_number, episode_number)
VALUES (?, 'video', 'The Wire/Season 1/S01E02.mkv', ?, 'Pilot', 'pilot', 1, 2)`, libID, seasonID)
mustExec(t, db, `
INSERT INTO media_files (item_id, path, size, container, duration_ms)
VALUES (?, 'The Wire/Season 1/S01E02.mkv', 20, 'mkv', 300000)`, episodeID)
// 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,
series: seriesID, season: seasonID, episode: episodeID,
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")
}
}
// ── the television tree ─────────────────────────────────────────────────
// items queries /Items and returns the decoded envelope, failing the test on a
// non-200 the way a client would.
func (f *fixture) items(t *testing.T, token, query string) dto.ItemQueryResult {
t.Helper()
resp, body := f.do(t, "GET", "/Items?"+query, "X-Emby-Token", token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("/Items?%s: status = %d (body %s)", query, resp.StatusCode, body)
}
var out dto.ItemQueryResult
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("decode /Items?%s: %v (body %s)", query, err, body)
}
return out
}
// one returns the only item in a result, failing otherwise: a client rendering a
// show's poster page expects exactly one series back.
func one(t *testing.T, what string, res dto.ItemQueryResult) dto.BaseItemDto {
t.Helper()
if len(res.Items) != 1 {
t.Fatalf("%s returned %d items, want 1", what, len(res.Items))
}
return res.Items[0]
}
// A real Jellyfin client browsing television asks for Series, Season and Episode
// and drills ParentId down the tree. Before this, /Items could only answer with
// films and albums: an episode is kind 'video', so a show came back as a pile of
// movies with no numbers and nothing to drill through.
func TestItemsReturnsTheSeriesShape(t *testing.T) {
f := newFixture(t)
// The television query, exactly as a client spells it.
res := f.items(t, f.granted, "Recursive=true&IncludeItemTypes=Series,Season,Episode&SortBy=SortName")
if res.TotalRecordCount != 3 {
t.Fatalf("TV query returned %d items, want 3 (series, season, episode): %+v",
res.TotalRecordCount, res.Items)
}
byType := map[string]dto.BaseItemDto{}
for _, it := range res.Items {
byType[it.Type] = it
if it.Type == "Movie" {
t.Error("a film was returned as a television query asked for none")
}
}
series, ok := byType["Series"]
if !ok {
t.Fatalf("no Series in the response: %+v", res.Items)
}
if series.Id != dto.ItemID(f.series) || series.Name != "The Wire" {
t.Errorf("series id/name = %q/%q", series.Id, series.Name)
}
// A series is a folder in the tvshows collection: that pair is what makes a
// client open its television view instead of an empty generic folder.
if !series.IsFolder || series.CollectionType != "tvshows" || series.MediaType != "Video" {
t.Errorf("series = %+v, want a Video folder with CollectionType tvshows", series)
}
// It has no file of its own, so it must not claim a runtime or a source.
if series.RunTimeTicks != 0 || len(series.MediaSources) != 0 || series.Path != "" {
t.Errorf("series carries file facts it does not have: %+v", series)
}
season, ok := byType["Season"]
if !ok {
t.Fatalf("no Season in the response: %+v", res.Items)
}
if season.IndexNumber != 1 || season.ParentId != dto.ItemID(f.series) || !season.IsFolder {
t.Errorf("season = %+v, want IndexNumber 1 under the series", season)
}
episode, ok := byType["Episode"]
if !ok {
t.Fatalf("no Episode in the response: %+v", res.Items)
}
if episode.IndexNumber != 2 || episode.ParentIndexNumber != 1 {
t.Errorf("episode numbers = %d/%d, want 2/1", episode.IndexNumber, episode.ParentIndexNumber)
}
if episode.ParentId != dto.ItemID(f.season) {
t.Errorf("episode ParentId = %q, want the season", episode.ParentId)
}
if episode.IsFolder || episode.RunTimeTicks != 300000*10000 {
t.Errorf("episode = %+v, want a playable item with the file's runtime", episode)
}
// Drilling works both ways, which is how a client navigates the tree.
seasons := f.items(t, f.granted, "ParentId="+dto.ItemID(f.series))
if s := one(t, "series' seasons", seasons); s.Type != "Season" {
t.Errorf("series' child = %q, want Season", s.Type)
}
eps := f.items(t, f.granted, "ParentId="+dto.ItemID(f.season))
if e := one(t, "season's episodes", eps); e.Type != "Episode" || e.IndexNumber != 2 {
t.Errorf("season's child = %+v, want Episode 2", e)
}
}
// The grant applies to the television tree exactly as it does to a film: a token
// with no grants sees an empty page, never an item it was not given.
func TestItemsSeriesAreGrantScoped(t *testing.T) {
f := newFixture(t)
for _, q := range []string{
"Recursive=true&IncludeItemTypes=Series,Season,Episode",
"ParentId=" + dto.ItemID(f.series),
} {
res := f.items(t, f.none, q)
if res.TotalRecordCount != 0 || len(res.Items) != 0 {
t.Errorf("/Items?%s returned %d items to a no-grant token", q, res.TotalRecordCount)
}
}
}
// ── 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)
}
}
}