package cabana import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "sync" "testing" "testing/fstest" "time" "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 := txContext(tx) recordHook(ctx, "before_validate") if clear, _ := ctx.Value(clearNameKey{}).(bool); clear { if r.Name == "" { return errors.New("fill did not run before validate") } r.Name = "" } return nil } func (r *crudRow) BeforeSave(tx *gorm.DB) error { recordHook(txContext(tx), "before_save") return nil } func (r *crudRow) BeforeCreate(tx *gorm.DB) error { recordHook(txContext(tx), "before_create") return nil } func (r *crudRow) AfterCreate(tx *gorm.DB) error { recordHook(txContext(tx), "after_create") return nil } func (r *crudRow) AfterSave(tx *gorm.DB) error { recordHook(txContext(tx), "after_save") return nil } func (r *crudRow) BeforeUpdate(tx *gorm.DB) error { recordHook(txContext(tx), "before_update") return nil } func (r *crudRow) AfterUpdate(tx *gorm.DB) error { recordHook(txContext(tx), "after_update") return nil } func (r *crudRow) BeforeDelete(tx *gorm.DB) error { ctx := txContext(tx) if _, ok := ctx.Value(bulkSlowKey{}).(bool); ok { time.Sleep(300 * time.Millisecond) } recordHook(ctx, "before_delete") appendDeletedID(ctx, r.ID) if fail, _ := ctx.Value(failDeleteKey{}).(bool); fail { return errors.New("secret-hook-boom") } if ids, ok := ctx.Value(failIDsKey{}).(map[uint]bool); ok && ids[r.ID] { return errors.New("secret-hook-boom") } if id, ok := ctx.Value(cancelOnKey{}).(uint); ok && r.ID == id { if cancel, ok := ctx.Value(cancelKey{}).(context.CancelFunc); ok { cancel() } return context.Canceled } return nil } func (r *crudRow) AfterDelete(tx *gorm.DB) error { recordHook(txContext(tx), "after_delete") return nil } type hookSinkKey struct{} type failDeleteKey struct{} type failIDsKey struct{} type idSinkKey struct{} type bulkSlowKey struct{} type cancelOnKey struct{} type cancelKey struct{} var hookMu sync.Mutex func txContext(tx *gorm.DB) context.Context { if tx != nil && tx.Statement != nil && tx.Statement.Context != nil { return tx.Statement.Context } return context.Background() } func recordHook(ctx context.Context, name string) { if ctx == nil { return } hookMu.Lock() defer hookMu.Unlock() sink, _ := ctx.Value(hookSinkKey{}).(*[]string) if sink == nil { return } *sink = append(*sink, name) } func appendDeletedID(ctx context.Context, id uint) { if ctx == nil { return } hookMu.Lock() defer hookMu.Unlock() sink, _ := ctx.Value(idSinkKey{}).(*[]uint) if sink == nil { return } *sink = append(*sink, id) } 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 }