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
311 lines
9.5 KiB
Go
311 lines
9.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"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"
|
|
)
|
|
|
|
// The content types the tests below expect.
|
|
const (
|
|
avifType = "image/avif"
|
|
webpType = "image/webp"
|
|
jpegType = "image/jpeg"
|
|
jsonType = "application/json"
|
|
)
|
|
|
|
// 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", webpType, 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 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) (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 signedURL, "/v1/e/" + token + "/img.jpg"
|
|
}
|
|
|
|
// requestImage sends method for target to srv with an Accept header line for
|
|
// each of accept, and returns the response.
|
|
func requestImage(
|
|
t *testing.T, srv http.Handler, method, target string, accept ...string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), method, target, nil)
|
|
|
|
for _, value := range accept {
|
|
req.Header.Add("Accept", value)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
srv.ServeHTTP(rec, req)
|
|
t.Logf("%s %s with Accept %q: %d, Content-Type %s, Vary %v, X-Pixa-Cache %s",
|
|
method, target, accept, 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))
|
|
signedURL, encryptedURL := autoPhotoURLs(t, h)
|
|
|
|
tests := []struct {
|
|
name string
|
|
accept []string
|
|
wantStatus int
|
|
wantType string
|
|
}{
|
|
{"AVIF accepted", []string{"image/avif,image/webp,*/*;q=0.8"},
|
|
http.StatusOK, avifType},
|
|
{"WebP accepted", []string{"image/webp,*/*;q=0.8"}, http.StatusOK, webpType},
|
|
{"no Accept", nil, http.StatusOK, jpegType},
|
|
{"two Accept lines", []string{"image/png", webpType}, http.StatusOK, webpType},
|
|
{"none of the three", []string{"image/gif"},
|
|
http.StatusNotAcceptable, jsonType},
|
|
{"not valid", []string{"image/avif;q=high"}, http.StatusBadRequest, jsonType},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, target := range []string{signedURL, encryptedURL} {
|
|
rec := requestImage(t, srv, http.MethodGet, target, tt.accept...)
|
|
gotType := rec.Header().Get("Content-Type")
|
|
|
|
if rec.Code != tt.wantStatus || gotType != tt.wantType {
|
|
t.Errorf("%s: %d %s, want %d %s; body %s", target,
|
|
rec.Code, gotType, tt.wantStatus, tt.wantType, rec.Body)
|
|
}
|
|
|
|
if !slices.Contains(rec.Header().Values("Vary"), "Accept") {
|
|
t.Errorf("%s: Vary = %v, want Accept in it",
|
|
target, 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, avifType)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
// TestFormatAuto_CachesEachFormatApart requests an auto URL 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()
|
|
|
|
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
|
signedURL, _ := autoPhotoURLs(t, h)
|
|
|
|
steps := []struct {
|
|
wantType string
|
|
wantCache string
|
|
}{
|
|
{avifType, "MISS"},
|
|
{jpegType, "MISS"},
|
|
{avifType, "HIT"},
|
|
{jpegType, "HIT"},
|
|
}
|
|
|
|
etags := make(map[string]string)
|
|
|
|
for _, step := range steps {
|
|
rec := requestImage(t, srv, http.MethodGet, signedURL, step.wantType)
|
|
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.wantType,
|
|
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[avifType] == etags[jpegType] {
|
|
t.Errorf("AVIF and JPEG have the same ETag %s", etags[avifType])
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
|
|
signedURL, encryptedURL := autoPhotoURLs(t, h)
|
|
|
|
for _, urls := range [][2]string{
|
|
{signedURL, signedPhotoURL(t, h)},
|
|
{encryptedURL, encPhotoURL(t, h)},
|
|
} {
|
|
autoURL, fixedURL := urls[0], urls[1]
|
|
|
|
head := requestImage(t, srv, http.MethodHead, autoURL, webpType)
|
|
checkVaryAccept(t, head, http.StatusOK, true)
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
|
autoURL, nil)
|
|
req.Header.Set("Accept", webpType)
|
|
req.Header.Set("If-None-Match", head.Header().Get("ETag"))
|
|
|
|
notModified := httptest.NewRecorder()
|
|
srv.ServeHTTP(notModified, req)
|
|
checkVaryAccept(t, notModified, http.StatusNotModified, true)
|
|
|
|
fixed := requestImage(t, srv, http.MethodGet, fixedURL)
|
|
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()
|
|
|
|
vary := rec.Header().Values("Vary")
|
|
t.Logf("status %d, Vary %v", rec.Code, vary)
|
|
|
|
if rec.Code != wantStatus {
|
|
t.Errorf("status = %d, want %d", rec.Code, wantStatus)
|
|
}
|
|
|
|
if slices.Contains(vary, "Accept") != wantVary {
|
|
t.Errorf("Vary = %v, want Accept in it: %v", vary, wantVary)
|
|
}
|
|
}
|