~bigbes/sr-ht-ecore

996577debafad52175a58dad9819376a0ba1887c — Eugene Blikh 9 days ago 77b1a8d
pages: the shared page set, buffered render and error page
5 files changed, 755 insertions(+), 0 deletions(-)

A pages/error.go
A pages/pages.go
A pages/pages_test.go
A pages/templates/error.html
A pages/templates/error.tmpl
A pages/error.go => pages/error.go +130 -0
@@ 0,0 1,130 @@
package pages

import (
	"embed"
	"net/http"
)

// sharedFS holds this package's own templates: the error partial parsed into
// every set, and the error page used by a service that ships none.
//
//go:embed templates
var sharedFS embed.FS

const (
	sharedDir = "templates"

	// errorPartialFile defines ErrorPartial and nothing else, so that it can be
	// parsed into every page set without bringing a content block with it.
	errorPartialFile = "error.tmpl"

	// errorPageFile is the default error page: a content block that is one
	// invocation of ErrorPartial.
	errorPageFile = "error.html"
)

// ErrorPage is the name Load registers the error page under, and the name to
// pass Render for it. It is a constant because a service's `fail` names it on
// every arm of its switch.
const ErrorPage = "error"

// ErrorPartial is the shared error body: the status line, the message and the
// way back. Its dot is an ErrorData — not a view struct — so a service that
// wants its own error.html around it invokes it as
// {{template "srht-error" .Data}}.
const ErrorPartial = "srht-error"

// The messages of the error pages a surface produces on its own. They are
// constants, and shared ones, for the reason the donors gave: the visibility
// rules of these services require "somebody else's private thing" and "no such
// thing" to be indistinguishable, and two 404s that differed in their prose
// would rebuild the distinction the status code was chosen to erase.
//
// A 400 has no constant deliberately. It is the one class that describes
// something the viewer just typed — "1y is not a duration", "grants are not
// lower case" — and an error page that replaced that with a house phrase would
// send them back to the form with nothing to change.
const (
	NotFoundMessage     = "There is nothing here."
	UnauthorizedMessage = "You need to be logged in to do that."
	ForbiddenMessage    = "You may not do that."
	MethodMessage       = "That is not something you can do to this page."
	InternalMessage     = "Something went wrong on our side. It has been logged."
	UnavailableMessage  = "Something we depend on is not answering. Try again in a moment."
)

// Message is the standard message for a status, or "" for a status that has
// none — a 400 above all, whose message is the caller's own text.
func Message(status int) string {
	switch status {
	case http.StatusUnauthorized:
		return UnauthorizedMessage
	case http.StatusForbidden:
		return ForbiddenMessage
	case http.StatusNotFound:
		return NotFoundMessage
	case http.StatusMethodNotAllowed:
		return MethodMessage
	case http.StatusInternalServerError:
		return InternalMessage
	case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
		return UnavailableMessage
	default:
		return ""
	}
}

// A Link is a href and the text that carries it.
type Link struct {
	Href string
	Text string
}

// DefaultBack is the way back off an error page for a service that names none:
// its own root, which every one of these services answers with a landing page.
var DefaultBack = Link{Href: "/", Text: "Back to the start"}

// ErrorData is the payload of the error page. A service puts it where its view
// struct keeps page payload — the field the layout's content sees as .Data in
// all of the donors:
//
//	vd := s.view(r, http.StatusText(status))
//	vd.Data = pages.Error(status, message).BackTo("/tokens", "Back to your tokens")
//	err := s.pages.Render(w, status, pages.ErrorPage, vd)
type ErrorData struct {
	Status     int
	StatusText string

	// Message is what the viewer can act on. It is never the raw error from
	// below: those name tables, queries and paths. It goes through
	// html/template, which escapes it — which matters, because the messages
	// that are not constants quote what the caller typed.
	Message string

	// Back is the way off this page. Empty renders no link at all rather than a
	// link to nowhere, which is what a hand-built ErrorData would otherwise get.
	Back Link
}

// Error builds the payload for a status. An empty message takes the standard
// one for that status (Message), so a caller that has nothing of its own to add
// says nothing rather than inventing a phrase.
func Error(status int, message string) ErrorData {
	if message == "" {
		message = Message(status)
	}
	return ErrorData{
		Status:     status,
		StatusText: http.StatusText(status),
		Message:    message,
		Back:       DefaultBack,
	}
}

