test: expect the MCP view of BM answers
- viaMCP, includeBody and the material views (no body unless asked) - jz.bm.mcp-links: absolute url / *_url under MCP only - jz.bm.manage keyed per user for dispatched calls - bm_material and bm_materials in the pinned tool list Compile-only stubs; the tests fail on their assertions.
This commit is contained in:
20
controllers/api/mcp_view.go
Normal file
20
controllers/api/mcp_view.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// viaMCP reports whether r is an MCP tool call.
|
||||
func viaMCP(r *http.Request) bool { return false }
|
||||
|
||||
// includeBody reports whether r asks for the material body.
|
||||
func includeBody(r *http.Request) bool { return false }
|
||||
|
||||
// materialView is the answer of GET /materials/{id} for r.
|
||||
func materialView(r *http.Request, d materialDetailJSON) any { return d }
|
||||
|
||||
// managedMaterialView is a managed material for r.
|
||||
func managedMaterialView(r *http.Request, m managedMaterialJSON, write bool) any { return m }
|
||||
|
||||
// managedMaterialDetailView is a managed material with its files for r.
|
||||
func managedMaterialDetailView(r *http.Request, d managedMaterialDetailJSON, write bool) any { return d }
|
||||
166
controllers/api/mcp_view_test.go
Normal file
166
controllers/api/mcp_view_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
)
|
||||
|
||||
// mcpRequest is a request as pier dispatches it in-process: the api_token
|
||||
// marker with a principal on its context.
|
||||
func mcpRequest(method, target string) *http.Request {
|
||||
r := httptest.NewRequest(method, target, nil)
|
||||
return r.WithContext(bouncer.WithDispatch(r.Context(), &bouncer.Dispatch{
|
||||
Guards: []string{"api_token"},
|
||||
Principal: &bouncer.Principal{ID: 7},
|
||||
}))
|
||||
}
|
||||
|
||||
// keysOf marshals v and returns its top-level JSON object.
|
||||
func keysOf(t *testing.T, v any) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestViaMCP(t *testing.T) {
|
||||
plain := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if viaMCP(plain) {
|
||||
t.Error("a plain request is an MCP call")
|
||||
}
|
||||
if !viaMCP(mcpRequest(http.MethodGet, "/")) {
|
||||
t.Error("a dispatched api_token request is not an MCP call")
|
||||
}
|
||||
backend := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
backend = backend.WithContext(bouncer.WithDispatch(backend.Context(), &bouncer.Dispatch{
|
||||
Guards: []string{"backend"},
|
||||
Principal: &bouncer.Principal{ID: 7},
|
||||
}))
|
||||
if viaMCP(backend) {
|
||||
t.Error("a backend dispatch is an MCP call of jz.bm")
|
||||
}
|
||||
for target, want := range map[string]bool{
|
||||
"/": false,
|
||||
"/?include_body=false": false,
|
||||
"/?include_body=0": false,
|
||||
"/?include_body=true": true,
|
||||
"/?include_body=1": true,
|
||||
} {
|
||||
if got := includeBody(httptest.NewRequest(http.MethodGet, target, nil)); got != want {
|
||||
t.Errorf("includeBody(%s) = %v, want %v", target, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPMaterialView(t *testing.T) {
|
||||
body := "transcript text"
|
||||
url := "https://x.test/a"
|
||||
detail := materialDetailJSON{
|
||||
materialListJSON: materialListJSON{ID: 3, Title: "T", Type: "notatka", URL: &url, Tags: []tagJSON{}},
|
||||
Body: &body,
|
||||
Files: []fileJSON{{ID: 9, URL: "/storage/f.pdf", Name: "f.pdf", Size: 10, Mime: "application/pdf"}},
|
||||
}
|
||||
|
||||
t.Run("plain request unchanged", func(t *testing.T) {
|
||||
got := keysOf(t, materialView(httptest.NewRequest(http.MethodGet, "/", nil), detail))
|
||||
want := keysOf(t, detail)
|
||||
gotRaw, _ := json.Marshal(got)
|
||||
wantRaw, _ := json.Marshal(want)
|
||||
if string(gotRaw) != string(wantRaw) {
|
||||
t.Errorf("plain view = %s, want %s", gotRaw, wantRaw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCP without include_body", func(t *testing.T) {
|
||||
got := keysOf(t, materialView(mcpRequest(http.MethodGet, "/"), detail))
|
||||
if _, ok := got["body"]; ok {
|
||||
t.Errorf("body present: %v", got)
|
||||
}
|
||||
if got["body_omitted"] != true {
|
||||
t.Errorf("body_omitted = %v, want true", got["body_omitted"])
|
||||
}
|
||||
files, _ := got["files"].([]any)
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("files = %v", got["files"])
|
||||
}
|
||||
f := files[0].(map[string]any)
|
||||
for _, k := range []string{"id", "name", "size", "mime", "url"} {
|
||||
if _, ok := f[k]; !ok {
|
||||
t.Errorf("file lacks %s: %v", k, f)
|
||||
}
|
||||
}
|
||||
if got["title"] != "T" || got["url"] != url {
|
||||
t.Errorf("list fields lost: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCP without include_body, empty body", func(t *testing.T) {
|
||||
empty := detail
|
||||
empty.Body = nil
|
||||
got := keysOf(t, materialView(mcpRequest(http.MethodGet, "/"), empty))
|
||||
if _, ok := got["body"]; ok {
|
||||
t.Errorf("body present: %v", got)
|
||||
}
|
||||
if _, ok := got["body_omitted"]; ok {
|
||||
t.Errorf("body_omitted set for an empty body: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCP with include_body", func(t *testing.T) {
|
||||
got := keysOf(t, materialView(mcpRequest(http.MethodGet, "/?include_body=true"), detail))
|
||||
if got["body"] != body {
|
||||
t.Errorf("body = %v, want %q", got["body"], body)
|
||||
}
|
||||
if _, ok := got["body_omitted"]; ok {
|
||||
t.Errorf("body_omitted set with include_body: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
managed := managedMaterialJSON{ID: 3, Title: "T", Type: "notatka", Body: &body, Tags: []tagJSON{}}
|
||||
|
||||
t.Run("managed plain unchanged", func(t *testing.T) {
|
||||
got := keysOf(t, managedMaterialView(httptest.NewRequest(http.MethodGet, "/", nil), managed, false))
|
||||
if got["body"] != body {
|
||||
t.Errorf("plain managed body = %v", got["body"])
|
||||
}
|
||||
if _, ok := got["body_omitted"]; ok {
|
||||
t.Errorf("plain managed has body_omitted: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed MCP read", func(t *testing.T) {
|
||||
got := keysOf(t, managedMaterialView(mcpRequest(http.MethodGet, "/"), managed, false))
|
||||
if _, ok := got["body"]; ok || got["body_omitted"] != true {
|
||||
t.Errorf("managed MCP read = %v, want no body and body_omitted", got)
|
||||
}
|
||||
got = keysOf(t, managedMaterialView(mcpRequest(http.MethodGet, "/?include_body=1"), managed, false))
|
||||
if got["body"] != body {
|
||||
t.Errorf("managed MCP read with include_body = %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed MCP write never echoes the body", func(t *testing.T) {
|
||||
got := keysOf(t, managedMaterialView(mcpRequest(http.MethodPost, "/?include_body=true"), managed, true))
|
||||
if _, ok := got["body"]; ok || got["body_omitted"] != true {
|
||||
t.Errorf("managed MCP write = %v, want no body and body_omitted", got)
|
||||
}
|
||||
d := managedMaterialDetailJSON{managedMaterialJSON: managed, Files: []fileJSON{}}
|
||||
got = keysOf(t, managedMaterialDetailView(mcpRequest(http.MethodPost, "/?include_body=true"), d, true))
|
||||
if _, ok := got["body"]; ok || got["body_omitted"] != true {
|
||||
t.Errorf("managed MCP write detail = %v, want no body and body_omitted", got)
|
||||
}
|
||||
if _, ok := got["files"]; !ok {
|
||||
t.Errorf("managed MCP write detail lost files: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
9
mcp_links.go
Normal file
9
mcp_links.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package bm
|
||||
|
||||
import "net/http"
|
||||
|
||||
// MiddlewareMCPLinks makes the links of an MCP answer absolute.
|
||||
const MiddlewareMCPLinks = "jz.bm.mcp-links"
|
||||
|
||||
// mcpLinks is not implemented yet.
|
||||
func (p *Plugin) mcpLinks(next http.Handler) http.Handler { return next }
|
||||
144
mcp_test.go
144
mcp_test.go
@@ -1,13 +1,19 @@
|
||||
package bm_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/pier"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
@@ -47,6 +53,8 @@ var pinnedTools = []string{
|
||||
"bm_deadlines",
|
||||
"bm_mark_reading_owned",
|
||||
"bm_mark_reading_read",
|
||||
"bm_material",
|
||||
"bm_materials",
|
||||
"bm_meetings",
|
||||
"bm_readings",
|
||||
"bm_schedule",
|
||||
@@ -63,6 +71,8 @@ var studentQueryKeys = map[string][]string{
|
||||
"schedule": {"from", "to"},
|
||||
"deadlines": {"from", "to"},
|
||||
"readings": {"course"},
|
||||
"materials": {"course", "meeting", "tag", "type", "page", "per_page"},
|
||||
"material": {"include_body"},
|
||||
}
|
||||
|
||||
// bmTools returns jz.bm's declared tools.
|
||||
@@ -240,3 +250,137 @@ func TestTokenMirrorRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatched returns r as pier dispatches it in-process: the api_token
|
||||
// marker with principal id on its context.
|
||||
func dispatched(r *http.Request, id uint) *http.Request {
|
||||
return r.WithContext(bouncer.WithDispatch(r.Context(), &bouncer.Dispatch{
|
||||
Guards: []string{"api_token"},
|
||||
Principal: &bouncer.Principal{ID: id},
|
||||
}))
|
||||
}
|
||||
|
||||
// TestMCPLinks (D-16): under MCP every string under url or *_url that starts
|
||||
// with a single / becomes absolute; other keys, protocol-relative and
|
||||
// absolute URLs, error answers and plain HTTP answers are left alone.
|
||||
func TestMCPLinks(t *testing.T) {
|
||||
const answer = `{"data":{"url":"/storage/x","files":[{"url":"/storage/y","name":"/n"}],"syllabus_url":"/sylabusy/z.pdf","link":"https://x.test/a","other":"/keep","cdn_url":"//cdn.test/a","abs_url":"https://y.test/b","count":12345678901234567890,"nested":{"items":[{"thumb_url":"/t.png"}]}}}`
|
||||
mw := (&bm.Plugin{}).Middlewares()[bm.MiddlewareMCPLinks]
|
||||
if mw == nil {
|
||||
t.Fatalf("%s is not exported", bm.MiddlewareMCPLinks)
|
||||
}
|
||||
handler := func(status int, body string) http.Handler {
|
||||
return mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
}
|
||||
|
||||
t.Run("plain HTTP untouched", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handler(http.StatusOK, answer).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Body.String() != answer {
|
||||
t.Errorf("plain answer = %s, want it unchanged", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCP answer absolute", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handler(http.StatusOK, answer).ServeHTTP(rec, dispatched(httptest.NewRequest(http.MethodGet, "/", nil), 7))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(rec.Body.Len()) {
|
||||
t.Errorf("Content-Length = %s, body is %d bytes", cl, rec.Body.Len())
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
|
||||
dec.UseNumber()
|
||||
var got struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := dec.Decode(&got); err != nil {
|
||||
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
d := got.Data
|
||||
want := map[string]string{
|
||||
"url": "http://example.com/storage/x",
|
||||
"syllabus_url": "http://example.com/sylabusy/z.pdf",
|
||||
"link": "https://x.test/a",
|
||||
"other": "/keep",
|
||||
"cdn_url": "//cdn.test/a",
|
||||
"abs_url": "https://y.test/b",
|
||||
}
|
||||
for k, v := range want {
|
||||
if d[k] != v {
|
||||
t.Errorf("%s = %v, want %s", k, d[k], v)
|
||||
}
|
||||
}
|
||||
file := d["files"].([]any)[0].(map[string]any)
|
||||
if file["url"] != "http://example.com/storage/y" || file["name"] != "/n" {
|
||||
t.Errorf("files[0] = %v", file)
|
||||
}
|
||||
thumb := d["nested"].(map[string]any)["items"].([]any)[0].(map[string]any)
|
||||
if thumb["thumb_url"] != "http://example.com/t.png" {
|
||||
t.Errorf("nested thumb_url = %v", thumb["thumb_url"])
|
||||
}
|
||||
if n, _ := d["count"].(json.Number); n.String() != "12345678901234567890" {
|
||||
t.Errorf("count = %v, want the number kept exactly", d["count"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCP forwarded scheme", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Host = "bm.example.test"
|
||||
r.Header.Set("X-Forwarded-Proto", "https")
|
||||
handler(http.StatusOK, `{"url":"/a"}`).ServeHTTP(rec, dispatched(r, 7))
|
||||
if !strings.Contains(rec.Body.String(), `"https://bm.example.test/a"`) {
|
||||
t.Errorf("answer = %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCP error untouched", func(t *testing.T) {
|
||||
const notFound = `{"error":"not_found","url":"/x"}`
|
||||
rec := httptest.NewRecorder()
|
||||
handler(http.StatusNotFound, notFound).ServeHTTP(rec, dispatched(httptest.NewRequest(http.MethodGet, "/", nil), 7))
|
||||
if rec.Code != http.StatusNotFound || rec.Body.String() != notFound {
|
||||
t.Errorf("error answer = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestManageBucketKey (research Pitfall 6): a dispatched management call is
|
||||
// throttled per user, a bearer request per token hash, a bare one per IP;
|
||||
// the token twins are throttled per user.
|
||||
func TestManageBucketKey(t *testing.T) {
|
||||
buckets := (&bm.Plugin{}).Buckets()
|
||||
manage, ok := buckets["jz.bm.manage"]
|
||||
if !ok {
|
||||
t.Fatal("jz.bm.manage is not declared")
|
||||
}
|
||||
if manage.Max != 120 {
|
||||
t.Errorf("jz.bm.manage Max = %d, want 120", manage.Max)
|
||||
}
|
||||
if got := manage.Key(dispatched(httptest.NewRequest(http.MethodGet, "/", nil), 7)); got != "jz.bm.manage.user|7" {
|
||||
t.Errorf("dispatched key = %q, want jz.bm.manage.user|7", got)
|
||||
}
|
||||
bearer := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
bearer.Header.Set("Authorization", "Bearer usr_secret")
|
||||
if got := manage.Key(bearer); !strings.HasPrefix(got, "jz.bm.manage.token|") || strings.Contains(got, "usr_secret") {
|
||||
t.Errorf("bearer key = %q, want the token hash", got)
|
||||
}
|
||||
if got := manage.Key(httptest.NewRequest(http.MethodGet, "/", nil)); !strings.HasPrefix(got, "jz.bm.manage.ip|") {
|
||||
t.Errorf("bare key = %q, want the IP", got)
|
||||
}
|
||||
token, ok := buckets["jz.bm.token"]
|
||||
if !ok {
|
||||
t.Fatal("jz.bm.token is not declared")
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r = r.WithContext(bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 7}))
|
||||
if got := token.Key(r); got != "jz.bm.token|u:7" {
|
||||
t.Errorf("token twin key = %q, want jz.bm.token|u:7", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,12 @@ const MiddlewareRealtimeAccess = "jz.bm.realtime-access"
|
||||
// here without importing that plugin.
|
||||
const presentationsOwnerGuard = "presentations.realtime-owner"
|
||||
|
||||
// Middlewares exports the realtime access guard.
|
||||
// Middlewares exports the realtime access guard and the MCP link rewriter.
|
||||
func (p *Plugin) Middlewares() map[string]pact.Middleware {
|
||||
return map[string]pact.Middleware{MiddlewareRealtimeAccess: p.realtimeAccess}
|
||||
return map[string]pact.Middleware{
|
||||
MiddlewareRealtimeAccess: p.realtimeAccess,
|
||||
MiddlewareMCPLinks: p.mcpLinks,
|
||||
}
|
||||
}
|
||||
|
||||
// realtimeAccess lets a chat-group member through, otherwise, when
|
||||
|
||||
Reference in New Issue
Block a user