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" ) // 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_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", } // studentQueryKeys are the query keys each student handler reads // (controllers/api: query(r, ...)), by tool name. A tool query field outside // these would be sent and silently ignored. var studentQueryKeys = map[string][]string{ "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 strings.HasPrefix(tool.Path, bm.TokenPrefix+"/") && !slices.Contains(studentQueryKeys[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) } } } } }) } // 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) } }