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:
226
modules/cabana/phase101_assets_test.go
Normal file
226
modules/cabana/phase101_assets_test.go
Normal 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{}
|
||||
Reference in New Issue
Block a user