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" bm "git.golem15.com/jakub/sm-bm-plugin" "git.golem15.com/jakub/sm-bm-plugin/controllers/api" "git.golem15.com/jakub/sm-bm-plugin/models" ) // mountHost adapts a built surf router to pier.Host for a compile-only // mount: pier compiles every declared tool against the real route table. type mountHost struct{ r *surf.Router } func (h mountHost) Routes() []pier.RouteInfo { var out []pier.RouteInfo for _, ri := range h.r.Routes() { out = append(out, pier.RouteInfo{Method: ri.Method, Pattern: ri.Pattern, PluginID: ri.PluginID, Middleware: ri.Middleware}) } return out } func (h mountHost) Handler(method, pattern string) (http.Handler, bool) { return h.r.Handler(method, pattern) } func (mountHost) MountStream(string, string, http.Handler, ...string) error { return nil } func (mountHost) MountJSON(string, string, http.Handler) error { return nil } func (h mountHost) Owner(method, path string) (string, bool) { for _, ri := range h.r.Routes() { if ri.Method == method && ri.Pattern == path { return ri.PluginID, true } } return "", false } // pinnedTools is the sorted tool list of golem15.user plus jz.bm. var pinnedTools = []string{ "bm_course", "bm_courses", "bm_dashboard", "bm_deadlines", "bm_manage_attach_material_file", "bm_manage_create_class_session", "bm_manage_create_course", "bm_manage_create_deadline", "bm_manage_create_meeting", "bm_manage_create_reading", "bm_manage_delete_class_session", "bm_manage_delete_deadline", "bm_manage_delete_material", "bm_manage_delete_reading", "bm_manage_detach_material_file", "bm_manage_list_class_sessions", "bm_manage_list_courses", "bm_manage_list_deadlines", "bm_manage_list_materials", "bm_manage_list_meetings", "bm_manage_list_readings", "bm_manage_save_material", "bm_manage_show_class_session", "bm_manage_show_course", "bm_manage_show_deadline", "bm_manage_show_material", "bm_manage_show_meeting", "bm_manage_show_reading", "bm_manage_update_class_session", "bm_manage_update_course", "bm_manage_update_deadline", "bm_manage_update_material", "bm_manage_update_meeting", "bm_manage_update_reading", "bm_mark_reading_owned", "bm_mark_reading_read", "bm_material", "bm_materials", "bm_meetings", "bm_readings", "bm_schedule", "bm_unmark_reading_owned", "bm_unmark_reading_read", "user_me", } // queryKeys are the query keys each handler reads, by tool name: the // student handlers' query(r, ...) and the management record Filters // (routes.go) plus include_body of the material views. A tool query field // outside these would be sent and silently ignored. var queryKeys = map[string][]string{ "manage_list_courses": {"semester", "form", "code"}, "manage_list_class_sessions": {"course_id", "meeting_id"}, "manage_list_readings": {"course_id", "meeting_id", "is_required"}, "manage_list_deadlines": {"course_id", "kind"}, "manage_list_materials": {"include_body"}, "manage_show_material": {"include_body"}, "courses": {"semester"}, "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. func bmTools() []pact.MCPTool { return (&bm.Plugin{}).MCP().Tools } // TestBMMCP (criterion 4, D-09, D-14, D-15) mounts pier against golem15.user // and jz.bm without a database and pins the compiled tools; every tool binds // a token-guarded jz.bm route, names its scope, and reads only keys the // route reads. Runs under -short. func TestBMMCP(t *testing.T) { tools := bmTools() t.Run("compiles", func(t *testing.T) { app := backpack.New(testConfig(t)) if err := app.Config.Set("app.url", "https://bm.example.test"); err != nil { t.Fatal(err) } plugins := activate(t, app) router, err := surf.BuildRouter(app, plugins) if err != nil { t.Fatalf("BuildRouter: %v", err) } if err := app.Config.Set(pier.EnabledKey, true); err != nil { t.Fatal(err) } if err := pier.Mount(mountHost{r: router}, app, plugins); err != nil { t.Fatalf("pier.Mount: %v", err) } p, ok := app.Lookup[*pier.Pier]() if !ok { t.Fatal("pier was not mounted") } if got := p.ToolNames(); !slices.Equal(got, pinnedTools) { t.Fatalf("compiled tools %v, want %v", got, pinnedTools) } }) t.Run("routes", func(t *testing.T) { for _, tool := range tools { if !strings.HasPrefix(tool.Path, bm.TokenPrefix+"/") && !strings.HasPrefix(tool.Path, bm.ManagePrefix+"/") { t.Errorf("%s binds %s, outside %s and %s", tool.Name, tool.Path, bm.TokenPrefix, bm.ManagePrefix) } if tool.Guard != "user" { t.Errorf("%s: Guard %q, want user", tool.Name, tool.Guard) } } }) t.Run("descriptions", func(t *testing.T) { for _, tool := range tools { if !strings.Contains(tool.Description, "Needs scope read") && !strings.Contains(tool.Description, "Needs scope write") { t.Errorf("%s: description does not name its scope: %q", tool.Name, tool.Description) } if tool.Title == "" { t.Errorf("%s has no title", tool.Name) } } }) t.Run("inputs", func(t *testing.T) { for _, tool := range tools { rt := reflect.TypeOf(tool.Input) for rt.Kind() == reflect.Pointer { rt = rt.Elem() } if rt.Kind() != reflect.Struct { t.Errorf("%s: input is %s, not a struct", tool.Name, rt.Kind()) continue } for i := 0; i < rt.NumField(); i++ { f := rt.Field(i) key, _, _ := strings.Cut(f.Tag.Get("json"), ",") if key == "dry_run" { t.Errorf("%s has a dry_run field (D-14)", tool.Name) } switch { case f.Tag.Get("path") != "": if !strings.Contains(tool.Path, "{"+f.Tag.Get("path")+"}") { t.Errorf("%s.%s: path tag %q not in %s", tool.Name, f.Name, f.Tag.Get("path"), tool.Path) } case f.Tag.Get("query") != "": if !slices.Contains(queryKeys[tool.Name], f.Tag.Get("query")) { t.Errorf("%s.%s: query key %q is not one the handler reads", tool.Name, f.Name, f.Tag.Get("query")) } default: if tool.Method == http.MethodGet || tool.Method == http.MethodDelete { t.Errorf("%s.%s: a %s tool carries a body field %q", tool.Name, f.Name, tool.Method, key) } } } } }) t.Run("management", func(t *testing.T) { testManageTools(t, tools) }) } // recordModel is what the management record tests read of a model. type recordModel interface { Fillable() []string Rules() map[string]string } // manageTables maps a management path segment to its model (nil for // materials), its permission code and whether the route can delete. var manageTables = map[string]struct { model recordModel perm string deletable bool }{ "courses": {models.Course{}, "jz.bm.access_courses", false}, "meetings": {models.Meeting{}, "jz.bm.access_schedule", false}, "class-sessions": {models.ClassSession{}, "jz.bm.access_schedule", true}, "readings": {models.Reading{}, "jz.bm.access_courses", true}, "deadlines": {models.Deadline{}, "jz.bm.access_schedule", true}, "materials": {nil, "jz.bm.access_materials", true}, } // materialBodyKeys are the keys POST and PUT /materials read (materialInput). var materialBodyKeys = []string{"course_id", "meeting_id", "title", "type", "body", "source", "url", "published_at", "tags"} // bodyFields returns the JSON keys of a tool input's body fields and // whether each is optional (omitempty). func bodyFields(input any) map[string]bool { rt := reflect.TypeOf(input) for rt.Kind() == reflect.Pointer { rt = rt.Elem() } out := map[string]bool{} for i := 0; i < rt.NumField(); i++ { f := rt.Field(i) if f.Tag.Get("path") != "" || f.Tag.Get("query") != "" { continue } key, opts, _ := strings.Cut(f.Tag.Get("json"), ",") out[key] = strings.Contains(opts, "omitempty") } return out } // requiredColumns are the columns whose rules start with or contain // required. func requiredColumns(m recordModel) []string { var out []string for col, rule := range m.Rules() { if slices.Contains(strings.Split(rule, "|"), "required") { out = append(out, col) } } return out } // testManageTools (D-13, D-14, D-15, D-22) checks the management tools // against the routes and the models. func testManageTools(t *testing.T, tools []pact.MCPTool) { t.Helper() deletes := map[string]bool{} manage := 0 for _, tool := range tools { if !strings.HasPrefix(tool.Name, "manage_") { if !strings.HasPrefix(tool.Path, bm.TokenPrefix+"/") { t.Errorf("%s binds %s, outside the token twins", tool.Name, tool.Path) } continue } manage++ if !strings.HasPrefix(tool.Path, bm.ManagePrefix+"/") { t.Errorf("%s binds %s, outside %s", tool.Name, tool.Path, bm.ManagePrefix) continue } rest := strings.TrimPrefix(tool.Path, bm.ManagePrefix+"/") segment, _, _ := strings.Cut(rest, "/") table, ok := manageTables[segment] if !ok { t.Errorf("%s binds %s, not a managed table (seed and me are not tools)", tool.Name, tool.Path) continue } if !strings.Contains(tool.Description, "Needs scope") || !strings.Contains(tool.Description, "jz.bm.access_") { t.Errorf("%s: description names no scope or permission: %q", tool.Name, tool.Description) } if !strings.Contains(tool.Description, table.perm) { t.Errorf("%s: description does not name %s", tool.Name, table.perm) } if tool.Method == http.MethodDelete { deletes[segment] = true if !table.deletable { t.Errorf("%s deletes %s, which the route cannot", tool.Name, segment) } if !strings.Contains(tool.Description, "cannot be undone") { t.Errorf("%s: description does not say the delete cannot be undone", tool.Name) } } fields := bodyFields(tool.Input) if segment == "materials" && strings.Contains(rest, "/files") { testFileTool(t, tool, fields) if tool.Method == http.MethodDelete { deletes[segment] = true } continue } if segment == "materials" { if !strings.Contains(tool.Description, bm.MCPMaterialPrivacy) { t.Errorf("%s: description lacks the material privacy sentence", tool.Name) } for key := range fields { if !slices.Contains(materialBodyKeys, key) { t.Errorf("%s: body key %q is not a material key", tool.Name, key) } } if tool.Method == http.MethodPost || tool.Method == http.MethodPut { for _, key := range []string{"title", "type", "body", "source", "url"} { if optional, ok := fields[key]; !ok || optional { t.Errorf("%s: %s must be a required field (present %v)", tool.Name, key, ok) } } if !strings.Contains(tool.Description, "replaced as sent") { t.Errorf("%s: description does not say body, source and url are replaced as sent", tool.Name) } } continue } fillable := table.model.Fillable() for key := range fields { if !slices.Contains(fillable, key) { t.Errorf("%s: body key %q is not a Fillable column of %s", tool.Name, key, segment) } } switch tool.Method { case http.MethodPost: for _, col := range fillable { if _, ok := fields[col]; !ok { t.Errorf("%s: Fillable column %s has no field", tool.Name, col) } } for _, col := range requiredColumns(table.model) { if optional, ok := fields[col]; !ok || optional { t.Errorf("%s: required column %s is not a required field", tool.Name, col) } } case http.MethodPatch: for _, col := range fillable { if optional, ok := fields[col]; !ok || !optional { t.Errorf("%s: column %s must be an optional field (present %v)", tool.Name, col, ok) } } } } if manage != manageToolCount { t.Errorf("%d management tools, want %d", manage, manageToolCount) } want := map[string]bool{"class-sessions": true, "readings": true, "deadlines": true, "materials": true} if !reflect.DeepEqual(deletes, want) { t.Errorf("delete tools for %v, want %v", deletes, want) } } // manageToolCount is the number of management tools: 28 record and // material tools plus the material file tools. const manageToolCount = 30 // fileBodyKeys are the body keys of each material file tool, exactly. var fileBodyKeys = map[string]map[string]bool{ "manage_attach_material_file": {"name": false, "mime": true, "content_base64": false}, "manage_detach_material_file": {}, } // testFileTool (D-10, D-11, D-17) checks one material file tool: its body // keys, the public-file warning and the privacy sentence, and that the // detach tool is destructive. func testFileTool(t *testing.T, tool pact.MCPTool, fields map[string]bool) { t.Helper() want, ok := fileBodyKeys[tool.Name] if !ok { t.Errorf("%s: not a known material file tool", tool.Name) return } if !reflect.DeepEqual(fields, want) { t.Errorf("%s: body keys (optional) %v, want %v", tool.Name, fields, want) } if !strings.Contains(tool.Description, api.MaterialFileWarning) { t.Errorf("%s: description lacks the public-file warning", tool.Name) } if !strings.Contains(tool.Description, bm.MCPMaterialPrivacy) { t.Errorf("%s: description lacks the material privacy sentence", tool.Name) } if !strings.Contains(tool.Description, "Needs scope write and the jz.bm.access_materials permission") { t.Errorf("%s: description does not name scope write and jz.bm.access_materials", tool.Name) } rt := reflect.TypeOf(tool.Input) idPath := false for i := 0; i < rt.NumField(); i++ { if rt.Field(i).Tag.Get("path") == "id" { idPath = true } } if !idPath { t.Errorf("%s: no id path field", tool.Name) } if tool.Name == "manage_detach_material_file" { if tool.Method != http.MethodDelete || (tool.Destructive != nil && !*tool.Destructive) { t.Errorf("%s is not destructive (method %s)", tool.Name, tool.Method) } } else if tool.Method == http.MethodDelete || (tool.Destructive != nil && *tool.Destructive) { t.Errorf("%s is destructive", tool.Name) } } // studentRoutes are the routes mountStudent registers, in order. var studentRoutes = []string{ "GET /dashboard", "GET /courses", "GET /courses/{id}", "GET /schedule", "GET /meetings", "GET /deadlines", "GET /readings", "PUT /readings/{id}/progress", "DELETE /readings/{id}/progress", "PUT /readings/{id}/owned", "DELETE /readings/{id}/owned", "GET /materials", "GET /materials/{id}", } // TestTokenMirrorRoutes is the invariant of the two student surfaces: every // student route exists under APIPrefix (jwt.auth, no user.* middleware) and // under TokenPrefix (user.api_token, the token bucket and the matching // scope, no jwt.auth); the calendar feed and the chat have no twin. func TestTokenMirrorRoutes(t *testing.T) { app := backpack.New(testConfig(t)) plugins := activate(t, app) router, err := surf.BuildRouter(app, plugins) if err != nil { t.Fatalf("BuildRouter: %v", err) } byRoute := map[string][]string{} for _, rt := range router.Routes() { if rt.PluginID != "jz.bm" { continue } byRoute[rt.Method+" "+rt.Pattern] = rt.Middleware if strings.HasPrefix(rt.Pattern, bm.TokenPrefix+"/") { rest := strings.TrimPrefix(rt.Pattern, bm.TokenPrefix) if !slices.Contains(studentRoutes, rt.Method+" "+rest) { t.Errorf("%s %s under the token prefix is not a student route", rt.Method, rt.Pattern) } } } for _, route := range studentRoutes { method, pattern, _ := strings.Cut(route, " ") jwt, ok := byRoute[method+" "+bm.APIPrefix+pattern] if !ok { t.Errorf("%s %s is not registered", method, bm.APIPrefix+pattern) } else { if !slices.Contains(jwt, "jwt.auth") { t.Errorf("%s %s lacks jwt.auth: %v", method, bm.APIPrefix+pattern, jwt) } if slices.ContainsFunc(jwt, func(name string) bool { return strings.HasPrefix(name, "user.") }) { t.Errorf("%s %s carries user.* middleware: %v", method, bm.APIPrefix+pattern, jwt) } } tok, ok := byRoute[method+" "+bm.TokenPrefix+pattern] if !ok { t.Errorf("%s %s (the token twin) is not registered", method, bm.TokenPrefix+pattern) continue } scope := "user.scope:write" if method == http.MethodGet { scope = "user.scope:read" } for _, name := range []string{"user.api_token", "throttle:jz.bm.token", scope} { if !slices.Contains(tok, name) { t.Errorf("%s %s lacks %s: %v", method, bm.TokenPrefix+pattern, name, tok) } } if slices.Contains(tok, "jwt.auth") { t.Errorf("%s %s carries jwt.auth: %v", method, bm.TokenPrefix+pattern, tok) } if slices.Index(tok, "user.api_token") > slices.Index(tok, "throttle:jz.bm.token") { t.Errorf("%s %s: the token bucket runs before the guard: %v", method, bm.TokenPrefix+pattern, tok) } } for route := range byRoute { if strings.Contains(route, bm.TokenPrefix+"/calendar-feed") || strings.Contains(route, bm.TokenPrefix+"/chat") { t.Errorf("%s has a token twin", route) } } } // 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) } }