// BackTo replaces the way back, for the services whose landing page is not "/"
// or whose word for it is not "back to the start". It returns a copy, so it
// chains off Error.
func (d ErrorData) BackTo(href, text string) ErrorData {
	d.Back = Link{Href: href, Text: text}
	return d
}

A pages/pages.go => pages/pages.go +349 -0
@@ 0,0 1,349 @@
// Package pages is the shared page-template machinery for the custom services
// of a self-hosted SourceHut instance (compare, dolt, cover, bench, tokens).
//
// Every one of those services grew the same web/templates.go: discover the page
// templates next to a shared layout, parse one template set per page, refuse a
// page that defines no "content", execute into a buffer and only then write the
// response, and render one error page for every status the surface refuses
// with. The copies drifted in the ways copies do — one kept a hand-maintained
// list of page names instead of reading the directory, one wrote the template
// error into the response body, one skipped the content check entirely — and
// each drift is a different way to serve a viewer a page that is silently
// wrong. This package is the one copy, and it keeps the two refusals the
// donors argued for at length:
//
//   - A page that defines no "content" is a startup error. Executed, it would
//     render the chrome around an empty hole and answer 200, and a blank page
//     is the one failure a viewer cannot report usefully. This is also why the
//     layout must invoke the hole with {{template "content" .}} and never with
//     {{block "content" .}}: `block` defines the name it invokes, which would
//     hand every page an empty default at once and silently disarm the check.
//
//   - A render goes into a buffer first. html/template writes as it evaluates,
//     so executing straight into the ResponseWriter commits the status line and
//     however many kilobytes of chrome were already produced before reaching
//     the expression that fails. Buffering costs one page of memory and turns
//     that into a clean 500.
//
// Usage, at startup:
//
//	set, err := pages.Load(tmplFS, pages.Options{Funcs: myHelpers})
//
// and in a handler:
//
//	if err := s.pages.Render(w, http.StatusOK, "index", vd); err != nil {
//		log.Printf("web: %s %s: %v", r.Method, r.URL.Path, err)
//	}
//
// Render answers the response itself in every case, so a returned error is for
// the log and never for a second answer — see Render.
//
// What stays in the services: the mapping from their own domain sentinels onto
// HTTP statuses (each service's `fail`). Two surfaces of one service must agree
// about which object exists, and that agreement is a property of that service's
// domain, not of this package.
package pages

import (
	"bytes"
	"errors"
	"fmt"
	"html/template"
	"io/fs"
	"maps"
	"net/http"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
)

// Defaults for Options, and the file extension a page or partial is recognised
// by. The extension is not configurable: every service in this family writes
// .html, and a second spelling would only make the discovery rule harder to
// read than the list it replaces.
const (
	DefaultDir           = "templates"
	DefaultLayout        = "layout.html"
	DefaultPartialPrefix = "_"
	DefaultContentBlock  = "content"

	pageExt = ".html"
)

// ErrNoContent is returned by Load for a page template that defines no content
// block. It is a sentinel rather than a bare string because it is the one Load
// failure that is a template-authoring mistake and not a build or packaging
// one, and a service that wants to say so in its startup message needs to tell
// them apart.
var ErrNoContent = errors.New("pages: no content block")

// ErrUnknownPage is returned by Render when the name is not in the set. It is
// always a bug in the calling package — the set is built from the files that
// exist — so it is worth recognising in a log filter.
var ErrUnknownPage = errors.New("pages: unknown page")

// internalServerError is the body of every 500 this package writes itself. It
// says nothing, deliberately: the error it stands for names templates, fields
// and payload types, and the dolt donor published exactly that string to the
// browser.
const internalServerError = "internal server error"

