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:
2026-10-23 18:36:51 +02:00
parent b1299021cd
commit 2aeeccc15f
2 changed files with 194 additions and 1 deletions

View File

@@ -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
View 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)
}
})
}