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)
}
}
// Reports that cannot be about this forge's code are not journalled:
// an extension's script, an opaque cross-origin "Script error.", and a
// view transition the browser abandoned. Each of these sat on the
// exceptions page from a real reader. One of our own still is.
func TestClientErrorDropsWhatIsNotOurs(t *testing.T) {
errlog.Default = errlog.New(50)
h := clientErrorHandler("", nil)
for _, body := range []string{
`{"kind":"error","message":"Script error.","source":"moz-extension://7cdd/script.js","url":"/r/quarry"}`,
`{"kind":"error","message":"Script error.","url":"/r/quarry"}`,
`{"kind":"unhandledrejection","message":"Invalid call to runtime.sendMessage(). Tab not found.","url":"/"}`,
`{"kind":"error","message":"TypeError: can't access dead object","url":"/r/zit/file/x"}`,
`{"kind":"error","message":"x is undefined","stack":"f@chrome-extension://abc/content.js:1:2","url":"/"}`,
`{"kind":"unhandledrejection","message":"Transition was aborted because of invalid state. Viewport size changed","url":"/r/quarry/landing/1"}`,
`{"kind":"unhandledrejection","message":"Transition was skipped","url":"/login"}`,
`{"kind":"error","message":"x is not a function","source":"/assets/wiki.js","stack":"at f (/assets/wiki.js:12:3)","url":"/r/quarry/wiki"}`,
} {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest("POST", "/api/client-error", strings.NewReader(body)))
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d for %s", rec.Code, body)
}
}
got := errlog.Default.Recent(50)
if len(got) != 1 || !strings.Contains(got[0].Message, "x is not a function") {
var msgs []string
for _, e := range got {
msgs = append(msgs, e.Message)
}
t.Errorf("FAIL: journalled %q, want only our own error", msgs)
}
}