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=)", } { 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) } }