package test import ( domain "RenewCMS/internal/domain/image" "bytes" "encoding/json" "net/http" "net/http/httptest" "runtime" "strings" "sync" "testing" "time" ) const ( thumbURL = "/static/uploadedImages/a1b2.png?v=thumb" unknownMessage = "Unknown variant" unsupportedText = "Unsupported parameter" cacheHeader = "public, no-cache" privateBlobName = "a1b2.png" ) func servingEnv(t *testing.T) *variantEnv { t.Helper() env := newVariantEnv(t) env.seedThumb() env.seedPhoto() return env } func assertRequestError(t *testing.T, recorder *httptest.ResponseRecorder, code string, message string, detail string) { t.Helper() if recorder.Code != 400 || !strings.HasPrefix(recorder.Header().Get("Content-Type"), "application/json") { t.Fatalf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type")) } var body struct { Status int `json:"status"` Error string `json:"error"` Message string `json:"message"` Detail string `json:"detail"` } if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { t.Fatalf("body = %q", recorder.Body.String()) } if body.Status != 400 || body.Error != code || body.Message != message || body.Detail != detail { t.Errorf("body = %+v", body) } } func TestImageVariantsFirstAndCachedRequests(t *testing.T) { t.Run("The first request generates and caches", func(t *testing.T) { env := servingEnv(t) original := append([]byte(nil), env.storage.blobs[privateBlobName]...) recorder := env.get(thumbURL, nil) if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/jpeg" { t.Fatalf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type")) } width, height := decodedSize(t, recorder.Body.Bytes()) _, format := decodeImage(t, recorder.Body.Bytes()) if width != 400 || height != 300 || format != "jpeg" { t.Errorf("output = %dx%d %s", width, height, format) } rows := env.files.rowsOf("thumb") if env.storage.putCount() != 1 || len(rows) != 1 || rows[0].Size != int64(recorder.Body.Len()) { t.Errorf("puts = %d, rows = %+v", env.storage.putCount(), rows) } if !bytes.Equal(env.storage.blobs[privateBlobName], original) { t.Error("the original blob changed") } }) t.Run("The second request serves the cached file without rendering", func(t *testing.T) { env := servingEnv(t) first := env.get(thumbURL, nil) second := env.get(thumbURL, nil) if !bytes.Equal(first.Body.Bytes(), second.Body.Bytes()) || env.renderer.renderCount() != 1 || env.storage.putCount() != 1 { t.Errorf("renders = %d, puts = %d", env.renderer.renderCount(), env.storage.putCount()) } }) t.Run("A request without ?v= is the unchanged original", func(t *testing.T) { env := servingEnv(t) recorder := env.get("/static/uploadedImages/a1b2.png", nil) if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/png" || recorder.Header().Get("Cache-Control") != cacheHeader || recorder.Header().Get("X-Content-Type-Options") != "nosniff" || recorder.Header().Get("ETag") != "" { t.Errorf("headers = %v", recorder.Header()) } if !bytes.Equal(recorder.Body.Bytes(), env.storage.blobs[privateBlobName]) || env.renderer.renderCount() != 0 { t.Error("the original was altered or the renderer ran") } }) t.Run("Other query parameters are ignored", func(t *testing.T) { env := servingEnv(t) recorder := env.get(thumbURL+"&t=123", nil) if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/jpeg" { t.Errorf("response = %d", recorder.Code) } }) t.Run("A HEAD request answers the headers of the variant", func(t *testing.T) { env := servingEnv(t) request := httptest.NewRequest(http.MethodHead, thumbURL, nil) recorder := httptest.NewRecorder() env.public.ServeHTTP(recorder, request) if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/jpeg" || recorder.Body.Len() != 0 { t.Errorf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type")) } }) } func TestImageVariantsETag(t *testing.T) { t.Run("The ETag derives from the variant settings", func(t *testing.T) { env := servingEnv(t) first := env.get(thumbURL, nil) etag := first.Header().Get("ETag") again := env.get(thumbURL, nil) conditional := env.get(thumbURL, http.Header{"If-None-Match": []string{etag}}) if len(etag) < 3 || !strings.HasPrefix(etag, `"`) || !strings.HasSuffix(etag, `"`) || again.Header().Get("ETag") != etag { t.Errorf("etag = %q then %q", etag, again.Header().Get("ETag")) } if conditional.Code != 304 || conditional.Body.Len() != 0 || env.renderer.renderCount() != 1 { t.Errorf("conditional = %d %q, renders = %d", conditional.Code, conditional.Body.String(), env.renderer.renderCount()) } }) t.Run("A 304 does not touch the cache", func(t *testing.T) { env := servingEnv(t) etag := env.get(thumbURL, nil).Header().Get("ETag") callsBefore := env.files.calls env.get(thumbURL, http.Header{"If-None-Match": []string{etag}}) if env.files.calls != callsBefore { t.Error("the file index was read") } }) changes := []struct { setting string body string changes bool }{ {"quality", `{"width": 400, "height": 300, "fit": "cover", "format": "jpeg", "quality": 70}`, true}, {"width", `{"width": 500, "height": 300, "fit": "cover", "format": "jpeg", "quality": 80}`, true}, {"height", `{"width": 400, "height": 200, "fit": "cover", "format": "jpeg", "quality": 80}`, true}, {"fit", `{"width": 400, "height": 300, "fit": "inside", "format": "jpeg", "quality": 80}`, true}, {"format", `{"width": 400, "height": 300, "fit": "cover", "format": "png"}`, true}, {"generate_on_upload", `{"width": 400, "height": 300, "fit": "cover", "format": "jpeg", "quality": 80, "generate_on_upload": true}`, false}, } for _, row := range changes { t.Run("The ETag changes when a setting changes and stays when a switch changes: "+row.setting, func(t *testing.T) { env := servingEnv(t) before := env.get(thumbURL, nil).Header().Get("ETag") update := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/thumb", row.body) after := env.get(thumbURL, nil).Header().Get("ETag") if update.Code != 200 || (before != after) != row.changes { t.Errorf("update = %d, before = %q, after = %q", update.Code, before, after) } }) } t.Run("A stale If-None-Match after an edit gets a fresh 200", func(t *testing.T) { env := servingEnv(t) stale := env.get(thumbURL, nil).Header().Get("ETag") env.sendAsAdmin(http.MethodPut, "/v1/image-variants/thumb", `{"width": 400, "height": 300, "fit": "cover", "format": "jpeg", "quality": 70}`) recorder := env.get(thumbURL, http.Header{"If-None-Match": []string{stale}}) if recorder.Code != 200 || recorder.Header().Get("ETag") == stale || recorder.Body.Len() == 0 { t.Errorf("response = %d %q", recorder.Code, recorder.Header().Get("ETag")) } }) t.Run("Different images get different ETags for the same variant", func(t *testing.T) { env := servingEnv(t) env.seedSource("c3d4.png", encodePNG(t, variantSolidImage(900, 700, variantBlue))) first := env.get(thumbURL, nil).Header().Get("ETag") second := env.get("/static/uploadedImages/c3d4.png?v=thumb", nil).Header().Get("ETag") if first == second { t.Error("both images share one ETag") } }) t.Run("Editing a variant regenerates on the next request", func(t *testing.T) { env := servingEnv(t) env.get(thumbURL, nil) oldName := env.files.rowsOf("thumb")[0].CachedName env.sendAsAdmin(http.MethodPut, "/v1/image-variants/thumb", `{"width": 400, "height": 300, "fit": "cover", "format": "jpeg", "quality": 70}`) env.get(thumbURL, nil) rows := env.files.rowsOf("thumb") if env.storage.has(oldName) || len(rows) != 1 || rows[0].CachedName == oldName || env.storage.putCount() != 2 { t.Errorf("rows = %+v, puts = %d", rows, env.storage.putCount()) } }) t.Run("A row with an old fingerprint is replaced and its blob removed", func(t *testing.T) { env := servingEnv(t) env.storage.seed("old.jpeg", []byte("old")) env.files.Save(domain.VariantFile{VariantName: "thumb", SourceName: privateBlobName, CachedName: "old.jpeg", ContentType: "image/jpeg", Size: 3, Fingerprint: "outdated"}) recorder := env.get(thumbURL, nil) if recorder.Code != 200 || env.storage.has("old.jpeg") || env.files.rowsOf("thumb")[0].CachedName == "old.jpeg" { t.Error("the stale row was served or kept") } }) t.Run("A cached file that vanished from storage is generated again", func(t *testing.T) { env := servingEnv(t) env.get(thumbURL, nil) vanished := env.files.rowsOf("thumb")[0].CachedName delete(env.storage.blobs, vanished) recorder := env.get(thumbURL, nil) rows := env.files.rowsOf("thumb") if recorder.Code != 200 || recorder.Body.Len() == 0 || len(rows) != 1 || rows[0].CachedName == vanished || !env.storage.has(rows[0].CachedName) { t.Errorf("response = %d, rows = %+v", recorder.Code, rows) } }) } func TestImageVariantsConcurrency(t *testing.T) { t.Run("Concurrent first requests generate once", func(t *testing.T) { const clients = 20 env := servingEnv(t) env.renderer.onRender = func() { for env.storage.openCountOf(privateBlobName) < clients { runtime.Gosched() } } bodies := make([][]byte, clients) codes := make([]int, clients) start := make(chan struct{}) var finished sync.WaitGroup for index := 0; index < clients; index++ { finished.Add(1) go func() { defer finished.Done() <-start recorder := env.get(thumbURL, nil) codes[index], bodies[index] = recorder.Code, recorder.Body.Bytes() }() } close(start) finished.Wait() for index := range bodies { if codes[index] != 200 || !bytes.Equal(bodies[index], bodies[0]) { t.Fatalf("client %d got %d", index, codes[index]) } } if env.renderer.renderCount() != 1 || env.storage.putCount() != 1 { t.Errorf("renders = %d, puts = %d", env.renderer.renderCount(), env.storage.putCount()) } }) t.Run("Different variants and different images are not serialised together", func(t *testing.T) { env := servingEnv(t) seedVariant(env.variants, "card", 200, 0, domain.FitInside, domain.FormatJPEG, 80) env.seedSource("c3d4.png", encodePNG(t, variantSolidImage(900, 700, variantBlue))) urls := []string{ "/static/uploadedImages/a1b2.png?v=thumb", "/static/uploadedImages/a1b2.png?v=card", "/static/uploadedImages/c3d4.png?v=thumb", "/static/uploadedImages/c3d4.png?v=card", } rendezvous := newRendezvous(len(urls)) env.renderer.onRender = rendezvous.arrive var finished sync.WaitGroup codes := make([]int, len(urls)) for index, url := range urls { finished.Add(1) go func() { defer finished.Done() codes[index] = env.get(url, nil).Code }() } finished.Wait() if !rendezvous.allInFlight() { t.Error("the renders were serialised") } for _, code := range codes { if code != 200 { t.Errorf("codes = %v", codes) } } }) t.Run("A failed generation is not cached and is retried", func(t *testing.T) { env := servingEnv(t) env.renderer.failFirst = true first := env.get(thumbURL, nil) blobsAfterFailure, rowsAfterFailure := env.storage.putCount(), env.files.rowCount() second := env.get(thumbURL, nil) if first.Code != 500 || blobsAfterFailure != 0 || rowsAfterFailure != 0 || second.Code != 200 { t.Errorf("statuses = %d then %d, puts = %d, rows = %d", first.Code, second.Code, blobsAfterFailure, rowsAfterFailure) } }) } type rendezvous struct { expected int mutex sync.Mutex arrived int all chan struct{} timedOut bool } func newRendezvous(expected int) *rendezvous { return &rendezvous{expected: expected, all: make(chan struct{})} } func (r *rendezvous) arrive() { r.mutex.Lock() r.arrived++ if r.arrived == r.expected { close(r.all) } r.mutex.Unlock() select { case <-r.all: case <-time.After(10 * time.Second): r.mutex.Lock() r.timedOut = true r.mutex.Unlock() } } func (r *rendezvous) allInFlight() bool { r.mutex.Lock() defer r.mutex.Unlock() return r.arrived == r.expected && !r.timedOut } func TestImageVariantsRequestContract(t *testing.T) { rows := []struct { name, url, code, message, detail string }{ {"unknown name", "/static/uploadedImages/a1b2.png?v=banner", "unknown_variant", unknownMessage, "banner"}, {"uppercase name", "/static/uploadedImages/a1b2.png?v=Thumb", "unknown_variant", unknownMessage, "Thumb"}, {"empty name", "/static/uploadedImages/a1b2.png?v=", "unknown_variant", unknownMessage, ""}, {"disabled variant", "/static/uploadedImages/a1b2.png?v=hero", "unknown_variant", unknownMessage, "hero"}, {"width", "/static/uploadedImages/a1b2.png?width=400", "unsupported_parameter", unsupportedText, "width"}, {"height", "/static/uploadedImages/a1b2.png?height=300", "unsupported_parameter", unsupportedText, "height"}, {"format", "/static/uploadedImages/a1b2.png?format=webp", "unsupported_parameter", unsupportedText, "format"}, {"quality", "/static/uploadedImages/a1b2.png?quality=50", "unsupported_parameter", unsupportedText, "quality"}, {"variant and width", "/static/uploadedImages/a1b2.png?v=thumb&width=400", "unsupported_parameter", unsupportedText, "width"}, {"unknown variant and quality", "/static/uploadedImages/a1b2.png?v=banner&quality=50", "unsupported_parameter", unsupportedText, "quality"}, {"parameter before missing source", "/static/uploadedImages/missing.png?width=400", "unsupported_parameter", unsupportedText, "width"}, {"unknown variant before missing source", "/static/uploadedImages/missing.png?v=banner", "unknown_variant", unknownMessage, "banner"}, } for _, row := range rows { t.Run("A request outside the contract answers 400 with the JSON error body: "+row.name, func(t *testing.T) { env := servingEnv(t) hero := seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80) hero.Enabled = false env.variants.variants["hero"] = hero recorder := env.get(row.url, nil) assertRequestError(t, recorder, row.code, row.message, row.detail) if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 { t.Error("something was generated") } }) } t.Run("A disabled variant serves again once enabled, from its kept cache", func(t *testing.T) { env := servingEnv(t) env.get(thumbURL, nil) env.sendAsAdmin(http.MethodPatch, "/v1/image-variants/thumb", `{"enabled": false}`) disabled := env.get(thumbURL, nil) env.sendAsAdmin(http.MethodPatch, "/v1/image-variants/thumb", `{"enabled": true}`) enabled := env.get(thumbURL, nil) assertRequestError(t, disabled, "unknown_variant", unknownMessage, "thumb") if enabled.Code != 200 || env.storage.putCount() != 1 { t.Errorf("response = %d, puts = %d", enabled.Code, env.storage.putCount()) } }) t.Run("A deleted variant answers 400", func(t *testing.T) { env := servingEnv(t) env.get(thumbURL, nil) env.sendAsAdmin(http.MethodDelete, "/v1/image-variants/thumb", "") recorder := env.get(thumbURL, nil) assertRequestError(t, recorder, "unknown_variant", unknownMessage, "thumb") }) } func TestImageVariantsMissingAndUnreadableSources(t *testing.T) { urls := []string{ "/static/uploadedImages/missing.png?v=thumb", "/static/uploadedImages/../secret.png?v=thumb", "/static/uploadedImages/nested/a1b2.png?v=thumb", } for _, url := range urls { t.Run("A missing source answers 404: "+url, func(t *testing.T) { env := servingEnv(t) recorder := env.get(url, nil) if recorder.Code != 404 || env.renderer.renderCount() != 0 || env.storage.putCount() != 0 { t.Errorf("response = %d", recorder.Code) } }) } t.Run("A source that cannot be decoded answers 500 and caches nothing", func(t *testing.T) { env := servingEnv(t) env.seedSource("bad.png", []byte("these bytes are not an image")) recorder := env.get("/static/uploadedImages/bad.png?v=thumb", nil) if recorder.Code != 500 || env.storage.putCount() != 0 || env.files.rowCount() != 0 { t.Errorf("response = %d", recorder.Code) } }) } func TestImageVariantsSourceCap(t *testing.T) { for _, row := range []struct { size int status int }{{1000, 200}, {1001, 413}} { t.Run("A source beyond IMAGE_VARIANT_MAX_SOURCE_BYTES answers 413: "+http.StatusText(row.status), func(t *testing.T) { env := newVariantEnvWith(t, newCountingRenderer(), 1000) env.seedThumb() env.seedSource("big.png", padSizedPNG(t, row.size)) recorder := env.get("/static/uploadedImages/big.png?v=thumb", nil) if recorder.Code != row.status { t.Errorf("status = %d", recorder.Code) } }) } t.Run("The 413 body is plain text and nothing is generated", func(t *testing.T) { env := newVariantEnvWith(t, newCountingRenderer(), 1000) env.seedThumb() env.seedSource("big.png", padSizedPNG(t, 1001)) recorder := env.get("/static/uploadedImages/big.png?v=thumb", nil) assertStatusAndBody(t, recorder, 413, "The source image is too large to generate this variant.") if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 { t.Error("something was generated") } }) t.Run("The cap never affects the original", func(t *testing.T) { env := newVariantEnvWith(t, newCountingRenderer(), 1000) content := padSizedPNG(t, 1001) env.seedSource("big.png", content) recorder := env.get("/static/uploadedImages/big.png", nil) if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), content) { t.Errorf("status = %d", recorder.Code) } }) } func TestImageVariantsAsIsSources(t *testing.T) { const svg = `` t.Run("An SVG with a valid variant is served unchanged", func(t *testing.T) { env := servingEnv(t) env.seedSource("e5f6.svg", []byte(svg)) recorder := env.get("/static/uploadedImages/e5f6.svg?v=thumb", nil) if recorder.Code != 200 || recorder.Body.String() != svg || recorder.Header().Get("Content-Type") != "image/svg+xml" || recorder.Header().Get("Content-Security-Policy") != "default-src 'none'; style-src 'unsafe-inline'; sandbox" { t.Errorf("response = %d %v", recorder.Code, recorder.Header()) } if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 || env.files.rowCount() != 0 { t.Error("something was generated") } }) t.Run("An SVG with an unknown variant is still a 400", func(t *testing.T) { env := servingEnv(t) env.seedSource("e5f6.svg", []byte(svg)) recorder := env.get("/static/uploadedImages/e5f6.svg?v=banner", nil) assertRequestError(t, recorder, "unknown_variant", unknownMessage, "banner") }) t.Run("An SVG larger than the cap is still served", func(t *testing.T) { env := newVariantEnvWith(t, newCountingRenderer(), 10) env.seedThumb() env.seedSource("e5f6.svg", []byte(svg)) recorder := env.get("/static/uploadedImages/e5f6.svg?v=thumb", nil) if recorder.Code != 200 || recorder.Body.String() != svg { t.Errorf("response = %d", recorder.Code) } }) t.Run("An animated GIF is served unchanged", func(t *testing.T) { env := servingEnv(t) animation := animatedGIF(t) env.seedSource("anim.gif", animation) recorder := env.get("/static/uploadedImages/anim.gif?v=thumb", nil) if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), animation) || recorder.Header().Get("Content-Type") != "image/gif" || env.files.rowCount() != 0 || env.renderer.renderCount() != 0 { t.Errorf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type")) } }) t.Run("A single-frame GIF is treated like any raster source", func(t *testing.T) { env := servingEnv(t) env.seedSource("still.gif", stillGIF(t)) recorder := env.get("/static/uploadedImages/still.gif?v=thumb", nil) if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/jpeg" || env.renderer.renderCount() != 1 { t.Errorf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type")) } }) t.Run("A stored GIF is served with the image/gif content type", func(t *testing.T) { env := servingEnv(t) env.seedSource("anim.gif", animatedGIF(t)) recorder := env.get("/static/uploadedImages/anim.gif", nil) if recorder.Header().Get("Content-Type") != "image/gif" { t.Errorf("content type = %q", recorder.Header().Get("Content-Type")) } }) } func TestImageVariantsServeFormats(t *testing.T) { t.Run("A webp variant is served as image/webp with the right dimensions", func(t *testing.T) { env := servingEnv(t) seedVariant(env.variants, "wp", 200, 0, domain.FitCover, domain.FormatWebP, 70) recorder := env.get("/static/uploadedImages/a1b2.png?v=wp", nil) width, height := webpSize(t, recorder.Body.Bytes()) if recorder.Header().Get("Content-Type") != "image/webp" || width != 200 || height != 160 { t.Errorf("response = %q %dx%d", recorder.Header().Get("Content-Type"), width, height) } }) t.Run("The content type follows the generated file, not the source", func(t *testing.T) { env := servingEnv(t) seedVariant(env.variants, "logo", 100, 0, domain.FitCover, domain.FormatPNG, 0) recorder := env.get("/static/uploadedImages/a1b2.png?v=logo", nil) if recorder.Header().Get("Content-Type") != "image/png" || !bytes.HasPrefix(recorder.Body.Bytes(), []byte("\x89PNG")) { t.Errorf("content type = %q", recorder.Header().Get("Content-Type")) } }) }