- FieldRelationContract/FieldRelationProvider bind every type: relation
field to a belongsTo foreign key or a belongsToMany pivot; activation
fails naming plugin, controller and field on a missing or broken contract
- GET /{vendor}/{plugin}/{controller}/fields/{field}/options serves
{value, label} pages scoped by pact.RelationExtendOptionsQuery, behind
the controller permission; read-only and non-relation fields are 404
- Saves apply present relation keys after the Before hook: ids are
revalidated through the same scoped query (422 and full rollback
otherwise), belongsTo sets the foreign key, belongsToMany replaces pivot
rows in submitted order with the order column set to the index
- Show, create and update return relation values in data and meta.labels
- A belongsTo on a protected fill key is read-only (D-26)
- One six-segment GET pattern dispatches relation lists and field options,
which ServeMux cannot register side by side
- Admin OpenAPI documents the options route and RecordEnvelope
733 lines
25 KiB
Go
733 lines
25 KiB
Go
package cabana
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/backpack"
|
|
"git.golem15.com/golem15/summercms/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")
|
|
}
|
|
}
|