Files
navidrome/server/imghttp/headers_test.go
Deluan 42fd263cdf fix(artwork): clamp negative sizes to full-size; convert imghttp test to Ginkgo
- A negative size (Subsonic size / Jellyfin maxwidth accept signed ints) reached
  resizeStaticImage, where the square path builds image.NewNRGBA(Rect(0,0,size,size))
  — a giant rectangle that panics/OOMs. Clamp size<0 to 0 (full-size) at the Service
  entry. Positive sizes were already clamped to the original.
- imghttp used a plain func Test with a table; convert to a Ginkgo DescribeTable with
  the suite entry point in imghttp_suite_test.go (AGENTS.md test-framework requirement).
2026-07-23 14:08:08 -04:00

106 lines
4.8 KiB
Go

package imghttp_test
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"time"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/server/imghttp"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const testHash = "0123456789abcdef"
const testRepTag = testHash + ".300.false.q75"
var lastMod = time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
func found() *artwork.Image {
return &artwork.Image{
ReadCloser: io.NopCloser(strings.NewReader("IMG")),
Hash: testHash,
LastUpdated: lastMod,
}
}
func placeholder() *artwork.Image {
return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("PH")), Placeholder: true}
}
// resized carries a representation ETag distinct from the pixel hash (as a resized/re-encoded
// response does), so the validator versions with the encode settings.
func resized() *artwork.Image {
return &artwork.Image{
ReadCloser: io.NopCloser(strings.NewReader("IMG")),
Hash: testHash,
ETag: testRepTag,
LastUpdated: lastMod,
}
}
var _ = Describe("WriteImageHeaders", func() {
type testCase struct {
img *artwork.Image
requestedHash string
ifNoneMatch string
want304 bool
wantCache string
wantETag string
wantLastMod bool
}
DescribeTable("applies the artwork caching contract",
func(c testCase) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/img", nil)
if c.ifNoneMatch != "" {
r.Header.Set("If-None-Match", c.ifNoneMatch)
}
Expect(imghttp.WriteImageHeaders(w, r, c.img, c.requestedHash)).To(Equal(c.want304))
h := w.Header()
Expect(h.Get("Cache-Control")).To(Equal(c.wantCache))
Expect(h.Get("ETag")).To(Equal(c.wantETag))
if c.img.Placeholder {
Expect(h.Get("ETag")).To(BeEmpty(), "placeholder must not set an ETag")
Expect(h.Get("Last-Modified")).To(BeEmpty(), "placeholder must not set Last-Modified")
}
Expect(h.Get("Last-Modified") != "").To(Equal(c.wantLastMod))
if c.want304 {
Expect(w.Code).To(Equal(http.StatusNotModified))
Expect(w.Body.Len()).To(BeZero(), "a 304 must have an empty body")
}
},
Entry("placeholder is never cached and carries no validators",
testCase{img: placeholder(), wantCache: "no-store"}),
Entry("found with matching requested hash is immutable",
testCase{img: found(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}),
Entry("found with bare id revalidates via no-cache",
testCase{img: found(), wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}),
Entry("found with mismatched requested hash revalidates",
testCase{img: found(), requestedHash: "ffffffffffffffff", wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}),
Entry("resized keeps pixel-hash immutable but serves the representation ETag",
testCase{img: resized(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testRepTag + `"`, wantLastMod: true}),
Entry("resized 304s on the representation ETag, not the pixel hash",
testCase{img: resized(), ifNoneMatch: `"` + testRepTag + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}),
Entry("resized does not 304 on a stale pixel-hash validator (config changed)",
testCase{img: resized(), ifNoneMatch: `"` + testHash + `"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}),
Entry("If-None-Match matching the hash yields 304",
testCase{img: found(), requestedHash: testHash, ifNoneMatch: `"` + testHash + `"`, want304: true, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}),
Entry("weak If-None-Match matches (weak comparison)",
testCase{img: found(), ifNoneMatch: `W/"` + testHash + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}),
Entry("If-None-Match with multiple values matches one",
testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef", W/"` + testHash + `", "cafecafecafecafe"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}),
Entry("If-None-Match star matches any current representation",
testCase{img: found(), ifNoneMatch: "*", want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}),
Entry("non-matching If-None-Match serves body",
testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}),
Entry("placeholder ignores If-None-Match and never 304s",
testCase{img: placeholder(), ifNoneMatch: "*", want304: false, wantCache: "no-store"}),
)
})