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.
375 lines
13 KiB
Go
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)
|
|
}
|
|
}
|