Files
sm-menu-plugin/menu_smoke_test.go
Jakub Zych 7255d34ae0 fix(17): WR-01 keep a menu entry's subtree and parent in its menu
- The admin refuses a parent from another menu than the one the form
  names (errors.parent_menu) instead of moving the entry to the parent's
  menu without a word; BeforeSave records the named menu
  (MenuEntry.RequestedMenuID).
- An entry moved to another menu moves its descendants in the same
  transaction, so the public tree no longer drops them.
2026-10-12 21:30:36 +02:00

782 lines
34 KiB
Go

package menu_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
_ "git.golem15.com/golem15/sm-menu-plugin"
"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/bonfire"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"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"
"gorm.io/gorm"
)
// testTypesPluginID is the fake content plugin that registers menu item
// types for the smoke tests, so the menu plugin needs no content plugin.
const testTypesPluginID = "acme.menutypes"
const permManage = "golem15.menu.manage_menu"
// acmeTypes registers acme-page (references 1 Home with 2 About under it),
// acme-list (a list type without references) and all-journal-categories,
// the registry key the Winter import maps blog_categories to.
type acmeTypes struct{}
func (acmeTypes) ID() string { return testTypesPluginID }
func (acmeTypes) Requires() []string { return nil }
func (acmeTypes) Register(*backpack.App) error { return nil }
func (acmeTypes) Boot(*backpack.App) error { return nil }
func (acmeTypes) MenuItemTypes() []pact.MenuItemType {
return []pact.MenuItemType{
{
Key: "acme-page",
Label: "Acme page",
DefaultPattern: "/pages/:slug",
References: func(context.Context) ([]pact.MenuItemReference, error) {
return []pact.MenuItemReference{{Value: "1", Label: "Home", Items: []pact.MenuItemReference{{Value: "2", Label: "About"}}}}, nil
},
Resolve: resolvePage,
},
{
Key: "acme-list",
Label: "Acme pages",
DefaultPattern: "/pages/:slug",
PatternFrom: "acme-page",
Nesting: true,
DynamicItems: true,
Resolve: func(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
var items []pact.ResolvedMenuItem
for _, slug := range []string{"home", "about"} {
url := strings.ReplaceAll(in.Pattern, ":slug", slug)
items = append(items, pact.ResolvedMenuItem{
Title: slug, URL: url,
Alternates: []pact.MenuItemAlternate{{Locale: "pl", URL: url, Title: slug}, {Locale: "en", URL: "/en" + url, Title: slug + " (en)"}},
})
}
return &pact.ResolvedMenuItem{Items: items}, nil
},
},
{
Key: "all-journal-categories",
Label: "All categories",
DefaultPattern: "/:slug",
Nesting: true,
DynamicItems: true,
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
return &pact.ResolvedMenuItem{Items: []pact.ResolvedMenuItem{{Title: "News", URL: "/news", Alternates: []pact.MenuItemAlternate{{Locale: "pl", URL: "/news", Title: "News"}}}}}, nil
},
},
}
}
// resolvePage resolves references 1 (home) and 2 (about) with the layered
// pattern; any other reference no longer exists.
func resolvePage(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
slugs := map[string]string{"1": "home", "2": "about"}
slug, ok := slugs[in.Reference]
if !ok {
return nil, nil
}
url := strings.ReplaceAll(in.Pattern, ":slug", slug)
return &pact.ResolvedMenuItem{
Title: slug,
URL: url,
Alternates: []pact.MenuItemAlternate{
{Locale: "pl", URL: url, Title: slug},
{Locale: "en", URL: "/en" + url, Title: slug + " (en)"},
},
}, nil
}
var _ pact.HasMenuItemTypes = acmeTypes{}
func init() {
party.Register(acmeTypes{})
}
// menuEnv is a booted application with the menu plugin and the fake types.
type menuEnv struct {
h http.Handler
app *backpack.App
gdb *gorm.DB
}
func testConfig(t *testing.T) *compass.Config {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: menu-smoke\nlocale: en\nfallback_locale: en\nurl: https://api.example.test\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=menu-smoke-test-only-hs256-secret-key-0001"},
})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]any{
"http.body_limits.default_bytes": 1048576,
"http.body_limits.upload_bytes": 8388608,
"mail.driver": "log",
"mail.from": "noreply@example.test",
} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
return cfg
}
func bootMenu(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, "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}
}
var adminSeq atomic.Uint64
// call sends an admin API request with an optional bearer token and JSON
// body.
func (e *menuEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, cabana.DefaultAdminPrefix+"/api/v1"+path, reader)
req.Host = "api.example.test"
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1)
req.Header.Set("Accept-Language", "en")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
// admin signs in a new administrator whose role grants permissions.
func (e *menuEnv) admin(t *testing.T, permissions ...string) string {
t.Helper()
stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1))
login, password := "admin-"+stamp, "admin-secret-"+stamp
hash, err := bouncer.HashPassword(10, password)
if err != nil {
t.Fatal(err)
}
grants := map[string]int{}
for _, code := range permissions {
grants[code] = 1
}
raw, _ := json.Marshal(grants)
var roleID uint
if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`,
"Role "+stamp, "role-"+stamp, string(raw)).Scan(&roleID).Error; err != nil || roleID == 0 {
t.Fatalf("backend role: id=%d err=%v", roleID, err)
}
if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`,
login, login+"@example.test", hash, roleID).Error; err != nil {
t.Fatal(err)
}
rec := e.call(t, http.MethodPost, "/auth/login", "", map[string]string{"login": login, "password": password})
var body struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Data.AccessToken == "" {
t.Fatalf("admin login: %d %s", rec.Code, rec.Body.String())
}
return body.Data.AccessToken
}
// create posts a record and returns its id; it fails the test unless the
// answer is 201.
func (e *menuEnv) create(t *testing.T, token, path string, body map[string]any) uint {
t.Helper()
rec := e.call(t, http.MethodPost, path, token, body)
var out struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &out) != nil || out.Data.ID == 0 {
t.Fatalf("POST %s %v: %d %s", path, body, rec.Code, rec.Body.String())
}
return out.Data.ID
}
func (e *menuEnv) entry(t *testing.T, id uint) models.MenuEntry {
t.Helper()
var entry models.MenuEntry
if err := e.gdb.First(&entry, id).Error; err != nil {
t.Fatal(err)
}
return entry
}
func intOf(p *int) int {
if p == nil {
return -1
}
return *p
}
// expect422 posts or puts body and checks for a 422 naming field and,
// when key is set, the phrase key.
func (e *menuEnv) expect422(t *testing.T, token, method, path string, body map[string]any, field, key string) {
t.Helper()
rec := e.call(t, method, path, token, body)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+field+`":`) || (key != "" && !strings.Contains(rec.Body.String(), key)) {
t.Fatalf("%s %s %v: %d %s, want 422 on %s %s", method, path, body, rec.Code, rec.Body.String(), field, key)
}
}
const (
menusPath = "/golem15/menu/menus"
entriesPath = "/golem15/menu/menuentries"
)
// TestMenuAdminSmoke drives the menus and menu entries screens: the PHP save
// rules, the D-05 type set, the reference recheck, the URL scheme and target
// refusals, the parent cycle refusal and the tree reorder page scoped to one
// menu.
func TestMenuAdminSmoke(t *testing.T) {
e := bootMenu(t)
token := e.admin(t, permManage)
mainID := e.create(t, token, menusPath, map[string]any{"name": "Main", "code": "main", "is_active": true})
e.expect422(t, token, http.MethodPost, menusPath, map[string]any{"name": "Again", "code": "main", "is_active": true}, "code", "")
// The form offers the built-ins and the registry types; the reference
// depends on the type.
rec := e.call(t, http.MethodGet, entriesPath+"/schema/form", token, nil)
for _, want := range []string{`"value":"url"`, `"value":"divider"`, `"value":"acme-page"`, `"value":"acme-list"`, `"dependsOn":"type"`} {
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), want) {
t.Fatalf("form schema lacks %s: %d %s", want, rec.Code, rec.Body.String())
}
}
rec = e.call(t, http.MethodGet, entriesPath+"/fields/reference/dependent-options?values%5Btype%5D=acme-page", token, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"value":"2"`) || !strings.Contains(rec.Body.String(), `"applies":true`) {
t.Fatalf("dependent options acme-page: %d %s", rec.Code, rec.Body.String())
}
rec = e.call(t, http.MethodGet, entriesPath+"/fields/reference/dependent-options?values%5Btype%5D=url", token, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"applies":false`) {
t.Fatalf("dependent options url: %d %s", rec.Code, rec.Body.String())
}
// The first entry; the second has no sort_order and goes after it with
// zero nest columns.
home := e.create(t, token, entriesPath, map[string]any{"menu": mainID, "type": "url", "title": "Home", "url": "/", "target": "_self"})
divider := e.create(t, token, entriesPath, map[string]any{"menu": mainID, "type": "divider"})
d := e.entry(t, divider)
if intOf(d.SortOrder) != intOf(e.entry(t, home).SortOrder)+1 || intOf(d.NestLeft) != 0 || intOf(d.NestRight) != 0 || intOf(d.NestDepth) != 0 || models.Text(d.Title) != "" || d.IsPublished == nil || !*d.IsPublished {
t.Fatalf("divider = %+v", d)
}
page := e.create(t, token, entriesPath, map[string]any{"menu": mainID, "type": "acme-page", "title": "About", "reference": "2", "url_pattern": "/p/:slug"})
// A child without a menu takes its parent's menu.
child := e.create(t, token, entriesPath, map[string]any{"parent": home, "type": "url", "title": "Contact", "url": "mailto:hello@example.test", "target": "_blank"})
if c := e.entry(t, child); c.MenuID != mainID || c.ParentID == nil || *c.ParentID != home {
t.Fatalf("child = %+v", c)
}
// Refusals: a reference the type does not offer, a javascript: URL, an
// unknown target, an unknown type, no menu at all, a parent cycle.
e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"menu": mainID, "type": "acme-page", "title": "Gone", "reference": "99"}, "reference", "")
e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"menu": mainID, "type": "url", "title": "Evil", "url": "javascript:alert(1)"}, "url", "golem15.menu::lang.errors.url_scheme")
e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"menu": mainID, "type": "url", "title": "Top", "url": "/", "target": "_top"}, "target", "golem15.menu::lang.errors.target")
e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"menu": mainID, "type": "page", "title": "Old"}, "type", "")
e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"type": "url", "title": "Nowhere", "url": "/"}, "menu", "golem15.menu::lang.errors.menu_required")
e.expect422(t, token, http.MethodPut, fmt.Sprintf("%s/%d", entriesPath, home), map[string]any{"parent": child}, "parent", "golem15.menu::lang.errors.parent_cycle")
var n int64
e.gdb.Model(&models.MenuEntry{}).Count(&n)
if n != 4 {
t.Fatalf("refusals left %d entries, want 4", n)
}
// The list filtered on the menu, and the reorder tree of that menu.
footerID := e.create(t, token, menusPath, map[string]any{"name": "Footer", "code": "footer", "is_active": true})
foreign := e.create(t, token, entriesPath, map[string]any{"menu": footerID, "type": "url", "title": "Imprint", "url": "/imprint"})
rec = e.call(t, http.MethodGet, fmt.Sprintf("%s?filter[menu_id]=%d", entriesPath, mainID), token, nil)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "Imprint") || !strings.Contains(rec.Body.String(), "Contact") {
t.Fatalf("filtered list: %d %s", rec.Code, rec.Body.String())
}
rec = e.call(t, http.MethodGet, fmt.Sprintf("%s/reorder?partition=%d", entriesPath, mainID), token, nil)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "Imprint") || !strings.Contains(rec.Body.String(), `"title":"Reorder menu entries"`) {
t.Fatalf("reorder tree: %d %s", rec.Code, rec.Body.String())
}
// Nest the acme page under Home after Contact; the divider moves up.
nodes := []map[string]any{
{"id": home, "parent_id": nil, "sort_order": 1},
{"id": child, "parent_id": home, "sort_order": 1},
{"id": page, "parent_id": home, "sort_order": 2},
{"id": divider, "parent_id": nil, "sort_order": 2},
}
rec = e.call(t, http.MethodPost, entriesPath+"/reorder", token, map[string]any{"partition": mainID, "nodes": nodes})
if rec.Code != http.StatusOK {
t.Fatalf("reorder: %d %s", rec.Code, rec.Body.String())
}
if p := e.entry(t, page); p.ParentID == nil || *p.ParentID != home || intOf(p.SortOrder) != 2 {
t.Fatalf("reordered page = %+v", p)
}
if dv := e.entry(t, divider); dv.ParentID != nil || intOf(dv.SortOrder) != 2 {
t.Fatalf("reordered divider = %+v", dv)
}
// A reorder naming an entry of another menu changes nothing.
nodes = append(nodes, map[string]any{"id": foreign, "parent_id": nil, "sort_order": 3})
rec = e.call(t, http.MethodPost, entriesPath+"/reorder", token, map[string]any{"partition": mainID, "nodes": nodes})
if rec.Code != http.StatusConflict {
t.Fatalf("reorder with a foreign entry: %d %s", rec.Code, rec.Body.String())
}
// WR-01: a parent from another menu than the one the entry names is
// refused, and an entry moved to another menu takes its subtree along.
e.expect422(t, token, http.MethodPut, fmt.Sprintf("%s/%d", entriesPath, divider), map[string]any{"menu": mainID, "parent": foreign}, "parent", "golem15.menu::lang.errors.parent_menu")
if dv := e.entry(t, divider); dv.MenuID != mainID || dv.ParentID != nil {
t.Fatalf("a refused move changed the divider: %+v", dv)
}
rec = e.call(t, http.MethodPut, fmt.Sprintf("%s/%d", entriesPath, home), token, map[string]any{"menu": footerID})
if rec.Code != http.StatusOK {
t.Fatalf("move Home to the footer: %d %s", rec.Code, rec.Body.String())
}
for _, id := range []uint{home, child, page} {
if got := e.entry(t, id); got.MenuID != footerID {
t.Fatalf("entry %d stayed in menu %d after its root moved to %d", id, got.MenuID, footerID)
}
}
if dv := e.entry(t, divider); dv.MenuID != mainID {
t.Fatalf("the divider moved with Home: %+v", dv)
}
// Without the permission the screens answer 403.
other := e.admin(t)
for _, path := range []string{menusPath, entriesPath} {
if rec := e.call(t, http.MethodGet, path, other, nil); rec.Code != http.StatusForbidden {
t.Errorf("GET %s without permission: %d", path, rec.Code)
}
}
}
func writeFile(t *testing.T, dir, name, body string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func testOutput() (bonfire.Output, *bytes.Buffer) {
var buf bytes.Buffer
return bonfire.NewOutput(strings.NewReader(""), &buf, &buf), &buf
}
func (e *menuEnv) counts(t *testing.T) (int64, int64) {
t.Helper()
var menus, entries int64
if err := e.gdb.Model(&models.Menu{}).Count(&menus).Error; err != nil {
t.Fatal(err)
}
if err := e.gdb.Model(&models.MenuEntry{}).Count(&entries).Error; err != nil {
t.Fatal(err)
}
return menus, entries
}
// winterMenus and winterEntries are a Winter export: two menus; a link with
// a divider under it, a page entry with a child, a blog_categories list, a
// stale and a current acme-page reference, and a custom type renamed by
// --map.
const (
winterMenus = `{"id": 3, "name": "Main", "code": "main", "is_active": 1, "css_class": "nav", "created_at": "2024-01-01 10:00:00.000000", "updated_at": null}
{"id": 7, "name": "Footer", "code": "footer", "is_active": 0, "css_class": null, "created_at": null, "updated_at": null}
`
winterEntries = `{"id": 10, "menu_id": 3, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 1, "type": "link", "title": "Home", "url": "/", "reference": null, "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
{"id": 11, "menu_id": 3, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 2, "type": "page", "title": "About", "url": null, "reference": "about", "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
{"id": 12, "menu_id": 3, "parent_id": 11, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 1, "type": "link", "title": "Team", "url": "/team", "reference": null, "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
{"id": 13, "menu_id": 3, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 3, "type": "blog_categories", "title": "Blog", "url": "/blog", "reference": null, "code": "blog", "icon": null, "css_class": "drop", "target": "_self", "is_published": 1, "created_at": null, "updated_at": null}
{"id": 14, "menu_id": 3, "parent_id": 10, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 1, "type": "divider", "title": "", "url": null, "reference": null, "code": null, "icon": null, "css_class": null, "target": null, "is_published": 0, "created_at": null, "updated_at": null}
{"id": 15, "menu_id": 7, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 1, "type": "acme-page", "title": "Gone", "url": null, "reference": "99", "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
{"id": 16, "menu_id": 7, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 2, "type": "acme-page", "title": "About us", "url": null, "reference": "2", "code": null, "icon": null, "css_class": null, "target": "_blank", "is_published": 1, "created_at": null, "updated_at": null}
{"id": 17, "menu_id": 7, "parent_id": null, "nest_left": 0, "nest_right": 0, "nest_depth": 0, "sort_order": 3, "type": "custom_link", "title": "Shop", "url": "https://shop.example.test", "reference": null, "code": null, "icon": null, "css_class": null, "target": null, "is_published": 1, "created_at": null, "updated_at": null}
`
)
// TestMenuImportSmoke copies a Winter export through menu:import-winter:
// link becomes url, blog_categories becomes all-journal-categories, a page
// entry and its child are dropped and reported, a stale reference is
// dropped, ids and parent_id are kept, both sequences move past the copied
// ids, a dry run writes nothing, a second copy is refused without --force,
// and empty files report zero.
func TestMenuImportSmoke(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()
menusFile := writeFile(t, dir, "menus.jsonl", winterMenus)
entriesFile := writeFile(t, dir, "entries.jsonl", winterEntries)
opts := console.WinterOptions{MenusFile: menusFile, EntriesFile: entriesFile, Maps: map[string]string{"custom_link": "url"}}
dry := opts
dry.DryRun = true
out, buf := testOutput()
report, err := console.ImportWinterFiles(ctx, e.gdb, reg, dry, out)
if err != nil || report.Menus != 2 || report.Entries != 5 || report.Dropped != 3 || report.Remapped != 4 {
t.Fatalf("dry run: %+v %v\n%s", report, err, buf.String())
}
if m, n := e.counts(t); m != 0 || n != 0 {
t.Fatalf("dry run wrote %d menus, %d entries", m, n)
}
out, buf = testOutput()
report, err = console.ImportWinterFiles(ctx, e.gdb, reg, opts, out)
if err != nil || report.Entries != 5 {
t.Fatalf("import: %+v %v\n%s", report, err, buf.String())
}
for _, want := range []string{
"entry 10 'Home' (menu 3, type 'link'): type 'link' mapped to 'url'",
"entry 11 'About' (menu 3, type 'page', reference 'about'): dropped (page entries (Winter.Pages) are not supported)",
"entry 12 'Team' (menu 3, type 'link'): dropped (dropped with its parent 11)",
"entry 13 'Blog' (menu 3, type 'blog_categories'): type 'blog_categories' mapped to 'all-journal-categories'",
"entry 15 'Gone' (menu 7, type 'acme-page', reference '99'): dropped (reference '99' is not among the current references of 'acme-page')",
"entry 17 'Shop' (menu 7, type 'custom_link'): type 'custom_link' mapped to 'url'",
} {
if !strings.Contains(buf.String(), want) {
t.Errorf("report lacks %q:\n%s", want, buf.String())
}
}
var entries []models.MenuEntry
if err := e.gdb.Order("id").Find(&entries).Error; err != nil {
t.Fatal(err)
}
ids := []uint{}
for _, en := range entries {
ids = append(ids, en.ID)
}
if fmt.Sprint(ids) != "[10 13 14 16 17]" {
t.Fatalf("copied ids = %v", ids)
}
if en := entries[0]; en.Type != "url" || en.MenuID != 3 || intOf(en.SortOrder) != 1 {
t.Fatalf("entry 10 = %+v", en)
}
if en := entries[1]; en.Type != "all-journal-categories" || models.Text(en.URL) != "/blog" || models.Text(en.Code) != "blog" || models.Text(en.Target) != "_self" {
t.Fatalf("entry 13 = %+v", en)
}
if en := entries[2]; en.ParentID == nil || *en.ParentID != 10 || en.IsPublished == nil || *en.IsPublished || en.Type != "divider" {
t.Fatalf("entry 14 = %+v", en)
}
if en := entries[3]; en.Type != "acme-page" || models.Text(en.Reference) != "2" || en.MenuID != 7 || en.URLPattern != nil {
t.Fatalf("entry 16 = %+v", en)
}
var footer models.Menu
if err := e.gdb.First(&footer, 7).Error; err != nil || footer.IsActive || footer.Code != "footer" {
t.Fatalf("menu 7 = %+v %v", footer, err)
}
// Both sequences moved past the copied ids.
token := e.admin(t, permManage)
if id := e.create(t, token, menusPath, map[string]any{"name": "Side", "code": "side", "is_active": true}); id != 8 {
t.Fatalf("new menu id = %d, want 8", id)
}
if id := e.create(t, token, entriesPath, map[string]any{"menu": 3, "type": "url", "title": "New", "url": "/new"}); id != 18 {
t.Fatalf("new entry id = %d, want 18", id)
}
// A second copy is refused without --force and changes nothing; --force
// replaces both tables.
out, buf = testOutput()
if _, err := console.ImportWinterFiles(ctx, e.gdb, reg, opts, out); err == nil || !strings.Contains(err.Error(), "--force") {
t.Fatalf("second import: %v\n%s", err, buf.String())
}
if m, n := e.counts(t); m != 3 || n != 6 {
t.Fatalf("refused import changed the tables: %d menus, %d entries", m, n)
}
forced := opts
forced.Force = true
out, buf = testOutput()
if _, err := console.ImportWinterFiles(ctx, e.gdb, reg, forced, out); err != nil {
t.Fatalf("forced import: %v\n%s", err, buf.String())
}
if m, n := e.counts(t); m != 2 || n != 5 {
t.Fatalf("forced import left %d menus, %d entries", m, n)
}
// Empty files write nothing and report zero.
empty := writeFile(t, dir, "empty.jsonl", "")
out, buf = testOutput()
if report, err := console.ImportWinterFiles(ctx, e.gdb, reg, console.WinterOptions{MenusFile: empty, EntriesFile: empty}, out); err != nil || report.Menus != 0 || report.Entries != 0 || !strings.Contains(buf.String(), "0 menus, 0 entries") {
t.Fatalf("empty files: %+v %v\n%s", report, err, buf.String())
}
if m, n := e.counts(t); m != 2 || n != 5 {
t.Fatalf("empty files changed the tables: %d menus, %d entries", m, n)
}
}
// publicGet sends an anonymous GET to a public path from remote, or from a
// fresh client address when remote is "".
func (e *menuEnv) publicGet(t *testing.T, path, remote string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Host = "api.example.test"
if remote == "" {
remote = fmt.Sprintf("203.0.113.%d:5000", adminSeq.Add(1)%250+1)
}
req.RemoteAddr = remote
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
// rawEntry is an entry written with raw SQL, past the admin save gate.
type rawEntry struct {
menu uint
parent *uint
sort int
typ string
title string
url *string
reference *string
pattern *string
target *string
css *string
code *string
published bool
}
func (e *menuEnv) insertMenu(t *testing.T, name, code string, active bool) uint {
t.Helper()
var id uint
if err := e.gdb.Raw(`INSERT INTO golem15_menu_menus (name, code, is_active, css_class, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW()) RETURNING id`,
name, code, active, "nav-"+code).Scan(&id).Error; err != nil || id == 0 {
t.Fatalf("insert menu %s: id=%d err=%v", code, id, err)
}
return id
}
func (e *menuEnv) insertEntry(t *testing.T, r rawEntry) uint {
t.Helper()
var id uint
if err := e.gdb.Raw(`INSERT INTO golem15_menu_menu_entries (menu_id, parent_id, nest_left, nest_right, nest_depth, sort_order, type, title, url, reference, url_pattern, target, css_class, code, is_published, created_at, updated_at)
VALUES (?, ?, 0, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) RETURNING id`,
r.menu, r.parent, r.sort, r.typ, r.title, r.url, r.reference, r.pattern, r.target, r.css, r.code, r.published).Scan(&id).Error; err != nil || id == 0 {
t.Fatalf("insert entry %s: id=%d err=%v", r.title, id, err)
}
return id
}
func str(s string) *string { return &s }
// apiItem is one item of the public menu tree.
type apiItem struct {
ID *uint `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
URL *string `json:"url"`
Target *string `json:"target"`
CSSClass *string `json:"css_class"`
Icon *string `json:"icon"`
Code *string `json:"code"`
Items []apiItem `json:"items"`
}
type menuBody struct {
Data struct {
Code string `json:"code"`
Name string `json:"name"`
CSSClass *string `json:"css_class"`
Items []apiItem `json:"items"`
} `json:"data"`
}
func (e *menuEnv) getMenu(t *testing.T, path string) menuBody {
t.Helper()
rec := e.publicGet(t, path, "")
var body menuBody
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil {
t.Fatalf("GET %s: %d %s", path, rec.Code, rec.Body.String())
}
return body
}
func deref(s *string) string {
if s == nil {
return "<nil>"
}
return *s
}
// TestMenuAPISmoke reads a menu through the anonymous public API: 404 JSON
// for unknown and inactive codes, unpublished entries and their subtrees
// absent, order by sort_order then id, a javascript: URL stored with raw SQL
// served as null, a list type keeping its stored title and URL and carrying
// generated children after the stored ones, a url_pattern override, the
// requested locale's alternate, unresolvable entries left out and the
// per-client throttle.
func TestMenuAPISmoke(t *testing.T) {
e := bootMenu(t)
mainID := e.insertMenu(t, "Main", "main", true)
e.insertMenu(t, "Hidden", "hidden", false)
home := e.insertEntry(t, rawEntry{menu: mainID, sort: 1, typ: "url", title: "Home", url: str("/"), target: str("_blank"), css: str("nav-home"), code: str("home"), published: true})
bad := e.insertEntry(t, rawEntry{menu: mainID, sort: 2, typ: "url", title: "Bad", url: str("javascript:alert(1)"), target: str("_top"), published: true})
divider := e.insertEntry(t, rawEntry{menu: mainID, sort: 2, typ: "divider", title: "", published: true})
about := e.insertEntry(t, rawEntry{menu: mainID, sort: 0, typ: "acme-page", title: "", reference: str("2"), published: true})
start := e.insertEntry(t, rawEntry{menu: mainID, sort: 5, typ: "acme-page", title: "Start", reference: str("1"), pattern: str("/p/:slug"), published: true})
list := e.insertEntry(t, rawEntry{menu: mainID, sort: 6, typ: "acme-list", title: "Pages", url: str("/pages"), published: true})
stored := e.insertEntry(t, rawEntry{menu: mainID, parent: &list, sort: 1, typ: "url", title: "Child", url: str("https://example.test/x"), published: true})
hidden := e.insertEntry(t, rawEntry{menu: mainID, sort: 7, typ: "url", title: "Hidden", url: str("/hidden"), published: false})
e.insertEntry(t, rawEntry{menu: mainID, parent: &hidden, sort: 1, typ: "url", title: "Hidden child", url: str("/hidden/child"), published: true})
e.insertEntry(t, rawEntry{menu: mainID, sort: 8, typ: "acme-page", title: "Gone", reference: str("99"), published: true})
e.insertEntry(t, rawEntry{menu: mainID, sort: 9, typ: "gone-type", title: "Unregistered", published: true})
e.insertEntry(t, rawEntry{menu: mainID, parent: &home, sort: 1, typ: "gone-type", title: "Unregistered child", published: true})
body := e.getMenu(t, "/_menu/api/v1/menus/main")
if body.Data.Code != "main" || body.Data.Name != "Main" || deref(body.Data.CSSClass) != "nav-main" {
t.Fatalf("menu = %+v", body.Data)
}
items := body.Data.Items
var order []uint
for _, it := range items {
if it.ID == nil {
t.Fatalf("top-level item without id: %+v", it)
}
order = append(order, *it.ID)
}
if want := []uint{about, home, bad, divider, start, list}; fmt.Sprint(order) != fmt.Sprint(want) {
t.Fatalf("order = %v, want %v (%+v)", order, want, items)
}
// The single type without a stored title takes the resolved title and
// URL of the default pattern.
if it := items[0]; it.Title != "about" || deref(it.URL) != "/pages/about" || it.Type != "acme-page" || len(it.Items) != 0 {
t.Fatalf("about = %+v", it)
}
// A url entry keeps its fields; its unregistered child is left out.
if it := items[1]; it.Title != "Home" || deref(it.URL) != "/" || deref(it.Target) != "_blank" || deref(it.CSSClass) != "nav-home" || deref(it.Code) != "home" || it.Icon != nil || it.Items == nil || len(it.Items) != 0 {
t.Fatalf("home = %+v", it)
}
// A javascript: URL and an unknown target come back null.
if it := items[2]; it.URL != nil || it.Target != nil {
t.Fatalf("bad = %+v", it)
}
if it := items[3]; it.Type != "divider" || it.URL != nil {
t.Fatalf("divider = %+v", it)
}
// The url_pattern override and the stored title.
if it := items[4]; it.Title != "Start" || deref(it.URL) != "/p/home" {
t.Fatalf("start = %+v", it)
}
// The list keeps its stored title and URL; the stored child comes first,
// then the generated children without an id.
it := items[5]
if it.Title != "Pages" || deref(it.URL) != "/pages" || len(it.Items) != 3 {
t.Fatalf("list = %+v", it)
}
if c := it.Items[0]; c.ID == nil || *c.ID != stored || deref(c.URL) != "https://example.test/x" {
t.Fatalf("stored child = %+v", c)
}
for i, slug := range []string{"home", "about"} {
c := it.Items[i+1]
if c.ID != nil || c.Type != "acme-list" || c.Title != slug || deref(c.URL) != "/pages/"+slug || c.Target != nil || c.CSSClass != nil || c.Items == nil {
t.Fatalf("generated child %d = %+v", i, c)
}
}
raw := e.publicGet(t, "/_menu/api/v1/menus/main", "").Body.String()
for _, absent := range []string{"Hidden", "Gone", "Unregistered", "javascript"} {
if strings.Contains(raw, absent) {
t.Fatalf("body holds %q: %s", absent, raw)
}
}
if !strings.Contains(raw, `"id":null`) || !strings.Contains(raw, `"items":[]`) {
t.Fatalf("nulls and empty lists not encoded: %s", raw)
}
// ?locale=en picks the en alternates; stored titles stay.
items = e.getMenu(t, "/_menu/api/v1/menus/main?locale=en").Data.Items
if it := items[0]; it.Title != "about (en)" || deref(it.URL) != "/en/pages/about" {
t.Fatalf("about en = %+v", it)
}
if it := items[4]; it.Title != "Start" || deref(it.URL) != "/en/p/home" {
t.Fatalf("start en = %+v", it)
}
if c := items[5].Items[1]; c.Title != "home (en)" || deref(c.URL) != "/en/pages/home" {
t.Fatalf("generated child en = %+v", c)
}
// A locale without alternates keeps the resolved values.
if it := e.getMenu(t, "/_menu/api/v1/menus/main?locale=de").Data.Items[0]; it.Title != "about" || deref(it.URL) != "/pages/about" {
t.Fatalf("about de = %+v", it)
}
// Misses are 404 JSON: an unknown code, an inactive menu, a malformed code.
for _, path := range []string{"/_menu/api/v1/menus/nope", "/_menu/api/v1/menus/hidden", "/_menu/api/v1/menus/main.json"} {
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())
}
}
// One client gets 120 requests a minute, then surf's 429 body.
const client = "192.0.2.77:6000"
for i := 0; i < 120; i++ {
if rec := e.publicGet(t, "/_menu/api/v1/menus/nope", client); rec.Code != http.StatusNotFound {
t.Fatalf("request %d: %d %s", i+1, rec.Code, rec.Body.String())
}
}
rec := e.publicGet(t, "/_menu/api/v1/menus/main", client)
if rec.Code != http.StatusTooManyRequests || !strings.Contains(rec.Body.String(), "Too Many Attempts.") {
t.Fatalf("121st request: %d %s", rec.Code, rec.Body.String())
}
}