// Options configures Load. The zero value is the layout of every service in
// this family: templates/layout.html, partials prefixed with '_', a "content"
// block, and the shared chrome helpers.
type Options struct {
	// Dir is the directory inside the FS holding the layout, the partials and
	// the pages. Defaults to DefaultDir.
	Dir string

	// Layout is the outer chrome every page is executed through, by file name.
	// Defaults to DefaultLayout.
	Layout string

	// PartialPrefix marks the files in Dir that are fragments rather than
	// pages. Defaults to DefaultPartialPrefix.
	//
	// A partial is parsed into *every* page's set, not only into the pages that
	// invoke it today: a partial known only to the pages that used it on the
	// day it was written is a lookup failure on the page that needs it next.
	PartialPrefix string

	// ContentBlock is the one block a page must define. Defaults to
	// DefaultContentBlock.
	ContentBlock string

	// Funcs are the service's own template helpers. They are merged over
	// chrome.Funcs, in that order, so the shared partials always find the
	// helpers they were written against and a service can still shadow one
	// deliberately rather than by accident of map ordering.
	Funcs template.FuncMap
}

// withDefaults fills the unset fields. It works on a copy: Load must not
// rewrite the caller's struct, which is usually a literal at a call site that
// documents what the service actually chose.
func (o Options) withDefaults() Options {
	if o.Dir == "" {
		o.Dir = DefaultDir
	}
	if o.Layout == "" {
		o.Layout = DefaultLayout
	}
	if o.PartialPrefix == "" {
		o.PartialPrefix = DefaultPartialPrefix
	}
	if o.ContentBlock == "" {
		o.ContentBlock = DefaultContentBlock
	}
	return o
}

// funcs is the merged helper map, chrome's first and the service's on top.
func (o Options) funcs() template.FuncMap {
	m := chrome.Funcs()
	maps.Copy(m, o.Funcs)
	return m
}

// A Set maps a page name — the template file's name without its extension — to
// the template set that renders it: the layout, the shared chrome partials, the
// shipped error partial, every local partial, and that one page's content.
//
// Every page gets its own set rather than all of them sharing one, because each
// defines "content" and a shared set would let the last one parsed win.
type Set map[string]*template.Template

// Load parses one template set per content page found in the FS.
//
// Pages are discovered from the directory rather than listed in a slice, so
// adding templates/whatever.html is the whole registration of a page. The
// alternative — the static list the compare donor kept — is one more edit to
// forget, and forgetting it yields a page that 500s with "unknown template"
// while the file sits right there in the tree.
//
// A page that defines no content block is refused here, at startup, for the
// reason given in the package doc.
//
// The set always has an "error" page: the one this package ships (error.html),
// unless the FS carries an error.html of its own, which then wins whole. Either
// way the "srht-error" partial is parsed into every set, so a service that
// wants its own error page around the standard body can invoke it rather than
// copy it.
//
// It takes the FS rather than an embed.FS so a test can hand it a bad tree: the
// refusals above are the whole point of this function and none of them is
// reachable through a service's own embedded templates, which are exactly the
// files it ships and is expected to keep valid.
func Load(fsys fs.FS, opts Options) (Set, error) {
	opts = opts.withDefaults()
	funcs := opts.funcs()

	entries, err := fs.ReadDir(fsys, opts.Dir)
	if err != nil {
		return nil, fmt.Errorf("pages: read %s: %w", opts.Dir, err)
	}

	var pageNames, partials []string
	layoutFound := false
	for _, e := range entries {
		name := e.Name()
		switch {
		case e.IsDir() || !strings.HasSuffix(name, pageExt):
			continue
		case name == opts.Layout:
			layoutFound = true
		case strings.HasPrefix(name, opts.PartialPrefix):
			partials = append(partials, opts.Dir+"/"+name)
		default:
			pageNames = append(pageNames, name)
		}
	}
	if !layoutFound {
		// Reported here rather than left to ParseFS, whose message for a pattern
		// that matches nothing does not mention that the missing file is the
		// layout every page is executed through.
		return nil, fmt.Errorf("pages: no layout %s in %s", opts.Layout, opts.Dir)
	}
	if len(pageNames) == 0 {
		// Only reachable if an embed pattern stops matching, which is a build
		// change and not a runtime condition; it is checked because the symptom
		// otherwise is a daemon that starts happily and 500s on every route.
		return nil, fmt.Errorf("pages: no page templates in %s", opts.Dir)
	}

	set := make(Set, len(pageNames)+1)
	for _, page := range pageNames {
		defines, err := opts.definesContent(fsys, page, funcs)
		if err != nil {
			return nil, err
		}
		if !defines {
			return nil, fmt.Errorf("pages: template %s defines no %q block: %w",
				page, opts.ContentBlock, ErrNoContent)
		}

		t, err := opts.base(funcs)
		if err != nil {
			return nil, err
		}
		// The layout first and the page last, so that a page's content wins over
		// any default the layout may carry for it: text/template keeps the last
		// definition of a name it parses.
		files := append([]string{opts.Dir + "/" + opts.Layout}, partials...)
		files = append(files, opts.Dir+"/"+page)
		if _, err := t.ParseFS(fsys, files...); err != nil {
			return nil, fmt.Errorf("pages: parse template %s: %w", page, err)
		}
		set[strings.TrimSuffix(page, pageExt)] = t
	}

	if _, ok := set[ErrorPage]; !ok {
		t, err := opts.base(funcs)
		if err != nil {
			return nil, err
		}
		files := append([]string{opts.Dir + "/" + opts.Layout}, partials...)
		if _, err := t.ParseFS(fsys, files...); err != nil {
			return nil, fmt.Errorf("pages: parse the layout for the error page: %w", err)
		}
		if _, err := t.ParseFS(sharedFS, sharedDir+"/"+errorPageFile); err != nil {
			return nil, fmt.Errorf("pages: parse the shipped error page: %w", err)
		}
		set[ErrorPage] = t
	}
	return set, nil
}

