check / check (push) Canceled after 0s
The query follows the source path after %3F. The image route now parses the request's path decoded once instead of chi's wildcard, which holds the path as the client wrote it whenever that is not Go's own encoding of it, so a / ; or , written as %2F, %3B or %2C, as GenerateSignedURL wrote them, hid the %3F and the request failed with 401. Now the ? and every % in the source path or query must be percent-encoded and any other character may be. GenerateSignedURL writes Go's own encoding of the path. The README states the rule, with an example and its signature. Model: opus-5-5
265 lines
7.6 KiB
Go
265 lines
7.6 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_GeneratedSignedURLWithSourceQueryVerifies sends URLs built
|
|
// by the service's signed-URL generator 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()
|
|
|
|
fix := setupTestHandler(t)
|
|
|
|
signedURL, err := fix.service.GenerateSignedURL("", &imgcache.ImageRequest{
|
|
SourceHost: signedHost,
|
|
SourcePath: photoPath,
|
|
SourceQuery: tt.query,
|
|
Size: imgcache.Size{Width: 50, Height: 50},
|
|
Format: imgcache.FormatJPEG,
|
|
}, 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_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(&signature.Request{
|
|
SourceHost: signedHost,
|
|
SourcePath: photoPath,
|
|
SourceQuery: "url=https://origin.example.com/my%20cat.jpg",
|
|
Width: 50,
|
|
Height: 50,
|
|
Format: string(imgcache.FormatJPEG),
|
|
Quality: 85,
|
|
FitMode: string(imgcache.FitCover),
|
|
Expires: 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)
|
|
}
|
|
})
|
|
}
|
|
}
|