Test metrics auth, CORS preflight, login logging and metrics (closes #79)
check / check (push) Failing after 2s

New tests only. The basic auth in front of /metrics 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 / whose form carries the
signing key leaves the key out of the log line. The router records
requests, and /metrics reports them, only when the metrics username is
set; that test is the one in internal/server allowed to set up the
process-wide Prometheus registry.

The pinned basicauth-go already compares the password in constant time
with crypto/subtle, so no code changes.

Model: opus-5-5
This commit is contained in:
2026-10-04 07:47:07 +00:00
parent 1616e91a6a
commit 483ada3be0
3 changed files with 240 additions and 0 deletions
+10
View File
@@ -29,6 +29,16 @@ P2: security: referer blacklist
# Completed Steps
- 2026-10-04 the metrics basic auth, CORS preflight, request logging and
metrics recording have tests (closes #79): the basic auth in front of
`/metrics` 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 log
line; the router records requests, and `/metrics` reports them, only when
the metrics username is set. Tests only; the basic auth library already
compares the password in constant time.
- 2026-10-04 shutdown stops cache eviction in progress (closes #102):
`StartEviction` runs the eviction goroutine with its own context, which
`StopEviction` cancels, so a pass in progress stops at its next database
@@ -1,9 +1,12 @@
package middleware
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"sneak.berlin/go/pixa/internal/config"
@@ -56,6 +59,171 @@ 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 the basic auth
// in front of /metrics 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.
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)
}
})
}
}
// 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()
+62
View File
@@ -0,0 +1,62 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// TestMetricsRecordRequestsOnlyWithMetricsUsername checks that the router
// records nothing about the requests it serves while no metrics username is
// set, and that with one set /metrics reports a request it served. The two
// halves are one test, run in this order, because the router records into
// the process-wide Prometheus registry, which only one test in this package
// can set up and which keeps what is recorded in it.
func TestMetricsRecordRequestsOnlyWithMetricsUsername(t *testing.T) {
t.Parallel()
// The line /metrics shows once the router has served one GET /robots.txt.
const robotsRequestCount = `http_request_duration_seconds_count{` +
`code="200",handler="/robots.txt",method="GET",service=""} 1`
s := newTestServer(t)
s.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/robots.txt", nil))
// With no metrics username the router serves no /metrics, so the
// registry is read directly.
registry := httptest.NewRecorder()
promhttp.Handler().ServeHTTP(registry, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/metrics", nil))
if strings.Contains(registry.Body.String(), "http_request_duration_seconds") {
t.Errorf("with no metrics username a request was recorded:\n%s",
registry.Body.String())
}
s.config.MetricsUsername = "metricsuser"
s.config.MetricsPassword = "metricspass"
s.SetupRoutes()
s.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/robots.txt", nil))
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/metrics", nil)
req.SetBasicAuth("metricsuser", "metricspass")
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /metrics status = %d, want %d", rec.Code, http.StatusOK)
}
if !strings.Contains(rec.Body.String(), robotsRequestCount) {
t.Errorf("/metrics does not report the GET /robots.txt served; "+
"want the line %q in:\n%s", robotsRequestCount, rec.Body.String())
}
}