- /_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
243 lines
7.6 KiB
Go
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")
|
|
}
|
|
}
|