- 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
227 lines
9.3 KiB
Go
227 lines
9.3 KiB
Go
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{}
|