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 and consented for the linked // backend user 1 (pact.MCPGrant.BackendUserID). 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), BackendUserID: 1}, 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) } }) }