fix(10.1): WR-06 honour the widget field's context on the action route

widgetAction derives the form from the request (create without record_id,
update with one) and answers 404 when the field's context hides the
widget on that form, reusing contextAllows as the save path does. An
update-only action can no longer run with a nil record through a direct
POST.
This commit is contained in:
Jakub Zych
2026-09-29 10:00:08 +02:00
parent 7b72bf4be4
commit 719ed719b4
3 changed files with 80 additions and 1 deletions

View File

@@ -14,7 +14,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates and for linking and unlinking. Framework code never guesses table, pivot or foreign-key names: the controller supplies them.
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.

View File

@@ -40,6 +40,17 @@ func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
writeCRUDError(w, err)
return
}
// The field's context applies here as on save: without record_id
// the request comes from the create form, with one from the update
// form. A widget its context hides on that form does not exist.
op := "create"
if in.RecordID != nil {
op = "update"
}
if !contextAllows(cc, field.Name, op) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
input := pact.AdminActionInput{Field: field.Name, Values: onlyFillScalars(field.Fill, in.Values)}
if in.RecordID != nil {
db, err := s.db()

View File

@@ -11,9 +11,11 @@ import (
"reflect"
"strings"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
@@ -349,3 +351,69 @@ type vmNode struct {
Label string
Next *vmNode
}
// TestPhase101WidgetContext covers WR-06: the widget route honours the
// field's context like the save path. Without record_id the request is the
// create form's, with one the update form's; a widget its context hides on
// that form answers 404 and its action never runs.
func TestPhase101WidgetContext(t *testing.T) {
const lookup = ` lookup:
label: acme.demo::lang.gadgets.lookup
type: widget
widget: acme-demo-lookup
action: lookup
fill: [name, active]
`
for _, tc := range []struct {
context string
body string
want int
}{
{context: "update", body: `{}`, want: http.StatusNotFound},
{context: "[update, preview]", body: `{"values":{}}`, want: http.StatusNotFound},
{context: "create", body: `{"record_id":1}`, want: http.StatusNotFound},
{context: "create", body: `{}`, want: http.StatusOK},
{context: "", body: `{}`, want: http.StatusOK},
} {
t.Run(tc.context+" "+tc.body, func(t *testing.T) {
fsys := extFS(t)
fields := string(fsys["models/gadget/fields.yaml"].Data)
if !strings.Contains(fields, lookup) {
t.Fatalf("fixture fields.yaml changed:\n%s", fields)
}
if tc.context != "" {
fields = strings.Replace(fields, lookup, lookup+" context: "+tc.context+"\n", 1)
}
fsys["models/gadget/fields.yaml"] = &fstest.MapFile{Data: []byte(fields)}
ran := 0
ctl := newExtController()
ctl.actions = []pact.AdminAction{{
Name: "lookup", Label: "acme.demo::lang.gadgets.lookup",
Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
ran++
return pact.AdminActionResult{}, nil
},
}, extActions()[1]}
app, _ := extTranslator(t)
reg, _ := mustCompileExt(t, ctl, fsys)
svc := &service{app: app, reg: reg}
req := httptest.NewRequest(http.MethodPost, adminAPI("/acme/demo/gadgets/widgets/lookup"), strings.NewReader(tc.body))
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "gadgets")
req.SetPathValue("field", "lookup")
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}))
rec := httptest.NewRecorder()
svc.widgetAction(rec, req)
if rec.Code != tc.want {
t.Fatalf("status=%d body=%s, want %d", rec.Code, rec.Body.String(), tc.want)
}
if wantRan := tc.want == http.StatusOK; (ran == 1) != wantRan {
t.Fatalf("action ran %d times, want ran=%v", ran, wantRan)
}
if tc.want == http.StatusNotFound {
assertErrorCode(t, rec.Body.Bytes(), "not_found")
}
})
}
}