diff --git a/internal/handlers/image_cache_control_internal_test.go b/internal/handlers/image_cache_control_internal_test.go new file mode 100644 index 0000000..a7522da --- /dev/null +++ b/internal/handlers/image_cache_control_internal_test.go @@ -0,0 +1,201 @@ +package handlers + +import ( + "image/color" + "log/slog" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/go-chi/chi/v5" + + "sneak.berlin/go/pixa/internal/encurl" + "sneak.berlin/go/pixa/internal/imgcache" +) + +// photoPath is the path of the JPEG that newSignedHostServer serves. +const photoPath = "/images/photo.jpg" + +// newSignedHostServer returns a router for both image routes, and the Handlers +// behind it, whose fetcher serves a JPEG at photoPath on signedHost. signedHost +// is not on the allowlist, so a /v1/image/ URL for it is served only with a +// valid signature. +func newSignedHostServer(t *testing.T) (*Handlers, http.Handler) { + t.Helper() + + cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{ + StateDir: t.TempDir(), + CacheTTL: time.Hour, + NegativeTTL: 5 * time.Minute, + }) + if err != nil { + t.Fatalf("imgcache.NewCache() error = %v", err) + } + + jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255}) + + svc, err := imgcache.NewService(&imgcache.ServiceConfig{ + Cache: cache, + Fetcher: newMockFetcher(fstest.MapFS{ + signedHost + photoPath: &fstest.MapFile{Data: jpegData}, + }), + SigningKey: testSigningKey, + }) + if err != nil { + t.Fatalf("imgcache.NewService() error = %v", err) + } + + encGen, err := encurl.NewGenerator(testSigningKey) + if err != nil { + t.Fatalf("encurl.NewGenerator() error = %v", err) + } + + h := &Handlers{ + log: slog.New(slog.DiscardHandler), + imgSvc: svc, + encGen: encGen, + } + + r := chi.NewRouter() + r.Get("/v1/image/*", h.HandleImage()) + r.Get("/v1/e/{token}/*", h.HandleImageEnc()) + + return h, r +} + +// getMaxAge sends a GET for target to srv, requires a 200, and returns the +// max-age of the response's Cache-Control header, which must read +// "public, max-age=, immutable". +func getMaxAge(t *testing.T, srv http.Handler, target string) int { + t.Helper() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil) + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + header := rec.Header().Get("Cache-Control") + t.Logf("GET %s: %d, Cache-Control: %s", target, rec.Code, header) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + value, hasPrefix := strings.CutPrefix(header, "public, max-age=") + value, hasSuffix := strings.CutSuffix(value, ", immutable") + + maxAge, err := strconv.Atoi(value) + if !hasPrefix || !hasSuffix || err != nil { + t.Fatalf("Cache-Control = %q, want public, max-age=, immutable", + header) + } + + return maxAge +} + +// TestHandleImage_SignedURL_MaxAgeEndsAtExp verifies that an image served +// through a signed URL expiring in 60 seconds may be cached for at most those +// 60 seconds. The lower bound of 50 shows the max-age is the time left, not 0. +func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) { + t.Parallel() + + h, srv := newSignedHostServer(t) + + signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{ + SourceHost: signedHost, + SourcePath: photoPath, + Size: imgcache.Size{Width: 50, Height: 50}, + Format: imgcache.FormatJPEG, + }, time.Minute) + if err != nil { + t.Fatalf("GenerateSignedURL() error = %v", err) + } + + maxAge := getMaxAge(t, srv, signedURL) + if maxAge < 50 || maxAge > 60 { + t.Errorf("max-age = %d, want 50 to 60", maxAge) + } +} + +// TestHandleImage_AllowlistedHost_MaxAge verifies the max-age of an image from +// an allowlisted host, which is served without checking sig or exp. A URL with +// no exp may be cached for a year. A URL whose exp has passed is the one request +// that reaches the header after its expiry, and must get 0, never less. +func TestHandleImage_AllowlistedHost_MaxAge(t *testing.T) { + t.Parallel() + + pastExp := strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10) + + tests := []struct { + name string + query string + wantMaxAge int + }{ + {"no exp", "", 31536000}, + {"exp already past", "?exp=" + pastExp, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fix := setupTestHandler(t) + + r := chi.NewRouter() + r.Get("/v1/image/*", fix.handler.HandleImage()) + + maxAge := getMaxAge(t, r, + "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg"+tt.query) + if maxAge != tt.wantMaxAge { + t.Errorf("max-age = %d, want %d", maxAge, tt.wantMaxAge) + } + }) + } +} + +// TestHandleImageEnc_MaxAge verifies that an image served through an encrypted +// URL with a 60 second TTL may be cached for at most those 60 seconds, and that +// one made without a TTL, which never expires, may be cached for a year. +func TestHandleImageEnc_MaxAge(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expiresAt int64 + wantAtLeast int + wantAtMost int + }{ + {"60 second TTL", time.Now().Add(time.Minute).Unix(), 50, 60}, + {"no TTL", 0, 31536000, 31536000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h, srv := newSignedHostServer(t) + + token, err := h.encGen.Generate(&encurl.Payload{ + SourceHost: signedHost, + SourcePath: photoPath, + Width: 50, + Height: 50, + Format: imgcache.FormatJPEG, + ExpiresAt: tt.expiresAt, + }) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + + maxAge := getMaxAge(t, srv, "/v1/e/"+token+"/img.jpg") + if maxAge < tt.wantAtLeast || maxAge > tt.wantAtMost { + t.Errorf("max-age = %d, want %d to %d", + maxAge, tt.wantAtLeast, tt.wantAtMost) + } + }) + } +} diff --git a/internal/handlers/image_signature_internal_test.go b/internal/handlers/image_signature_internal_test.go index c435668..c057514 100644 --- a/internal/handlers/image_signature_internal_test.go +++ b/internal/handlers/image_signature_internal_test.go @@ -14,9 +14,9 @@ import ( ) // signedHost is not on the allowlist setupTestHandler builds, so a request -// for it needs a valid signature. No image is served for it: a request that -// passes the signature check gets 502 from the failed fetch, and one that -// fails the check gets 401. +// for it needs a valid signature. setupTestHandler serves no image for it: a +// request that passes the signature check gets 502 from the failed fetch, and +// one that fails the check gets 401. const signedHost = "signed.example.com" // getImage sends a GET for target to the image route of fix and returns the