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:
435
modules/cabana/companions_postgres_test.go
Normal file
435
modules/cabana/companions_postgres_test.go
Normal 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> & \"quoted\" —</p>\n<ul>\n <li>one</li>\n</ul>",
|
||||
`<p class='a b'><a title="t" href="https://example.com/?a=1&b=2">link</a></p>`,
|
||||
"",
|
||||
} {
|
||||
if _, notices := save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": clean}, http.StatusOK); len(notices) != 0 {
|
||||
t.Fatalf("clean %q reported %v", clean, notices)
|
||||
}
|
||||
if got := stored(t, id); got == nil || *got != clean {
|
||||
t.Fatalf("clean value not stored byte for byte: %v, want %q", got, clean)
|
||||
}
|
||||
}
|
||||
if _, notices := save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": nil}, http.StatusOK); len(notices) != 0 || stored(t, id) != nil {
|
||||
t.Fatalf("null description: %v %v", notices, stored(t, id))
|
||||
}
|
||||
keep := "<p>kept</p>"
|
||||
save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": keep}, http.StatusOK)
|
||||
save(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, http.StatusOK)
|
||||
if got := stored(t, id); got == nil || *got != keep {
|
||||
t.Fatalf("an update without the field changed it: %v", got)
|
||||
}
|
||||
before := companionsLinkRows(t, e)
|
||||
for _, bad := range []any{12, true, []any{"<p>x</p>"}, map[string]any{"pl": "<p>x</p>"}} {
|
||||
rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Bad", "description": bad}, true)
|
||||
wantStatus(t, fmt.Sprintf("description %v", bad), rec, http.StatusUnprocessableEntity, rec.Body.String())
|
||||
if _, details := companionsError(t, rec.Body.Bytes()); len(details["description"]) != 1 {
|
||||
t.Fatalf("details = %v", details)
|
||||
}
|
||||
sameLinkRows(t, e, before, "a non-string description")
|
||||
}
|
||||
}
|
||||
222
modules/cabana/dependent_test.go
Normal file
222
modules/cabana/dependent_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
265
modules/cabana/reorder_test.go
Normal file
265
modules/cabana/reorder_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
426
modules/cabana/repeater_test.go
Normal file
426
modules/cabana/repeater_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
136
modules/cabana/richeditor_test.go
Normal file
136
modules/cabana/richeditor_test.go
Normal 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> & \"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{} }
|
||||
113
modules/cabana/singleton_test.go
Normal file
113
modules/cabana/singleton_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
117
modules/pact/menu_item_types_test.go
Normal file
117
modules/pact/menu_item_types_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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: ®1, atBoot: &boot1, typesAtBoot: &n1}
|
||||
b := &menuTypesPlugin{id: "acme.b", key: "acme-b", log: &log, atRegister: ®2, 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: ®1, atBoot: &boot1, typesAtBoot: &n1}
|
||||
dupB := &menuTypesPlugin{id: "acme.b", key: "acme-same", log: &log, atRegister: ®2, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:alert(1)">x</a>`, []string{"href", "javascript", "alert"}},
|
||||
{`<a href="javascript:alert(1)">x</a>`, []string{"href", "javascript", "alert"}},
|
||||
{`<a href="javascript: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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user