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
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user