Files
minstrel/internal/server/requestlog_test.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

174 lines
5.5 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// captureLogger is a slog.Handler that records each Record emitted, so
// requestLog tests can assert level + attrs without parsing JSON.
type capturedRecord struct {
Level slog.Level
Msg string
Attrs map[string]any
}
type captureHandler struct {
records *[]capturedRecord
}
func (h *captureHandler) Enabled(_ context.Context, _ slog.Level) bool { return true }
func (h *captureHandler) Handle(_ context.Context, r slog.Record) error {
rec := capturedRecord{Level: r.Level, Msg: r.Message, Attrs: map[string]any{}}
r.Attrs(func(a slog.Attr) bool { rec.Attrs[a.Key] = a.Value.Any(); return true })
*h.records = append(*h.records, rec)
return nil
}
func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
func (h *captureHandler) WithGroup(_ string) slog.Handler { return h }
func newCaptureLogger() (*slog.Logger, *[]capturedRecord) {
records := &[]capturedRecord{}
return slog.New(&captureHandler{records: records}), records
}
func TestRequestLog_StatusToSeverity(t *testing.T) {
cases := []struct {
name string
status int
wantLevel slog.Level
}{
{"2xx is Info", http.StatusOK, slog.LevelInfo},
{"4xx is Warn", http.StatusNotFound, slog.LevelWarn},
{"5xx is Error", http.StatusInternalServerError, slog.LevelError},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
logger, records := newCaptureLogger()
h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.status)
}))
req := httptest.NewRequest(http.MethodGet, "/something", nil)
h.ServeHTTP(httptest.NewRecorder(), req)
if len(*records) != 1 {
t.Fatalf("len(records) = %d, want 1", len(*records))
}
rec := (*records)[0]
if rec.Level != tc.wantLevel {
t.Errorf("level = %v, want %v", rec.Level, tc.wantLevel)
}
if rec.Attrs["status"] != int64(tc.status) {
t.Errorf("status = %v, want %d", rec.Attrs["status"], tc.status)
}
})
}
}
func TestRequestLog_SkipsHealthz(t *testing.T) {
logger, records := newCaptureLogger()
h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
h.ServeHTTP(httptest.NewRecorder(), req)
if len(*records) != 0 {
t.Errorf("expected /healthz to be skipped, got %d records", len(*records))
}
}
// Sanity that other paths produce a useful structured payload.
func TestRequestLog_AttributesPresent(t *testing.T) {
// Use a real slog text handler buffer so we also exercise the
// formatter (catches WithAttrs/WithGroup integration regressions).
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/api/something", strings.NewReader(""))
h.ServeHTTP(httptest.NewRecorder(), req)
var got map[string]any
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("decode log line: %v\nraw: %s", err, buf.String())
}
for _, key := range []string{"method", "path", "status", "duration_ms", "remote"} {
if _, ok := got[key]; !ok {
t.Errorf("expected key %q in log entry, got %v", key, got)
}
}
}
// Behind a proxy, the access log's `remote` must be the client rather than the
// proxy, and must agree with what the Active-sessions surface records for the
// same request. Logs and UI disagreeing about who connected is worse than
// either being wrong alone.
func TestRequestLog_RemoteHonoursTrustedProxyDepth(t *testing.T) {
cases := []struct {
name string
hops func() int
remoteAddr string
forwarded string
want string
}{
{
name: "nil accessor falls back to the socket peer",
hops: nil,
remoteAddr: "203.0.113.200:40000",
forwarded: "198.51.100.7",
want: "203.0.113.200",
},
{
name: "depth 0 ignores a forwarded header",
hops: func() int { return 0 },
remoteAddr: "203.0.113.200:40000",
forwarded: "198.51.100.7",
want: "203.0.113.200",
},
{
// A proxy on a PUBLIC address: a "trust XFF only from private
// peers" heuristic would log the proxy forever.
name: "depth 1 through a public-addressed proxy logs the client",
hops: func() int { return 1 },
remoteAddr: "203.0.113.200:40000",
forwarded: "198.51.100.7",
want: "198.51.100.7",
},
{
name: "depth 2 reaches through a cdn to the client",
hops: func() int { return 2 },
remoteAddr: "172.18.0.1:40000",
forwarded: "198.51.100.7, 203.0.113.50",
want: "198.51.100.7",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
logger, records := newCaptureLogger()
h := requestLog(logger, tc.hops)(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
req := httptest.NewRequest(http.MethodGet, "/api/something", nil)
req.RemoteAddr = tc.remoteAddr
req.Header.Set("X-Forwarded-For", tc.forwarded)
h.ServeHTTP(httptest.NewRecorder(), req)
if len(*records) != 1 {
t.Fatalf("len(records) = %d, want 1", len(*records))
}
if got := (*records)[0].Attrs["remote"]; got != tc.want {
t.Errorf("remote = %v, want %q", got, tc.want)
}
})
}
}