- 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
137 lines
6.5 KiB
Go
137 lines
6.5 KiB
Go
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{} }
|