fix(10.1): CR-01 answer a value that does not fit its column with a 422
lagoon.Fill now returns a *lagoon.FillTypeError naming the key when a requested value cannot be stored in its column (a fraction, exponent or overflow for an integer field, or a value of the wrong type). The admin save path maps it to a validation_failed 422 on that field instead of a 500 CapabilityError; genuine capability failures keep the 500.
This commit is contained in:
@@ -12,7 +12,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
||||
|
||||
- Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages.
|
||||
- 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), 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.
|
||||
- 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 with scalar values. 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.
|
||||
|
||||
@@ -325,6 +325,12 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
}
|
||||
projected := projectOperation(cc, in.Body, op)
|
||||
if err := lagoon.Fill(target, fillAllowed(cc, target, op), projected, false); err != nil {
|
||||
// A value that does not fit its column is the admin's input,
|
||||
// not a missing capability: answer it on the field.
|
||||
var typed *lagoon.FillTypeError
|
||||
if errors.As(err, &typed) {
|
||||
return &ValidationError{Details: fillTypeDetails(typed.Key)}
|
||||
}
|
||||
return &CapabilityError{ControllerID: controllerID(cc)}
|
||||
}
|
||||
if hook, ok := target.(lagoon.HasBeforeValidate); ok && hook != nil {
|
||||
@@ -737,6 +743,12 @@ func valuesForRules(model any, rules map[string]string) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// fillTypeDetails is the 422 detail for a value lagoon.Fill could not store
|
||||
// in its column. Writable fill keys equal their form field names.
|
||||
func fillTypeDetails(key string) map[string]any {
|
||||
return map[string]any{key: []string{"The " + key + " field has an invalid value."}}
|
||||
}
|
||||
|
||||
func validationDetails(msgs map[string][]string) map[string]any {
|
||||
out := make(map[string]any, len(msgs))
|
||||
for key, messages := range msgs {
|
||||
|
||||
@@ -469,3 +469,86 @@ func crudBody(t *testing.T, raw string) map[string]any {
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
type crudYearRow struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Year *int `gorm:"column:year"`
|
||||
}
|
||||
|
||||
func (crudYearRow) TableName() string { return "cabana_crud_year_rows" }
|
||||
func (crudYearRow) Fillable() []string { return []string{"name", "year"} }
|
||||
func (crudYearRow) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||
|
||||
// TestCRUDFillTypeIsValidation covers CR-01: a value lagoon.Fill cannot store
|
||||
// in its column (a fraction, an exponent or an overflow for an integer field,
|
||||
// or a value of the wrong type) is the admin's input, so create and update
|
||||
// answer 422 with a message on that field rather than a 500 capability error.
|
||||
func TestCRUDFillTypeIsValidation(t *testing.T) {
|
||||
_, db := newListService(t)
|
||||
if err := db.Migrator().DropTable(&crudYearRow{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&crudYearRow{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fsys := crudFS()
|
||||
fsys["models/record/fields.yaml"] = &fstest.MapFile{Data: []byte(`fields:
|
||||
name:
|
||||
label: Name
|
||||
type: text
|
||||
required: true
|
||||
year:
|
||||
label: Year
|
||||
type: number
|
||||
`)}
|
||||
reg, err := compileRegistry([]controllerRef{{
|
||||
plugin: formPlugin{fsys: fsys},
|
||||
ctl: crudController{rec: func() any { return &crudYearRow{} }},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("registry: %v", err)
|
||||
}
|
||||
cc, ok := reg.Get("acme.demo.records")
|
||||
if !ok {
|
||||
t.Fatal("compiled controller missing")
|
||||
}
|
||||
svc := CRUDService{DB: db}
|
||||
ctx := context.Background()
|
||||
count := func() int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := db.Model(&crudYearRow{}).Count(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
for _, raw := range []string{`{"name":"Ada","year":1977.5}`, `{"name":"Ada","year":1e21}`, `{"name":"Ada","year":99999999999999999999}`, `{"name":"Ada","year":"1977"}`} {
|
||||
_, err := svc.Create(ctx, cc, RecordInput{Body: crudBody(t, raw)})
|
||||
assertValidation(t, err, "year", "The year field has an invalid value.")
|
||||
if n := count(); n != 0 {
|
||||
t.Fatalf("%s: rows=%d, an invalid year persisted", raw, n)
|
||||
}
|
||||
}
|
||||
_, err = svc.Create(ctx, cc, RecordInput{Body: crudBody(t, `{"name":true}`)})
|
||||
assertValidation(t, err, "name", "The name field has an invalid value.")
|
||||
|
||||
rec, err := svc.Create(ctx, cc, RecordInput{Body: crudBody(t, `{"name":"Ada","year":1977}`)})
|
||||
if err != nil || rec["year"] == nil {
|
||||
t.Fatalf("create=%#v err=%v, want year 1977", rec, err)
|
||||
}
|
||||
var stored crudYearRow
|
||||
if err := db.Where("name = ?", "Ada").Take(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = svc.Update(ctx, cc, stored.ID, RecordInput{Body: crudBody(t, `{"year":1977.5}`)})
|
||||
assertValidation(t, err, "year", "The year field has an invalid value.")
|
||||
var after crudYearRow
|
||||
if err := db.Take(&after, stored.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Year == nil || *after.Year != 1977 {
|
||||
t.Fatalf("year after rejected update = %v, want 1977", after.Year)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user