Test that max-age never outlives an expiring image URL (closes #63)
Route tests for both image routes. An image served through a signed URL expiring in 60 seconds, or an encrypted URL with a 60 second TTL, must get a max-age of at most 60. A URL with no expiry keeps one year. An allowlisted URL whose exp has already passed, which is served without checking exp, must get 0. The expiring cases fail until the fix that follows. Model: opus-5-5
This commit is contained in:
@@ -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=<seconds>, 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=<seconds>, 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user