// base is the empty set every page starts from: the funcs, the shared chrome
// partials, and this package's own.
//
// The chrome partials go in through chrome.Attach rather than
// chrome.MustAttach: a parse failure in somebody else's module is still a
// startup error the daemon should report with a sentence naming what it was
// doing, and Load already returns an error for everything else that can go
// wrong here.
func (o Options) base(funcs template.FuncMap) (*template.Template, error) {
	t, err := chrome.Attach(template.New(o.Layout).Funcs(funcs))
	if err != nil {
		return nil, fmt.Errorf("pages: attach the shared chrome partials: %w", err)
	}
	if _, err := t.ParseFS(sharedFS, sharedDir+"/"+errorPartialFile); err != nil {
		return nil, fmt.Errorf("pages: parse the shared error partial: %w", err)
	}
	return t, nil
}

// definesContent reports whether a page's own file defines the content block.
//
// It parses the page alone, without the layout and without the partials, and
// that separate parse is the whole of what this function is for. The obvious
// check — looking "content" up in the assembled set — only works while
// layout.html spells its hole {{template "content" .}} and its neighbours are
// {{block "head" .}} and {{block "scripts" .}}: `block` *defines* the name it
// invokes, so the day somebody makes the three consistent — a tidying edit no
// reviewer would question — Lookup starts finding the layout's own empty
// default on every page and the guard silently stops guarding. What comes back
// then is the failure it exists to prevent: the chrome around an empty hole,
// answered 200. Parsed on its own a page has only what it defines itself, and
// no edit to the layout can reach that.
//
// The cost is one extra parse per page, once, at startup.
func (o Options) definesContent(fsys fs.FS, page string, funcs template.FuncMap) (bool, error) {
	t := template.New(page).Funcs(funcs)
	if _, err := t.ParseFS(fsys, o.Dir+"/"+page); err != nil {
		return false, fmt.Errorf("pages: parse template %s: %w", page, err)
	}
	return t.Lookup(o.ContentBlock) != nil, nil
}

