fix(32): CR-01 check required extension fields on the lifted value
A required: true field in a form extension became a rule on the owner's model, where its value never exists, so every save of the host form failed, also for administrators who cannot see the extension. mergedRules now skips extension fields; liftExtensionValues checks required on the submitted value for visible extensions only (create: must be sent and not blank; update: only when sent).
This commit is contained in:
@@ -533,3 +533,68 @@ func TestFormExtensionSave(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormExtensionRequired (CR-01): a required extension field is checked
|
||||
// on the lifted value, never as a rule on the owner's model. An administrator
|
||||
// who sees the extension must send it on create and may not blank it; one who
|
||||
// does not see it saves the host form freely.
|
||||
func TestFormExtensionRequired(t *testing.T) {
|
||||
store := &feStore{}
|
||||
extra := feContributor{
|
||||
id: "acme.extra",
|
||||
fsys: feExtraFS("fields:\n c:\n type: text\n required: true\n after: a\n"),
|
||||
extensions: []pact.AdminActionExtension{{
|
||||
Controller: "acme.blog.posts",
|
||||
Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Permissions: []string{"acme.extra.manage"}, Load: store.load, Save: store.save},
|
||||
}},
|
||||
}
|
||||
env := newFEEnv(t, feOwner{fields: feOwnerFields}, extra)
|
||||
editor := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.blog.access": true}}
|
||||
create := func(principal *bouncer.Principal, body map[string]any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return feCall(t, env.svc, principal, "acme.blog.posts", http.MethodPost, "record", "", body)
|
||||
}
|
||||
|
||||
for name, body := range map[string]map[string]any{
|
||||
"missing": {"a": "A"},
|
||||
"blank": {"a": "A", "c": " "},
|
||||
"null": {"a": "A", "c": nil},
|
||||
} {
|
||||
rec := create(superUser(), body)
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The c field is required.") {
|
||||
t.Fatalf("%s: status=%d body=%s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
rec := create(superUser(), map[string]any{"a": "A", "c": "C"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create with c: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
created := pgFTDecode(t, rec)
|
||||
idText := ""
|
||||
switch v := created.Data["id"].(type) {
|
||||
case float64:
|
||||
idText = strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case string:
|
||||
idText = v
|
||||
}
|
||||
if idText == "" {
|
||||
t.Fatalf("create data=%v", created.Data)
|
||||
}
|
||||
|
||||
// An update without c keeps the stored value; a blank c is refused.
|
||||
if rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "A2"}); rec.Code != http.StatusOK {
|
||||
t.Fatalf("partial update: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "A3", "c": ""}); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("blank update: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// The extension is hidden from editor: required does not apply to them.
|
||||
rec = create(editor, map[string]any{"a": "E"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("editor create: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := feCall(t, env.svc, editor, "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "E2", "c": ""}); rec.Code != http.StatusOK {
|
||||
t.Fatalf("editor update: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user