check / check (push) Canceled after 0s
The image request now holds only its source and its transform, exactly what the variant key covers. The /v1/image/ handler checks the allowlist and verifies the signature itself with Signer.Verify(req, sig, expires). Both image handlers keep the URL's expiry as a local value for Cache-Control. The image service holds no signer, allowlist or AllowHTTP, and ValidateRequest is gone. The fetcher takes the source and builds its URL with Source.URL from its own AllowHTTP, so no URL string is built and parsed again. chooseAutoFormat returns the format it chooses instead of writing into the request. The signed URL format and every route's answers are unchanged. Model: opus-5-5
316 lines
9.7 KiB
Go
316 lines
9.7 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/imagerequest"
|
|
)
|
|
|
|
// 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 imagerequest.Format
|
|
wantErr error
|
|
}{
|
|
{"AVIF-capable browser",
|
|
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
|
imagerequest.FormatAVIF, nil},
|
|
{"WebP-capable browser",
|
|
"image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5",
|
|
imagerequest.FormatWebP, nil},
|
|
{"WebP only", webpType, imagerequest.FormatWebP, nil},
|
|
{"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imagerequest.FormatJPEG, nil},
|
|
{"wildcard only", "*/*", imagerequest.FormatJPEG, nil},
|
|
{"image wildcard only", "image/*", imagerequest.FormatJPEG, nil},
|
|
{"absent", "", imagerequest.FormatJPEG, nil},
|
|
{"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imagerequest.FormatWebP, nil},
|
|
{"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*",
|
|
imagerequest.FormatJPEG, nil},
|
|
{"upper case and spaces", " Image/AVIF ; Q=0.5 ", imagerequest.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. The first is signed with testSigningKey, the key of the
|
|
// image service newSignedHostServer builds, and the second is made with h's
|
|
// generator.
|
|
func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) {
|
|
t.Helper()
|
|
|
|
signedURL := generateSignedURL(testSigningKey, &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: signedHost,
|
|
Path: photoPath,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatAuto,
|
|
},
|
|
}, time.Minute)
|
|
|
|
token, err := h.encGen.Generate(&imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: signedHost,
|
|
Path: photoPath,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatAuto,
|
|
},
|
|
}, time.Now().Add(time.Minute))
|
|
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()
|
|
|
|
_, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
|
|
|
signedForAVIF := generateSignedURL(testSigningKey, &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: signedHost,
|
|
Path: photoPath,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatAVIF,
|
|
},
|
|
}, time.Minute)
|
|
|
|
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)
|
|
}
|
|
}
|