check / check (push) Failing after 1s
A buffer failure on POST /api/v1/reports now returns 500 instead of a
false {"status":"ok"}, so clients can retry. Decode errors are split:
an over-limit body returns 413 (via errors.As on *http.MaxBytesError),
malformed JSON stays 400. A new MaxBodyBytes middleware caps every
route (1 MiB default; rejects an oversized Content-Length up-front and
caps the read otherwise), replacing the per-route reader so the health
check and future routes are bounded too. The raw attacker-controlled
geo blob is no longer logged — only its byte length — and client_id and
timestamp are length-bounded before logging. A decodeJSON handler helper
is added per the HTTP server conventions. Panic recovery is now a local
middleware that routes the stack through slog as structured JSON rather
than chi's plain-text stderr. Error bodies still leak nothing internal.
Chosen failure code for a storage failure: 500, since a full buffer or
write error is server-side and retryable, not the client's fault.
Model: opus-4-8
142 lines
3.1 KiB
Go
142 lines
3.1 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/netwatch/internal/handlers"
|
|
)
|
|
|
|
var errStorageFailed = errors.New("storage failed")
|
|
|
|
// stubAppender drives the storage success/failure path without a
|
|
// real buffer or disk.
|
|
type stubAppender struct {
|
|
err error
|
|
}
|
|
|
|
func (s stubAppender) Append(any) error { return s.err }
|
|
|
|
func newTestHandlers(buf stubAppender, out io.Writer) *handlers.Handlers {
|
|
return handlers.NewForTest(buf, slog.New(slog.NewJSONHandler(out, nil)))
|
|
}
|
|
|
|
func decodeStatus(t *testing.T, body []byte) string {
|
|
t.Helper()
|
|
|
|
var resp struct {
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
err := json.Unmarshal(body, &resp)
|
|
if err != nil {
|
|
t.Fatalf("response body not JSON: %v (%q)", err, body)
|
|
}
|
|
|
|
return resp.Status
|
|
}
|
|
|
|
func TestHandleReportStorageFailureIsNon2xx(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newTestHandlers(stubAppender{err: errStorageFailed}, io.Discard)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(
|
|
http.MethodPost, "/api/v1/reports",
|
|
strings.NewReader(`{"clientId":"c1","hosts":[]}`),
|
|
)
|
|
|
|
h.HandleReport().ServeHTTP(rec, req)
|
|
|
|
if rec.Code < 500 {
|
|
t.Fatalf("storage failure status = %d, want a 5xx", rec.Code)
|
|
}
|
|
|
|
if got := decodeStatus(t, rec.Body.Bytes()); got != "error" {
|
|
t.Fatalf("status field = %q, want %q", got, "error")
|
|
}
|
|
}
|
|
|
|
func TestHandleReportMalformedJSONIs400(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newTestHandlers(stubAppender{}, io.Discard)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(
|
|
http.MethodPost, "/api/v1/reports",
|
|
strings.NewReader(`{not json`),
|
|
)
|
|
|
|
h.HandleReport().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("malformed status = %d, want %d",
|
|
rec.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func TestHandleReportOversizeIs413(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const limit = 32
|
|
|
|
h := newTestHandlers(stubAppender{}, io.Discard)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(
|
|
http.MethodPost, "/api/v1/reports",
|
|
strings.NewReader(`{"clientId":"`+strings.Repeat("x", 200)+`"}`),
|
|
)
|
|
// Emulate the body-size middleware capping the body so the
|
|
// handler observes a *http.MaxBytesError while decoding.
|
|
req.Body = http.MaxBytesReader(rec, req.Body, limit)
|
|
|
|
h.HandleReport().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("oversize status = %d, want %d",
|
|
rec.Code, http.StatusRequestEntityTooLarge)
|
|
}
|
|
}
|
|
|
|
func TestHandleReportDoesNotLogRawGeo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const sentinel = "SENSITIVE-GEO-BLOB"
|
|
|
|
var logbuf bytes.Buffer
|
|
|
|
h := newTestHandlers(stubAppender{}, &logbuf)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(
|
|
http.MethodPost, "/api/v1/reports",
|
|
strings.NewReader(
|
|
`{"clientId":"c1","geo":{"raw":"`+sentinel+`"},"hosts":[]}`,
|
|
),
|
|
)
|
|
|
|
h.HandleReport().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
if strings.Contains(logbuf.String(), sentinel) {
|
|
t.Fatal("raw geo bytes were written to the log")
|
|
}
|
|
|
|
if !strings.Contains(logbuf.String(), "geo_bytes") {
|
|
t.Fatal("expected a bounded geo_bytes field in the log")
|
|
}
|
|
}
|