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.
203 lines
7.0 KiB
Go
203 lines
7.0 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|