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) } }