fix(backend): report ingest correctness: 500 on a refused report, 413 on oversize, global body cap (closes #23)
check / check (push) Successful in 10s
check / check (push) Successful in 10s
A report the buffer refuses now returns 500 instead of a false `ok`. Reports reach disk later, so a failed disk write is still answered 200 and shows in the log, and at shutdown as a failed stop with a non-zero exit. An over-limit body returns 413; malformed JSON stays 400. A MaxBodyBytes middleware caps every route at 1 MiB; a route group can only lower that limit. The raw geo blob is no longer logged; client_id, timestamp and decode error text are cut to 128 bytes before logging. Panic recovery logs the panic value and stack through slog. Model: opus-5-5
This commit was merged in pull request #58.
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/middleware"
|
||||
@@ -146,3 +151,152 @@ func TestSecurityHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxBodyBytesRejectsOversizeOnNonReadingRoute confirms the
|
||||
// limit is enforced even for a handler that never reads the body
|
||||
// (for example the health check), via the Content-Length check.
|
||||
func TestMaxBodyBytesRejectsOversizeOnNonReadingRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const limit = 16
|
||||
|
||||
called := false
|
||||
handler := (&middleware.Middleware{}).MaxBodyBytes(limit)(
|
||||
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
}),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/.well-known/healthcheck",
|
||||
strings.NewReader(strings.Repeat("x", limit+1)),
|
||||
)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want %d",
|
||||
rec.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
|
||||
if called {
|
||||
t.Fatal("handler ran despite oversize body")
|
||||
}
|
||||
|
||||
if got := rec.Body.String(); got != "{\"status\":\"error\"}\n" {
|
||||
t.Errorf("body = %q, want %q", got, "{\"status\":\"error\"}\n")
|
||||
}
|
||||
|
||||
got := rec.Header().Get("Content-Type")
|
||||
if got != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want a JSON content type", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxBodyBytesAllowsWithinLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const limit = 64
|
||||
|
||||
handler := (&middleware.Middleware{}).MaxBodyBytes(limit)(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{"clientId":"c1"}`),
|
||||
)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecovererReturns500AndLogsThroughSlog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var logbuf bytes.Buffer
|
||||
|
||||
mw := middleware.NewWithLogger(
|
||||
slog.New(slog.NewJSONHandler(&logbuf, nil)),
|
||||
)
|
||||
|
||||
handler := mw.Recoverer()(
|
||||
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
panic("boom")
|
||||
}),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d",
|
||||
rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var record map[string]any
|
||||
|
||||
err := json.Unmarshal(logbuf.Bytes(), &record)
|
||||
if err != nil {
|
||||
t.Fatalf("panic log is not one JSON record: %v (%q)", err, logbuf.String())
|
||||
}
|
||||
|
||||
if record["msg"] != "panic recovered" || record["level"] != "ERROR" {
|
||||
t.Errorf("log record = %v, want msg %q at level ERROR",
|
||||
record, "panic recovered")
|
||||
}
|
||||
|
||||
if record["panic"] != "boom" {
|
||||
t.Errorf("panic field = %v, want %q", record["panic"], "boom")
|
||||
}
|
||||
|
||||
stack, _ := record["stack"].(string)
|
||||
if !strings.HasPrefix(stack, "goroutine ") {
|
||||
t.Errorf("stack field = %q, want a stack trace", stack)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecovererRepanicsOnAbortHandler checks that a handler aborting
|
||||
// with http.ErrAbortHandler is not treated as a crash: Recoverer
|
||||
// panics again so the server aborts the response, and logs nothing.
|
||||
func TestRecovererRepanicsOnAbortHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var logbuf bytes.Buffer
|
||||
|
||||
mw := middleware.NewWithLogger(
|
||||
slog.New(slog.NewJSONHandler(&logbuf, nil)),
|
||||
)
|
||||
|
||||
handler := mw.Recoverer()(
|
||||
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
panic(http.ErrAbortHandler)
|
||||
}),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||
|
||||
var recovered any
|
||||
|
||||
func() {
|
||||
defer func() { recovered = recover() }()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
}()
|
||||
|
||||
err, _ := recovered.(error)
|
||||
if !errors.Is(err, http.ErrAbortHandler) {
|
||||
t.Errorf("Recoverer panicked with %v, want http.ErrAbortHandler", recovered)
|
||||
}
|
||||
|
||||
if logbuf.Len() != 0 {
|
||||
t.Errorf("abort was logged: %q", logbuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user