package cabana import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "testing/fstest" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "gorm.io/gorm" ) const p10FormConfig = `name: records form: ~/plugins/acme/demo/models/record/fields.yaml modelClass: Record ` const p10ListConfig = `modelClass: Record list: ~/plugins/acme/demo/models/record/columns.yaml recordsPerPage: 20 ` const p10Columns = `columns: name: label: Name searchable: true ` const p10Fields = `fields: name: label: Name type: text required: true group: label: Group type: relation nameFrom: title emptyOption: None tags: label: Tags type: relation nameFrom: label person: label: Person type: relation nameFrom: username ` type p10Record struct { ID uint `gorm:"column:id;primaryKey"` Name string `gorm:"column:name"` GroupID *uint `gorm:"column:group_id"` UserID uint `gorm:"column:user_id"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` } func (p10Record) TableName() string { return "cabana_p10_records" } func (p10Record) Fillable() []string { return []string{"name"} } func (p10Record) Rules() map[string]string { return map[string]string{"name": "required"} } func (p10Group) TableName() string { return "cabana_p10_groups" } func (p10Tag) TableName() string { return "cabana_p10_tags" } func (p10RecordTag) TableName() string { return "cabana_p10_record_tags" } func (p10Person) TableName() string { return "cabana_p10_people" } func (p10Controller) ID() string { return "acme.demo.records" } func (p10Controller) ModelName() string { return "Record" } func (p10Controller) ConfigDir() string { return "controllers/records" } func (p10Controller) NewRecord() any { return &p10Record{} } func (c p10Controller) RequiredPermissions() []string { return c.perms } type p10Group struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` Scope string `gorm:"column:scope"` } type p10Tag struct { ID uint `gorm:"column:id;primaryKey"` Label *string `gorm:"column:label"` Scope string `gorm:"column:scope"` } type p10RecordTag struct { RecordID uint `gorm:"column:record_id;primaryKey"` TagID uint `gorm:"column:tag_id;primaryKey"` Position int `gorm:"column:position"` } type p10Person struct { ID uint `gorm:"column:id;primaryKey"` Email string `gorm:"column:email"` } // p10Controller serves the acme fixtures. Options are scoped to rows whose // scope is "visible"; the person relation writes the protected user_id and is // therefore read-only (D-26). type p10Controller struct { perms []string mutate func([]FieldRelationContract) []FieldRelationContract } func (c p10Controller) AdminFieldRelations() []FieldRelationContract { out := []FieldRelationContract{ {Field: "group", Kind: "belongsTo", NewRelated: func() any { return &p10Group{} }, ForeignKey: "group_id"}, {Field: "tags", Kind: "belongsToMany", NewRelated: func() any { return &p10Tag{} }, NewPivot: func() any { return &p10RecordTag{} }, ParentForeignKey: "record_id", RelatedForeignKey: "tag_id", OrderColumn: "position"}, {Field: "person", Kind: "belongsTo", NewRelated: func() any { return &p10Person{} }, ForeignKey: "user_id", LabelColumn: "email"}, } if c.mutate != nil { out = c.mutate(out) } return out } func (p10Controller) RelationExtendOptionsQuery(_ context.Context, field string, db *gorm.DB) *gorm.DB { switch field { case "group", "tags": return db.Where("scope = ?", "visible") default: return db.Where("1 = 0") } } func (p10Controller) FormBeforeCreate(ctx context.Context, model any) error { record, ok := model.(*p10Record) if !ok { return fmt.Errorf("unexpected model %T", model) } principal, _ := bouncer.User(ctx) if principal != nil { record.UserID = principal.ID } return nil } func p10FS() fstest.MapFS { return fstest.MapFS{ "controllers/records/config_list.yaml": &fstest.MapFile{Data: []byte(p10ListConfig)}, "controllers/records/config_form.yaml": &fstest.MapFile{Data: []byte(p10FormConfig)}, "models/record/columns.yaml": &fstest.MapFile{Data: []byte(p10Columns)}, "models/record/fields.yaml": &fstest.MapFile{Data: []byte(p10Fields)}, } } func p10Compile(ctl p10Controller) (*Registry, error) { return compileRegistry([]controllerRef{{plugin: formPlugin{fsys: p10FS()}, ctl: ctl}}) } type p10Seed struct { groups map[string]uint tags map[string]uint person uint } func p10Fixture(t *testing.T) (*service, *gorm.DB, p10Seed) { t.Helper() _, db := newListService(t) models := []any{&p10Record{}, &p10Group{}, &p10Tag{}, &p10RecordTag{}, &p10Person{}} if err := db.Migrator().DropTable(models...); err != nil { t.Fatal(err) } if err := db.AutoMigrate(models...); err != nil { t.Fatal(err) } reg, err := p10Compile(p10Controller{perms: []string{"acme.demo.access"}}) if err != nil { t.Fatalf("registry: %v", err) } app := backpack.New(nil) if err := app.Publish(db); err != nil { t.Fatal(err) } seed := p10Seed{groups: map[string]uint{}, tags: map[string]uint{}} for _, g := range []p10Group{{Title: "Alpha", Scope: "visible"}, {Title: "Beta", Scope: "visible"}, {Title: "Hidden", Scope: "hidden"}} { if err := db.Create(&g).Error; err != nil { t.Fatal(err) } seed.groups[g.Title] = g.ID } for _, spec := range []struct{ label, scope string }{{"one", "visible"}, {"two", "visible"}, {"three", "visible"}, {"hidden", "hidden"}} { label := spec.label tag := p10Tag{Label: &label, Scope: spec.scope} if err := db.Create(&tag).Error; err != nil { t.Fatal(err) } seed.tags[spec.label] = tag.ID } person := p10Person{ID: 1, Email: "admin@acme.test"} if err := db.Create(&person).Error; err != nil { t.Fatal(err) } seed.person = person.ID return &service{app: app, reg: reg}, db, seed } func p10Principal(granted bool) *bouncer.Principal { p := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{}} if granted { p.PermissionGrants["acme.demo.access"] = true } return p } func p10Request(method, id, field, query string, body any, principal *bouncer.Principal) *http.Request { var reader *bytes.Reader if body != nil { raw, _ := json.Marshal(body) reader = bytes.NewReader(raw) } else { reader = bytes.NewReader(nil) } req := httptest.NewRequest(method, "/", reader) req.URL.RawQuery = query req.SetPathValue("vendor", "acme") req.SetPathValue("plugin", "demo") req.SetPathValue("controller", "records") if id != "" { req.SetPathValue("id", id) } if field != "" { req.SetPathValue("field", field) } if principal != nil { req = req.WithContext(bouncer.WithUser(req.Context(), principal)) } return req } func p10Save(svc *service, method, id string, body any) *httptest.ResponseRecorder { rec := httptest.NewRecorder() req := p10Request(method, id, "", "", body, p10Principal(true)) switch method { case http.MethodPost: svc.create(rec, req) case http.MethodPut: svc.update(rec, req) case http.MethodGet: svc.show(rec, req) } return rec } func p10Options(svc *service, field, query string, principal *bouncer.Principal) *httptest.ResponseRecorder { rec := httptest.NewRecorder() svc.fieldOptions(rec, p10Request(http.MethodGet, "", field, query, nil, principal)) return rec } type p10Envelope struct { Data map[string]any `json:"data"` Meta struct { Labels map[string][]RelationOption `json:"labels"` } `json:"meta"` } func p10Decode(t *testing.T, rec *httptest.ResponseRecorder, status int) p10Envelope { t.Helper() if rec.Code != status { t.Fatalf("status=%d want %d body=%s", rec.Code, status, rec.Body.String()) } var body p10Envelope dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes())) dec.UseNumber() if err := dec.Decode(&body); err != nil { t.Fatalf("decode %s: %v", rec.Body.String(), err) } if body.Meta.Labels == nil { t.Fatalf("meta.labels missing: %s", rec.Body.String()) } return body } func p10ID(v any) uint { n, ok := v.(json.Number) if !ok { return 0 } i, err := n.Int64() if err != nil || i < 0 { return 0 } return uint(i) } func p10IDs(v any) []uint { items, ok := v.([]any) if !ok { return nil } out := make([]uint, 0, len(items)) for _, item := range items { out = append(out, p10ID(item)) } return out } func p10Pivot(t *testing.T, db *gorm.DB, recordID uint) []uint { t.Helper() var rows []p10RecordTag if err := db.Where("record_id = ?", recordID).Order("position, tag_id").Find(&rows).Error; err != nil { t.Fatal(err) } out := make([]uint, len(rows)) for i, row := range rows { if row.Position != i { t.Fatalf("pivot positions=%+v", rows) } out[i] = row.TagID } return out } func p10Stored(t *testing.T, db *gorm.DB, id uint) p10Record { t.Helper() var row p10Record if err := db.First(&row, id).Error; err != nil { t.Fatal(err) } return row } func sameUintSeq(got, want []uint) bool { if len(got) != len(want) { return false } for i := range got { if got[i] != want[i] { return false } } return true } func TestPhase10RelationOptions(t *testing.T) { svc, db, seed := p10Fixture(t) extra := []struct{ label, scope string }{ {"alpha first", "visible"}, {"ALPHA second", "visible"}, {"alpha hidden", "hidden"}, {"100%_off", "visible"}, {"100 off", "visible"}, {"same", "visible"}, {"same", "visible"}, } ids := map[string][]uint{} for _, spec := range extra { label := spec.label tag := p10Tag{Label: &label, Scope: spec.scope} if err := db.Create(&tag).Error; err != nil { t.Fatal(err) } ids[spec.label] = append(ids[spec.label], tag.ID) } for i := 0; i < 25; i++ { label := fmt.Sprintf("bulk %02d", i) if err := db.Create(&p10Tag{Label: &label, Scope: "visible"}).Error; err != nil { t.Fatal(err) } } if err := db.Create(&p10Tag{Scope: "visible"}).Error; err != nil { t.Fatal(err) } visible := int64(3 + 6 + 25 + 1) decode := func(rec *httptest.ResponseRecorder) ([]RelationOption, ListMeta) { t.Helper() if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } var body struct { Data []RelationOption `json:"data"` Meta ListMeta `json:"meta"` } dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes())) dec.DisallowUnknownFields() if err := dec.Decode(&body); err != nil { t.Fatalf("decode %s: %v", rec.Body.String(), err) } return body.Data, body.Meta } all := p10Options(svc, "tags", "", p10Principal(true)) rows, meta := decode(all) if meta.Page != 1 || meta.PerPage != 20 || meta.Total != visible || meta.LastPage != 2 || len(rows) != 20 { t.Fatalf("default page meta=%+v rows=%d", meta, len(rows)) } if !strings.Contains(all.Body.String(), `"value":`) || strings.Contains(all.Body.String(), `"value":"`) { t.Fatalf("option values are not numbers: %s", all.Body.String()) } // Label order follows the database collation; the "bulk NN" labels sort // the same under every collation. bulk, _ := decode(p10Options(svc, "tags", "search=bulk&per_page=100", p10Principal(true))) if len(bulk) != 25 { t.Fatalf("bulk rows=%d", len(bulk)) } for i, row := range bulk { if row.Label != fmt.Sprintf("bulk %02d", i) { t.Fatalf("options not ordered by label: %+v", bulk) } } if rows[0].Label != "" { t.Fatalf("a null label must sort first as an empty string: %+v", rows[0]) } for _, row := range rows { if strings.Contains(row.Label, "hidden") { t.Fatalf("scoped options leaked a hidden row: %+v", rows) } } page2, meta2 := decode(p10Options(svc, "tags", "page=2", p10Principal(true))) if meta2.Page != 2 || len(page2) != int(visible)-20 { t.Fatalf("page 2 meta=%+v rows=%d", meta2, len(page2)) } big, bigMeta := decode(p10Options(svc, "tags", "per_page=100", p10Principal(true))) if bigMeta.PerPage != 100 || len(big) != int(visible) || bigMeta.LastPage != 1 { t.Fatalf("per_page=100 meta=%+v rows=%d", bigMeta, len(big)) } caseless, _ := decode(p10Options(svc, "tags", "search=Alpha", p10Principal(true))) if len(caseless) != 2 || caseless[0].Value != ids["ALPHA second"][0] && caseless[0].Value != ids["alpha first"][0] { t.Fatalf("case-insensitive search=%+v", caseless) } literal, _ := decode(p10Options(svc, "tags", "search=%25_", p10Principal(true))) if len(literal) != 1 || literal[0].Label != "100%_off" { t.Fatalf("LIKE metacharacters were not escaped: %+v", literal) } same, _ := decode(p10Options(svc, "tags", "search=same", p10Principal(true))) if len(same) != 2 || same[0].Value != ids["same"][0] || same[1].Value != ids["same"][1] { t.Fatalf("equal labels not ordered by id: %+v want %v", same, ids["same"]) } groups, _ := decode(p10Options(svc, "group", "", p10Principal(true))) if len(groups) != 2 || groups[0].Value != seed.groups["Alpha"] || groups[0].Label != "Alpha" || groups[1].Label != "Beta" { t.Fatalf("group options=%+v", groups) } for _, tc := range []struct{ query, field string }{ {"per_page=101", "per_page"}, {"per_page=0", "per_page"}, {"page=0", "page"}, {"page=x", "page"}, } { rec := p10Options(svc, "tags", tc.query, p10Principal(true)) if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+tc.field+`"`) { t.Fatalf("%s status=%d body=%s", tc.query, rec.Code, rec.Body.String()) } } for _, field := range []string{"name", "person", "nope", "Tags"} { rec := p10Options(svc, field, "", p10Principal(true)) if rec.Code != http.StatusNotFound { t.Fatalf("field %s status=%d body=%s", field, rec.Code, rec.Body.String()) } assertErrorCode(t, rec.Body.Bytes(), "not_found") if strings.Contains(rec.Body.String(), "admin@acme.test") { t.Fatalf("read-only options disclosed a label: %s", rec.Body.String()) } } // Permission is checked before any SQL: this service has no database, so // reaching a query would be a 500, not a 403. denied := &service{reg: svc.reg} rec := p10Options(denied, "tags", "search=one", p10Principal(false)) if rec.Code != http.StatusForbidden { t.Fatalf("denied status=%d body=%s", rec.Code, rec.Body.String()) } assertErrorCode(t, rec.Body.Bytes(), "forbidden") frontend := p10Principal(true) frontend.Backend = false rec = httptest.NewRecorder() req := p10Request(http.MethodGet, "", "tags", "", nil, nil) req = req.WithContext(bouncer.WithUser(req.Context(), frontend)) denied.fieldOptions(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("frontend principal status=%d body=%s", rec.Code, rec.Body.String()) } } func TestPhase10RelationSave(t *testing.T) { svc, db, seed := p10Fixture(t) one, two, three := seed.tags["one"], seed.tags["two"], seed.tags["three"] alpha, beta := seed.groups["Alpha"], seed.groups["Beta"] created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{ "name": "record", "group": alpha, "tags": []uint{three, one}, "person": 99, "user_id": 99, }), http.StatusCreated) id := p10ID(created.Data["id"]) if p10ID(created.Data["group"]) != alpha || !sameUintSeq(p10IDs(created.Data["tags"]), []uint{three, one}) { t.Fatalf("created data=%v", created.Data) } if p10ID(created.Data["person"]) != seed.person { t.Fatalf("read-only person value=%v want %d", created.Data["person"], seed.person) } if _, leaked := created.Data["user_id"]; leaked { t.Fatalf("protected foreign key leaked: %v", created.Data) } labels := created.Meta.Labels if len(labels["group"]) != 1 || labels["group"][0] != (RelationOption{Value: alpha, Label: "Alpha"}) { t.Fatalf("group labels=%+v", labels["group"]) } if len(labels["tags"]) != 2 || labels["tags"][0] != (RelationOption{Value: three, Label: "three"}) || labels["tags"][1].Value != one { t.Fatalf("tag labels=%+v", labels["tags"]) } if len(labels["person"]) != 1 || labels["person"][0] != (RelationOption{Value: seed.person, Label: "admin@acme.test"}) { t.Fatalf("person labels=%+v", labels["person"]) } stored := p10Stored(t, db, id) if stored.GroupID == nil || *stored.GroupID != alpha || stored.UserID != seed.person { t.Fatalf("stored=%+v", stored) } if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{three, one}) { t.Fatalf("pivot=%v", got) } idText := fmt.Sprintf("%d", id) p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed"}), http.StatusOK) stored = p10Stored(t, db, id) if stored.Name != "renamed" || stored.GroupID == nil || *stored.GroupID != alpha { t.Fatalf("absent keys changed the relation: %+v", stored) } if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{three, one}) { t.Fatalf("absent key changed the pivot: %v", got) } cleared := p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "group": nil, "tags": []uint{two, one}}), http.StatusOK) if cleared.Data["group"] != nil || len(cleared.Meta.Labels["group"]) != 0 || cleared.Meta.Labels["group"] == nil { t.Fatalf("cleared group data=%v labels=%v", cleared.Data["group"], cleared.Meta.Labels) } if stored := p10Stored(t, db, id); stored.GroupID != nil { t.Fatalf("null did not clear group_id: %+v", stored) } if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{two, one}) { t.Fatalf("replaced pivot=%v", got) } p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "group": beta}), http.StatusOK) shown := p10Decode(t, p10Save(svc, http.MethodGet, idText, nil), http.StatusOK) if p10ID(shown.Data["group"]) != beta || !sameUintSeq(p10IDs(shown.Data["tags"]), []uint{two, one}) { t.Fatalf("show data=%v", shown.Data) } if len(shown.Meta.Labels["tags"]) != 2 || shown.Meta.Labels["tags"][0].Label != "two" || shown.Meta.Labels["group"][0].Label != "Beta" { t.Fatalf("show labels=%+v", shown.Meta.Labels) } emptied := p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "tags": []uint{}}), http.StatusOK) if tags, ok := emptied.Data["tags"].([]any); !ok || len(tags) != 0 { t.Fatalf("emptied tags=%#v", emptied.Data["tags"]) } if got := p10Pivot(t, db, id); len(got) != 0 { t.Fatalf("empty list left pivot rows %v", got) } } func TestPhase10RelationForgedID(t *testing.T) { svc, db, seed := p10Fixture(t) one := seed.tags["one"] created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{"name": "keep", "group": seed.groups["Alpha"], "tags": []uint{one}}), http.StatusCreated) id := p10ID(created.Data["id"]) idText := fmt.Sprintf("%d", id) for _, tc := range []struct { name string body map[string]any field string }{ {"out of scope tag", map[string]any{"tags": []uint{seed.tags["hidden"]}}, "tags"}, {"unknown tag", map[string]any{"tags": []uint{999999}}, "tags"}, {"text tag", map[string]any{"tags": []any{"x"}}, "tags"}, {"fractional tag", map[string]any{"tags": []any{1.5}}, "tags"}, {"negative tag", map[string]any{"tags": []any{-1}}, "tags"}, {"duplicate tag", map[string]any{"tags": []uint{one, one}}, "tags"}, {"scalar for many", map[string]any{"tags": one}, "tags"}, {"out of scope group", map[string]any{"group": seed.groups["Hidden"]}, "group"}, {"list for one", map[string]any{"group": []uint{seed.groups["Beta"]}}, "group"}, {"text group", map[string]any{"group": "abc"}, "group"}, } { t.Run(tc.name, func(t *testing.T) { body := map[string]any{"name": "changed"} for key, value := range tc.body { body[key] = value } rec := p10Save(svc, http.MethodPut, idText, body) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } assertErrorCode(t, rec.Body.Bytes(), "validation_failed") var envelope struct { Error struct { Details map[string][]string `json:"details"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil { t.Fatal(err) } if len(envelope.Error.Details[tc.field]) == 0 { t.Fatalf("details missing %s: %s", tc.field, rec.Body.String()) } stored := p10Stored(t, db, id) if stored.Name != "keep" || stored.GroupID == nil || *stored.GroupID != seed.groups["Alpha"] { t.Fatalf("rejected save committed: %+v", stored) } if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{one}) { t.Fatalf("rejected save changed the pivot: %v", got) } }) } var before int64 if err := db.Model(&p10Record{}).Count(&before).Error; err != nil { t.Fatal(err) } rec := p10Save(svc, http.MethodPost, "", map[string]any{"name": "forged", "tags": []uint{one, seed.tags["hidden"]}}) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("forged create status=%d body=%s", rec.Code, rec.Body.String()) } var after int64 if err := db.Model(&p10Record{}).Count(&after).Error; err != nil { t.Fatal(err) } var pivots int64 if err := db.Model(&p10RecordTag{}).Count(&pivots).Error; err != nil { t.Fatal(err) } if after != before || pivots != 1 { t.Fatalf("forged create committed rows=%d->%d pivots=%d", before, after, pivots) } } func TestPhase10RelationBoot(t *testing.T) { reg, err := p10Compile(p10Controller{}) if err != nil { t.Fatalf("valid contracts: %v", err) } cc, _ := reg.Get("acme.demo.records") raw, err := json.Marshal(cc.Form.Fields) if err != nil { t.Fatal(err) } fields := map[string]map[string]any{} var list []map[string]any if err := json.Unmarshal(raw, &list); err != nil { t.Fatal(err) } for _, field := range list { fields[field["name"].(string)] = field } if fields["tags"]["multiple"] != true || fields["tags"]["readOnly"] != nil { t.Fatalf("tags field=%v", fields["tags"]) } if fields["group"]["multiple"] != nil || fields["group"]["readOnly"] != nil { t.Fatalf("group field=%v", fields["group"]) } if fields["person"]["readOnly"] != true || fields["person"]["multiple"] != nil { t.Fatalf("person field=%v", fields["person"]) } for _, tc := range []struct { name string mutate func([]FieldRelationContract) []FieldRelationContract want []string }{ {"missing contract", func(in []FieldRelationContract) []FieldRelationContract { return in[1:] }, []string{"field group", "no relation contract"}}, {"missing foreign key column", func(in []FieldRelationContract) []FieldRelationContract { in[0].ForeignKey = "missing_id" return in }, []string{"field group", "missing_id"}}, {"missing pivot column", func(in []FieldRelationContract) []FieldRelationContract { in[1].OrderColumn = "rank" return in }, []string{"field tags", "rank"}}, {"missing label column", func(in []FieldRelationContract) []FieldRelationContract { in[2].LabelColumn = "username" return in }, []string{"field person", "username"}}, {"unknown kind", func(in []FieldRelationContract) []FieldRelationContract { in[0].Kind = "hasMany" return in }, []string{"field group", "unknown relation kind hasMany"}}, {"missing related model", func(in []FieldRelationContract) []FieldRelationContract { in[0].NewRelated = nil return in }, []string{"field group", "related model"}}, {"duplicate contract", func(in []FieldRelationContract) []FieldRelationContract { return append(in, in[0]) }, []string{"field group", "duplicate"}}, {"orphan contract", func(in []FieldRelationContract) []FieldRelationContract { return append(in, FieldRelationContract{Field: "extra", Kind: "belongsTo", NewRelated: func() any { return &p10Group{} }, ForeignKey: "group_id"}) }, []string{"field extra", "not a relation field"}}, } { t.Run(tc.name, func(t *testing.T) { _, err := p10Compile(p10Controller{mutate: tc.mutate}) if err == nil { t.Fatal("activation accepted a broken relation contract") } for _, want := range append([]string{"acme.demo", "acme.demo.records"}, tc.want...) { if !strings.Contains(err.Error(), want) { t.Fatalf("err=%v missing %q", err, want) } } }) } _, err = compileRegistry([]controllerRef{{plugin: formPlugin{fsys: p10FS()}, ctl: crudControllerLike{}}}) if err == nil || !strings.Contains(err.Error(), "field group") || !strings.Contains(err.Error(), "AdminFieldRelations") { t.Fatalf("controller without contracts err=%v", err) } } // crudControllerLike has the relation form but declares no contracts. type crudControllerLike struct{} func (crudControllerLike) ID() string { return "acme.demo.records" } func (crudControllerLike) ModelName() string { return "Record" } func (crudControllerLike) ConfigDir() string { return "controllers/records" } func (crudControllerLike) NewRecord() any { return &p10Record{} } func TestPhase10NestedGetDispatch(t *testing.T) { svc, _, seed := p10Fixture(t) call := func(id, segment, name string) *httptest.ResponseRecorder { req := p10Request(http.MethodGet, id, "", "", nil, p10Principal(true)) req.SetPathValue("segment", segment) req.SetPathValue("name", name) rec := httptest.NewRecorder() svc.nestedGet(rec, req) return rec } options := call("fields", "group", "options") if options.Code != http.StatusOK || !strings.Contains(options.Body.String(), fmt.Sprintf(`"value":%d`, seed.groups["Alpha"])) { t.Fatalf("fields dispatch status=%d body=%s", options.Code, options.Body.String()) } for _, tc := range [][3]string{{"fields", "group", "choices"}, {"5", "other", "x"}, {"filters", "group", "list"}} { rec := call(tc[0], tc[1], tc[2]) if rec.Code != http.StatusNotFound { t.Fatalf("%v status=%d body=%s", tc, rec.Code, rec.Body.String()) } assertErrorCode(t, rec.Body.Bytes(), "not_found") } }