The plugin field-type upload route resolved a form extension's field without checking the extension's Permissions and Visible, so an administrator who cannot see the extension could still run its Upload. The route now answers 404 for them, like the extension's widget actions and partials.
677 lines
26 KiB
Go
677 lines
26 KiB
Go
package cabana
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"mime/multipart"
|
|
"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())
|
|
}
|
|
}
|
|
|
|
// TestFormExtensionUpload (CR-02): the field upload route of an extension's
|
|
// plugin field type answers 404 to an administrator who does not see the
|
|
// extension, and its Upload never runs for them.
|
|
func TestFormExtensionUpload(t *testing.T) {
|
|
var uploads int
|
|
fileType := pact.FormFieldType{
|
|
Name: "extrafile", Element: "acme-extra-file", Scripts: []string{"assets/js/extra.js"},
|
|
Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
|
return pact.FormFieldDecoded{Value: in.Value}, nil
|
|
},
|
|
Upload: func(_ context.Context, in pact.FormFieldUpload) (pact.FormFieldUploadResult, error) {
|
|
uploads++
|
|
return pact.FormFieldUploadResult{URL: "/media/" + in.Filename}, nil
|
|
},
|
|
}
|
|
extra := feContributor{
|
|
id: "acme.extra",
|
|
fsys: fstest.MapFS{
|
|
"extensions/posts.yaml": &fstest.MapFile{Data: []byte("fields:\n img:\n type: extrafile\n after: a\n")},
|
|
"assets/js/extra.js": &fstest.MapFile{Data: []byte("// extra\n")},
|
|
},
|
|
types: []pact.FormFieldType{fileType},
|
|
extensions: []pact.AdminActionExtension{{
|
|
Controller: "acme.blog.posts",
|
|
Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Permissions: []string{"acme.extra.manage"}, Save: feNoopSave},
|
|
}},
|
|
}
|
|
env := newFEEnv(t, feOwner{fields: feOwnerFields}, extra)
|
|
seed := fePost{A: "A0"}
|
|
if err := env.db.Create(&seed).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
upload := func(principal *bouncer.Principal, id string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
part, err := mw.CreateFormFile("file", "a.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := part.Write([]byte("png")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/", &buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
|
for name, value := range map[string]string{"vendor": "acme", "plugin": "blog", "controller": "posts", "id": id, "field": "img"} {
|
|
req.SetPathValue(name, value)
|
|
}
|
|
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
|
rec := httptest.NewRecorder()
|
|
requireAjax(env.svc.fieldUpload)(rec, req)
|
|
return rec
|
|
}
|
|
id := strconv.FormatUint(uint64(seed.ID), 10)
|
|
for _, target := range []string{"0", id} {
|
|
if rec := upload(superUser(), target); rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), "/media/a.png") {
|
|
t.Fatalf("visible upload to %s: status=%d body=%s", target, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
editor := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.blog.access": true}}
|
|
before := uploads
|
|
for _, target := range []string{"0", id} {
|
|
if rec := upload(editor, target); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("invisible upload to %s: status=%d body=%s", target, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
if uploads != before {
|
|
t.Fatalf("Upload ran for an administrator who does not see the extension")
|
|
}
|
|
}
|