Files
pixa/internal/handlers/format_auto_internal_test.go
T
clawbot 5ff5caad6f
check / check (push) Failing after 2s
Add failing tests for the format auto
Tests for #88, before the change:
the format chosen for each Accept header (AVIF-capable, WebP-only,
neither, wildcard only, absent, q=0 on AVIF, malformed), both image
routes serving an auto URL in the chosen format with Vary: Accept, also
on HEAD and 304, the signature covering auto rather than the chosen
format, each chosen format cached apart, and Vary: Accept next to the
CORS middleware's Vary: Origin. They do not compile yet: FormatAuto and
formatForAccept do not exist.

Model: opus-5-5
2026-10-05 02:33:50 +00:00

320 lines
9.7 KiB
Go

package handlers
import (
"errors"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
)
// TestFormatForAccept verifies the format chosen for the format auto from each
// Accept header below, and the error for one that allows none of AVIF, WebP
// and JPEG or is not valid.
func TestFormatForAccept(t *testing.T) {
t.Parallel()
tests := []struct {
name string
accept string
want imgcache.ImageFormat
wantErr error
}{
{"AVIF-capable browser",
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
imgcache.FormatAVIF, nil},
{"WebP-capable browser",
"image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5",
imgcache.FormatWebP, nil},
{"WebP only", "image/webp", imgcache.FormatWebP, nil},
{"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imgcache.FormatJPEG, nil},
{"wildcard only", "*/*", imgcache.FormatJPEG, nil},
{"image wildcard only", "image/*", imgcache.FormatJPEG, nil},
{"absent", "", imgcache.FormatJPEG, nil},
{"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imgcache.FormatWebP, nil},
{"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*",
imgcache.FormatJPEG, nil},
{"upper case and spaces", " Image/AVIF ; Q=0.5 ", imgcache.FormatAVIF, nil},
{"q=0 on JPEG", "image/jpeg;q=0,image/*", "", errNotAcceptable},
{"q=0 on everything", "*/*;q=0", "", errNotAcceptable},
{"PNG only", "image/png", "", errNotAcceptable},
{"malformed media range", "image/", "", errInvalidAccept},
{"q not a number", "image/avif;q=high", "", errInvalidAccept},
{"q above 1", "image/avif;q=2", "", errInvalidAccept},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := formatForAccept(tt.accept)
t.Logf("Accept %q: %q, %v", tt.accept, got, err)
if got != tt.want || !errors.Is(err, tt.wantErr) {
t.Errorf("formatForAccept(%q) = %q, %v, want %q, %v",
tt.accept, got, err, tt.want, tt.wantErr)
}
})
}
}
// autoPhotoURLs returns, by route, a signed /v1/image/ URL and an encrypted
// /v1/e/ URL, both valid for a minute, for the JPEG at photoPath on signedHost
// at 50x50 in the format auto, made with h's image service and generator.
func autoPhotoURLs(t *testing.T, h *Handlers) map[string]string {
t.Helper()
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: photoPath,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatAuto,
}, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
token, err := h.encGen.Generate(&encurl.Payload{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
Format: imgcache.FormatAuto,
ExpiresAt: time.Now().Add(time.Minute).Unix(),
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
return map[string]string{
"/v1/image/": signedURL,
"/v1/e/": "/v1/e/" + token + "/img.jpg",
}
}
// requestImage sends method for target to srv with header and returns the
// response.
func requestImage(
t *testing.T, srv http.Handler, method, target string, header http.Header,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), method, target, nil)
maps.Copy(req.Header, header)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
t.Logf("%s %s with %v: %d, Content-Type %s, Vary %v, X-Pixa-Cache %s",
method, target, header, rec.Code, rec.Header().Get("Content-Type"),
rec.Header().Values("Vary"), rec.Header().Get("X-Pixa-Cache"))
return rec
}
// TestFormatAuto_ChosenFromAccept requests an auto URL on each image route
// with each Accept below, and checks the answer and that it carries
// Vary: Accept. The signed URL is signed for auto, so it is valid whatever
// Accept chooses.
func TestFormatAuto_ChosenFromAccept(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
tests := []struct {
name string
accept []string
wantStatus int
wantType string
}{
{"AVIF accepted", []string{"image/avif,image/webp,*/*;q=0.8"},
http.StatusOK, "image/avif"},
{"WebP accepted", []string{"image/webp,*/*;q=0.8"},
http.StatusOK, "image/webp"},
{"no Accept", nil, http.StatusOK, "image/jpeg"},
{"two Accept lines", []string{"image/png", "image/webp"},
http.StatusOK, "image/webp"},
{"none of the three", []string{"image/png"},
http.StatusNotAcceptable, "application/json"},
{"not valid", []string{"image/avif;q=high"},
http.StatusBadRequest, "application/json"},
}
for route, target := range autoPhotoURLs(t, h) {
for _, tt := range tests {
t.Run(route+" "+tt.name, func(t *testing.T) {
t.Parallel()
rec := requestImage(t, srv, http.MethodGet, target,
http.Header{"Accept": tt.accept})
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d; body %s",
rec.Code, tt.wantStatus, rec.Body)
}
if got := rec.Header().Get("Content-Type"); got != tt.wantType {
t.Errorf("Content-Type = %q, want %q", got, tt.wantType)
}
if !slices.Contains(rec.Header().Values("Vary"), "Accept") {
t.Errorf("Vary = %v, want Accept in it",
rec.Header().Values("Vary"))
}
})
}
}
}
// TestFormatAuto_SignatureCoversAuto verifies that a /v1/image/ URL with the
// format auto is checked against a signature for auto, not for the format
// chosen: a URL signed for avif, with auto put in its path, is refused for a
// client whose Accept chooses AVIF.
func TestFormatAuto_SignatureCoversAuto(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
signedForAVIF, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: photoPath,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatAVIF,
}, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
target := strings.Replace(signedForAVIF, "/50x50.avif?", "/50x50.auto?", 1)
if target == signedForAVIF {
t.Fatalf("no /50x50.avif? in %s", signedForAVIF)
}
rec := requestImage(t, srv, http.MethodGet, target,
http.Header{"Accept": {"image/avif"}})
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
// TestFormatAuto_CachesEachFormatApart requests an auto URL on each image
// route for AVIF, then JPEG, then both again. Each format is processed once
// and then served from the cache, with an ETag of its own, so a client never
// gets the other format from the cache.
func TestFormatAuto_CachesEachFormatApart(t *testing.T) {
t.Parallel()
for _, route := range []string{"/v1/image/", "/v1/e/"} {
t.Run(route, func(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
target := autoPhotoURLs(t, h)[route]
steps := []struct {
accept string
wantType string
wantCache string
}{
{"image/avif", "image/avif", "MISS"},
{"image/jpeg", "image/jpeg", "MISS"},
{"image/avif", "image/avif", "HIT"},
{"image/jpeg", "image/jpeg", "HIT"},
}
etags := make(map[string]string)
for _, step := range steps {
rec := requestImage(t, srv, http.MethodGet, target,
http.Header{"Accept": {step.accept}})
gotType := rec.Header().Get("Content-Type")
gotCache := rec.Header().Get("X-Pixa-Cache")
if rec.Code != http.StatusOK || gotType != step.wantType ||
gotCache != step.wantCache {
t.Fatalf("Accept %s: %d %s %s, want 200 %s %s",
step.accept, rec.Code, gotType, gotCache,
step.wantType, step.wantCache)
}
etag := rec.Header().Get("ETag")
if previous, seen := etags[gotType]; seen && previous != etag {
t.Errorf("%s ETag changed from %s to %s", gotType, previous, etag)
}
etags[gotType] = etag
}
if etags["image/avif"] == etags["image/jpeg"] {
t.Errorf("AVIF and JPEG have the same ETag %s", etags["image/avif"])
}
})
}
}
// TestFormatAuto_Vary verifies that on each image route a HEAD answer and a
// 304 for an auto URL carry Vary: Accept, and that the answer for a URL with
// a fixed format does not.
func TestFormatAuto_Vary(t *testing.T) {
t.Parallel()
h, _ := newSignedHostServer(t, slog.New(slog.DiscardHandler))
srv := chi.NewRouter()
srv.Get("/v1/image/*", h.HandleImage())
srv.Head("/v1/image/*", h.HandleImage())
srv.Get("/v1/e/{token}/*", h.HandleImageEnc())
srv.Head("/v1/e/{token}/*", h.HandleImageEnc())
autoURLs := autoPhotoURLs(t, h)
fixedURLs := map[string]string{
"/v1/image/": signedPhotoURL(t, h),
"/v1/e/": encPhotoURL(t, h),
}
for route, target := range autoURLs {
t.Run(route, func(t *testing.T) {
t.Parallel()
header := http.Header{"Accept": {"image/webp"}}
head := requestImage(t, srv, http.MethodHead, target, header)
checkVaryAccept(t, head, http.StatusOK, true)
header.Set("If-None-Match", head.Header().Get("ETag"))
notModified := requestImage(t, srv, http.MethodGet, target, header)
checkVaryAccept(t, notModified, http.StatusNotModified, true)
fixed := requestImage(t, srv, http.MethodGet, fixedURLs[route], nil)
checkVaryAccept(t, fixed, http.StatusOK, false)
})
}
}
// checkVaryAccept fails the test unless rec answered wantStatus and, as
// wantVary says, has or has not Accept in its Vary header.
func checkVaryAccept(
t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantVary bool,
) {
t.Helper()
if rec.Code != wantStatus {
t.Errorf("status = %d, want %d", rec.Code, wantStatus)
}
vary := rec.Header().Values("Vary")
if slices.Contains(vary, "Accept") != wantVary {
t.Errorf("Vary = %v, want Accept in it: %v", vary, wantVary)
}
}