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("
{{ range .Data.Items }}
\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("

{{ raw .Data.Name }}

\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) } }) }