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
This commit is contained in:
Jakub Zych
2026-09-29 02:48:12 +02:00
parent c3c547c394
commit 7eed4acd87
15 changed files with 1832 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
package cabana
import (
"crypto/sha256"
"encoding/hex"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/boardwalk"
"git.golem15.com/golem15/summercms/modules/pact"
)
// extAssetRouter mounts the plugin asset route and the SPA shell the way
// service.mount does under the default prefix, with the real embedded SPA
// handler behind the fall-through.
func extAssetRouter(t *testing.T, reg *Registry) http.Handler {
t.Helper()
spa, err := boardwalk.Handler(DefaultAdminPrefix, http.HandlerFunc(writeNotFound))
if err != nil {
t.Fatal(err)
}
svc := &service{reg: reg, spa: spa}
mux := http.NewServeMux()
mux.HandleFunc("GET "+DefaultAdminPrefix+"/assets/{vendor}/{plugin}/{file...}", svc.pluginAsset)
mux.HandleFunc("GET "+DefaultAdminPrefix+"/{path...}", svc.serveSPA)
return mux
}
func serve(h http.Handler, method, target string, header map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
for key, value := range header {
req.Header.Set(key, value)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// TestPhase101Assets covers the plugin asset route (D-13, D-15, D-16;
// T-10.1-01, T-10.1-02, T-10.1-03): exact-key hits with explicit types and
// the admin security headers, revalidation, fall-through for everything else,
// the boot checks on declared paths, and the ?v= schema URLs.
func TestPhase101Assets(t *testing.T) {
reg, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
h := extAssetRouter(t, reg)
base := DefaultAdminPrefix + "/assets/acme/demo/"
for _, tc := range []struct{ file, url, contentType string }{
{extJS, base + "js/lookup.js", "text/javascript; charset=utf-8"},
{extCSS, base + "css/gadgets.css", "text/css; charset=utf-8"},
} {
t.Run("hit "+tc.file, func(t *testing.T) {
body, err := os.ReadFile(extDir + "/" + tc.file)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(body)
etag := `"` + hex.EncodeToString(sum[:]) + `"`
rec := serve(h, http.MethodGet, tc.url+"?v=ignored", nil)
hdr := rec.Header()
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
t.Fatalf("status=%d body=%.80q", rec.Code, rec.Body.String())
}
for key, want := range map[string]string{
"Content-Type": tc.contentType,
"X-Content-Type-Options": "nosniff",
"Cross-Origin-Resource-Policy": "same-origin",
"Cache-Control": "no-cache",
"ETag": etag,
"X-Frame-Options": "DENY",
"Referrer-Policy": "same-origin",
} {
if got := hdr.Get(key); got != want {
t.Fatalf("%s=%q want %q", key, got, want)
}
}
if !strings.Contains(hdr.Get("Content-Security-Policy"), "script-src 'self'") {
t.Fatalf("CSP=%q", hdr.Get("Content-Security-Policy"))
}
if strings.Contains(hdr.Get("Cache-Control"), "immutable") {
t.Fatal("plugin files must be revalidated, never immutable")
}
notModified := serve(h, http.MethodGet, tc.url, map[string]string{"If-None-Match": etag})
if notModified.Code != http.StatusNotModified || notModified.Body.Len() != 0 {
t.Fatalf("If-None-Match status=%d body=%d", notModified.Code, notModified.Body.Len())
}
stale := serve(h, http.MethodGet, tc.url, map[string]string{"If-None-Match": `"stale"`})
if stale.Code != http.StatusOK {
t.Fatalf("stale ETag status=%d", stale.Code)
}
head := serve(h, http.MethodHead, tc.url, nil)
if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Type") != tc.contentType {
t.Fatalf("HEAD status=%d body=%d type=%q", head.Code, head.Body.Len(), head.Header().Get("Content-Type"))
}
})
}
t.Run("everything else falls through to the SPA", func(t *testing.T) {
for _, target := range []string{
base + "controllers/gadgets/config_list.yaml",
base + "controllers/gadgets/_stats.htm",
base + "models/gadget/fields.yaml",
base + "js/other.js",
base + "assets/js/lookup.js",
base + "js/lookup.js/",
base + "JS/lookup.js",
DefaultAdminPrefix + "/assets/acme/other/js/lookup.js",
base + "..%2Fcontrollers%2Fgadgets%2Fconfig_list.yaml",
base + "js/..%2F..%2Fcontrollers/gadgets/_stats.htm",
base + "%2e%2e/controllers/gadgets/_stats.htm",
base + "js%2Flookup.js%00.yaml",
} {
rec := serve(h, http.MethodGet, target, nil)
body := rec.Body.String()
if rec.Code == http.StatusOK || strings.Contains(body, "modelClass") || strings.Contains(body, "summer-stat") ||
strings.Contains(body, "customElements") || strings.Contains(body, "fields:") {
t.Fatalf("%s status=%d body=%.120q", target, rec.Code, body)
}
}
})
t.Run("dist assets still come from the SPA handler", func(t *testing.T) {
dist, err := boardwalk.Dist()
if err != nil {
t.Fatal(err)
}
matches, err := fs.Glob(dist, "assets/index-*.js")
if err != nil || len(matches) == 0 {
t.Fatalf("no dist entry script: %v", err)
}
rec := serve(h, http.MethodGet, DefaultAdminPrefix+"/"+matches[0], nil)
if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" ||
rec.Header().Get("Content-Type") != "text/javascript; charset=utf-8" {
t.Fatalf("dist %s status=%d headers=%v", matches[0], rec.Code, rec.Header())
}
})
t.Run("schema URLs carry a content version", func(t *testing.T) {
svc := &service{reg: reg}
assets := svc.controllerAssets(cc)
version := regexp.MustCompile(`\?v=([0-9a-f]{12})$`)
for _, tc := range []struct{ url, file string }{{assets.Scripts[0], extJS}, {assets.Styles[0], extCSS}} {
match := version.FindStringSubmatch(tc.url)
if match == nil || !strings.HasPrefix(tc.url, base+strings.TrimPrefix(tc.file, "assets/")+"?v=") {
t.Fatalf("asset url %q", tc.url)
}
body, _ := os.ReadFile(extDir + "/" + tc.file)
sum := sha256.Sum256(body)
if match[1] != hex.EncodeToString(sum[:])[:12] {
t.Fatalf("version %s does not hash %s", match[1], tc.file)
}
}
if len(assets.Scripts) != 1 || len(assets.Styles) != 1 {
t.Fatalf("assets = %+v", assets)
}
if empty := svc.controllerAssets(nil); empty.Scripts == nil || empty.Styles == nil || len(empty.Scripts)+len(empty.Styles) != 0 {
t.Fatalf("nil controller assets = %#v", empty)
}
prefixed := (&service{reg: reg, prefix: "/acme-admin"}).controllerAssets(cc)
if !strings.HasPrefix(prefixed.Scripts[0], "/acme-admin/assets/acme/demo/js/lookup.js?v=") {
t.Fatalf("prefixed url %q", prefixed.Scripts[0])
}
})
t.Run("two controllers of one plugin share one entry", func(t *testing.T) {
spares := newExtController()
spares.id = "acme.demo.spares"
fsys := os.DirFS(extDir)
shared, err := compileRegistry([]controllerRef{
{plugin: extPlugin{fsys: fsys}, ctl: newExtController()},
{plugin: extPlugin{fsys: fsys}, ctl: spares},
})
if err != nil {
t.Fatal(err)
}
first, _ := shared.Get(extID)
second, _ := shared.Get("acme.demo.spares")
if len(shared.assets) != 2 || shared.assets["acme/demo/js/lookup.js"] != first.scripts[0] ||
first.scripts[0].key != second.scripts[0].key {
t.Fatalf("assets=%v first=%v second=%v", shared.assets, first.scripts, second.scripts)
}
})
assetCase := func(name string, js, css []string, want string) bootCase {
return bootCase{name: name, ctl: extController{extAssets{extBase: extBase{actions: extActions()}, js: js, css: css}},
want: []string{extPluginID, extID, want}}
}
runBootCases(t, []bootCase{
assetCase("path outside assets/", []string{"js/lookup.js"}, nil, "asset path must be a clean path under assets/"),
assetCase("leading slash", []string{"/assets/js/lookup.js"}, nil, "asset path must be a clean path under assets/"),
assetCase("dot-dot segment", []string{"assets/../assets/js/lookup.js"}, nil, "asset path must be a clean path under assets/"),
assetCase("escaping dot-dot", []string{"assets/../controllers/gadgets/_stats.js"}, nil, "asset path must be a clean path under assets/"),
assetCase("bare assets directory", []string{"assets/"}, nil, "asset path must be a clean path under assets/"),
assetCase("text file", []string{"assets/js/notes.txt"}, nil, "asset must end in .js or .mjs"),
assetCase("stylesheet declared as JS", []string{extCSS}, nil, "asset must end in .js or .mjs"),
assetCase("script declared as CSS", []string{extJS}, []string{extJS}, "asset must end in .css"),
assetCase("missing file", []string{"assets/js/missing.js"}, nil, "asset is not in the plugin's embedded files"),
assetCase("duplicate path", []string{extJS, extJS}, nil, "asset declared twice"),
})
t.Run("three-segment plugin ID", func(t *testing.T) {
ctl := newExtController()
ctl.id = "acme.demo.extra.gadgets"
err := compileClientAssets("acme.demo.extra", &CompiledController{Controller: ctl}, os.DirFS(extDir))
if err == nil || !strings.Contains(err.Error(), "plugin ID must be vendor.plugin") {
t.Fatalf("err = %v", err)
}
for _, id := range []string{"acme", "acme.de mo", "ac/me.demo"} {
if err := compileClientAssets(id, &CompiledController{Controller: ctl}, fstest.MapFS{}); err == nil {
t.Fatalf("plugin ID %q served assets", id)
}
}
if err := compileClientAssets(extPluginID, &CompiledController{Controller: extBase{}}, fstest.MapFS{}); err != nil {
t.Fatalf("controller without assets: %v", err)
}
})
}
var _ pact.AdminClientAssets = extAssets{}