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.
126 lines
4.5 KiB
Go
126 lines
4.5 KiB
Go
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())
|
|
}
|
|
}
|