- signpost: activation refusals, host overrides, D-02 pattern layers incl. PatternFrom, ReferenceOptions depths - seaglass: removal report, bluemonday bypass corpus, Check - pact Option.Depth omitempty and the menu item type contracts; party activates menu item types after Register and before the first Boot - cabana: repeater compile and decode, dependsOn compile and recheck, richeditor keys and notices, reorder and singleton boot checks (Docker-free); repeater save, dependent recheck, richeditor save, reorder and singleton routes on Postgres; conformance cases for the documented 404, 409 and 422 answers of the new routes - admin: richeditor value contract, link popover, notices and chunk failure; repeater bounds, remove and reorder, collapsed rows; dependent dropdown states and last request wins; reorder announcements, partition switch and tree rules; SingletonForm spec - gate: required names in the go, admin and postgres stages
436 lines
18 KiB
Go
436 lines
18 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
)
|
|
|
|
// Phase 17 plan 06 tests of the companion form features through the admin
|
|
// routes on Postgres (the acme.companions fixture of companions_smoke_test.go)
|
|
// and their boot refusals through cabana.Activate (Docker-free).
|
|
|
|
// companionsOverlay is the acme.companions fixture tree with files replaced;
|
|
// an empty value removes the file.
|
|
func companionsOverlay(t *testing.T, files map[string]string) fstest.MapFS {
|
|
t.Helper()
|
|
out := fstest.MapFS{}
|
|
err := fs.WalkDir(os.DirFS(companionsDir), ".", func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(companionsDir, path))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out[path] = &fstest.MapFile{Data: raw}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for path, data := range files {
|
|
if data == "" {
|
|
delete(out, path)
|
|
continue
|
|
}
|
|
out[path] = &fstest.MapFile{Data: []byte(data)}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// companionsFile is one file of the fixture tree.
|
|
func companionsFile(t *testing.T, path string) string {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(filepath.Join(companionsDir, path))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
// companionsFSPlugin is the acme.companions fixture served from another
|
|
// tree, optionally with other controllers or settings.
|
|
type companionsFSPlugin struct {
|
|
companionsPlugin
|
|
fsys fs.FS
|
|
controllers []pact.AdminController
|
|
settings []pact.SettingsItem
|
|
}
|
|
|
|
func (p companionsFSPlugin) AdminFS() fs.FS { return p.fsys }
|
|
func (p companionsFSPlugin) AdminControllers() []pact.AdminController {
|
|
if p.controllers != nil {
|
|
return p.controllers
|
|
}
|
|
return p.companionsPlugin.AdminControllers()
|
|
}
|
|
func (p companionsFSPlugin) Settings() []pact.SettingsItem {
|
|
if p.settings != nil {
|
|
return p.settings
|
|
}
|
|
return p.companionsPlugin.Settings()
|
|
}
|
|
|
|
// activateCompanions boots the admin of p without a database.
|
|
func activateCompanions(t *testing.T, p party.Plugin) error {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-companions-boot\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{p})
|
|
return err
|
|
}
|
|
|
|
// companionsLinkRows reads every link row by id.
|
|
func companionsLinkRows(t *testing.T, e *conformEnv) map[uint]companionsLink {
|
|
t.Helper()
|
|
var rows []companionsLink
|
|
if err := e.db.Order("id").Find(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := make(map[uint]companionsLink, len(rows))
|
|
for _, row := range rows {
|
|
out[row.ID] = row
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sameLinkRows fails unless the link table is exactly before.
|
|
func sameLinkRows(t *testing.T, e *conformEnv, before map[uint]companionsLink, what string) {
|
|
t.Helper()
|
|
a, err := json.Marshal(companionsLinkRows(t, e))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := json.Marshal(before)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(a) != string(b) {
|
|
t.Fatalf("%s wrote rows:\nbefore %s\nafter %s", what, b, a)
|
|
}
|
|
}
|
|
|
|
// wantStatus fails unless rec answered status, and returns the body.
|
|
func wantStatus(t *testing.T, what string, rec interface {
|
|
Result() *http.Response
|
|
}, status int, body string) {
|
|
t.Helper()
|
|
if code := rec.Result().StatusCode; code != status {
|
|
t.Fatalf("%s: status=%d want %d body=%s", what, code, status, body)
|
|
}
|
|
}
|
|
|
|
// companionsError decodes the code and details of an error envelope.
|
|
func companionsError(t *testing.T, raw []byte) (string, map[string][]string) {
|
|
t.Helper()
|
|
var body struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Details map[string][]string `json:"details"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
t.Fatalf("error body %s: %v", raw, err)
|
|
}
|
|
return body.Error.Code, body.Error.Details
|
|
}
|
|
|
|
// TestRepeaterSave pins the D-07 save through the links routes: rows keep
|
|
// only their nested keys with scalars coerced, required applies per row
|
|
// with row-path errors, maxItems is enforced, nested richeditors are
|
|
// sanitized with row-path notices, nested dependsOn is rechecked, a refused
|
|
// save writes nothing, and the record response and show hydrate the rows.
|
|
func TestRepeaterSave(t *testing.T) {
|
|
e := newCompanionsEnv(t)
|
|
rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Rows", "rows": []any{
|
|
map[string]any{"title": 5, "body": "<p>clean</p>", "type": "acme-page", "reference": 2, "extra": "dropped"},
|
|
map[string]any{"title": true, "body": `<p onclick="x()">dirty</p><!-- c -->`, "type": "acme-list", "reference": "1"},
|
|
map[string]any{"title": "third"},
|
|
}}, true)
|
|
wantStatus(t, "create", rec, http.StatusCreated, rec.Body.String())
|
|
created := decodeCompanionsRecord(t, rec.Body.Bytes())
|
|
id := dataID(t, rec.Body.Bytes())
|
|
var stored companionsLink
|
|
if err := e.db.First(&stored, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows := stored.Rows.Get()
|
|
want := []map[string]any{
|
|
{"title": "5", "body": "<p>clean</p>", "type": "acme-page", "reference": "2"},
|
|
{"title": "true", "body": "<p>dirty</p>", "type": "acme-list", "reference": nil},
|
|
{"title": "third", "body": nil, "type": nil, "reference": nil},
|
|
}
|
|
if fmt.Sprint(rows) != fmt.Sprint(want) {
|
|
t.Fatalf("stored rows = %v\nwant %v", rows, want)
|
|
}
|
|
var noticed []string
|
|
for _, n := range created.Meta.Notices {
|
|
noticed = append(noticed, n.Field+": "+n.Message)
|
|
}
|
|
if len(noticed) != 2 || !strings.HasPrefix(noticed[0], "rows.1.body: Usunięto 1 atrybut: onclick") || !strings.HasPrefix(noticed[1], "rows.1.body: Usunięto 1 komentarz HTML") {
|
|
t.Fatalf("notices = %v", noticed)
|
|
}
|
|
if list, ok := created.Data["rows"].([]any); !ok || len(list) != 3 {
|
|
t.Fatalf("response rows = %#v", created.Data["rows"])
|
|
}
|
|
|
|
path := fmt.Sprintf("/acme/companions/links/%d", id)
|
|
before := companionsLinkRows(t, e)
|
|
for _, tc := range []struct {
|
|
name string
|
|
rows any
|
|
keys []string
|
|
}{
|
|
{"per-row required", []any{map[string]any{"title": "ok"}, map[string]any{"body": "<p>x</p>"}, map[string]any{"title": " "}}, []string{"rows.1.title", "rows.2.title"}},
|
|
{"nested dependent value", []any{map[string]any{"title": "a", "type": "acme-page", "reference": "99"}}, []string{"rows.0.reference"}},
|
|
{"coercion", []any{map[string]any{"title": map[string]any{"x": 1}}}, []string{"rows.0.title"}},
|
|
{"not a list", "rows", []string{"rows"}},
|
|
{"a row that is not an object", []any{"x"}, []string{"rows"}},
|
|
{"above maxItems", []any{map[string]any{"title": "1"}, map[string]any{"title": "2"}, map[string]any{"title": "3"}, map[string]any{"title": "4"}, map[string]any{"title": "5"}, map[string]any{"title": "6"}}, []string{"rows"}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Rows", "rows": tc.rows}, true)
|
|
wantStatus(t, tc.name, rec, http.StatusUnprocessableEntity, rec.Body.String())
|
|
_, details := companionsError(t, rec.Body.Bytes())
|
|
for _, key := range tc.keys {
|
|
if len(details[key]) != 1 {
|
|
t.Fatalf("details = %v, want %s", details, key)
|
|
}
|
|
}
|
|
if len(details) != len(tc.keys) {
|
|
t.Fatalf("details = %v, want exactly %v", details, tc.keys)
|
|
}
|
|
if tc.name == "above maxItems" && details["rows"][0] != "Usuń pozycje, aby zostało ich najwyżej 5." {
|
|
t.Fatalf("max message = %q", details["rows"][0])
|
|
}
|
|
if tc.name == "nested dependent value" && details["rows.0.reference"][0] != "Wybierz jedną z dostępnych opcji." {
|
|
t.Fatalf("dependent message = %q", details["rows.0.reference"][0])
|
|
}
|
|
sameLinkRows(t, e, before, tc.name)
|
|
})
|
|
}
|
|
|
|
t.Run("an update without rows keeps them, null stores none", func(t *testing.T) {
|
|
rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, true)
|
|
wantStatus(t, "update", rec, http.StatusOK, rec.Body.String())
|
|
var again companionsLink
|
|
if err := e.db.First(&again, id).Error; err != nil || len(again.Rows.Get()) != 3 || again.Title != "Renamed" {
|
|
t.Fatalf("rows after an update without rows = %+v (%v)", again, err)
|
|
}
|
|
rec = e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed", "rows": nil}, true)
|
|
wantStatus(t, "null rows", rec, http.StatusOK, rec.Body.String())
|
|
if err := e.db.First(&again, id).Error; err != nil || len(again.Rows.Get()) != 0 {
|
|
t.Fatalf("rows after null = %+v (%v)", again.Rows, err)
|
|
}
|
|
shown := e.send(t, http.MethodGet, path, nil, true)
|
|
if shown.Code != http.StatusOK || !strings.Contains(shown.Body.String(), `"rows":[]`) {
|
|
t.Fatalf("show = %d %s", shown.Code, shown.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestDependentRecheck pins D-08 through the routes: the options route per
|
|
// sibling value (tree depths, Applies false, unknown and empty values), its
|
|
// 404s and 401, and the save recheck at the top level, with the stored
|
|
// sibling or value used when only the other is sent; a refused save writes
|
|
// nothing.
|
|
func TestDependentRecheck(t *testing.T) {
|
|
e := newCompanionsEnv(t)
|
|
options := func(t *testing.T, field, typ string) cabana.AdminDependentOptionsResult {
|
|
t.Helper()
|
|
rec := e.send(t, http.MethodGet, dependentOptionsPath(field, typ), nil, true)
|
|
wantStatus(t, field+" "+typ, rec, http.StatusOK, rec.Body.String())
|
|
var body cabana.Envelope[cabana.AdminDependentOptionsResult]
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return body.Data
|
|
}
|
|
for _, field := range []string{"reference", "rows.reference"} {
|
|
page := options(t, field, "acme-page")
|
|
if !page.Applies || fmt.Sprint(page.Options) != "[{1 Home 0} {2 About 1}]" {
|
|
t.Fatalf("%s acme-page = %+v", field, page)
|
|
}
|
|
for _, typ := range []string{"acme-list", "acme-unknown", "url", ""} {
|
|
if none := options(t, field, typ); none.Applies || none.Options == nil || len(none.Options) != 0 {
|
|
t.Fatalf("%s %q = %+v", field, typ, none)
|
|
}
|
|
}
|
|
}
|
|
for _, path := range []string{dependentOptionsPath("title", "acme-page"), dependentOptionsPath("type", "acme-page"), dependentOptionsPath("rows.title", "acme-page"), dependentOptionsPath("missing", "acme-page"), dependentOptionsPath("rows.missing", "acme-page")} {
|
|
if rec := e.send(t, http.MethodGet, path, nil, true); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("%s status=%d", path, rec.Code)
|
|
}
|
|
}
|
|
if rec := e.send(t, http.MethodGet, dependentOptionsPath("reference", "acme-page"), nil, false); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("anonymous status=%d", rec.Code)
|
|
}
|
|
|
|
rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Dep", "type": "acme-page", "reference": "2"}, true)
|
|
wantStatus(t, "create", rec, http.StatusCreated, rec.Body.String())
|
|
id := dataID(t, rec.Body.Bytes())
|
|
path := fmt.Sprintf("/acme/companions/links/%d", id)
|
|
load := func(t *testing.T) companionsLink {
|
|
t.Helper()
|
|
var link companionsLink
|
|
if err := e.db.First(&link, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return link
|
|
}
|
|
str := func(p *string) string {
|
|
if p == nil {
|
|
return "<nil>"
|
|
}
|
|
return *p
|
|
}
|
|
if link := load(t); str(link.Type) != "acme-page" || str(link.Reference) != "2" {
|
|
t.Fatalf("created = %+v", link)
|
|
}
|
|
refused := func(t *testing.T, body map[string]any) {
|
|
t.Helper()
|
|
before := companionsLinkRows(t, e)
|
|
rec := e.send(t, http.MethodPut, path, body, true)
|
|
wantStatus(t, "refused", rec, http.StatusUnprocessableEntity, rec.Body.String())
|
|
code, details := companionsError(t, rec.Body.Bytes())
|
|
if code != "validation_failed" || len(details) != 1 || len(details["reference"]) != 1 || details["reference"][0] != "Wybierz jedną z dostępnych opcji." {
|
|
t.Fatalf("details = %s %v", code, details)
|
|
}
|
|
sameLinkRows(t, e, before, "a refused dependent save")
|
|
}
|
|
refused(t, map[string]any{"title": "Dep", "type": "acme-page", "reference": "99"})
|
|
refused(t, map[string]any{"title": "Dep", "reference": "99"}) // stored type acme-page decides
|
|
refused(t, map[string]any{"title": "Dep", "reference": 7})
|
|
|
|
accepted := func(t *testing.T, body map[string]any, wantType, wantRef string) {
|
|
t.Helper()
|
|
rec := e.send(t, http.MethodPut, path, body, true)
|
|
wantStatus(t, "accepted", rec, http.StatusOK, rec.Body.String())
|
|
if link := load(t); str(link.Type) != wantType || str(link.Reference) != wantRef {
|
|
t.Fatalf("after %v: type=%s reference=%s, want %s/%s", body, str(link.Type), str(link.Reference), wantType, wantRef)
|
|
}
|
|
}
|
|
accepted(t, map[string]any{"title": "Dep", "reference": "1"}, "acme-page", "1")
|
|
accepted(t, map[string]any{"title": "Dep", "reference": 2}, "acme-page", "2")
|
|
accepted(t, map[string]any{"title": "Dep", "reference": nil}, "acme-page", "<nil>")
|
|
accepted(t, map[string]any{"title": "Dep", "reference": "1"}, "acme-page", "1")
|
|
// Only the sibling: the stored value is checked against the new type,
|
|
// and a type without references clears it.
|
|
accepted(t, map[string]any{"title": "Dep", "type": "acme-list"}, "acme-list", "<nil>")
|
|
accepted(t, map[string]any{"title": "Dep", "type": "acme-list", "reference": "1"}, "acme-list", "<nil>")
|
|
accepted(t, map[string]any{"title": "Dep", "type": "acme-page", "reference": "2"}, "acme-page", "2")
|
|
accepted(t, map[string]any{"title": "Dep", "type": "url", "reference": "2"}, "url", "<nil>")
|
|
}
|
|
|
|
// TestRichEditor pins D-10 through the routes: a save stores the sanitized
|
|
// value with notices in the request locale, clean HTML byte for byte with
|
|
// none, null stays null, a value that is not a string is a 422 that writes
|
|
// nothing; and settings and relation forms refuse the type at boot.
|
|
func TestRichEditor(t *testing.T) {
|
|
t.Run("boot refusals", func(t *testing.T) {
|
|
for name, field := range map[string]string{
|
|
"richeditor": " body:\n type: richeditor\n",
|
|
"repeater": " body:\n type: repeater\n form:\n fields:\n a:\n type: text\n",
|
|
} {
|
|
err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/part/fields.yaml": "fields:\n label:\n type: text\n" + field})}, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "field body: "+name+" is supported in controller forms only") {
|
|
t.Fatalf("relation form with %s: err = %v", name, err)
|
|
}
|
|
fsys := companionsOverlay(t, map[string]string{"models/settings/fields.yaml": "fields:\n title:\n type: text\n body:\n" + strings.TrimPrefix(field, " body:\n")})
|
|
err = activateCompanions(t, companionsFSPlugin{fsys: fsys, settings: []pact.SettingsItem{{
|
|
Code: "companions_settings", Label: "Settings", Category: "Acme", Form: "models/settings/fields.yaml", Model: "Profile",
|
|
NewModel: func() any { return &companionsProfile{} }, Permissions: []string{"acme.companions.access"},
|
|
}}})
|
|
if err == nil || !strings.Contains(err.Error(), "setting companions_settings field body: "+name+" is supported in controller forms only") {
|
|
t.Fatalf("settings form with %s: err = %v", name, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
e := newCompanionsEnv(t)
|
|
type noticeView struct{ field, message string }
|
|
save := func(t *testing.T, method, path string, body map[string]any, status int) (uint, []noticeView) {
|
|
t.Helper()
|
|
rec := e.send(t, method, path, body, true)
|
|
wantStatus(t, method+" "+path, rec, status, rec.Body.String())
|
|
record := decodeCompanionsRecord(t, rec.Body.Bytes())
|
|
var out []noticeView
|
|
for _, n := range record.Meta.Notices {
|
|
out = append(out, noticeView{n.Field, n.Message})
|
|
}
|
|
return dataID(t, rec.Body.Bytes()), out
|
|
}
|
|
stored := func(t *testing.T, id uint) *string {
|
|
t.Helper()
|
|
var link companionsLink
|
|
if err := e.db.First(&link, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return link.Description
|
|
}
|
|
|
|
id, notices := save(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Rich", "description": `<p onclick="a()" onmouseover="b()">x</p><script>1</script><iframe src="https://evil.example"></iframe><!-- c -->`}, http.StatusCreated)
|
|
if got := stored(t, id); got == nil || *got != "<p>x</p>" {
|
|
t.Fatalf("stored = %v", got)
|
|
}
|
|
want := []noticeView{
|
|
{"description", "Usunięto 2 elementy: script, iframe"},
|
|
{"description", "Usunięto 2 atrybuty: onclick, onmouseover"},
|
|
{"description", "Usunięto 1 komentarz HTML"},
|
|
}
|
|
if fmt.Sprint(notices) != fmt.Sprint(want) {
|
|
t.Fatalf("notices = %v\nwant %v", notices, want)
|
|
}
|
|
|
|
path := fmt.Sprintf("/acme/companions/links/%d", id)
|
|
for _, clean := range []string{
|
|
"<p>It's <strong>fine</strong> & \"quoted\" —</p>\n<ul>\n <li>one</li>\n</ul>",
|
|
`<p class='a b'><a title="t" href="https://example.com/?a=1&b=2">link</a></p>`,
|
|
"",
|
|
} {
|
|
if _, notices := save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": clean}, http.StatusOK); len(notices) != 0 {
|
|
t.Fatalf("clean %q reported %v", clean, notices)
|
|
}
|
|
if got := stored(t, id); got == nil || *got != clean {
|
|
t.Fatalf("clean value not stored byte for byte: %v, want %q", got, clean)
|
|
}
|
|
}
|
|
if _, notices := save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": nil}, http.StatusOK); len(notices) != 0 || stored(t, id) != nil {
|
|
t.Fatalf("null description: %v %v", notices, stored(t, id))
|
|
}
|
|
keep := "<p>kept</p>"
|
|
save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": keep}, http.StatusOK)
|
|
save(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, http.StatusOK)
|
|
if got := stored(t, id); got == nil || *got != keep {
|
|
t.Fatalf("an update without the field changed it: %v", got)
|
|
}
|
|
before := companionsLinkRows(t, e)
|
|
for _, bad := range []any{12, true, []any{"<p>x</p>"}, map[string]any{"pl": "<p>x</p>"}} {
|
|
rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Bad", "description": bad}, true)
|
|
wantStatus(t, fmt.Sprintf("description %v", bad), rec, http.StatusUnprocessableEntity, rec.Body.String())
|
|
if _, details := companionsError(t, rec.Body.Bytes()); len(details["description"]) != 1 {
|
|
t.Fatalf("details = %v", details)
|
|
}
|
|
sameLinkRows(t, e, before, "a non-string description")
|
|
}
|
|
}
|