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

@@ -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")
}
})
}
}