Files
summercms/modules/cabana/phase101_render_test.go
Jakub Zych 7eed4acd87 test(10.1-04): cover the Phase 10.1 extension point Go code
- acme fixture plugin under modules/cabana/testdata/extension (gadgets
  controller, header and form partials, lookup widget, JS and CSS)
- TestPhase101FormExtensionSchema, TestPhase101PartialSchema and
  TestPhase101Toolbar: every widget, partial and toolbar boot rule
- TestPhase101PartialSanitizer: tag, attribute and URL allowlist, escaping,
  per-request trans, size/node/depth caps and the view-model guard
- TestPhase101Assets: exact-key asset hits, revalidation, SPA fall-through,
  boot path checks and ?v= schema URLs
- TestPhase101Actions (PostgreSQL): scoping, fill filter, strict body,
  action permission, error mapping, CSRF header, toolbar and partial routes
- TestPhase101BoardwalkExports: ContentType and SetSecurityHeaders
2026-09-29 02:48:12 +02:00

285 lines
11 KiB
Go

package cabana
import (
"context"
"encoding/json"
"errors"
"html/template"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/towel"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// sanitizeHTML runs raw markup through the same fragment parse and allowlist
// walk a rendered partial goes through, without html/template in front (which
// would already strip comments).
func sanitizeHTML(t *testing.T, src string) []PartialNode {
t.Helper()
container := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div}
parsed, err := html.ParseFragment(strings.NewReader(src), container)
if err != nil {
t.Fatalf("parse %q: %v", src, err)
}
nodes, err := sanitizePartialNodes(parsed, 0, &partialBudget{nodes: partialMaxNodes})
if err != nil {
t.Fatalf("sanitize %q: %v", src, err)
}
return nodes
}
func nodesJSON(t *testing.T, nodes []PartialNode) string {
t.Helper()
raw, err := json.Marshal(nodes)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
// renderPartial parses src as a partial template and renders it once.
func renderPartial(t *testing.T, src string, data any) ([]PartialNode, error) {
t.Helper()
compiled, err := parsePartial("probe", []byte(src))
if err != nil {
t.Fatalf("parse template: %v", err)
}
return compiled.render(context.Background(), nil, data)
}
// TestPhase101PartialSanitizer covers the server half of T-10.1-08, T-10.1-09
// and T-10.1-12: the tag, attribute and URL allowlist, escaping of view-model
// data, per-request translation, the size, node and depth caps, and the
// view-model guard.
func TestPhase101PartialSanitizer(t *testing.T) {
t.Run("dropped tags go with their subtree", func(t *testing.T) {
for tag := range partialDroppedTags {
var src string
switch tag {
case "input", "link", "meta", "base", "embed":
src = `<p>keep</p><` + tag + ` value="gone" href="/gone" content="gone"><p>tail</p>`
case "svg", "math":
src = `<p>keep</p><` + tag + `><text>gone</text><mi>gone</mi></` + tag + `><p>tail</p>`
case "select":
src = `<p>keep</p><select><option>gone</option></select><p>tail</p>`
default:
src = `<p>keep</p><` + tag + `>gone <b>gone</b></` + tag + `><p>tail</p>`
}
got := nodesJSON(t, sanitizeHTML(t, src))
if strings.Contains(got, "gone") || strings.Contains(got, `"`+tag+`"`) {
t.Fatalf("%s survived: %s", tag, got)
}
if !strings.Contains(got, `"text":"keep"`) || !strings.Contains(got, `"text":"tail"`) {
t.Fatalf("%s took its siblings with it: %s", tag, got)
}
}
if len(partialDroppedTags) != 19 {
t.Fatalf("dropped tag list has %d entries, the test expects 19", len(partialDroppedTags))
}
})
t.Run("unknown elements are unwrapped, children kept", func(t *testing.T) {
nodes := sanitizeHTML(t, `<article><acme-card><span class="x">kept</span></acme-card> text</article>`)
want := []PartialNode{{Tag: "span", Attrs: map[string]string{"class": "x"}, Children: []PartialNode{{Text: "kept"}}}, {Text: " text"}}
if !reflect.DeepEqual(nodes, want) {
t.Fatalf("nodes = %s", nodesJSON(t, nodes))
}
})
t.Run("attribute allowlist", func(t *testing.T) {
nodes := sanitizeHTML(t, `<div id="x" style="color:red" onclick="steal()" onmouseover="steal()" class="c" title="t" lang="pl" dir="rtl" role="note" aria-label="a" data-count="3" formaction="/x" tabindex="1">v</div>`)
want := map[string]string{"class": "c", "title": "t", "lang": "pl", "dir": "rtl", "role": "note", "aria-label": "a", "data-count": "3"}
if len(nodes) != 1 || !reflect.DeepEqual(nodes[0].Attrs, want) {
t.Fatalf("attrs = %s", nodesJSON(t, nodes))
}
cells := nodesJSON(t, sanitizeHTML(t, `<table><tbody><tr><td colspan="2" rowspan="1" scope="row" width="9">c</td></tr></tbody></table><time datetime="2026-09-29">d</time><meter value="1" min="0" max="2" low="0" high="2" optimum="1" onload="x()">m</meter><progress value="1" max="3">p</progress><data value="7">n</data>`))
for _, want := range []string{`"colspan":"2"`, `"rowspan":"1"`, `"scope":"row"`, `"datetime":"2026-09-29"`, `"optimum":"1"`, `"low":"0"`, `"high":"2"`, `"max":"3"`, `"value":"7"`} {
if !strings.Contains(cells, want) {
t.Fatalf("%s missing from %s", want, cells)
}
}
if strings.Contains(cells, "width") || strings.Contains(cells, "onload") {
t.Fatalf("per-tag attributes leaked: %s", cells)
}
// A per-tag attribute is not global: colspan on a div is dropped.
if got := sanitizeHTML(t, `<div colspan="2" href="/x" src="/y">v</div>`); got[0].Attrs != nil {
t.Fatalf("div attrs = %v", got[0].Attrs)
}
})
t.Run("link and image URLs", func(t *testing.T) {
for _, tc := range []struct {
src, attr string
keep bool
}{
{`<a href="/x">l</a>`, "href", true},
{`<a href="#top">l</a>`, "href", true},
{`<a href="/">l</a>`, "href", true},
{`<a href="//evil.test/x">l</a>`, "href", false},
{`<a href="/\evil.test">l</a>`, "href", false},
{`<a href="javascript:alert(1)">l</a>`, "href", false},
{`<a href="JaVaScRiPt:alert(1)">l</a>`, "href", false},
{`<a href="https://evil.test">l</a>`, "href", false},
{`<a href="x">l</a>`, "href", false},
{"<a href=\"/\t/evil.test\">l</a>", "href", false},
{`<a href="/&#10;/evil.test">l</a>`, "href", false},
{`<a href="">l</a>`, "href", false},
{`<img src="/a.png" alt="a" width="10" height="10">`, "src", true},
{`<img src="data:image/png;base64,AAAA">`, "src", false},
{`<img src="#frag">`, "src", false},
{`<img src="//evil.test/a.png">`, "src", false},
} {
nodes := sanitizeHTML(t, tc.src)
if len(nodes) != 1 {
t.Fatalf("%s: nodes = %s", tc.src, nodesJSON(t, nodes))
}
_, kept := nodes[0].Attrs[tc.attr]
if kept != tc.keep {
t.Fatalf("%s: %s kept=%v, want %v (%v)", tc.src, tc.attr, kept, tc.keep, nodes[0].Attrs)
}
}
if got := sanitizeHTML(t, `<img src="/a.png" alt="a" width="10" height="10" onerror="x()">`); !reflect.DeepEqual(got[0].Attrs, map[string]string{"src": "/a.png", "alt": "a", "width": "10", "height": "10"}) {
t.Fatalf("img attrs = %v", got[0].Attrs)
}
})
t.Run("comments and doctypes are dropped", func(t *testing.T) {
got := nodesJSON(t, sanitizeHTML(t, `<p>a<!-- secret -->b</p><!DOCTYPE html><!-- also -->`))
if strings.Contains(got, "secret") || strings.Contains(got, "also") || strings.Contains(got, "html") {
t.Fatalf("comment leaked: %s", got)
}
nodes, err := renderPartial(t, "<p>a<!-- template comment -->b</p>", nil)
if err != nil || strings.Contains(nodesJSON(t, nodes), "template comment") {
t.Fatalf("rendered comment: %v %s", err, nodesJSON(t, nodes))
}
})
t.Run("view-model markup stays text", func(t *testing.T) {
hostile := `<script>alert(1)</script><b onclick="x()">bold</b>`
nodes, err := renderPartial(t, `<p class="n">{{ .Data.Name }}</p><span title="{{ .Data.Name }}">t</span>`, struct{ Name string }{hostile})
if err != nil {
t.Fatal(err)
}
want := []PartialNode{
{Tag: "p", Attrs: map[string]string{"class": "n"}, Children: []PartialNode{{Text: hostile}}},
{Tag: "span", Attrs: map[string]string{"title": hostile}, Children: []PartialNode{{Text: "t"}}},
}
if !reflect.DeepEqual(nodes, want) {
t.Fatalf("nodes = %s", nodesJSON(t, nodes))
}
})
t.Run("trans resolves per request on one compiled partial", func(t *testing.T) {
_, tr := extTranslator(t)
_, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
stats := cc.partials["stats"]
vm, _ := extPartialView("stats", nil)
for _, tc := range []struct{ locale, label string }{{"en", "All gadgets"}, {"pl", "Wszystkie gadżety"}, {"en", "All gadgets"}} {
nodes, err := stats.render(towel.WithLocale(context.Background(), tc.locale), tr, vm)
if err != nil {
t.Fatalf("%s render: %v", tc.locale, err)
}
got := nodesJSON(t, nodes)
if !strings.Contains(got, `"text":"`+tc.label+`"`) || !strings.Contains(got, `"text":"3"`) || !strings.Contains(got, `"class":"summer-stats"`) {
t.Fatalf("%s nodes = %s", tc.locale, got)
}
}
// The pristine template was never executed, so it can still be cloned.
if _, err := stats.pristine.Clone(); err != nil {
t.Fatalf("pristine template was executed: %v", err)
}
})
t.Run("caps", func(t *testing.T) {
big := strings.Repeat("x", partialMaxBytes+1)
if _, err := renderPartial(t, `<p>{{ .Data }}</p>`, big); !errors.Is(err, errPartialTooLarge) {
t.Fatalf("size cap err = %v", err)
}
if _, err := renderPartial(t, `<p>{{ .Data }}</p>`, strings.Repeat("x", partialMaxBytes-len("<p></p>"))); err != nil {
t.Fatalf("output at the size cap: %v", err)
}
// Every <b>x</b> is two nodes: the element and its text.
atCap := strings.Repeat("<b>x</b>", partialMaxNodes/2)
if _, err := renderPartial(t, atCap, nil); err != nil {
t.Fatalf("%d nodes: %v", partialMaxNodes, err)
}
if _, err := renderPartial(t, atCap+"y", nil); err == nil || !strings.Contains(err.Error(), "2000 nodes") {
t.Fatalf("node cap err = %v", err)
}
nested := func(depth int) string {
return strings.Repeat("<div>", depth) + "x" + strings.Repeat("</div>", depth)
}
if _, err := renderPartial(t, nested(partialMaxDepth), nil); err != nil {
t.Fatalf("depth %d: %v", partialMaxDepth, err)
}
if _, err := renderPartial(t, nested(partialMaxDepth+1), nil); err == nil || !strings.Contains(err.Error(), "depth 32") {
t.Fatalf("depth cap err = %v", err)
}
})
t.Run("view-model guard", func(t *testing.T) {
_, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
for name, vm := range map[string]any{
"model": extGadget{},
"pointer": &extGadget{},
"slice": []extGadget{{}},
"slice of ptr": []*extGadget{{}},
"map of model": map[string]*extGadget{},
"template.HTML": struct{ Body template.HTML }{},
"nested HTMLAttr": struct {
Items []struct{ A template.HTMLAttr }
}{},
"template.URL": map[string]template.URL{},
} {
if refusedViewModel(cc, vm) == "" {
t.Fatalf("%s view model was accepted", name)
}
}
for name, vm := range map[string]any{
"nil": nil,
"curated": struct{ Name string }{},
"items": struct{ Items []extStatItem }{},
"string": "text",
} {
if reason := refusedViewModel(cc, vm); reason != "" {
t.Fatalf("%s view model refused: %s", name, reason)
}
}
})
t.Run("the partial route refuses a model view model", func(t *testing.T) {
app, _ := extTranslator(t)
reg, _ := mustCompileExt(t, leakyController{newExtController()}, os.DirFS(extDir))
svc := &service{app: app, reg: reg}
// _summary.htm reads .Data.Name, which the model has too: without
// the guard the model would render.
req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/gadgets/partials/summary"), nil)
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "gadgets")
req.SetPathValue("name", "summary")
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}))
rec := httptest.NewRecorder()
svc.partial(rec, req)
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "top-secret") {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "error")
})
}
// leakyController hands its GORM model to the template, which the partial
// handler must refuse.
type leakyController struct{ extController }
func (leakyController) PartialData(context.Context, string, any) (any, error) {
return &extGadget{Name: "top-secret"}, nil
}