check / check (push) Successful in 12s
An exp that was not a whole number, or empty, was ignored, so a URL for a host that needs a signature got 401 as if it had no exp. It is now a 400 naming exp and the value, on every host; only an exp missing from the URL is unchanged. A failed variant .meta write, source metadata JSON write, Stats count query, stats counter update, negative cache write or expired negative cache delete was discarded without a trace. Each is now logged at warn with the path or key and the error, and stays non-fatal, with tests for those that can be made to fail. VariantStorage takes the cache's logger. 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. 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.
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|