package cabana import ( "context" "encoding/json" "errors" "math" "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 showCheckboxes: true toolbar: buttons: [create, delete] ` 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 } 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) } } // TestWriteJSONEncodeFailure covers WR-04: a body that cannot be encoded is // a 500 with the generic error envelope, not a 200 with a truncated body. func TestWriteJSONEncodeFailure(t *testing.T) { rec := httptest.NewRecorder() WriteData(rec, http.StatusOK, map[string]any{"price": math.Inf(1)}, nil) if rec.Code != http.StatusInternalServerError { t.Fatalf("status=%d body=%q, want 500", 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.Fatalf("body %q: %v", rec.Body.String(), err) } if body.Error.Code != "error" || body.Error.Message != msgServerError || body.Error.Details == nil { t.Fatalf("envelope=%s", rec.Body.String()) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Fatalf("content type %q", ct) } ok := httptest.NewRecorder() WriteData(ok, http.StatusCreated, map[string]any{"n": 1}, nil) if ok.Code != http.StatusCreated || ok.Body.String() != "{\"data\":{\"n\":1},\"meta\":{}}\n" { t.Fatalf("status=%d body=%q", ok.Code, ok.Body.String()) } } // TestIsJSONScalar covers WR-04: scalar-ness is decided by the encoding. func TestIsJSONScalar(t *testing.T) { name := "x" var nilPtr *string for _, v := range []any{nil, "s", true, 1, uint8(2), 1.5, json.Number("3"), &name, nilPtr} { if !isJSONScalar(v) { t.Fatalf("%#v refused", v) } } for _, v := range []any{math.NaN(), math.Inf(-1), []string{"a"}, map[string]any{}, struct{}{}, scalarAsArray("a"), func() {}} { if isJSONScalar(v) { t.Fatalf("%#v accepted", v) } } } 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) } }