package surf import ( "maps" "net/http" "net/http/httptest" "strconv" "strings" "testing" "git.golem15.com/golem15/summercms/modules/backpack" "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, "", tc.title, `href="/"`, "data:image/webp;base64,", "Powered by SummerCMS"} { if !strings.Contains(body, want) { t.Fatalf("body lacks %q", want) } } if !strings.Contains(body, `

`+strconv.Itoa(tc.status)+"

") { t.Fatalf("body lacks the status %d", tc.status) } if strings.Contains(body, "GET", "
Request ID
"} { if !strings.Contains(body, want) { t.Fatalf("body lacks %q", want) } } body = serveReq(h, browserReq("GET", marker)).Body.String() if strings.Contains(body, "Request ID") { t.Fatal("Request ID row shown without the header") } body = serveReq(h, browserReq("GET", "/%3Cb%3Ex%3C/b%3E")).Body.String() if strings.Contains(body, "") || !strings.Contains(body, "<b>x</b>") { t.Fatal("the path is not HTML-escaped") } body = serveReq(h, withID(browserReq("GET", marker), strings.Repeat("r", 300))).Body.String() if !strings.Contains(body, strings.Repeat("r", 128)) || strings.Contains(body, strings.Repeat("r", 129)) { t.Fatal("X-Request-Id is not cut to 128 bytes") } body = serveReq(h, browserReq("GET", "/boom")).Body.String() if strings.Contains(body, errorPagePanic) { t.Fatal("the panic value leaked onto the debug page") } }) } } // renderFunc adapts a func to ErrorPageRenderer. type renderFunc func(*http.Request, ErrorPage) ([]byte, bool) func (f renderFunc) RenderErrorPage(r *http.Request, page ErrorPage) ([]byte, bool) { return f(r, page) } func TestErrorPageRendererOverride(t *testing.T) { build := func(t *testing.T, renderer ErrorPageRenderer) http.Handler { return buildErrorPageRouter(t, "production", nil, func(app *backpack.App) { if err := app.Publish[ErrorPageRenderer](renderer); err != nil { t.Fatal(err) } }, errorPageRoutes) } var seen ErrorPage custom := build(t, renderFunc(func(_ *http.Request, page ErrorPage) ([]byte, bool) { seen = page if page.Status != http.StatusNotFound { return nil, false } return []byte("

custom 404

"), true })) rec := serveReq(custom, browserReq("GET", "/nowhere")) checkErrorPage(t, rec, 404) if rec.Body.String() != "

custom 404

" { t.Fatalf("custom body = %q", rec.Body.String()) } if seen.Status != 404 || seen.Title != "Page not found" || seen.Home != "/" || seen.ShowDetails || seen.Path != "" { t.Fatalf("renderer got %+v", seen) } rec = serveReq(custom, browserReq("GET", "/boom")) checkErrorPage(t, rec, 500) if !strings.Contains(rec.Body.String(), "Something went wrong") { t.Fatal("ok=false did not keep the built-in 500 page") } if rec := serveReq(custom, curlReq("GET", "/nowhere")); rec.Body.String() != "404 page not found\n" { t.Fatalf("curl with a renderer: %q", rec.Body.String()) } for name, renderer := range map[string]ErrorPageRenderer{ "panicking renderer": renderFunc(func(*http.Request, ErrorPage) ([]byte, bool) { panic("theme: broken") }), "empty body": renderFunc(func(*http.Request, ErrorPage) ([]byte, bool) { return nil, true }), } { t.Run(name, func(t *testing.T) { h := build(t, renderer) for path, status := range map[string]int{"/nowhere": 404, "/boom": 500} { rec := serveReq(h, browserReq("GET", path)) checkErrorPage(t, rec, status) if !strings.Contains(rec.Body.String(), "Powered by SummerCMS") || strings.Contains(rec.Body.String(), "theme: broken") { t.Fatalf("%s: not the built-in page: %q", path, rec.Body.String()) } } }) } } func TestErrorPageAdminUntouched(t *testing.T) { build := func(t *testing.T, disable bool) http.Handler { t.Helper() app := adminPrefixApp(t) if disable { if err := app.Config.Set("http.error_pages.enabled", false); err != nil { t.Fatal(err) } } h, err := Assemble(app, []party.Plugin{adminPrefixPlugin{}}) if err != nil { t.Fatal(err) } return h } on, off := build(t, false), build(t, true) for _, tc := range []struct{ method, path string }{ {"GET", "/acme-admin/no-such-screen"}, {"POST", "/acme-admin/no-such-screen"}, {"GET", "/acme-admin/api/no-such-endpoint"}, } { a, b := serveReq(on, browserReq(tc.method, tc.path)), serveReq(off, browserReq(tc.method, tc.path)) if a.Code != b.Code || a.Body.String() != b.Body.String() || a.Header().Get("Content-Type") != b.Header().Get("Content-Type") { t.Fatalf("%s %s: on=%d %q %q off=%d %q %q", tc.method, tc.path, a.Code, a.Header().Get("Content-Type"), a.Body.String(), b.Code, b.Header().Get("Content-Type"), b.Body.String()) } if a.Header().Get("Content-Security-Policy") == errorPageCSP { t.Fatalf("%s %s: the admin got the error page", tc.method, tc.path) } } // Outside the admin prefix the same router still serves the page. checkErrorPage(t, serveReq(on, browserReq("GET", "/acme-adminx")), 404) } func TestErrorPageUploads(t *testing.T) { h := buildErrorPageRouter(t, "production", map[string]string{"storage.yaml": "uploads:\n serve: true\n"}, nil) rec := serveReq(h, browserReq("GET", "/storage/uploads/a/b.png")) checkErrorPage(t, rec, 404) if rec.Header().Get("Content-Disposition") != "" { t.Fatalf("Content-Disposition = %q", rec.Header().Get("Content-Disposition")) } req := httptest.NewRequest("GET", "/storage/uploads/a/b.png", nil) req.Header.Set("Accept", "image/avif,image/webp,*/*") req.Header.Set("Sec-Fetch-Mode", "no-cors") rec = serveReq(h, req) if rec.Code != 404 || rec.Body.String() != "404 page not found\n" { t.Fatalf("image request: %d %q", rec.Code, rec.Body.String()) } if got := rec.Header().Get("Content-Security-Policy"); got != uploadsContentSecurityPolicy { t.Fatalf("image request CSP = %q", got) } }