diff --git a/README.md b/README.md index 39bc96a..c65d9ba 100644 --- a/README.md +++ b/README.md @@ -400,7 +400,7 @@ Postgres integration smokes start Docker unless `-short`. Missing Docker is a fa Personal API token coverage: `TestResolveErrorMapping` (Docker-free, `controllers/api`) maps resolver errors to the PHP bodies; `TestJournalTokenTracer` and `TestJournalTokenExpansion` (root package, Postgres) mint tokens through the admin account API and exercise write, read-only, unrelated, revoked, featured-image and code-declared-role paths. -`TestJournalMCPSmoke` (root package, Docker-free) pins the seven tools and the prompt (guard, permission, destructive delete, the prompt's arguments and text) and checks that each tool's method and path is a route the plugin registers; `TestOptionalBackendPrincipalDispatch` (`controllers/api`) checks that the list and show handlers see a dispatched backend principal and stay anonymous without one. +`TestJournalMCPSmoke` (root package, Docker-free) pins the seven tools and the prompt (guard, permission, destructive delete, the prompt's arguments and text) and checks that each tool's method and path is a route the plugin registers; `TestOptionalBackendPrincipalDispatch` (`controllers/api`) checks that the list and show handlers see a dispatched backend principal and stay anonymous without one. `TestJournalPromptWrites` (root package, Docker-free) checks that `draft_post` declares `Writes` and, through a served pier endpoint, that a read-only grant's `prompts/list` omits `journal_draft_post` while a read and write grant lists it. `TestMenuItemTypesSmoke` (root package, Postgres) checks the five registered types and resolves each through the registry with host and item pattern overrides, ordered alternates, nesting, the published filter and the `x-default` rule. diff --git a/mcp_prompt_writes_test.go b/mcp_prompt_writes_test.go new file mode 100644 index 0000000..f4bd8b2 --- /dev/null +++ b/mcp_prompt_writes_test.go @@ -0,0 +1,193 @@ +package journal_test + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + journal "git.golem15.com/golem15/sm-journal-plugin" + "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" +) + +// servingHost is mountHost that also keeps the endpoint pier mounts, so +// the test can talk to it. +type servingHost struct { + mountHost + path string + handlers map[string]http.Handler +} + +func (h *servingHost) MountStream(method, path string, handler http.Handler, _ ...string) error { + h.handlers[method] = handler + h.path = path + return nil +} + +func (h *servingHost) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if handler, ok := h.handlers[r.Method]; ok && r.URL.Path == h.path { + handler.ServeHTTP(w, r) + return + } + http.NotFound(w, r) +} + +// promptGrants verifies two OAuth grants of one linked user: read only and +// read+write, both with the backend scope. +type promptGrants struct{} + +func (promptGrants) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant, error) { + scopes := map[string][]string{"oas_at_reader": {"read", "backend"}, "oas_at_writer": {"read", "write", "backend"}}[raw] + if scopes == nil { + return pact.MCPGrant{}, pact.ErrMCPTokenInvalid + } + return pact.MCPGrant{UserID: 5, ClientID: "agent", TokenID: 9, Scopes: scopes, ExpiresAt: time.Now().Add(time.Hour)}, nil +} + +func (promptGrants) MCPResource() string { return "https://app.example.test/mcp" } + +// linkedAdmin links frontend user 5 to backend user 1. +type linkedAdmin struct{} + +func (linkedAdmin) LinkedBackendUser(context.Context, uint) (pact.LinkedBackendUser, bool, error) { + return pact.LinkedBackendUser{ID: 1, Login: "editor"}, true, nil +} + +// allowingBackend stands in for the backend guard provider (which needs the +// admin database): it allows the linked admin everything. +type allowingBackend struct{} + +func (allowingBackend) MCPGuardName() string { return pier.BackendGuard } +func (allowingBackend) MCPTokenGuarded(mw []string) bool { + return slices.Contains(mw, "backend") +} +func (allowingBackend) MCPRequirements([]string) (pact.MCPRequirements, error) { + return pact.MCPRequirements{}, nil +} +func (allowingBackend) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) { + return true, nil +} +func (allowingBackend) MCPDispatch(_ context.Context, s pact.MCPSubject, _ string) (pact.MCPDispatch, error) { + return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID, Backend: true}, Guards: []string{pier.BackendGuard}}, nil +} + +// promptsFor opens a stateful session with bearer and answers the prompt +// names prompts/list returns. +func promptsFor(t *testing.T, url, bearer string) []string { + t.Helper() + post := func(session, body string) (string, string) { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+bearer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Mcp-Protocol-Version", "2025-11-25") + if session != "" { + req.Header.Set("Mcp-Session-Id", session) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode >= 300 { + raw, _ := io.ReadAll(res.Body) + t.Fatalf("POST %s = %d %s", body, res.StatusCode, raw) + } + scanner := bufio.NewScanner(res.Body) + scanner.Buffer(make([]byte, 1<<20), 1<<20) + for scanner.Scan() { + if line := scanner.Text(); strings.HasPrefix(line, "data: ") { + return res.Header.Get("Mcp-Session-Id"), strings.TrimPrefix(line, "data: ") + } + } + return res.Header.Get("Mcp-Session-Id"), "" + } + session, _ := post("", `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"journal-test","version":"1"}}}`) + if session == "" { + t.Fatal("initialize opened no session") + } + post(session, `{"jsonrpc":"2.0","method":"notifications/initialized"}`) + _, body := post(session, `{"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}`) + var out struct { + Result struct { + Prompts []struct { + Name string `json:"name"` + } `json:"prompts"` + } `json:"result"` + } + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("prompts/list = %s (%v)", body, err) + } + var names []string + for _, p := range out.Result.Prompts { + names = append(names, p.Name) + } + return names +} + +// TestJournalPromptWrites pins plan 08's prompt ceiling for the blog plugin +// (C-IN-01): draft_post declares Writes, and through pier's catalog (the +// TestJournalMCP harness, served) a read-only grant's prompts/list omits +// journal_draft_post while a read and write grant lists it. Runs under +// -short. +func TestJournalPromptWrites(t *testing.T) { + decl := (&journal.Plugin{}).MCP() + t.Run("draft_post declares Writes", func(t *testing.T) { + i := slices.IndexFunc(decl.Prompts, func(p pact.MCPPrompt) bool { return p.Name == "draft_post" }) + if i < 0 || !decl.Prompts[i].Writes { + t.Fatalf("draft_post = %+v, want Writes", decl.Prompts) + } + }) + + t.Run("prompts/list follows the read/write ceiling", func(t *testing.T) { + app, plugins := journalApp(t) + router, err := surf.BuildRouter(app, plugins) + if err != nil { + t.Fatal(err) + } + if err := app.Publish[pact.MCPTokenVerifier](promptGrants{}); err != nil { + t.Fatal(err) + } + if err := app.Publish[pact.MCPLinkedBackendResolver](linkedAdmin{}); err != nil { + t.Fatal(err) + } + guards := pact.NewMCPGuards() + if err := guards.Register(allowingBackend{}); err != nil { + t.Fatal(err) + } + if err := app.Publish(guards); err != nil { + t.Fatal(err) + } + if err := app.Config.Set(pier.EnabledKey, true); err != nil { + t.Fatal(err) + } + host := &servingHost{mountHost: mountHost{r: router}, handlers: map[string]http.Handler{}} + if err := pier.Mount(host, app, plugins); err != nil { + t.Fatalf("pier.Mount: %v", err) + } + srv := httptest.NewServer(host) + t.Cleanup(srv.Close) + endpoint := srv.URL + host.path + + if got := promptsFor(t, endpoint, "oas_at_reader"); slices.Contains(got, "journal_draft_post") { + t.Fatalf("a read-only grant lists %v", got) + } + if got := promptsFor(t, endpoint, "oas_at_writer"); !slices.Contains(got, "journal_draft_post") { + t.Fatalf("a read and write grant lists %v, want journal_draft_post", got) + } + }) +}