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: