test(17-06): framework and admin SPA unit tests for the blog companions

- 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
This commit is contained in:
2026-10-12 19:29:37 +02:00
parent 490a4e3e6a
commit 91593da1f7
17 changed files with 3136 additions and 17 deletions

View File

@@ -0,0 +1,435 @@
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> &amp; \"quoted\" &nbsp;&#8212;</p>\n<ul>\n <li>one</li>\n</ul>",
`<p class='a b'><a title="t" href="https://example.com/?a=1&amp;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")
}
}

View File

@@ -0,0 +1,222 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestDependentDropdownCompile pins D-08's compile rules for dependsOn: a
// dropdown only, no static options, a sibling of the same form (or repeater
// row) with a scalar value, never itself, and a controller that implements
// pact.DependentOptionsProvider.
func TestDependentDropdownCompile(t *testing.T) {
t.Run("a valid dependent dropdown compiles", func(t *testing.T) {
cc := ucMustCompile(t, ucFields+ucRepeater(""), nil)
if ref := ucField(t, cc, "reference"); ref.DependsOn != "type" || len(ref.Options) != 0 {
t.Fatalf("reference = %+v", ref)
}
if !usesDependsOn(cc.Form.Fields) {
t.Fatal("usesDependsOn = false")
}
})
base := "fields:\n title:\n type: text\n type:\n type: dropdown\n options: typeOptions\n"
for _, tc := range []struct {
name, fields string
ctl pact.AdminController
want string
}{
{"on a text field", base + " reference:\n type: text\n dependsOn: type\n", nil, "dependsOn is only valid on type: dropdown"},
{"on a textarea", base + " reference:\n type: textarea\n dependsOn: type\n", nil, "dependsOn is only valid on type: dropdown"},
{"with static options", base + " reference:\n type: dropdown\n dependsOn: type\n options: typeOptions\n", nil, "a dropdown with dependsOn takes no options"},
{"not a field name", base + " reference:\n type: dropdown\n dependsOn: \"a b\"\n", nil, `dependsOn "a b" is not a field name`},
{"names itself", base + " reference:\n type: dropdown\n dependsOn: reference\n", nil, "field reference: dependsOn names the field itself"},
{"unknown sibling", base + " reference:\n type: dropdown\n dependsOn: kind\n", nil, "field reference: dependsOn kind is not a field of this form"},
{"sibling without a scalar value", base + ucRepeater("") + " pick:\n type: dropdown\n dependsOn: rows\n", nil, "field pick: dependsOn rows must be a field with a scalar value, not type repeater"},
{"no provider", ucFields, ucPlainLinks{}, "field reference: dependsOn needs controller acme.unit.links to implement pact.DependentOptionsProvider"},
{"no provider for a nested dropdown", base + ucRepeater(""), ucPlainLinks{}, "field rows.reference: dependsOn needs controller acme.unit.links to implement pact.DependentOptionsProvider"},
{"nested unknown sibling", base + " rows:\n type: repeater\n form:\n fields:\n reference:\n type: dropdown\n dependsOn: title\n", nil, "field rows.reference: dependsOn title is not a field of the same repeater row"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := ucCompile(tc.fields, tc.ctl)
wantErr(t, err, tc.want)
})
}
t.Run("a controller without dependsOn needs no provider", func(t *testing.T) {
if _, err := ucCompile(base, ucPlainLinks{}); err != nil {
t.Fatal(err)
}
})
t.Run("dependent fields are found by path", func(t *testing.T) {
cc := ucMustCompile(t, ucFields+ucRepeater(""), nil)
for _, tc := range []struct {
path, want string
ok bool
}{
{"reference", "reference", true},
{"rows.reference", "rows.reference", true},
{"title", "", false},
{"type", "", false},
{"rows.title", "", false},
{"rows.missing", "", false},
{"rows", "", false},
{"title.reference", "", false},
{"missing", "", false},
} {
field, ok := dependentField(cc, tc.path)
if ok != tc.ok || (ok && (field.Name != tc.want || field.DependsOn != "type")) {
t.Fatalf("dependentField(%q) = %+v, %v", tc.path, field, ok)
}
}
if _, ok := dependentField(nil, "reference"); ok {
t.Fatal("nil controller answered")
}
})
}
// TestDependentValues pins the Docker-free half of the D-08 save recheck:
// sibling values are normalized the same way for the options route and a
// save, null is always accepted, a value outside the options is a 422 on the
// field (field.index.nested in a row), a type that takes no reference stores
// null, the stored sibling or value is used when only the other is written,
// and the provider is asked once per path and value.
func TestDependentValues(t *testing.T) {
for _, tc := range []struct {
in any
want any
}{
{nil, nil}, {"", nil}, {"x", "x"}, {true, "true"}, {false, "false"},
{json.Number("12"), "12"}, {float64(2), "2"}, {2.5, "2.5"}, {3, "3"}, {int64(4), "4"}, {uint(5), "5"},
{[]any{1}, "[1]"},
} {
if got := dependentValue(tc.in); got != tc.want {
t.Fatalf("dependentValue(%#v) = %#v, want %#v", tc.in, got, tc.want)
}
}
set := pact.DependentOptionSet{Applies: true, Options: []pact.Option{{Value: "1"}, {Value: "2"}}}
for _, tc := range []struct {
value any
ok bool
}{{nil, true}, {"", true}, {"1", true}, {float64(2), true}, {json.Number("2"), true}, {"3", false}, {"01", false}, {true, false}} {
if got := amongOptions(set, tc.value); got != tc.ok {
t.Fatalf("amongOptions(%#v) = %v", tc.value, got)
}
}
page, list := "acme-page", "acme-list"
stale := "99"
two := "2"
check := func(t *testing.T, target *ucLink, projected map[string]any) (map[string]any, []string, error) {
t.Helper()
var calls []string
cc := ucMustCompile(t, ucFields+ucRepeater(""), ucLinks{calls: &calls})
err := checkDependentValues(context.Background(), cc, nil, target, projected, "update")
var invalid *ValidationError
if errors.As(err, &invalid) {
return invalid.Details, calls, nil
}
return nil, calls, err
}
t.Run("top level", func(t *testing.T) {
for _, tc := range []struct {
name string
target ucLink
projected map[string]any
wantInvalid bool
wantReference any
wantCalls string
}{
{"a value among the options", ucLink{}, map[string]any{"type": page, "reference": "2"}, false, "2", "[reference=map[type:acme-page]]"},
{"a number value is compared as text", ucLink{}, map[string]any{"type": page, "reference": float64(1)}, false, float64(1), "[reference=map[type:acme-page]]"},
{"null is accepted", ucLink{}, map[string]any{"type": page, "reference": nil}, false, nil, "[reference=map[type:acme-page]]"},
{"a value outside the options", ucLink{}, map[string]any{"type": page, "reference": "99"}, true, "99", "[reference=map[type:acme-page]]"},
{"a type without references stores null", ucLink{}, map[string]any{"type": list, "reference": "1"}, false, nil, "[reference=map[type:acme-list]]"},
{"an empty type stores null", ucLink{}, map[string]any{"type": "", "reference": "1"}, false, nil, "[reference=map[type:<nil>]]"},
{"only the value: the stored sibling decides", ucLink{Type: &page}, map[string]any{"reference": "99"}, true, "99", "[reference=map[type:acme-page]]"},
{"only the value, stored sibling accepts it", ucLink{Type: &page}, map[string]any{"reference": "1"}, false, "1", "[reference=map[type:acme-page]]"},
{"only the sibling: the stored value is checked", ucLink{Reference: &stale}, map[string]any{"type": page}, true, nil, "[reference=map[type:acme-page]]"},
{"only the sibling, stored value still valid", ucLink{Reference: &two}, map[string]any{"type": page}, false, nil, "[reference=map[type:acme-page]]"},
{"only the sibling to a type without references clears the value", ucLink{Reference: &two}, map[string]any{"type": list}, false, nil, "[reference=map[type:acme-list]]"},
{"neither written asks nothing", ucLink{Type: &page, Reference: &stale}, map[string]any{"title": "x"}, false, nil, "[]"},
} {
t.Run(tc.name, func(t *testing.T) {
target := tc.target
details, calls, err := check(t, &target, tc.projected)
if err != nil {
t.Fatal(err)
}
if (details != nil) != tc.wantInvalid {
t.Fatalf("details = %v, want invalid %v", details, tc.wantInvalid)
}
if tc.wantInvalid {
if msgs, _ := details["reference"].([]string); len(msgs) != 1 || msgs[0] != msgDependentInvalid || len(details) != 1 {
t.Fatalf("details = %v", details)
}
}
if got, present := tc.projected["reference"]; present || tc.wantReference != nil {
if fmt.Sprint(got) != fmt.Sprint(tc.wantReference) {
t.Fatalf("projected reference = %#v, want %#v", got, tc.wantReference)
}
}
if strings.Contains(tc.name, "stores null") || strings.Contains(tc.name, "clears the value") {
if v, present := tc.projected["reference"]; !present || v != nil {
t.Fatalf("reference not written as null: %#v (present %v)", v, present)
}
}
if fmt.Sprint(calls) != tc.wantCalls {
t.Fatalf("provider calls = %v, want %s", calls, tc.wantCalls)
}
})
}
})
t.Run("repeater rows", func(t *testing.T) {
projected := map[string]any{"rows": []any{
map[string]any{"title": "a", "type": page, "reference": "1"},
map[string]any{"title": "b", "type": page, "reference": "99"},
map[string]any{"title": "c", "type": list, "reference": "1"},
map[string]any{"title": "d", "type": page, "reference": "2"},
"not a row",
}}
details, calls, err := check(t, &ucLink{}, projected)
if err != nil {
t.Fatal(err)
}
if msgs, _ := details["rows.1.reference"].([]string); len(msgs) != 1 || msgs[0] != msgDependentInvalid || len(details) != 1 {
t.Fatalf("details = %v", details)
}
rows := projected["rows"].([]any)
if rows[2].(map[string]any)["reference"] != nil || rows[0].(map[string]any)["reference"] != "1" {
t.Fatalf("rows = %v", rows)
}
// One call per distinct path and value: the second acme-page row
// and the fourth reuse the first answer.
if fmt.Sprint(calls) != "[rows.reference=map[type:acme-page] rows.reference=map[type:acme-list]]" {
t.Fatalf("provider calls = %v", calls)
}
})
t.Run("a provider error is returned, not a 422", func(t *testing.T) {
broken := "acme-broken"
_, _, err := check(t, &ucLink{}, map[string]any{"type": broken, "reference": "1"})
if err == nil || !strings.Contains(err.Error(), "references failed") {
t.Fatalf("err = %v", err)
}
})
t.Run("a form without dependsOn asks nothing", func(t *testing.T) {
cc := ucMustCompile(t, "fields:\n title:\n type: text\n", ucPlainLinks{})
if err := checkDependentValues(context.Background(), cc, nil, &ucLink{}, map[string]any{"title": "x"}, "update"); err != nil {
t.Fatal(err)
}
})
}

View File

@@ -223,6 +223,105 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
t.FailNow()
}
}
// The documented error answers of the Phase 17 routes, after a fresh
// login (the last case logged out).
if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": env.login, "password": adminTestPassword}, false); rec.Code != http.StatusOK {
t.Fatalf("re-login status=%d body=%s", rec.Code, rec.Body.String())
}
for _, tc := range companionsErrorCases() {
t.Run(fmt.Sprintf("%s %d %s", tc.key, tc.status, tc.name), func(t *testing.T) {
rec := tc.call(t, env)
if rec.Code != tc.status {
t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String())
}
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
dec.DisallowUnknownFields()
var body cabana.ErrorEnvelope
if err := dec.Decode(&body); err != nil || body.Error.Code == "" {
t.Fatalf("body is not an ErrorEnvelope: %v\n%s", err, rec.Body.String())
}
if tc.code != "" && body.Error.Code != tc.code {
t.Fatalf("error code = %q, want %q", body.Error.Code, tc.code)
}
method, path, _ := strings.Cut(tc.key, " ")
if got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status)); got != "cabana.ErrorEnvelope" {
t.Fatalf("admin.json documents %q for %s %d, the handler writes cabana.ErrorEnvelope", got, tc.key, tc.status)
}
})
}
}
// companionsErrorCase is one documented error answer of a Phase 17 route.
type companionsErrorCase struct {
key string
status int
name string
code string
call func(t *testing.T, env *conformEnv) *httptest.ResponseRecorder
}
// companionsErrorCases are the 404, 409 and 422 answers the dependent
// options, reorder and singleton routes document, and the 404 of the list,
// create and delete routes on a singleton controller.
func companionsErrorCases() []companionsErrorCase {
partitionLinks := func(t *testing.T, e *conformEnv) (uint, []uint) {
t.Helper()
group := companionsGroup{Title: "Errors " + e.stamp}
if err := e.db.Create(&group).Error; err != nil {
t.Fatal(err)
}
var ids []uint
for i := range 2 {
link := companionsLink{Title: fmt.Sprintf("err-%d-%s", i, e.stamp), GroupID: &group.ID, SortOrder: i + 1}
if err := e.db.Create(&link).Error; err != nil {
t.Fatal(err)
}
ids = append(ids, link.ID)
}
return group.ID, ids
}
profileID := func(t *testing.T, e *conformEnv) uint {
t.Helper()
return dataID(t, e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true).Body.Bytes())
}
return []companionsErrorCase{
{"GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options", 404, "field without dependsOn", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, dependentOptionsPath("title", "acme-page"), nil, true)
}},
{"GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options", 401, "anonymous", "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, dependentOptionsPath("reference", "acme-page"), nil, false)
}},
{"GET /{vendor}/{plugin}/{controller}/reorder", 404, "unknown partition", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/companions/links/reorder?partition=999999", nil, true)
}},
{"GET /{vendor}/{plugin}/{controller}/reorder", 422, "partition not an id", "validation_failed", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/companions/links/reorder?partition=abc", nil, true)
}},
{"POST /{vendor}/{plugin}/{controller}/reorder", 409, "stale set", "reorder_stale", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
group, ids := partitionLinks(t, e)
return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": group, "nodes": []any{reorderNode(ids[0], nil, 1)}}, true)
}},
{"POST /{vendor}/{plugin}/{controller}/reorder", 422, "cycle", "validation_failed", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
group, ids := partitionLinks(t, e)
return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": group, "nodes": []any{reorderNode(ids[0], &ids[1], 1), reorderNode(ids[1], &ids[0], 1)}}, true)
}},
{"POST /{vendor}/{plugin}/{controller}/reorder", 404, "unknown partition", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": 999999, "nodes": []any{}}, true)
}},
{"GET /{vendor}/{plugin}/{controller}/singleton", 404, "not a singleton", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/companions/links/singleton", nil, true)
}},
{"GET /{vendor}/{plugin}/{controller}", 404, "list of a singleton", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/companions/profiles", nil, true)
}},
{"POST /{vendor}/{plugin}/{controller}", 404, "create on a singleton", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/acme/companions/profiles", map[string]any{"title": "x"}, true)
}},
{"DELETE /{vendor}/{plugin}/{controller}/{id}", 404, "delete on a singleton", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/companions/profiles/%d", profileID(t, e)), nil, true)
}},
}
}
// conformCases is the conformance case of every inventoried admin route, in

View File

@@ -0,0 +1,265 @@
package cabana_test
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestReorderConfigCompile pins the boot checks of config_reorder.yaml
// (D-09), Docker-free through cabana.Activate: unknown keys, a modelClass
// mismatch, columns that are not columns of the record, a negative maxDepth,
// a partition over a column without a belongsTo relation field, a partition
// label column the related record lacks, and the reorder toolbar button
// without the file.
func TestReorderConfigCompile(t *testing.T) {
const cfg = "controllers/links/config_reorder.yaml"
reorder := func(t *testing.T, old, new string) map[string]string {
t.Helper()
src := companionsFile(t, cfg)
if !strings.Contains(src, old) {
t.Fatalf("fixture lacks %q", old)
}
return map[string]string{cfg: strings.Replace(src, old, new, 1)}
}
t.Run("the fixture compiles, and defaults fill parentColumn and sortColumn", func(t *testing.T) {
if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, nil)}); err != nil {
t.Fatal(err)
}
files := reorder(t, "parentColumn: parent_id\nsortColumn: sort_order\n", "")
if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil {
t.Fatalf("defaults: %v", err)
}
files = reorder(t, "maxDepth: 3\n", "maxDepth: 0\n")
if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil {
t.Fatalf("unlimited depth: %v", err)
}
files = reorder(t, "partition:\n column: group_id\n relation: group\n label: Group\n", "")
if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil {
t.Fatalf("without a partition: %v", err)
}
files = reorder(t, " label: Group\n", " label: Group\n nameFrom: title\n")
if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil {
t.Fatalf("partition nameFrom: %v", err)
}
})
for _, tc := range []struct {
name string
files func(t *testing.T) map[string]string
want []string
}{
{"unknown key", func(t *testing.T) map[string]string {
return reorder(t, "title: Reorder links\n", "title: Reorder links\nshowTree: true\n")
}, []string{cfg, "showTree"}},
{"unknown partition key", func(t *testing.T) map[string]string {
return reorder(t, " label: Group\n", " label: Group\n scope: x\n")
}, []string{cfg, "scope"}},
{"modelClass mismatch", func(t *testing.T) map[string]string { return reorder(t, "modelClass: Link\n", "modelClass: Other\n") }, []string{cfg, `modelClass "Other" does not match "Link"`}},
{"nameFrom not a column", func(t *testing.T) map[string]string { return reorder(t, "nameFrom: title\n", "nameFrom: label\n") }, []string{"nameFrom label is not a column of the record"}},
{"nameFrom not an identifier", func(t *testing.T) map[string]string {
return reorder(t, "nameFrom: title\n", "nameFrom: \"title; drop\"\n")
}, []string{"nameFrom", "is not a column name"}},
{"parentColumn not a column", func(t *testing.T) map[string]string {
return reorder(t, "parentColumn: parent_id\n", "parentColumn: parent\n")
}, []string{"parentColumn parent is not a column of the record"}},
{"sortColumn not a column", func(t *testing.T) map[string]string {
return reorder(t, "sortColumn: sort_order\n", "sortColumn: position\n")
}, []string{"sortColumn position is not a column of the record"}},
{"negative maxDepth", func(t *testing.T) map[string]string { return reorder(t, "maxDepth: 3\n", "maxDepth: -1\n") }, []string{"maxDepth must be 0 (unlimited) or more"}},
{"partition column not a column", func(t *testing.T) map[string]string { return reorder(t, "column: group_id\n", "column: menu_id\n") }, []string{"partition.column menu_id is not a column of the record"}},
{"partition relation unknown", func(t *testing.T) map[string]string { return reorder(t, "relation: group\n", "relation: menu\n") }, []string{"partition.relation menu must be a belongsTo relation field of the form over group_id"}},
{"partition relation over another column", func(t *testing.T) map[string]string { return reorder(t, "column: group_id\n", "column: parent_id\n") }, []string{"partition.relation group must be a belongsTo relation field of the form over parent_id"}},
{"partition nameFrom not a related column", func(t *testing.T) map[string]string {
return reorder(t, " label: Group\n", " label: Group\n nameFrom: name\n")
}, []string{"partition.nameFrom name is not a column of the related record"}},
{"reorder button without the file", func(t *testing.T) map[string]string { return map[string]string{cfg: ""} }, []string{"config_list.yaml", "toolbar.buttons: reorder needs config_reorder.yaml"}},
{"a reorder page needs a list", func(t *testing.T) map[string]string {
return map[string]string{"controllers/profiles/config_reorder.yaml": "title: Profiles\nnameFrom: title\nparentColumn: id\nsortColumn: id\n"}
}, []string{"a reorder page needs config_list.yaml"}},
} {
t.Run(tc.name, func(t *testing.T) {
err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, tc.files(t))})
if err == nil {
t.Fatal("boot accepted the reorder config")
}
for _, part := range tc.want {
if !strings.Contains(err.Error(), part) {
t.Fatalf("err = %v, want %q", err, part)
}
}
})
}
}
// TestReorderPostgres pins the D-09 routes on Postgres: the GET scopes and
// orders the partition's records, the POST writes both columns, and every
// refused request (stale set, foreign partition node, unknown id, duplicate,
// self parent, parent outside the set, cycle, depth above maxDepth, missing
// or unknown partition) answers its status and writes nothing.
func TestReorderPostgres(t *testing.T) {
e := newCompanionsEnv(t)
alpha, beta := companionsGroup{Title: "Beta"}, companionsGroup{Title: "Alpha"}
for _, g := range []*companionsGroup{&alpha, &beta} {
if err := e.db.Create(g).Error; err != nil {
t.Fatal(err)
}
}
// Records of alpha ("Beta"): sort order ties break by id; one record of
// beta ("Alpha") is the foreign node.
links := []companionsLink{
{Title: "A", GroupID: &alpha.ID, SortOrder: 2},
{Title: "B", GroupID: &alpha.ID, SortOrder: 1},
{Title: "C", GroupID: &alpha.ID, SortOrder: 2},
{Title: "D", GroupID: &alpha.ID, SortOrder: 3},
{Title: "E", GroupID: &alpha.ID, SortOrder: 4},
{Title: "X", GroupID: &beta.ID, SortOrder: 1},
{Title: "Free"},
}
for i := range links {
if err := e.db.Create(&links[i]).Error; err != nil {
t.Fatal(err)
}
}
a, b, c, d, x := links[0].ID, links[1].ID, links[2].ID, links[3].ID, links[5].ID
ee := links[4].ID
t.Run("GET", func(t *testing.T) {
tree := reorderTree(t, e.send(t, http.MethodGet, fmt.Sprintf("/acme/companions/links/reorder?partition=%d", alpha.ID), nil, true))
var names []string
for _, n := range tree.Nodes {
names = append(names, n.Name)
}
if strings.Join(names, ",") != "B,A,C,D,E" || tree.Title != "Reorder links" || tree.Partition.Label != "Group" {
t.Fatalf("tree = %+v (names %v)", tree, names)
}
// Without a partition the first one by label ("Alpha") is shown.
first := reorderTree(t, e.send(t, http.MethodGet, "/acme/companions/links/reorder", nil, true))
if first.Partition == nil || first.Partition.Value == nil || *first.Partition.Value != beta.ID || len(first.Nodes) != 1 || first.Nodes[0].ID != x {
t.Fatalf("default partition tree = %+v", first)
}
if opts := first.Partition.Options; len(opts) != 2 || opts[0].Label != "Alpha" || opts[1].Label != "Beta" {
t.Fatalf("partition options = %+v", opts)
}
for path, status := range map[string]int{
"/acme/companions/links/reorder?partition=999999": http.StatusNotFound,
"/acme/companions/links/reorder?partition=abc": http.StatusUnprocessableEntity,
"/acme/companions/links/reorder?partition=0": http.StatusUnprocessableEntity,
"/acme/companions/profiles/reorder": http.StatusNotFound,
} {
if rec := e.send(t, http.MethodGet, path, nil, true); rec.Code != status {
t.Fatalf("%s status=%d want %d body=%s", path, rec.Code, status, rec.Body.String())
}
}
if rec := e.send(t, http.MethodGet, "/acme/companions/links/reorder", nil, false); rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous status=%d", rec.Code)
}
})
post := func(partition any, nodes ...map[string]any) *httptest.ResponseRecorder {
body := map[string]any{"nodes": nodes}
if partition != nil {
body["partition"] = partition
}
return e.send(t, http.MethodPost, "/acme/companions/links/reorder", body, true)
}
n := reorderNode
t.Run("POST writes both columns", func(t *testing.T) {
tree := reorderTree(t, post(alpha.ID, n(a, nil, 1), n(b, &a, 1), n(c, &b, 1), n(d, nil, 2), n(ee, &a, 2)))
got := companionsLinkRows(t, e)
check := func(id uint, parent *uint, sort int) {
t.Helper()
row := got[id]
if (parent == nil) != (row.ParentID == nil) || (parent != nil && *row.ParentID != *parent) || row.SortOrder != sort {
t.Fatalf("row %d = parent %v sort %d, want %v %d", id, row.ParentID, row.SortOrder, parent, sort)
}
}
check(a, nil, 1)
check(b, &a, 1)
check(c, &b, 1)
check(d, nil, 2)
check(ee, &a, 2)
check(x, nil, 1)
if len(tree.Nodes) != 5 || tree.Nodes[0].ID != a {
t.Fatalf("answered tree = %+v", tree)
}
})
before := companionsLinkRows(t, e)
for _, tc := range []struct {
name string
rec func() *httptest.ResponseRecorder
status int
code string
detail string
}{
{"a node left out", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4))
}, http.StatusConflict, "reorder_stale", ""},
{"a node of another partition", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5), n(x, nil, 6))
}, http.StatusConflict, "reorder_stale", ""},
{"a foreign node in place of one", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(x, nil, 5))
}, http.StatusConflict, "reorder_stale", ""},
{"an unknown id", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(999999, nil, 5))
}, http.StatusConflict, "reorder_stale", ""},
{"a duplicate id", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5), n(a, nil, 6))
}, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
{"id zero", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(0, nil, 1), n(b, nil, 2))
}, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
{"its own parent", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, &a, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5))
}, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
{"a parent outside the set", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, &x, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5))
}, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
{"a cycle", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, &c, 1), n(b, &a, 1), n(c, &b, 1), n(d, nil, 2), n(ee, nil, 3))
}, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
{"deeper than maxDepth", func() *httptest.ResponseRecorder {
return post(alpha.ID, n(a, nil, 1), n(b, &a, 1), n(c, &b, 1), n(d, &c, 1), n(ee, nil, 2))
}, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
{"no partition", func() *httptest.ResponseRecorder {
return post(nil, n(a, nil, 1))
}, http.StatusUnprocessableEntity, "validation_failed", "partition"},
{"an unknown partition", func() *httptest.ResponseRecorder {
return post(999999, n(a, nil, 1))
}, http.StatusNotFound, "", ""},
{"a singleton controller", func() *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/acme/companions/profiles/reorder", map[string]any{"nodes": []any{}}, true)
}, http.StatusNotFound, "", ""},
{"anonymous", func() *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": alpha.ID, "nodes": []any{}}, false)
}, http.StatusUnauthorized, "", ""},
} {
t.Run(tc.name, func(t *testing.T) {
rec := tc.rec()
if rec.Code != tc.status {
t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String())
}
if tc.code != "" {
code, details := companionsError(t, rec.Body.Bytes())
if code != tc.code || (tc.detail != "" && len(details[tc.detail]) != 1) {
t.Fatalf("error = %s %v, want %s on %q", code, details, tc.code, tc.detail)
}
if tc.code == "reorder_stale" && !strings.Contains(rec.Body.String(), "Rekordy zmieniły się w międzyczasie") {
t.Fatalf("stale message = %s", rec.Body.String())
}
}
sameLinkRows(t, e, before, tc.name)
})
}
t.Run("exactly maxDepth is accepted", func(t *testing.T) {
reorderTree(t, post(alpha.ID, n(a, nil, 1), n(b, &a, 1), n(c, &b, 1), n(d, nil, 2), n(ee, nil, 3)))
if row := companionsLinkRows(t, e)[c]; row.ParentID == nil || *row.ParentID != b {
t.Fatalf("c = %+v", row)
}
})
}

View File

@@ -0,0 +1,426 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
)
// Docker-free unit tests of the Phase 17 form features (plan 06): they
// compile the acme.unit links controller from an fstest tree and run the
// decode, recheck and sanitize steps of a save without a database. The
// Postgres tests through the HTTP routes are in companions_postgres_test.go,
// reorder_test.go and singleton_test.go.
// ucLink is the links model of the unit fixture.
type ucLink struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Type *string `gorm:"column:type"`
Reference *string `gorm:"column:reference"`
Description *string `gorm:"column:description"`
Rows lagoon.Jsonable[[]map[string]any] `gorm:"column:rows"`
}
func (*ucLink) TableName() string { return "uc_links" }
func (*ucLink) Fillable() []string {
return []string{"title", "type", "reference", "description", "rows"}
}
func (*ucLink) Rules() map[string]string { return map[string]string{} }
// ucDependent is the fake dependent options binding: acme-page offers a
// two-level tree, acme-list and every other value take no reference.
func ucDependent(_ context.Context, _ string, values map[string]any) (pact.DependentOptionSet, error) {
for _, v := range values {
switch v {
case "acme-page":
return pact.DependentOptionSet{Applies: true, Options: []pact.Option{{Value: "1", Label: "Home"}, {Value: "2", Label: "About", Depth: 1}}}, nil
case "acme-broken":
return pact.DependentOptionSet{}, errors.New("references failed")
}
}
return pact.DependentOptionSet{Applies: false, Options: []pact.Option{}}, nil
}
// ucLinks is the links controller of the unit fixture.
type ucLinks struct {
calls *[]string
}
func (ucLinks) ID() string { return "acme.unit.links" }
func (ucLinks) ModelName() string { return "Link" }
func (ucLinks) ConfigDir() string { return "controllers/links" }
func (ucLinks) RequiredPermissions() []string { return []string{"acme.unit.access"} }
func (ucLinks) NewRecord() any { return &ucLink{} }
func (ucLinks) DropdownOptions(string) []pact.Option {
return []pact.Option{{Value: "url", Label: "URL"}, {Value: "acme-page", Label: "Page"}, {Value: "acme-list", Label: "All pages"}}
}
func (c ucLinks) DependentOptions(ctx context.Context, field string, values map[string]any) (pact.DependentOptionSet, error) {
if c.calls != nil {
*c.calls = append(*c.calls, fmt.Sprintf("%s=%v", field, values))
}
return ucDependent(ctx, field, values)
}
// ucPlainLinks is the same controller without a DependentOptionsProvider.
type ucPlainLinks struct{}
func (ucPlainLinks) ID() string { return "acme.unit.links" }
func (ucPlainLinks) ModelName() string { return "Link" }
func (ucPlainLinks) ConfigDir() string { return "controllers/links" }
func (ucPlainLinks) RequiredPermissions() []string { return []string{"acme.unit.access"} }
func (ucPlainLinks) NewRecord() any { return &ucLink{} }
func (ucPlainLinks) DropdownOptions(string) []pact.Option {
return []pact.Option{{Value: "url", Label: "URL"}}
}
// ucPlugin serves the unit fixture tree with one controller.
type ucPlugin struct {
fsys fs.FS
ctl pact.AdminController
}
func (ucPlugin) ID() string { return "acme.unit" }
func (ucPlugin) Requires() []string { return nil }
func (ucPlugin) Register(*backpack.App) error { return nil }
func (ucPlugin) Boot(*backpack.App) error { return nil }
func (p ucPlugin) AdminFS() fs.FS { return p.fsys }
func (p ucPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{p.ctl}
}
// ucFields is the start of the unit fixture's fields.yaml: a title, a type
// dropdown and a reference dropdown that depends on it.
const ucFields = "fields:\n" +
" title:\n type: text\n label: Title\n" +
" type:\n type: dropdown\n label: Type\n options: typeOptions\n" +
" reference:\n type: dropdown\n label: Reference\n dependsOn: type\n"
// ucRepeater is a valid repeater named rows with every nested type it
// takes; extra is appended to the repeater's own keys.
func ucRepeater(extra string) string {
return " rows:\n type: repeater\n label: Rows\n" + extra +
" form:\n fields:\n" +
" title:\n type: text\n required: true\n" +
" body:\n type: richeditor\n" +
" count:\n type: number\n" +
" on:\n type: checkbox\n" +
" flag:\n type: switch\n" +
" note:\n type: textarea\n" +
" type:\n type: dropdown\n options: typeOptions\n" +
" reference:\n type: dropdown\n dependsOn: type\n"
}
// ucFS is the unit fixture tree with the given fields.yaml.
func ucFS(fields string) fstest.MapFS {
return fstest.MapFS{
"controllers/links/config_form.yaml": &fstest.MapFile{Data: []byte("name: Link\nform: ~/plugins/acme/unit/models/link/fields.yaml\nmodelClass: Link\n")},
"controllers/links/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/unit/models/link/columns.yaml\nmodelClass: Link\nrecordsPerPage: 20\n")},
"models/link/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")},
"models/link/fields.yaml": &fstest.MapFile{Data: []byte(fields)},
}
}
// ucCompile compiles the links controller of fields with ctl (ucLinks when
// nil).
func ucCompile(fields string, ctl pact.AdminController) (*CompiledController, error) {
if ctl == nil {
ctl = ucLinks{}
}
reg, err := ftCompile(ucPlugin{fsys: ucFS(fields), ctl: ctl})
if err != nil {
return nil, err
}
cc, ok := reg.Get("acme.unit.links")
if !ok {
return nil, errors.New("no links controller")
}
return cc, nil
}
func ucMustCompile(t *testing.T, fields string, ctl pact.AdminController) *CompiledController {
t.Helper()
cc, err := ucCompile(fields, ctl)
if err != nil {
t.Fatalf("compile: %v", err)
}
return cc
}
// ucField returns the compiled form field name of cc.
func ucField(t *testing.T, cc *CompiledController, name string) FormField {
t.Helper()
for _, field := range cc.Form.Fields {
if field.Name == name {
return field
}
}
t.Fatalf("no field %s", name)
return FormField{}
}
// TestRepeaterCompile pins D-07's compile rules: every key a repeater
// refuses, every nested type it refuses, the nested tab/context/preset and
// dependsOn checks, the row bounds and titleFrom, and the repeater-only keys
// on other types.
func TestRepeaterCompile(t *testing.T) {
t.Run("a valid repeater compiles with its nested form", func(t *testing.T) {
cc := ucMustCompile(t, ucFields+ucRepeater(" prompt: Add row\n titleFrom: title\n minItems: 1\n maxItems: 3\n tab: Content\n span: left\n required: true\n comment: Rows\n"), nil)
rows := ucField(t, cc, "rows")
if rows.Span != "full" || rows.Prompt != "Add row" || rows.TitleFrom != "title" || rows.MinItems == nil || *rows.MinItems != 1 || rows.MaxItems == nil || *rows.MaxItems != 3 || !rows.Required || rows.Tab != "Content" {
t.Fatalf("rows = %+v", rows)
}
if rows.Form == nil || len(rows.Form.Fields) != 8 {
t.Fatalf("nested form = %+v", rows.Form)
}
var names []string
for _, inner := range rows.Form.Fields {
names = append(names, inner.Name+":"+inner.Type)
}
if strings.Join(names, ",") != "title:text,body:richeditor,count:number,on:checkbox,flag:switch,note:textarea,type:dropdown,reference:dropdown" {
t.Fatalf("nested fields = %v", names)
}
if !rows.Form.Fields[0].Required || rows.Form.Fields[7].DependsOn != "type" {
t.Fatalf("nested keys lost: %+v", rows.Form.Fields)
}
if names := repeaterNames(cc); !names["rows"] || len(names) != 1 {
t.Fatalf("repeaterNames = %v", names)
}
})
nested := func(field string) string {
return " rows:\n type: repeater\n form:\n fields:\n" + field
}
for _, tc := range []struct {
name, fields, want string
}{
{"groups", ucFields + ucRepeater(" groups: ~/groups.yaml\n"), "repeater groups are not supported"},
{"style", ucFields + ucRepeater(" style: accordion\n"), "repeater style is not supported"},
{"options key", ucFields + ucRepeater(" options: typeOptions\n"), "options is not valid on type: repeater"},
{"size key", ucFields + ucRepeater(" size: large\n"), "size is not valid on type: repeater"},
{"default key", ucFields + ucRepeater(" default: []\n"), "default is not valid on type: repeater"},
{"attributes key", ucFields + ucRepeater(" attributes: {}\n"), "attributes is not valid on type: repeater"},
{"no form", ucFields + " rows:\n type: repeater\n", "a repeater needs form: with fields"},
{"form without fields", ucFields + " rows:\n type: repeater\n form:\n label: x\n", "a repeater form takes fields only"},
{"form groups", ucFields + " rows:\n type: repeater\n form:\n groups: {}\n", "repeater groups are not supported"},
{"empty fields", ucFields + " rows:\n type: repeater\n form:\n fields: {}\n", "form.fields is empty"},
{"nested tab", ucFields + nested(" title:\n type: text\n tab: Main\n"), "tab, context and preset are not supported in a repeater row"},
{"nested context", ucFields + nested(" title:\n type: text\n context: update\n"), "tab, context and preset are not supported in a repeater row"},
{"nested dependsOn to a missing sibling", ucFields + nested(" reference:\n type: dropdown\n dependsOn: type\n"), "is not a field of the same repeater row"},
{"nested dependsOn to itself", ucFields + nested(" reference:\n type: dropdown\n dependsOn: reference\n"), "dependsOn names the field itself"},
{"negative maxItems", ucFields + ucRepeater(" maxItems: -1\n"), "maxItems: want a non-negative integer"},
{"text maxItems", ucFields + ucRepeater(" maxItems: many\n"), "maxItems: want a non-negative integer"},
{"negative minItems", ucFields + ucRepeater(" minItems: -2\n"), "minItems: want a non-negative integer"},
{"minItems above maxItems", ucFields + ucRepeater(" minItems: 4\n maxItems: 2\n"), "minItems 4 is greater than maxItems 2"},
{"titleFrom outside the form", ucFields + ucRepeater(" titleFrom: missing\n"), "titleFrom missing is not a field of the repeater form"},
{"maxItems on text", ucFields + " extra:\n type: text\n maxItems: 2\n", "maxItems is only valid on type: repeater"},
{"minItems on textarea", ucFields + " extra:\n type: textarea\n minItems: 2\n", "minItems is only valid on type: repeater"},
{"titleFrom on dropdown", ucFields + " extra:\n type: dropdown\n options: typeOptions\n titleFrom: x\n", "titleFrom is only valid on type: repeater"},
{"form on text", ucFields + " extra:\n type: text\n form:\n fields: {}\n", "form is only valid on type: repeater"},
{"groups on text", ucFields + " extra:\n type: text\n groups: x\n", "repeater groups are not supported"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := ucCompile(tc.fields, nil)
wantErr(t, err, tc.want)
})
}
// Every core type outside text, textarea, number, checkbox, switch,
// dropdown and richeditor is refused in a row.
// Each type gets the keys it needs on its own, so the refusal reached is
// the repeater's.
typeKeys := map[string]string{
"relation-manager": " relation: parts\n",
"widget": " widget: acme-x\n action: lookup\n",
"partial": " path: summary\n",
"permissioneditor": " mode: checkbox\n",
"repeater": " form:\n fields:\n a:\n type: text\n",
}
for typ := range formFieldTypes {
if repeaterNestedTypes[typ] {
continue
}
t.Run("nested "+typ, func(t *testing.T) {
_, err := ucCompile(ucFields+nested(" inner:\n type: "+typ+"\n"+typeKeys[typ]), nil)
wantErr(t, err, "field inner: type "+typ+" is not supported in a repeater")
})
}
}
// ucDecode runs liftRepeaterValues on a JSON body.
func ucDecode(t *testing.T, cc *CompiledController, raw, op string) (map[string]any, map[string]any) {
t.Helper()
body := utJSON(t, raw).(map[string]any)
err := liftRepeaterValues(context.Background(), cc, body, op, nil)
if err == nil {
return body, nil
}
var invalid *ValidationError
if !errors.As(err, &invalid) {
t.Fatalf("err = %v, want a ValidationError", err)
}
return body, invalid.Details
}
// TestRepeaterDecode pins the D-07 decode of a submitted repeater value:
// rows keep only the nested keys, scalars are coerced per nested type,
// required nested values fail under field.index.nested, the row bounds and
// the list shape are checked, and stored values hydrate back to rows.
func TestRepeaterDecode(t *testing.T) {
cc := ucMustCompile(t, ucFields+ucRepeater(" minItems: 1\n maxItems: 3\n"), nil)
t.Run("rows keep only nested keys, coerced per type", func(t *testing.T) {
body, details := ucDecode(t, cc, `{"rows":[{"title":"One","body":"<p>x</p>","count":"12","on":"yes","flag":1,"note":7,"type":"acme-page","reference":2,"extra":"dropped"},{"title":5,"count":3.5,"on":"off","flag":false,"note":true}]}`, "update")
if details != nil {
t.Fatalf("details = %v", details)
}
rows := body["rows"].([]any)
first, second := rows[0].(map[string]any), rows[1].(map[string]any)
if _, extra := first["extra"]; extra || len(first) != 8 || len(second) != 8 {
t.Fatalf("row keys = %v / %v", first, second)
}
want := map[string]any{"title": "One", "body": "<p>x</p>", "count": json.Number("12"), "on": true, "flag": true, "note": "7", "type": "acme-page", "reference": "2"}
if fmt.Sprint(first) != fmt.Sprint(want) {
t.Fatalf("first row = %v\nwant %v", first, want)
}
want = map[string]any{"title": "5", "body": nil, "count": json.Number("3.5"), "on": false, "flag": false, "note": "true", "type": nil, "reference": nil}
if fmt.Sprint(second) != fmt.Sprint(want) {
t.Fatalf("second row = %v\nwant %v", second, want)
}
})
t.Run("coercion refusals and required values are keyed per row", func(t *testing.T) {
_, details := ucDecode(t, cc, `{"rows":[{"title":"ok","count":"abc"},{"title":" ","on":"maybe"},{"title":{"x":1},"flag":[1]}]}`, "update")
for key, part := range map[string]string{
"rows.0.count": "must be a number",
"rows.1.title": "is required",
"rows.1.on": "must be true or false",
"rows.2.title": "must be text",
"rows.2.flag": "must be true or false",
} {
msgs, _ := details[key].([]string)
if len(msgs) != 1 || !strings.Contains(msgs[0], key) || !strings.Contains(msgs[0], part) {
t.Fatalf("%s = %v, want %q", key, details[key], part)
}
}
if len(details) != 5 {
t.Fatalf("details = %v", details)
}
})
t.Run("a missing required nested value fails", func(t *testing.T) {
_, details := ucDecode(t, cc, `{"rows":[{"title":"ok"},{"body":"<p>x</p>"}]}`, "update")
if msgs, _ := details["rows.1.title"].([]string); len(msgs) != 1 || len(details) != 1 {
t.Fatalf("details = %v", details)
}
})
t.Run("the list shape is checked", func(t *testing.T) {
for _, raw := range []string{`{"rows":"x"}`, `{"rows":{"a":1}}`, `{"rows":3}`, `{"rows":[{"title":"ok"},"x"]}`, `{"rows":[[1]]}`} {
_, details := ucDecode(t, cc, raw, "update")
msgs, _ := details["rows"].([]string)
if len(msgs) != 1 || !strings.Contains(msgs[0], "must be a list of rows") {
t.Fatalf("%s: details = %v", raw, details)
}
}
})
t.Run("row bounds", func(t *testing.T) {
_, details := ucDecode(t, cc, `{"rows":[{"title":"1"},{"title":"2"},{"title":"3"},{"title":"4"}]}`, "update")
if msgs, _ := details["rows"].([]string); len(msgs) != 1 || msgs[0] != msgRepeaterMaxItems {
t.Fatalf("max details = %v", details)
}
_, details = ucDecode(t, cc, `{"rows":[]}`, "update")
if msgs, _ := details["rows"].([]string); len(msgs) != 1 || msgs[0] != msgRepeaterMinItems {
t.Fatalf("min details = %v", details)
}
body, details := ucDecode(t, cc, `{"rows":[{"title":"1"},{"title":"2"},{"title":"3"}]}`, "update")
if details != nil || len(body["rows"].([]any)) != 3 {
t.Fatalf("three rows: %v %v", body, details)
}
})
t.Run("null is an empty list and an absent repeater is left alone", func(t *testing.T) {
free := ucMustCompile(t, ucFields+ucRepeater(""), nil)
body, details := ucDecode(t, free, `{"rows":null}`, "update")
if details != nil || fmt.Sprint(body["rows"]) != "[]" {
t.Fatalf("null rows: %v %v", body, details)
}
body, details = ucDecode(t, free, `{"title":"x"}`, "create")
if _, present := body["rows"]; present || details != nil {
t.Fatalf("absent rows: %v %v", body, details)
}
})
t.Run("a required repeater needs a row", func(t *testing.T) {
required := ucMustCompile(t, ucFields+ucRepeater(" required: true\n"), nil)
for _, tc := range []struct{ raw, op string }{{`{"rows":[]}`, "update"}, {`{"rows":null}`, "create"}, {`{"title":"x"}`, "create"}} {
_, details := ucDecode(t, required, tc.raw, tc.op)
if msgs, _ := details["rows"].([]string); len(msgs) != 1 || !strings.Contains(msgs[0], "is required") {
t.Fatalf("%s on %s: details = %v", tc.raw, tc.op, details)
}
}
if _, details := ucDecode(t, required, `{"title":"x"}`, "update"); details != nil {
t.Fatalf("absent on update: %v", details)
}
})
t.Run("nested richeditors are sanitized with row-path notices", func(t *testing.T) {
projected := map[string]any{"rows": []any{
map[string]any{"title": "a", "body": "<p>clean</p>"},
map[string]any{"title": "b", "body": `<p onclick="x()">dirty</p><script>1</script>`},
map[string]any{"title": "c", "body": nil},
}}
notices := sanitizeRepeaterRichEditors(cc, projected)
rows := projected["rows"].([]any)
if rows[0].(map[string]any)["body"] != "<p>clean</p>" || rows[1].(map[string]any)["body"] != "<p>dirty</p>" || rows[2].(map[string]any)["body"] != nil {
t.Fatalf("rows = %v", rows)
}
var fields []string
for _, n := range notices {
fields = append(fields, n.field+":"+n.notice.Message)
}
if strings.Join(fields, ",") != "rows.1.body:"+msgRichRemovedElements+",rows.1.body:"+msgRichRemovedAttributes {
t.Fatalf("notices = %v", fields)
}
})
t.Run("stored rows hydrate to a list", func(t *testing.T) {
stored := lagoon.Jsonable[[]map[string]any]{Data: []map[string]any{{"title": "a"}}, Valid: true}
text := `[{"title":"b"}]`
var nilText *string
for _, tc := range []struct {
value any
want string
}{
{stored, "[map[title:a]]"},
{text, "[map[title:b]]"},
{&text, "[map[title:b]]"},
{nilText, "[]"},
{nil, "[]"},
{"not json", "[]"},
{"null", "[]"},
{[]map[string]any{{"title": "c"}}, "[map[title:c]]"},
} {
data := map[string]any{"rows": tc.value, "title": "kept"}
hydrateRepeaterFields(cc, data)
if fmt.Sprint(data["rows"]) != tc.want || data["title"] != "kept" {
t.Fatalf("hydrate(%#v) = %v, want %s", tc.value, data["rows"], tc.want)
}
}
data := map[string]any{"title": "no rows key"}
hydrateRepeaterFields(cc, data)
if _, present := data["rows"]; present {
t.Fatal("hydrate added a missing rows key")
}
})
}

View File

@@ -0,0 +1,136 @@
package cabana
import (
"errors"
"fmt"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/seaglass"
)
// TestRichEditorCompile pins D-10's Docker-free rules: the keys a richeditor
// takes, the notices a removal report becomes, the value check before a
// save, the projected sanitize step, and the boot refusal of the
// controller-only features on settings forms.
func TestRichEditorCompile(t *testing.T) {
t.Run("keys", func(t *testing.T) {
cc := ucMustCompile(t, ucFields+" description:\n type: richeditor\n label: Description\n size: large\n span: left\n comment: HTML\n required: true\n tab: Content\n default: \"<p>x</p>\"\n", nil)
if d := ucField(t, cc, "description"); d.Type != richEditorType || d.Size != "large" || d.Tab != "Content" {
t.Fatalf("description = %+v", d)
}
for _, key := range []string{"options: typeOptions", "attributes: {}", "placeholder: x", "toolbarButtons: bold", "prompt: x", "maxItems: 2"} {
_, err := ucCompile(ucFields+" description:\n type: richeditor\n "+key+"\n", nil)
if err == nil {
t.Fatalf("richeditor accepted %s", key)
}
}
_, err := ucCompile(ucFields+" description:\n type: richeditor\n options: typeOptions\n", nil)
wantErr(t, err, "options is not valid on type: richeditor")
})
t.Run("notices", func(t *testing.T) {
for _, tc := range []struct {
name string
in seaglass.Removals
want string
}{
{"nothing removed", seaglass.Removals{}, "[]"},
{"elements", seaglass.Removals{Elements: []string{"script", "iframe"}, ElementCount: 3, Changed: true},
"[" + msgRichRemovedElements + " names=script, iframe count=3]"},
{"attributes", seaglass.Removals{Attributes: []string{"onclick"}, AttributeCount: 1, Changed: true},
"[" + msgRichRemovedAttributes + " names=onclick count=1]"},
{"comments", seaglass.Removals{Comments: 2, Changed: true}, "[" + msgRichRemovedComments + " names= count=2]"},
{"all three in order", seaglass.Removals{Elements: []string{"style"}, ElementCount: 1, Attributes: []string{"style", "rel"}, AttributeCount: 2, Comments: 1, Changed: true},
"[" + msgRichRemovedElements + " names=style count=1 " + msgRichRemovedAttributes + " names=style, rel count=2 " + msgRichRemovedComments + " names= count=1]"},
{"markup changed only", seaglass.Removals{Changed: true}, "[" + msgRichMarkupChanged + " names= count=0]"},
} {
var parts []string
for _, n := range richEditorNotices(tc.in) {
parts = append(parts, fmt.Sprintf("%s names=%s count=%d", n.Message, n.Params["names"], n.Count))
}
if got := "[" + strings.Join(parts, " ") + "]"; got != tc.want {
t.Fatalf("%s: notices = %s\nwant %s", tc.name, got, tc.want)
}
}
})
cc := ucMustCompile(t, ucFields+" description:\n type: richeditor\n"+ucRepeater(""), nil)
t.Run("a value that is not a string or null is a 422 on the field", func(t *testing.T) {
for _, raw := range []string{`{"description":"<p>x</p>"}`, `{"description":null}`, `{"title":"no description"}`} {
if err := checkRichEditorValues(cc, utJSON(t, raw).(map[string]any), "update"); err != nil {
t.Fatalf("%s: %v", raw, err)
}
}
for _, raw := range []string{`{"description":1}`, `{"description":true}`, `{"description":["<p>x</p>"]}`, `{"description":{"pl":"<p>x</p>"}}`} {
err := checkRichEditorValues(cc, utJSON(t, raw).(map[string]any), "create")
var invalid *ValidationError
if !errors.As(err, &invalid) || invalid.Details["description"] == nil || len(invalid.Details) != 1 {
t.Fatalf("%s: err = %v", raw, err)
}
}
})
t.Run("projected values are sanitized, clean ones byte for byte", func(t *testing.T) {
clean := "<p>It's <strong>fine</strong> &amp; \"quoted\"</p>"
projected := map[string]any{"description": clean}
if notices := sanitizeRichEditors(cc, projected); len(notices) != 0 || projected["description"] != clean {
t.Fatalf("clean: %v %v", projected, notices)
}
projected = map[string]any{"description": `<p onclick="x()">a</p><!-- c -->`}
notices := sanitizeRichEditors(cc, projected)
if projected["description"] != "<p>a</p>" || len(notices) != 2 || notices[0].field != "description" ||
notices[0].notice.Message != msgRichRemovedAttributes || notices[1].notice.Message != msgRichRemovedComments {
t.Fatalf("dirty: %v %+v", projected, notices)
}
projected = map[string]any{"description": nil, "title": `<script>x</script>`}
if notices := sanitizeRichEditors(cc, projected); len(notices) != 0 || projected["description"] != nil || projected["title"] != `<script>x</script>` {
t.Fatalf("null and other fields: %v %v", projected, notices)
}
if notices := sanitizeRichEditors(cc, map[string]any{}); len(notices) != 0 {
t.Fatal("absent value reported")
}
})
t.Run("settings forms refuse the controller-only features", func(t *testing.T) {
for name, field := range map[string]string{
"richeditor": " type: richeditor\n",
"repeater": " type: repeater\n form:\n fields:\n a:\n type: text\n",
"dependsOn": " type: dropdown\n dependsOn: enabled\n",
} {
fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n enabled:\n type: switch\n extra:\n" + field)}}
item := pact.SettingsItem{Code: "demo", Label: "Demo", Form: "models/settings/fields.yaml", Model: "DemoSettings", NewModel: func() any { return &ucSettings{} }}
_, err := compileSetting("acme.unit", item, fsys)
wantErr(t, err, "setting demo field extra", "supported in controller forms only")
if name != "dependsOn" {
wantErr(t, err, name)
}
}
})
t.Run("controllerFormOnly", func(t *testing.T) {
for _, field := range []FormField{{Type: richEditorType}, {Type: repeaterType}, {Type: "dropdown", DependsOn: "type"}} {
if err := controllerFormOnly(field); err == nil {
t.Fatalf("%+v accepted", field)
}
}
for _, field := range []FormField{{Type: "text"}, {Type: "dropdown"}, {Type: "markdown"}} {
if err := controllerFormOnly(field); err != nil {
t.Fatalf("%+v refused: %v", field, err)
}
}
})
}
// ucSettings is the settings model of the settings refusal case.
type ucSettings struct {
ID uint `gorm:"column:id;primaryKey"`
Enabled bool `gorm:"column:enabled"`
Extra string `gorm:"column:extra"`
}
func (*ucSettings) Fillable() []string { return []string{"enabled", "extra"} }
func (*ucSettings) Rules() map[string]string { return map[string]string{} }

View File

@@ -0,0 +1,113 @@
package cabana_test
import (
"fmt"
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestSingletonCompile pins the D-11 boot rules, Docker-free: a singleton
// controller needs config_form.yaml and no config_list.yaml, and may keep
// one (its create button is dropped then).
func TestSingletonCompile(t *testing.T) {
if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, nil)}); err != nil {
t.Fatalf("fixture without a list: %v", err)
}
withList := companionsOverlay(t, map[string]string{
"controllers/profiles/config_list.yaml": "list: ~/plugins/acme/companions/models/profile/columns.yaml\nmodelClass: Profile\ntoolbar:\n buttons: [create]\n",
"models/profile/columns.yaml": "columns:\n title:\n label: Title\n",
})
if err := activateCompanions(t, companionsFSPlugin{fsys: withList}); err != nil {
t.Fatalf("singleton with a list: %v", err)
}
err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, map[string]string{"controllers/profiles/config_form.yaml": ""})})
if err == nil || !strings.Contains(err.Error(), "acme.companions.profiles: a singleton controller (pact.AdminSingleton) needs config_form.yaml") {
t.Fatalf("singleton without a form: err = %v", err)
}
// A controller that is not a singleton still needs its list.
err = activateCompanions(t, companionsFSPlugin{
fsys: companionsOverlay(t, nil),
controllers: []pact.AdminController{plainProfiles{}},
})
if err == nil || !strings.Contains(err.Error(), "config_list.yaml") {
t.Fatalf("plain controller without a list: err = %v", err)
}
}
// plainProfiles is the profiles controller without pact.AdminSingleton.
type plainProfiles struct{}
func (plainProfiles) ID() string { return "acme.companions.profiles" }
func (plainProfiles) ModelName() string { return "Profile" }
func (plainProfiles) ConfigDir() string { return "controllers/profiles" }
func (plainProfiles) RequiredPermissions() []string { return []string{"acme.companions.access"} }
func (plainProfiles) NewRecord() any { return &companionsProfile{} }
// TestSingleton pins the D-11 routes on Postgres: the singleton route
// creates the record once and answers its id; the record shows and updates; list, create, delete, bulk delete
// and reorder answer 404; the form schema says singleton; a controller that
// is not a singleton answers 404 on the route; anonymous requests 401.
func TestSingleton(t *testing.T) {
e := newCompanionsEnv(t)
first := e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true)
id := dataID(t, first.Body.Bytes())
for range 2 {
if again := dataID(t, e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true).Body.Bytes()); again != id {
t.Fatalf("id = %d, want %d", again, id)
}
}
var count int64
if err := e.db.Model(&companionsProfile{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("profiles = %d (%v)", count, err)
}
var profile companionsProfile
if err := e.db.First(&profile, id).Error; err != nil || profile.Key != "default" || profile.Title != "Default" {
t.Fatalf("profile = %+v (%v)", profile, err)
}
path := fmt.Sprintf("/acme/companions/profiles/%d", id)
if rec := e.send(t, http.MethodGet, path, nil, true); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"title":"Default"`) {
t.Fatalf("show = %d %s", rec.Code, rec.Body.String())
}
if rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, true); rec.Code != http.StatusOK {
t.Fatalf("update = %d %s", rec.Code, rec.Body.String())
}
if err := e.db.First(&profile, id).Error; err != nil || profile.Title != "Renamed" || profile.Key != "default" {
t.Fatalf("updated profile = %+v (%v)", profile, err)
}
if again := dataID(t, e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true).Body.Bytes()); again != id {
t.Fatalf("id after update = %d", again)
}
for _, tc := range []struct {
method, path string
body any
auth bool
status int
}{
{http.MethodGet, "/acme/companions/profiles", nil, true, http.StatusNotFound},
{http.MethodPost, "/acme/companions/profiles", map[string]any{"title": "x"}, true, http.StatusNotFound},
{http.MethodDelete, path, nil, true, http.StatusNotFound},
{http.MethodPost, "/acme/companions/profiles/bulk-delete", map[string]any{"ids": []uint{id}}, true, http.StatusNotFound},
{http.MethodGet, "/acme/companions/profiles/reorder", nil, true, http.StatusNotFound},
{http.MethodGet, "/acme/companions/links/singleton", nil, true, http.StatusNotFound},
{http.MethodGet, "/acme/companions/profiles/singleton", nil, false, http.StatusUnauthorized},
} {
if rec := e.send(t, tc.method, tc.path, tc.body, tc.auth); rec.Code != tc.status {
t.Fatalf("%s %s status=%d want %d body=%s", tc.method, tc.path, rec.Code, tc.status, rec.Body.String())
}
}
if err := e.db.Model(&companionsProfile{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("profiles after refused requests = %d (%v)", count, err)
}
schema := e.send(t, http.MethodGet, "/acme/companions/profiles/schema/form", nil, true)
if !strings.Contains(schema.Body.String(), `"singleton":true`) {
t.Fatalf("form schema = %s", schema.Body.String())
}
if links := e.send(t, http.MethodGet, "/acme/companions/links/schema/form", nil, true); strings.Contains(links.Body.String(), `"singleton":true`) {
t.Fatalf("a plain form schema says singleton: %s", links.Body.String())
}
}

View File

@@ -0,0 +1,117 @@
package pact
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"gorm.io/gorm"
)
// TestOptionDepthOmitEmpty pins that Option.Depth leaves every flat option
// payload unchanged: depth 0 has no JSON key, a nested option carries it.
func TestOptionDepthOmitEmpty(t *testing.T) {
for _, tc := range []struct {
opt Option
want string
}{
{Option{Value: "a", Label: "A"}, `{"value":"a","label":"A"}`},
{Option{Value: "", Label: ""}, `{"value":"","label":""}`},
{Option{Value: "b", Label: "B", Depth: 1}, `{"value":"b","label":"B","depth":1}`},
{Option{Value: "c", Label: "C", Depth: 3}, `{"value":"c","label":"C","depth":3}`},
} {
raw, err := json.Marshal(tc.opt)
if err != nil || string(raw) != tc.want {
t.Fatalf("Marshal(%+v) = %s, %v; want %s", tc.opt, raw, err, tc.want)
}
var back Option
if err := json.Unmarshal(raw, &back); err != nil || back != tc.opt {
t.Fatalf("round trip of %s = %+v, %v", raw, back, err)
}
}
}
// menuTypesPlugin is a plugin-shaped value with menu item types.
type menuTypesPlugin struct{ types []MenuItemType }
func (p menuTypesPlugin) MenuItemTypes() []MenuItemType { return p.types }
// singletonController is a controller-shaped value that edits one record.
type singletonController struct{}
func (singletonController) SingletonRecord(context.Context, *gorm.DB) (any, error) { return 1, nil }
// dependentController answers the options of a dependent dropdown.
type dependentController struct{}
func (dependentController) DependentOptions(_ context.Context, field string, values map[string]any) (DependentOptionSet, error) {
if values["type"] == nil {
return DependentOptionSet{Applies: false}, nil
}
return DependentOptionSet{Applies: true, Options: []Option{{Value: field, Label: "x"}}}, nil
}
var (
_ HasMenuItemTypes = menuTypesPlugin{}
_ AdminSingleton = singletonController{}
_ DependentOptionsProvider = dependentController{}
)
// TestMenuItemTypeContracts pins the shape of the menu item type contracts:
// a type marshals without its functions, a resolved item keeps its
// alternates in the order given (never a map), list types carry their
// children in Items, and the capability interfaces are satisfied by plain
// values.
func TestMenuItemTypeContracts(t *testing.T) {
typ := MenuItemType{
Key: "acme-post", Label: "acme.blog::lang.post", DefaultPattern: "/blog/:slug", PatternFrom: "",
Placeholders: []string{":slug", ":id"}, Nesting: true, DynamicItems: false,
References: func(context.Context) ([]MenuItemReference, error) { return nil, nil },
Resolve: func(_ context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) {
return &ResolvedMenuItem{Title: in.Reference, URL: in.Pattern}, nil
},
}
raw, err := json.Marshal(typ)
if err != nil {
t.Fatalf("Marshal(MenuItemType) = %v", err)
}
if strings.Contains(string(raw), "Resolve") || strings.Contains(string(raw), "References") {
t.Fatalf("functions leaked into JSON: %s", raw)
}
got, err := typ.Resolve(context.Background(), MenuItemResolve{Type: "acme-post", Reference: "7", Pattern: "/blog/:slug"})
if err != nil || got.Title != "7" || got.URL != "/blog/:slug" {
t.Fatalf("Resolve = %+v, %v", got, err)
}
when := time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)
item := ResolvedMenuItem{
Title: "All posts", URL: "", MTime: &when,
Alternates: []MenuItemAlternate{{Locale: "pl", URL: "/a"}, {Locale: "en", URL: "/en/a"}, {Locale: "de", URL: "/de/a"}},
Items: []ResolvedMenuItem{{Title: "One", URL: "/blog/one"}, {Title: "Two", URL: "/blog/two"}},
}
var locales []string
for _, alt := range item.Alternates {
locales = append(locales, alt.Locale)
}
if strings.Join(locales, ",") != "pl,en,de" || item.URL != "" || len(item.Items) != 2 {
t.Fatalf("resolved list item = %+v", item)
}
var source any = menuTypesPlugin{types: []MenuItemType{typ}}
if p, ok := source.(HasMenuItemTypes); !ok || len(p.MenuItemTypes()) != 1 {
t.Fatal("HasMenuItemTypes not satisfied")
}
set, err := dependentController{}.DependentOptions(context.Background(), "reference", map[string]any{"type": nil})
if err != nil || set.Applies || set.Options != nil {
t.Fatalf("not-applying set = %+v, %v", set, err)
}
set, err = dependentController{}.DependentOptions(context.Background(), "rows.reference", map[string]any{"type": "acme-post"})
if err != nil || !set.Applies || set.Options[0].Value != "rows.reference" {
t.Fatalf("applying set = %+v, %v", set, err)
}
if rec, err := (singletonController{}).SingletonRecord(context.Background(), nil); err != nil || rec != 1 {
t.Fatalf("SingletonRecord = %v, %v", rec, err)
}
}

