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.
This commit is contained in:
parent
35442e4aff
commit
608ac5c6d6
2 changed files with 737 additions and 0 deletions
362
internal/jellyfin/dto/dto_test.go
Normal file
362
internal/jellyfin/dto/dto_test.go
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/mithral/vellum/internal/library"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mithral/vellum/internal/catalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests pin the WIRE contract. Real Jellyfin apps parse this JSON and
|
||||||
|
// there is no client in this repository that would fail if a field name
|
||||||
|
// changed, so every test here compares against a literal, not against a value
|
||||||
|
// recomputed by the same helpers the production code uses.
|
||||||
|
|
||||||
|
// jsonKeys returns the sorted top-level object keys of v, so a test can assert
|
||||||
|
// both the exact set and (with the golden tests) the exact rendering.
|
||||||
|
func jsonKeys(t *testing.T, v any) []string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
t.Fatalf("unmarshal %s: %v", b, err)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sampleView is a fully populated ItemView: every optional field populated, so
|
||||||
|
// the golden JSON below contains every key BaseItemDto can emit.
|
||||||
|
func sampleView() ItemView {
|
||||||
|
return ItemView{
|
||||||
|
Item: catalog.Item{
|
||||||
|
ID: 42,
|
||||||
|
LibraryID: 7,
|
||||||
|
Kind: "video",
|
||||||
|
Path: "The Movie (2001).mp4",
|
||||||
|
Title: "The Movie (2001)",
|
||||||
|
Year: 2001,
|
||||||
|
},
|
||||||
|
File: catalog.File{
|
||||||
|
ID: 100,
|
||||||
|
Path: "The Movie (2001).mp4",
|
||||||
|
Size: 1000,
|
||||||
|
Container: "mp4",
|
||||||
|
DurationMS: 5000,
|
||||||
|
VCodec: "h264",
|
||||||
|
ACodec: "aac",
|
||||||
|
Width: 640,
|
||||||
|
Height: 360,
|
||||||
|
},
|
||||||
|
SortTitle: "the movie (2001)",
|
||||||
|
ParentID: 7,
|
||||||
|
HasCover: true,
|
||||||
|
|
||||||
|
PositionMS: 2500,
|
||||||
|
Percent: 0.5,
|
||||||
|
PlayCount: 3,
|
||||||
|
Favorite: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestToBaseItemDtoGoldenJSON pins every JSON field name and value of the
|
||||||
|
// browse DTO. RunTimeTicks is the load-bearing one: Jellyfin counts 100ns
|
||||||
|
// ticks and Vellum stores milliseconds, so 5000ms must be 50,000,000 ticks.
|
||||||
|
// Getting this wrong makes every client seek to the wrong offset.
|
||||||
|
func TestToBaseItemDtoGoldenJSON(t *testing.T) {
|
||||||
|
got, err := json.Marshal(ToBaseItemDto(sampleView(), false, ""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := `{"Id":"0000000000000000000000000000002a",` +
|
||||||
|
`"Name":"The Movie (2001)","Type":"Movie","MediaType":"Video",` +
|
||||||
|
`"Container":"mp4","RunTimeTicks":50000000,"ProductionYear":2001,` +
|
||||||
|
`"ParentId":"00000000000000000000000000000007","IsFolder":false,` +
|
||||||
|
`"SortName":"the movie (2001)","Path":"The Movie (2001).mp4",` +
|
||||||
|
`"ImageTags":{"Primary":"0000000000000000000000000000002a"},` +
|
||||||
|
`"UserData":{"PlaybackPositionTicks":25000000,"PlayedPercentage":50,` +
|
||||||
|
`"PlayCount":3,"IsFavorite":true,"Played":false}}`
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("BaseItemDto JSON changed.\n got: %s\nwant: %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestToBaseItemDtoExactKeys asserts the key set, so adding or dropping a field
|
||||||
|
// fails even if the golden above is regenerated.
|
||||||
|
func TestToBaseItemDtoExactKeys(t *testing.T) {
|
||||||
|
want := []string{
|
||||||
|
"Container", "Id", "ImageTags", "IsFolder", "MediaType", "Name",
|
||||||
|
"ParentId", "Path", "ProductionYear",
|
||||||
|
"RunTimeTicks", "SortName", "Type", "UserData",
|
||||||
|
}
|
||||||
|
got := jsonKeys(t, ToBaseItemDto(sampleView(), false, ""))
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("BaseItemDto keys = %v\nwant %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestToBaseItemDtoOmitsEmptyOptionals pins which keys are absent rather than
|
||||||
|
// zero-valued. A client that switches on the presence of MediaSources must not
|
||||||
|
// see an empty array where "not requested" is meant.
|
||||||
|
func TestToBaseItemDtoOmitsEmptyOptionals(t *testing.T) {
|
||||||
|
v := ItemView{Item: catalog.Item{ID: 1, Kind: "video", Title: "T"}}
|
||||||
|
got := jsonKeys(t, ToBaseItemDto(v, false, ""))
|
||||||
|
// MediaType is present because the kind is known; the omitted keys are the
|
||||||
|
// ones that are genuinely empty (Container, Path, ProductionYear,
|
||||||
|
// RunTimeTicks, ImageTags, ParentId).
|
||||||
|
want := []string{"Id", "IsFolder", "MediaType", "Name", "Type", "UserData"}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("minimal keys = %v\nwant %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if keys := jsonKeys(t, ToBaseItemDto(v, true, "k")); !slices.Contains(keys, "MediaSources") {
|
||||||
|
t.Errorf("includeMediaSources=true must emit MediaSources; keys = %v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestToBaseItemDtoUserDataTicks pins the tick conversion on the per-user
|
||||||
|
// overlay: Vellum stores a resume position in ms, Jellyfin clients send and
|
||||||
|
// expect 100ns ticks.
|
||||||
|
func TestToBaseItemDtoUserDataTicks(t *testing.T) {
|
||||||
|
v := sampleView()
|
||||||
|
v.PositionMS = 1 // 1ms must be 10000 ticks, not 1
|
||||||
|
d := ToBaseItemDto(v, false, "")
|
||||||
|
if d.UserData.PlaybackPositionTicks != 10_000 {
|
||||||
|
t.Errorf("PlaybackPositionTicks = %d, want 10000", d.UserData.PlaybackPositionTicks)
|
||||||
|
}
|
||||||
|
if got := TicksToMS(d.UserData.PlaybackPositionTicks); got != 1 {
|
||||||
|
t.Errorf("TicksToMS round-trip = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if d.RunTimeTicks != 50_000_000 {
|
||||||
|
t.Errorf("RunTimeTicks = %d, want 50000000", d.RunTimeTicks)
|
||||||
|
}
|
||||||
|
if MsToTicks(0) != 0 || MsToTicks(1) != 10_000 {
|
||||||
|
t.Errorf("MsToTicks broken: %d %d", MsToTicks(0), MsToTicks(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestToBaseItemDtoParentIDZero pins that ParentId is omitted, not sent as the
|
||||||
|
// zero id, when an item has no parent.
|
||||||
|
func TestToBaseItemDtoParentIDZero(t *testing.T) {
|
||||||
|
v := sampleView()
|
||||||
|
v.ParentID = 0
|
||||||
|
d := ToBaseItemDto(v, false, "")
|
||||||
|
if d.ParentId != "" {
|
||||||
|
t.Errorf("ParentId = %q, want empty for ParentID 0", d.ParentId)
|
||||||
|
}
|
||||||
|
if slices.Contains(jsonKeys(t, d), "ParentId") {
|
||||||
|
t.Error("ParentId key must be omitted when there is no parent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageTagsOnlyWithCover: ImageTags is what makes a client fetch
|
||||||
|
// /Images/Primary. Emitting it for an item with no artwork produces a request
|
||||||
|
// that 404s on every browse.
|
||||||
|
func TestImageTagsOnlyWithCover(t *testing.T) {
|
||||||
|
v := sampleView()
|
||||||
|
v.HasCover = true
|
||||||
|
d := ToBaseItemDto(v, false, "")
|
||||||
|
want := map[string]string{"Primary": ItemID(42)}
|
||||||
|
if !jsonEq(want, d.ImageTags) {
|
||||||
|
t.Errorf("ImageTags = %v, want %v", d.ImageTags, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
v.HasCover = false
|
||||||
|
d = ToBaseItemDto(v, false, "")
|
||||||
|
if d.ImageTags != nil {
|
||||||
|
t.Errorf("ImageTags = %v, want nil without a cover", d.ImageTags)
|
||||||
|
}
|
||||||
|
if slices.Contains(jsonKeys(t, d), "ImageTags") {
|
||||||
|
t.Error("ImageTags key must be absent without a cover")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMediaSourceNeverTranscodes is the contract that matters most on a
|
||||||
|
// headless, GPU-less host: Vellum cannot transcode, and a client told
|
||||||
|
// transcoding is available will wait forever for a stream that never starts.
|
||||||
|
// The refusal happens in the handler (ErrorCode), but the source it is built
|
||||||
|
// from must never advertise transcoding.
|
||||||
|
func TestMediaSourceNeverTranscodes(t *testing.T) {
|
||||||
|
ms := BuildMediaSource(sampleView().Item, sampleView().File, "tok123")
|
||||||
|
if ms.SupportsTranscoding {
|
||||||
|
t.Error("SupportsTranscoding = true: Vellum never transcodes")
|
||||||
|
}
|
||||||
|
if !ms.SupportsDirectPlay {
|
||||||
|
t.Error("SupportsDirectPlay = false, want true")
|
||||||
|
}
|
||||||
|
if !ms.SupportsDirectStream {
|
||||||
|
t.Error("SupportsDirectStream = false, want true")
|
||||||
|
}
|
||||||
|
if ms.Protocol != "File" || ms.Type != "Default" || ms.IsRemote {
|
||||||
|
t.Errorf("protocol/type/remote = %q/%q/%v, want File/Default/false",
|
||||||
|
ms.Protocol, ms.Type, ms.IsRemote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDirectStreamURLCarriesAPIKey: media elements cannot set request headers,
|
||||||
|
// so the token has to be in the URL or playback fails with a 401 mid-stream.
|
||||||
|
// This is the single most common Jellyfin integration break.
|
||||||
|
func TestDirectStreamURLCarriesAPIKey(t *testing.T) {
|
||||||
|
item := catalog.Item{ID: 42, Kind: "video", Title: "T"}
|
||||||
|
|
||||||
|
if got, want := BuildMediaSource(item, catalog.File{}, "abc123").DirectStreamUrl,
|
||||||
|
"/Videos/"+ItemID(42)+"/stream?api_key=abc123"; got != want {
|
||||||
|
t.Errorf("video DirectStreamUrl = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
item.Kind = "audiobooks"
|
||||||
|
if got, want := BuildMediaSource(item, catalog.File{}, "abc123").DirectStreamUrl,
|
||||||
|
"/Audio/"+ItemID(42)+"/stream?api_key=abc123"; got != want {
|
||||||
|
t.Errorf("non-video DirectStreamUrl = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
item.Kind = "video"
|
||||||
|
if got, want := BuildMediaSource(item, catalog.File{}, "").DirectStreamUrl,
|
||||||
|
"/Videos/"+ItemID(42)+"/stream"; got != want {
|
||||||
|
t.Errorf("no-key DirectStreamUrl = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMediaStreamsFromProbeFacts: the codec facts are what let a CLIENT decide
|
||||||
|
// whether it can direct-play, which matters precisely because Vellum will not
|
||||||
|
// help it otherwise.
|
||||||
|
func TestMediaStreamsFromProbeFacts(t *testing.T) {
|
||||||
|
ms := BuildMediaSource(sampleView().Item, sampleView().File, "")
|
||||||
|
if len(ms.MediaStreams) != 2 {
|
||||||
|
t.Fatalf("MediaStreams = %d, want 2", len(ms.MediaStreams))
|
||||||
|
}
|
||||||
|
if ms.MediaStreams[0] != (MediaStream{Type: "Video", Codec: "h264", Index: 0, IsDefault: true, Width: 640, Height: 360}) {
|
||||||
|
t.Errorf("video stream = %+v", ms.MediaStreams[0])
|
||||||
|
}
|
||||||
|
if ms.MediaStreams[1] != (MediaStream{Type: "Audio", Codec: "aac", Index: 1, IsDefault: true}) {
|
||||||
|
t.Errorf("audio stream = %+v", ms.MediaStreams[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file with no probed codecs yields no streams rather than empty entries.
|
||||||
|
ms = BuildMediaSource(catalog.Item{ID: 1}, catalog.File{Container: "mp3"}, "")
|
||||||
|
if ms.MediaStreams != nil {
|
||||||
|
t.Errorf("MediaStreams = %v, want nil without probe facts", ms.MediaStreams)
|
||||||
|
}
|
||||||
|
if ms.Size != 0 || ms.RunTimeTicks != 0 {
|
||||||
|
t.Errorf("zero File must not invent facts: %+v", ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMediaSourceGoldenJSON pins the source shape clients parse.
|
||||||
|
func TestMediaSourceGoldenJSON(t *testing.T) {
|
||||||
|
v := sampleView()
|
||||||
|
got, err := json.Marshal(BuildMediaSource(v.Item, v.File, "abc123"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := `{"Id":"0000000000000000000000000000002a","Name":"The Movie (2001)",` +
|
||||||
|
`"Path":"The Movie (2001).mp4","Container":"mp4","RunTimeTicks":50000000,` +
|
||||||
|
`"Size":1000,"SupportsDirectPlay":true,"SupportsDirectStream":true,` +
|
||||||
|
`"SupportsTranscoding":false,` +
|
||||||
|
`"DirectStreamUrl":"/Videos/0000000000000000000000000000002a/stream?api_key=abc123",` +
|
||||||
|
`"Protocol":"File","Type":"Default","IsRemote":false,` +
|
||||||
|
`"MediaStreams":[` +
|
||||||
|
`{"Type":"Video","Codec":"h264","Index":0,"IsDefault":true,"Width":640,"Height":360},` +
|
||||||
|
`{"Type":"Audio","Codec":"aac","Index":1,"IsDefault":true}]}`
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("MediaSourceInfo JSON changed.\n got: %s\nwant: %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKindMappings pins the kind-to-type table. A mis-mapped kind does not
|
||||||
|
// crash: the client just renders an empty library view.
|
||||||
|
func TestKindMappings(t *testing.T) {
|
||||||
|
cases := []struct{ kind, item, media, collection string }{
|
||||||
|
{"video", "Movie", "Video", "movies"},
|
||||||
|
{"audiobooks", "Audio", "Audio", "music"},
|
||||||
|
{"music", "Audio", "Audio", "music"},
|
||||||
|
{"photos", "Photo", "Photo", "homevideos"},
|
||||||
|
{"ebooks", "Book", "Book", "books"},
|
||||||
|
{"comics", "Book", "Book", "books"},
|
||||||
|
{"games", "Folder", "", ""},
|
||||||
|
{"", "Folder", "", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := ItemType(c.kind); got != c.item {
|
||||||
|
t.Errorf("ItemType(%q) = %q, want %q", c.kind, got, c.item)
|
||||||
|
}
|
||||||
|
if got := MediaType(c.kind); got != c.media {
|
||||||
|
t.Errorf("MediaType(%q) = %q, want %q", c.kind, got, c.media)
|
||||||
|
}
|
||||||
|
if got := CollectionType(c.kind); got != c.collection {
|
||||||
|
t.Errorf("CollectionType(%q) = %q, want %q", c.kind, got, c.collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKindsForTypes pins the inverse map, including that an unknown Jellyfin
|
||||||
|
// type maps to NOTHING rather than to everything: "show me everything I did not
|
||||||
|
// ask for" is a data leak, not a convenience.
|
||||||
|
func TestKindsForTypes(t *testing.T) {
|
||||||
|
if got := KindsForTypes(nil); got != nil {
|
||||||
|
t.Errorf("KindsForTypes(nil) = %v, want nil", got)
|
||||||
|
}
|
||||||
|
if got := KindsForTypes([]string{"Episode", "Nonsense"}); got != nil {
|
||||||
|
t.Errorf("unknown types = %v, want nil", got)
|
||||||
|
}
|
||||||
|
if got, want := KindsForTypes([]string{"Movie"}), []string{"video"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("Movie = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if got, want := KindsForTypes([]string{"Audio"}), []string{"audiobooks", "music"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("Audio = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibraryDto: a library is a folder, and its CollectionType is what tells a
|
||||||
|
// client which view to render. A library rendered without CollectionType shows
|
||||||
|
// up as an empty generic folder.
|
||||||
|
func TestLibraryDto(t *testing.T) {
|
||||||
|
d := LibraryDto(libraryFixture())
|
||||||
|
if d.Id != LibraryID(7) {
|
||||||
|
t.Errorf("Id = %q, want %q", d.Id, LibraryID(7))
|
||||||
|
}
|
||||||
|
if d.Type != "CollectionFolder" || !d.IsFolder {
|
||||||
|
t.Errorf("Type/IsFolder = %q/%v, want CollectionFolder/true", d.Type, d.IsFolder)
|
||||||
|
}
|
||||||
|
if d.CollectionType != "movies" || d.Name != "Movies" || d.SortName != "Movies" {
|
||||||
|
t.Errorf("library dto = %+v", d)
|
||||||
|
}
|
||||||
|
// A folder carries no media facts.
|
||||||
|
keys := jsonKeys(t, d)
|
||||||
|
for _, k := range []string{"Container", "RunTimeTicks", "MediaSources", "Path"} {
|
||||||
|
if slices.Contains(keys, k) {
|
||||||
|
t.Errorf("folder DTO must not carry %q; keys = %v", k, keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonEq compares two string maps without depending on map iteration order.
|
||||||
|
// libraryFixture is the library the LibraryDto test maps. CollectionType comes
|
||||||
|
// from the library's kind, so "video" must render as a movies collection.
|
||||||
|
func libraryFixture() library.Library {
|
||||||
|
return library.Library{
|
||||||
|
ID: 7,
|
||||||
|
Name: "Movies",
|
||||||
|
Kind: "video",
|
||||||
|
RootPath: "/media/movies",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonEq(want, got map[string]string) bool {
|
||||||
|
if len(want) != len(got) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for k, v := range want {
|
||||||
|
if got[k] != v {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
375
internal/jellyfin/jellyfin_test.go
Normal file
375
internal/jellyfin/jellyfin_test.go
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue