Files
summercms/modules/surf/example_test.go
Jakub Zych b1236cbfd4 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.
2026-10-20 03:12:25 +02:00

310 lines
10 KiB
Go

package surf_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/wire"
)
// secret signs the example's tokens. A real application reads its JWT
// secret from configuration.
const secret = "example-secret-that-is-long-enough"
// users loads the principal behind a token subject.
type users struct{}
func (users) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
return &bouncer.Principal{ID: id}, nil
}
// BlogPlugin is the acme.blog plugin; only its HTTP surface is shown here.
type BlogPlugin struct {
trusted []netip.Prefix
}
func (p *BlogPlugin) ID() string { return "acme.blog" }
func (p *BlogPlugin) Requires() []string { return nil }
// Register reads http.trusted_proxies once, for the plugin's bucket keys.
func (p *BlogPlugin) Register(app *backpack.App) error {
p.trusted = surf.TrustedProxies(app.Config)
return nil
}
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
// Middlewares registers the plugin's named middleware: here, a JWT guard
// that answers 401 when the request has no valid token.
func (p *BlogPlugin) Middlewares() map[string]pact.Middleware {
guards := bouncer.NewRegistry()
guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist())
if err := guards.Register(p.ID(), "acme.auth", guard); err != nil {
panic(err)
}
auth, err := guards.Middleware("acme.auth")
if err != nil {
panic(err)
}
return map[string]pact.Middleware{"acme.auth": auth}
}
// Buckets declares a named rate limit, used as throttle:blog.comments.
func (p *BlogPlugin) Buckets() map[string]surf.Bucket {
return map[string]surf.Bucket{
"blog.comments": {
Max: 1,
Decay: time.Minute,
Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, p.trusted) },
},
}
}
// openAPISpec is the plugin's hand-written OpenAPI document. A plugin
// usually keeps it in a file and embeds it with //go:embed.
const openAPISpec = `openapi: 3.0.3
info:
title: Blog API
version: "1"
paths:
/api/blog/posts/{id}:
get:
summary: Show a post
`
// APIDocsSpec supplies the document behind the opt-in /api-docs page
// (surf.APIDocsProvider).
func (p *BlogPlugin) APIDocsSpec() ([]byte, error) {
return []byte(openAPISpec), nil
}
// Routes is the Go form of the plugin's routes.php.
func (p *BlogPlugin) Routes(r pact.Router) error {
r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) {
g.Get("/posts/{id}", showPost)
g.Where("id", `[0-9]+`)
g.Get("/posts/{status}/list", listPosts)
g.WhereIn("status", "draft", "published")
// An auth group: every route inside needs a signed-in user.
g.Group("", surf.Use("acme.auth"), func(auth pact.Router) {
auth.Get("/me", showMe)
auth.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536")
})
})
return nil
}
func showPost(w http.ResponseWriter, r *http.Request) {
id, ok := surf.IntParam(r, "id")
if !ok {
http.NotFound(w, r)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id})
}
func listPosts(w http.ResponseWriter, r *http.Request) {
wire.WriteJSON(w, http.StatusOK, map[string]any{"status": r.PathValue("status"), "data": wire.Slice[string](nil)})
}
func showMe(w http.ResponseWriter, r *http.Request) {
user, _ := bouncer.User(r.Context())
wire.WriteJSON(w, http.StatusOK, map[string]any{"id": user.ID})
}
func addComment(w http.ResponseWriter, r *http.Request) {
wire.WriteJSON(w, http.StatusCreated, map[string]any{"created": true})
}
func ExampleAssemble() {
// The application passes its config; http.body_limits is required there.
app := backpack.New(nil)
plugin := &BlogPlugin{}
if err := plugin.Register(app); err != nil { // the runtime calls Register
fmt.Println(err)
return
}
h, err := surf.Assemble(app, []party.Plugin{plugin})
if err != nil {
fmt.Println(err)
return
}
token, _, _ := bouncer.Mint(secret, "42", "http://127.0.0.1:8080/api/login", time.Hour)
do := func(method, path string, auth bool) {
req := httptest.NewRequest(method, path, strings.NewReader("{}"))
if auth {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Println(method, path, rec.Code, strings.TrimSpace(rec.Body.String()))
}
do("GET", "/api/blog/posts/7", false)
do("GET", "/api/blog/posts/seven", false)
do("GET", "/api/blog/posts/draft/list", false)
do("GET", "/api/blog/posts/deleted/list", false)
do("GET", "/api/blog/me", false)
do("GET", "/api/blog/me", true)
do("POST", "/api/blog/posts/7/comments", true)
do("POST", "/api/blog/posts/7/comments", true)
// Output:
// GET /api/blog/posts/7 200 {"id":7}
// GET /api/blog/posts/seven 404 404 page not found
// GET /api/blog/posts/draft/list 200 {"data":[],"status":"draft"}
// GET /api/blog/posts/deleted/list 404 404 page not found
// GET /api/blog/me 401 {"error":true,"message":"Token not provided"}
// GET /api/blog/me 200 {"id":42}
// POST /api/blog/posts/7/comments 201 {"created":true}
// POST /api/blog/posts/7/comments 429 {"message":"Too Many Attempts."}
}
func ExampleBuildRouter() {
r, err := surf.BuildRouter(backpack.New(nil), []party.Plugin{&BlogPlugin{}})
if err != nil {
fmt.Println(err)
return
}
for _, rt := range r.Routes() {
fmt.Println(rt.Method, rt.Pattern, rt.PluginID, rt.Middleware)
}
// Output:
// GET /api/blog/posts/{id} acme.blog [throttle:60,1]
// GET /api/blog/posts/{status}/list acme.blog [throttle:60,1]
// GET /api/blog/me acme.blog [throttle:60,1 acme.auth]
// POST /api/blog/posts/{id}/comments acme.blog [throttle:60,1 acme.auth throttle:blog.comments body.limit:65536]
}
func ExampleClientIP() {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
fmt.Println(err)
return
}
_ = cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"})
trusted := surf.TrustedProxies(cfg)
// Through the load balancer at 10.0.0.5: the forwarded client is used.
viaProxy := httptest.NewRequest("GET", "/api/blog/posts", nil)
viaProxy.RemoteAddr = "10.0.0.5:4711"
viaProxy.Header.Set("X-Forwarded-For", "198.51.100.23, 10.0.0.9")
fmt.Println(surf.ClientIP(viaProxy, trusted))
// Straight from the internet: a forged header is ignored.
direct := httptest.NewRequest("GET", "/api/blog/posts", nil)
direct.RemoteAddr = "203.0.113.7:5000"
direct.Header.Set("X-Forwarded-For", "127.0.0.1")
fmt.Println(surf.ClientIP(direct, trusted))
// Output:
// 198.51.100.23
// 203.0.113.7
}
func ExampleAPIDocsProvider() {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
fmt.Println(err)
return
}
_ = cfg.Set("http.body_limits.default_bytes", 1048576)
_ = cfg.Set("http.body_limits.upload_bytes", 1048576)
_ = cfg.Set("http.api_docs.enabled", true)
h, err := surf.Assemble(backpack.New(cfg), []party.Plugin{&BlogPlugin{}})
if err != nil {
fmt.Println(err)
return
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api-docs.json", nil))
fmt.Println(rec.Code, rec.Body.String())
// Output:
// 200 {"openapi":"3.0.3","info":{"title":"Blog API","version":"1"},"paths":{"/api/blog/posts/{id}":{"get":{"summary":"Show a post"}}}}
}
// themePages is the acme.theme error page.
type themePages struct{}
// RenderErrorPage returns the theme's own 404 and keeps the built-in page
// for every other status.
func (themePages) RenderErrorPage(r *http.Request, page surf.ErrorPage) ([]byte, bool) {
if page.Status != http.StatusNotFound {
return nil, false // keep the built-in page
}
body := `<!doctype html><title>Not found</title><h1>Nothing here</h1><a href="` + page.Home + `">Home</a>`
return []byte(body), true
}
// ThemePlugin is the acme.theme plugin; Register publishes its error page.
type ThemePlugin struct{}
func (ThemePlugin) ID() string { return "acme.theme" }
func (ThemePlugin) Requires() []string { return nil }
func (ThemePlugin) Boot(*backpack.App) error { return nil }
// Register publishes the theme's error page for surf to look up.
func (ThemePlugin) Register(app *backpack.App) error {
return app.Publish[surf.ErrorPageRenderer](themePages{})
}
func ExampleErrorPageRenderer() {
app := backpack.New(nil)
plugin := ThemePlugin{}
if err := plugin.Register(app); err != nil { // the runtime calls Register
fmt.Println(err)
return
}
h, err := surf.Assemble(app, []party.Plugin{plugin})
if err != nil {
fmt.Println(err)
return
}
browser := httptest.NewRequest("GET", "/nowhere", nil)
browser.Header.Set("Accept", "text/html,application/xhtml+xml,*/*;q=0.8")
browser.Header.Set("Sec-Fetch-Mode", "navigate")
curl := httptest.NewRequest("GET", "/nowhere", nil)
curl.Header.Set("Accept", "*/*")
for _, req := range []*http.Request{browser, curl} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Println(rec.Code, rec.Header().Get("Content-Type"), strings.TrimSpace(rec.Body.String()))
}
// Output:
// 404 text/html; charset=utf-8 <!doctype html><title>Not found</title><h1>Nothing here</h1><a href="/">Home</a>
// 404 text/plain; charset=utf-8 404 page not found
}
// TestDocsDeclarations checks the plugin declarations the Routing and Rate
// limiting pages show, the error-page override included.
func TestDocsDeclarations(t *testing.T) {
p := &BlogPlugin{}
if _, ok := p.Middlewares()["acme.auth"]; !ok {
t.Fatal("Middlewares() has no acme.auth")
}
if b := p.Buckets()["blog.comments"]; b.Max != 1 || b.Decay != time.Minute {
t.Fatalf("Buckets() = %+v", b)
}
if spec, err := p.APIDocsSpec(); err != nil || !strings.HasPrefix(string(spec), "openapi:") {
t.Fatalf("APIDocsSpec() = %q, %v", spec, err)
}
req := httptest.NewRequest("GET", "/nowhere", nil)
if body, ok := (themePages{}).RenderErrorPage(req, surf.ErrorPage{Status: http.StatusNotFound, Home: "/"}); !ok || !strings.Contains(string(body), `href="/"`) {
t.Fatalf("RenderErrorPage(404) = %q, %v", body, ok)
}
if _, ok := (themePages{}).RenderErrorPage(req, surf.ErrorPage{Status: http.StatusInternalServerError}); ok {
t.Fatal("RenderErrorPage(500) should keep the built-in page")
}
}