check / check (push) Waiting to run
Cached images were served until eviction, so upstream changes were missed. After 24 hours a request checks the source with upstream, sending its stored ETag and Last-Modified. A 304 renews it with no download or conversion. New bytes replace it; older variants are converted again. A failed check serves the cached image and puts the URL in the negative cache. Images not checked for 30 days are evicted. Variant rows keep the source's ETag and Last-Modified for checking a variant of an evicted source. A variant file's modification time is its source's check time when it was made or renewed. Hits read it without a database call. The ETag includes it, so new bytes get a new ETag. Model: opus-5-5
588 lines
18 KiB
Go
588 lines
18 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image/color"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"sneak.berlin/go/pixa/internal/allowlist"
|
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
"sneak.berlin/go/pixa/internal/imageproxy"
|
|
"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 which checks signatures with testSigningKey.
|
|
func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
|
t.Helper()
|
|
|
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.Config{
|
|
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 := imageproxy.New(&imageproxy.Config{
|
|
Cache: cache,
|
|
Fetcher: fetcher,
|
|
Processor: imageprocessor.New(imageprocessor.Params{
|
|
MaxInputBytes: 50 << 20,
|
|
MaxConcurrentProcessing: 2,
|
|
}),
|
|
MaxResponseSize: 50 << 20,
|
|
}, prometheus.NewRegistry())
|
|
if err != nil {
|
|
t.Fatalf("failed to create proxy: %v", err)
|
|
}
|
|
|
|
h := &Handlers{
|
|
imgSvc: svc,
|
|
log: slog.New(slog.DiscardHandler),
|
|
signer: signature.New(testSigningKey),
|
|
allowlist: allowlist.New([]string{allowlistedHost}),
|
|
}
|
|
|
|
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 source 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())
|
|
}
|
|
|
|
// photoRequest returns the image request of photoURL(host) at the default
|
|
// quality and fit.
|
|
func photoRequest(host string) *imagerequest.Request {
|
|
return &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,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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(photoRequest(host), 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. Later steps put the host
|
|
// unparsed into the allowlist check, the cache key and the upstream URL, so
|
|
// such a host is refused first.
|
|
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.
|
|
// A "#" starts a URL's fragment, so neither the source path nor the source
|
|
// query may hold one.
|
|
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,
|
|
},
|
|
}, 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 "#"`)
|
|
})
|
|
}
|
|
}
|
|
|
|
// failingFetcher answers every fetch with its error.
|
|
type failingFetcher struct {
|
|
err error
|
|
}
|
|
|
|
// Fetch returns f's error.
|
|
func (f failingFetcher) Fetch(
|
|
context.Context, imagerequest.Source, string, string,
|
|
) (*httpfetcher.FetchResult, error) {
|
|
return nil, f.err
|
|
}
|
|
|
|
// photoURLsByRoute returns, for each image route, the function that makes the
|
|
// URL of the JPEG at photoPath on signedHost on that route.
|
|
func photoURLsByRoute() map[string]func(*testing.T, *Handlers) string {
|
|
return map[string]func(*testing.T, *Handlers) string{
|
|
"/v1/image/": signedPhotoURL,
|
|
"/v1/e/": encPhotoURL,
|
|
}
|
|
}
|
|
|
|
// TestImageRoutes_SameErrorAnswers checks that both image routes answer each
|
|
// upstream fetch error below with the same status. They log it as "failed to
|
|
// get image" at the same level: error for the 500 and warn for the others.
|
|
// Each request gets routes of its own, so no request finds another's failed
|
|
// fetch in the negative cache.
|
|
func TestImageRoutes_SameErrorAnswers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
wantStatus int
|
|
wantLevel slog.Level
|
|
}{
|
|
{"blocked upstream address", httpfetcher.ErrSSRFBlocked,
|
|
http.StatusForbidden, slog.LevelWarn},
|
|
{"upstream error status",
|
|
fmt.Errorf("%w: status 404", httpfetcher.ErrUpstreamError),
|
|
http.StatusBadGateway, slog.LevelWarn},
|
|
{"no response headers within the fetch timeout",
|
|
httpfetcher.ErrUpstreamTimeout, http.StatusGatewayTimeout,
|
|
slog.LevelWarn},
|
|
{"no free upstream connection", httpfetcher.ErrTooManyConnections,
|
|
http.StatusServiceUnavailable, slog.LevelWarn},
|
|
{"Content-Type not a source format",
|
|
fmt.Errorf("%w: image/svg+xml", httpfetcher.ErrInvalidContentType),
|
|
http.StatusUnsupportedMediaType, slog.LevelWarn},
|
|
{"any other failure", io.ErrUnexpectedEOF,
|
|
http.StatusInternalServerError, slog.LevelError},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
for route, imageURL := range photoURLsByRoute() {
|
|
t.Run(route+" "+tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var logs bytes.Buffer
|
|
|
|
h, routes := newRoutesFetchingThrough(t,
|
|
failingFetcher{err: tt.err},
|
|
slog.New(slog.NewJSONHandler(&logs, nil)))
|
|
|
|
rec := sendGet(t, routes, imageURL(t, h))
|
|
if rec.Code != tt.wantStatus {
|
|
t.Errorf("status = %d, want %d: %s",
|
|
rec.Code, tt.wantStatus, rec.Body)
|
|
}
|
|
|
|
levels := loggedLevels(t, &logs)
|
|
if !slices.Contains(levels[tt.wantLevel.String()],
|
|
"failed to get image") {
|
|
t.Errorf(`no "failed to get image" line logged at level %s`,
|
|
tt.wantLevel)
|
|
}
|
|
|
|
if tt.wantLevel != slog.LevelError &&
|
|
len(levels[slog.LevelError.String()]) != 0 {
|
|
t.Errorf("logged at error level: %q",
|
|
levels[slog.LevelError.String()])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// failingResponseWriter is a response writer whose Write always fails, as when
|
|
// the client has gone away.
|
|
type failingResponseWriter struct {
|
|
*httptest.ResponseRecorder
|
|
}
|
|
|
|
// Write writes nothing and returns io.ErrClosedPipe.
|
|
func (failingResponseWriter) Write([]byte) (int, error) {
|
|
return 0, io.ErrClosedPipe
|
|
}
|
|
|
|
// TestImageRoutes_FailedWriteLoggedOnce checks that both image routes log a
|
|
// failed write of the image once, at warn level, with the request's ID, the
|
|
// source host and path, the bytes served and the error. Neither may also log
|
|
// the image as served.
|
|
func TestImageRoutes_FailedWriteLoggedOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const requestID = "test-request-id"
|
|
|
|
for route, imageURL := range photoURLsByRoute() {
|
|
t.Run(route, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var logs bytes.Buffer
|
|
|
|
h, srv := newSignedHostServer(t,
|
|
slog.New(slog.NewJSONHandler(&logs, nil)))
|
|
|
|
ctx := context.WithValue(t.Context(),
|
|
middleware.RequestIDKey, requestID)
|
|
srv.ServeHTTP(failingResponseWriter{httptest.NewRecorder()},
|
|
httptest.NewRequestWithContext(
|
|
ctx, http.MethodGet, imageURL(t, h), nil))
|
|
|
|
t.Logf("logged:\n%s", logs.String())
|
|
|
|
var failedWrites []map[string]any
|
|
|
|
dec := json.NewDecoder(&logs)
|
|
for dec.More() {
|
|
var line map[string]any
|
|
|
|
err := dec.Decode(&line)
|
|
if err != nil {
|
|
t.Fatalf("decoding log line: %v", err)
|
|
}
|
|
|
|
switch line["msg"] {
|
|
case "image served":
|
|
t.Error(`"image served" logged after a failed write`)
|
|
case "failed to write response":
|
|
failedWrites = append(failedWrites, line)
|
|
}
|
|
}
|
|
|
|
if len(failedWrites) != 1 {
|
|
t.Fatalf(`"failed to write response" logged %d times, want 1`,
|
|
len(failedWrites))
|
|
}
|
|
|
|
want := map[string]any{
|
|
"level": slog.LevelWarn.String(),
|
|
"request_id": requestID,
|
|
"host": signedHost,
|
|
"path": photoPath,
|
|
"served_bytes": float64(0),
|
|
"error": io.ErrClosedPipe.Error(),
|
|
}
|
|
|
|
for key, wantValue := range want {
|
|
if got := failedWrites[0][key]; got != wantValue {
|
|
t.Errorf("%s = %v, want %v", key, got, wantValue)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|