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 } 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 report files // are at their 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") } }