package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"craftdesign.group/quarry/internal/errlog"
)
// A browser exception reaches the same journal the server's own do —
// that is the whole point: one place to ask what is broken.
func TestClientErrorIsJournalled(t *testing.T) {
errlog.Default = errlog.New(50)
h := clientErrorHandler("", nil)
body := `{"kind":"error","message":"x is not a function","source":"/assets/wiki.js",
"line":12,"col":3,"stack":"at f (/assets/wiki.js:12:3)","url":"/r/quarry/wiki","ua":"probe"}`
req := httptest.NewRequest("POST", "/api/client-error", strings.NewReader(body))
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d, want 204 — the page must never react to its own reporter", rec.Code)
}
got := errlog.Default.Recent(50)
if len(got) != 1 {
t.Fatalf("journalled %d entries, want 1", len(got))
}
e := got[0]
if e.Surface != "browser" {
t.Errorf("surface %q, want browser — the panel must say which side of the wire failed", e.Surface)
}
if !strings.Contains(e.Message, "x is not a function") {
t.Errorf("message %q lost the error", e.Message)
}
// The page the reader was on, not the script file: that is what
// reproduces the fault.
if e.Path != "/r/quarry/wiki" {
t.Errorf("path %q, want the page the reader was on", e.Path)
}
if !strings.Contains(e.Trace, "wiki.js:12:3") {
t.Errorf("trace %q lost the stack", e.Trace)
}
}
// The endpoint is anonymous, so its bounds are the security. Untrusted
// text must not be able to forge extra lines in the plain-text journal,
// and must not be stored unbounded.
func TestClientErrorBoundsUntrustedInput(t *testing.T) {
errlog.Default = errlog.New(50)
h := clientErrorHandler("", nil)
req := httptest.NewRequest("POST", "/api/client-error",
strings.NewReader(`{"message":"boom\nFAKE forged journal line","url":"/x"}`))
h(httptest.NewRecorder(), req)
got := errlog.Default.Recent(50)
if len(got) != 1 {
t.Fatalf("journalled %d, want 1", len(got))
}
if strings.ContainsAny(got[0].Message, "\n\r") {
t.Errorf("newline survived into the journal: %q — a report could forge log lines", got[0].Message)
}
}
// A flood is capped rather than trusted: this is the one write path a
// stranger can reach.
func TestClientErrorRateLimits(t *testing.T) {
errlog.Default = errlog.New(5000)
h := clientErrorHandler("", nil)
for i := 0; i < clientErrorsPerMinute+50; i++ {
req := httptest.NewRequest("POST", "/api/client-error",
strings.NewReader(`{"message":"flood","url":"/x"}`))
h(httptest.NewRecorder(), req)
}
// Deduplicated to one signature, but the COUNT is what the budget
// bounds; it must stop at the ceiling rather than climb forever.
got := errlog.Default.Recent(50)
if len(got) != 1 {
t.Fatalf("journalled %d signatures, want 1", len(got))
}
if got[0].Count > clientErrorsPerMinute {
t.Errorf("recorded %d occurrences, want at most %d", got[0].Count, clientErrorsPerMinute)
}
}
// Another site must not be able to drive this endpoint.
func TestClientErrorRefusesForeignOrigin(t *testing.T) {
errlog.Default = errlog.New(50)
h := clientErrorHandler("", nil)
req := httptest.NewRequest("POST", "/api/client-error",
strings.NewReader(`{"message":"from elsewhere","url":"/x"}`))
req.Header.Set("Origin", "https://evil.example")
h(httptest.NewRecorder(), req)
if got := errlog.Default.Recent(50); len(got) != 0 {
t.Errorf("accepted a cross-origin report: %+v", got)
}
}