Move to the layout REPO_POLICIES.md gives (closes #30)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The Go module moves from backend/ to the root (go.mod, go.sum, .golangci.yml, cmd/, internal/), the page's source to web/, and nginx's two files to configs/. backend/ is gone: its LICENSE and .dockerignore are deleted, and its .gitignore entries, README and scripts are folded into the root ones. script/build builds the server binary after the frontend; script/run and script/clean move up from backend/script/; make test, lint, fmt and fmt-check do what the backend's did. Vite's root is web/ and it still builds into dist/. go.mod ignores node_modules/, whose npm packages can ship .go files. script/bootstrap no longer installs gcc, which only the host Go tests needed. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
package handlers
|
||||
|
||||
import "log/slog"
|
||||
|
||||
// NewForTest builds a Handlers around a report sink and logger,
|
||||
// bypassing the fx graph so handler behaviour (including the
|
||||
// storage failure path) is exercisable in unit tests.
|
||||
func NewForTest(buf reportAppender, log *slog.Logger) *Handlers {
|
||||
return &Handlers{buf: buf, log: log}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Package handlers implements HTTP request handlers for the
|
||||
// netwatch-server API.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/healthcheck"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
// reportAppender is the subset of the report buffer the handlers
|
||||
// depend on. Defining it here keeps the storage failure path
|
||||
// exercisable with a stub in tests.
|
||||
type reportAppender interface {
|
||||
Append(v any) error
|
||||
}
|
||||
|
||||
// Params defines the dependencies for Handlers.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Buffer *reportbuf.Buffer
|
||||
Globals *globals.Globals
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Handlers provides HTTP handler factories for all endpoints.
|
||||
type Handlers struct {
|
||||
buf reportAppender
|
||||
hc *healthcheck.Healthcheck
|
||||
log *slog.Logger
|
||||
params *Params
|
||||
}
|
||||
|
||||
// New creates a Handlers instance.
|
||||
func New(
|
||||
lc fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Handlers, error) {
|
||||
s := new(Handlers)
|
||||
s.buf = params.Buffer
|
||||
s.params = ¶ms
|
||||
s.log = params.Logger.Get()
|
||||
s.hc = params.Healthcheck
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Handlers) respondJSON(
|
||||
w http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
data any,
|
||||
status int,
|
||||
) {
|
||||
w.Header().Set("Content-Type", jsonContentType)
|
||||
w.WriteHeader(status)
|
||||
|
||||
if data != nil {
|
||||
err := json.NewEncoder(w).Encode(data)
|
||||
if err != nil {
|
||||
s.log.Error("json encode error", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decodeJSON decodes the request body into v. The body is
|
||||
// expected to already be bounded by the body-size middleware, so
|
||||
// a caller can distinguish an over-limit body from malformed
|
||||
// JSON by testing the returned error for *http.MaxBytesError.
|
||||
func (s *Handlers) decodeJSON(
|
||||
_ http.ResponseWriter,
|
||||
r *http.Request,
|
||||
v any,
|
||||
) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// HandleHealthCheck returns a handler for the health check
|
||||
// endpoint.
|
||||
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
s.respondJSON(w, r, s.hc.Healthcheck(), http.StatusOK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/handlers"
|
||||
"sneak.berlin/go/netwatch/internal/healthcheck"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
|
||||
"go.uber.org/fx/fxtest"
|
||||
)
|
||||
|
||||
// newStartedHandlers builds Handlers with a real health check for the
|
||||
// server named in g, and starts them, which records the time the
|
||||
// uptime counts from.
|
||||
func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
|
||||
t.Helper()
|
||||
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
|
||||
log, err := logger.New(lc, logger.Params{Globals: g})
|
||||
if err != nil {
|
||||
t.Fatalf("logger: %v", err)
|
||||
}
|
||||
|
||||
hc, err := healthcheck.New(lc,
|
||||
healthcheck.Params{Globals: g, Logger: log})
|
||||
if err != nil {
|
||||
t.Fatalf("health check: %v", err)
|
||||
}
|
||||
|
||||
h, err := handlers.New(lc,
|
||||
handlers.Params{Globals: g, Healthcheck: hc, Logger: log})
|
||||
if err != nil {
|
||||
t.Fatalf("handlers: %v", err)
|
||||
}
|
||||
|
||||
lc.RequireStart()
|
||||
t.Cleanup(lc.RequireStop)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// TestHandleHealthCheck checks the health check's answer: 200, a JSON
|
||||
// content type, and a JSON object with exactly the fields of
|
||||
// healthcheck.HealthcheckResponse, carrying this server's name and
|
||||
// version and an uptime counted from its start.
|
||||
func TestHandleHealthCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := &globals.Globals{Appname: "netwatch-server", Version: "v1.2.3"}
|
||||
h := newStartedHandlers(t, g)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodGet, "/.well-known/healthcheck", http.NoBody)
|
||||
|
||||
h.HandleHealthCheck().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
contentType := rec.Header().Get("Content-Type")
|
||||
if contentType != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want %q",
|
||||
contentType, "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if err != nil {
|
||||
t.Fatalf("body not a JSON object: %v (%q)", err, rec.Body.String())
|
||||
}
|
||||
|
||||
fields := []string{
|
||||
"appname", "now", "status", "uptime_human", "uptime_seconds", "version",
|
||||
}
|
||||
if got := slices.Sorted(maps.Keys(body)); !slices.Equal(got, fields) {
|
||||
t.Fatalf("fields = %v, want %v", got, fields)
|
||||
}
|
||||
|
||||
for field, want := range map[string]string{
|
||||
"appname": g.Appname, "status": "ok", "version": g.Version,
|
||||
} {
|
||||
if body[field] != want {
|
||||
t.Errorf("%s = %v, want %q", field, body[field], want)
|
||||
}
|
||||
}
|
||||
|
||||
now, _ := body["now"].(string)
|
||||
|
||||
at, err := time.Parse(time.RFC3339Nano, now)
|
||||
if err != nil || time.Since(at).Abs() > time.Minute {
|
||||
t.Errorf("now = %q, want the current time in RFC 3339 (%v)", now, err)
|
||||
}
|
||||
|
||||
// Started just now, so the uptime is well under a minute.
|
||||
human, _ := body["uptime_human"].(string)
|
||||
|
||||
uptime, err := time.ParseDuration(human)
|
||||
if err != nil || uptime > time.Minute {
|
||||
t.Errorf("uptime_human = %q, want a duration under a minute (%v)",
|
||||
human, err)
|
||||
}
|
||||
|
||||
seconds, ok := body["uptime_seconds"].(float64)
|
||||
if !ok || seconds < 0 || seconds > time.Minute.Seconds() {
|
||||
t.Errorf("uptime_seconds = %v, want a number of seconds under a minute",
|
||||
body["uptime_seconds"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
)
|
||||
|
||||
type reportSample struct {
|
||||
T int64 `json:"t"`
|
||||
Latency *int `json:"latency"`
|
||||
Error *string `json:"error"`
|
||||
}
|
||||
|
||||
type reportHost struct {
|
||||
History []reportSample `json:"history"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type report struct {
|
||||
ClientID string `json:"clientId"`
|
||||
Geo json.RawMessage `json:"geo"`
|
||||
Hosts []reportHost `json:"hosts"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// HandleReport returns a handler that accepts telemetry
|
||||
// reports from NetWatch clients.
|
||||
func (s *Handlers) HandleReport() http.HandlerFunc {
|
||||
type response struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var rpt report
|
||||
|
||||
err := s.decodeJSON(w, r, &rpt)
|
||||
if err != nil {
|
||||
s.respondJSON(w, r,
|
||||
&response{Status: "error"},
|
||||
s.decodeErrorStatus(err),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.logReportReceived(rpt)
|
||||
|
||||
err = s.buf.Append(rpt)
|
||||
if err != nil {
|
||||
s.respondJSON(w, r,
|
||||
&response{Status: "error"},
|
||||
s.appendErrorStatus(err),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.respondJSON(w, r, &response{Status: "ok"}, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeErrorStatus logs a report decode failure and returns the
|
||||
// status to send: 413 when the body exceeded the size limit,
|
||||
// otherwise 400 for malformed JSON.
|
||||
func (s *Handlers) decodeErrorStatus(err error) int {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
s.log.Warn("report body too large", "limit_bytes", tooLarge.Limit)
|
||||
|
||||
return http.StatusRequestEntityTooLarge
|
||||
}
|
||||
|
||||
// The decoder's error text can quote request bytes (a whole
|
||||
// oversized number, for example), so it is bounded too.
|
||||
s.log.Error("failed to decode report",
|
||||
"error", logger.BoundedForLog(err.Error()),
|
||||
)
|
||||
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
// appendErrorStatus logs a failure to store a report and returns
|
||||
// the status to send: 507 when the reports waiting to be written fill
|
||||
// the size cap, otherwise 500.
|
||||
func (s *Handlers) appendErrorStatus(err error) int {
|
||||
if errors.Is(err, reportbuf.ErrFull) {
|
||||
s.log.Warn("report refused: " +
|
||||
"reports waiting to be written fill the size cap")
|
||||
|
||||
return http.StatusInsufficientStorage
|
||||
}
|
||||
|
||||
s.log.Error("failed to buffer report", "error", err)
|
||||
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// logReportReceived logs an accepted report. Untrusted fields are
|
||||
// bounded (client_id, timestamp) or reduced to a length
|
||||
// (geo_bytes) so the raw attacker-controlled body never reaches
|
||||
// the log.
|
||||
func (s *Handlers) logReportReceived(rpt report) {
|
||||
totalSamples := 0
|
||||
for _, h := range rpt.Hosts {
|
||||
totalSamples += len(h.History)
|
||||
}
|
||||
|
||||
s.log.Info("report received",
|
||||
"client_id", logger.BoundedForLog(rpt.ClientID),
|
||||
"timestamp", logger.BoundedForLog(rpt.Timestamp),
|
||||
"host_count", len(rpt.Hosts),
|
||||
"total_samples", totalSamples,
|
||||
"geo_bytes", len(rpt.Geo),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/handlers"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/middleware"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// TestHandleReportAcceptsValidReports checks the answer to a valid
|
||||
// report, one with no hosts and one shaped as the frontend sends them:
|
||||
// 200 and {"status":"ok"} as JSON.
|
||||
func TestHandleReportAcceptsValidReports(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "no hosts",
|
||||
body: `{"clientId":"c1","geo":null,"hosts":[],` +
|
||||
`"timestamp":"2026-10-03T12:00:00.000Z"}`,
|
||||
},
|
||||
{
|
||||
name: "a host with a latency and an error sample",
|
||||
body: `{"clientId":"c1","geo":null,"hosts":[{` +
|
||||
`"name":"Example","url":"https://example.com/",` +
|
||||
`"status":"error","history":[` +
|
||||
`{"t":1790000000000,"latency":42,"error":null},` +
|
||||
`{"t":1790000003000,"latency":null,"error":"timeout"}]}],` +
|
||||
`"timestamp":"2026-10-03T12:00:00.000Z"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newTestHandlers(stubAppender{}, io.Discard)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(tt.body),
|
||||
)
|
||||
|
||||
h.HandleReport().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
contentType := rec.Header().Get("Content-Type")
|
||||
if contentType != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want %q",
|
||||
contentType, "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
if got := rec.Body.String(); got != "{\"status\":\"ok\"}\n" {
|
||||
t.Errorf("body = %q, want %q", got, "{\"status\":\"ok\"}\n")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReportStorageFailureIsNon2xx(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newTestHandlers(stubAppender{err: errStorageFailed}, io.Discard)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleReportFullIs507 checks the answer when the reports waiting
|
||||
// to be written fill the size cap: 507 and the usual error body, which
|
||||
// tells the client nothing more.
|
||||
func TestHandleReportFullIs507(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newTestHandlers(stubAppender{err: reportbuf.ErrFull}, io.Discard)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{"clientId":"c1","hosts":[]}`),
|
||||
)
|
||||
|
||||
h.HandleReport().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInsufficientStorage {
|
||||
t.Fatalf("status = %d, want %d",
|
||||
rec.Code, http.StatusInsufficientStorage)
|
||||
}
|
||||
|
||||
if got := rec.Body.String(); got != "{\"status\":\"error\"}\n" {
|
||||
t.Errorf("body = %q, want %q", got, "{\"status\":\"error\"}\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReportMalformedJSONIs400(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newTestHandlers(stubAppender{}, io.Discard)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
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)
|
||||
handler := (&middleware.Middleware{}).MaxBodyBytes(limit)(
|
||||
h.HandleReport(),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{"clientId":"`+strings.Repeat("x", 200)+`"}`),
|
||||
)
|
||||
// No declared length, so only the middleware's read cap can
|
||||
// stop this body.
|
||||
req.ContentLength = -1
|
||||
|
||||
handler.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.NewRequestWithContext(t.Context(),
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReportLogsClientIDCutToBound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
long := strings.Repeat("c", 2*logger.MaxLoggedFieldBytes)
|
||||
|
||||
var logbuf bytes.Buffer
|
||||
|
||||
h := newTestHandlers(stubAppender{}, &logbuf)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(
|
||||
`{"clientId":"`+long+`","timestamp":"`+long+`","hosts":[]}`,
|
||||
),
|
||||
)
|
||||
|
||||
h.HandleReport().ServeHTTP(rec, req)
|
||||
|
||||
var logged map[string]any
|
||||
|
||||
err := json.Unmarshal(logbuf.Bytes(), &logged)
|
||||
if err != nil {
|
||||
t.Fatalf("log line not JSON: %v (%q)", err, logbuf.String())
|
||||
}
|
||||
|
||||
want := long[:logger.MaxLoggedFieldBytes]
|
||||
|
||||
if logged["client_id"] != want {
|
||||
t.Fatalf("logged client_id not cut to %d bytes: %q",
|
||||
logger.MaxLoggedFieldBytes, logged["client_id"])
|
||||
}
|
||||
|
||||
if logged["timestamp"] != want {
|
||||
t.Fatalf("logged timestamp not cut to %d bytes: %q",
|
||||
logger.MaxLoggedFieldBytes, logged["timestamp"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReportDecodeErrorLogIsBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A number too large for its int64 field makes the decoder's
|
||||
// error text quote the whole number.
|
||||
huge := strings.Repeat("9", 2*logger.MaxLoggedFieldBytes)
|
||||
|
||||
var logbuf bytes.Buffer
|
||||
|
||||
h := newTestHandlers(stubAppender{}, &logbuf)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{"hosts":[{"history":[{"t":`+huge+`}]}]}`),
|
||||
)
|
||||
|
||||
h.HandleReport().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if strings.Contains(logbuf.String(), huge) {
|
||||
t.Fatal("the whole oversized number was written to the log")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user