check / check (push) Waiting to run
The table set the expiry when it was built. The parallel subtests can wait more than 10 seconds behind other tests on a busy host, so the 60 second case saw a max-age under 50. The table now holds the TTL, and each subtest computes the expiry just before generating its token. Model: opus-5-5
233 lines
6.5 KiB
Go
233 lines
6.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"image/color"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
|
|
"sneak.berlin/go/pixa/internal/allowlist"
|
|
"sneak.berlin/go/pixa/internal/encurl"
|
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
"sneak.berlin/go/pixa/internal/imageproxy"
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
"sneak.berlin/go/pixa/internal/seal"
|
|
"sneak.berlin/go/pixa/internal/signature"
|
|
)
|
|
|
|
// 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. The handlers and the image service log to log.
|
|
func newSignedHostServer(
|
|
t *testing.T, log *slog.Logger,
|
|
) (*Handlers, http.Handler) {
|
|
t.Helper()
|
|
|
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.Config{
|
|
StateDir: t.TempDir(),
|
|
NegativeTTL: 5 * time.Minute,
|
|
MaxBytes: 1 << 30,
|
|
}, prometheus.NewRegistry())
|
|
if err != nil {
|
|
t.Fatalf("imgcache.NewCache() error = %v", err)
|
|
}
|
|
|
|
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
|
|
|
|
svc, err := imageproxy.New(&imageproxy.Config{
|
|
Cache: cache,
|
|
Fetcher: newMockFetcher(fstest.MapFS{
|
|
signedHost + photoPath: &fstest.MapFile{Data: jpegData},
|
|
}),
|
|
Processor: imageprocessor.New(imageprocessor.Params{
|
|
MaxInputBytes: 50 << 20,
|
|
MaxConcurrentProcessing: 2,
|
|
}),
|
|
MaxResponseSize: 50 << 20,
|
|
Logger: log,
|
|
}, prometheus.NewRegistry())
|
|
if err != nil {
|
|
t.Fatalf("imageproxy.New() error = %v", err)
|
|
}
|
|
|
|
keys, err := seal.DeriveKeys(testSigningKey)
|
|
if err != nil {
|
|
t.Fatalf("seal.DeriveKeys() error = %v", err)
|
|
}
|
|
|
|
encGen := encurl.NewGenerator(keys.EncryptedURLKey)
|
|
|
|
h := &Handlers{
|
|
log: log,
|
|
imgSvc: svc,
|
|
encGen: encGen,
|
|
signer: signature.New(testSigningKey),
|
|
allowlist: allowlist.New(nil),
|
|
}
|
|
|
|
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()
|
|
|
|
_, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
|
|
|
signedURL := generateSignedURL(testSigningKey, &imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: signedHost,
|
|
Path: photoPath,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
},
|
|
}, time.Minute)
|
|
|
|
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
|
|
ttl time.Duration
|
|
wantAtLeast int
|
|
wantAtMost int
|
|
}{
|
|
{"60 second TTL", time.Minute, 50, 60},
|
|
{"two-year TTL", 2 * 365 * 24 * time.Hour, 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, slog.New(slog.DiscardHandler))
|
|
|
|
// The expiry is set here, not in the table: a parallel subtest
|
|
// can wait behind other tests for more than the 10 seconds the
|
|
// 60 second case allows.
|
|
var expires time.Time
|
|
if tt.ttl != 0 {
|
|
expires = time.Now().Add(tt.ttl)
|
|
}
|
|
|
|
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.FormatJPEG,
|
|
},
|
|
}, expires)
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|