- golem15.menu plugin, tables golem15_menu_menus and golem15_menu_menu_entries with the PHP columns plus a nullable url_pattern (D-02) - MenuEntry BeforeSave ports PHP beforeSave: nest columns 0, menu from the parent, sort_order after the menu's last - entry type dropdown: url, divider and the signpost registry types; reference depends on type through signpost.ReferenceOptions - URL scheme allowlist (errors.url_scheme), target _self or _blank, menu and parent cycle checks - menu filter, toolbar reorder/create/delete and config_reorder.yaml partitioned by menu - navigation Menus with Menus and Menu entries, permission golem15.menu.manage_menu, en and pl phrasebooks - TestMenuAdminSmoke
375 lines
15 KiB
Go
375 lines
15 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/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"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/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)
|
|
// and acme-list (a list type without references).
|
|
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
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|