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() { // An action named in the list toolbar is a toolbar action, // any other one a form widget's. if slices.Contains(buttons, a.Name) { need(id, OpToolbarAction+":"+a.Name) } else { 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()}, {Controller: controllers.PagesControllerID, Form: p.pagesForm()}, } 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") } }