Files
sm-menu-plugin/menu_api_test.go
Jakub Zych 9c0f833878 fix(17): WR-06 apply the admin save rules in menu:import-winter
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.
2026-10-12 21:39:57 +02:00

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)
}
}