check / check (push) Waiting to run
The new package internal/providers builds the cache, the fetcher, the processor, the image service, the session manager and the encrypted URL generator from the config. pixad's fx graph provides them with providers.Constructors(), and handlers.New receives them built. The server tests build the same graph, with only the fetcher's dial function replaced. database.New opens the database, so these parts can read it while fx builds them; the cache's stop hook still runs before the database's. Each setting's default is declared in internal/config, and internal/providers works out the default cache_max_bytes. The cache's MaxBytes is the one value for the disk cache limit, and 0 turns the disk cache off. The fetcher and the processor take explicit values. Model: opus-5-5
411 lines
13 KiB
Go
411 lines
13 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"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
"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(),
|
|
NegativeTTL: 5 * time.Minute,
|
|
MaxBytes: 1 << 30,
|
|
}, prometheus.NewRegistry())
|
|
if err != nil {
|
|
t.Fatalf("failed to create cache: %v", err)
|
|
}
|
|
|
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
|
Cache: cache,
|
|
Fetcher: fetcher,
|
|
Processor: imageprocessor.New(imageprocessor.Params{
|
|
MaxInputBytes: 50 << 20,
|
|
MaxConcurrentProcessing: 2,
|
|
}),
|
|
SigningKey: testSigningKey,
|
|
Allowlist: []string{allowlistedHost},
|
|
MaxResponseSize: 50 << 20,
|
|
})
|
|
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(&imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: host,
|
|
Path: photoPath,
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.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.Config{
|
|
Timeout: 30 * time.Second,
|
|
MaxResponseSize: 50 << 20,
|
|
MaxConnectionsPerHost: 20,
|
|
MaxConnections: 64,
|
|
})
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_InvalidHost_Returns400 sends image URLs whose host no URL
|
|
// host can be, and checks that each is refused with 400 naming the host as
|
|
// decoded: allowlistedHost with a character after it that a URL host cannot
|
|
// hold, percent-encoded, an IPv6 address without brackets, and brackets
|
|
// around something that is not an IPv6 address. A "?" or "#" after
|
|
// allowlistedHost would otherwise end the host when the source URL is read,
|
|
// so the request would pass the allowlist and be fetched and cached under a
|
|
// host that is not the one fetched.
|
|
func TestHandleImage_InvalidHost_Returns400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
photos := newPhotoFetcher(t, allowlistedHost)
|
|
|
|
tests := []struct {
|
|
name string
|
|
writtenHost string
|
|
decodedHost string
|
|
}{
|
|
{"? written as %3F", allowlistedHost + "%3Fx=1", allowlistedHost + "?x=1"},
|
|
{"# written as %23", allowlistedHost + "%23x", allowlistedHost + "#x"},
|
|
{"% written as %25", allowlistedHost + "%253F", allowlistedHost + "%3F"},
|
|
{`\ written as %5C`, allowlistedHost + "%5Cx", allowlistedHost + `\x`},
|
|
{"space written as %20", allowlistedHost + "%20", allowlistedHost + " "},
|
|
{"IPv6 address without brackets", "2001:db8::1", "2001:db8::1"},
|
|
{"IPv4 address in brackets", "[203.0.113.5]", "[203.0.113.5]"},
|
|
{"no address in brackets", "[abc]", "[abc]"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := sendGet(t, newImageRoute(t, photos), photoURL(tt.writtenHost))
|
|
checkErrorBody(t, rec, http.StatusBadRequest,
|
|
fmt.Sprintf("invalid image URL: invalid source host: %q",
|
|
tt.decodedHost))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_HostForms_Served checks that the image route serves, with
|
|
// a valid signature, the image at photoPath on a host name, an IPv4 address,
|
|
// an IPv6 address in brackets, and a host followed by ":" with or without a
|
|
// port.
|
|
func TestHandleImage_HostForms_Served(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
hosts := []string{
|
|
allowlistedHost,
|
|
"img_1.example.com",
|
|
"203.0.113.5",
|
|
"[2001:db8::1]",
|
|
allowlistedHost + ":8443",
|
|
"[2001:db8::1]:8443",
|
|
allowlistedHost + ":",
|
|
}
|
|
|
|
photos := newPhotoFetcher(t, hosts...)
|
|
exp := time.Now().Add(time.Hour)
|
|
|
|
for _, host := range hosts {
|
|
t.Run(host, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sig := photoSignature(testSigningKey, host, exp)
|
|
|
|
rec := sendGet(t, newImageRoute(t, photos),
|
|
photoURLWithSig(host, sig, exp))
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d: %s",
|
|
rec.Code, http.StatusOK, rec.Body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleImage_HashInSourcePathOrQuery_Returns400 sends image URLs whose
|
|
// source path or source query holds a "#", written as %23, and checks that
|
|
// each is refused with 400, the one signed for that source path included. The
|
|
// source URL would otherwise end at the "#" when it is fetched, so each text
|
|
// after it would fetch the image at photoPath and cache it under keys of its
|
|
// own.
|
|
func TestHandleImage_HashInSourcePathOrQuery_Returns400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
photos := newPhotoFetcher(t, allowlistedHost, signedHost)
|
|
exp := time.Now().Add(time.Hour)
|
|
|
|
sig := signature.New(testSigningKey).Sign(&imagerequest.Request{
|
|
Source: imagerequest.Source{
|
|
Host: signedHost,
|
|
Path: photoPath + "#x",
|
|
},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
Expires: exp,
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
target string
|
|
}{
|
|
{"# in the source path",
|
|
"/v1/image/" + allowlistedHost + photoPath + "%23x/50x50.jpeg"},
|
|
{"# in the source query",
|
|
"/v1/image/" + allowlistedHost + photoPath + "%3Fv=1%23x/50x50.jpeg"},
|
|
{"# in the source path, signed",
|
|
fmt.Sprintf("/v1/image/%s%s%%23x/50x50.jpeg?sig=%s&exp=%d",
|
|
signedHost, photoPath, sig, exp.Unix())},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := sendGet(t, newImageRoute(t, photos), tt.target)
|
|
checkErrorBody(t, rec, http.StatusBadRequest,
|
|
`invalid image URL: invalid image path: `+
|
|
`the source path or query holds a "#"`)
|
|
})
|
|
}
|
|
}
|