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
172 lines
4.7 KiB
Go
172 lines
4.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
"sneak.berlin/go/pixa/internal/signature"
|
|
)
|
|
|
|
// signedHost is not on the allowlist setupTestHandler builds, so a request
|
|
// 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
|
|
// response status.
|
|
func getImage(t *testing.T, fix *testFixtures, target string) int {
|
|
t.Helper()
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(rec, req)
|
|
t.Logf("GET %s: %d", target, rec.Code)
|
|
|
|
return rec.Code
|
|
}
|
|
|
|
// TestHandleImage_SignatureCoversQualityAndFit signs a URL for quality 85
|
|
// and fit cover, the values the route uses when a URL has no q or fit, and
|
|
// sends that signature with each q and fit below.
|
|
func TestHandleImage_SignatureCoversQualityAndFit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
expires := time.Now().Add(time.Hour)
|
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
|
sig := signer.Sign(&signature.Request{
|
|
SourceHost: signedHost,
|
|
SourcePath: "/images/photo.jpg",
|
|
Width: 50,
|
|
Height: 50,
|
|
Format: string(imgcache.FormatJPEG),
|
|
Quality: 85,
|
|
FitMode: string(imgcache.FitCover),
|
|
Expires: expires,
|
|
})
|
|
signedURL := fmt.Sprintf("/v1/image/%s/images/photo.jpg/50x50.jpeg?sig=%s&exp=%d",
|
|
signedHost, sig, expires.Unix())
|
|
|
|
tests := []struct {
|
|
name string
|
|
query string
|
|
wantStatus int
|
|
}{
|
|
{"no q or fit", "", http.StatusBadGateway},
|
|
{"q=85 and fit=cover", "&q=85&fit=cover", http.StatusBadGateway},
|
|
{"replayed with q=40", "&q=40", http.StatusUnauthorized},
|
|
{"replayed with fit=contain", "&fit=contain", http.StatusUnauthorized},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
status := getImage(t, setupTestHandler(t), signedURL+tt.query)
|
|
if status != tt.wantStatus {
|
|
t.Errorf("status = %d, want %d", status, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_GeneratedSignedURLVerifies sends URLs built by the
|
|
// service's signed-URL generator to the route.
|
|
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
quality int
|
|
fitMode imgcache.FitMode
|
|
}{
|
|
{"quality 40 and fit contain", 40, imgcache.FitContain},
|
|
{"quality and fit unset", 0, ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fix := setupTestHandler(t)
|
|
|
|
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
|
SourceHost: signedHost,
|
|
SourcePath: "/images/photo.jpg",
|
|
Size: imgcache.Size{Width: 50, Height: 50},
|
|
Format: imgcache.FormatJPEG,
|
|
Quality: tt.quality,
|
|
FitMode: tt.fitMode,
|
|
}, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GenerateSignedURL() error = %v", err)
|
|
}
|
|
|
|
status := getImage(t, fix, signedURL)
|
|
if status != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_InvalidExp_Returns400 sends a signed-host URL whose exp is
|
|
// not a whole number, and one whose exp is empty. Each is refused with 400
|
|
// naming exp and the value, not with the 401 a URL without exp still gets.
|
|
func TestHandleImage_InvalidExp_Returns400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
query string
|
|
wantStatus int
|
|
wantError string
|
|
}{
|
|
{"sig=x&exp=banana", http.StatusBadRequest,
|
|
`invalid exp: not a number, got "banana"`},
|
|
{"sig=x&exp=", http.StatusBadRequest, `invalid exp: not a number, got ""`},
|
|
{"sig=x", http.StatusUnauthorized, "unauthorized"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.query, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fix := setupTestHandler(t)
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/v1/image/*", fix.handler.HandleImage())
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
|
"/v1/image/"+signedHost+"/images/photo.jpg/50x50.jpeg?"+tt.query, nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(rec, req)
|
|
t.Logf("GET %s: %d %s", req.URL, rec.Code, rec.Body)
|
|
|
|
var body struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
|
if err != nil {
|
|
t.Fatalf("decoding response body: %v", err)
|
|
}
|
|
|
|
if rec.Code != tt.wantStatus || body.Error != tt.wantError {
|
|
t.Errorf("got %d %q, want %d %q",
|
|
rec.Code, body.Error, tt.wantStatus, tt.wantError)
|
|
}
|
|
})
|
|
}
|
|
}
|