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, that one // with a two-year TTL may be cached for a year, 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}, {"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour).Unix(), 31536000, 31536000}, {"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) } }) } }