test: TestJournalPromptWrites pins draft_post under the read/write ceiling
draft_post declares Writes, and through a served pier endpoint a read-only grant's prompts/list omits journal_draft_post while a read and write grant lists it (C-IN-01).
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
193
mcp_prompt_writes_test.go
Normal file
193
mcp_prompt_writes_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user