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
This commit is contained in:
@@ -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.
|
||||
|
||||
71
cabana/crud.go
Normal file
71
cabana/crud.go
Normal file
@@ -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)
|
||||
}
|
||||
373
cabana/crud_test.go
Normal file
373
cabana/crud_test.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user