View File

@@ -1,6 +1,7 @@
package party
import (
"context"
"fmt"
"io/fs"
"os"
@@ -15,6 +16,7 @@ import (
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/postcard"
"git.golem15.com/golem15/summercms/modules/signpost"
)
type tracePlugin struct {
@@ -346,7 +348,6 @@ func TestActivatePublishesActivatedOrderAndRefusesSecond(t *testing.T) {
}
}
func TestRegisterNilIsNoop(t *testing.T) {
Register(nil)
}
@@ -527,12 +528,12 @@ type mailPlugin struct {
sawMailer *bool
}
func (p *mailPlugin) ID() string { return p.id }
func (p *mailPlugin) Requires() []string { return nil }
func (p *mailPlugin) Register(*backpack.App) error { return nil }
func (p *mailPlugin) MailTemplatesFS() fs.FS { return p.fsys }
func (p *mailPlugin) MailTemplates() []string { return p.templates }
func (p *mailPlugin) MailLayouts() map[string]string { return p.layouts }
func (p *mailPlugin) ID() string { return p.id }
func (p *mailPlugin) Requires() []string { return nil }
func (p *mailPlugin) Register(*backpack.App) error { return nil }
func (p *mailPlugin) MailTemplatesFS() fs.FS { return p.fsys }
func (p *mailPlugin) MailTemplates() []string { return p.templates }
func (p *mailPlugin) MailLayouts() map[string]string { return p.layouts }
func (p *mailPlugin) Boot(app *backpack.App) error {
if p.sawMailer == nil {
@@ -551,7 +552,7 @@ func TestActivatePublishesMailerBeforeBoot(t *testing.T) {
templates: []string{"golem15.hello::mail.hello"},
layouts: map[string]string{"hello": "golem15.hello::mail.layouts.hello"},
fsys: fstest.MapFS{
"views/mail/hello.htm": {Data: []byte("subject = \"Hi\"\nlayout = \"hello\"\n==\nHi.\n")},
"views/mail/hello.htm": {Data: []byte("subject = \"Hi\"\nlayout = \"hello\"\n==\nHi.\n")},
"views/mail/layouts/hello.htm": {Data: []byte("name = \"Hello\"\n==\n{{ .Content }}\n==\n<div>{{ .Content }}</div>\n")},
},
}
@@ -580,3 +581,80 @@ func TestActivateMissingMailTemplateFailsBootWithName(t *testing.T) {
t.Fatalf("error %q should name the boot plugin and missing template", msg)
}
}
// menuTypesPlugin registers one menu item type and records whether the
// signpost registry was published at Register and at Boot.
type menuTypesPlugin struct {
id string
key string
log *[]string
atRegister *bool
atBoot *bool
typesAtBoot *int
}
func (p *menuTypesPlugin) ID() string { return p.id }
func (p *menuTypesPlugin) Requires() []string { return nil }
func (p *menuTypesPlugin) MenuItemTypes() []pact.MenuItemType {
*p.log = append(*p.log, p.id+":types")
return []pact.MenuItemType{{Key: p.key, DefaultPattern: "/:slug", Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
return &pact.ResolvedMenuItem{}, nil
}}}
}
func (p *menuTypesPlugin) Register(app *backpack.App) error {
*p.log = append(*p.log, p.id+":register")
_, ok := app.Lookup[*signpost.Registry]()
*p.atRegister = ok
return nil
}
func (p *menuTypesPlugin) Boot(app *backpack.App) error {
*p.log = append(*p.log, p.id+":boot")
reg, ok := app.Lookup[*signpost.Registry]()
*p.atBoot = ok
if ok {
*p.typesAtBoot = len(reg.Types())
}
return nil
}
// TestActivateMenuItemTypesOrder pins the party hook: menu item types are
// collected after every Register and before the first Boot, so a Boot sees
// the full registry; a duplicate key fails activation naming both plugins and
// no plugin boots.
func TestActivateMenuItemTypesOrder(t *testing.T) {
var log []string
var reg1, boot1, reg2, boot2 bool
var n1, n2 int
a := &menuTypesPlugin{id: "acme.a", key: "acme-a", log: &log, atRegister: &reg1, atBoot: &boot1, typesAtBoot: &n1}
b := &menuTypesPlugin{id: "acme.b", key: "acme-b", log: &log, atRegister: &reg2, atBoot: &boot2, typesAtBoot: &n2}
app := backpack.New(&compass.Config{})
if _, err := activate([]Plugin{a, b}, app, []string{"acme.a", "acme.b"}); err != nil {
t.Fatalf("activate: %v", err)
}
if reg1 || reg2 {
t.Fatal("the registry existed during Register")
}
if !boot1 || !boot2 || n1 != 2 || n2 != 2 {
t.Fatalf("Boot saw registry %v/%v with %d/%d types, want both and 2", boot1, boot2, n1, n2)
}
want := "acme.a:register,acme.b:register,acme.a:types,acme.b:types,acme.a:boot,acme.b:boot"
if got := strings.Join(log, ","); got != want {
t.Fatalf("order = %s\nwant %s", got, want)
}
log = nil
dupA := &menuTypesPlugin{id: "acme.a", key: "acme-same", log: &log, atRegister: &reg1, atBoot: &boot1, typesAtBoot: &n1}
dupB := &menuTypesPlugin{id: "acme.b", key: "acme-same", log: &log, atRegister: &reg2, atBoot: &boot2, typesAtBoot: &n2}
_, err := activate([]Plugin{dupA, dupB}, backpack.New(&compass.Config{}), []string{"acme.a", "acme.b"})
if err == nil || !strings.Contains(err.Error(), "party: menu item types") || !strings.Contains(err.Error(), "acme.a") || !strings.Contains(err.Error(), "acme.b") {
t.Fatalf("duplicate error = %v", err)
}
for _, entry := range log {
if strings.HasSuffix(entry, ":boot") {
t.Fatalf("a plugin booted after the duplicate: %v", log)
}
}
}

View File

@@ -1,6 +1,8 @@
package seaglass_test
import (
"slices"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/seaglass"
@@ -48,3 +50,161 @@ func TestSanitizeByteStable(t *testing.T) {
}
}
}
// TestSanitizeReport pins the removal report: element, attribute and comment
// names and counts, the ten-name cap with its exact counts, a repeated
// attribute, a style that lost a property, and the sanitized output itself.
func TestSanitizeReport(t *testing.T) {
for _, tc := range []struct {
name string
in, out string
elements []string
elemCount int
attributes []string
attrCount int
comments int
}{
{
name: "element, attribute and comments",
in: `<p onclick="a()" class="x">Hi</p><script>x</script><!-- c --><!-- d -->`,
out: `<p class="x">Hi</p>`, elements: []string{"script"}, elemCount: 1, attributes: []string{"onclick"}, attrCount: 1, comments: 2,
},
{
name: "names are distinct in first-seen order, counts are exact",
in: `<p onclick="a()">1</p><p onclick="b()" onmouseover="c()">2</p><script>1</script><script>2</script>`,
out: `<p>1</p><p>2</p>`, elements: []string{"script"}, elemCount: 2, attributes: []string{"onclick", "onmouseover"}, attrCount: 3,
},
{
name: "ten names then an ellipsis, the count stays exact",
in: `<iframe></iframe><object></object><embed><form></form><input><base><meta><link><svg></svg><math></math><button></button><select></select>`,
out: "", elements: []string{"iframe", "object", "embed", "form", "input", "base", "meta", "link", "svg", "math", "…"}, elemCount: 12,
},
{
name: "a repeated attribute counts as removed",
in: `<a href="/a" href="/b">x</a>`, out: `<a href="/a">x</a>`, attributes: []string{"href"}, attrCount: 1,
},
{
name: "a style that lost a property counts under style",
in: `<p style="color: red; position: fixed">x</p>`, out: `<p style="color: red">x</p>`, attributes: []string{"style"}, attrCount: 1,
},
{
name: "target and rel are removed",
in: `<a href="https://ok" target="_blank" rel="opener">x</a>`, out: `<a href="https://ok">x</a>`, attributes: []string{"target", "rel"}, attrCount: 2,
},
{
name: "a link without an allowed href loses its tag, not its text",
in: `<p><a href="javascript:alert(1)">x</a> y</p>`, out: `<p>x y</p>`, elements: []string{"a"}, elemCount: 1,
},
{
name: "only comments",
in: `<p>a</p><!-- one -->`, out: `<p>a</p>`, comments: 1,
},
} {
t.Run(tc.name, func(t *testing.T) {
got, r := seaglass.SanitizeReport(tc.in)
if got != tc.out {
t.Fatalf("output = %q, want %q", got, tc.out)
}
if !r.Changed {
t.Fatal("Changed = false")
}
if !slices.Equal(r.Elements, tc.elements) || r.ElementCount != tc.elemCount {
t.Fatalf("elements = %v (%d), want %v (%d)", r.Elements, r.ElementCount, tc.elements, tc.elemCount)
}
if !slices.Equal(r.Attributes, tc.attributes) || r.AttributeCount != tc.attrCount {
t.Fatalf("attributes = %v (%d), want %v (%d)", r.Attributes, r.AttributeCount, tc.attributes, tc.attrCount)
}
if r.Comments != tc.comments {
t.Fatalf("comments = %d, want %d", r.Comments, tc.comments)
}
if seaglass.Sanitize(tc.in) != tc.out {
t.Fatal("Sanitize differs from SanitizeReport")
}
// The sanitized output is itself clean and stable.
again, r2 := seaglass.SanitizeReport(got)
if again != got || r2.Changed {
t.Fatalf("sanitizing the output changed it: %q %+v", again, r2)
}
})
}
}
// TestSanitizeBypassCorpus runs the payloads the bluemonday decision notes
// list as bypasses of the regex gate it replaced (no space before an event
// handler, entity-encoded and whitespace-split javascript schemes, svg
// onload, MathML xlink:href, base, meta refresh, form, data and vbscript
// URLs, a repeated href) plus the ordinary unsafe elements: each comes back
// without the dangerous construct, with Changed set, and Check refuses it.
func TestSanitizeBypassCorpus(t *testing.T) {
for _, tc := range []struct {
payload string
banned []string
}{
{`<img src="x"onerror="alert(1)">`, []string{"onerror", "alert"}},
{`<img src=x onerror=alert(1)>`, []string{"onerror", "alert"}},
{`<p onmouseover=alert(1)>x</p>`, []string{"onmouseover", "alert"}},
{`<svg/onload=alert(1)>`, []string{"<svg", "onload", "alert"}},
{`<a href="javascript&#58;alert(1)">x</a>`, []string{"href", "javascript", "alert"}},
{`<a href="javascript&colon;alert(1)">x</a>`, []string{"href", "javascript", "alert"}},
{`<a href="&#106;avascript:alert(1)">x</a>`, []string{"href", "avascript", "alert"}},
{"<a href=\"java\tscript:alert(1)\">x</a>", []string{"href", "script", "alert"}},
{`<a HREF="JaVaScRiPt:alert(1)">x</a>`, []string{"href", "javascript", "alert"}},
{`<math><mi xlink:href="javascript:alert(1)">x</mi></math>`, []string{"<math", "<mi", "xlink", "javascript"}},
{`<base href="https://evil.example/">`, []string{"<base", "evil.example"}},
{`<meta http-equiv="refresh" content="0;url=https://evil.example">`, []string{"<meta", "refresh", "evil.example"}},
{`<form action="https://evil.example"><input name="q"></form>`, []string{"<form", "<input", "evil.example"}},
{`<a href="data:text/html;base64,PHNjcmlwdD4=">x</a>`, []string{"href", "data:"}},
{`<img src="data:image/svg+xml;base64,PHN2Zz4=">`, []string{"<img", "data:"}},
{`<a href="vbscript:msgbox(1)">x</a>`, []string{"href", "vbscript"}},
{`<a href="https://a" href="javascript:alert(1)">x</a>`, []string{"javascript", "alert"}},
{`<div style="background:url(javascript:alert(1))">x</div>`, []string{"style", "javascript"}},
{`<iframe src="https://evil.example"></iframe>`, []string{"<iframe", "evil.example"}},
{`<p>x</p><script>alert(1)</script>`, []string{"<script", "alert"}},
{`<p>x</p><style>p{display:none}</style>`, []string{"<style", "display"}},
{`<object data="x.swf"></object><embed src="x.swf">`, []string{"<object", "<embed", "swf"}},
} {
t.Run(tc.payload, func(t *testing.T) {
got, r := seaglass.SanitizeReport(tc.payload)
lower := strings.ToLower(got)
for _, bad := range tc.banned {
if strings.Contains(lower, strings.ToLower(bad)) {
t.Fatalf("%q survived: %q", bad, got)
}
}
if !r.Changed {
t.Fatalf("Changed = false for %q -> %q", tc.payload, got)
}
if err := seaglass.Check(tc.payload); err == nil {
t.Fatal("Check accepted the payload")
}
})
}
}
// TestCheck pins Check: nil when the policy keeps the HTML whole (including
// text that only looks like a scheme, the WR-01 false refusals), an error
// prefixed "seaglass: " when it would change anything.
func TestCheck(t *testing.T) {
for _, html := range []string{
"",
"<p>Data: 12 maja 2024, metadata: none</p>",
"<p>javascript: is just a word here</p>",
`<p>It's "fine"</p>`,
`<a href="tel:+48123">call</a>`,
} {
if err := seaglass.Check(html); err != nil {
t.Errorf("Check(%q) = %v", html, err)
}
}
for _, html := range []string{
"<!-- a comment -->",
`<a href="https://a" href="https://b">dup</a>`,
`<p style="position: fixed">x</p>`,
`<a href="ftp://example.com/x">ftp</a>`,
} {
err := seaglass.Check(html)
if err == nil || !strings.HasPrefix(err.Error(), "seaglass: ") {
t.Errorf("Check(%q) = %v, want a seaglass error", html, err)
}
}
}