// Render executes a page and writes it.
//
// The execution goes into a buffer first, and that is the whole point of this
// function: a template that fails halfway has otherwise already written a
// partial page under a 200 that cannot be taken back, and a viewer cannot tell
// half a document from a page that is genuinely that short. On some of these
// surfaces the missing half is the one carrying a secret that will never be
// shown again.
//
// Render answers the response in every case, and the contract that follows from
// that is the important half of this comment: **a returned error means the
// response has already been answered**, so the caller must log it and nothing
// else. Handing it back to a `fail` that renders an error page would either
// write a second response over a committed one, or — when the failure is in the
// error page itself — recurse until the stack runs out. It is returned rather
// than logged here because this package has no opinion about the caller's
// logger, and the callers have three between them.
//
// What is answered on failure is a bare 500 carrying a fixed string. The dolt
// donor wrote "template render error: "+err.Error() into the body instead,
// which publishes template names, field paths and whatever the payload's
// String method produces to whoever asked for the page.
func (s Set) Render(w http.ResponseWriter, status int, name string, data any) error {
	t, ok := s[name]
	if !ok {
		// A page name that is not a template is a bug in the calling package —
		// the set is built from the files that exist — so it is answered as the
		// 500 it is.
		http.Error(w, internalServerError, http.StatusInternalServerError)
		return fmt.Errorf("pages: page %q: %w", name, ErrUnknownPage)
	}

	// t.Execute and not ExecuteTemplate(layout): Load names every set after the
	// layout it parses, so t *is* the layout, and naming it again here would be
	// a second place for Options.Layout to be spelled.
	var buf bytes.Buffer
	if err := t.Execute(&buf, data); err != nil {
		http.Error(w, internalServerError, http.StatusInternalServerError)
		return fmt.Errorf("pages: execute template %q: %w", name, err)
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(status)
	if _, err := buf.WriteTo(w); err != nil {
		// The viewer hung up mid-response. Nothing left to answer with and the
		// read is already done, so this is a log line — which is what every
		// error out of here is.
		return fmt.Errorf("pages: write the %d page: %w", status, err)
	}
	return nil
}

A pages/pages_test.go => pages/pages_test.go +251 -0
@@ 0,0 1,251 @@
package pages

import (
	"errors"
	"html/template"
	"net/http"
	"net/http/httptest"
	"testing"
	"testing/fstest"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// view is the dot every test renders through: the shape the donors' view
// structs have — chrome fields (here only a title) plus the page's payload
// under .Data, which is what the shipped error page reads.
type view struct {
	Title string
	Data  any
}

// chromeMark is written by the layout before it reaches the content hole, so a
// test can tell "nothing was written" from "half a page was written".
const chromeMark = "CHROME-MARK"

// boomer fails at execution and only there: html/template reports an error
// returned by a method as a render error, which is the mid-render failure the
// buffering exists for. Its message names something a viewer must never see.
type boomer struct{}

func (boomer) Boom() (string, error) { return "", errors.New("kaboom /etc/srht/config.ini") }

// testFS is a service's template tree: a layout, a page, a partial, and a page
// that fails halfway through rendering.
func testFS() fstest.MapFS {
	return fstest.MapFS{
		"templates/layout.html": &fstest.MapFile{Data: []byte(
			`<!doctype html><title>{{.Title}}</title><body>` + chromeMark +
				`{{template "content" .}}</body>`)},
		"templates/index.html": &fstest.MapFile{Data: []byte(
			`{{define "content"}}<p>index {{template "_row" .Data}}</p>{{end}}`)},
		"templates/_row.html": &fstest.MapFile{Data: []byte(
			`{{define "_row"}}<i>{{.}}</i>{{end}}`)},
		"templates/boom.html": &fstest.MapFile{Data: []byte(
			`{{define "content"}}<p>{{.Data.Boom}}</p>{{end}}`)},
	}
}

func TestLoadDiscoversPagesPartialsAndTheShippedErrorPage(t *testing.T) {
	set, err := Load(testFS(), Options{})
	require.NoError(t, err)

	// The pages, plus the error page this package ships. The layout and the
	// partial are not pages and must not be renderable by name.
	assert.ElementsMatch(t, []string{"index", "boom", ErrorPage}, keys(set))
}

func TestLoadParsesEveryPartialIntoEveryPage(t *testing.T) {
	set, err := Load(testFS(), Options{})
	require.NoError(t, err)

	// A partial is parsed into every set, not only into the page that invokes
	// it today — including the error page, which invokes none of them.
	for name, tmpl := range set {
		assert.NotNil(t, tmpl.Lookup("_row"), "local partial missing from %q", name)
		assert.NotNil(t, tmpl.Lookup(ErrorPartial), "error partial missing from %q", name)
		assert.NotNil(t, tmpl.Lookup("srht-nav"), "chrome partial missing from %q", name)
	}
}

func TestLoadRefusesAPageThatDefinesNoContent(t *testing.T) {
	fsys := testFS()
	// A page that renders nothing into the hole: executed, it would answer 200
	// with the chrome around an empty document.
	fsys["templates/blank.html"] = &fstest.MapFile{Data: []byte(`<p>forgot the define</p>`)}

	_, err := Load(fsys, Options{})
	require.Error(t, err)
	assert.ErrorIs(t, err, ErrNoContent)
	assert.Contains(t, err.Error(), "blank.html")
}

func TestLoadRefusesATreeWithoutALayout(t *testing.T) {
	fsys := testFS()
	delete(fsys, "templates/layout.html")

	_, err := Load(fsys, Options{})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "no layout")
}

func TestLoadRefusesATreeWithNoPages(t *testing.T) {
	fsys := fstest.MapFS{
		"templates/layout.html": &fstest.MapFile{Data: []byte(`{{template "content" .}}`)},
		"templates/_row.html":   &fstest.MapFile{Data: []byte(`{{define "_row"}}{{end}}`)},
	}

	_, err := Load(fsys, Options{})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "no page templates")
}

