From b8084080cb86e3aa9c6eb2285a7d20ae6913748e Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 1 Oct 2026 21:05:33 +0200 Subject: [PATCH] fix(09): WR-04 apply a form field's required flag only in the contexts that can supply it --- modules/cabana/crud.go | 9 ++++++--- modules/cabana/crud_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/modules/cabana/crud.go b/modules/cabana/crud.go index d89bb84..c64f926 100644 --- a/modules/cabana/crud.go +++ b/modules/cabana/crud.go @@ -335,7 +335,7 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i return &CapabilityError{ControllerID: controllerID(cc)} } } - rules := mergedRules(cc, target) + rules := mergedRules(cc, target, op) msgs, err := lagoon.Validate(ctx, tx, target, rules, valuesForRules(target, rules), nil) if err != nil { return &CapabilityError{ControllerID: controllerID(cc)} @@ -689,7 +689,10 @@ func fillAllowed(cc *CompiledController, model any, op string) []string { return out } -func mergedRules(cc *CompiledController, model any) map[string]string { +// mergedRules combines the model's rules with the form's `required` flags. A +// field whose `context` hides it on op cannot be supplied there, so its form +// level `required` does not apply to that operation. +func mergedRules(cc *CompiledController, model any, op string) map[string]string { out := map[string]string{} if rules, ok := model.(hasRules); ok && rules != nil { for key, rule := range rules.Rules() { @@ -702,7 +705,7 @@ func mergedRules(cc *CompiledController, model any) map[string]string { for _, field := range cc.Form.Fields { // Relation fields are not writable columns. required stays on the // schema for the client, but it cannot be checked by Fill. - if field.Required && scalarFormField(field.Type) { + if field.Required && scalarFormField(field.Type) && contextAllows(cc, field.Name, op) { out[field.Name] = mergeRequired(out[field.Name]) } } diff --git a/modules/cabana/crud_test.go b/modules/cabana/crud_test.go index fa7d2a0..80faca2 100644 --- a/modules/cabana/crud_test.go +++ b/modules/cabana/crud_test.go @@ -608,3 +608,32 @@ func TestIsJSONScalar(t *testing.T) { type scalarAsArray string func (s scalarAsArray) MarshalJSON() ([]byte, error) { return json.Marshal([]string{string(s)}) } + +// TestCRUDRequiredFollowsContext pins WR-04: a form field that is `required` +// but limited to the update context cannot be supplied on create, so it must +// not make every create fail; it still binds on update. +func TestCRUDRequiredFollowsContext(t *testing.T) { + svc, cc, db := crudFixture(t) + for i := range cc.Form.Fields { + if cc.Form.Fields[i].Name == "note" { + cc.Form.Fields[i].Required = true + cc.Form.Fields[i].Context = &fieldContext{values: []string{"update"}} + } + } + created, err := svc.CreateRecord(context.Background(), cc, RecordInput{Body: crudBody(t, `{"name":"Ada"}`)}) + if err != nil { + t.Fatalf("create with an update-only required field: %v", err) + } + id := created.Data["id"] + if _, err := svc.UpdateRecord(context.Background(), cc, id, RecordInput{Body: crudBody(t, `{"name":"Bea"}`)}); err == nil { + t.Fatal("update without the required update-context field succeeded") + } else { + assertValidation(t, err, "note", "The note field is required.") + } + if _, err := svc.UpdateRecord(context.Background(), cc, id, RecordInput{Body: crudBody(t, `{"name":"Bea","note":"n"}`)}); err != nil { + t.Fatalf("update with the field: %v", err) + } + if row := loadCrud(t, db, "Bea"); row.Note != "n" { + t.Fatalf("row=%+v", row) + } +}