feat(surf): branded HTML error pages for browser navigations
A browser navigation (Sec-Fetch-Mode navigate, or GET/HEAD without it; no X-Requested-With; Accept naming text/html above every JSON type; not /api, /_<name> or the admin prefix) that hits a framework-generated 404, 405 or 500 gets a self-contained SummerCMS page: unmatched routes and wrong methods at the ServeMux, failed Where/WhereIn constraints, overlap-family misses, the unpublished uploads route and recovered panics. Every other request keeps the exact stdlib text, wire's opaque JSON 500 or the bare raw-group 500. Details (method, path, X-Request-Id) only with app.debug or outside production. Applications replace the page with a published surf.ErrorPageRenderer; http.error_pages.enabled false turns it off.
This commit is contained in:
125
modules/surf/errorpage_test.go
Normal file
125
modules/surf/errorpage_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
const chromeAccept = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
|
||||
|
||||
// TestErrorPageTracer: a browser navigation to an unmatched path gets the
|
||||
// branded 404 through surf.Assemble; curl keeps the stdlib text.
|
||||
func TestErrorPageTracer(t *testing.T) {
|
||||
app := transportApp(t, "production", map[string]string{"http.yaml": transportBodyLimits})
|
||||
plugin := routesPluginFunc{id: "acme.ping", routes: func(r pact.Router) {
|
||||
r.Get("/api/ping", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("pong")) })
|
||||
}}
|
||||
h, err := Assemble(app, []party.Plugin{plugin})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/nowhere", nil)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("browser status = %d, want 404", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
|
||||
t.Fatalf("browser Content-Type = %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Security-Policy"); !strings.HasPrefix(got, "default-src 'none'") {
|
||||
t.Fatalf("Content-Security-Policy = %q", got)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"404", `href="/"`, "data:image/webp;base64,"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("browser body lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "<script") {
|
||||
t.Fatal("browser body contains a script tag")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/nowhere", nil)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound || rec.Body.String() != "404 page not found\n" {
|
||||
t.Fatalf("curl got %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
||||
t.Fatalf("curl Content-Type = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorPageCallSites: surf's own 404 and panic recovery negotiate too,
|
||||
// and http.error_pages.enabled false restores the stdlib text.
|
||||
func TestErrorPageCallSites(t *testing.T) {
|
||||
routes := routesPluginFunc{id: "acme.blog", routes: func(r pact.Router) {
|
||||
r.Get("/posts/{id}", func(w http.ResponseWriter, _ *http.Request) {})
|
||||
r.Where("id", "[0-9]+")
|
||||
r.Get("/boom", func(http.ResponseWriter, *http.Request) { panic("blog: secret panic detail") })
|
||||
r.GroupRaw("/raw", nil, func(g pact.Router) {
|
||||
g.Get("/boom", func(http.ResponseWriter, *http.Request) { panic("blog: secret panic detail") })
|
||||
})
|
||||
}}
|
||||
build := func(t *testing.T, httpYAML string) http.Handler {
|
||||
t.Helper()
|
||||
app := transportApp(t, "production", map[string]string{"http.yaml": transportBodyLimits + httpYAML})
|
||||
h, err := Assemble(app, []party.Plugin{routes})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
serve := func(h http.Handler, path string, browser bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if browser {
|
||||
req.Header.Set("Accept", chromeAccept)
|
||||
req.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
} else {
|
||||
req.Header.Set("Accept", "*/*")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
on := build(t, "")
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
status int
|
||||
curl string
|
||||
}{
|
||||
{"/posts/abc", 404, "404 page not found\n"},
|
||||
{"/boom", 500, `{"error":true,"message":"Internal server error"}`},
|
||||
{"/raw/boom", 500, ""},
|
||||
} {
|
||||
rec := serve(on, tc.path, true)
|
||||
if rec.Code != tc.status || rec.Header().Get("Content-Type") != "text/html; charset=utf-8" {
|
||||
t.Fatalf("browser %s: %d %q", tc.path, rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "secret panic detail") {
|
||||
t.Fatalf("browser %s: panic value leaked", tc.path)
|
||||
}
|
||||
rec = serve(on, tc.path, false)
|
||||
if rec.Code != tc.status || strings.TrimSpace(rec.Body.String()) != strings.TrimSpace(tc.curl) {
|
||||
t.Fatalf("curl %s: %d %q", tc.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
off := build(t, "error_pages:\n enabled: false\n")
|
||||
rec := serve(off, "/nowhere", true)
|
||||
if rec.Code != 404 || rec.Body.String() != "404 page not found\n" {
|
||||
t.Fatalf("disabled browser: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user