func TestLoadMergesTheServiceFuncsOverChromes(t *testing.T) {
	fsys := testFS()
	fsys["templates/index.html"] = &fstest.MapFile{Data: []byte(
		`{{define "content"}}{{shortsha "0123456789"}}|{{shout "hi"}}{{end}}`)}

	set, err := Load(fsys, Options{Funcs: template.FuncMap{
		"shout":    func(s string) string { return s + "!" },
		"shortsha": func(string) string { return "SHADOWED" },
	}})
	require.NoError(t, err)

	rec := httptest.NewRecorder()
	require.NoError(t, set.Render(rec, http.StatusOK, "index", view{}))
	assert.Contains(t, rec.Body.String(), "SHADOWED|hi!")
}

func TestRenderWritesTheStatusAndTheBufferedPage(t *testing.T) {
	set, err := Load(testFS(), Options{})
	require.NoError(t, err)

	rec := httptest.NewRecorder()
	require.NoError(t, set.Render(rec, http.StatusCreated, "index", view{Title: "T", Data: "payload"}))

	assert.Equal(t, http.StatusCreated, rec.Code)
	assert.Equal(t, "text/html; charset=utf-8", rec.Header().Get("Content-Type"))
	body := rec.Body.String()
	assert.Contains(t, body, "<title>T</title>")
	assert.Contains(t, body, chromeMark)
	assert.Contains(t, body, "<i>payload</i>")
}

func TestRenderFailureLeavesNoPartialPageAndNoTemplateText(t *testing.T) {
	set, err := Load(testFS(), Options{})
	require.NoError(t, err)

	rec := httptest.NewRecorder()
	err = set.Render(rec, http.StatusOK, "boom", view{Title: "T", Data: boomer{}})

	// The caller gets the error to log, naming the page and carrying the cause.
	require.Error(t, err)
	assert.Contains(t, err.Error(), `"boom"`)
	assert.Contains(t, err.Error(), "kaboom")

	// The viewer gets a clean 500 and nothing else: not the status the handler
	// asked for, not the chrome the template had already produced when it
	// failed, and above all not the template's own error text — which is what
	// the dolt donor wrote into the body.
	assert.Equal(t, http.StatusInternalServerError, rec.Code)
	assert.Equal(t, internalServerError+"\n", rec.Body.String())
	assert.NotContains(t, rec.Body.String(), chromeMark)
	assert.NotContains(t, rec.Body.String(), "kaboom")
	assert.NotContains(t, rec.Body.String(), "config.ini")
}

func TestRenderRefusesAnUnknownPage(t *testing.T) {
	set, err := Load(testFS(), Options{})
	require.NoError(t, err)

	rec := httptest.NewRecorder()
	err = set.Render(rec, http.StatusOK, "nope", view{})

	require.Error(t, err)
	assert.ErrorIs(t, err, ErrUnknownPage)
	assert.Equal(t, http.StatusInternalServerError, rec.Code)
	assert.Equal(t, internalServerError+"\n", rec.Body.String())
}

func TestErrorPageRendersTheStatusTheMessageAndTheWayBack(t *testing.T) {
	set, err := Load(testFS(), Options{})
	require.NoError(t, err)

	data := Error(http.StatusNotFound, "").BackTo("/tokens", "Back to your tokens")
	rec := httptest.NewRecorder()
	require.NoError(t, set.Render(rec, data.Status, ErrorPage,
		view{Title: data.StatusText, Data: data}))

	assert.Equal(t, http.StatusNotFound, rec.Code)
	body := rec.Body.String()
	assert.Contains(t, body, chromeMark, "the error page is rendered inside the chrome")
	assert.Contains(t, body, "404")
	assert.Contains(t, body, "Not Found")
	assert.Contains(t, body, NotFoundMessage)
	assert.Contains(t, body, `<a href="/tokens">Back to your tokens</a>`)
}

