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