From 996577debafad52175a58dad9819376a0ba1887c Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 8 Aug 2026 22:16:40 +0300 Subject: [PATCH] pages: the shared page set, buffered render and error page --- pages/error.go | 130 ++++++++++++++ pages/pages.go | 349 +++++++++++++++++++++++++++++++++++++ pages/pages_test.go | 251 ++++++++++++++++++++++++++ pages/templates/error.html | 6 + pages/templates/error.tmpl | 19 ++ 5 files changed, 755 insertions(+) create mode 100644 pages/error.go create mode 100644 pages/pages.go create mode 100644 pages/pages_test.go create mode 100644 pages/templates/error.html create mode 100644 pages/templates/error.tmpl diff --git a/pages/error.go b/pages/error.go new file mode 100644 index 0000000000000000000000000000000000000000..1ed4808c8ed1c50edc161220b5636f6dc6d98fae --- /dev/null +++ b/pages/error.go @@ -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 +} diff --git a/pages/pages.go b/pages/pages.go new file mode 100644 index 0000000000000000000000000000000000000000..061a26b64657af9b80409bcf8b40afbd4fa388f9 --- /dev/null +++ b/pages/pages.go @@ -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 +} diff --git a/pages/pages_test.go b/pages/pages_test.go new file mode 100644 index 0000000000000000000000000000000000000000..491be05c6e19387181f2e110b58c7ce75044a2c9 --- /dev/null +++ b/pages/pages_test.go @@ -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( + `{{.Title}}` + chromeMark + + `{{template "content" .}}`)}, + "templates/index.html": &fstest.MapFile{Data: []byte( + `{{define "content"}}

index {{template "_row" .Data}}

{{end}}`)}, + "templates/_row.html": &fstest.MapFile{Data: []byte( + `{{define "_row"}}{{.}}{{end}}`)}, + "templates/boom.html": &fstest.MapFile{Data: []byte( + `{{define "content"}}

{{.Data.Boom}}

{{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(`

forgot the define

`)} + + _, 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, "T") + assert.Contains(t, body, chromeMark) + assert.Contains(t, body, "payload") +} + +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, `Back to your tokens`) +} + +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, ` 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, "