Files
summercms/modules/cabana/form_extensions_postgres_test.go
Jakub Zych 676ca539aa fix(32): CR-01 check required extension fields on the lifted value
A required: true field in a form extension became a rule on the owner's
model, where its value never exists, so every save of the host form failed,
also for administrators who cannot see the extension. mergedRules now skips
extension fields; liftExtensionValues checks required on the submitted value
for visible extensions only (create: must be sent and not blank; update:
only when sent).
2026-10-27 15:20:33 +01:00

601 lines
23 KiB
Go

package cabana
import (
"bytes"
"context"
"encoding/json"
"errors"
"io/fs"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"gorm.io/gorm"
)
// The D-91 form extension save, show and schema paths on a real Postgres
// database (testcontainers, like newListService): an owner plugin acme.blog
// with the posts controller, and a contributor acme.extra that adds fields
// and stores their values in its own table.
// fePost is the owner's model: columns a and b only.
type fePost struct {
ID uint `gorm:"column:id;primaryKey"`
A string `gorm:"column:a"`
B string `gorm:"column:b"`
}
func (fePost) TableName() string { return "acme_blog_posts" }
func (*fePost) Fillable() []string { return []string{"a", "b"} }
func (*fePost) Rules() map[string]string { return map[string]string{} }
// feExtraValue is the contributor's own table: one value per post.
type feExtraValue struct {
RecordID uint `gorm:"column:record_id;primaryKey;autoIncrement:false"`
Value string `gorm:"column:value"`
}
func (feExtraValue) TableName() string { return "acme_extra_values" }
// fePosts is the owner's admin controller.
type fePosts struct{}
func (fePosts) ID() string { return "acme.blog.posts" }
func (fePosts) ModelName() string { return "Post" }
func (fePosts) ConfigDir() string { return "controllers/posts" }
func (fePosts) NewRecord() any { return &fePost{} }
func (fePosts) RequiredPermissions() []string { return []string{"acme.blog.access"} }
// feOwner is the owner plugin; fields is the body of its fields.yaml.
type feOwner struct {
fields string
}
func (feOwner) ID() string { return "acme.blog" }
func (feOwner) Requires() []string { return nil }
func (feOwner) Register(*backpack.App) error { return nil }
func (feOwner) Boot(*backpack.App) error { return nil }
func (p feOwner) AdminFS() fs.FS { return feOwnerFS(p.fields) }
func (feOwner) AdminControllers() []pact.AdminController {
return []pact.AdminController{fePosts{}}
}
// feOwnerFields is the owner form of the tracer: a and b, no tabs.
const feOwnerFields = "fields:\n a:\n type: text\n label: A\n b:\n type: text\n label: B\n"
func feOwnerFS(fields string) fstest.MapFS {
return fstest.MapFS{
"controllers/posts/config_form.yaml": &fstest.MapFile{Data: []byte("name: Post\nform: ~/plugins/acme/blog/models/post/fields.yaml\nmodelClass: Post\n")},
"controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/blog/models/post/columns.yaml\nmodelClass: Post\nrecordsPerPage: 20\ntoolbar:\n buttons: [create]\n")},
"models/post/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n a:\n label: A\n")},
"models/post/fields.yaml": &fstest.MapFile{Data: []byte(fields)},
}
}
// feContributor is a plugin that extends other plugins' forms.
type feContributor struct {
id string
requires []string
fsys fstest.MapFS
types []pact.FormFieldType
extensions []pact.AdminActionExtension
}
func (p feContributor) ID() string { return p.id }
func (p feContributor) Requires() []string { return p.requires }
func (feContributor) Register(*backpack.App) error { return nil }
func (feContributor) Boot(*backpack.App) error { return nil }
func (p feContributor) AdminFS() fs.FS { return p.fsys }
func (p feContributor) FormFieldTypes() []pact.FormFieldType { return p.types }
func (p feContributor) AdminActionExtensions() []pact.AdminActionExtension {
return p.extensions
}
var (
_ party.Plugin = feContributor{}
_ pact.HasAdminActionExtensions = feContributor{}
)
// feCompile runs the boot path: field types, the registry, then the form
// extensions, as Activate does.
func feCompile(plugins ...party.Plugin) (*Registry, error) {
reg, err := ftCompile(plugins...)
if err != nil {
return nil, err
}
if err := compileFormExtensions(reg, plugins); err != nil {
return nil, err
}
return reg, nil
}
// feStore is the contributor's storage spy: every Save call's input, the
// owner row as Save read it through the save transaction, and failures to
// return.
type feStore struct {
mu sync.Mutex
saves []pact.AdminFormSave
ownerA []string
fail error
notices map[string][]pact.FormFieldNotice
}
func (s *feStore) save(_ context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.saves = append(s.saves, in)
var a string
if err := in.DB.Raw("SELECT a FROM acme_blog_posts WHERE id = ?", in.ID).Scan(&a).Error; err != nil {
return pact.AdminFormSaveResult{}, err
}
s.ownerA = append(s.ownerA, a)
if value, ok := in.Values["c"]; ok {
text, _ := value.(string)
id, _ := strconv.ParseUint(in.ID, 10, 64)
row := feExtraValue{RecordID: uint(id), Value: text}
if err := in.DB.Save(&row).Error; err != nil {
return pact.AdminFormSaveResult{}, err
}
}
if s.fail != nil {
return pact.AdminFormSaveResult{}, s.fail
}
return pact.AdminFormSaveResult{Notices: s.notices}, nil
}
func (s *feStore) load(_ context.Context, in pact.AdminFormLoad) (map[string]any, error) {
var row feExtraValue
err := in.DB.Where("record_id = ?", in.ID).Limit(1).Find(&row).Error
if err != nil {
return nil, err
}
return map[string]any{"c": row.Value, "a": "never served"}, nil
}
// feExtraFS is the contributor's tree with one fields document.
func feExtraFS(doc string) fstest.MapFS {
return fstest.MapFS{"extensions/posts.yaml": &fstest.MapFile{Data: []byte(doc)}}
}
// feCall runs one admin request against a controller of svc.
func feCall(t *testing.T, svc *service, principal *bouncer.Principal, controller, method, route, id string, body any) *httptest.ResponseRecorder {
t.Helper()
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
t.Fatal(err)
}
}
req := httptest.NewRequest(method, "/", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
parts := strings.SplitN(controller, ".", 3)
req.SetPathValue("vendor", parts[0])
req.SetPathValue("plugin", parts[1])
req.SetPathValue("controller", parts[2])
if id != "" {
req.SetPathValue("id", id)
}
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
rec := httptest.NewRecorder()
switch route {
case "schema":
svc.formSchema(rec, req)
case "record":
switch method {
case http.MethodPost:
svc.create(rec, req)
case http.MethodGet:
svc.show(rec, req)
case http.MethodPut:
svc.update(rec, req)
default:
t.Fatalf("method %s", method)
}
default:
t.Fatalf("route %s", route)
}
return rec
}
// feSchemaNames is the field name list of a form schema response.
func feSchemaNames(t *testing.T, rec *httptest.ResponseRecorder) []string {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("schema status=%d body=%s", rec.Code, rec.Body.String())
}
var out struct {
Data struct {
Fields []struct {
Name string `json:"name"`
Tab string `json:"tab"`
} `json:"fields"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
names := make([]string, 0, len(out.Data.Fields))
for _, field := range out.Data.Fields {
names = append(names, field.Name)
}
return names
}
// feEnv is a Postgres-backed admin service with the owner and contributor.
type feEnv struct {
db *gorm.DB
svc *service
}
func newFEEnv(t *testing.T, plugins ...party.Plugin) feEnv {
t.Helper()
_, db := newListService(t)
if err := db.Migrator().DropTable(&fePost{}, &feExtraValue{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&fePost{}, &feExtraValue{}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Migrator().DropTable(&fePost{}, &feExtraValue{}) })
reg, err := feCompile(plugins...)
if err != nil {
t.Fatalf("compile: %v", err)
}
app := backpack.New(nil)
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
return feEnv{db: db, svc: &service{app: app, reg: reg}}
}
func (e feEnv) post(t *testing.T, id uint) fePost {
t.Helper()
var out fePost
if err := e.db.First(&out, id).Error; err != nil {
t.Fatal(err)
}
return out
}
func (e feEnv) extra(t *testing.T, id uint) (string, bool) {
t.Helper()
var rows []feExtraValue
if err := e.db.Where("record_id = ?", id).Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) == 0 {
return "", false
}
return rows[0].Value, true
}
// TestFormExtensionTracer (SC-1, D-91): plugin acme.extra adds field c after
// a on acme.blog's form of a and b. The schema lists a, c, b; an update
// stores a and b in the owner row and c in the contributor's table in the
// same transaction; show serves c; the owner model never gains a c column.
func TestFormExtensionTracer(t *testing.T) {
store := &feStore{}
extra := feContributor{
id: "acme.extra",
fsys: feExtraFS("fields:\n c:\n type: text\n label: C\n after: a\n"),
extensions: []pact.AdminActionExtension{{
Controller: "acme.blog.posts",
Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Load: store.load, Save: store.save},
}},
}
env := newFEEnv(t, feOwner{fields: feOwnerFields}, extra)
names := feSchemaNames(t, feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "schema", "", nil))
if strings.Join(names, " ") != "a c b" {
t.Fatalf("schema fields = %v, want [a c b]", names)
}
seed := fePost{A: "A0", B: "B0"}
if err := env.db.Create(&seed).Error; err != nil {
t.Fatal(err)
}
idText := strconv.FormatUint(uint64(seed.ID), 10)
rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "A1", "b": "B1", "c": "C1"})
if rec.Code != http.StatusOK {
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
}
saved := pgFTDecode(t, rec)
if saved.Data["c"] != "C1" || saved.Data["a"] != "A1" {
t.Fatalf("update data=%v, want a=A1 and c=C1 served by Load", saved.Data)
}
if got := env.post(t, seed.ID); got.A != "A1" || got.B != "B1" {
t.Fatalf("owner row=%+v", got)
}
if value, ok := env.extra(t, seed.ID); !ok || value != "C1" {
t.Fatalf("contributor row=%q,%v", value, ok)
}
if len(store.saves) != 1 || store.saves[0].Created || store.saves[0].ID != idText || store.saves[0].Controller != "acme.blog.posts" {
t.Fatalf("saves=%+v", store.saves)
}
if len(store.saves[0].Values) != 1 || store.saves[0].Values["c"] != "C1" {
t.Fatalf("Save values=%v, want only c", store.saves[0].Values)
}
if store.ownerA[0] != "A1" {
t.Fatalf("Save read owner a=%q through its DB, want the uncommitted A1 of the same transaction", store.ownerA[0])
}
rec = feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "record", idText, nil)
if rec.Code != http.StatusOK {
t.Fatalf("show status=%d body=%s", rec.Code, rec.Body.String())
}
if shown := pgFTDecode(t, rec); shown.Data["c"] != "C1" || shown.Data["a"] != "A1" {
t.Fatalf("show data=%v", shown.Data)
}
if env.db.Migrator().HasColumn(&fePost{}, "c") {
t.Fatal("the owner table gained a c column")
}
}
// feMLType is a translatable field type of acme.extra: Decode trims and
// reports the trim per locale.
func feMLType() pact.FormFieldType {
return pact.FormFieldType{
Name: "extraml", Element: "acme-extra-ml", Translatable: true, Scripts: []string{"assets/js/extra.js"},
Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
text, _ := in.Value.(string)
if text == "bad" {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"bad in " + in.Locale}}
}
out := pact.FormFieldDecoded{Value: strings.TrimSpace(text)}
if out.Value != text {
out.Notices = []pact.FormFieldNotice{{Message: "trimmed " + in.Locale}}
}
return out, nil
},
Load: func(_ context.Context, in pact.FormFieldValue) (any, error) {
text, _ := in.Value.(string)
return "served:" + in.Locale + ":" + text, nil
},
}
}
// TestFormExtensionSave (D-91, T-32-01, T-32-02): Save errors roll the whole
// save back, AdminFormErrors answer 422 field lines, notices reach the save
// response, an invisible extension is inert on save, translatable values
// reach Save as locale maps without touching the owner's translations, and a
// hidden owner field leaves show and save.
func TestFormExtensionSave(t *testing.T) {
store := &feStore{}
var mlSaves []map[string]any
extra := feContributor{
id: "acme.extra",
fsys: fstest.MapFS{"extensions/posts.yaml": &fstest.MapFile{Data: []byte("fields:\n c:\n type: text\n after: a\n")}, "assets/js/extra.js": &fstest.MapFile{Data: []byte("// extra\n")}},
types: []pact.FormFieldType{feMLType()},
extensions: []pact.AdminActionExtension{
{Controller: "acme.blog.posts", Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Load: store.load, Save: store.save, Hide: []string{"b"}}},
{Controller: "acme.blog.posts", Form: &pact.AdminFormExtension{
FieldsYAML: []byte("tabs:\n - name: acme.extra::lang.tab\nfields:\n t:\n type: mltext\n tab: acme.extra::lang.tab\n n:\n type: extraml\n tab: acme.extra::lang.tab\n"),
Permissions: []string{"acme.extra.translate"},
Load: func(context.Context, pact.AdminFormLoad) (map[string]any, error) {
return map[string]any{"t": map[string]string{"pl": "TP", "en": "TE"}, "n": map[string]string{"pl": "NP", "en": "NE"}}, nil
},
Save: func(_ context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
mlSaves = append(mlSaves, in.Values)
return pact.AdminFormSaveResult{}, nil
},
}},
},
}
env := newFEEnv(t, feOwner{fields: feOwnerFields}, extra)
writer := ftWriter()
if err := env.svc.app.Publish[TranslationWriter](writer); err != nil {
t.Fatal(err)
}
seed := fePost{A: "A0", B: "B0"}
if err := env.db.Create(&seed).Error; err != nil {
t.Fatal(err)
}
id := strconv.FormatUint(uint64(seed.ID), 10)
put := func(principal *bouncer.Principal, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
return feCall(t, env.svc, principal, "acme.blog.posts", http.MethodPut, "record", id, body)
}
t.Run("AdminFormErrors answer 422 and roll back", func(t *testing.T) {
store.fail = pact.AdminFormErrors{"c": {"The c field is required."}}
defer func() { store.fail = nil }()
rec := put(superUser(), map[string]any{"a": "A1", "c": "C1"})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The c field is required.") {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got := env.post(t, seed.ID); got.A != "A0" {
t.Fatalf("owner row changed: %+v", got)
}
if value, ok := env.extra(t, seed.ID); ok {
t.Fatalf("contributor row kept after rollback: %q", value)
}
})
t.Run("a plain Save error answers 500 and rolls back", func(t *testing.T) {
store.fail = errors.New("disk full")
defer func() { store.fail = nil }()
rec := put(superUser(), map[string]any{"a": "A1", "c": "C1"})
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "disk full") {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got := env.post(t, seed.ID); got.A != "A0" {
t.Fatalf("owner row changed: %+v", got)
}
})
t.Run("notices reach the save response, on owner fields too", func(t *testing.T) {
store.notices = map[string][]pact.FormFieldNotice{"a": {{Message: "address changed"}}}
defer func() { store.notices = nil }()
rec := put(superUser(), map[string]any{"a": "A1", "c": "C1"})
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
notices := pgFTNotices(t, pgFTDecode(t, rec))
if len(notices) != 1 || notices[0].Field != "a" || notices[0].Message != "address changed" {
t.Fatalf("notices = %+v", notices)
}
})
t.Run("a hidden owner field leaves show and save", func(t *testing.T) {
rec := put(superUser(), map[string]any{"a": "A2", "b": "B9", "c": "C2"})
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got := env.post(t, seed.ID); got.A != "A2" || got.B != "B0" {
t.Fatalf("owner row=%+v, want a written and hidden b untouched", got)
}
shown := pgFTDecode(t, feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "record", id, nil))
if _, ok := shown.Data["b"]; ok {
t.Fatalf("show serves hidden b: %v", shown.Data)
}
names := feSchemaNames(t, feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "schema", "", nil))
if feJoin(names) != "a c t n" {
t.Fatalf("schema = %v", names)
}
})
t.Run("translatable values reach Save as locale maps", func(t *testing.T) {
mlSaves = nil
rec := put(superUser(), map[string]any{"a": "A3", "t": map[string]any{"pl": "P", "en": "E"}, "n": map[string]any{"pl": " X ", "en": "Y"}})
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if len(mlSaves) != 1 {
t.Fatalf("saves = %v", mlSaves)
}
tv, _ := mlSaves[0]["t"].(map[string]string)
nv, _ := mlSaves[0]["n"].(map[string]string)
if tv["pl"] != "P" || tv["en"] != "E" || nv["pl"] != "X" || nv["en"] != "Y" {
t.Fatalf("Save values = %#v", mlSaves[0])
}
if len(writer.attrs) != 0 {
t.Fatalf("the owner's translations were written: %v", writer.attrs)
}
body := pgFTDecode(t, rec)
notices := pgFTNotices(t, body)
if len(notices) != 1 || notices[0].Field != "n" || notices[0].Locale != "pl" {
t.Fatalf("notices = %+v", notices)
}
served, _ := body.Data["n"].(map[string]any)
if served["pl"] != "served:pl:NP" || served["en"] != "served:en:NE" {
t.Fatalf("n served = %v, want the type's Load after the extension's", body.Data["n"])
}
refused := put(superUser(), map[string]any{"a": "A3", "n": map[string]any{"pl": "bad"}})
if refused.Code != http.StatusUnprocessableEntity || !strings.Contains(refused.Body.String(), "bad in pl") {
t.Fatalf("refused status=%d body=%s", refused.Code, refused.Body.String())
}
})
t.Run("an invisible extension is inert on save", func(t *testing.T) {
editor := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.blog.access": true}}
mlSaves = nil
rec := put(editor, map[string]any{"a": "A4", "t": map[string]any{"pl": "Z"}, "c": "C4"})
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if len(mlSaves) != 0 {
t.Fatalf("Save of the invisible extension ran: %v", mlSaves)
}
if value, _ := env.extra(t, seed.ID); value != "C4" {
t.Fatalf("the visible extension did not save: %q", value)
}
data := pgFTDecode(t, rec).Data
if _, ok := data["t"]; ok {
t.Fatalf("save response serves the invisible extension's field: %v", data)
}
shown := pgFTDecode(t, feCall(t, env.svc, editor, "acme.blog.posts", http.MethodGet, "record", id, nil))
if _, ok := shown.Data["n"]; ok {
t.Fatalf("show serves the invisible extension's field: %v", shown.Data)
}
if names := feSchemaNames(t, feCall(t, env.svc, editor, "acme.blog.posts", http.MethodGet, "schema", "", nil)); feJoin(names) != "a c" {
t.Fatalf("schema = %v", names)
}
})
t.Run("create hands Save the new record", func(t *testing.T) {
before := len(store.saves)
rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPost, "record", "", map[string]any{"a": "N", "c": "NC"})
if rec.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
last := store.saves[len(store.saves)-1]
if len(store.saves) != before+1 || !last.Created || last.ID == "" || last.ID == id {
t.Fatalf("create Save = %+v", last)
}
})
}
// TestFormExtensionRequired (CR-01): a required extension field is checked
// on the lifted value, never as a rule on the owner's model. An administrator
// who sees the extension must send it on create and may not blank it; one who
// does not see it saves the host form freely.
func TestFormExtensionRequired(t *testing.T) {
store := &feStore{}
extra := feContributor{
id: "acme.extra",
fsys: feExtraFS("fields:\n c:\n type: text\n required: true\n after: a\n"),
extensions: []pact.AdminActionExtension{{
Controller: "acme.blog.posts",
Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Permissions: []string{"acme.extra.manage"}, Load: store.load, Save: store.save},
}},
}
env := newFEEnv(t, feOwner{fields: feOwnerFields}, extra)
editor := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.blog.access": true}}
create := func(principal *bouncer.Principal, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
return feCall(t, env.svc, principal, "acme.blog.posts", http.MethodPost, "record", "", body)
}
for name, body := range map[string]map[string]any{
"missing": {"a": "A"},
"blank": {"a": "A", "c": " "},
"null": {"a": "A", "c": nil},
} {
rec := create(superUser(), body)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The c field is required.") {
t.Fatalf("%s: status=%d body=%s", name, rec.Code, rec.Body.String())
}
}
rec := create(superUser(), map[string]any{"a": "A", "c": "C"})
if rec.Code != http.StatusCreated {
t.Fatalf("create with c: status=%d body=%s", rec.Code, rec.Body.String())
}
created := pgFTDecode(t, rec)
idText := ""
switch v := created.Data["id"].(type) {
case float64:
idText = strconv.FormatFloat(v, 'f', -1, 64)
case string:
idText = v
}
if idText == "" {
t.Fatalf("create data=%v", created.Data)
}
// An update without c keeps the stored value; a blank c is refused.
if rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "A2"}); rec.Code != http.StatusOK {
t.Fatalf("partial update: status=%d body=%s", rec.Code, rec.Body.String())
}
if rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "A3", "c": ""}); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("blank update: status=%d body=%s", rec.Code, rec.Body.String())
}
// The extension is hidden from editor: required does not apply to them.
rec = create(editor, map[string]any{"a": "E"})
if rec.Code != http.StatusCreated {
t.Fatalf("editor create: status=%d body=%s", rec.Code, rec.Body.String())
}
if rec := feCall(t, env.svc, editor, "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "E2", "c": ""}); rec.Code != http.StatusOK {
t.Fatalf("editor update: status=%d body=%s", rec.Code, rec.Body.String())
}
}