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>
174 lines
5.5 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|