3 Commits
Author SHA1 Message Date
clawbot 719bdbec7a Write the deployment guide and an example Caddy config (closes #89)
check / check (push) Failing after 2s
README.md gains a "Deployment" section: what the reverse proxy in front
of pixa must do (terminate TLS, pass Host, Origin and Referer on
unchanged, set X-Forwarded-For with trusted_proxies to match, wait at
least downstream_timeout, optionally refuse /metrics) and what pixa does
itself; that the state directory needs a persistent volume, what
cache_max_bytes counts and why to set it; the health check for a load
balancer; what SIGTERM does and the exit codes; and what running outside
Docker needs. configs/Caddyfile is the example, as Caddy needs no
settings beyond the host name and pixa's address.

Model: opus-5-5
2026-10-04 12:42:27 +00:00
clawbot 604b51eea6 Test metrics auth, CORS preflight, login logging and metrics (closes #79)
check / check (push) Failing after 1s
New tests only. MetricsAuth on its own answers 401 with a challenge
without credentials or with a wrong username or password, and lets the
configured ones through. A CORS preflight request gets the same
Access-Control-Allow-Origin as a GET. A POST / carrying the signing key
leaves the key out of the request log line, and the login handler's own
log lines leave out the submitted key. The metrics middleware on its own
records a request it served; the router records nothing while no metrics
username is set.

Not tested through the router: the basic auth in front of /metrics and
recording with a metrics username set (#180).

The pinned basicauth-go compares the password in constant time.

Model: opus-5-5
2026-10-04 14:39:27 +02:00
clawbot ba5a716223 Test the image route's signature check and error answers (closes #76)
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
2026-10-04 13:58:29 +02:00
6 changed files with 674 additions and 0 deletions
+24
View File
@@ -38,6 +38,30 @@ P2: security: referer blacklist
counts, the health check for a load balancer, what a stop does and its exit
codes, and what running outside Docker needs; `configs/Caddyfile` is the
example, checked with `caddy validate`.
- 2026-10-04 the metrics basic auth, CORS preflight, request logging and
metrics recording have tests (closes #79): `MetricsAuth` on its own answers
401 with a challenge without credentials or with a wrong username or password
and lets the configured ones through; a preflight request gets `*` for any
origin when `access_control_allow_origin` is `*` and no
`Access-Control-Allow-Origin` from another origin than the configured one; a
`POST /` carrying the signing key leaves no trace of it in the request log
line, and the login handler's own log lines leave out the submitted key; the
metrics middleware on its own records a request it served, and the router
records nothing while no metrics username is set. Not tested: that the router
puts the basic auth in front of `/metrics` and records requests when a
metrics username is set. Only one test per package can set up `/metrics`, and
in `internal/server` that is `TestMaintenanceModeKeepsOtherRoutes`, which
needs the owner's approval to change; #180 holds it. Tests only; the basic
auth library already compares the password in constant time.
- 2026-10-04 the image route's signature check and error answers are tested
(closes #76): new tests in `internal/handlers`, with no network, check the
status and JSON error body 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 (401), an unparseable path (400), `localhost` as the
upstream host (403) and an upstream error (502); that an allowlisted host is
served without a signature, another host only with a valid one; and the
answers of `/robots.txt` and the health check. No code changes.
- 2026-10-04 request IDs returned and passed on, and `/v1/e/` revalidates
(closes #84): pixa's own `RequestID` middleware, in place of chi's, gives each
request an ID, its own `X-Request-ID` when that is at most 64 letters, digits,
@@ -0,0 +1,59 @@
package handlers
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/session"
)
// TestLoginLogLeavesOutSubmittedKey verifies that the log lines for a
// failed and for a successful login do not contain the submitted key.
func TestLoginLogLeavesOutSubmittedKey(t *testing.T) {
t.Parallel()
const wrongKey = "wrong-signing-key-fedcba9876543210"
var buf bytes.Buffer
sessMgr, err := session.NewManager(testSigningKey)
if err != nil {
t.Fatalf("session.NewManager() error = %v", err)
}
h := &Handlers{
log: slog.New(slog.NewJSONHandler(&buf, nil)),
config: &config.Config{SigningKey: testSigningKey},
sessMgr: sessMgr,
}
submittedKeys := []string{wrongKey, testSigningKey}
for _, key := range submittedKeys {
form := url.Values{loginKeyField: {key}}
req := httptest.NewRequestWithContext(
t.Context(), http.MethodPost, "/",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
h.handleLoginPost(httptest.NewRecorder(), req)
}
for _, msg := range []string{"failed login attempt", "successful login"} {
if !strings.Contains(buf.String(), msg) {
t.Fatalf("log missing %q; got %q", msg, buf.String())
}
}
for _, key := range submittedKeys {
if strings.Contains(buf.String(), key) {
t.Errorf("log contains submitted key %q; got %q", key, buf.String())
}
}
}
@@ -0,0 +1,267 @@
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)
}
})
}
}
@@ -0,0 +1,90 @@
package handlers
import (
"encoding/json"
"log/slog"
"net/http"
"testing"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/globals"
"sneak.berlin/go/pixa/internal/healthcheck"
"sneak.berlin/go/pixa/internal/logger"
)
// TestHandleRobotsTxt checks that /robots.txt asks every crawler to stay off
// the whole site.
func TestHandleRobotsTxt(t *testing.T) {
t.Parallel()
h := &Handlers{log: slog.New(slog.DiscardHandler)}
rec := sendGet(t, h.HandleRobotsTxt(), "/robots.txt")
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/plain" {
t.Errorf("Content-Type = %q, want text/plain", ct)
}
want := "User-agent: *\nDisallow: /\n"
if rec.Body.String() != want {
t.Errorf("body = %q, want %q", rec.Body.String(), want)
}
}
// TestHandleHealthCheck checks that the health check answers 200 with status
// ok, the app's name and version, now, uptime_seconds, uptime_human and
// maintenance_mode, which is true here: the health check stays 200 while
// maintenance mode is on.
func TestHandleHealthCheck(t *testing.T) {
t.Parallel()
lc := fxtest.NewLifecycle(t)
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
if err != nil {
t.Fatalf("logger.New() error = %v", err)
}
hc, err := healthcheck.New(lc, healthcheck.Params{
Globals: &globals.Globals{Appname: "pixad", Version: "v1.2.3"},
Config: &config.Config{MaintenanceMode: true},
Logger: log,
})
if err != nil {
t.Fatalf("healthcheck.New() error = %v", err)
}
h := &Handlers{hc: hc, log: slog.New(slog.DiscardHandler)}
rec := sendGet(t, h.HandleHealthCheck(), "/.well-known/healthcheck.json")
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var body map[string]any
err = json.NewDecoder(rec.Body).Decode(&body)
if err != nil {
t.Fatalf("decoding response body: %v", err)
}
if body["status"] != "ok" || body["appname"] != "pixad" ||
body["version"] != "v1.2.3" || body["maintenance_mode"] != true {
t.Errorf("body = %v, want status ok, appname pixad, version v1.2.3 "+
"and maintenance_mode true", body)
}
for _, key := range []string{"now", "uptime_seconds", "uptime_human"} {
if _, ok := body[key]; !ok {
t.Errorf("body = %v, has no %s", body, key)
}
}
}
@@ -1,11 +1,16 @@
package middleware
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus/promhttp"
"sneak.berlin/go/pixa/internal/config"
)
@@ -56,6 +61,203 @@ func TestCORSAnswersWithConfiguredOrigin(t *testing.T) {
}
}
// TestCORSAnswersPreflightWithConfiguredOrigin checks that the CORS
// middleware answers a preflight request, which the CORS library handles
// apart from other requests, the same way: "*" lets any origin read
// responses and a single origin lets only that origin read them.
func TestCORSAnswersPreflightWithConfiguredOrigin(t *testing.T) {
t.Parallel()
const appOrigin = "https://app.example.com"
cases := []struct {
configured string
requestOrigin string
want string
}{
{"*", "https://any.example.com", "*"},
{appOrigin, appOrigin, appOrigin},
{appOrigin, "https://other.example.com", ""},
}
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
for _, tc := range cases {
mw := &Middleware{
log: slog.Default(),
config: &config.Config{AccessControlAllowOrigin: tc.configured},
}
handler := mw.CORS()(testHandler)
// An OPTIONS request naming the method it asks about is the
// preflight a browser sends before some cross-origin requests.
req := httptest.NewRequestWithContext(
t.Context(), http.MethodOptions, "/v1/image/example.com/a.jpg/1x1.png", nil)
req.Header.Set("Origin", tc.requestOrigin)
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
got := rec.Header().Get("Access-Control-Allow-Origin")
if got != tc.want {
t.Errorf("configured %q, preflight from %q: "+
"Access-Control-Allow-Origin = %q, want %q",
tc.configured, tc.requestOrigin, got, tc.want)
}
}
}
// TestMetricsAuthRequiresConfiguredCredentials checks that MetricsAuth on
// its own answers 401 with a challenge to a request without credentials or
// with a wrong username or password, and lets a request with the configured
// username and password through. That the router puts it in front of
// /metrics is not tested.
func TestMetricsAuthRequiresConfiguredCredentials(t *testing.T) {
t.Parallel()
const (
username = "metricsuser"
password = "metricspass"
challenge = `Basic realm="metrics"`
)
// An empty username stands for a request sent without credentials.
cases := []struct {
name string
username string
password string
wantReached bool
}{
{"no credentials", "", "", false},
{"wrong username", "someone", password, false},
{"wrong password", username, "wrongpass", false},
{"configured credentials", username, password, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
mw := &Middleware{
log: slog.Default(),
config: &config.Config{
MetricsUsername: username,
MetricsPassword: password,
},
}
reached := false
handler := mw.MetricsAuth()(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {
reached = true
}))
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/metrics", nil)
if tc.username != "" {
req.SetBasicAuth(tc.username, tc.password)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if reached != tc.wantReached {
t.Fatalf("request reached /metrics = %v, want %v",
reached, tc.wantReached)
}
if tc.wantReached {
return
}
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d",
rec.Code, http.StatusUnauthorized)
}
if got := rec.Header().Get("WWW-Authenticate"); got != challenge {
t.Errorf("WWW-Authenticate = %q, want %q", got, challenge)
}
})
}
}
// TestMetricsRecordsServedRequest checks that the metrics middleware
// records a request it served, so /metrics reports it. It is the only test
// in this package that sets up the metrics middleware, which registers with
// the process-wide Prometheus registry and can do so only once.
func TestMetricsRecordsServedRequest(t *testing.T) {
t.Parallel()
// The line /metrics shows once one GET /test has been served.
const want = `http_request_duration_seconds_count{` +
`code="200",handler="/test",method="GET",service=""} 1`
mw := &Middleware{log: slog.Default(), config: &config.Config{}}
handler := mw.Metrics()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/test", nil))
rec := httptest.NewRecorder()
promhttp.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/metrics", nil))
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("/metrics does not report the GET /test served; "+
"want the line %q in:\n%s", want, rec.Body.String())
}
}
// TestLoggingLeavesOutSubmittedSigningKey checks that a login, a POST /
// whose form carries the signing key, leaves no trace of the key in the
// request's log line.
func TestLoggingLeavesOutSubmittedSigningKey(t *testing.T) {
t.Parallel()
const signingKey = "test-signing-key-0123456789abcdef"
var buf bytes.Buffer
mw := newTestMiddleware(t, &buf)
// The handler reads the key from the form, as the login handler does.
handler := mw.Logging()(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if got := r.FormValue("key"); got != signingKey {
t.Errorf("key in form = %q, want %q", got, signingKey)
}
w.WriteHeader(http.StatusSeeOther)
}))
form := url.Values{"key": {signingKey}}
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handler.ServeHTTP(httptest.NewRecorder(), req)
if !strings.Contains(buf.String(), `"method":"POST"`) {
t.Fatalf("no log line for the request; got %q", buf.String())
}
if strings.Contains(buf.String(), signingKey) {
t.Errorf("log output contains the signing key; got %q", buf.String())
}
}
func TestSecurityHeaders(t *testing.T) {
t.Parallel()
+32
View File
@@ -0,0 +1,32 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// TestNoMetricsRecordedWithoutMetricsUsername checks that with no metrics
// username set the router records nothing about the requests it serves.
// /metrics is not served then, so the process-wide Prometheus registry is
// read directly. TestMaintenanceModeKeepsOtherRoutes records into the same
// registry, but never a GET /robots.txt.
func TestNoMetricsRecordedWithoutMetricsUsername(t *testing.T) {
t.Parallel()
s := newTestServer(t)
s.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/robots.txt", nil))
rec := httptest.NewRecorder()
promhttp.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/metrics", nil))
if strings.Contains(rec.Body.String(), `handler="/robots.txt"`) {
t.Errorf("with no metrics username GET /robots.txt was recorded:\n%s",
rec.Body.String())
}
}