- PUT /_seo/api/v1/seo writes to the record behind a URL in its locale, else to an exact URL rule (force_rule writes a rule); the answer says where (D-11) - management routes resolve the backend administrator from a token or the MCP dispatch and require golem15.seo.manage (D-10, D-15) - MCP tool set_url_seo; history records via mcp - classes.WithTx, FindOwner, SetURLSEO, SaveRule and DeleteRule
149 lines
6.1 KiB
Go
149 lines
6.1 KiB
Go
package seo_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
)
|
|
|
|
// principal resolves the backend administrator of an access token, the
|
|
// identity an MCP session acts as.
|
|
func (e *testEnv) principal(t *testing.T, token string) *bouncer.Principal {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
_, p, err := cabana.ResolveBackend(e.app, req)
|
|
if err != nil || p == nil {
|
|
t.Fatalf("resolve principal: %v", err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// mcp runs one request the way pier dispatches a tool call: in-process,
|
|
// without an Authorization header, with the backend dispatch marker.
|
|
func (e *testEnv) mcp(t *testing.T, method, path string, p *bouncer.Principal, 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, path, reader)
|
|
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", seq.Add(1)%250+1)
|
|
req.Header.Set("Accept-Language", "en")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: p}))
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// mcpExpect runs an MCP-dispatched request and fails unless it answers
|
|
// status; it returns the decoded JSON body.
|
|
func (e *testEnv) mcpExpect(t *testing.T, status int, method, path string, p *bouncer.Principal, body any) map[string]any {
|
|
t.Helper()
|
|
rec := e.mcp(t, method, path, p, body)
|
|
if rec.Code != status {
|
|
t.Fatalf("MCP %s %s = %d, want %d: %s", method, path, rec.Code, status, rec.Body.String())
|
|
}
|
|
out := map[string]any{}
|
|
if rec.Body.Len() > 0 && strings.HasPrefix(strings.TrimSpace(rec.Body.String()), "{") {
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestMCPSetURLSeoTracer is the end-to-end slice of D-10/D-11: an MCP
|
|
// client (an administrator dispatched for the backend guard) sets noindex
|
|
// on a URL a record is behind; the change lands in the record's SEO,
|
|
// resolve serves it as robots and X-Robots-Tag, and history records it
|
|
// with via mcp. A URL without a record gets an exact URL rule, and an
|
|
// administrator without golem15.seo.manage is refused.
|
|
func TestMCPSetURLSeoTracer(t *testing.T) {
|
|
env := bootEnv(t)
|
|
token := env.admin(t, true)
|
|
id := env.createPage(t, token, map[string]any{"title": "Page A", "slug": "a", "excerpt": "About A", "published": true})
|
|
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
|
|
|
|
got := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{
|
|
"url": "/pages/a", "values": map[string]any{"robots": "noindex"},
|
|
})
|
|
if got["written_to"] != "record" || got["type"] != pagesTypeKey || got["id"] != id || got["locale"] != "en" {
|
|
t.Fatalf("set record = %v, want written_to record %s/%s en", got, pagesTypeKey, id)
|
|
}
|
|
|
|
res := env.resolve(t, "/pages/a")
|
|
seoBody, _ := res["seo"].(map[string]any)
|
|
if res["action"] != "ok" || !strings.HasPrefix(fmt.Sprint(seoBody["robots"]), "noindex") || !strings.HasPrefix(fmt.Sprint(seoBody["x_robots_tag"]), "noindex") {
|
|
t.Fatalf("resolve /pages/a = %v, want robots and x_robots_tag noindex", res)
|
|
}
|
|
|
|
revs := env.revisions(t, "entry")
|
|
if len(revs) != 1 || revs[0].Via != "mcp" || !strings.HasPrefix(revs[0].AuthorLabel, "MCP: ") || !strings.Contains(revs[0].After, "noindex") {
|
|
t.Fatalf("entry revisions = %+v, want one via mcp with the MCP author and noindex", revs)
|
|
}
|
|
|
|
got = env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{
|
|
"url": "/landing", "values": map[string]any{"title": "Landing", "robots": "noindex,follow"},
|
|
})
|
|
ruleID, _ := got["rule_id"].(float64)
|
|
if got["written_to"] != "rule" || ruleID == 0 || got["url"] != "/landing" {
|
|
t.Fatalf("set /landing = %v, want written_to rule with a rule id", got)
|
|
}
|
|
var rules []struct {
|
|
URL, Match, Title, Robots string
|
|
}
|
|
if err := env.db.Raw(`SELECT url, match, title, robots FROM golem15_seo_rules`).Scan(&rules).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rules) != 1 || rules[0].URL != "/landing" || rules[0].Match != "exact" || rules[0].Title != "Landing" {
|
|
t.Fatalf("rules = %+v, want one exact /landing rule", rules)
|
|
}
|
|
if revs := env.revisions(t, "rule"); len(revs) != 1 || revs[0].Via != "mcp" {
|
|
t.Fatalf("rule revisions = %+v, want one via mcp", revs)
|
|
}
|
|
res = env.resolve(t, "/landing")
|
|
seoBody, _ = res["seo"].(map[string]any)
|
|
if res["action"] != "ok" || seoBody["title"] != "Landing" {
|
|
t.Fatalf("resolve /landing = %v, want the rule's title", res)
|
|
}
|
|
|
|
// force_rule writes a rule even with a record behind the URL.
|
|
got = env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{
|
|
"url": "/pages/a", "values": map[string]any{"title": "Forced"}, "force_rule": true,
|
|
})
|
|
if got["written_to"] != "rule" {
|
|
t.Fatalf("force_rule = %v, want written_to rule", got)
|
|
}
|
|
|
|
// Without golem15.seo.manage: 403 over MCP and over a bearer token.
|
|
plain := env.admin(t, false)
|
|
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", env.principal(t, plain), map[string]any{"url": "/pages/a", "values": map[string]any{"robots": "index"}}); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("MCP without the permission = %d %s, want 403", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", plain, map[string]any{"url": "/pages/a", "values": map[string]any{"robots": "index"}}); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("bearer without the permission = %d %s, want 403", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", "", map[string]any{"url": "/pages/a"}); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("anonymous = %d, want 401", rec.Code)
|
|
}
|
|
if revs := env.revisions(t, "entry"); len(revs) != 1 {
|
|
t.Fatalf("refused writes changed history: %+v", revs)
|
|
}
|
|
}
|