From 719ed719b4411bd2e4d53a627ad6f3014b46707c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 29 Sep 2026 10:00:08 +0200 Subject: [PATCH] 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. --- modules/cabana/README.md | 2 +- modules/cabana/actions.go | 11 +++++ modules/cabana/phase101_render_test.go | 68 ++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/modules/cabana/README.md b/modules/cabana/README.md index c466e64..c97b0ef 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -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: ` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: ` in `fields.yaml` render the template `{ConfigDir}/_.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. diff --git a/modules/cabana/actions.go b/modules/cabana/actions.go index c69a227..3511a44 100644 --- a/modules/cabana/actions.go +++ b/modules/cabana/actions.go @@ -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() diff --git a/modules/cabana/phase101_render_test.go b/modules/cabana/phase101_render_test.go index f9c2c1a..93b1127 100644 --- a/modules/cabana/phase101_render_test.go +++ b/modules/cabana/phase101_render_test.go @@ -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") + } + }) + } +}