check / check (push) Failing after 1s
New tests in internal/handlers, with no network: the status and JSON error body the image route answers for a missing, wrong, unpadded, upper-case or expired signature on a host not on the allowlist, or a valid one sent for its parent domain, a sibling host, a subdomain or the host with another domain appended; an unparseable path, localhost as the upstream host, and an upstream error; that an allowlisted host is served without a signature and another host only with a valid one; and the answers of /robots.txt and the health check. An expired signature is answered 401, as the code and README.md say, where the issue body expected 410. No code changes. Model: opus-5-5
268 lines
8.1 KiB
Go
268 lines
8.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"image/color"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
"sneak.berlin/go/pixa/internal/signature"
|
|
)
|
|
|
|
// allowlistedHost is the only host on the allowlist of the image route
|
|
// newImageRoute builds.
|
|
const allowlistedHost = "allowed.example.com"
|
|
|
|
// newImageRoute returns the image route of a Handlers whose service fetches
|
|
// with fetcher and checks signatures with testSigningKey.
|
|
func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
|
t.Helper()
|
|
|
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
|
StateDir: t.TempDir(),
|
|
CacheTTL: time.Hour,
|
|
NegativeTTL: 5 * time.Minute,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create cache: %v", err)
|
|
}
|
|
|
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
|
Cache: cache,
|
|
Fetcher: fetcher,
|
|
SigningKey: testSigningKey,
|
|
Allowlist: []string{allowlistedHost},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
h := &Handlers{imgSvc: svc, log: slog.New(slog.DiscardHandler)}
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/v1/image/*", h.HandleImage())
|
|
|
|
return r
|
|
}
|
|
|
|
// newPhotoFetcher returns a mock fetcher that serves a JPEG at photoPath on
|
|
// each of hosts, and answers any other URL with an upstream error.
|
|
func newPhotoFetcher(t *testing.T, hosts ...string) *httpfetcher.MockFetcher {
|
|
t.Helper()
|
|
|
|
photo := &fstest.MapFile{
|
|
Data: generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255}),
|
|
}
|
|
|
|
files := fstest.MapFS{}
|
|
for _, host := range hosts {
|
|
files[host+photoPath] = photo
|
|
}
|
|
|
|
return httpfetcher.NewMock(files)
|
|
}
|
|
|
|
// photoURL returns the image route URL of photoPath on host, as a 50x50 JPEG.
|
|
func photoURL(host string) string {
|
|
return "/v1/image/" + host + photoPath + "/50x50.jpeg"
|
|
}
|
|
|
|
// photoURLWithSig returns photoURL(host) with sig and expires as its sig and
|
|
// exp.
|
|
func photoURLWithSig(host, sig string, expires time.Time) string {
|
|
return fmt.Sprintf("%s?sig=%s&exp=%d", photoURL(host), sig, expires.Unix())
|
|
}
|
|
|
|
// photoSignature returns the signature of photoURL(host) at the default
|
|
// quality and fit, made with key and expiring at expires.
|
|
func photoSignature(key, host string, expires time.Time) string {
|
|
return signature.New(key).Sign(&signature.Request{
|
|
SourceHost: host,
|
|
SourcePath: photoPath,
|
|
Width: 50,
|
|
Height: 50,
|
|
Format: string(imgcache.FormatJPEG),
|
|
Quality: 85,
|
|
FitMode: string(imgcache.FitCover),
|
|
Expires: expires,
|
|
})
|
|
}
|
|
|
|
// sendGet sends a GET for target to route and returns the response.
|
|
func sendGet(
|
|
t *testing.T, route http.Handler, target string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
route.ServeHTTP(rec, req)
|
|
t.Logf("GET %s: %d", target, rec.Code)
|
|
|
|
return rec
|
|
}
|
|
|
|
// checkErrorBody checks that rec has status wantStatus and the JSON error body
|
|
// the image route sends: wantError, wantStatus and the time in RFC 3339.
|
|
func checkErrorBody(
|
|
t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantError string,
|
|
) {
|
|
t.Helper()
|
|
|
|
if rec.Code != wantStatus {
|
|
t.Errorf("status = %d, want %d", rec.Code, wantStatus)
|
|
}
|
|
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
|
|
var body struct {
|
|
Error string `json:"error"`
|
|
Status int `json:"status"`
|
|
Timestamp string `json:"timestamp"`
|
|
}
|
|
|
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
|
if err != nil {
|
|
t.Fatalf("decoding response body: %v", err)
|
|
}
|
|
|
|
if body.Error != wantError || body.Status != wantStatus {
|
|
t.Errorf("body error and status = %q %d, want %q %d",
|
|
body.Error, body.Status, wantError, wantStatus)
|
|
}
|
|
|
|
_, err = time.Parse(time.RFC3339, body.Timestamp)
|
|
if err != nil {
|
|
t.Errorf("body timestamp: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_ErrorAnswers checks the status and the JSON error body the
|
|
// image route answers each request below with. The JPEG at photoPath exists on
|
|
// signedHost and on each host below that differs from it, so a request refused
|
|
// with 401 would otherwise be served.
|
|
func TestHandleImage_ErrorAnswers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A signature for signedHost must not verify for any of these.
|
|
parentHost := "example.com"
|
|
siblingHost := "other.example.com"
|
|
subdomainHost := "img." + signedHost
|
|
appendedHost := signedHost + ".example.net"
|
|
|
|
photos := newPhotoFetcher(t,
|
|
signedHost, parentHost, siblingHost, subdomainHost, appendedHost)
|
|
// The real fetcher refuses localhost before any lookup or connection.
|
|
realFetcher := httpfetcher.New(httpfetcher.DefaultConfig())
|
|
|
|
exp := time.Now().Add(time.Hour)
|
|
expired := time.Now().Add(-time.Hour)
|
|
sig := photoSignature(testSigningKey, signedHost, exp)
|
|
otherKeySig := photoSignature("another-signing-key", signedHost, exp)
|
|
expiredSig := photoSignature(testSigningKey, signedHost, expired)
|
|
localhostSig := photoSignature(testSigningKey, "localhost", exp)
|
|
|
|
// The error every request refused for its signature gets.
|
|
const unauthorized = "unauthorized"
|
|
|
|
tests := []struct {
|
|
name string
|
|
fetcher httpfetcher.Fetcher
|
|
target string
|
|
wantStatus int
|
|
wantError string
|
|
}{
|
|
{"no sig or exp", photos, photoURL(signedHost),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"exp but no sig", photos,
|
|
fmt.Sprintf("%s?exp=%d", photoURL(signedHost), exp.Unix()),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig made with another key", photos,
|
|
photoURLWithSig(signedHost, otherKeySig, exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig without its = padding", photos,
|
|
photoURLWithSig(signedHost, strings.TrimRight(sig, "="), exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig in upper case", photos,
|
|
photoURLWithSig(signedHost, strings.ToUpper(sig), exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"expired sig", photos, photoURLWithSig(signedHost, expiredSig, expired),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig sent for the parent domain", photos,
|
|
photoURLWithSig(parentHost, sig, exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig sent for a sibling host", photos,
|
|
photoURLWithSig(siblingHost, sig, exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig sent for a subdomain", photos,
|
|
photoURLWithSig(subdomainHost, sig, exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"sig sent with another domain appended", photos,
|
|
photoURLWithSig(appendedHost, sig, exp),
|
|
http.StatusUnauthorized, unauthorized},
|
|
{"unparseable path", photos,
|
|
"/v1/image/" + allowlistedHost + photoPath + "/big.jpeg",
|
|
http.StatusBadRequest, "invalid image URL: invalid size format"},
|
|
{"blocked upstream address", realFetcher,
|
|
photoURLWithSig("localhost", localhostSig, exp),
|
|
http.StatusForbidden, "forbidden"},
|
|
{"upstream error", photos,
|
|
"/v1/image/" + allowlistedHost + "/images/missing.jpg/50x50.jpeg",
|
|
http.StatusBadGateway, "upstream error"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := sendGet(t, newImageRoute(t, tt.fetcher), tt.target)
|
|
checkErrorBody(t, rec, tt.wantStatus, tt.wantError)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_AllowlistOrSignature checks that the image route serves an
|
|
// image without a signature for a host on the allowlist only, and for another
|
|
// host only with a valid signature.
|
|
func TestHandleImage_AllowlistOrSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
photos := newPhotoFetcher(t, allowlistedHost, signedHost)
|
|
exp := time.Now().Add(time.Hour)
|
|
sig := photoSignature(testSigningKey, signedHost, exp)
|
|
|
|
tests := []struct {
|
|
name string
|
|
target string
|
|
wantStatus int
|
|
}{
|
|
{"allowlisted host, no sig", photoURL(allowlistedHost), http.StatusOK},
|
|
{"other host, no sig", photoURL(signedHost), http.StatusUnauthorized},
|
|
{"other host, valid sig", photoURLWithSig(signedHost, sig, exp),
|
|
http.StatusOK},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := sendGet(t, newImageRoute(t, photos), tt.target)
|
|
if rec.Code != tt.wantStatus {
|
|
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|