test(17-06): menu plugin unit tests
- TestAllowedURL and TestMenuTree (Docker-free): the URL scheme and target rules, BuildTree order, unreachable and looping entries, list and single registry types, generated children, locale alternates, left-out entries logged, the JSON shape - TestMenuEntryModel (Docker-free): fillable, rules, filter scope - TestMenuEntryBeforeSave, TestMenuAPI and TestMenuImport (Postgres): PHP beforeSave rules, D-16 API rules beyond the smoke test, import drops for page subtrees, foreign menus, missing parents, maps to nothing and unregistered types
This commit is contained in:
34
classes/links_test.go
Normal file
34
classes/links_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package classes
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestAllowedURL pins the D-16 URL scheme rule shared by the admin save and
|
||||
// the public API: empty, a path, a fragment, or http, https, mailto and tel
|
||||
// in any case; never javascript:, data:, vbscript: or another scheme, and
|
||||
// never a URL with surrounding whitespace or control characters, which
|
||||
// browsers strip before reading the scheme.
|
||||
func TestAllowedURL(t *testing.T) {
|
||||
for _, url := range []string{
|
||||
"", "/", "/blog/post?x=1#a", "#top", "//cdn.example.test/a",
|
||||
"http://example.test", "https://example.test/a", "HTTPS://EXAMPLE.TEST", "Http://x",
|
||||
"mailto:a@example.test", "MAILTO:a@example.test", "tel:+48123456", "TEL:123",
|
||||
} {
|
||||
if !AllowedURL(url) {
|
||||
t.Errorf("AllowedURL(%q) = false", url)
|
||||
}
|
||||
}
|
||||
for _, url := range []string{
|
||||
"javascript:alert(1)", "JavaScript:alert(1)", "data:text/html,x", "vbscript:x", "ftp://x", "file:///etc/passwd",
|
||||
"blog", "example.test", "?q=1", "./a", "../a", "mailto", "https:x", "tel",
|
||||
" /a", "/a ", "\thttps://x", "https://x\n", "java\tscript:x", "/a\x00b", "/a\x7f", "\x01javascript:x",
|
||||
} {
|
||||
if AllowedURL(url) {
|
||||
t.Errorf("AllowedURL(%q) = true", url)
|
||||
}
|
||||
}
|
||||
for target, want := range map[string]bool{"": true, TargetSelf: true, TargetBlank: true, "_top": false, "_parent": false, "_BLANK": false, "frame": false} {
|
||||
if AllowedTarget(target) != want {
|
||||
t.Errorf("AllowedTarget(%q) != %v", target, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
214
classes/tree_test.go
Normal file
214
classes/tree_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-menu-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// fakeRegistry resolves menu item types from a table: references keyed by
|
||||
// type and reference; an absent reference resolves to nothing; "boom"
|
||||
// fails.
|
||||
type fakeRegistry struct {
|
||||
types map[string]pact.MenuItemType
|
||||
resolved map[string]map[string]*pact.ResolvedMenuItem
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) Type(key string) (pact.MenuItemType, bool) {
|
||||
t, ok := f.types[key]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) Resolve(_ context.Context, key, reference, pattern string, nesting bool, unprefixed string) (*pact.ResolvedMenuItem, error) {
|
||||
nest := "flat"
|
||||
if nesting {
|
||||
nest = "nested"
|
||||
}
|
||||
f.calls = append(f.calls, key+"|"+reference+"|"+pattern+"|"+nest+"|"+unprefixed)
|
||||
if reference == "boom" {
|
||||
return nil, errors.New("resolve failed")
|
||||
}
|
||||
return f.resolved[key][reference], nil
|
||||
}
|
||||
|
||||
func uintp(v uint) *uint { return &v }
|
||||
func strp(s string) *string { return &s }
|
||||
func boolp(b bool) *bool { return &b }
|
||||
func intp(i int) *int { return &i }
|
||||
func entry(id uint, parent uint, typ, title string) models.MenuEntry {
|
||||
e := models.MenuEntry{ID: id, MenuID: 1, Type: typ, Title: strp(title), IsPublished: boolp(true), SortOrder: intp(int(id))}
|
||||
if parent != 0 {
|
||||
e.ParentID = uintp(parent)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func registry() *fakeRegistry {
|
||||
alt := func(locale, url, title string) pact.MenuItemAlternate {
|
||||
return pact.MenuItemAlternate{Locale: locale, URL: url, Title: title}
|
||||
}
|
||||
return &fakeRegistry{
|
||||
types: map[string]pact.MenuItemType{
|
||||
"acme-page": {Key: "acme-page"},
|
||||
"acme-list": {Key: "acme-list", Nesting: true},
|
||||
},
|
||||
resolved: map[string]map[string]*pact.ResolvedMenuItem{
|
||||
"acme-page": {
|
||||
"1": {Title: "home", URL: "/home", Alternates: []pact.MenuItemAlternate{alt("pl", "/home", "dom"), alt("en", "/en/home", "")}},
|
||||
"bad": {Title: "bad", URL: "javascript:alert(1)"},
|
||||
},
|
||||
"acme-list": {
|
||||
"": {Items: []pact.ResolvedMenuItem{
|
||||
{Title: "a", URL: "/a", Alternates: []pact.MenuItemAlternate{alt("en", "/en/a", "a (en)")}, Items: []pact.ResolvedMenuItem{{Title: "a1", URL: "data:text/html,x"}}},
|
||||
{Title: "b", URL: "/b"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func shape(items []Item) string {
|
||||
var parts []string
|
||||
for _, it := range items {
|
||||
s := it.Title
|
||||
if it.URL != nil {
|
||||
s += "=" + *it.URL
|
||||
} else {
|
||||
s += "=<nil>"
|
||||
}
|
||||
if it.ID == nil {
|
||||
s += "*"
|
||||
}
|
||||
if len(it.Items) > 0 {
|
||||
s += "(" + shape(it.Items) + ")"
|
||||
}
|
||||
parts = append(parts, s)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// TestMenuTree pins classes.BuildTree without a database (D-16): nesting in
|
||||
// the given order, unreachable entries and cycles left out, the URL scheme
|
||||
// and target filters, built-in and registry types, list types keeping their
|
||||
// stored title and URL with generated children after the stored ones, the
|
||||
// locale's alternate with fallbacks, entries that do not resolve left out
|
||||
// and logged, and the JSON shape (nulls, never-nil items).
|
||||
func TestMenuTree(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&logs, nil))
|
||||
|
||||
page := entry(4, 0, "acme-page", "")
|
||||
page.Reference = strp("1")
|
||||
titled := entry(5, 0, "acme-page", "Start")
|
||||
titled.Reference = strp("1")
|
||||
titled.URLPattern = strp("/p/:slug")
|
||||
list := entry(6, 0, "acme-list", "Pages")
|
||||
list.URL = strp("/pages")
|
||||
listBad := entry(7, 0, "acme-list", "Bad list")
|
||||
listBad.URL = strp("javascript:x")
|
||||
link := entry(1, 0, "url", "Home")
|
||||
link.URL = strp("/")
|
||||
link.Target = strp("_blank")
|
||||
link.CSSClass = strp("")
|
||||
link.Icon = strp("icon-home")
|
||||
link.Code = strp("home")
|
||||
unsafe := entry(2, 0, "url", "Unsafe")
|
||||
unsafe.URL = strp(" javascript:alert(1)")
|
||||
unsafe.Target = strp("_top")
|
||||
divider := entry(3, 0, "divider", "")
|
||||
divider.URL = strp("/ignored")
|
||||
child := entry(8, 6, "url", "Child")
|
||||
child.URL = strp("https://example.test/x")
|
||||
grandchild := entry(9, 8, "url", "Grandchild")
|
||||
grandchild.URL = strp("mailto:a@example.test")
|
||||
gone := entry(10, 0, "acme-page", "Gone")
|
||||
gone.Reference = strp("99")
|
||||
boom := entry(11, 0, "acme-page", "Boom")
|
||||
boom.Reference = strp("boom")
|
||||
unregistered := entry(12, 0, "gone-type", "Unregistered")
|
||||
underGone := entry(13, 10, "url", "Under gone")
|
||||
orphan := entry(14, 99, "url", "Orphan")
|
||||
badResolved := entry(15, 0, "acme-page", "")
|
||||
badResolved.Reference = strp("bad")
|
||||
loopA := entry(16, 17, "url", "Loop A")
|
||||
loopB := entry(17, 16, "url", "Loop B")
|
||||
|
||||
entries := []models.MenuEntry{link, unsafe, divider, page, titled, list, listBad, child, grandchild, gone, boom, unregistered, underGone, orphan, badResolved, loopA, loopB}
|
||||
reg := registry()
|
||||
items := BuildTree(context.Background(), entries, TreeOptions{Registry: reg, Logger: logger})
|
||||
want := "Home=/ Unsafe=<nil> =<nil> home=/home Start=/home Pages=/pages(Child=https://example.test/x(Grandchild=mailto:a@example.test) a=/a*(a1=<nil>*) b=/b*) Bad list=<nil>(a=/a*(a1=<nil>*) b=/b*) bad=<nil>"
|
||||
if got := shape(items); got != want {
|
||||
t.Fatalf("tree:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
if it := items[0]; *it.Target != "_blank" || it.CSSClass != nil || *it.Icon != "icon-home" || *it.Code != "home" || *it.ID != 1 || it.Type != "url" {
|
||||
t.Fatalf("link = %+v", it)
|
||||
}
|
||||
if it := items[1]; it.Target != nil {
|
||||
t.Fatalf("unknown target kept: %+v", it)
|
||||
}
|
||||
if it := items[2]; it.Type != "divider" || it.URL != nil || it.Items == nil {
|
||||
t.Fatalf("divider = %+v", it)
|
||||
}
|
||||
gen := items[5].Items[1]
|
||||
if gen.Type != "acme-list" || gen.ID != nil || gen.Target != nil || gen.CSSClass != nil || gen.Icon != nil || gen.Code != nil || gen.Items[0].Type != "acme-list" {
|
||||
t.Fatalf("generated child = %+v", gen)
|
||||
}
|
||||
// Registry entries are resolved with their reference, url_pattern and
|
||||
// their type's Nesting, with no unprefixed locale.
|
||||
for _, call := range []string{"acme-page|1||flat|", "acme-page|1|/p/:slug|flat|", "acme-list|||nested|"} {
|
||||
if !strings.Contains(strings.Join(reg.calls, "\n"), call) {
|
||||
t.Fatalf("calls %v lack %s", reg.calls, call)
|
||||
}
|
||||
}
|
||||
for _, logged := range []string{"entry=10", "entry=11", "entry=12", "reference does not resolve", "resolve failed", "not registered"} {
|
||||
if !strings.Contains(logs.String(), logged) {
|
||||
t.Fatalf("log lacks %q:\n%s", logged, logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The locale picks the alternate; an empty alternate title keeps the
|
||||
// resolved one, a missing locale keeps both.
|
||||
en := BuildTree(context.Background(), []models.MenuEntry{page, titled, list}, TreeOptions{Registry: registry(), Locale: "en", Logger: logger})
|
||||
if got := shape(en); got != "home=/en/home Start=/en/home Pages=/pages(a (en)=/en/a*(a1=<nil>*) b=/b*)" {
|
||||
t.Fatalf("en tree = %s", got)
|
||||
}
|
||||
pl := BuildTree(context.Background(), []models.MenuEntry{page}, TreeOptions{Registry: registry(), Locale: "pl", Logger: logger})
|
||||
if got := shape(pl); got != "dom=/home" {
|
||||
t.Fatalf("pl tree = %s", got)
|
||||
}
|
||||
de := BuildTree(context.Background(), []models.MenuEntry{page}, TreeOptions{Registry: registry(), Locale: "de", Logger: logger})
|
||||
if got := shape(de); got != "home=/home" {
|
||||
t.Fatalf("de tree = %s", got)
|
||||
}
|
||||
|
||||
// Without a registry every registry entry is left out.
|
||||
plain := BuildTree(context.Background(), []models.MenuEntry{link, page, list, child}, TreeOptions{Logger: logger})
|
||||
if got := shape(plain); got != "Home=/" {
|
||||
t.Fatalf("no registry = %s", got)
|
||||
}
|
||||
|
||||
// The JSON shape: nulls and empty lists, never a missing key.
|
||||
raw, err := json.Marshal(BuildTree(context.Background(), []models.MenuEntry{divider}, TreeOptions{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != `[{"id":3,"title":"","type":"divider","url":null,"target":null,"css_class":null,"icon":null,"code":null,"items":[]}]` {
|
||||
t.Fatalf("json = %s", raw)
|
||||
}
|
||||
if empty := BuildTree(context.Background(), nil, TreeOptions{}); empty == nil || len(empty) != 0 {
|
||||
t.Fatalf("no entries = %#v", empty)
|
||||
}
|
||||
// A nil context is replaced.
|
||||
var noCtx context.Context
|
||||
if got := shape(BuildTree(noCtx, []models.MenuEntry{page}, TreeOptions{Registry: registry(), Logger: logger})); got != "home=/home" {
|
||||
t.Fatalf("nil context = %s", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user