check / check (push) Canceled after 0s
The image request now holds only its source and its transform, exactly what the variant key covers. The /v1/image/ handler checks the allowlist and verifies the signature itself with Signer.Verify(req, sig, expires). Both image handlers keep the URL's expiry as a local value for Cache-Control. The image service holds no signer, allowlist or AllowHTTP, and ValidateRequest is gone. The fetcher takes the source and builds its URL with Source.URL from its own AllowHTTP, so no URL string is built and parsed again. chooseAutoFormat returns the format it chooses instead of writing into the request. The signed URL format and every route's answers are unchanged. Model: opus-5-5
383 lines
11 KiB
Go
383 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
"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
|
|
}
|
|
|
|
// generateSignedURL returns the /v1/image/ URL of req, signed with key and
|
|
// expiring after ttl. It signs a copy of req, so req itself is not changed.
|
|
// An unset quality or fit in the copy is set to 85 or cover, the values the
|
|
// route uses when a URL has no q or fit. The URL carries q and fit next to sig
|
|
// and exp, so the route verifies it for the quality and fit it was signed
|
|
// with.
|
|
func generateSignedURL(
|
|
key string, req *imagerequest.Request, ttl time.Duration,
|
|
) string {
|
|
signed := *req
|
|
|
|
if signed.Transform.Quality == 0 {
|
|
signed.Transform.Quality = 85
|
|
}
|
|
|
|
if signed.Transform.FitMode == "" {
|
|
signed.Transform.FitMode = imagerequest.FitCover
|
|
}
|
|
|
|
expires := time.Now().Add(ttl)
|
|
sig := signature.New(key).Sign(&signed, expires)
|
|
|
|
// A source query follows the source path after a "?". The image route
|
|
// reads the path decoded once, so the whole path is written in Go's own
|
|
// percent-encoding of a URL path, which writes "?" as %3F and "%" as %25,
|
|
// and leaves "/", ";", ",", "=" and "&" as they are.
|
|
sourcePathAndQuery := signed.Source.Path
|
|
if signed.Source.Query != "" {
|
|
sourcePathAndQuery += "?" + signed.Source.Query
|
|
}
|
|
|
|
unescapedPath := fmt.Sprintf("/v1/image/%s%s/%dx%d.%s",
|
|
signed.Source.Host, sourcePathAndQuery,
|
|
signed.Transform.Size.Width, signed.Transform.Size.Height,
|
|
signed.Transform.Format)
|
|
|
|
return fmt.Sprintf("%s?sig=%s&exp=%d&q=%d&fit=%s",
|
|
(&url.URL{Path: unescapedPath}).EscapedPath(), sig, expires.Unix(),
|
|
signed.Transform.Quality, signed.Transform.FitMode)
|
|
}
|
|
|
|
// 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(&imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: signedHost,
|
|
Path: "/images/photo.jpg",
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}, 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
|
|
// generateSignedURL to the route.
|
|
func TestHandleImage_GeneratedSignedURLVerifies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
quality int
|
|
fitMode imagerequest.Fit
|
|
}{
|
|
{"quality 40 and fit contain", 40, imagerequest.FitContain},
|
|
{"quality and fit unset", 0, ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
|
|
&imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: signedHost,
|
|
Path: "/images/photo.jpg",
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: tt.quality,
|
|
FitMode: tt.fitMode,
|
|
},
|
|
}, time.Hour)
|
|
|
|
status := getImage(t, setupTestHandler(t), 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
|
|
// by generateSignedURL for source URLs with a query to the route, through a
|
|
// chi router: chi routes on the path as the client wrote it whenever that is
|
|
// not Go's own encoding of it. A 502 means the signature passed, so the route
|
|
// read the source path and query that were signed.
|
|
func TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
query string
|
|
}{
|
|
{"slash and colon", "url=https://origin.example.com/cat.jpg"},
|
|
{"semicolon", "a=1;b=2"},
|
|
{"comma", "crop=0,0,50,50"},
|
|
{"equals and ampersand", "a=1&b=2"},
|
|
{"percent-escape", "name=my%20cat"},
|
|
{"question mark", "next=/a?b=1"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
|
|
&imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: signedHost,
|
|
Path: photoPath,
|
|
Query: tt.query,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
},
|
|
}, time.Hour)
|
|
|
|
status := getImage(t, setupTestHandler(t), signedURL)
|
|
if status != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_SourceQueryEncodedAnyWay signs one source URL with a query
|
|
// and writes it into the image URL in ways the README allows: the "?" before
|
|
// the query and each "%" in it percent-encoded, and every other character as
|
|
// itself or percent-encoded. A 502 means the signature passed.
|
|
func TestHandleImage_SourceQueryEncodedAnyWay(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
expires := time.Now().Add(time.Hour)
|
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
|
sig := signer.Sign(&imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: signedHost,
|
|
Path: photoPath,
|
|
Query: "url=https://origin.example.com/my%20cat.jpg",
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}, expires)
|
|
|
|
tests := []struct {
|
|
name string
|
|
writtenQuery string
|
|
}{
|
|
{"only ? and % encoded",
|
|
"%3Furl=https://origin.example.com/my%2520cat.jpg"},
|
|
{"every reserved character encoded",
|
|
"%3Furl%3Dhttps%3A%2F%2Forigin.example.com%2Fmy%2520cat.jpg"},
|
|
{"lower-case hex digits",
|
|
"%3furl=https://origin.example.com/my%2520cat.jpg"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
target := fmt.Sprintf("/v1/image/%s%s%s/50x50.jpeg?sig=%s&exp=%d",
|
|
signedHost, photoPath, tt.writtenQuery, sig, expires.Unix())
|
|
|
|
status := getImage(t, setupTestHandler(t), target)
|
|
if status != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_SourceURLHoldingPercent25Verifies signs a source path and a
|
|
// source query that each hold %25, the percent-escape of a literal "%", and
|
|
// sends each to the route written as the README says, with that "%" written as
|
|
// %25, and as generateSignedURL writes it. A 502 means the signature passed.
|
|
func TestHandleImage_SourceURLHoldingPercent25Verifies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
sourcePath string
|
|
sourceQuery string
|
|
// writtenPathAndQuery is the source path and query as the README says
|
|
// to write them into the image URL
|
|
writtenPathAndQuery string
|
|
}{
|
|
{"path", "/sale/50%25off.jpg", "", "/sale/50%2525off.jpg"},
|
|
{"query", photoPath, "discount=50%25", photoPath + "%3Fdiscount=50%2525"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name+" written by the README rule", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
expires := time.Now().Add(time.Hour)
|
|
signer := signature.New("test-signing-key-must-be-32-chars")
|
|
sig := signer.Sign(&imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: signedHost,
|
|
Path: tt.sourcePath,
|
|
Query: tt.sourceQuery,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
}, expires)
|
|
|
|
target := fmt.Sprintf("/v1/image/%s%s/50x50.jpeg?sig=%s&exp=%d",
|
|
signedHost, tt.writtenPathAndQuery, sig, expires.Unix())
|
|
|
|
status := getImage(t, setupTestHandler(t), target)
|
|
if status != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
|
}
|
|
})
|
|
|
|
t.Run(tt.name+" written by generateSignedURL", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signedURL := generateSignedURL("test-signing-key-must-be-32-chars",
|
|
&imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: signedHost,
|
|
Path: tt.sourcePath,
|
|
Query: tt.sourceQuery,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
},
|
|
}, time.Hour)
|
|
|
|
status := getImage(t, setupTestHandler(t), signedURL)
|
|
if status != http.StatusBadGateway {
|
|
t.Errorf("status = %d, want %d", status, http.StatusBadGateway)
|
|
}
|
|
})
|
|
}
|
|
}
|