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, "<script>")
}
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
}