feat(cabana): settings entries that link to an admin controller
pact.SettingsItem gains an additive Controller field, the equivalent of a WinterCMS registerSettings 'url' => Backend::url(...) entry. A link entry declares no Model, Form or NewModel and needs no AdminFS; start-up fails when it combines Controller with a singleton form or a model, or names an unregistered controller. Settings codes stay one namespace. GET /settings lists a link entry with its controller only when the principal passes the item's permissions and may open the controller. Registry.Setting never returns a link entry, so the singleton settings endpoints answer 404 for its code. SettingsEntry carries controller, empty for singletons; the OpenAPI document, generated SPA types and settings fixture follow, and the pact and cabana READMEs and the settings docs describe the link.
This commit is contained in:
202
modules/cabana/settings_link_test.go
Normal file
202
modules/cabana/settings_link_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// settingsLinkItem is a settings entry that opens an admin controller instead
|
||||
// of a singleton form.
|
||||
func settingsLinkItem() pact.SettingsItem {
|
||||
return pact.SettingsItem{
|
||||
Code: "locales", Label: "Locales", Description: "Manage locales", Category: "Demo",
|
||||
Icon: "languages", Order: 30, Permissions: []string{"acme.demo.manage_settings"},
|
||||
Controller: "acme.demo.widgets",
|
||||
}
|
||||
}
|
||||
|
||||
// settingsLinkPlugin declares only a link entry and ships no admin assets.
|
||||
func settingsLinkPlugin(items ...pact.SettingsItem) contributionPlugin {
|
||||
return contributionPlugin{
|
||||
id: "acme.links",
|
||||
permissions: []pact.Permission{{Code: "acme.links.unused", Roles: []string{"developer"}}},
|
||||
settings: items,
|
||||
}
|
||||
}
|
||||
|
||||
// settingsLinkRegistry compiles the metadata plugin (one singleton setting)
|
||||
// together with a plugin holding one controller link.
|
||||
func settingsLinkRegistry(t *testing.T) *Registry {
|
||||
t.Helper()
|
||||
reg := metadataRegistry()
|
||||
if err := compileContributions(reg, []party.Plugin{metadataPlugin(), settingsLinkPlugin(settingsLinkItem())}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
func TestSettingsLinkCompilesWithoutAdminFS(t *testing.T) {
|
||||
reg := settingsLinkRegistry(t)
|
||||
stored, ok := reg.settings["locales"]
|
||||
if !ok || stored == nil {
|
||||
t.Fatal("link entry was not registered")
|
||||
}
|
||||
if stored.Form != nil || stored.Writable != nil || stored.Item.Controller != "acme.demo.widgets" || stored.PluginID != "acme.links" {
|
||||
t.Fatalf("link entry = %#v", stored)
|
||||
}
|
||||
if _, ok := reg.Setting("locales"); ok {
|
||||
t.Fatal("Registry.Setting returned a link entry")
|
||||
}
|
||||
if setting, ok := reg.Setting("demo"); !ok || setting.Form == nil {
|
||||
t.Fatalf("singleton lookup = %#v %v", setting, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsLinkCompileValidation(t *testing.T) {
|
||||
withForm := settingsLinkItem()
|
||||
withForm.Form = "models/settings/fields.yaml"
|
||||
withFactory := settingsLinkItem()
|
||||
withFactory.NewModel = func() any { return &singletonSetting{} }
|
||||
withModel := settingsLinkItem()
|
||||
withModel.Model = "Settings"
|
||||
missing := settingsLinkItem()
|
||||
missing.Controller = "acme.demo.missing"
|
||||
duplicate := settingsLinkItem()
|
||||
duplicate.Code = "demo"
|
||||
unknownPermission := settingsLinkItem()
|
||||
unknownPermission.Permissions = []string{"acme.demo.nope"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
item pact.SettingsItem
|
||||
want string
|
||||
}{
|
||||
{"form", withForm, "cabana: setting locales links controller acme.demo.widgets and declares a singleton form"},
|
||||
{"model factory", withFactory, "cabana: setting locales links controller acme.demo.widgets and declares a singleton form"},
|
||||
{"model", withModel, "cabana: setting locales links controller acme.demo.widgets and names a model"},
|
||||
{"unknown controller", missing, "cabana: setting locales references unknown controller acme.demo.missing"},
|
||||
{"duplicate code", duplicate, "duplicate setting demo"},
|
||||
{"unknown permission", unknownPermission, "unknown permission"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := compileContributions(metadataRegistry(), []party.Plugin{metadataPlugin(), settingsLinkPlugin(tc.item)})
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsLinkMetadata(t *testing.T) {
|
||||
reg := settingsLinkRegistry(t)
|
||||
developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")}
|
||||
_, settings := reg.Metadata(context.Background(), developer, nil)
|
||||
if len(settings) != 2 {
|
||||
t.Fatalf("developer settings = %#v", settings)
|
||||
}
|
||||
byCode := map[string]SettingsEntry{}
|
||||
for _, entry := range settings {
|
||||
byCode[entry.Code] = entry
|
||||
}
|
||||
link, single := byCode["locales"], byCode["demo"]
|
||||
if link.Controller != "acme.demo.widgets" || link.Model != "" || link.Label != "Locales" || link.Order != 30 {
|
||||
t.Fatalf("link entry = %#v", link)
|
||||
}
|
||||
if link.Keywords == nil {
|
||||
t.Fatal("link entry keywords are nil")
|
||||
}
|
||||
if single.Controller != "" || single.Model != "Settings" {
|
||||
t.Fatalf("singleton entry = %#v", single)
|
||||
}
|
||||
|
||||
// The principal passes the item's permission but cannot open the
|
||||
// controller (it requires acme.demo.access): the link is hidden, the
|
||||
// singleton stays.
|
||||
restricted := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.demo.manage_settings": true}}
|
||||
_, settings = reg.Metadata(context.Background(), restricted, nil)
|
||||
if len(settings) != 1 || settings[0].Code != "demo" {
|
||||
t.Fatalf("restricted settings = %#v", settings)
|
||||
}
|
||||
|
||||
// Controller access alone does not pass the item's own permission.
|
||||
controllerOnly := &bouncer.Principal{ID: 3, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
|
||||
_, settings = reg.Metadata(context.Background(), controllerOnly, nil)
|
||||
if len(settings) != 0 {
|
||||
t.Fatalf("controller-only settings = %#v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsLinkEndpoints(t *testing.T) {
|
||||
reg := settingsLinkRegistry(t)
|
||||
svc := &service{reg: reg}
|
||||
developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")}
|
||||
request := func(method, rel, code string) *http.Request {
|
||||
var body *strings.Reader
|
||||
if method == http.MethodPut {
|
||||
body = strings.NewReader(`{"enabled":true}`)
|
||||
} else {
|
||||
body = strings.NewReader("")
|
||||
}
|
||||
req := httptest.NewRequest(method, adminAPI(rel), body)
|
||||
if code != "" {
|
||||
req.SetPathValue("code", code)
|
||||
}
|
||||
return req.WithContext(bouncer.WithUser(req.Context(), developer))
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
rel string
|
||||
handler func(*service, http.ResponseWriter, *http.Request)
|
||||
}{
|
||||
{"schema", http.MethodGet, "/settings/locales/schema", (*service).settingsSchema},
|
||||
{"get", http.MethodGet, "/settings/locales", (*service).settingsGet},
|
||||
{"put", http.MethodPut, "/settings/locales", (*service).settingsPut},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
tc.handler(svc, rec, request(tc.method, tc.rel, "locales"))
|
||||
if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), `"not_found"`) {
|
||||
t.Fatalf("%s %s = %d %s", tc.method, tc.rel, rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
svc.settingsList(rec, request(http.MethodGet, "/settings", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /settings = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(envelope.Data) != 2 {
|
||||
t.Fatalf("GET /settings data = %#v", envelope.Data)
|
||||
}
|
||||
for _, entry := range envelope.Data {
|
||||
controller, ok := entry["controller"]
|
||||
if !ok {
|
||||
t.Fatalf("entry without controller key: %#v", entry)
|
||||
}
|
||||
want := ""
|
||||
if entry["code"] == "locales" {
|
||||
want = "acme.demo.widgets"
|
||||
}
|
||||
if controller != want {
|
||||
t.Fatalf("entry %v controller = %v, want %q", entry["code"], controller, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user