feat(10.1-01): serve controller JS/CSS and run registered toolbar actions
- boardwalk exports ContentType and SetSecurityHeaders
- pact.AdminClientAssets files are read and hashed at boot and served by exact
key under {prefix}/assets/{vendor}/{plugin}/ with nosniff, CSP, CORP,
no-cache and an ETag; a miss falls through to the SPA
- list and form schemas carry assets URLs with a ?v= hash
- toolbar.buttons resolves create, delete and registered actions after decode;
toolbarActions is permission-filtered per admin
- POST .../toolbar/{action} behind requireAjax and action permissions
This commit is contained in:
@@ -85,7 +85,20 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true)
|
||||
}, into[cabana.Envelope[cabana.SettingsResult]]()},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/list", 200, "cabana.Envelope-cabana_ListSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true)
|
||||
rec := e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true)
|
||||
var body cabana.Envelope[cabana.ListSchema]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("list schema: %v", err)
|
||||
}
|
||||
if len(body.Data.ToolbarActions) != 1 || body.Data.ToolbarActions[0].Name != "recount" {
|
||||
t.Fatalf("toolbarActions = %#v", body.Data.ToolbarActions)
|
||||
}
|
||||
if len(body.Data.Assets.Scripts) != 1 || len(body.Data.Assets.Styles) != 1 {
|
||||
t.Fatalf("assets = %#v", body.Data.Assets)
|
||||
}
|
||||
e.assertPluginAsset(t, body.Data.Assets.Scripts[0], "text/javascript; charset=utf-8")
|
||||
e.assertPluginAsset(t, body.Data.Assets.Styles[0], "text/css; charset=utf-8")
|
||||
return rec
|
||||
}, into[cabana.Envelope[cabana.ListSchema]]()},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/form", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/form", nil, true)
|
||||
@@ -138,6 +151,9 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true)
|
||||
}, into[cabana.Envelope[cabana.RelationMutationResult]]()},
|
||||
{"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true)
|
||||
}, into[cabana.Envelope[cabana.AdminActionResult]]()},
|
||||
{"POST /{vendor}/{plugin}/{controller}/bulk-delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||
spare := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "spare-" + e.stamp}, true)
|
||||
return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": []uint{dataID(t, spare.Body.Bytes())}}, true)
|
||||
@@ -261,6 +277,35 @@ func (e *conformEnv) send(t *testing.T, method, rel string, body any, auth bool)
|
||||
return rec
|
||||
}
|
||||
|
||||
// assertPluginAsset fetches a schema asset URL through the assembled router
|
||||
// and checks the D-16 headers; an undeclared file under the same directory
|
||||
// must fall through to the SPA's 404.
|
||||
func (e *conformEnv) assertPluginAsset(t *testing.T, url, contentType string) {
|
||||
t.Helper()
|
||||
path, version, ok := strings.Cut(url, "?v=")
|
||||
if !ok || !strings.HasPrefix(path, cabana.DefaultAdminPrefix+"/assets/acme/conform/") || len(version) != 12 {
|
||||
t.Fatalf("asset url %q", url)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil))
|
||||
h := rec.Header()
|
||||
if rec.Code != http.StatusOK || h.Get("Content-Type") != contentType || h.Get("X-Content-Type-Options") != "nosniff" ||
|
||||
h.Get("Cross-Origin-Resource-Policy") != "same-origin" || h.Get("Cache-Control") != "no-cache" ||
|
||||
!strings.HasPrefix(h.Get("ETag"), `"`+version) || !strings.Contains(h.Get("Content-Security-Policy"), "script-src 'self'") {
|
||||
t.Fatalf("asset %s status=%d headers=%v", url, rec.Code, h)
|
||||
}
|
||||
miss := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(miss, httptest.NewRequest(http.MethodGet, cabana.DefaultAdminPrefix+"/assets/acme/conform/../controllers/gadgets/config_list.yaml", nil))
|
||||
if miss.Code == http.StatusOK && strings.Contains(miss.Body.String(), "modelClass") {
|
||||
t.Fatalf("plugin YAML leaked through the asset route")
|
||||
}
|
||||
undeclared := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(undeclared, httptest.NewRequest(http.MethodGet, cabana.DefaultAdminPrefix+"/assets/acme/conform/js/other.js", nil))
|
||||
if undeclared.Code != http.StatusNotFound {
|
||||
t.Fatalf("undeclared asset status=%d", undeclared.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func dataID(t *testing.T, raw []byte) uint {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
@@ -423,8 +468,15 @@ func (c conformController) AdminActions() []pact.AdminAction {
|
||||
Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
|
||||
return pact.AdminActionResult{Message: "Looked up", Fill: map[string]any{"name": "lookup-" + c.stamp, "active": true}}, nil
|
||||
},
|
||||
}, {
|
||||
Name: "recount", Label: "Recount", Permissions: []string{"acme.conform.access"},
|
||||
Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
|
||||
return pact.AdminActionResult{Message: "Recounted"}, nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
func (conformController) AdminJS() []string { return []string{"assets/js/lookup.js"} }
|
||||
func (conformController) AdminCSS() []string { return []string{"assets/css/gadgets.css"} }
|
||||
|
||||
func conformFS() fs.FS {
|
||||
file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }
|
||||
@@ -437,9 +489,30 @@ recordsPerPage: 20
|
||||
showCheckboxes: true
|
||||
filter: config_filter.yaml
|
||||
toolbar:
|
||||
buttons: [create, delete]
|
||||
buttons: [create, delete, recount]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
`),
|
||||
// A plain custom element: a light-DOM button that asks the admin SPA
|
||||
// to run the field's action. It makes no network call and never
|
||||
// touches cookies; the SPA owns HTTP (D-05).
|
||||
"assets/js/lookup.js": file(`class AcmeConformLookup extends HTMLElement {
|
||||
connectedCallback() {
|
||||
if (this.firstChild) return
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.textContent = this.getAttribute('label') || 'Lookup'
|
||||
button.addEventListener('click', () => {
|
||||
this.dispatchEvent(new CustomEvent('summer-action', { bubbles: true, composed: true }))
|
||||
})
|
||||
this.append(button)
|
||||
}
|
||||
}
|
||||
if (!customElements.get('acme-conform-lookup')) {
|
||||
customElements.define('acme-conform-lookup', AcmeConformLookup)
|
||||
}
|
||||
`),
|
||||
"assets/css/gadgets.css": file(`acme-conform-lookup button { font: inherit; }
|
||||
`),
|
||||
"controllers/gadgets/config_filter.yaml": file(`scopes:
|
||||
grouped:
|
||||
|
||||
Reference in New Issue
Block a user