Files
silo-server/internal/jellycompat/images_test.go

601 lines
21 KiB
Go

package jellycompat
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) {
upstreamCalled := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
w.WriteHeader(http.StatusTeapot)
}))
defer upstream.Close()
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
item := &models.MediaItem{
ContentID: contentID,
PosterPath: upstream.URL,
PosterThumbhash: "poster-thumbhash",
UpdatedAt: updatedAt,
}
cfg := &config.Config{Auth: config.AuthConfig{JWTSecret: "image-secret"}}
tag := newMapper(codec, cfg).itemFromList(upstreamListItem{
ContentID: contentID,
Type: "movie",
Title: "Movie",
PosterURL: item.PosterPath,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
images: NewImageCache(time.Hour, func() time.Time { return updatedAt }),
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(cfg.Auth.JWTSecret),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
assertImageRedirect(t, rec, upstream.URL)
if upstreamCalled {
t.Fatal("compat image route proxied the upstream image instead of redirecting")
}
if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" {
t.Fatal("signed-tag image URL was not cached after resolution")
}
}
func TestHandleItemImageProxiesInfuseSignedTagWithoutSessionOrCache(t *testing.T) {
upstreamCalled := false
var gotIfNoneMatch string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
gotIfNoneMatch = r.Header.Get("If-None-Match")
w.Header().Set("Cache-Control", "public, max-age=14400")
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("ETag", `"poster-v1"`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("image-bytes"))
}))
defer upstream.Close()
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
item := &models.MediaItem{
ContentID: contentID,
PosterPath: upstream.URL,
PosterThumbhash: "poster-thumbhash",
UpdatedAt: updatedAt,
}
cfg := &config.Config{Auth: config.AuthConfig{JWTSecret: "image-secret"}}
tag := newMapper(codec, cfg).itemFromList(upstreamListItem{
ContentID: contentID,
Type: "movie",
Title: "Movie",
PosterURL: item.PosterPath,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
images: NewImageCache(time.Hour, func() time.Time { return updatedAt }),
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(cfg.Auth.JWTSecret),
httpClient: upstream.Client(),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+compatImageProxyTag(tag), nil)
req.Header.Set("If-None-Match", `"poster-v1"`)
req.Header.Set("User-Agent", "Infuse-Direct/8.4.6")
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "image-bytes" {
t.Fatalf("body = %q, want image-bytes", got)
}
if got := rec.Header().Get("Location"); got != "" {
t.Fatalf("Location = %q, want empty", got)
}
if got := rec.Header().Get("Content-Type"); got != "image/jpeg" {
t.Fatalf("Content-Type = %q, want image/jpeg", got)
}
if got := rec.Header().Get("Cache-Control"); got != compatImageRouteCacheControl {
t.Fatalf("Cache-Control = %q, want %q", got, compatImageRouteCacheControl)
}
if got := rec.Header().Get("CDN-Cache-Control"); got != "private, no-store, no-cache, max-age=0" {
t.Fatalf("CDN-Cache-Control = %q, want private, no-store, no-cache, max-age=0", got)
}
if got := rec.Header().Get("X-Accel-Expires"); got != "0" {
t.Fatalf("X-Accel-Expires = %q, want 0", got)
}
if got := gotIfNoneMatch; got != `"poster-v1"` {
t.Fatalf("forwarded If-None-Match = %q, want poster-v1", got)
}
if !upstreamCalled {
t.Fatal("Infuse compat image route did not proxy the upstream image")
}
}
func TestHandleItemImageProxyRouteIDUsesCanonicalItemAndProxy(t *testing.T) {
upstreamCalled := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
w.Header().Set("Content-Type", "image/webp")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("proxy-route-image"))
}))
defer upstream.Close()
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
proxyRouteID := compatImageProxyRouteID(codec, routeID)
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
item := &models.MediaItem{
ContentID: contentID,
PosterPath: upstream.URL,
PosterThumbhash: "poster-thumbhash",
UpdatedAt: updatedAt,
}
cfg := &config.Config{Auth: config.AuthConfig{JWTSecret: "image-secret"}}
tag := newMapper(codec, cfg).itemFromList(upstreamListItem{
ContentID: contentID,
Type: "movie",
Title: "Movie",
PosterURL: item.PosterPath,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
images: NewImageCache(time.Hour, func() time.Time { return updatedAt }),
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(cfg.Auth.JWTSecret),
httpClient: upstream.Client(),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+proxyRouteID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+compatImageProxyTag(tag), nil)
req = withImageRouteParams(req, proxyRouteID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "proxy-route-image" {
t.Fatalf("body = %q, want proxy-route-image", got)
}
if got := rec.Header().Get("Location"); got != "" {
t.Fatalf("Location = %q, want empty", got)
}
if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" {
t.Fatal("proxy route image URL was not cached under the canonical route ID")
}
if !upstreamCalled {
t.Fatal("proxy route did not fetch the upstream image")
}
}
func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) {
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
item := &models.MediaItem{
ContentID: contentID,
PosterPath: "https://cdn.example.test/poster.jpg",
PosterThumbhash: "poster-thumbhash",
UpdatedAt: updatedAt,
}
tag := newMapper(codec, &config.Config{}).itemFromList(upstreamListItem{
ContentID: contentID,
Type: "movie",
Title: "Movie",
PosterURL: item.PosterPath,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(""),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
// An unsigned/invalid tag must not serve the image. Per the Jellyfin
// contract (item-image GETs are anonymous, 200/404 only) the rejection
// surfaces as a 404, not a 401.
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s; want 404", rec.Code, rec.Body.String())
}
}
func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t *testing.T) {
upstreamCalled := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
w.WriteHeader(http.StatusTeapot)
}))
defer upstream.Close()
codec := NewResourceIDCodec()
contentID := "series-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
secret := "image-secret"
tag := newImageTagSigner(secret).Tag(
imageTagSeed(contentID, "Backdrop", compatCardImageSize, upstream.URL, "", time.Time{}),
upstream.URL,
)
h := &ImagesHandler{
codec: codec,
images: NewImageCache(time.Hour, time.Now),
itemRepo: fakeImageItemRepo{item: &models.MediaItem{
ContentID: contentID,
BackdropPath: upstream.URL,
}},
imageTags: newImageTagSigner(secret),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Thumb?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Thumb")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
assertImageRedirect(t, rec, upstream.URL)
if upstreamCalled {
t.Fatal("compat image route proxied the upstream image instead of redirecting")
}
}
func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.T) {
upstreamCalled := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
w.WriteHeader(http.StatusTeapot)
}))
defer upstream.Close()
codec := NewResourceIDCodec()
libraryID := 1
routeID := codec.EncodeIntID(EncodedIDLibrary, int64(libraryID))
posterPath := "library-posters/1/original.jpg"
secret := "image-secret"
tag := newImageTagSigner(secret).Tag(
imageTagSeed(routeID, "Primary", compatCardImageSize, posterPath, "", time.Time{}),
"",
)
h := &ImagesHandler{
codec: codec,
images: NewImageCache(time.Hour, time.Now),
folderRepo: fakeImageFolderRepo{folder: &models.MediaFolder{ID: libraryID, PosterPath: posterPath}},
posterSigner: fakeLibraryPosterPresigner{url: upstream.URL},
imageTags: newImageTagSigner(secret),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
assertImageRedirect(t, rec, upstream.URL)
if upstreamCalled {
t.Fatal("compat image route proxied the upstream image instead of redirecting")
}
}
func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing.T) {
upstreamCalled := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
w.WriteHeader(http.StatusTeapot)
}))
defer upstream.Close()
codec := NewResourceIDCodec()
routeID := codec.EncodeStringID(EncodedIDItem, "movie-1")
cache := NewImageCache(time.Hour, time.Now)
cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize)
h := &ImagesHandler{
codec: codec,
images: cache,
imageTags: newImageTagSigner("image-secret"),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tagValue(upstream.URL), nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
assertImageRedirect(t, rec, upstream.URL)
if upstreamCalled {
t.Fatal("compat image route proxied the upstream image instead of redirecting")
}
}
func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) {
called := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
_, _ = w.Write([]byte("stale-image"))
}))
defer upstream.Close()
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
item := &models.MediaItem{
ContentID: contentID,
PosterPath: upstream.URL,
PosterThumbhash: "poster-thumbhash",
UpdatedAt: updatedAt,
}
cache := NewImageCache(time.Hour, func() time.Time { return updatedAt })
cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize)
tag := newMapper(codec, &config.Config{
Auth: config.AuthConfig{JWTSecret: "old-secret"},
}).itemFromList(upstreamListItem{
ContentID: contentID,
Type: "movie",
Title: "Movie",
PosterURL: item.PosterPath,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
images: cache,
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner("new-secret"),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
// A stale tag (signed with the old secret) must not serve the cached image.
// Per the Jellyfin contract the rejection surfaces as a 404, not a 401.
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s; want 404", rec.Code, rec.Body.String())
}
if called {
t.Fatal("served cached image before validating the signed tag")
}
}
func TestRedirectImageURLRejectsNonHTTPURL(t *testing.T) {
h := &ImagesHandler{}
req := httptest.NewRequest(http.MethodGet, "/Items/1/Images/Primary", nil)
rec := httptest.NewRecorder()
h.redirectImageURL(rec, req, "catalog/poster.jpg")
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, body = %s; want 502", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "" {
t.Fatalf("Location = %q, want empty", got)
}
}
// TestHandleItemImageChapterReturns404WithoutSession verifies an anonymous
// chapter-image request (no auth, cold cache, no tag) degrades to a 404, not a
// 401: Silo never stores "Chapter" route art, so the cache misses and the
// session-fallback now returns NotFound per the Jellyfin contract.
func TestHandleItemImageChapterReturns404WithoutSession(t *testing.T) {
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
h := &ImagesHandler{
codec: codec,
images: NewImageCache(time.Hour, time.Now),
itemRepo: fakeImageItemRepo{item: &models.MediaItem{ContentID: contentID}},
imageTags: newImageTagSigner("image-secret"),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Chapter/0", nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", routeID)
routeCtx.URLParams.Add("imageType", "Chapter")
routeCtx.URLParams.Add("index", "0")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s; want 404", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "" {
t.Fatalf("Location = %q, want empty", got)
}
}
// TestHandleItemImagePrimaryCacheHitRedirectsWithoutSession verifies a warm
// Primary cache entry serves an anonymous <img> request via redirect, never
// consulting auth.
func TestHandleItemImagePrimaryCacheHitRedirectsWithoutSession(t *testing.T) {
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
upstreamURL := "https://cdn.example.test/poster.jpg"
h := &ImagesHandler{
codec: codec,
images: NewImageCache(time.Hour, time.Now),
itemRepo: fakeImageItemRepo{item: &models.MediaItem{ContentID: contentID}},
imageTags: newImageTagSigner("image-secret"),
}
h.images.RememberSized(routeID, "Primary", upstreamURL, compatCardImageSize)
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary", nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
assertImageRedirect(t, rec, upstreamURL)
}
// serveUserImage issues an avatar request for pathID with the chi "id" route
// param and no session in context.
func serveUserImage(h *ImagesHandler, method, pathID string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "/Users/"+pathID+"/Images/Primary", nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", pathID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
h.HandleUserImage(rec, req)
return rec
}
// TestHandleUserImageServesPlaceholderWithoutSession verifies the user-avatar
// route serves a placeholder PNG without a session: it is byte-stable per id and
// the palette varies across ids (the avatar is now drawn from a bounded fixed
// palette, so two distinct ids may collide — variety is asserted over a sample).
func TestHandleUserImageServesPlaceholderWithoutSession(t *testing.T) {
h := &ImagesHandler{codec: NewResourceIDCodec()}
id := PseudoUserID(1, "profile-1").String()
rec := serveUserImage(h, http.MethodGet, id)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
t.Fatalf("Content-Type = %q, want image/png", ct)
}
body := rec.Body.Bytes()
if len(body) == 0 {
t.Fatal("expected a non-empty avatar body")
}
again := serveUserImage(h, http.MethodGet, id)
if got := again.Body.Bytes(); string(got) != string(body) {
t.Fatal("avatar bytes for the same path id must be stable across calls")
}
// The palette is bounded, so individual ids may collide; assert variety over
// a sample instead of strict per-id divergence.
distinct := map[string]struct{}{}
for i := 0; i < 12; i++ {
out := serveUserImage(h, http.MethodGet, PseudoUserID(i+10, "profile").String())
distinct[out.Body.String()] = struct{}{}
}
if len(distinct) < 2 {
t.Fatalf("expected the avatar palette to vary across ids; got %d distinct outputs", len(distinct))
}
}
// TestHandleUserImageHeadRequest verifies the HEAD variant of the anonymous
// avatar route returns 200 + image/png (the body may be empty for HEAD).
func TestHandleUserImageHeadRequest(t *testing.T) {
h := &ImagesHandler{codec: NewResourceIDCodec()}
id := PseudoUserID(1, "profile-1").String()
rec := serveUserImage(h, http.MethodHead, id)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
t.Fatalf("Content-Type = %q, want image/png", ct)
}
}
func assertImageRedirect(t *testing.T, rec *httptest.ResponseRecorder, wantLocation string) {
t.Helper()
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, body = %s; want 302", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != wantLocation {
t.Fatalf("Location = %q, want %q", got, wantLocation)
}
if got := rec.Header().Get("Cache-Control"); got != compatImageRouteCacheControl {
t.Fatalf("Cache-Control = %q, want %q", got, compatImageRouteCacheControl)
}
}
type fakeImageItemRepo struct {
item *models.MediaItem
}
func (r fakeImageItemRepo) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
if r.item != nil && r.item.ContentID == contentID {
return r.item, nil
}
return nil, catalog.ErrItemNotFound
}
func (r fakeImageItemRepo) EnsureAccessible(context.Context, string, catalog.AccessFilter) error {
return nil
}
type fakeImageFolderRepo struct {
folder *models.MediaFolder
}
func (r fakeImageFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) {
if r.folder != nil && r.folder.ID == id {
return r.folder, nil
}
return nil, catalog.ErrFolderNotFound
}
type fakeLibraryPosterPresigner struct {
url string
}
func (p fakeLibraryPosterPresigner) PresignGetURL(context.Context, string, string, time.Duration) (string, error) {
return p.url, nil
}
func (p fakeLibraryPosterPresigner) Bucket() string {
return "test-bucket"
}
func withImageRouteParams(r *http.Request, routeID, imageType string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", routeID)
routeCtx.URLParams.Add("imageType", imageType)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx))
}