Files
sm-seo-plugin/operations_test.go
Jakub Zych aa0c3bcacf feat: management API and an MCP tool per SEO admin operation
- /_seo/api/v1 routes for the effective SEO of a URL with sources and
  owner, URL rules, redirects (loops refused with redirect_loop), the
  404 log actions, robots.txt and its tester, settings and history
- the CMS-data issue report: missing and duplicate titles and
  descriptions, rules over records, redirect chains, canonicals to
  noindex or redirected addresses, noindex addresses robots.txt blocks
- 24 MCP tools on those routes (backend guard, golem15.seo.manage)
- AdminOperations maps every admin operation to its tool;
  TestEveryAdminOperationHasTool and TestMCPReadToolsAreGET guard drift
- robots.txt writes go through classes.SaveRobots from the admin too
2026-10-27 12:17:36 +01:00

243 lines
7.6 KiB
Go

package seo
import (
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/goccy/go-yaml"
)
// recordingRouter records the method and full pattern of every route.
type recordingRouter struct {
prefix string
routes map[string]bool
}
func (r *recordingRouter) add(method, path string) { r.routes[method+" "+r.prefix+path] = true }
func (r *recordingRouter) Group(prefix string, _ []string, fn func(pact.Router)) {
fn(&recordingRouter{prefix: r.prefix + prefix, routes: r.routes})
}
func (r *recordingRouter) GroupRaw(prefix string, mw []string, fn func(pact.Router)) {
r.Group(prefix, mw, fn)
}
func (r *recordingRouter) Get(p string, _ http.HandlerFunc, _ ...string) { r.add(http.MethodGet, p) }
func (r *recordingRouter) Post(p string, _ http.HandlerFunc, _ ...string) { r.add(http.MethodPost, p) }
func (r *recordingRouter) Put(p string, _ http.HandlerFunc, _ ...string) { r.add(http.MethodPut, p) }
func (r *recordingRouter) Patch(p string, _ http.HandlerFunc, _ ...string) {
r.add(http.MethodPatch, p)
}
func (r *recordingRouter) Delete(p string, _ http.HandlerFunc, _ ...string) {
r.add(http.MethodDelete, p)
}
func (r *recordingRouter) Where(string, string) {}
func (r *recordingRouter) WhereIn(string, ...string) {}
// routesOf records the routes the plugin registers.
func routesOf(t *testing.T, p *Plugin) map[string]bool {
t.Helper()
rec := &recordingRouter{routes: map[string]bool{}}
if err := p.Routes(rec); err != nil {
t.Fatal(err)
}
return rec.routes
}
// configKeys reads the top-level keys and toolbar buttons of a controller
// config file; a missing file answers none.
func configKeys(t *testing.T, path string) (keys map[string]bool, buttons []string) {
t.Helper()
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return map[string]bool{}, nil
}
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := yaml.Unmarshal(raw, &doc); err != nil {
t.Fatalf("%s: %v", path, err)
}
keys = map[string]bool{}
for k := range doc {
keys[k] = true
}
if tb, ok := doc["toolbar"].(map[string]any); ok {
if list, ok := tb["buttons"].([]any); ok {
for _, b := range list {
if s, ok := b.(string); ok {
buttons = append(buttons, s)
}
}
}
}
return keys, buttons
}
// requiredOperations derives the operations of the SEO admin from the
// controllers, their configs, actions, field types and form extensions.
func requiredOperations(t *testing.T, p *Plugin) []AdminOperation {
t.Helper()
var out []AdminOperation
need := func(controller, kind string) {
out = append(out, AdminOperation{Controller: controller, Kind: kind})
}
noApp := func() *backpack.App { return nil }
for _, c := range controllers.AdminControllers(noApp) {
id := c.ID()
formKeys, _ := configKeys(t, filepath.Join(c.ConfigDir(), "config_form.yaml"))
listKeys, buttons := configKeys(t, filepath.Join(c.ConfigDir(), "config_list.yaml"))
if _, ok := c.(pact.AdminSingleton); ok {
need(id, OpShow)
need(id, OpUpdate)
} else {
if len(listKeys) > 0 {
need(id, OpList)
}
if len(formKeys) > 0 {
need(id, OpShow)
}
if formKeys["update"] {
need(id, OpUpdate)
}
}
for _, b := range buttons {
switch b {
case "create":
need(id, OpCreate)
case "delete":
need(id, OpDelete)
}
}
if h, ok := c.(pact.HasAdminRecordActions); ok {
for _, a := range h.AdminRecordActions() {
need(id, OpRecordAction+":"+a.Name)
}
}
if h, ok := c.(pact.HasAdminBulkActions); ok {
for _, a := range h.AdminBulkActions() {
need(id, OpBulkAction+":"+a.Name)
}
}
if h, ok := c.(pact.HasAdminActions); ok {
for _, a := range h.AdminActions() {
need(id, OpWidgetAction+":"+a.Name)
}
}
}
// The form extensions the plugin adds (AdminActionExtensions needs a
// booted registry, so each builder is called directly).
exts := []pact.AdminActionExtension{
{Controller: ContentController, Form: p.seoTab("acme-page", "Page")},
{Controller: controllers.SettingsControllerID, Form: p.typeSettingsTabs([]pact.MenuItemType{{Key: "acme-page", Label: "Page"}})},
{Controller: controllers.RulesControllerID, Form: p.ruleHistory()},
{Controller: controllers.RedirectsControllerID, Form: p.redirectChecks()},
{Controller: controllers.RobotsControllerID, Form: p.robotsForm()},
}
for _, ext := range exts {
if ext.Form == nil {
t.Fatalf("extension on %s is nil", ext.Controller)
}
if ext.Form.Save != nil {
need(ext.Controller, OpExtensionSave)
}
if ext.Form.Load != nil {
need(ext.Controller, OpShow)
}
for _, a := range ext.Form.Actions {
need(ext.Controller, OpWidgetAction+":"+a.Name)
}
}
for _, ft := range classes.FieldTypes(noApp, controllers.PermissionManage) {
if ft.Upload != nil {
need(ContentController, OpUpload)
break
}
}
return out
}
// TestEveryAdminOperationHasTool is the D-10 parity check (SC-4): every
// operation of the SEO admin is listed in AdminOperations, every listed
// tool exists in MCP(), and every tool is bound to a route Routes()
// registers (pier refuses an unbound tool at boot).
func TestEveryAdminOperationHasTool(t *testing.T) {
p := &Plugin{}
tools := map[string]pact.MCPTool{}
for _, tool := range p.MCP().Tools {
if _, dup := tools[tool.Name]; dup {
t.Errorf("tool %s is declared twice", tool.Name)
}
tools[tool.Name] = tool
}
mapped := map[string]string{}
for _, op := range AdminOperations {
mapped[op.Controller+" "+op.Kind] = op.Tool
if _, ok := tools[op.Tool]; !ok {
t.Errorf("operation %s %s maps to tool %q, which MCP() does not declare", op.Controller, op.Kind, op.Tool)
}
}
required := requiredOperations(t, p)
if len(required) < 20 {
t.Fatalf("derived only %d operations; the inventory is broken", len(required))
}
for _, op := range required {
if _, ok := mapped[op.Controller+" "+op.Kind]; !ok {
t.Errorf("admin operation %s %s has no MCP tool in AdminOperations", op.Controller, op.Kind)
}
}
routes := routesOf(t, p)
for _, tool := range tools {
if !routes[tool.Method+" "+tool.Path] {
t.Errorf("tool %s binds to %s %s, which Routes does not register", tool.Name, tool.Method, tool.Path)
}
if !strings.HasPrefix(tool.Path, "/_seo/api/v1/") {
t.Errorf("tool %s is not on the management API: %s", tool.Name, tool.Path)
}
}
}
// TestMCPReadToolsAreGET checks the read/write ceiling (D-10, T-32-23):
// a tool is read-only exactly when it is a GET (so a read grant sees only
// GET tools), destructive tools are never GET, and every tool runs as the
// backend guard behind golem15.seo.manage.
func TestMCPReadToolsAreGET(t *testing.T) {
p := &Plugin{}
reads := 0
for _, tool := range p.MCP().Tools {
get := tool.Method == http.MethodGet
readOnly := get
if tool.ReadOnly != nil {
readOnly = *tool.ReadOnly
}
if readOnly != get {
t.Errorf("tool %s: read-only %v but method %s", tool.Name, readOnly, tool.Method)
}
if get {
reads++
}
if tool.Destructive != nil && *tool.Destructive && get {
t.Errorf("tool %s is a destructive GET", tool.Name)
}
if tool.Method == http.MethodDelete && tool.Destructive != nil && !*tool.Destructive {
t.Errorf("tool %s: a DELETE marked not destructive", tool.Name)
}
if tool.Guard != "backend" {
t.Errorf("tool %s: guard %q, want backend", tool.Name, tool.Guard)
}
if !slices.Equal(tool.Permissions, []string{controllers.PermissionManage}) {
t.Errorf("tool %s: permissions %v, want [%s]", tool.Name, tool.Permissions, controllers.PermissionManage)
}
}
if reads == 0 {
t.Fatal("no read tool")
}
}