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:
Jakub Zych
2026-10-07 18:22:01 +02:00
parent b040ee3a74
commit 4a9b0896b1
15 changed files with 338 additions and 14 deletions

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