func TestErrorPageEscapesTheMessage(t *testing.T) {
	set, err := Load(testFS(), Options{})
	require.NoError(t, err)

	// A 400's message quotes what the caller typed, so it is the one that can
	// carry markup.
	data := Error(http.StatusBadRequest, `<script>alert(1)</script> is not a duration`)
	rec := httptest.NewRecorder()
	require.NoError(t, set.Render(rec, data.Status, ErrorPage, view{Data: data}))

	body := rec.Body.String()
	assert.NotContains(t, body, "<script>")
	assert.Contains(t, body, "&lt;script&gt;")
}

func TestErrorPageOmitsAWayBackItWasNotGiven(t *testing.T) {
	set, err := Load(testFS(), Options{})
	require.NoError(t, err)

	rec := httptest.NewRecorder()
	require.NoError(t, set.Render(rec, http.StatusForbidden, ErrorPage,
		view{Data: ErrorData{Status: 403, StatusText: "Forbidden", Message: ForbiddenMessage}}))

	assert.NotContains(t, rec.Body.String(), "<a href=", "no link rather than a link to nowhere")
}

func TestAServiceErrorPageWinsOverTheShippedOne(t *testing.T) {
	fsys := testFS()
	fsys["templates/error.html"] = &fstest.MapFile{Data: []byte(
		`{{define "content"}}<div class="own">{{template "srht-error" .Data}}</div>{{end}}`)}

	set, err := Load(fsys, Options{})
	require.NoError(t, err)

	rec := httptest.NewRecorder()
	data := Error(http.StatusServiceUnavailable, "")
	require.NoError(t, set.Render(rec, data.Status, ErrorPage, view{Data: data}))

	body := rec.Body.String()
	assert.Contains(t, body, `<div class="own">`, "the service's own error page is used")
	assert.Contains(t, body, UnavailableMessage, "and it can still invoke the shared body")
}

func TestErrorTakesTheStandardMessageForItsStatus(t *testing.T) {
	assert.Equal(t, NotFoundMessage, Error(http.StatusNotFound, "").Message)
	assert.Equal(t, InternalMessage, Error(http.StatusInternalServerError, "").Message)
	assert.Equal(t, "Not Found", Error(http.StatusNotFound, "").StatusText)
	assert.Equal(t, DefaultBack, Error(http.StatusNotFound, "").Back)

	// A caller's own message is never replaced, and a status with no standard
	// message keeps the empty one rather than inventing a phrase.
	assert.Equal(t, "1y is not a duration", Error(http.StatusBadRequest, "1y is not a duration").Message)
	assert.Empty(t, Error(http.StatusBadRequest, "").Message)
	assert.Empty(t, Message(http.StatusTeapot))
}

func keys(s Set) []string {
	out := make([]string, 0, len(s))
	for name := range s {
		out = append(out, name)
	}
	return out
}

A pages/templates/error.html => pages/templates/error.html +6 -0
@@ 0,0 1,6 @@
{{/*
  The default error page, registered as "error" for every service that ships no
  error.html of its own. It is one invocation of the shared body, and it reads
  .Data because that is where all of the donors' view structs keep page payload.
*/}}
{{define "content"}}{{template "srht-error" .Data}}{{end}}

A pages/templates/error.tmpl => pages/templates/error.tmpl +19 -0
@@ 0,0 1,19 @@
{{/*
  The shared error body, for every status a surface refuses with. Its dot is a
  pages.ErrorData and not a view struct, so a service that wants its own
  error.html can wrap this rather than copy it:

    {{define "content"}}{{template "srht-error" .Data}}{{end}}

  The message is escaped here — it is a constant on most arms, but the ones
  that are not quote what the caller typed.
*/}}
{{define "srht-error" -}}
<div class="row">
  <div class="col-md-8">
    <h2>{{.Status}} &mdash; {{.StatusText}}</h2>
    {{if .Message}}<p>{{.Message}}</p>{{end}}
    {{with .Back.Href}}<p><a href="{{.}}">{{$.Back.Text}}</a></p>{{end}}
  </div>
</div>
{{- end}}