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,667 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
)
// The Phase 10.1 schema, sanitizer and asset tests load the acme fixture
// plugin under testdata/extension: a gadgets controller with a header
// partial, a registered toolbar action, a lookup widget and a summary form
// partial, plus one JS and one CSS file.
const (
extDir = "testdata/extension"
extPluginID = "acme.demo"
extID = "acme.demo.gadgets"
extFields = "models/gadget/fields.yaml"
extList = "controllers/gadgets/config_list.yaml"
extForm = "controllers/gadgets/config_form.yaml"
extStats = "controllers/gadgets/_stats.htm"
extSummary = "controllers/gadgets/_summary.htm"
extJS = "assets/js/lookup.js"
extCSS = "assets/css/gadgets.css"
)
// extFS copies the fixture tree into a MapFS, so a case can rewrite or drop
// one file without touching the others.
func extFS(t *testing.T) fstest.MapFS {
t.Helper()
root := os.DirFS(extDir)
out := fstest.MapFS{}
err := fs.WalkDir(root, ".", func(name string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
data, err := fs.ReadFile(root, name)
if err != nil {
return err
}
out[name] = &fstest.MapFile{Data: data}
return nil
})
if err != nil {
t.Fatalf("fixture tree: %v", err)
}
return out
}
// edit replaces old with new in one fixture file and fails when old is not
// there, so a case can never silently test the unmodified fixture.
func edit(t *testing.T, fsys fstest.MapFS, file, old, new string) {
t.Helper()
src := string(fsys[file].Data)
if !strings.Contains(src, old) {
t.Fatalf("%s does not contain %q", file, old)
}
fsys[file] = &fstest.MapFile{Data: []byte(strings.Replace(src, old, new, 1))}
}
type extGadget struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Active bool `gorm:"column:active"`
GroupID *uint `gorm:"column:group_id"`
CollectionID *uint `gorm:"column:collection_id"`
}
type extGroup struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
}
func extRun(message string) func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
return func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
return pact.AdminActionResult{Message: message}, nil
}
}
// extActions are the fixture's registered actions: the lookup widget action
// and the recount toolbar action, both gated by acme.demo.run.
func extActions() []pact.AdminAction {
return []pact.AdminAction{
{Name: "lookup", Label: "acme.demo::lang.gadgets.lookup", Permissions: []string{"acme.demo.run"}, Run: extRun("acme.demo::lang.gadgets.looked_up")},
{Name: "recount", Label: "acme.demo::lang.gadgets.recount", Permissions: []string{"acme.demo.run"}, Run: extRun("acme.demo::lang.gadgets.recounted")},
}
}
// extBase is a gadgets controller with registered actions but neither client
// assets nor partial data.
type extBase struct {
id string
actions []pact.AdminAction
// formless drops the group relation contract, for a controller that
// ships no config_form.yaml.
formless bool
}
func (c extBase) ID() string {
if c.id == "" {
return extID
}
return c.id
}
func (extBase) ModelName() string { return "Gadget" }
func (extBase) ConfigDir() string { return "controllers/gadgets" }
func (extBase) RequiredPermissions() []string { return []string{"acme.demo.access"} }
func (extBase) NewRecord() any { return &extGadget{} }
func (c extBase) AdminActions() []pact.AdminAction { return c.actions }
func (c extBase) AdminFieldRelations() []FieldRelationContract {
if c.formless {
return nil
}
return []FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &extGroup{} }, ForeignKey: "group_id"}}
}
// extAssets adds pact.AdminClientAssets.
type extAssets struct {
extBase
js, css []string
}
func (c extAssets) AdminJS() []string { return c.js }
func (c extAssets) AdminCSS() []string { return c.css }
// extController is the complete fixture controller: actions, assets and
// partial view models.
type extController struct{ extAssets }
func (extController) PartialData(_ context.Context, name string, record any) (any, error) {
return extPartialView(name, record)
}
// extNoAssets has partial data but declares no client assets.
type extNoAssets struct{ extBase }
func (extNoAssets) PartialData(_ context.Context, name string, record any) (any, error) {
return extPartialView(name, record)
}
type extStatItem struct {
Label string
Count int
}
func extPartialView(name string, record any) (any, error) {
switch name {
case "stats":
return struct{ Items []extStatItem }{Items: []extStatItem{{Label: "acme.demo::lang.gadgets.total", Count: 3}}}, nil
case "summary":
view := struct{ Name string }{}
if gadget, ok := record.(*extGadget); ok && gadget != nil {
view.Name = gadget.Name
}
return view, nil
}
return nil, errors.New("unknown partial " + name)
}
func newExtController() extController {
return extController{extAssets{extBase: extBase{actions: extActions()}, js: []string{extJS}, css: []string{extCSS}}}
}
// extPlugin serves the fixture tree and the fixture permissions.
type extPlugin struct {
id string
fsys fs.FS
perms []pact.Permission
}
func (p extPlugin) ID() string {
if p.id == "" {
return extPluginID
}
return p.id
}
func (extPlugin) Requires() []string { return nil }
func (extPlugin) Register(*backpack.App) error { return nil }
func (extPlugin) Boot(*backpack.App) error { return nil }
func (p extPlugin) AdminFS() fs.FS { return p.fsys }
func (p extPlugin) Permissions() []pact.Permission {
if p.perms != nil {
return p.perms
}
return []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}, {Code: "acme.demo.run", Roles: []string{"developer"}}}
}
var _ party.Plugin = extPlugin{}
func compileExt(pluginID string, ctl pact.AdminController, fsys fs.FS) (*Registry, error) {
return compileRegistry([]controllerRef{{plugin: extPlugin{id: pluginID, fsys: fsys}, ctl: ctl}})
}
func mustCompileExt(t *testing.T, ctl pact.AdminController, fsys fs.FS) (*Registry, *CompiledController) {
t.Helper()
reg, err := compileExt(extPluginID, ctl, fsys)
if err != nil {
t.Fatalf("compile fixture: %v", err)
}
cc, ok := reg.Get(ctl.ID())
if !ok {
t.Fatalf("controller %s not compiled", ctl.ID())
}
return reg, cc
}
// extTranslator activates phrasebook with the framework strings and the
// fixture's en and pl catalog.
func extTranslator(t *testing.T) (*backpack.App, *phrasebook.Translator) {
t.Helper()
app := backpack.New(nil)
lang := fstest.MapFS{}
for name, file := range extFS(t) {
if strings.HasPrefix(name, "lang/") {
lang[name] = file
}
}
if err := phrasebook.Activate(app, []langPlugin{{id: extPluginID, lang: lang}}); err != nil {
t.Fatal(err)
}
tr, ok := app.Lookup[*phrasebook.Translator]()
if !ok || tr == nil {
t.Fatal("translator missing")
}
return app, tr
}
// bootCase is one fixture variant that must fail boot with every want
// substring in the error.
type bootCase struct {
name string
pluginID string
ctl pact.AdminController
mutate func(t *testing.T, fsys fstest.MapFS)
want []string
}
func runBootCases(t *testing.T, cases []bootCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fsys := extFS(t)
if tc.mutate != nil {
tc.mutate(t, fsys)
}
pluginID := tc.pluginID
if pluginID == "" {
pluginID = extPluginID
}
ctl := tc.ctl
if ctl == nil {
ctl = newExtController()
}
_, err := compileExt(pluginID, ctl, fsys)
if err == nil {
t.Fatal("expected a boot error")
}
for _, want := range tc.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error %q is missing %q", err, want)
}
}
})
}
}
// TestPhase101FormExtensionSchema covers `type: widget` (D-06, D-07, D-13):
// the valid fixture compiles its widget with the action label and fill keys,
// and every rule that guards a widget stops boot naming the plugin, the
// controller and, for YAML rules, the file.
func TestPhase101FormExtensionSchema(t *testing.T) {
t.Run("valid widget compiles", func(t *testing.T) {
reg, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
field, ok := widgetField(cc, "lookup")
if !ok {
t.Fatal("lookup widget not compiled")
}
if field.Widget != "acme-demo-lookup" || field.Action != "lookup" || field.ActionLabel != "acme.demo::lang.gadgets.lookup" ||
!reflect.DeepEqual(field.Fill, []string{"name", "active"}) {
t.Fatalf("widget field = %+v", field)
}
if _, ok := widgetField(cc, "name"); ok {
t.Fatal("a text field was reported as a widget")
}
if len(cc.Actions) != 2 || cc.Actions["lookup"].Run == nil || cc.Actions["recount"].Run == nil {
t.Fatalf("actions = %v", cc.Actions)
}
if len(cc.scripts) != 1 || len(cc.styles) != 1 || len(reg.assets) != 2 {
t.Fatalf("scripts=%d styles=%d assets=%d", len(cc.scripts), len(cc.styles), len(reg.assets))
}
if !isRegisteredWidget(cc.Form, "lookup") {
t.Fatalf("form fields = %+v", cc.Form.Fields)
}
})
withActions := func(actions ...pact.AdminAction) pact.AdminController {
ctl := newExtController()
ctl.actions = actions
return ctl
}
lookup := extActions()[0]
named := func(name string) pact.AdminAction {
action := lookup
action.Name = name
return action
}
fieldsFile := []string{extPluginID, extID, extFields}
cases := []bootCase{
{name: "widget key on a text field", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " type: text\n", " type: text\n widget: acme-demo-name\n")
}, want: append(fieldsFile, "widget is only valid on type: widget")},
{name: "fill key on a switch", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " type: switch\n", " type: switch\n fill: [name]\n")
}, want: append(fieldsFile, "fill is only valid on type: widget")},
{name: "widget without a tag", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " widget: acme-demo-lookup\n", "")
}, want: append(fieldsFile, "widget (the custom-element tag) is required")},
{name: "widget without an action", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " action: lookup\n", "")
}, want: append(fieldsFile, "is not an identifier")},
{name: "tag without a hyphen", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "widget: acme-demo-lookup", "widget: acmedemolookup")
}, want: append(fieldsFile, "not a valid custom-element name")},
{name: "tag with uppercase", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "widget: acme-demo-lookup", "widget: acme-demo-Lookup")
}, want: append(fieldsFile, "not a valid custom-element name")},
{name: "another plugin's prefix", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "widget: acme-demo-lookup", "widget: acme-other-lookup")
}, want: append(fieldsFile, `must start with the plugin prefix "acme-demo-"`)},
{name: "reserved element name", pluginID: "font.face", ctl: extController{extAssets{extBase: extBase{id: "font.face.gadgets", actions: extActions()}, js: []string{extJS}}},
mutate: func(t *testing.T, fsys fstest.MapFS) {
for name, file := range fsys {
fsys[name] = &fstest.MapFile{Data: []byte(strings.ReplaceAll(string(file.Data), "~/plugins/acme/demo/", "~/plugins/font/face/"))}
}
edit(t, fsys, extFields, "widget: acme-demo-lookup", "widget: font-face-src")
}, want: []string{"font.face", "font.face.gadgets", extFields, `"font-face-src" is a reserved element name`}},
{name: "unregistered action", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "action: lookup", "action: missing")
}, want: append(fieldsFile, "action missing is not registered")},
{name: "registered action named create", ctl: withActions(extActions()[0], extActions()[1], named("create")),
want: []string{extPluginID, extID, "action create uses a reserved built-in name"}},
{name: "registered action named delete", ctl: withActions(extActions()[0], extActions()[1], named("delete")),
want: []string{extPluginID, extID, "action delete uses a reserved built-in name"}},
{name: "action without Run", ctl: withActions(pact.AdminAction{Name: "lookup", Label: "Look up"}, extActions()[1]),
want: []string{extPluginID, extID, "action lookup has no Run function"}},
{name: "duplicate action", ctl: withActions(extActions()[0], extActions()[1], extActions()[0]),
want: []string{extPluginID, extID, "duplicate action lookup"}},
{name: "action name not an identifier", ctl: withActions(extActions()[0], extActions()[1], named("re-count")),
want: []string{extPluginID, extID, `action name "re-count" is not an identifier`}},
{name: "fill key that is not a field", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "fill: [name, active]", "fill: [name, missing]")
}, want: append(fieldsFile, "fill missing is not a field of this form")},
{name: "protected fill key", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "fields:\n", "fields:\n collection_id:\n label: Collection\n type: text\n")
edit(t, fsys, extFields, "fill: [name, active]", "fill: [name, collection_id]")
}, want: append(fieldsFile, "fill collection_id is not a writable scalar field")},
{name: "relation fill key", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "fill: [name, active]", "fill: [name, group]")
}, want: append(fieldsFile, "fill group is not a writable scalar field")},
{name: "repeated fill key", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "fill: [name, active]", "fill: [name, name]")
}, want: append(fieldsFile, "fill: duplicate field name")},
{name: "widget without controller JS", ctl: extNoAssets{extBase{actions: extActions()}},
want: append(fieldsFile, "a widget needs the controller to declare its JS")},
{name: "unknown key on a widget", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " action: lookup\n", " action: lookup\n onLoad: boot\n")
}, want: append(fieldsFile, "onLoad")},
}
runBootCases(t, cases)
t.Run("widget and partial are refused on a settings form", func(t *testing.T) {
for typ, field := range map[string]string{
"widget": " type: widget\n widget: acme-demo-lookup\n action: lookup\n",
"partial": " type: partial\n path: summary\n",
} {
fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n enabled:\n type: switch\n extra:\n" + field)}}
item := pact.SettingsItem{Code: "demo", Label: "Demo", Form: "models/settings/fields.yaml", Model: "DemoSettings", NewModel: func() any { return &extSettings{} }}
_, err := compileSetting(extPluginID, item, fsys)
if err == nil || !strings.Contains(err.Error(), "setting demo field extra: type "+typ+" is not supported on a settings form") {
t.Fatalf("%s: err = %v", typ, err)
}
}
})
t.Run("unknown action permission fails compileContributions", func(t *testing.T) {
ctl := newExtController()
ctl.actions = extActions()
ctl.actions[1].Permissions = []string{"acme.demo.nope"}
reg, err := compileExt(extPluginID, ctl, os.DirFS(extDir))
if err != nil {
t.Fatal(err)
}
err = compileContributions(reg, []party.Plugin{extPlugin{fsys: os.DirFS(extDir)}})
if err == nil || !strings.Contains(err.Error(), "action "+extID+".recount references unknown permission acme.demo.nope") {
t.Fatalf("err = %v", err)
}
reg, _ = compileExt(extPluginID, newExtController(), os.DirFS(extDir))
if err := compileContributions(reg, []party.Plugin{extPlugin{fsys: os.DirFS(extDir)}}); err != nil {
t.Fatalf("valid permissions: %v", err)
}
})
t.Run("action labels: phrase keys must resolve, literals pass", func(t *testing.T) {
_, tr := extTranslator(t)
check := func(label string) error {
ctl := newExtController()
ctl.actions = extActions()
ctl.actions[1].Label = label
reg, err := compileExt(extPluginID, ctl, os.DirFS(extDir))
if err != nil {
t.Fatal(err)
}
return validateMessageKeys(reg, tr)
}
if err := check("acme.demo::lang.gadgets.recount"); err != nil {
t.Fatalf("resolving key: %v", err)
}
if err := check("Recount everything"); err != nil {
t.Fatalf("literal label: %v", err)
}
err := check("acme.demo::lang.gadgets.missing")
if err == nil || !strings.Contains(err.Error(), "action recount label names missing phrase key acme.demo::lang.gadgets.missing") {
t.Fatalf("missing key: %v", err)
}
})
}
func isRegisteredWidget(form *FormSchema, name string) bool {
if form == nil {
return false
}
for _, field := range form.Fields {
if field.Name == name {
return field.Type == "widget"
}
}
return false
}
type extSettings struct {
ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"`
}
func (extSettings) Fillable() []string { return []string{"enabled"} }
func (extSettings) Rules() map[string]string { return map[string]string{} }
// TestPhase101PartialSchema covers config_list.yaml headerPartial and
// `type: partial` (D-09, D-11): both compile from the fixture, and every
// template or path problem stops boot with the partial-name hint.
func TestPhase101PartialSchema(t *testing.T) {
t.Run("header and form partials compile", func(t *testing.T) {
_, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
if cc.List.HeaderPartial != "stats" {
t.Fatalf("headerPartial = %q", cc.List.HeaderPartial)
}
if len(cc.partials) != 2 || cc.partials["stats"] == nil || cc.partials["summary"] == nil {
t.Fatalf("partials = %v", cc.partials)
}
if !cc.formPartials["summary"] || cc.formPartials["stats"] {
t.Fatalf("form partials = %v", cc.formPartials)
}
var summary FormField
for _, field := range cc.Form.Fields {
if field.Name == "summary" {
summary = field
}
}
if summary.Type != "partial" || summary.Path != "summary" {
t.Fatalf("summary field = %+v", summary)
}
})
listFile := []string{extPluginID, extID, extList}
fieldsFile := []string{extPluginID, extID, extFields}
cases := []bootCase{
{name: "headerPartial not an identifier", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extList, "headerPartial: stats", "headerPartial: stats-strip")
}, want: append(listFile, `headerPartial "stats-strip"`, partialPathHint)},
{name: "headerPartial as a Winter path", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extList, "headerPartial: stats", "headerPartial: $/acme/demo/controllers/gadgets/_stats.htm")
}, want: append(listFile, partialPathHint)},
{name: "header template missing", mutate: func(t *testing.T, fsys fstest.MapFS) {
delete(fsys, extStats)
}, want: []string{extPluginID, extID, extStats, "partial stats: template is not in the plugin's embedded files"}},
{name: "form template missing", mutate: func(t *testing.T, fsys fstest.MapFS) {
delete(fsys, extSummary)
}, want: []string{extPluginID, extID, extSummary, "partial summary: template is not in the plugin's embedded files"}},
{name: "template parse error", mutate: func(t *testing.T, fsys fstest.MapFS) {
fsys[extStats] = &fstest.MapFile{Data: []byte("<dl>{{ range .Data.Items }}</dl>\n")}
}, want: []string{extPluginID, extID, extStats, "partial stats:"}},
{name: "template calls an unknown function", mutate: func(t *testing.T, fsys fstest.MapFS) {
fsys[extSummary] = &fstest.MapFile{Data: []byte("<p>{{ raw .Data.Name }}</p>\n")}
}, want: []string{extPluginID, extID, extSummary, `function "raw" not defined`}},
{name: "controller without AdminPartialData", ctl: extAssets{extBase: extBase{actions: extActions()}, js: []string{extJS}},
want: []string{extPluginID, extID, extStats, "partial stats needs the controller to implement pact.AdminPartialData"}},
{name: "partial without path", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " path: summary\n", "")
}, want: append(fieldsFile, "type partial needs a path", partialPathHint)},
{name: "partial path with $/", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "path: summary", "path: $/acme/demo/controllers/gadgets/_summary.htm")
}, want: append(fieldsFile, partialPathHint)},
{name: "partial path with ~/", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "path: summary", "path: ~/plugins/acme/demo/controllers/gadgets/_summary.htm")
}, want: append(fieldsFile, partialPathHint)},
{name: "partial path with a directory", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, "path: summary", "path: gadgets/summary")
}, want: append(fieldsFile, `partial "gadgets/summary"`, partialPathHint)},
{name: "path on another type", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extFields, " type: text\n", " type: text\n path: summary\n")
}, want: append(fieldsFile, "path is only valid on type: partial")},
}
runBootCases(t, cases)
}
// TestPhase101Toolbar covers registered toolbar actions (D-12) and the
// assumption-delta invariant: every toolbar.buttons name resolves to exactly
// one built-in or registered action.
func TestPhase101Toolbar(t *testing.T) {
t.Run("registered names compile in declared order", func(t *testing.T) {
fsys := extFS(t)
edit(t, fsys, extList, "buttons: [create, delete, recount]", "buttons: [recount, create, delete]")
_, cc := mustCompileExt(t, newExtController(), fsys)
if got := strings.Join(cc.List.ToolbarButtons, ","); got != "recount,create,delete" {
t.Fatalf("toolbarButtons = %s", got)
}
if len(cc.List.ToolbarActions) != 1 || cc.List.ToolbarActions[0] != (ToolbarAction{Name: "recount", Label: "acme.demo::lang.gadgets.recount"}) {
t.Fatalf("toolbarActions = %+v", cc.List.ToolbarActions)
}
})
t.Run("invariant: each name is exactly one built-in or registered action", func(t *testing.T) {
_, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
if len(cc.List.ToolbarButtons) != 3 {
t.Fatalf("toolbarButtons = %v", cc.List.ToolbarButtons)
}
for _, name := range cc.List.ToolbarButtons {
matches := 0
if builtinToolbarActions[name] {
matches++
}
if _, ok := cc.Actions[name]; ok {
matches++
}
if matches != 1 {
t.Fatalf("toolbar name %s resolves to %d actions", name, matches)
}
if _, ok := toolbarActionOf(cc, name); ok == builtinToolbarActions[name] {
t.Fatalf("toolbarActionOf(%s) = %v, built-in %v", name, ok, builtinToolbarActions[name])
}
}
})
cases := []bootCase{
{name: "unknown name", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extList, "buttons: [create, delete, recount]", "buttons: [create, delete, launch]")
}, want: []string{extPluginID, extID, extList, "unsupported action launch"}},
{name: "Winter partial name", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extList, "buttons: [create, delete, recount]", "buttons: list_toolbar")
}, want: []string{extPluginID, extID, extList, "not supported"}},
{name: "delete without showCheckboxes", mutate: func(t *testing.T, fsys fstest.MapFS) {
edit(t, fsys, extList, "showCheckboxes: true\n", "")
}, want: []string{extPluginID, extID, extList, "delete needs showCheckboxes: true"}},
{name: "toolbar action without a label", ctl: func() pact.AdminController {
ctl := newExtController()
ctl.actions = extActions()
ctl.actions[1].Label = " "
return ctl
}(), want: []string{extPluginID, extID, extList, "action recount needs a label"}},
{name: "registered action named create", ctl: func() pact.AdminController {
ctl := newExtController()
ctl.actions = append(extActions(), pact.AdminAction{Name: "create", Label: "Create", Run: extRun("")})
return ctl
}(), want: []string{extPluginID, extID, "reserved built-in name"}},
{name: "registered action named delete", ctl: func() pact.AdminController {
ctl := newExtController()
ctl.actions = append(extActions(), pact.AdminAction{Name: "delete", Label: "Delete", Run: extRun("")})
return ctl
}(), want: []string{extPluginID, extID, "reserved built-in name"}},
}
runBootCases(t, cases)
t.Run("create is dropped without a form, custom names stay", func(t *testing.T) {
fsys := extFS(t)
delete(fsys, extForm)
ctl := newExtController()
ctl.formless = true
_, cc := mustCompileExt(t, ctl, fsys)
if cc.Form != nil {
t.Fatal("form compiled without config_form.yaml")
}
if got := strings.Join(cc.List.ToolbarButtons, ","); got != "delete,recount" {
t.Fatalf("toolbarButtons = %s", got)
}
if len(cc.List.ToolbarActions) != 1 || cc.List.ToolbarActions[0].Name != "recount" {
t.Fatalf("toolbarActions = %+v", cc.List.ToolbarActions)
}
})
t.Run("labels localize per request and follow the principal's permissions", func(t *testing.T) {
app, _ := extTranslator(t)
fsys := extFS(t)
edit(t, fsys, extList, "buttons: [create, delete, recount]", "buttons: [create, delete, recount, archive]")
ctl := newExtController()
ctl.actions = append(extActions(), pact.AdminAction{Name: "archive", Label: "Archive", Permissions: []string{"acme.demo.archive"}, Run: extRun("")})
reg, _ := mustCompileExt(t, ctl, fsys)
svc := &service{app: app, reg: reg}
read := func(principal *bouncer.Principal, locale string) []ToolbarAction {
t.Helper()
req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/gadgets/schema/list"), nil)
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "gadgets")
req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), principal), locale))
rec := httptest.NewRecorder()
svc.listSchema(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("list schema status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data ListSchema `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if got := strings.Join(body.Data.ToolbarButtons, ","); got != "create,delete,recount,archive" {
t.Fatalf("toolbarButtons = %s", got)
}
return body.Data.ToolbarActions
}
runner := &bouncer.Principal{ID: 7, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true, "acme.demo.run": true}}
if got := read(runner, "en"); len(got) != 1 || got[0] != (ToolbarAction{Name: "recount", Label: "Recount"}) {
t.Fatalf("en actions = %+v", got)
}
if got := read(runner, "pl"); len(got) != 1 || got[0] != (ToolbarAction{Name: "recount", Label: "Przelicz"}) {
t.Fatalf("pl actions = %+v", got)
}
viewer := &bouncer.Principal{ID: 8, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
if got := read(viewer, "en"); len(got) != 0 {
t.Fatalf("viewer sees actions it may not run: %+v", got)
}
super := &bouncer.Principal{ID: 9, Backend: true, IsSuperuser: true}
if got := read(super, "en"); len(got) != 2 || got[0].Name != "recount" || got[1] != (ToolbarAction{Name: "archive", Label: "Archive"}) {
t.Fatalf("superuser actions = %+v", got)
}
// The compiled schema keeps its source labels; localizing never
// writes back into the cache.
cc, _ := reg.Get(extID)
if cc.List.ToolbarActions[0].Label != "acme.demo::lang.gadgets.recount" {
t.Fatalf("cached label mutated: %+v", cc.List.ToolbarActions)
}
})
}