Files
LibreMail-Bug-Report-Ingest/internal/ingest/ingest_test.go
T
JMR-devandClaude Opus 4.8 4c22056e66 #7 Ingest HTTPS endpoint: accept report POST, size limit
Add internal/ingest implementing POST /v1/reports, wired into the core
build-tag-free handler so the same route serves on the dev server and the
Cloudflare Worker.

Response contract (ADR #6 §2.4):
- 202 Accepted for valid JSON within the 256 KiB cap ({"status":"accepted"})
- 413 for oversized bodies (Content-Length fast path AND a MaxBytesReader
  hard cap, so a missing/lying Content-Length cannot bypass the limit)
- 415 when Content-Type is not application/json
- 400 for malformed JSON or failed schema validation (generic error body,
  never echoes request content)
- 405 with Allow: POST for any non-POST method
- 503 when the storage Sink fails

Storage is decoupled behind a small Sink interface (Store(ctx, raw)) with a
NopSink default and a MemorySink for tests, so PII scrubbing (#8) and
encrypted R2 storage (#9) can slot in without touching the HTTP contract.
Rate limiting (429) and volumetric shedding stay a Cloudflare-edge/Pulumi
concern per #2 and are intentionally not implemented in the Worker.

Tests:
- Go unit tests (net/http/httptest) for every response code, including 413
  via both Content-Length and an oversized streamed body, plus boundary,
  storage-failure, and no-content-echo cases.
- Bruno API tests in OpenCollection YAML format under api-tests/, asserting
  the full contract against the local dev server via @usebruno/cli.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:52:04 -05:00

234 lines
8.2 KiB
Go

package ingest
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// validBody is a minimal well-formed v1 report.
const validBody = `{"appVersion":"1.4.2 (142)","platform":"android","osVersion":"Android 14","device":"Pixel 7","clientTimestamp":"2026-07-02T12:34:56Z","report":"NPE in SyncService\n<logs>"}`
// newHandler returns a Handler backed by a MemorySink for assertions.
func newHandler(t *testing.T) (*Handler, *MemorySink) {
t.Helper()
sink := &MemorySink{}
return NewHandler(sink), sink
}
// do runs one request through h and returns the recorder.
func do(t *testing.T, h http.Handler, method, contentType, body string) *httptest.ResponseRecorder {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, "/v1/reports", r)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// decodeMap parses the JSON response body into a map.
func decodeMap(t *testing.T, rec *httptest.ResponseRecorder) map[string]string {
t.Helper()
var got map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("response body is not valid JSON: %v (body=%q)", err, rec.Body.String())
}
return got
}
func TestAccepted202(t *testing.T) {
h, sink := newHandler(t)
rec := do(t, h, http.MethodPost, "application/json", validBody)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusAccepted, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q, want application/json; charset=utf-8", ct)
}
if got := decodeMap(t, rec); got["status"] != "accepted" {
t.Errorf("status field = %q, want accepted", got["status"])
}
if sink.Len() != 1 {
t.Fatalf("sink stored %d reports, want 1", sink.Len())
}
if stored := sink.Reports()[0]; !bytes.Equal(stored, []byte(validBody)) {
t.Errorf("sink stored %q, want the raw body %q", stored, validBody)
}
}
func TestAcceptedContentTypeWithCharset(t *testing.T) {
h, _ := newHandler(t)
for _, ct := range []string{"application/json", "application/json; charset=utf-8", "application/JSON", " application/json "} {
rec := do(t, h, http.MethodPost, ct, validBody)
if rec.Code != http.StatusAccepted {
t.Errorf("Content-Type %q: status = %d, want %d", ct, rec.Code, http.StatusAccepted)
}
}
}
func TestAcceptedAtExactLimit(t *testing.T) {
h, _ := newHandler(t)
prefix := `{"appVersion":"1.0.0","platform":"android","report":"`
suffix := `"}`
pad := MaxBodyBytes - len(prefix) - len(suffix)
body := prefix + strings.Repeat("x", pad) + suffix
if len(body) != MaxBodyBytes {
t.Fatalf("test setup: body len = %d, want exactly %d", len(body), MaxBodyBytes)
}
rec := do(t, h, http.MethodPost, "application/json", body)
if rec.Code != http.StatusAccepted {
t.Fatalf("body of exactly %d bytes: status = %d, want %d", MaxBodyBytes, rec.Code, http.StatusAccepted)
}
}
func TestOversizedViaContentLength413(t *testing.T) {
h, sink := newHandler(t)
big := strings.Repeat("x", MaxBodyBytes+1024)
body := `{"appVersion":"1.0.0","platform":"android","report":"` + big + `"}`
req := httptest.NewRequest(http.MethodPost, "/v1/reports", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if req.ContentLength <= MaxBodyBytes {
t.Fatalf("test setup: ContentLength = %d, want > %d (fast path must trigger)", req.ContentLength, MaxBodyBytes)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
}
if sink.Len() != 0 {
t.Errorf("sink stored %d reports, want 0 (oversized must not be stored)", sink.Len())
}
}
func TestOversizedViaStreamedBody413(t *testing.T) {
h, sink := newHandler(t)
big := strings.Repeat("x", MaxBodyBytes+1024)
body := `{"appVersion":"1.0.0","platform":"android","report":"` + big + `"}`
req := httptest.NewRequest(http.MethodPost, "/v1/reports", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// Simulate a missing/lying Content-Length so the fast path is bypassed and
// only the streamed MaxBytesReader cap can catch the oversized body.
req.ContentLength = -1
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
}
if sink.Len() != 0 {
t.Errorf("sink stored %d reports, want 0 (oversized must not be stored)", sink.Len())
}
}
func TestWrongContentType415(t *testing.T) {
h, _ := newHandler(t)
for _, ct := range []string{"text/plain", "application/xml", "multipart/form-data", ""} {
rec := do(t, h, http.MethodPost, ct, validBody)
if rec.Code != http.StatusUnsupportedMediaType {
t.Errorf("Content-Type %q: status = %d, want %d", ct, rec.Code, http.StatusUnsupportedMediaType)
}
}
}
func TestMalformedJSON400(t *testing.T) {
h, _ := newHandler(t)
cases := map[string]string{
"truncated object": `{"appVersion":"1.0.0","platform":`,
"not json": `this is not json`,
"empty body": ``,
"trailing garbage": `{"appVersion":"1.0.0","platform":"android","report":"x"} garbage`,
"json array": `["not","an","object"]`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
rec := do(t, h, http.MethodPost, "application/json", body)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d (body=%q)", rec.Code, http.StatusBadRequest, rec.Body.String())
}
})
}
}
func TestSchemaValidation400(t *testing.T) {
h, _ := newHandler(t)
cases := map[string]string{
"missing appVersion": `{"platform":"android","report":"boom"}`,
"empty appVersion": `{"appVersion":" ","platform":"android","report":"boom"}`,
"missing platform": `{"appVersion":"1.0.0","report":"boom"}`,
"missing report": `{"appVersion":"1.0.0","platform":"android"}`,
"empty report": `{"appVersion":"1.0.0","platform":"android","report":" "}`,
"bad clientTimestamp": `{"appVersion":"1.0.0","platform":"android","report":"boom","clientTimestamp":"not-a-time"}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
rec := do(t, h, http.MethodPost, "application/json", body)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d (body=%q)", rec.Code, http.StatusBadRequest, rec.Body.String())
}
})
}
}
func TestWrongMethod405(t *testing.T) {
h, _ := newHandler(t)
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch, http.MethodHead} {
rec := do(t, h, method, "application/json", validBody)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s: status = %d, want %d", method, rec.Code, http.StatusMethodNotAllowed)
}
if allow := rec.Header().Get("Allow"); allow != http.MethodPost {
t.Errorf("%s: Allow header = %q, want POST", method, allow)
}
}
}
// failSink always fails, to exercise the 503 storage-unavailable path.
type failSink struct{}
func (failSink) Store(context.Context, []byte) error { return errors.New("boom") }
func TestStorageFailure503(t *testing.T) {
h := NewHandler(failSink{})
rec := do(t, h, http.MethodPost, "application/json", validBody)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
}
func TestErrorBodiesDoNotEchoRequest(t *testing.T) {
h, _ := newHandler(t)
secret := "SUPER-SECRET-TOKEN-DO-NOT-LEAK"
body := `{"appVersion":"1.0.0","platform":"android","report":"boom","clientTimestamp":"` + secret + `"}`
rec := do(t, h, http.MethodPost, "application/json", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("error body echoed request content: %q", rec.Body.String())
}
}
func TestNilSinkDefaultsToNop(t *testing.T) {
h := NewHandler(nil)
rec := do(t, h, http.MethodPost, "application/json", validBody)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d (nil sink should default to NopSink)", rec.Code, http.StatusAccepted)
}
}