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:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeCRUDError(w, err)
|
writeCRUDError(w, err)
|
||||||
return
|
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)}
|
input := pact.AdminActionInput{Field: field.Name, Values: onlyFillScalars(field.Fill, in.Values)}
|
||||||
if in.RecordID != nil {
|
if in.RecordID != nil {
|
||||||
db, err := s.db()
|
db, err := s.db()
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"git.golem15.com/golem15/summercms/modules/towel"
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
"golang.org/x/net/html/atom"
|
"golang.org/x/net/html/atom"
|
||||||
@@ -349,3 +351,69 @@ type vmNode struct {
|
|||||||
Label string
|
Label string
|
||||||
Next *vmNode
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user