check / check (push) Canceled after 0s
HandleImage parses the path and verifies the signature, and HandleImageEnc decrypts the token. Each then calls serveImage, which chooses the format for auto, gets the image and serves it. respondImageError answers the image service's errors on both routes, and handleImageError is gone. /v1/image/ now answers 504 when the upstream sends no response headers within upstream_fetch_timeout, as README.md documented for /v1/e/. respondImageError logs a 500 at error level and the other answers at warn. The image service alone logs a source refused for its first bytes. A failed write is logged once, at warn, with the request and the bytes served. README.md lists each image route's answers as bullets. Tests send the same errors through both routes. Model: opus-5-5
587 lines
18 KiB
Go
587 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/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.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,
|
|
}),
|
|
MaxResponseSize: 50 << 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %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,
|
|
) (*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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|