From 1e14da7bb5e2856e5fdfec32a186b04f92877c86 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 24 Sep 2026 19:25:18 +0200 Subject: [PATCH] test(09-05): add failing tests for writable fill and validate - Create and update must Fill then Validate and return D-10 422 field errors - Schema bindings exclude protected, cased, nested, and unknown keys - Missing Fill or Validate capability fails closed with controller context --- cabana/contracts.go | 8 + cabana/crud.go | 71 +++++++++ cabana/crud_test.go | 373 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 452 insertions(+) create mode 100644 cabana/crud.go create mode 100644 cabana/crud_test.go diff --git a/cabana/contracts.go b/cabana/contracts.go index edd6c9c..945a33e 100644 --- a/cabana/contracts.go +++ b/cabana/contracts.go @@ -56,12 +56,20 @@ func (BackendUser) TableName() string { return "backend_users" } // Option is a dropdown choice shared with later schema plans. type Option = pact.Option +// WritableField is one schema field bound to a model fill key at activation. +// FillKey comes from the model column, not from request JSON. +type WritableField struct { + Name string + FillKey string +} + // CompiledController is one admin controller after YAML compilation. type CompiledController struct { PluginID string Controller pact.AdminController List *ListSchema Form *FormSchema + Writable []WritableField } // Registry is the immutable controller map keyed by controller ID. diff --git a/cabana/crud.go b/cabana/crud.go new file mode 100644 index 0000000..e9c1163 --- /dev/null +++ b/cabana/crud.go @@ -0,0 +1,71 @@ +package cabana + +import ( + "context" + "errors" + "net/http" + + "gorm.io/gorm" +) + +// CRUDService runs schema-projected record and bulk operations. +type CRUDService struct { + DB *gorm.DB +} + +// RecordInput is a decoded JSON object. Keys are untrusted. +type RecordInput struct { + Body map[string]any +} + +// BulkDeleteInput is the bulk-delete body. +type BulkDeleteInput struct { + IDs []any `json:"ids"` +} + +// BulkResult is the deleted-count payload. +type BulkResult struct { + Deleted int `json:"deleted"` +} + +// ValidationError is a D-10 validation_failed failure. +type ValidationError struct { + Details map[string]any +} + +func (e *ValidationError) Error() string { return "validation_failed" } + +// CapabilityError is a fail-closed Fill/Validate failure with controller context. +type CapabilityError struct { + ControllerID string +} + +func (e *CapabilityError) Error() string { + return "cabana: controller " + e.ControllerID + ": missing Fill/Validate capability" +} + +var errCRUDNotReady = errors.New("cabana: crud is not implemented") + +// ProjectWritableFields copies only activation-bound writable keys. +func ProjectWritableFields(cc *CompiledController, body map[string]any) map[string]any { + return nil +} + +// BindWritableFields records schema field names onto model fill keys. +func BindWritableFields(cc *CompiledController) error { + return errCRUDNotReady +} + +// Create persists a projected record after Fill and Validate. +func (s CRUDService) Create(ctx context.Context, cc *CompiledController, in RecordInput) (map[string]any, error) { + return nil, errCRUDNotReady +} + +// Update persists a projected change after Fill and Validate. +func (s CRUDService) Update(ctx context.Context, cc *CompiledController, id any, in RecordInput) (map[string]any, error) { + return nil, errCRUDNotReady +} + +func writeCRUDError(w http.ResponseWriter, err error) { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) +} diff --git a/cabana/crud_test.go b/cabana/crud_test.go new file mode 100644 index 0000000..a2040e6 --- /dev/null +++ b/cabana/crud_test.go @@ -0,0 +1,373 @@ +package cabana + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "testing/fstest" + + "gorm.io/gorm" +) + +const crudFormConfig = `name: records +form: ~/plugins/acme/demo/models/record/fields.yaml +modelClass: Record +` + +const crudFields = `fields: + name: + label: Name + type: text + required: true + note: + label: Note + type: text + id: + label: ID + type: number + created_at: + label: Created + type: text + scope_id: + label: Scope + type: number + is_system: + label: System + type: switch +` + +const crudListConfig = `modelClass: Record +list: ~/plugins/acme/demo/models/record/columns.yaml +recordsPerPage: 20 +showSearch: true +` + +const crudColumns = `columns: + name: + label: Name + searchable: true +` + +type clearNameKey struct{} + +var crudRuleSet = map[string]string{"name": "required"} + +type crudRow struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Note string `gorm:"column:note"` + ScopeID uint `gorm:"column:scope_id"` + IsSystem bool `gorm:"column:is_system"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (crudRow) TableName() string { return "cabana_crud_rows" } + +func (crudRow) Fillable() []string { return []string{"name", "note"} } + +func (crudRow) Rules() map[string]string { return crudRuleSet } + +func (r *crudRow) BeforeValidate(tx *gorm.DB) error { + ctx := context.Background() + if tx != nil && tx.Statement != nil && tx.Statement.Context != nil { + ctx = tx.Statement.Context + } + if clear, _ := ctx.Value(clearNameKey{}).(bool); clear { + if r.Name == "" { + return errors.New("fill did not run before validate") + } + r.Name = "" + } + return nil +} + +type crudNoRules struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` +} + +func (crudNoRules) TableName() string { return "cabana_crud_rows" } +func (crudNoRules) Fillable() []string { return []string{"name", "note"} } + +type crudNoFill struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` +} + +func (crudNoFill) TableName() string { return "cabana_crud_rows" } +func (crudNoFill) Rules() map[string]string { return map[string]string{"name": "required"} } + +type crudController struct{ rec func() any } + +func (c crudController) ID() string { return "acme.demo.records" } +func (c crudController) ModelName() string { return "Record" } +func (c crudController) ConfigDir() string { return "controllers/records" } +func (c crudController) NewRecord() any { + if c.rec != nil { + return c.rec() + } + return &crudRow{} +} + +func TestCRUDFillValidate(t *testing.T) { + svc, cc, db := crudFixture(t) + ctx := context.Background() + + t.Run("empty name is 422 and does not persist", func(t *testing.T) { + _, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": ""}}) + assertValidation(t, err, "name", "The name field is required.") + if n := countCrud(t, db); n != 0 { + t.Fatalf("rows=%d, validation must not persist", n) + } + }) + + t.Run("yaml required merges when model rules are empty", func(t *testing.T) { + prev := crudRuleSet + crudRuleSet = map[string]string{} + t.Cleanup(func() { crudRuleSet = prev }) + _, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"note": "only"}}) + assertValidation(t, err, "name", "The name field is required.") + if n := countCrud(t, db); n != 0 { + t.Fatalf("rows=%d, yaml required was skipped", n) + } + }) + + t.Run("validate runs after fill", func(t *testing.T) { + clearCtx := context.WithValue(ctx, clearNameKey{}, true) + _, err := svc.Create(clearCtx, cc, RecordInput{Body: map[string]any{"name": "Ada"}}) + assertValidation(t, err, "name", "The name field is required.") + if n := countCrud(t, db); n != 0 { + t.Fatalf("rows=%d, validate ran before BeforeValidate mutated the model", n) + } + }) + + rec, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada", "note": "kept"}}) + if err != nil || rec["name"] != "Ada" || rec["note"] != "kept" { + t.Fatalf("create=%#v err=%v, want filled name and note", rec, err) + } + stored := loadCrud(t, db, "Ada") + if stored.Note != "kept" { + t.Fatalf("stored note=%q, Fill did not persist the writable field", stored.Note) + } + + updated, err := svc.Update(ctx, cc, stored.ID, RecordInput{Body: map[string]any{"name": "Bea"}}) + if err != nil || updated["name"] != "Bea" { + t.Fatalf("update=%#v err=%v", updated, err) + } + if _, err := svc.Update(ctx, cc, stored.ID, RecordInput{Body: map[string]any{"name": ""}}); err == nil { + t.Fatal("blank update name succeeded") + } else { + assertValidation(t, err, "name", "The name field is required.") + } + if got := loadCrud(t, db, "Bea"); got.Note != "kept" || got.Name != "Bea" { + t.Fatalf("row after rejected update = %+v", got) + } +} + +func TestCRUDWritableProjection(t *testing.T) { + cc := crudCompiled(t) + gotKeys := map[string]bool{} + for _, field := range cc.Writable { + gotKeys[field.Name] = true + if field.FillKey == "" || field.FillKey != field.Name { + t.Fatalf("binding %+v is not an explicit model fill key", field) + } + } + for _, banned := range []string{"id", "created_at", "scope_id", "is_system"} { + if gotKeys[banned] { + t.Fatalf("protected field %s was bound writable: %+v", banned, cc.Writable) + } + } + if !gotKeys["name"] || !gotKeys["note"] { + t.Fatalf("writable = %+v, want name and note", cc.Writable) + } + + body := crudBody(t, `{"name":"Ada","Name":"Nope","note":"n","id":9,"ID":8,"created_at":"1999-01-01T00:00:00Z","CreatedAt":"1999-01-01T00:00:00Z","scope_id":4,"is_system":true,"IsSystem":true,"unknown":"x","meta":{"name":"hidden","id":1,"is_system":true}}`) + got := ProjectWritableFields(cc, body) + if len(got) != 2 || got["name"] != "Ada" || got["note"] != "n" { + t.Fatalf("projected = %#v, want only name and note", got) + } +} + +func TestCRUDRejectsProtectedFields(t *testing.T) { + svc, cc, db := crudFixture(t) + body := crudBody(t, `{"name":"Ada","note":"kept","id":424242,"ID":7,"Id":7,"created_at":"1999-01-01T00:00:00Z","CreatedAt":"1999-01-01T00:00:00Z","createdAt":"1999-01-01T00:00:00Z","scope_id":9,"ScopeID":9,"is_system":true,"IsSystem":true,"isSystem":true,"meta":{"id":1,"scope_id":3,"is_system":true,"name":"hidden"},"nested":{"created_at":"1999-01-01T00:00:00Z"}}`) + rec, err := svc.Create(context.Background(), cc, RecordInput{Body: body}) + if err != nil { + t.Fatalf("create err=%v", err) + } + if rec["name"] != "Ada" { + t.Fatalf("create=%#v", rec) + } + stored := loadCrud(t, db, "Ada") + if stored.ID == 0 || stored.ID == 424242 || stored.ID == 7 || stored.ScopeID != 0 || stored.IsSystem || stored.CreatedAt.Year() == 1999 || stored.Note != "kept" { + t.Fatalf("protected fields mutated the row: %+v", stored) + } + if _, err := svc.Update(context.Background(), cc, stored.ID, RecordInput{Body: crudBody(t, `{"note":"still","id":424242,"scope_id":9,"is_system":true,"created_at":"1999-01-01T00:00:00Z","meta":{"is_system":true}}`)}); err != nil { + t.Fatalf("update err=%v", err) + } + stored = loadCrud(t, db, "Ada") + if stored.ID == 424242 || stored.ScopeID != 0 || stored.IsSystem || stored.CreatedAt.Year() == 1999 || stored.Note != "still" || stored.Name != "Ada" { + t.Fatalf("update mutated protected fields: %+v", stored) + } +} + +func TestCRUDCapabilityFailure(t *testing.T) { + svc, cc, db := crudFixture(t) + ctx := context.Background() + + t.Run("missing rules", func(t *testing.T) { + bare := *cc + bare.Controller = crudController{rec: func() any { return &crudNoRules{} }} + _, err := svc.Create(ctx, &bare, RecordInput{Body: map[string]any{"name": "Ada"}}) + assertCapability(t, err, bare.Controller.ID()) + if n := countCrud(t, db); n != 0 { + t.Fatalf("rows=%d, missing Rules persisted", n) + } + }) + + t.Run("missing fillable", func(t *testing.T) { + bare := *cc + bare.Controller = crudController{rec: func() any { return &crudNoFill{} }} + _, err := svc.Create(ctx, &bare, RecordInput{Body: map[string]any{"name": "Ada"}}) + assertCapability(t, err, bare.Controller.ID()) + if n := countCrud(t, db); n != 0 { + t.Fatalf("rows=%d, missing Fillable persisted", n) + } + }) + + t.Run("provider error", func(t *testing.T) { + prev := crudRuleSet + crudRuleSet = map[string]string{"name": "nope"} + t.Cleanup(func() { crudRuleSet = prev }) + _, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "Ada"}}) + assertCapability(t, err, cc.Controller.ID()) + rec := httptest.NewRecorder() + writeCRUDError(rec, err) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "nope") || strings.Contains(rec.Body.String(), "unrecognized") { + t.Fatalf("response leaked provider error: %s", rec.Body.String()) + } + if n := countCrud(t, db); n != 0 { + t.Fatalf("rows=%d, provider error persisted", n) + } + }) +} + +func crudFixture(t *testing.T) (CRUDService, *CompiledController, *gorm.DB) { + t.Helper() + crudRuleSet = map[string]string{"name": "required"} + _, db := newListService(t) + if err := db.Migrator().DropTable(&crudRow{}); err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&crudRow{}); err != nil { + t.Fatal(err) + } + return CRUDService{DB: db}, crudCompiled(t), db +} + +func crudCompiled(t *testing.T) *CompiledController { + t.Helper() + reg, err := compileRegistry([]controllerRef{{ + plugin: formPlugin{fsys: crudFS()}, + ctl: crudController{}, + }}) + if err != nil { + t.Fatalf("registry: %v", err) + } + cc, ok := reg.Get("acme.demo.records") + if !ok || cc.Form == nil { + t.Fatalf("compiled controller missing form: %+v", cc) + } + return cc +} + +func crudFS() fstest.MapFS { + return fstest.MapFS{ + "controllers/records/config_list.yaml": &fstest.MapFile{Data: []byte(crudListConfig)}, + "controllers/records/config_form.yaml": &fstest.MapFile{Data: []byte(crudFormConfig)}, + "models/record/columns.yaml": &fstest.MapFile{Data: []byte(crudColumns)}, + "models/record/fields.yaml": &fstest.MapFile{Data: []byte(crudFields)}, + } +} + +func assertValidation(t *testing.T, err error, field, message string) { + t.Helper() + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err=%v, want validation_failed", err) + } + got, _ := ve.Details[field].([]string) + if len(got) != 1 || got[0] != message { + t.Fatalf("details[%s]=%#v, want %q", field, ve.Details[field], message) + } + rec := httptest.NewRecorder() + writeCRUDError(rec, err) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status=%d body=%s, want 422", rec.Code, rec.Body.String()) + } + var body struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Error.Code != "validation_failed" || body.Error.Message != "Validation failed" { + t.Fatalf("envelope=%s", rec.Body.String()) + } + msgs, _ := body.Error.Details[field].([]any) + if len(msgs) != 1 || msgs[0] != message { + t.Fatalf("http details=%#v", body.Error.Details) + } +} + +func assertCapability(t *testing.T, err error, controllerID string) { + t.Helper() + var ce *CapabilityError + if !errors.As(err, &ce) || ce.ControllerID != controllerID || !strings.Contains(err.Error(), controllerID) { + t.Fatalf("err=%v, want capability failure for %s", err, controllerID) + } +} + +func countCrud(t *testing.T, db *gorm.DB) int64 { + t.Helper() + var n int64 + if err := db.Model(&crudRow{}).Count(&n).Error; err != nil { + t.Fatal(err) + } + return n +} + +func loadCrud(t *testing.T, db *gorm.DB, name string) crudRow { + t.Helper() + var row crudRow + if err := db.Where("name = ?", name).Take(&row).Error; err != nil { + t.Fatalf("load %s: %v", name, err) + } + return row +} + +func crudBody(t *testing.T, raw string) map[string]any { + t.Helper() + dec := json.NewDecoder(strings.NewReader(raw)) + dec.UseNumber() + var body map[string]any + if err := dec.Decode(&body); err != nil { + t.Fatal(err) + } + return body +}