View File

@@ -1,6 +1,9 @@
package signpost_test
import (
"context"
"errors"
"fmt"
"strings"
"testing"
@@ -44,3 +47,290 @@ func TestRegistryDuplicateKey(t *testing.T) {
t.Fatalf("duplicate inside one plugin: error = %v", err)
}
}
// TestRegistryActivate pins every boot refusal of Activate and what a
// successful activation publishes.
func TestRegistryActivate(t *testing.T) {
ok := func(key, pattern string) pact.MenuItemType {
return pact.MenuItemType{Key: key, DefaultPattern: pattern, Resolve: echo}
}
for _, tc := range []struct {
name string
host string
types []pact.MenuItemType
want []string
}{
{"empty key", "", []pact.MenuItemType{ok("", "/a")}, []string{"acme.blog", "empty key"}},
{"blank key", "", []pact.MenuItemType{ok(" ", "/a")}, []string{"empty key"}},
{"nil Resolve", "", []pact.MenuItemType{{Key: "acme-a", DefaultPattern: "/a"}}, []string{`"acme-a"`, "acme.blog", "no Resolve"}},
{"empty DefaultPattern", "", []pact.MenuItemType{ok("acme-a", "")}, []string{`"acme-a"`, "DefaultPattern"}},
{"relative DefaultPattern", "", []pact.MenuItemType{ok("acme-a", "blog/:slug")}, []string{`"acme-a"`, `"blog/:slug"`}},
{"PatternFrom names an unregistered type", "", []pact.MenuItemType{
{Key: "acme-posts", DefaultPattern: "/a", PatternFrom: "acme-missing", Resolve: echo},
}, []string{`"acme-posts"`, `"acme-missing"`, "no plugin registers"}},
{"PatternFrom names the type itself", "", []pact.MenuItemType{
{Key: "acme-posts", DefaultPattern: "/a", PatternFrom: "acme-posts", Resolve: echo},
}, []string{`"acme-posts"`, "names itself"}},
{"PatternFrom names a type with its own PatternFrom", "", []pact.MenuItemType{
ok("acme-post", "/a"),
{Key: "acme-posts", DefaultPattern: "/a", PatternFrom: "acme-post", Resolve: echo},
{Key: "acme-all", DefaultPattern: "/a", PatternFrom: "acme-posts", Resolve: echo},
}, []string{`"acme-all"`, `"acme-posts"`, "one level only"}},
{"host override of an unknown type", "patterns:\n acme-missing: /x\n", []pact.MenuItemType{ok("acme-post", "/a")}, []string{"menu_items.patterns", `"acme-missing"`}},
{"relative host override", "patterns:\n acme-post: news/:slug\n", []pact.MenuItemType{ok("acme-post", "/a")}, []string{"menu_items.patterns.acme-post", `"news/:slug"`}},
{"host patterns not a map", "patterns:\n - /x\n", []pact.MenuItemType{ok("acme-post", "/a")}, []string{"menu_items.patterns", "map"}},
} {
t.Run(tc.name, func(t *testing.T) {
app := smokeApp(t, tc.host)
err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: tc.types}})
if err == nil {
t.Fatal("Activate accepted the types")
}
if !strings.HasPrefix(err.Error(), "signpost: ") {
t.Fatalf("error %q lacks the signpost prefix", err)
}
for _, part := range tc.want {
if !strings.Contains(err.Error(), part) {
t.Fatalf("error %q does not contain %q", err, part)
}
}
if _, published := app.Lookup[*signpost.Registry](); published {
t.Fatal("a registry was published after a refusal")
}
})
}
t.Run("nil app", func(t *testing.T) {
if err := signpost.Activate[typesPlugin](nil, nil); err == nil || !strings.HasPrefix(err.Error(), "signpost: ") {
t.Fatalf("nil app error = %v", err)
}
})
t.Run("publishes types in plugin then declaration order", func(t *testing.T) {
app := smokeApp(t, "")
err := signpost.Activate(app, []typesPlugin{
{id: "acme.blog", types: []pact.MenuItemType{ok("acme-b", "/b"), ok("acme-a", "/a")}},
{id: "acme.none"},
{id: "acme.pages", types: []pact.MenuItemType{ok("acme-c", "/c")}},
})
if err != nil {
t.Fatal(err)
}
reg, found := app.Lookup[*signpost.Registry]()
if !found {
t.Fatal("no registry published")
}
var keys []string
for _, typ := range reg.Types() {
keys = append(keys, typ.Key)
}
if strings.Join(keys, ",") != "acme-b,acme-a,acme-c" {
t.Fatalf("types = %v", keys)
}
types := reg.Types()
types[0].Key = "changed"
if again := reg.Types(); again[0].Key != "acme-b" {
t.Fatal("Types returned the registry's own slice")
}
if typ, ok := reg.Type("acme-c"); !ok || typ.DefaultPattern != "/c" {
t.Fatalf("Type(acme-c) = %+v, %v", typ, ok)
}
if _, ok := reg.Type("acme-missing"); ok {
t.Fatal("Type found an unregistered key")
}
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: []pact.MenuItemType{ok("acme-x", "/x")}}}); err == nil {
t.Fatal("a second Activate on the same app was accepted")
}
})
t.Run("a plugin without menu item types publishes an empty registry", func(t *testing.T) {
app := smokeApp(t, "")
if err := signpost.Activate(app, []typesPlugin{{id: "acme.none"}}); err != nil {
t.Fatal(err)
}
reg, found := app.Lookup[*signpost.Registry]()
if !found || len(reg.Types()) != 0 {
t.Fatalf("registry = %v, %v", reg, found)
}
})
t.Run("a nil registry answers empty", func(t *testing.T) {
var reg *signpost.Registry
if len(reg.Types()) != 0 || reg.Pattern("acme-post", "") != "" || reg.Pattern("acme-post", "/x") != "/x" {
t.Fatal("nil registry answered")
}
if _, ok := reg.Type("acme-post"); ok {
t.Fatal("nil registry found a type")
}
if _, err := reg.Resolve(context.Background(), "acme-post", "", "", false, ""); err == nil {
t.Fatal("nil registry resolved")
}
})
}
// TestRegistryHostOverrideUnknown pins the host override checks: an override
// of an unregistered key fails the boot with the key named, while an absent
// menu_items file or an empty patterns map is no override at all.
func TestRegistryHostOverrideUnknown(t *testing.T) {
err := signpost.Activate(smokeApp(t, "patterns:\n acme-post: /news/:slug\n acme-typo: /x/:slug\n"), []typesPlugin{{id: "acme.blog", types: acmeTypes()}})
if err == nil || !strings.Contains(err.Error(), `"acme-typo"`) || !strings.Contains(err.Error(), "no plugin registers") {
t.Fatalf("unknown override error = %v", err)
}
for name, cfg := range map[string]string{"no file": "", "empty patterns": "patterns: {}\n", "other key only": "other: 1\n"} {
app := smokeApp(t, cfg)
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: acmeTypes()}}); err != nil {
t.Fatalf("%s: %v", name, err)
}
reg, _ := app.Lookup[*signpost.Registry]()
if got := reg.Pattern("acme-post", ""); got != "/blog/:slug" {
t.Fatalf("%s: Pattern = %q, want the default", name, got)
}
}
}
// TestRegistryPatternLayers pins D-02 over the record type acme-post and the
// list type acme-posts (PatternFrom acme-post): for the record type, the item
// override beats the host override, which beats the default; for the list
// type, the item override beats the list key's host override, which beats the
// record type's host override, which beats the list's DefaultPattern.
func TestRegistryPatternLayers(t *testing.T) {
types := func() []pact.MenuItemType {
return []pact.MenuItemType{
{Key: "acme-post", DefaultPattern: "/post-default/:slug", Resolve: echo},
{Key: "acme-posts", DefaultPattern: "/posts-default/:slug", PatternFrom: "acme-post", Resolve: echo},
}
}
const (
item = "/item/:slug"
listHost = "/list-host/:slug"
recHost = "/record-host/:slug"
)
for _, tc := range []struct {
item, listHost, recHost string
wantList, wantRecord string
}{
{"", "", "", "/posts-default/:slug", "/post-default/:slug"},
{"", "", recHost, recHost, recHost},
{"", listHost, "", listHost, "/post-default/:slug"},
{"", listHost, recHost, listHost, recHost},
{item, "", "", item, item},
{item, "", recHost, item, item},
{item, listHost, "", item, item},
{item, listHost, recHost, item, item},
{" ", "", recHost, recHost, recHost},
} {
name := fmt.Sprintf("item=%q list=%q record=%q", tc.item, tc.listHost, tc.recHost)
t.Run(name, func(t *testing.T) {
cfg := ""
if tc.listHost != "" || tc.recHost != "" {
cfg = "patterns:\n"
if tc.listHost != "" {
cfg += " acme-posts: " + tc.listHost + "\n"
}
if tc.recHost != "" {
cfg += " acme-post: " + tc.recHost + "\n"
}
}
app := smokeApp(t, cfg)
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: types()}}); err != nil {
t.Fatal(err)
}
reg, _ := app.Lookup[*signpost.Registry]()
if got := reg.Pattern("acme-posts", tc.item); got != tc.wantList {
t.Fatalf("list Pattern = %q, want %q", got, tc.wantList)
}
if got := reg.Pattern("acme-post", tc.item); got != tc.wantRecord {
t.Fatalf("record Pattern = %q, want %q", got, tc.wantRecord)
}
// Resolve hands the type exactly the layered pattern.
resolved, err := reg.Resolve(context.Background(), "acme-posts", "ref-1", tc.item, true, "pl")
if err != nil || resolved.URL != tc.wantList {
t.Fatalf("Resolve = %+v, %v", resolved, err)
}
})
}
t.Run("Resolve passes reference, nesting and locale", func(t *testing.T) {
var got pact.MenuItemResolve
capture := func(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
got = in
return &pact.ResolvedMenuItem{URL: in.Pattern}, nil
}
app := smokeApp(t, "patterns:\n acme-post: /news/:slug\n")
err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: []pact.MenuItemType{
{Key: "acme-post", DefaultPattern: "/blog/:slug", Resolve: capture},
}}})
if err != nil {
t.Fatal(err)
}
reg, _ := app.Lookup[*signpost.Registry]()
var noCtx context.Context // a nil context is accepted and replaced
if _, err := reg.Resolve(noCtx, "acme-post", "7", "", true, "pl"); err != nil {
t.Fatal(err)
}
want := pact.MenuItemResolve{Type: "acme-post", Reference: "7", Pattern: "/news/:slug", Nesting: true, UnprefixedLocale: "pl"}
if got != want {
t.Fatalf("Resolve input = %+v, want %+v", got, want)
}
if _, err := reg.Resolve(context.Background(), "acme-missing", "", "", false, ""); err == nil || !strings.Contains(err.Error(), `"acme-missing"`) {
t.Fatalf("unknown type error = %v", err)
}
})
}
// TestReferenceOptions pins the one admin binding: a reference tree is
// flattened depth first with Depth the nesting level, a type without
// References or an unknown type answers Applies false with no options, and a
// References error is returned with the type named.
func TestReferenceOptions(t *testing.T) {
tree := []pact.MenuItemReference{
{Value: "1", Label: "Root", Items: []pact.MenuItemReference{
{Value: "2", Label: "Child", Items: []pact.MenuItemReference{{Value: "3", Label: "Grandchild"}}},
{Value: "4", Label: "Second child"},
}},
{Value: "5", Label: "acme.blog::lang.other"},
}
app := smokeApp(t, "")
err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: []pact.MenuItemType{
{Key: "acme-tree", DefaultPattern: "/:slug", Resolve: echo, References: func(context.Context) ([]pact.MenuItemReference, error) { return tree, nil }},
{Key: "acme-empty", DefaultPattern: "/:slug", Resolve: echo, References: func(context.Context) ([]pact.MenuItemReference, error) { return nil, nil }},
{Key: "acme-broken", DefaultPattern: "/:slug", Resolve: echo, References: func(context.Context) ([]pact.MenuItemReference, error) { return nil, errors.New("db down") }},
{Key: "acme-none", DefaultPattern: "/:slug", Resolve: echo},
}}})
if err != nil {
t.Fatal(err)
}
reg, _ := app.Lookup[*signpost.Registry]()
set, err := signpost.ReferenceOptions(context.Background(), reg, "acme-tree")
if err != nil || !set.Applies {
t.Fatalf("tree = %+v, %v", set, err)
}
want := []pact.Option{
{Value: "1", Label: "Root", Depth: 0},
{Value: "2", Label: "Child", Depth: 1},
{Value: "3", Label: "Grandchild", Depth: 2},
{Value: "4", Label: "Second child", Depth: 1},
{Value: "5", Label: "acme.blog::lang.other", Depth: 0},
}
if fmt.Sprint(set.Options) != fmt.Sprint(want) {
t.Fatalf("options = %+v, want %+v", set.Options, want)
}
empty, err := signpost.ReferenceOptions(context.Background(), reg, "acme-empty")
if err != nil || !empty.Applies || empty.Options == nil || len(empty.Options) != 0 {
t.Fatalf("type with no references yet = %+v, %v (applies, empty non-nil options)", empty, err)
}
for _, key := range []string{"acme-none", "acme-missing", ""} {
none, err := signpost.ReferenceOptions(context.Background(), reg, key)
if err != nil || none.Applies || none.Options == nil || len(none.Options) != 0 {
t.Fatalf("%q = %+v, %v (applies false, empty non-nil options)", key, none, err)
}
}
if none, err := signpost.ReferenceOptions(context.Background(), nil, "acme-tree"); err != nil || none.Applies {
t.Fatalf("nil registry = %+v, %v", none, err)
}
if _, err := signpost.ReferenceOptions(context.Background(), reg, "acme-broken"); err == nil || !strings.Contains(err.Error(), `"acme-broken"`) || !strings.Contains(err.Error(), "db down") {
t.Fatalf("references error = %v", err)
}
}