The importer copied URLs, targets and cross-menu parents as exported, so a javascript: URL or a _parent target broke the first admin save of the row, and an entry under a parent in another menu vanished from the public tree. Kept entries now get a trimmed or cleared URL, a cleared target and a top-level place in their own menu, each with a report line.
326 lines
15 KiB
Go
326 lines
15 KiB
Go
package menu_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/sm-menu-plugin/console"
|
|
"git.golem15.com/golem15/sm-menu-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
)
|
|
|
|
// extraTypesPluginID registers the types the plan 06 API cases need beyond
|
|
// the smoke fixture: one that resolves to an unsafe URL and a list whose
|
|
// children nest and follow the item's url_pattern.
|
|
const extraTypesPluginID = "acme.menuextra"
|
|
|
|
type extraTypes struct{}
|
|
|
|
func (extraTypes) ID() string { return extraTypesPluginID }
|
|
func (extraTypes) Requires() []string { return nil }
|
|
func (extraTypes) Register(*backpack.App) error { return nil }
|
|
func (extraTypes) Boot(*backpack.App) error { return nil }
|
|
func (extraTypes) MenuItemTypes() []pact.MenuItemType {
|
|
return []pact.MenuItemType{
|
|
{Key: "acme-unsafe", Label: "Unsafe", DefaultPattern: "/u/:slug", Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
return &pact.ResolvedMenuItem{Title: "unsafe", URL: "javascript:alert(1)"}, nil
|
|
}},
|
|
{Key: "acme-tree", Label: "Tree", DefaultPattern: "/t/:slug", Nesting: true, Resolve: func(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
leaf := pact.ResolvedMenuItem{Title: "leaf", URL: strings.ReplaceAll(in.Pattern, ":slug", "leaf")}
|
|
bad := pact.ResolvedMenuItem{Title: "bad", URL: "data:text/html,x"}
|
|
return &pact.ResolvedMenuItem{Items: []pact.ResolvedMenuItem{{
|
|
Title: "branch", URL: strings.ReplaceAll(in.Pattern, ":slug", "branch"),
|
|
Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: "/en" + strings.ReplaceAll(in.Pattern, ":slug", "branch"), Title: ""}},
|
|
Items: []pact.ResolvedMenuItem{leaf, bad},
|
|
}}}, nil
|
|
}},
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
party.Register(extraTypes{})
|
|
}
|
|
|
|
// bootMenuWith boots the menu plugin with the smoke and the extra types.
|
|
func bootMenuWith(t *testing.T) *menuEnv {
|
|
t.Helper()
|
|
sqlDB := freshDB(t)
|
|
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := backpack.New(testConfig(t))
|
|
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plugins, err := party.Activate(app, []string{testTypesPluginID, extraTypesPluginID, "golem15.menu"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := surf.Assemble(app, plugins)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &menuEnv{h: h, app: app, gdb: gdb}
|
|
}
|
|
|
|
// TestMenuEntryBeforeSave pins the PHP beforeSave port on Postgres: unset
|
|
// nest columns become 0, a missing title is stored empty and a missing
|
|
// published flag true, an entry with a parent always takes the parent's
|
|
// menu, and an entry without a sort order goes after its menu's last one
|
|
// (1 in an empty menu); values that are set are kept.
|
|
func TestMenuEntryBeforeSave(t *testing.T) {
|
|
e := bootMenu(t)
|
|
one := e.insertMenu(t, "One", "one", true)
|
|
two := e.insertMenu(t, "Two", "two", true)
|
|
save := func(t *testing.T, entry *models.MenuEntry) models.MenuEntry {
|
|
t.Helper()
|
|
if err := e.gdb.Create(entry).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return e.entry(t, entry.ID)
|
|
}
|
|
first := save(t, &models.MenuEntry{MenuID: one, Type: "url", URL: str("/a")})
|
|
if intOf(first.NestLeft) != 0 || intOf(first.NestRight) != 0 || intOf(first.NestDepth) != 0 || models.Text(first.Title) != "" || first.Title == nil ||
|
|
first.IsPublished == nil || !*first.IsPublished || intOf(first.SortOrder) != 1 {
|
|
t.Fatalf("first = %+v", first)
|
|
}
|
|
sort := 7
|
|
save(t, &models.MenuEntry{MenuID: one, Type: "url", SortOrder: &sort})
|
|
next := save(t, &models.MenuEntry{MenuID: one, Type: "divider"})
|
|
if intOf(next.SortOrder) != 8 {
|
|
t.Fatalf("next sort = %d, want 8", intOf(next.SortOrder))
|
|
}
|
|
off := false
|
|
left := 3
|
|
kept := save(t, &models.MenuEntry{MenuID: two, Type: "url", Title: str("Kept"), IsPublished: &off, NestLeft: &left})
|
|
if *kept.IsPublished || models.Text(kept.Title) != "Kept" || intOf(kept.NestLeft) != 3 || intOf(kept.SortOrder) != 1 {
|
|
t.Fatalf("kept = %+v", kept)
|
|
}
|
|
// The parent's menu wins over the given one.
|
|
child := save(t, &models.MenuEntry{MenuID: two, ParentID: &first.ID, Type: "url"})
|
|
if child.MenuID != one || intOf(child.SortOrder) != 9 {
|
|
t.Fatalf("child = %+v, want menu %d and sort 9", child, one)
|
|
}
|
|
missing := uint(99999)
|
|
orphan := save(t, &models.MenuEntry{MenuID: two, ParentID: &missing, Type: "url"})
|
|
if orphan.MenuID != two {
|
|
t.Fatalf("orphan menu = %d", orphan.MenuID)
|
|
}
|
|
// An update runs the hook again without moving a set sort order.
|
|
child.SortOrder = intp(2)
|
|
if err := e.gdb.Save(&child).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again := e.entry(t, child.ID); intOf(again.SortOrder) != 2 || again.MenuID != one {
|
|
t.Fatalf("updated child = %+v", again)
|
|
}
|
|
}
|
|
|
|
func intp(i int) *int { return &i }
|
|
|
|
// TestMenuAPI pins the D-16 rules of GET /_menu/api/v1/menus/{code} beyond
|
|
// the smoke test: other menus' entries are absent, a null sort order sorts
|
|
// last, unsafe resolved, generated and stored list URLs are null, generated
|
|
// children nest and follow the entry's url_pattern, an empty alternate title
|
|
// keeps the resolved one, empty css classes are null, the answer is JSON
|
|
// with no CORS headers of its own, and codes outside the allowed shape are
|
|
// the JSON 404.
|
|
func TestMenuAPI(t *testing.T) {
|
|
e := bootMenuWith(t)
|
|
mainID := e.insertMenu(t, "Main", "main", true)
|
|
otherID := e.insertMenu(t, "Other", "other", true)
|
|
if err := e.gdb.Exec(`UPDATE golem15_menu_menus SET css_class = '' WHERE id = ?`, otherID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
last := e.insertEntry(t, rawEntry{menu: mainID, sort: 1, typ: "url", title: "Null sort", url: str("/n"), published: true})
|
|
if err := e.gdb.Exec(`UPDATE golem15_menu_menu_entries SET sort_order = NULL WHERE id = ?`, last).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := e.insertEntry(t, rawEntry{menu: mainID, sort: 1, typ: "url", title: "First", url: str("/f"), css: str(""), published: true})
|
|
unsafe := e.insertEntry(t, rawEntry{menu: mainID, sort: 2, typ: "acme-unsafe", title: "", reference: str("x"), published: true})
|
|
tree := e.insertEntry(t, rawEntry{menu: mainID, sort: 3, typ: "acme-tree", title: "Tree", url: str("data:text/html,x"), pattern: str("/custom/:slug"), published: true})
|
|
e.insertEntry(t, rawEntry{menu: otherID, sort: 1, typ: "url", title: "Elsewhere", url: str("/elsewhere"), published: true})
|
|
|
|
body := e.getMenu(t, "/_menu/api/v1/menus/main")
|
|
var ids []uint
|
|
for _, it := range body.Data.Items {
|
|
ids = append(ids, *it.ID)
|
|
}
|
|
if fmt.Sprint(ids) != fmt.Sprint([]uint{first, unsafe, tree, last}) {
|
|
t.Fatalf("order = %v", ids)
|
|
}
|
|
items := body.Data.Items
|
|
if items[0].CSSClass != nil {
|
|
t.Fatalf("empty css class = %q", deref(items[0].CSSClass))
|
|
}
|
|
if it := items[1]; it.URL != nil || it.Title != "unsafe" {
|
|
t.Fatalf("unsafe resolved URL = %+v", it)
|
|
}
|
|
it := items[2]
|
|
if it.Title != "Tree" || it.URL != nil || len(it.Items) != 1 {
|
|
t.Fatalf("tree = %+v", it)
|
|
}
|
|
branch := it.Items[0]
|
|
if branch.ID != nil || branch.Type != "acme-tree" || deref(branch.URL) != "/custom/branch" || len(branch.Items) != 2 {
|
|
t.Fatalf("branch = %+v", branch)
|
|
}
|
|
if leaf := branch.Items[0]; deref(leaf.URL) != "/custom/leaf" || leaf.Type != "acme-tree" || leaf.ID != nil {
|
|
t.Fatalf("leaf = %+v", leaf)
|
|
}
|
|
if bad := branch.Items[1]; bad.URL != nil || bad.Title != "bad" {
|
|
t.Fatalf("bad generated URL = %+v", bad)
|
|
}
|
|
raw := e.publicGet(t, "/_menu/api/v1/menus/main", "").Body.String()
|
|
if strings.Contains(raw, "Elsewhere") || strings.Contains(raw, "javascript") || strings.Contains(raw, "data:") {
|
|
t.Fatalf("body = %s", raw)
|
|
}
|
|
en := e.getMenu(t, "/_menu/api/v1/menus/main?locale=en").Data.Items[2].Items[0]
|
|
if en.Title != "branch" || deref(en.URL) != "/en/custom/branch" {
|
|
t.Fatalf("en branch = %+v", en)
|
|
}
|
|
other := e.getMenu(t, "/_menu/api/v1/menus/other")
|
|
if other.Data.CSSClass != nil || len(other.Data.Items) != 1 {
|
|
t.Fatalf("other = %+v", other.Data)
|
|
}
|
|
e.insertMenu(t, "Empty", "empty_menu-1", true)
|
|
rec := e.publicGet(t, "/_menu/api/v1/menus/empty_menu-1", "")
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"items":[]`) {
|
|
t.Fatalf("empty menu = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
|
t.Fatalf("Content-Type = %q", ct)
|
|
}
|
|
if rec.Header().Get("Access-Control-Allow-Origin") != "" {
|
|
t.Fatal("the plugin sets CORS headers itself")
|
|
}
|
|
for _, path := range []string{"/_menu/api/v1/menus/m%C3%A9nu", "/_menu/api/v1/menus/a%20b", "/_menu/api/v1/menus/a.b", "/_menu/api/v1/menus/%27or%271"} {
|
|
rec := e.publicGet(t, path, "")
|
|
if rec.Code != http.StatusNotFound || rec.Body.String() != `{"error":"Menu not found"}` {
|
|
t.Errorf("GET %s: %d %s", path, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &decoded); err != nil || decoded["data"] == nil {
|
|
t.Fatalf("envelope = %s", raw)
|
|
}
|
|
}
|
|
|
|
// TestMenuImport pins the menu:import-winter edge cases beyond the smoke
|
|
// test: a page subtree dropped to any depth, entries of a menu missing from
|
|
// the menus export, a parent missing from the entries export, a type
|
|
// mapped to nothing, an unregistered type, malformed rows and options.
|
|
func TestMenuImport(t *testing.T) {
|
|
e := bootMenu(t)
|
|
ctx := t.Context()
|
|
reg, ok := e.app.Lookup[*signpost.Registry]()
|
|
if !ok {
|
|
t.Fatal("signpost registry is not published")
|
|
}
|
|
dir := t.TempDir()
|
|
menus := writeFile(t, dir, "menus.jsonl", `{"id": 1, "name": "Main", "code": "main", "is_active": 1}`+"\n")
|
|
row := func(id, menu int, parent, typ, title string) string {
|
|
return fmt.Sprintf(`{"id": %d, "menu_id": %d, "parent_id": %s, "sort_order": %d, "type": %q, "title": %q, "url": "/x%d", "reference": null, "is_published": 1}`, id, menu, parent, id, typ, title, id) + "\n"
|
|
}
|
|
entries := writeFile(t, dir, "entries.jsonl",
|
|
row(1, 1, "null", "link", "Keep")+
|
|
row(2, 1, "null", "page", "Page")+
|
|
row(3, 1, "2", "link", "Under page")+
|
|
row(4, 1, "3", "link", "Deep under page")+
|
|
row(5, 2, "null", "link", "Other menu")+
|
|
row(6, 1, "50", "link", "Missing parent")+
|
|
row(7, 1, "null", "obsolete", "Mapped away")+
|
|
row(8, 1, "null", "unknown-type", "Unregistered")+
|
|
row(9, 1, "1", "divider", "Kept child"))
|
|
out, buf := testOutput()
|
|
report, err := console.ImportWinterFiles(ctx, e.gdb, reg, console.WinterOptions{MenusFile: menus, EntriesFile: entries, Maps: map[string]string{"obsolete": ""}}, out)
|
|
if err != nil {
|
|
t.Fatalf("import: %v\n%s", err, buf.String())
|
|
}
|
|
if report.Menus != 1 || report.Entries != 2 || report.Dropped != 7 {
|
|
t.Fatalf("report = %+v\n%s", report, buf.String())
|
|
}
|
|
for _, want := range []string{
|
|
"entry 2 'Page' (menu 1, type 'page'): dropped (page entries (Winter.Pages) are not supported)",
|
|
"entry 3 'Under page' (menu 1, type 'link'): dropped (dropped with its parent 2)",
|
|
"entry 4 'Deep under page' (menu 1, type 'link'): dropped (dropped with its parent 3)",
|
|
"entry 5 'Other menu' (menu 2, type 'link'): dropped (menu 2 is not in the menus export)",
|
|
"entry 6 'Missing parent' (menu 1, type 'link'): dropped (its parent 50 is not in the entries export)",
|
|
"entry 7 'Mapped away' (menu 1, type 'obsolete'): dropped (type 'obsolete' is mapped to nothing)",
|
|
"entry 8 'Unregistered' (menu 1, type 'unknown-type'): dropped (type 'unknown-type' is neither built-in nor registered; map it with --map unknown-type=<type>)",
|
|
} {
|
|
if !strings.Contains(buf.String(), want) {
|
|
t.Errorf("report lacks %q:\n%s", want, buf.String())
|
|
}
|
|
}
|
|
var kept []models.MenuEntry
|
|
if err := e.gdb.Order("id").Find(&kept).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(kept) != 2 || kept[0].ID != 1 || kept[0].Type != "url" || kept[1].ID != 9 || kept[1].ParentID == nil || *kept[1].ParentID != 1 {
|
|
t.Fatalf("kept = %+v", kept)
|
|
}
|
|
|
|
for name, tc := range map[string]struct {
|
|
opts console.WinterOptions
|
|
want string
|
|
}{
|
|
"no files": {console.WinterOptions{MenusFile: menus}, "give --menus and --entries"},
|
|
"bad menu row": {console.WinterOptions{MenusFile: writeFile(t, dir, "bad-menus.jsonl", `{"id": 0, "name": "x", "code": "x"}`+"\n"), EntriesFile: entries, Force: true}, "row 1"},
|
|
"menu sans code": {console.WinterOptions{MenusFile: writeFile(t, dir, "nocode.jsonl", `{"id": 4, "name": "x"}`+"\n"), EntriesFile: entries, Force: true}, "code is missing"},
|
|
"entry sans type": {console.WinterOptions{MenusFile: menus, EntriesFile: writeFile(t, dir, "notype.jsonl", `{"id": 4, "menu_id": 1}`+"\n"), Force: true}, "type is missing"},
|
|
} {
|
|
out, buf := testOutput()
|
|
if _, err := console.ImportWinterFiles(ctx, e.gdb, reg, tc.opts, out); err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Errorf("%s: err = %v, want %q\n%s", name, err, tc.want, buf.String())
|
|
}
|
|
}
|
|
var count int64
|
|
if err := e.gdb.Model(&models.MenuEntry{}).Count(&count).Error; err != nil || count != 2 {
|
|
t.Fatalf("refused imports changed the entries: %d %v", count, err)
|
|
}
|
|
|
|
// WR-06: the copies pass the admin save rules, with a report line each.
|
|
twoMenus := writeFile(t, dir, "two-menus.jsonl", `{"id": 1, "name": "Main", "code": "main", "is_active": 1}`+"\n"+`{"id": 2, "name": "Footer", "code": "footer", "is_active": 1}`+"\n")
|
|
ruled := writeFile(t, dir, "ruled.jsonl",
|
|
`{"id": 1, "menu_id": 1, "parent_id": null, "sort_order": 1, "type": "link", "title": "Evil", "url": "javascript:alert(1)", "target": "_parent", "is_published": 1}`+"\n"+
|
|
`{"id": 2, "menu_id": 1, "parent_id": null, "sort_order": 2, "type": "link", "title": "Spaced", "url": " /spaced ", "target": "_blank", "is_published": 1}`+"\n"+
|
|
`{"id": 3, "menu_id": 2, "parent_id": 2, "sort_order": 1, "type": "link", "title": "Elsewhere", "url": "/e", "is_published": 1}`+"\n"+
|
|
`{"id": 4, "menu_id": 2, "parent_id": 3, "sort_order": 2, "type": "link", "title": "Under elsewhere", "url": "/u", "is_published": 1}`+"\n")
|
|
out, buf = testOutput()
|
|
report, err = console.ImportWinterFiles(ctx, e.gdb, reg, console.WinterOptions{MenusFile: twoMenus, EntriesFile: ruled, Force: true}, out)
|
|
if err != nil || report.Entries != 4 || report.Dropped != 0 {
|
|
t.Fatalf("ruled import: %+v %v\n%s", report, err, buf.String())
|
|
}
|
|
for _, want := range []string{
|
|
`entry 1 'Evil' (menu 1, type 'link'): url "javascript:alert(1)" cleared (only http, https, mailto, tel, / and # links are served)`,
|
|
"entry 1 'Evil' (menu 1, type 'link'): target '_parent' cleared (only _self and _blank are allowed)",
|
|
"entry 2 'Spaced' (menu 1, type 'link'): url ' /spaced ' trimmed to '/spaced'",
|
|
"entry 3 'Elsewhere' (menu 2, type 'link'): copied as a top-level entry (its parent 2 is in menu 1)",
|
|
} {
|
|
if !strings.Contains(buf.String(), want) {
|
|
t.Errorf("report lacks %q:\n%s", want, buf.String())
|
|
}
|
|
}
|
|
kept = nil
|
|
if err := e.gdb.Order("id").Find(&kept).Error; err != nil || len(kept) != 4 {
|
|
t.Fatalf("ruled entries = %+v %v", kept, err)
|
|
}
|
|
if kept[0].URL != nil || kept[0].Target != nil || models.Text(kept[1].URL) != "/spaced" || models.Text(kept[1].Target) != "_blank" ||
|
|
kept[2].ParentID != nil || kept[2].MenuID != 2 || kept[3].ParentID == nil || *kept[3].ParentID != 3 {
|
|
t.Fatalf("ruled entries = %+v", kept)
|
|
}
|
|
}
|