Files
sm-menu-plugin/menu_smoke_test.go
Jakub Zych 64215f2bf3 fix(17): WR-03 require a reference for a menu entry whose type takes one
An entry of a registry type with References saved without a reference
passed the dependent recheck (null is always among the options), then
resolved to nothing and vanished from the public tree with its children.
The admin save now refuses it (errors.reference_required).
2026-10-12 21:33:36 +02:00

783 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": "acme-page", "title": "Unset"}, "reference", "golem15.menu::lang.errors.reference_required")
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())
}
}