diff --git a/modules/bouncer/dispatch_test.go b/modules/bouncer/dispatch_test.go new file mode 100644 index 00000000..05b2c6ce --- /dev/null +++ b/modules/bouncer/dispatch_test.go @@ -0,0 +1,124 @@ +package bouncer + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +// headerGuard authenticates a request whose Authorization header is exactly +// "Bearer good" as principal 7 and writes its own 401 otherwise, so a test +// sees whether the guard or the dispatch marker decided a request. +type headerGuard struct{ calls *int } + +func (g headerGuard) AuthenticateCredential(r *http.Request) (*Principal, any, error) { + *g.calls++ + if r.Header.Get("Authorization") == "Bearer good" { + return &Principal{ID: 7}, "guard-credential", nil + } + return nil, nil, errors.New("no token") +} + +func (headerGuard) WriteUnauthorized(w http.ResponseWriter, _ error) { + w.WriteHeader(http.StatusUnauthorized) +} + +// TestDispatchMarker (phase 19.7 D-17): Registry.Middleware honours a +// dispatch marker that lists its guard name (the guard does not run, the +// marker's principal and credential reach the handler), ignores a marker for +// other guards and a marker without a principal (the guard decides), and +// never reads a header, cookie or query string for it: a request with forged +// dispatch headers is authenticated by the guard alone. +func TestDispatchMarker(t *testing.T) { + calls := 0 + reg := NewRegistry() + if err := reg.Register("acme", "api_token", headerGuard{calls: &calls}); err != nil { + t.Fatal(err) + } + mw, err := reg.Middleware("api_token") + if err != nil { + t.Fatal(err) + } + type seen struct { + id uint + cred any + ok bool + } + var got seen + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p, ok := User(r.Context()) + cred, _ := Credential(r.Context()) + got = seen{ok: ok, cred: cred} + if ok { + got.id = p.ID + } + w.WriteHeader(http.StatusNoContent) + })) + serve := func(r *http.Request) int { + got = seen{} + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + return rec.Code + } + withMarker := func(r *http.Request, d *Dispatch) *http.Request { + return r.WithContext(WithDispatch(r.Context(), d)) + } + + t.Run("marker for the guard is honoured without running it", func(t *testing.T) { + calls = 0 + r := withMarker(httptest.NewRequest(http.MethodGet, "/x", nil), &Dispatch{Guards: []string{"backend", "api_token"}, Principal: &Principal{ID: 42}, Credential: "marker-credential"}) + if code := serve(r); code != http.StatusNoContent || got.id != 42 || got.cred != "marker-credential" || calls != 0 { + t.Fatalf("code %d, seen %+v, guard calls %d", code, got, calls) + } + }) + + t.Run("marker for other guards is ignored", func(t *testing.T) { + calls = 0 + r := withMarker(httptest.NewRequest(http.MethodGet, "/x", nil), &Dispatch{Guards: []string{"backend"}, Principal: &Principal{ID: 42}}) + if code := serve(r); code != http.StatusUnauthorized || calls != 1 { + t.Fatalf("code %d, guard calls %d", code, calls) + } + r = withMarker(httptest.NewRequest(http.MethodGet, "/x", nil), &Dispatch{Guards: []string{"backend"}, Principal: &Principal{ID: 42}}) + r.Header.Set("Authorization", "Bearer good") + if code := serve(r); code != http.StatusNoContent || got.id != 7 || got.cred != "guard-credential" { + t.Fatalf("guard after an unlisted marker: code %d, seen %+v", code, got) + } + }) + + t.Run("marker without a principal is ignored", func(t *testing.T) { + calls = 0 + r := withMarker(httptest.NewRequest(http.MethodGet, "/x", nil), &Dispatch{Guards: []string{"api_token"}}) + if code := serve(r); code != http.StatusUnauthorized || calls != 1 { + t.Fatalf("code %d, guard calls %d", code, calls) + } + var nilMarker *Dispatch + r = withMarker(httptest.NewRequest(http.MethodGet, "/x", nil), nilMarker) + if code := serve(r); code != http.StatusUnauthorized { + t.Fatalf("nil marker: code %d", code) + } + }) + + t.Run("forged headers and cookies are the guard's", func(t *testing.T) { + calls = 0 + r := httptest.NewRequest(http.MethodGet, "/x?dispatch=42&summer_dispatch=api_token", nil) + r.Header.Set("X-Summer-Dispatch", "42") + r.Header.Set("X-Dispatch-Guards", "api_token") + r.Header.Set("Authorization", "Dispatch 42") + r.AddCookie(&http.Cookie{Name: "summer_dispatch", Value: "42"}) + if code := serve(r); code != http.StatusUnauthorized || calls != 1 || got.ok { + t.Fatalf("forged request: code %d, seen %+v, guard calls %d", code, got, calls) + } + r.Header.Set("Authorization", "Bearer good") + if code := serve(r); code != http.StatusNoContent || got.id != 7 { + t.Fatalf("forged headers with a real token: code %d, seen %+v", code, got) + } + }) + + t.Run("plain context", func(t *testing.T) { + if _, ok := Dispatched(context.Background(), "api_token"); ok { + t.Fatal("a plain context carries a marker") + } + }) +} diff --git a/modules/cabana/administrators_harness_test.go b/modules/cabana/administrators_harness_test.go index 78fc6654..51966ddb 100644 --- a/modules/cabana/administrators_harness_test.go +++ b/modules/cabana/administrators_harness_test.go @@ -141,6 +141,7 @@ func (p grantPlugin) Permissions() []pact.Permission { return p.grants } // extra plugins and the admin's own screens, on the shared Postgres. type screensEnv struct { h http.Handler + app *backpack.App gdb *gorm.DB stamp string spy *pingSpy @@ -182,7 +183,7 @@ func newScreensEnv(t *testing.T, extra ...party.Plugin) *screensEnv { if err != nil { t.Fatal(err) } - return &screensEnv{h: h, gdb: gdb, stamp: fmt.Sprintf("s%d", time.Now().UnixNano()), spy: spy} + return &screensEnv{h: h, app: app, gdb: gdb, stamp: fmt.Sprintf("s%d", time.Now().UnixNano()), spy: spy} } // unique is name plus the env stamp and a per-env sequence number. diff --git a/modules/cabana/dispatch_marker_test.go b/modules/cabana/dispatch_marker_test.go new file mode 100644 index 00000000..a2189dbb --- /dev/null +++ b/modules/cabana/dispatch_marker_test.go @@ -0,0 +1,169 @@ +package cabana_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" +) + +// journalShaped is a plugin route shaped like a WinterCMS-style API that +// resolves its admin inside the handler (no route guard): it answers the +// principal cabana.ResolveBackend gives it. +type journalShaped struct{ app *backpack.App } + +func (journalShaped) ID() string { return "acme.posts" } +func (journalShaped) Requires() []string { return nil } +func (journalShaped) Register(*backpack.App) error { return nil } +func (journalShaped) Boot(*backpack.App) error { return nil } +func (p journalShaped) Routes(r pact.Router) error { + r.Get("/acme/posts", func(w http.ResponseWriter, req *http.Request) { + req, principal, err := cabana.ResolveBackend(p.app, req, "acme.posts") + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + user, _ := bouncer.User(req.Context()) + _ = json.NewEncoder(w).Encode(map[string]any{"principal": principal.ID, "stored": user.ID}) + }) + return nil +} + +func markerApp(t *testing.T) *backpack.App { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}}) + if err != nil { + t.Fatal(err) + } + return backpack.New(cfg) +} + +// TestDispatchMarker (phase 19.7 D-17, the cabana side): ResolveBackend +// answers a backend marker's principal and refuses a frontend one; a +// plugin route that resolves its admin inside the handler sees the +// dispatched principal through surf's compiled chain; and (Postgres) the +// assembled admin API honours a marker only in-process: the same request +// sent over HTTP, with any dispatch-looking header or cookie, is 401. +func TestDispatchMarker(t *testing.T) { + marker := func(r *http.Request, p *bouncer.Principal) *http.Request { + return r.WithContext(bouncer.WithDispatch(r.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: p})) + } + + t.Run("ResolveBackend backend and frontend markers", func(t *testing.T) { + app := backpack.New(nil) + _, p, err := cabana.ResolveBackend(app, marker(httptest.NewRequest(http.MethodGet, "/x", nil), &bouncer.Principal{ID: 42, Backend: true})) + if err != nil || p.ID != 42 { + t.Fatalf("backend marker = %+v, %v", p, err) + } + if _, _, err := cabana.ResolveBackend(app, marker(httptest.NewRequest(http.MethodGet, "/x", nil), &bouncer.Principal{ID: 7})); !errors.Is(err, cabana.ErrUnauthenticated) { + t.Fatalf("frontend marker: err = %v", err) + } + other := httptest.NewRequest(http.MethodGet, "/x", nil) + other = other.WithContext(bouncer.WithDispatch(other.Context(), &bouncer.Dispatch{Guards: []string{"api_token"}, Principal: &bouncer.Principal{ID: 42, Backend: true}})) + if _, _, err := cabana.ResolveBackend(app, other); !errors.Is(err, cabana.ErrUnauthenticated) { + t.Fatalf("marker for another guard: err = %v", err) + } + }) + + t.Run("plugin route resolving inside its handler", func(t *testing.T) { + app := markerApp(t) + router, err := surf.BuildRouter(app, []party.Plugin{journalShaped{app: app}}) + if err != nil { + t.Fatal(err) + } + h, ok := router.Handler(http.MethodGet, "/acme/posts") + if !ok { + t.Fatal("no compiled handler for GET /acme/posts") + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, marker(httptest.NewRequest(http.MethodGet, "/acme/posts", nil), &bouncer.Principal{ID: 42, Backend: true})) + var got map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &got) + if rec.Code != http.StatusOK || got["principal"] != 42.0 || got["stored"] != 42.0 { + t.Fatalf("dispatched = %d %s", rec.Code, rec.Body.String()) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/acme/posts", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("without the marker = %d", rec.Code) + } + }) + + // The Postgres part runs on a Docker host (the gate's docker stage); + // under -short it is not created, so no subtest is reported as skipped. + if testing.Short() { + return + } + t.Run("admin API honours a marker only in-process", func(t *testing.T) { + env := newScreensEnv(t) + admin, _ := env.admin(t, env.unique("marker"), false, `{}`) + principal, err := cabana.BackendPrincipal(context.Background(), env.app, admin.ID) + if err != nil || principal == nil || !principal.Backend { + t.Fatalf("BackendPrincipal = %+v, %v", principal, err) + } + + // In-process: the marker carries the admin through the backend guard. + req, err := env.request(http.MethodGet, "/auth/me", "", nil) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + env.h.ServeHTTP(rec, marker(req, principal)) + if rec.Code != http.StatusOK { + t.Fatalf("in-process marker = %d %s", rec.Code, rec.Body.String()) + } + var me struct { + Data struct { + ID uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil || me.Data.ID != admin.ID { + t.Fatalf("/auth/me answered %s", rec.Body.String()) + } + + // Over HTTP the marker never arrives: the client's context is not + // the server's, and no header, cookie or query string stands in. + srv := httptest.NewServer(env.h) + defer srv.Close() + out, err := http.NewRequestWithContext(bouncer.WithDispatch(context.Background(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: principal}), + http.MethodGet, srv.URL+adminAPI("/auth/me")+"?dispatch=backend", nil) + if err != nil { + t.Fatal(err) + } + out.Header.Set("X-Summer-Dispatch", "backend") + out.Header.Set("Authorization", "Dispatch backend") + out.AddCookie(&http.Cookie{Name: "summer_dispatch", Value: "backend"}) + res, err := http.DefaultClient.Do(out) + if err != nil { + t.Fatal(err) + } + _ = res.Body.Close() + if res.StatusCode != http.StatusUnauthorized { + t.Fatalf("over HTTP = %d, want 401", res.StatusCode) + } + + // A frontend marker is not an admin, in-process either. + req, _ = env.request(http.MethodGet, "/auth/me", "", nil) + rec = httptest.NewRecorder() + env.h.ServeHTTP(rec, marker(req, &bouncer.Principal{ID: admin.ID})) + if rec.Code == http.StatusOK { + t.Fatalf("frontend marker reached /auth/me: %s", rec.Body.String()) + } + }) +} diff --git a/modules/pier/helpers_test.go b/modules/pier/helpers_test.go new file mode 100644 index 00000000..5734eeca --- /dev/null +++ b/modules/pier/helpers_test.go @@ -0,0 +1,925 @@ +package pier_test + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/pier" + "git.golem15.com/golem15/summercms/modules/surf" + "github.com/alicebob/miniredis/v2" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Shared harness of the pier tests (phase 19.7 plan 05, Wave 0 of the +// validation map): the fixtures the plan 02 smokes introduced (the acme +// plugin, guard and verifier, the in-test Host adapter, the raw JSON-RPC +// client), and the configurable fakes the unit tests build on: fakeGuard, +// fakeVerifier, fakeLinkResolver, fakePersonalResolver, routePlugin, +// newApp, startInstance, twoInstances (two pier instances on one +// miniredis), sdkClient (an SDK client with a bearer-injecting transport +// and an optional list_changed channel) and fireSubjectChanged. +// +// The tests live in package pier_test and mount pier through testHost on a +// real surf router built with the MCP flag off, so they need no build tag. + +// refusingGuard is the fake plugin's bouncer guard: it never authenticates +// an HTTP request and writes its own 401, so only the dispatch marker can +// get a request through the route. +type refusingGuard struct{} + +func (refusingGuard) AuthenticateCredential(*http.Request) (*bouncer.Principal, any, error) { + return nil, nil, errors.New("acme: no token") +} + +func (refusingGuard) WriteUnauthorized(w http.ResponseWriter, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":true,"message":"Unauthenticated"}`)) +} + +// echoInput is the tool's input: id fills {id}, text forms the body. +type echoInput struct { + ID int `json:"id" path:"id" jsonschema:"the record id"` + Text string `json:"text" jsonschema:"the text to echo"` +} + +// acmePlugin registers bouncer guard "acme" as middleware "acme.auth", one +// guarded route and the MCP tools it is given. +type acmePlugin struct { + tools []pact.MCPTool + prompts []pact.MCPPrompt +} + +func (acmePlugin) ID() string { return "test.acme" } +func (acmePlugin) Requires() []string { return nil } +func (acmePlugin) Register(*backpack.App) error { return nil } +func (acmePlugin) Boot(*backpack.App) error { return nil } + +func (p acmePlugin) Middlewares() map[string]pact.Middleware { + guards := bouncer.NewRegistry() + if err := guards.Register(p.ID(), "acme", refusingGuard{}); err != nil { + panic(err) + } + auth, err := guards.Middleware("acme") + if err != nil { + panic(err) + } + return map[string]pact.Middleware{"acme.auth": auth} +} + +func (acmePlugin) Routes(r pact.Router) error { + r.Group("/acme", []string{"acme.auth"}, func(g pact.Router) { + g.Get("/items/{id}", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"item":"` + req.PathValue("id") + `"}`)) + }) + g.Post("/echo/{id}", func(w http.ResponseWriter, req *http.Request) { + user, _ := bouncer.User(req.Context()) + var body map[string]any + _ = json.NewDecoder(req.Body).Decode(&body) + id, _ := strconv.Atoi(req.PathValue("id")) + _, cookieErr := req.Cookie("summer_session") + hasCookie := cookieErr == nil + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": id, + "body": body, + "principal": user.ID, + "auth": req.Header.Get("Authorization") != "" || hasCookie, + }) + }) + }) + return nil +} + +func (p acmePlugin) MCP() pact.MCP { + return pact.MCP{Tools: p.tools, Prompts: p.prompts} +} + +// acmeGuard is the fake guard provider for "acme": token-guarded by +// acme.auth, allows everything unless deny is set, dispatches as the +// subject's user id. +type acmeGuard struct { + deny atomic.Bool +} + +func (*acmeGuard) MCPGuardName() string { return "acme" } +func (*acmeGuard) MCPTokenGuarded(mw []string) bool { + for _, m := range mw { + if m == "acme.auth" { + return true + } + } + return false +} +func (*acmeGuard) MCPRequirements([]string) (pact.MCPRequirements, error) { + return pact.MCPRequirements{}, nil +} +func (g *acmeGuard) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) { + return !g.deny.Load(), nil +} +func (*acmeGuard) MCPDispatch(_ context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) { + return pact.MCPDispatch{Principal: &bouncer.Principal{ID: subject.UserID}, Guards: []string{"acme"}}, nil +} + +// acmeVerifier accepts oas_at_test for user 7, client c1, read and write, +// and oas_at_other for user 8, client c2 (another binding). +type acmeVerifier struct{} + +func (acmeVerifier) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant, error) { + switch raw { + case "oas_at_test": + return pact.MCPGrant{UserID: 7, ClientID: "c1", TokenID: 1, Scopes: []string{"read", "write"}, ExpiresAt: time.Now().Add(time.Hour)}, nil + case "oas_at_other": + return pact.MCPGrant{UserID: 8, ClientID: "c2", TokenID: 2, Scopes: []string{"read", "write"}, ExpiresAt: time.Now().Add(time.Hour)}, nil + case "oas_at_read": + return pact.MCPGrant{UserID: 9, ClientID: "c3", TokenID: 3, Scopes: []string{"read"}, ExpiresAt: time.Now().Add(time.Hour)}, nil + } + return pact.MCPGrant{}, pact.ErrMCPTokenInvalid +} + +func (acmeVerifier) MCPResource() string { return "http://acme.test/mcp" } + +// testHost adapts a built surf router to pier.Host and records the +// endpoint handler pier mounts instead of compiling a route. +type testHost struct { + r *surf.Router + handlers map[string]http.Handler + path string + pier *pier.Pier +} + +func (h *testHost) 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 *testHost) Handler(method, pattern string) (http.Handler, bool) { + return h.r.Handler(method, pattern) +} + +func (h *testHost) MountStream(method, path string, handler http.Handler, _ ...string) error { + h.handlers[method] = handler + h.path = path + return nil +} + +func (h *testHost) MountJSON(method, path string, handler http.Handler) error { + h.handlers[method+" "+path] = handler + return nil +} + +func (h *testHost) 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 +} + +// ServeHTTP routes the endpoint path to the recorded handler of the +// request's method and any other recorded route by method and path. +func (h *testHost) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == h.path { + if handler, ok := h.handlers[r.Method]; ok { + handler.ServeHTTP(w, r) + return + } + } + if handler, ok := h.handlers[r.Method+" "+r.URL.Path]; ok { + handler.ServeHTTP(w, r) + return + } + http.NotFound(w, r) +} + +// pierApp builds an app with mcp.yaml from the given body and publishes +// the fake verifier and a guard registry with a fresh acme guard. +func pierApp(t *testing.T, mcpYAML string) *backpack.App { + t.Helper() + return pierAppWith(t, map[string]string{"mcp.yaml": mcpYAML}, &acmeGuard{}) +} + +// pierAppWith builds an app from extra config files (app.yaml and +// http.yaml have defaults) and publishes the fake verifier and, unless +// guard is nil, a guard registry with that provider; the router is built +// with the MCP flag off (so no tag is needed) and the flag is enabled for +// pier.Mount afterwards. +func pierAppWith(t *testing.T, extra map[string]string, guard *acmeGuard) *backpack.App { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + "app.yaml": "name: acme\nurl: http://acme.test\n", + "http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n", + } + for name, body := range extra { + files[name] = body + } + for name, body := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}}) + if err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + if err := app.Publish[pact.MCPTokenVerifier](acmeVerifier{}); err != nil { + t.Fatal(err) + } + if guard == nil { + return app + } + reg := pact.NewMCPGuards() + if err := reg.Register(guard); err != nil { + t.Fatal(err) + } + if err := app.Publish(reg); err != nil { + t.Fatal(err) + } + return app +} + +// mountPier builds the router for plugins with the flag off, then mounts +// pier with it on, through the test host. +func mountPier(t *testing.T, app *backpack.App, plugins []party.Plugin) (*testHost, error) { + t.Helper() + router, err := surf.BuildRouter(app, plugins) + if err != nil { + t.Fatal(err) + } + if err := app.Config.Set("mcp.enabled", true); err != nil { + t.Fatal(err) + } + host := &testHost{r: router, handlers: map[string]http.Handler{}} + err = pier.Mount(host, app, plugins) + if err == nil { + host.pier, _ = app.Lookup[*pier.Pier]() + } + return host, err +} + +// bearerTransport adds the bearer to every request of the SDK client. +type bearerTransport struct { + token string +} + +func (b bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + r.Header.Set("Authorization", "Bearer "+b.token) + return http.DefaultTransport.RoundTrip(r) +} + +func echoTool() pact.MCPTool { + return pact.MCPTool{ + Name: "echo", + Title: "Echo", + Description: "Echoes the text back with the record id and the acting principal.", + Method: "POST", + Path: "/acme/echo/{id}", + Input: echoInput{}, + } +} + +// instance is one mounted pier with its app and test server. +type instance struct { + app *backpack.App + host *testHost + srv *httptest.Server +} + +// newInstance mounts pier on a fresh app sharing the miniredis and guard. +func newInstance(t *testing.T, mr *miniredis.Miniredis, mcpYAML string, guard *acmeGuard) *instance { + t.Helper() + files := map[string]string{"mcp.yaml": mcpYAML} + if mr != nil { + files["redis.yaml"] = "url: redis://" + mr.Addr() + "/0\n" + } + app := pierAppWith(t, files, guard) + host, err := mountPier(t, app, []party.Plugin{acmePlugin{tools: []pact.MCPTool{echoTool()}}}) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(host) + t.Cleanup(srv.Close) + return &instance{app: app, host: host, srv: srv} +} + +// rpcReply is one raw JSON-RPC exchange: the status, the session header +// and the first message (an SSE data line or the JSON body). +type rpcReply struct { + status int + session string + body string + header http.Header +} + +// rpc posts one JSON-RPC message with a bearer and an optional session id. +func rpc(t *testing.T, ctx context.Context, url, token, session, body string) rpcReply { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + 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) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + reply := rpcReply{status: resp.StatusCode, session: resp.Header.Get("Mcp-Session-Id"), header: resp.Header} + if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") { + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 1<<20), 1<<20) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "data: ") { + reply.body = strings.TrimPrefix(line, "data: ") + break + } + } + return reply + } + raw, _ := io.ReadAll(resp.Body) + reply.body = strings.TrimSpace(string(raw)) + return reply +} + +const initializeBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"raw","version":"1"}}}` +const initializedBody = `{"jsonrpc":"2.0","method":"notifications/initialized"}` +const callBody = `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"acme_echo","arguments":{"id":9,"text":"hey"}}}` + +// openSession initialises a session on an instance and completes the +// handshake, returning the session id. +func openSession(t *testing.T, ctx context.Context, in *instance, token string) string { + t.Helper() + init := rpc(t, ctx, in.srv.URL+"/mcp", token, "", initializeBody) + if init.status != http.StatusOK || init.session == "" || !strings.Contains(init.body, `"protocolVersion":"2025-11-25"`) { + t.Fatalf("initialize = %d session %q body %s", init.status, init.session, init.body) + } + if done := rpc(t, ctx, in.srv.URL+"/mcp", token, init.session, initializedBody); done.status != http.StatusAccepted { + t.Fatalf("initialized = %d %s", done.status, done.body) + } + return init.session +} + +func containsPrefix(keys []string, prefix string) bool { + for _, k := range keys { + if strings.HasPrefix(k, prefix) { + return true + } + } + return false +} + +type showInput struct { + ID int `json:"id" path:"id" jsonschema:"the item id"` +} + +func showTool() pact.MCPTool { + return pact.MCPTool{Name: "show", Title: "Show item", Description: "Shows one item.", Method: "GET", Path: "/acme/items/{id}", Input: showInput{}} +} + +func summarizePrompt() pact.MCPPrompt { + return pact.MCPPrompt{ + Name: "summarize", Title: "Summarize", Description: "Asks for a summary.", Guard: "acme", + Arguments: []pact.MCPPromptArgument{{Name: "topic", Description: "what to summarize", Required: true}}, + Render: func(_ context.Context, args map[string]string) ([]pact.MCPPromptMessage, error) { + return []pact.MCPPromptMessage{{Role: "user", Text: "Summarize " + args["topic"] + "."}}, nil + }, + } +} + +func connectPinned(t *testing.T, ctx context.Context, url, token string) *mcp.ClientSession { + t.Helper() + client := mcp.NewClient(&mcp.Implementation{Name: "acme-test", Version: "1"}, nil) + transport := &mcp.StreamableClientTransport{Endpoint: url, HTTPClient: &http.Client{Transport: bearerTransport{token: token}}} + cs, err := client.Connect(ctx, transport, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) + if err != nil { + t.Fatalf("connect with %s: %v", token, err) + } + return cs +} + +func toolNamesOf(list *mcp.ListToolsResult) []string { + var out []string + for _, tl := range list.Tools { + out = append(out, tl.Name) + } + slices.Sort(out) + return out +} + +// --------------------------------------------------------------------------- +// Configurable fakes. + +// fakeGuard is a configurable guard provider. Name defaults to "acme" and +// the token-accepting middleware to "acme.auth"; parse turns a route's +// middleware into requirements (none by default); allow decides a subject +// (everything by default); dispatch lists the bouncer guards the marker is +// for (the guard's name by default). Every MCPAllows call is recorded. +type fakeGuard struct { + name string + tokenMW string + notToken bool + parse func(mw []string) (pact.MCPRequirements, error) + allow func(subject pact.MCPSubject, req pact.MCPRequirements, perms []string) bool + guards []string + + mu sync.Mutex + calls []guardCall +} + +// guardCall is one recorded MCPAllows call. +type guardCall struct { + subject pact.MCPSubject + req pact.MCPRequirements + perms []string + method string +} + +func (g *fakeGuard) MCPGuardName() string { + if g.name == "" { + return "acme" + } + return g.name +} + +func (g *fakeGuard) MCPTokenGuarded(mw []string) bool { + if g.notToken { + return false + } + want := g.tokenMW + if want == "" { + want = "acme.auth" + } + return slices.Contains(mw, want) +} + +func (g *fakeGuard) MCPRequirements(mw []string) (pact.MCPRequirements, error) { + if g.parse == nil { + return pact.MCPRequirements{}, nil + } + return g.parse(mw) +} + +func (g *fakeGuard) MCPAllows(_ context.Context, subject pact.MCPSubject, req pact.MCPRequirements, perms []string) (bool, error) { + return g.decide(subject, req, perms, ""), nil +} + +func (g *fakeGuard) decide(subject pact.MCPSubject, req pact.MCPRequirements, perms []string, method string) bool { + g.mu.Lock() + g.calls = append(g.calls, guardCall{subject: subject, req: req, perms: slices.Clone(perms), method: method}) + g.mu.Unlock() + if g.allow == nil { + return true + } + return g.allow(subject, req, perms) +} + +func (g *fakeGuard) MCPDispatch(_ context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) { + guards := g.guards + if len(guards) == 0 { + guards = []string{g.MCPGuardName()} + } + return pact.MCPDispatch{Principal: &bouncer.Principal{ID: subject.UserID, Backend: g.MCPGuardName() == pier.BackendGuard}, Guards: guards}, nil +} + +// recorded returns the MCPAllows calls so far. +func (g *fakeGuard) recorded() []guardCall { + g.mu.Lock() + defer g.mu.Unlock() + return slices.Clone(g.calls) +} + +// methodGuard is a fakeGuard that also answers the method-aware check pier +// prefers for providers whose answer depends on the route's method (the +// built-in backend provider and an admin token's scope limit). +type methodGuard struct { + *fakeGuard +} + +func (g methodGuard) MCPAllowsMethod(_ context.Context, subject pact.MCPSubject, req pact.MCPRequirements, perms []string, method string) (bool, error) { + return g.decide(subject, req, perms, method), nil +} + +// grant builds one verifier answer valid for an hour. +func grant(user uint, client string, token uint, scopes ...string) pact.MCPGrant { + return pact.MCPGrant{UserID: user, ClientID: client, TokenID: token, Scopes: scopes, ExpiresAt: time.Now().Add(time.Hour)} +} + +// fakeVerifier answers the grants it holds; revoke drops one. Its default +// table is the acme verifier's: oas_at_test (user 7, c1, read write), +// oas_at_other (user 8, c2, read write), oas_at_read (user 9, c3, read). +type fakeVerifier struct { + mu sync.Mutex + grants map[string]pact.MCPGrant + resource string +} + +func newFakeVerifier(extra map[string]pact.MCPGrant) *fakeVerifier { + v := &fakeVerifier{grants: map[string]pact.MCPGrant{ + "oas_at_test": grant(7, "c1", 1, "read", "write"), + "oas_at_other": grant(8, "c2", 2, "read", "write"), + "oas_at_read": grant(9, "c3", 3, "read"), + }, resource: "http://acme.test/mcp"} + for k, g := range extra { + v.grants[k] = g + } + return v +} + +func (v *fakeVerifier) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant, error) { + v.mu.Lock() + defer v.mu.Unlock() + g, ok := v.grants[raw] + if !ok { + return pact.MCPGrant{}, pact.ErrMCPTokenInvalid + } + return g, nil +} + +func (v *fakeVerifier) MCPResource() string { return v.resource } + +func (v *fakeVerifier) revoke(raw string) { + v.mu.Lock() + delete(v.grants, raw) + v.mu.Unlock() +} + +// fakeLinkResolver links frontend users to backend users in memory and +// counts its lookups. +type fakeLinkResolver struct { + mu sync.Mutex + links map[uint]uint + calls atomic.Int64 +} + +func newFakeLinks() *fakeLinkResolver { return &fakeLinkResolver{links: map[uint]uint{}} } + +func (l *fakeLinkResolver) set(user, admin uint) { + l.mu.Lock() + l.links[user] = admin + l.mu.Unlock() +} + +func (l *fakeLinkResolver) remove(user uint) { + l.mu.Lock() + delete(l.links, user) + l.mu.Unlock() +} + +func (l *fakeLinkResolver) LinkedBackendUser(_ context.Context, user uint) (pact.LinkedBackendUser, bool, error) { + l.calls.Add(1) + l.mu.Lock() + defer l.mu.Unlock() + admin, ok := l.links[user] + if !ok { + return pact.LinkedBackendUser{}, false, nil + } + return pact.LinkedBackendUser{ID: admin, Login: "admin" + strconv.FormatUint(uint64(admin), 10)}, true, nil +} + +// fakePersonalResolver resolves the personal tokens it holds; any other +// usr_ token is invalid and any other prefix is not its. +type fakePersonalResolver struct { + tokens map[string]pact.MCPSubject +} + +func (r fakePersonalResolver) MCPResolvePersonalToken(_ context.Context, raw string) (pact.MCPSubject, bool, error) { + if !strings.HasPrefix(raw, "usr_") { + return pact.MCPSubject{}, false, nil + } + s, ok := r.tokens[raw] + if !ok { + return pact.MCPSubject{}, true, pact.ErrMCPTokenInvalid + } + return s, true, nil +} + +// testRoute is one route of a routePlugin. +type testRoute struct { + method string + path string + mw []string + h http.HandlerFunc +} + +// routePlugin registers a bouncer guard (default "acme" as middleware +// "acme.auth" through refusingGuard, so only the dispatch marker passes), +// its routes with their own middleware and the MCP tools and prompts it is +// given. +type routePlugin struct { + id string + guard string + middleware string + routes []testRoute + tools []pact.MCPTool + prompts []pact.MCPPrompt +} + +func (p routePlugin) ID() string { return p.id } +func (routePlugin) Requires() []string { return nil } +func (routePlugin) Register(*backpack.App) error { return nil } +func (routePlugin) Boot(*backpack.App) error { return nil } +func (p routePlugin) MCP() pact.MCP { return pact.MCP{Tools: p.tools, Prompts: p.prompts} } +func (p routePlugin) guardNames() (string, string) { + guard, mw := p.guard, p.middleware + if guard == "" { + guard = "acme" + } + if mw == "" { + mw = guard + ".auth" + } + return guard, mw +} + +func (p routePlugin) Middlewares() map[string]pact.Middleware { + guard, name := p.guardNames() + reg := bouncer.NewRegistry() + if err := reg.Register(p.ID(), guard, refusingGuard{}); err != nil { + panic(err) + } + m, err := reg.Middleware(guard) + if err != nil { + panic(err) + } + return map[string]pact.Middleware{name: m} +} + +func (p routePlugin) Routes(r pact.Router) error { + for _, rt := range p.routes { + rt := rt + r.Group("", rt.mw, func(g pact.Router) { + switch rt.method { + case http.MethodGet: + g.Get(rt.path, rt.h) + case http.MethodPost: + g.Post(rt.path, rt.h) + case http.MethodPut: + g.Put(rt.path, rt.h) + case http.MethodPatch: + g.Patch(rt.path, rt.h) + case http.MethodDelete: + g.Delete(rt.path, rt.h) + } + }) + } + return nil +} + +// jsonRoute answers body with status as JSON. +func jsonRoute(status int, body string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + } +} + +// appSpec describes one test application: the mcp.yaml body (the flag is +// switched on by mountPier), extra config files, the guard providers, the +// verifier (default newFakeVerifier(nil)), the link resolver and the +// personal token resolver. +type appSpec struct { + mcp string + files map[string]string + guards []pact.MCPGuard + verifier pact.MCPTokenVerifier + links pact.MCPLinkedBackendResolver + personal pact.MCPPersonalTokenResolver +} + +// newApp builds the application of spec. +func newApp(t *testing.T, spec appSpec) *backpack.App { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + "app.yaml": "name: acme\nurl: http://acme.test\n", + "http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n", + "mcp.yaml": "enabled: false\n", + } + if spec.mcp != "" { + files["mcp.yaml"] = spec.mcp + } + for name, body := range spec.files { + files[name] = body + } + for name, body := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}}) + if err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + verifier := spec.verifier + if verifier == nil { + verifier = newFakeVerifier(nil) + } + if err := app.Publish[pact.MCPTokenVerifier](verifier); err != nil { + t.Fatal(err) + } + if spec.links != nil { + if err := app.Publish[pact.MCPLinkedBackendResolver](spec.links); err != nil { + t.Fatal(err) + } + } + if spec.personal != nil { + if err := app.Publish[pact.MCPPersonalTokenResolver](spec.personal); err != nil { + t.Fatal(err) + } + } + if len(spec.guards) > 0 { + reg := pact.NewMCPGuards() + for _, g := range spec.guards { + if err := reg.Register(g); err != nil { + t.Fatal(err) + } + } + if err := app.Publish(reg); err != nil { + t.Fatal(err) + } + } + return app +} + +// startInstance mounts pier for plugins on app and serves it. +func startInstance(t *testing.T, app *backpack.App, plugins ...party.Plugin) *instance { + t.Helper() + host, err := mountPier(t, app, plugins) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(host) + t.Cleanup(srv.Close) + return &instance{app: app, host: host, srv: srv} +} + +// twoInstances mounts two pier instances on separate apps built from spec +// (sharing its guard, verifier and resolver objects) whose Redis session +// store is one miniredis. +func twoInstances(t *testing.T, spec appSpec, plugins ...party.Plugin) (*instance, *instance, *miniredis.Miniredis) { + t.Helper() + mr := miniredis.RunT(t) + files := map[string]string{"redis.yaml": "url: redis://" + mr.Addr() + "/0\n"} + for k, v := range spec.files { + files[k] = v + } + spec.files = files + if spec.mcp == "" { + spec.mcp = "enabled: false\nsessions:\n store: redis\n" + } + a := startInstance(t, newApp(t, spec), plugins...) + b := startInstance(t, newApp(t, spec), plugins...) + return a, b, mr +} + +// url is the instance's MCP endpoint. +func (in *instance) url() string { return in.srv.URL + "/mcp" } + +// pier returns the mounted endpoint. +func (in *instance) pier() *pier.Pier { return in.host.pier } + +// clientOpts tunes sdkClient: the protocol version ("" pins 2025-11-25, +// the stateful path; "newest" leaves the SDK's default, the stateless +// path), a channel that receives every tools/list_changed and extra +// headers on every request. +type clientOpts struct { + version string + changed chan struct{} + headers map[string]string +} + +// headerTransport adds the bearer and the extra headers to every request. +type headerTransport struct { + token string + headers map[string]string +} + +func (h headerTransport) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + r.Header.Set("Authorization", "Bearer "+h.token) + for k, v := range h.headers { + r.Header.Set(k, v) + } + return http.DefaultTransport.RoundTrip(r) +} + +// sdkClient connects an SDK client to url with bearer. +func sdkClient(t *testing.T, ctx context.Context, url, bearer string, opts clientOpts) *mcp.ClientSession { + t.Helper() + var copts *mcp.ClientOptions + if opts.changed != nil { + ch := opts.changed + copts = &mcp.ClientOptions{ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) { + select { + case ch <- struct{}{}: + default: + } + }} + } + client := mcp.NewClient(&mcp.Implementation{Name: "pier-test", Version: "1"}, copts) + transport := &mcp.StreamableClientTransport{Endpoint: url, HTTPClient: &http.Client{Transport: headerTransport{token: bearer, headers: opts.headers}}} + var sopts *mcp.ClientSessionOptions + switch opts.version { + case "": + sopts = &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"} + case "newest": + default: + sopts = &mcp.ClientSessionOptions{ProtocolVersion: opts.version} + } + cs, err := client.Connect(ctx, transport, sopts) + if err != nil { + t.Fatalf("connect with %s: %v", bearer, err) + } + t.Cleanup(func() { _ = cs.Close() }) + return cs +} + +// listNames lists the tool names a session sees, sorted. +func listNames(t *testing.T, ctx context.Context, cs *mcp.ClientSession) []string { + t.Helper() + list, err := cs.ListTools(ctx, nil) + if err != nil { + t.Fatalf("tools/list: %v", err) + } + return toolNamesOf(list) +} + +// waitChanged waits for one tools/list_changed. +func waitChanged(t *testing.T, ch <-chan struct{}) { + t.Helper() + select { + case <-ch: + case <-time.After(10 * time.Second): + t.Fatal("no tools/list_changed within 10 s") + } +} + +// fireSubjectChanged fires one subject event on app, as a plugin does after +// its transaction commits. +func fireSubjectChanged(t *testing.T, app *backpack.App, guard string, id uint, kind string) { + t.Helper() + if err := app.Events.Fire(context.Background(), &pact.MCPSubjectChanged{Guard: guard, UserID: id, Kind: kind}); err != nil { + t.Fatal(err) + } +} + +// structured decodes a tool result's structured content as an object. +func structured(t *testing.T, res *mcp.CallToolResult) map[string]any { + t.Helper() + raw, err := json.Marshal(res.StructuredContent) + if err != nil { + t.Fatal(err) + } + var out map[string]any + _ = json.Unmarshal(raw, &out) + return out +} + +// resultText is the text of a tool result's single content item. +func resultText(t *testing.T, res *mcp.CallToolResult) string { + t.Helper() + if len(res.Content) != 1 { + t.Fatalf("content = %+v, want one item", res.Content) + } + text, ok := res.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("content = %T", res.Content[0]) + } + return text.Text +} diff --git a/modules/pier/identity_smoke_test.go b/modules/pier/identity_smoke_test.go index 79816bdf..3abcfc71 100644 --- a/modules/pier/identity_smoke_test.go +++ b/modules/pier/identity_smoke_test.go @@ -21,44 +21,6 @@ import ( // Task 4 cases of the tracer: identity kinds behind the flag, the // read/write ceiling, prompts and the audit line. -type showInput struct { - ID int `json:"id" path:"id" jsonschema:"the item id"` -} - -func showTool() pact.MCPTool { - return pact.MCPTool{Name: "show", Title: "Show item", Description: "Shows one item.", Method: "GET", Path: "/acme/items/{id}", Input: showInput{}} -} - -func summarizePrompt() pact.MCPPrompt { - return pact.MCPPrompt{ - Name: "summarize", Title: "Summarize", Description: "Asks for a summary.", Guard: "acme", - Arguments: []pact.MCPPromptArgument{{Name: "topic", Description: "what to summarize", Required: true}}, - Render: func(_ context.Context, args map[string]string) ([]pact.MCPPromptMessage, error) { - return []pact.MCPPromptMessage{{Role: "user", Text: "Summarize " + args["topic"] + "."}}, nil - }, - } -} - -func connectPinned(t *testing.T, ctx context.Context, url, token string) *mcp.ClientSession { - t.Helper() - client := mcp.NewClient(&mcp.Implementation{Name: "acme-test", Version: "1"}, nil) - transport := &mcp.StreamableClientTransport{Endpoint: url, HTTPClient: &http.Client{Transport: bearerTransport{token: token}}} - cs, err := client.Connect(ctx, transport, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) - if err != nil { - t.Fatalf("connect with %s: %v", token, err) - } - return cs -} - -func toolNamesOf(list *mcp.ListToolsResult) []string { - var out []string - for _, tl := range list.Tools { - out = append(out, tl.Name) - } - slices.Sort(out) - return out -} - func tracerIdentityCases(t *testing.T, ctx context.Context) { plugins := []party.Plugin{acmePlugin{tools: []pact.MCPTool{echoTool(), showTool()}, prompts: []pact.MCPPrompt{summarizePrompt()}}} diff --git a/modules/pier/sessions_smoke_test.go b/modules/pier/sessions_smoke_test.go index 2192573a..95737249 100644 --- a/modules/pier/sessions_smoke_test.go +++ b/modules/pier/sessions_smoke_test.go @@ -1,19 +1,13 @@ package pier_test import ( - "bufio" - "bytes" "context" - "io" "net/http" - "net/http/httptest" "strings" "testing" "time" - "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" - "git.golem15.com/golem15/summercms/modules/party" "github.com/alicebob/miniredis/v2" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -22,94 +16,6 @@ import ( // pier instances on one miniredis (rehydration, binding, DELETE, idle TTL, // the per-binding cap) and the pub/sub fan-out of subject events. -// instance is one mounted pier with its app and test server. -type instance struct { - app *backpack.App - host *testHost - srv *httptest.Server -} - -// newInstance mounts pier on a fresh app sharing the miniredis and guard. -func newInstance(t *testing.T, mr *miniredis.Miniredis, mcpYAML string, guard *acmeGuard) *instance { - t.Helper() - files := map[string]string{"mcp.yaml": mcpYAML} - if mr != nil { - files["redis.yaml"] = "url: redis://" + mr.Addr() + "/0\n" - } - app := pierAppWith(t, files, guard) - host, err := mountPier(t, app, []party.Plugin{acmePlugin{tools: []pact.MCPTool{echoTool()}}}) - if err != nil { - t.Fatal(err) - } - srv := httptest.NewServer(host) - t.Cleanup(srv.Close) - return &instance{app: app, host: host, srv: srv} -} - -// rpcReply is one raw JSON-RPC exchange: the status, the session header -// and the first message (an SSE data line or the JSON body). -type rpcReply struct { - status int - session string - body string - header http.Header -} - -// rpc posts one JSON-RPC message with a bearer and an optional session id. -func rpc(t *testing.T, ctx context.Context, url, token, session, body string) rpcReply { - t.Helper() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(body)) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer "+token) - 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) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - reply := rpcReply{status: resp.StatusCode, session: resp.Header.Get("Mcp-Session-Id"), header: resp.Header} - if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") { - scanner := bufio.NewScanner(resp.Body) - scanner.Buffer(make([]byte, 1<<20), 1<<20) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "data: ") { - reply.body = strings.TrimPrefix(line, "data: ") - break - } - } - return reply - } - raw, _ := io.ReadAll(resp.Body) - reply.body = strings.TrimSpace(string(raw)) - return reply -} - -const initializeBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"raw","version":"1"}}}` -const initializedBody = `{"jsonrpc":"2.0","method":"notifications/initialized"}` -const callBody = `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"acme_echo","arguments":{"id":9,"text":"hey"}}}` - -// openSession initialises a session on an instance and completes the -// handshake, returning the session id. -func openSession(t *testing.T, ctx context.Context, in *instance, token string) string { - t.Helper() - init := rpc(t, ctx, in.srv.URL+"/mcp", token, "", initializeBody) - if init.status != http.StatusOK || init.session == "" || !strings.Contains(init.body, `"protocolVersion":"2025-11-25"`) { - t.Fatalf("initialize = %d session %q body %s", init.status, init.session, init.body) - } - if done := rpc(t, ctx, in.srv.URL+"/mcp", token, init.session, initializedBody); done.status != http.StatusAccepted { - t.Fatalf("initialized = %d %s", done.status, done.body) - } - return init.session -} - func TestPierSessionsSmoke(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -170,15 +76,6 @@ func TestPierSessionsSmoke(t *testing.T) { } } -func containsPrefix(keys []string, prefix string) bool { - for _, k := range keys { - if strings.HasPrefix(k, prefix) { - return true - } - } - return false -} - func TestPierFanOutSmoke(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() diff --git a/modules/pier/tools_list_test.go b/modules/pier/tools_list_test.go new file mode 100644 index 00000000..c8a6f00e --- /dev/null +++ b/modules/pier/tools_list_test.go @@ -0,0 +1,150 @@ +package pier_test + +import ( + "context" + "encoding/json" + "net/http" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// listFixture is the two-guard application of TestToolsList: the frontend +// guard "acme" (user 8 lacks acme.echo) with a write and a read tool, the +// "backend" guard with one read tool, the verifier's grants for users 7, 8, +// 9 (read only), 10 (backend, linked to admin 42) and 11 (backend, no +// link), and the hit counters of the write route and the backend route. +type listFixture struct { + in *instance + echoHits *atomic.Int64 + officeHits *atomic.Int64 +} + +func newListFixture(t *testing.T) listFixture { + t.Helper() + echoHits, officeHits := &atomic.Int64{}, &atomic.Int64{} + front := &fakeGuard{allow: func(s pact.MCPSubject, _ pact.MCPRequirements, perms []string) bool { + return !(slices.Contains(perms, "acme.echo") && s.UserID == 8) + }} + back := &fakeGuard{name: "backend", tokenMW: "backend"} + links := newFakeLinks() + links.set(10, 42) + verifier := newFakeVerifier(map[string]pact.MCPGrant{ + "oas_at_admin": grant(10, "c4", 4, "read", "write", "backend"), + "oas_at_nolink": grant(11, "c5", 5, "read", "write", "backend"), + }) + principalOf := func(w http.ResponseWriter, r *http.Request) { + user, _ := bouncer.User(r.Context()) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"principal": user.ID, "backend": user.Backend}) + } + acme := routePlugin{ + id: "test.acme", + routes: []testRoute{ + {method: http.MethodPost, path: "/acme/echo/{id}", mw: []string{"acme.auth"}, h: func(w http.ResponseWriter, r *http.Request) { + echoHits.Add(1) + principalOf(w, r) + }}, + {method: http.MethodGet, path: "/acme/items/{id}", mw: []string{"acme.auth"}, h: principalOf}, + }, + tools: []pact.MCPTool{ + {Name: "echo", Description: "Echo.", Method: "POST", Path: "/acme/echo/{id}", Input: echoInput{}, Permissions: []string{"acme.echo"}}, + {Name: "show", Description: "Show.", Method: "GET", Path: "/acme/items/{id}", Input: showInput{}}, + }, + } + office := routePlugin{ + id: "test.office", guard: "backend", middleware: "backend", + routes: []testRoute{{method: http.MethodGet, path: "/office/posts", mw: []string{"backend"}, h: func(w http.ResponseWriter, r *http.Request) { + officeHits.Add(1) + principalOf(w, r) + }}}, + tools: []pact.MCPTool{{Name: "posts", Description: "Lists posts.", Method: "GET", Path: "/office/posts", Input: struct{}{}}}, + } + app := newApp(t, appSpec{guards: []pact.MCPGuard{front, back}, verifier: verifier, links: links}) + return listFixture{in: startInstance(t, app, []party.Plugin{acme, office}...), echoHits: echoHits, officeHits: officeHits} +} + +// TestToolsList (SC-3, D-20, D-23): tools/list is computed per principal; +// a read-only grant sees only read-only tools; a tool whose guard has no +// subject for the caller is absent; calling a tool outside the caller's +// list is a JSON-RPC error that never reaches the route; an identity with +// both a frontend and a backend subject sees the union. +func TestToolsList(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + f := newListFixture(t) + + t.Run("per principal", func(t *testing.T) { + seven := listNames(t, ctx, sdkClient(t, ctx, f.in.url(), "oas_at_test", clientOpts{})) + eight := listNames(t, ctx, sdkClient(t, ctx, f.in.url(), "oas_at_other", clientOpts{})) + if !slices.Equal(seven, []string{"acme_echo", "acme_show"}) || !slices.Equal(eight, []string{"acme_show"}) { + t.Fatalf("user 7 lists %v, user 8 lists %v", seven, eight) + } + }) + + t.Run("read grant lists read-only tools", func(t *testing.T) { + cs := sdkClient(t, ctx, f.in.url(), "oas_at_read", clientOpts{}) + list, err := cs.ListTools(ctx, nil) + if err != nil { + t.Fatal(err) + } + if got := toolNamesOf(list); !slices.Equal(got, []string{"acme_show"}) { + t.Fatalf("read grant lists %v", got) + } + for _, tl := range list.Tools { + if tl.Annotations == nil || !tl.Annotations.ReadOnlyHint { + t.Fatalf("read grant lists %s without readOnlyHint", tl.Name) + } + } + }) + + t.Run("no subject under the guard", func(t *testing.T) { + for _, token := range []string{"oas_at_test", "oas_at_nolink"} { + if got := listNames(t, ctx, sdkClient(t, ctx, f.in.url(), token, clientOpts{})); slices.Contains(got, "office_posts") { + t.Fatalf("%s lists the backend tool: %v", token, got) + } + } + }) + + t.Run("unlisted call never reaches the route", func(t *testing.T) { + before, office := f.echoHits.Load(), f.officeHits.Load() + for token, tool := range map[string]string{"oas_at_other": "acme_echo", "oas_at_read": "acme_echo", "oas_at_test": "office_posts"} { + cs := sdkClient(t, ctx, f.in.url(), token, clientOpts{}) + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: tool, Arguments: map[string]any{"id": 1, "text": "x"}}) + if err == nil || !strings.Contains(err.Error(), "not available to this caller") { + t.Fatalf("%s calling %s: %+v, %v", token, tool, res, err) + } + } + if f.echoHits.Load() != before || f.officeHits.Load() != office { + t.Fatalf("a refused call reached a route: echo %d -> %d, office %d -> %d", before, f.echoHits.Load(), office, f.officeHits.Load()) + } + }) + + t.Run("union of both guards", func(t *testing.T) { + cs := sdkClient(t, ctx, f.in.url(), "oas_at_admin", clientOpts{}) + if got := listNames(t, ctx, cs); !slices.Equal(got, []string{"acme_echo", "acme_show", "office_posts"}) { + t.Fatalf("frontend and backend subject lists %v", got) + } + front, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "acme_show", Arguments: map[string]any{"id": 3}}) + if err != nil || front.IsError { + t.Fatalf("acme_show = %+v, %v", front, err) + } + if got := structured(t, front); got["principal"] != 10.0 || got["backend"] != false { + t.Fatalf("acme_show ran as %+v, want frontend user 10", got) + } + back, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "office_posts"}) + if err != nil || back.IsError { + t.Fatalf("office_posts = %+v, %v", back, err) + } + if got := structured(t, back); got["principal"] != 42.0 || got["backend"] != true { + t.Fatalf("office_posts ran as %+v, want linked admin 42", got) + } + }) +} diff --git a/modules/pier/tracer_smoke_test.go b/modules/pier/tracer_smoke_test.go index 78adf860..1fef1580 100644 --- a/modules/pier/tracer_smoke_test.go +++ b/modules/pier/tracer_smoke_test.go @@ -3,22 +3,13 @@ package pier_test import ( "bytes" "context" - "encoding/json" - "errors" "io" "net/http" "net/http/httptest" - "os" - "path/filepath" - "strconv" "strings" - "sync/atomic" "testing" "time" - "git.golem15.com/golem15/summercms/modules/backpack" - "git.golem15.com/golem15/summercms/modules/bouncer" - "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/pier" @@ -37,270 +28,6 @@ import ( // pier package tests need no build tag (with the tag, surf imports pier, // which an external test package may still import). -// refusingGuard is the fake plugin's bouncer guard: it never authenticates -// an HTTP request and writes its own 401, so only the dispatch marker can -// get a request through the route. -type refusingGuard struct{} - -func (refusingGuard) AuthenticateCredential(*http.Request) (*bouncer.Principal, any, error) { - return nil, nil, errors.New("acme: no token") -} - -func (refusingGuard) WriteUnauthorized(w http.ResponseWriter, err error) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"error":true,"message":"Unauthenticated"}`)) -} - -// echoInput is the tool's input: id fills {id}, text forms the body. -type echoInput struct { - ID int `json:"id" path:"id" jsonschema:"the record id"` - Text string `json:"text" jsonschema:"the text to echo"` -} - -// acmePlugin registers bouncer guard "acme" as middleware "acme.auth", one -// guarded route and the MCP tools it is given. -type acmePlugin struct { - tools []pact.MCPTool - prompts []pact.MCPPrompt -} - -func (acmePlugin) ID() string { return "test.acme" } -func (acmePlugin) Requires() []string { return nil } -func (acmePlugin) Register(*backpack.App) error { return nil } -func (acmePlugin) Boot(*backpack.App) error { return nil } - -func (p acmePlugin) Middlewares() map[string]pact.Middleware { - guards := bouncer.NewRegistry() - if err := guards.Register(p.ID(), "acme", refusingGuard{}); err != nil { - panic(err) - } - auth, err := guards.Middleware("acme") - if err != nil { - panic(err) - } - return map[string]pact.Middleware{"acme.auth": auth} -} - -func (acmePlugin) Routes(r pact.Router) error { - r.Group("/acme", []string{"acme.auth"}, func(g pact.Router) { - g.Get("/items/{id}", func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"item":"` + req.PathValue("id") + `"}`)) - }) - g.Post("/echo/{id}", func(w http.ResponseWriter, req *http.Request) { - user, _ := bouncer.User(req.Context()) - var body map[string]any - _ = json.NewDecoder(req.Body).Decode(&body) - id, _ := strconv.Atoi(req.PathValue("id")) - _, cookieErr := req.Cookie("summer_session") - hasCookie := cookieErr == nil - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": id, - "body": body, - "principal": user.ID, - "auth": req.Header.Get("Authorization") != "" || hasCookie, - }) - }) - }) - return nil -} - -func (p acmePlugin) MCP() pact.MCP { - return pact.MCP{Tools: p.tools, Prompts: p.prompts} -} - -// acmeGuard is the fake guard provider for "acme": token-guarded by -// acme.auth, allows everything unless deny is set, dispatches as the -// subject's user id. -type acmeGuard struct { - deny atomic.Bool -} - -func (*acmeGuard) MCPGuardName() string { return "acme" } -func (*acmeGuard) MCPTokenGuarded(mw []string) bool { - for _, m := range mw { - if m == "acme.auth" { - return true - } - } - return false -} -func (*acmeGuard) MCPRequirements([]string) (pact.MCPRequirements, error) { - return pact.MCPRequirements{}, nil -} -func (g *acmeGuard) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) { - return !g.deny.Load(), nil -} -func (*acmeGuard) MCPDispatch(_ context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) { - return pact.MCPDispatch{Principal: &bouncer.Principal{ID: subject.UserID}, Guards: []string{"acme"}}, nil -} - -// acmeVerifier accepts oas_at_test for user 7, client c1, read and write, -// and oas_at_other for user 8, client c2 (another binding). -type acmeVerifier struct{} - -func (acmeVerifier) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant, error) { - switch raw { - case "oas_at_test": - return pact.MCPGrant{UserID: 7, ClientID: "c1", TokenID: 1, Scopes: []string{"read", "write"}, ExpiresAt: time.Now().Add(time.Hour)}, nil - case "oas_at_other": - return pact.MCPGrant{UserID: 8, ClientID: "c2", TokenID: 2, Scopes: []string{"read", "write"}, ExpiresAt: time.Now().Add(time.Hour)}, nil - case "oas_at_read": - return pact.MCPGrant{UserID: 9, ClientID: "c3", TokenID: 3, Scopes: []string{"read"}, ExpiresAt: time.Now().Add(time.Hour)}, nil - } - return pact.MCPGrant{}, pact.ErrMCPTokenInvalid -} - -func (acmeVerifier) MCPResource() string { return "http://acme.test/mcp" } - -// testHost adapts a built surf router to pier.Host and records the -// endpoint handler pier mounts instead of compiling a route. -type testHost struct { - r *surf.Router - handlers map[string]http.Handler - path string - pier *pier.Pier -} - -func (h *testHost) 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 *testHost) Handler(method, pattern string) (http.Handler, bool) { - return h.r.Handler(method, pattern) -} - -func (h *testHost) MountStream(method, path string, handler http.Handler, _ ...string) error { - h.handlers[method] = handler - h.path = path - return nil -} - -func (h *testHost) MountJSON(method, path string, handler http.Handler) error { - h.handlers[method+" "+path] = handler - return nil -} - -func (h *testHost) 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 -} - -// ServeHTTP routes the endpoint path to the recorded handler of the -// request's method and any other recorded route by method and path. -func (h *testHost) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == h.path { - if handler, ok := h.handlers[r.Method]; ok { - handler.ServeHTTP(w, r) - return - } - } - if handler, ok := h.handlers[r.Method+" "+r.URL.Path]; ok { - handler.ServeHTTP(w, r) - return - } - http.NotFound(w, r) -} - -// pierApp builds an app with mcp.yaml from the given body and publishes -// the fake verifier and a guard registry with a fresh acme guard. -func pierApp(t *testing.T, mcpYAML string) *backpack.App { - t.Helper() - return pierAppWith(t, map[string]string{"mcp.yaml": mcpYAML}, &acmeGuard{}) -} - -// pierAppWith builds an app from extra config files (app.yaml and -// http.yaml have defaults) and publishes the fake verifier and, unless -// guard is nil, a guard registry with that provider; the router is built -// with the MCP flag off (so no tag is needed) and the flag is enabled for -// pier.Mount afterwards. -func pierAppWith(t *testing.T, extra map[string]string, guard *acmeGuard) *backpack.App { - t.Helper() - dir := t.TempDir() - files := map[string]string{ - "app.yaml": "name: acme\nurl: http://acme.test\n", - "http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n", - } - for name, body := range extra { - files[name] = body - } - for name, body := range files { - if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { - t.Fatal(err) - } - } - cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}}) - if err != nil { - t.Fatal(err) - } - app := backpack.New(cfg) - if err := app.Publish[pact.MCPTokenVerifier](acmeVerifier{}); err != nil { - t.Fatal(err) - } - if guard == nil { - return app - } - reg := pact.NewMCPGuards() - if err := reg.Register(guard); err != nil { - t.Fatal(err) - } - if err := app.Publish(reg); err != nil { - t.Fatal(err) - } - return app -} - -// mountPier builds the router for plugins with the flag off, then mounts -// pier with it on, through the test host. -func mountPier(t *testing.T, app *backpack.App, plugins []party.Plugin) (*testHost, error) { - t.Helper() - router, err := surf.BuildRouter(app, plugins) - if err != nil { - t.Fatal(err) - } - if err := app.Config.Set("mcp.enabled", true); err != nil { - t.Fatal(err) - } - host := &testHost{r: router, handlers: map[string]http.Handler{}} - err = pier.Mount(host, app, plugins) - if err == nil { - host.pier, _ = app.Lookup[*pier.Pier]() - } - return host, err -} - -// bearerTransport adds the bearer to every request of the SDK client. -type bearerTransport struct { - token string -} - -func (b bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) { - r = r.Clone(r.Context()) - r.Header.Set("Authorization", "Bearer "+b.token) - return http.DefaultTransport.RoundTrip(r) -} - -func echoTool() pact.MCPTool { - return pact.MCPTool{ - Name: "echo", - Title: "Echo", - Description: "Echoes the text back with the record id and the acting principal.", - Method: "POST", - Path: "/acme/echo/{id}", - Input: echoInput{}, - } -} - func TestPierTracerSmoke(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() diff --git a/scripts/check-phase19.7.sh b/scripts/check-phase19.7.sh new file mode 100755 index 00000000..37b72e6a --- /dev/null +++ b/scripts/check-phase19.7.sh @@ -0,0 +1,571 @@ +#!/usr/bin/env bash +# check-phase19.7: fail-closed gate for Phase 19.7, the generic MCP module +# (pier) with its framework seams, the user plugin's account link and MCP +# guard, the OAuth server plugin's MCP verifier and consent, the blog plugin's +# MCP tools and the demo application that wires them end to end. It proves +# the phase across six repositories. +# +# Stages: +# self-test the detectors on planted output, the docker NOT RUN rule on a +# faked host, the marker rule on planted files and the additive +# check on planted diffs. +# go go vet ./... (untagged and with -tags summercms_mcp), go test +# -short ./..., go -C examples/hello test ./..., go build -tags +# summercms_mcp ./..., the hello binary without the MCP SDK, then +# the Docker-free framework tests by required name (GO_TESTS, +# and GO_TAGGED_TESTS with -tags summercms_mcp). +# plugins in the user plugin (GOWORK=off), the OAuth server plugin and +# the blog plugin: go vet, go test -short, the Docker-free tests +# by required name and the additive check (no exported func, +# method or type removed from a non-test .go file since the +# plugin's last commit before the phase). +# consumers the applications at their bumped pins (D-31): go build ./... +# and go test -short ./... in each, the demo also with -tags +# summercms_mcp and its pin check. +# docker Docker host only: the Postgres suites by required name in the +# framework (cabana), the three plugins and the demo (tagged). +# admin npm --prefix admin run typecheck, the vitest run (the phase's +# files must pass), scripts/check-admin-openapi.sh --check and +# scripts/check-admin-dist.sh. +# docs TestDocsTree, summer docs:build --check, the decision note's +# dependency names, the STACK rows, and no framework source, +# README or docs page and no test file of this phase names a +# consuming application. +# marker D-17: no non-test .go file under modules/ outside modules/pier +# and modules/bouncer calls bouncer.WithDispatch, and +# modules/pact depends on neither the MCP SDK, jsonschema-go nor +# modules/bouncer. +# +# A Go stage exits non-zero on a failing command, or on a go test -json run +# that fails, skips a test (a subtest included), matches zero tests, prints +# "no tests to run" or misses a required name (a subtest pass never counts +# for its parent's name). The vitest detector refuses a run whose summary is +# not all-passing, that reports a failed, skipped or todo test, or that has +# no passing line for a required file. +# +# --all runs self-test, go, plugins, consumers, admin, docs and marker, then +# docker when a Docker daemon answers (NOT RUN docker otherwise, a failure +# with PHASE197_REQUIRE_DOCKER=1). +# +# Environment overrides (default: the sibling checkouts next to this +# repository): PHASE197_ROOT (this repository), USER_PLUGIN, +# OAUTHSERVER_PLUGIN, JOURNAL_PLUGIN, DEMO_APP, FONOTEKA, BM_APP, the +# additive bases PHASE197_USER_BASE, PHASE197_OAS_BASE, +# PHASE197_JOURNAL_BASE, and PHASE197_REQUIRE_DOCKER. +set -euo pipefail + +unset FORCE_COLOR +export NO_COLOR=1 + +ROOT="${PHASE197_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +DEMO_APP="${DEMO_APP:-$ROOT/../sm-demo-app}" +USER_PLUGIN="${USER_PLUGIN:-$DEMO_APP/plugins/golem15/user}" +OAUTHSERVER_PLUGIN="${OAUTHSERVER_PLUGIN:-$ROOT/../sm-oauthserver-plugin}" +JOURNAL_PLUGIN="${JOURNAL_PLUGIN:-$ROOT/../sm-journal-plugin}" +FONOTEKA="${FONOTEKA:-$ROOT/../fono""teka.go}" +BM_APP="${BM_APP:-$ROOT/../sm-bm-app}" +# The plugins' last commits before the phase (the additive check's bases). +USER_BASE="${PHASE197_USER_BASE:-930b158}" +OAS_BASE="${PHASE197_OAS_BASE:-d31f8e7}" +JOURNAL_BASE="${PHASE197_JOURNAL_BASE:-ffa2c8f}" + +MCP_TAG="summercms_mcp" + +# --------------------------------------------------------------------------- +# Required test names. Each entry is :; the go stage runs +# every package's names with -run '^(a|b|...)$' and every name must pass. + +# go stage: Docker-free framework tests (-short). +GO_TESTS=( + # Plan 01 smokes. + ./modules/surf:TestDispatchSmoke ./modules/surf:TestStreamingRouteSmoke + ./modules/surf:TestPierMountSmoke ./modules/bouncer:TestDispatchedSmoke + ./modules/cabana:TestDispatchMarkerSmoke ./modules/wristband:TestMCPScopesSmoke + ./modules/cabana:TestAccountContributionsSmoke ./modules/cabana:TestRecordActionPromptSmoke + ./modules/pact:TestPhase197ContractsOnSamplePlugin ./modules/pact:TestMCPGuardsRegistry + ./modules/pact:TestPactStaysSDKFree + # Plan 02 smokes. + ./modules/pier:TestPierTracerSmoke ./modules/pier:TestPierSessionsSmoke + ./modules/pier:TestPierFanOutSmoke ./modules/pier:TestPierStatelessSmoke + ./modules/pier:TestExampleBlogPlugin + # Plan 05: the validation map (SC-3, D-17, D-20, D-23). + ./modules/pier:TestToolsList + ./modules/bouncer:TestDispatchMarker ./modules/cabana:TestDispatchMarker +) + +# go stage: framework tests that run in a binary built with the MCP tag. +GO_TAGGED_TESTS=( + ./modules/pier:TestToolsList +) + +# docker stage: Postgres tests in the framework. +DOCKER_TESTS=( + ./modules/cabana:TestDispatchMarker ./modules/cabana:TestRecordActionPromptRun +) + +# docs stage: names of consuming applications (split so this file does not +# match itself), the decision note and the dependency names it must carry. +APP_NAMES=("p[lł]yta""rium" "fono""teka" "wave""path" "horo""skopia" "bm\\.jakub""zych" "grzyby""funkcjonalne" "stu""dia") +DECISION_NOTE=".planning/notes/2026-10-22-mcp-go-sdk.md" +DECISION_NAMES=("modelcontextprotocol/go-sdk" "google/jsonschema-go" "govulncheck") + +usage() { + cat >&2 <<'EOF' +usage: + check-phase19.7.sh --self-test + check-phase19.7.sh go | plugins | consumers | docker | admin | docs | marker + check-phase19.7.sh --go | --plugins | --consumers | --docker | --admin | --docs | --marker + check-phase19.7.sh --all +EOF + exit 2 +} + +# detect_go reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests, +# 4 non-JSON, 5 missing required name. +detect_go() { + python3 - "$1" <<'PY' +import json, os, sys +path = sys.argv[1] +require = [n for n in os.environ.get("REQUIRE_TESTS", "").split() if n] +passed = set() +failed = [] +with open(path, encoding="utf-8", errors="replace") as fh: + for raw in fh: + line = raw.strip() + if not line.startswith("{"): + continue + try: + ev = json.loads(line) + except json.JSONDecodeError: + print("refuse: non-json test output", file=sys.stderr) + sys.exit(4) + action = ev.get("Action") + test = ev.get("Test") or "" + pkg = ev.get("Package") or ev.get("ImportPath") or "" + if action == "build-fail" or (action == "fail" and ev.get("FailedBuild")): + print(f"refuse: build failed {pkg}", file=sys.stderr) + sys.exit(1) + text = ev.get("Output") or "" + if action == "output" and "no tests to run" in text: + print(f"refuse: no tests to run in {pkg}", file=sys.stderr) + sys.exit(3) + if action == "skip" and test: + print(f"refuse: skipped {pkg} {test}", file=sys.stderr) + sys.exit(2) + if action == "fail": + failed.append(f"{pkg} {test}".strip()) + if action == "pass" and test: + passed.add(test) +if failed: + print("refuse: failed " + ", ".join(failed), file=sys.stderr) + sys.exit(1) +if not passed: + print("refuse: zero tests", file=sys.stderr) + sys.exit(3) +missing = [n for n in require if n not in passed] +if missing: + print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr) + sys.exit(5) +PY +} + +# detect_vitest reads a vitest run's output. Exit 1 failed, skipped or todo +# tests, 3 no all-passing summary, 5 a required file did not pass. +# REQUIRE_FILES is a space-separated list. Only the summary and the file +# lines are read: a slow test's title may contain any word. +detect_vitest() { + python3 - "$1" <<'PY' +import os, re, sys +text = open(sys.argv[1], encoding="utf-8", errors="replace").read() +text = re.sub(r"\x1b\[[0-9;]*m", "", text) +need = os.environ.get("REQUIRE_FILES", "").split() +summary = re.findall(r"^\s*(Test Files|Tests)\s+(.*)$", text, re.M) +if any(re.search(r"\b(failed|skipped|todo)\b", rest) for _, rest in summary) or re.search(r"^\s*(FAIL|×)\s", text, re.M): + print("refuse: vitest reports failed, skipped or todo tests", file=sys.stderr) + sys.exit(1) +m = re.search(r"^\s*Tests\s+(\d+) passed \((\d+)\)\s*$", text, re.M) +if not m or m.group(1) != m.group(2) or m.group(1) == "0": + print("refuse: no all-passing vitest summary", file=sys.stderr) + sys.exit(3) +missing = [f for f in need if not re.search(r"^\s*✓ (?:\|\w+\| )?" + re.escape(f) + r" \(\d+ tests?\)", text, re.M)] +if missing: + print("refuse: did not pass: " + ", ".join(missing), file=sys.stderr) + sys.exit(5) +PY +} + +# go_json DIR ARGS... runs go test -json ARGS in DIR and checks the stream +# with detect_go. GO_ENV (word-split) is prepended to the go command's +# environment, for GOWORK=off. +go_json() { + local dir="$1" + shift + local log err rc=0 dc=0 + log="$(mktemp)" + err="$(mktemp)" + # shellcheck disable=SC2086 + (cd "$dir" && env ${GO_ENV:-} go test -json "$@") >"$log" 2>"$err" || rc=$? + detect_go "$log" || dc=$? + if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then + cat "$err" >&2 || true + grep -v '^{' "$log" | tail -n 40 >&2 || true + python3 -c 'import json,sys +for raw in open(sys.argv[1], errors="replace"): + try: ev = json.loads(raw) + except ValueError: continue + if ev.get("Action") == "output" and ("FAIL" in ev.get("Output", "") or "SKIP" in ev.get("Output", "") or "_test.go:" in ev.get("Output", "")): + sys.stderr.write(ev["Output"])' "$log" 2>&1 | tail -n 40 >&2 || true + rm -f "$log" "$err" + echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2 + return 1 + fi + echo "go: $(grep -c '"Action":"pass","Package":"[^"]*","Test":"[^"/]*","Elapsed"' "$log") top-level tests passed in $dir ($*)" + rm -f "$log" "$err" +} + +# go_named DIR PACKAGES FLAGS NAME... runs exactly the named top-level tests +# and requires every one of them to pass. PACKAGES and FLAGS are word-split. +go_named() { + local dir="$1" pkgs="$2" flags="$3" + shift 3 + if [[ "$#" -eq 0 ]]; then + echo "refuse: go_named $pkgs in $dir without required names" >&2 + return 1 + fi + local names=("$@") regex + regex="^($(IFS='|'; echo "${names[*]}"))\$" + # shellcheck disable=SC2086 + REQUIRE_TESTS="${names[*]}" go_json "$dir" $flags -count=1 -run "$regex" $pkgs +} + +# go_pkg_named DIR FLAGS ENTRY... groups : entries by +# package and runs go_named once per package. +go_pkg_named() { + local dir="$1" flags="$2" + shift 2 + if [[ "$#" -eq 0 ]]; then + echo "refuse: go_pkg_named in $dir without required names" >&2 + return 1 + fi + local entry pkg name pkgs=() + declare -A by=() + for entry in "$@"; do + pkg="${entry%%:*}" + name="${entry#*:}" + if [[ -z "$pkg" || -z "$name" || "$pkg" == "$entry" ]]; then + echo "refuse: malformed required test entry $entry" >&2 + return 1 + fi + if [[ -z "${by[$pkg]+x}" ]]; then + pkgs+=("$pkg") + by[$pkg]="$name" + else + by[$pkg]="${by[$pkg]} $name" + fi + done + for pkg in "${pkgs[@]}"; do + # shellcheck disable=SC2086 + go_named "$dir" "$pkg" "$flags" ${by[$pkg]} + done +} + +# go_short DIR PACKAGES runs go test -short PACKAGES in DIR; on failure it +# prints the tail of the output and fails. GO_ENV and GO_FLAGS are +# word-split into the command. +go_short() { + local dir="$1" pkgs="${2:-./...}" out + # shellcheck disable=SC2086 + if ! out="$(cd "$dir" && env ${GO_ENV:-} go test ${GO_FLAGS:-} -count=1 -short $pkgs 2>&1)"; then + printf '%s\n' "$out" | grep -v '^ok ' | tail -n 60 >&2 + echo "refuse: go test -short $pkgs in $dir" >&2 + return 1 + fi + echo "short: $dir ($pkgs ${GO_FLAGS:-})" +} + +go_vet() { + local dir="$1" + shift + # shellcheck disable=SC2086 + (cd "$dir" && env ${GO_ENV:-} go vet "$@") + echo "vet: $dir ($*)" +} + +require_dir() { + [[ -d "$1" ]] || { + echo "refuse: $2 $1 not found" >&2 + return 1 + } +} + +expect_detector() { + local detector="$1" name="$2" want="$3" payload="$4" log dc=0 + log="$(mktemp)" + printf '%s\n' "$payload" >"$log" + "$detector" "$log" >/dev/null 2>&1 || dc=$? + rm -f "$log" + if [[ "$dc" -ne "$want" ]]; then + echo "refuse: self-test $detector $name: exit $dc, want $want" >&2 + return 1 + fi +} + +# expect_mode NAME WANT_EXIT WANT_TEXT MODE ENV... runs this script in MODE +# with ENV and checks its exit status and that its output carries WANT_TEXT. +expect_mode() { + local name="$1" want="$2" text="$3" mode="$4" + shift 4 + local out rc=0 + out="$(env "$@" bash "${BASH_SOURCE[0]}" "$mode" 2>&1)" || rc=$? + if [[ "$rc" -ne "$want" ]] || ! grep -qF -- "$text" <<<"$out"; then + printf '%s\n' "$out" | tail -n 10 >&2 + echo "refuse: self-test $name: exit $rc (want $want), output must carry \"$text\"" >&2 + return 1 + fi +} + +# marker_callers DIR prints every non-test .go file under DIR/modules, +# outside modules/pier and modules/bouncer, that calls the dispatch setter. +marker_callers() { + local dir="$1" + (cd "$dir" && grep -rlE '(bouncer\.)?WithDispatch\(' --include='*.go' modules 2>/dev/null) | + grep -vE '_test\.go$' | grep -vE '^modules/(pier|bouncer)/' || true +} + +# removed_exports reads a unified diff and prints every removed line that +# declares an exported func, method or type. +removed_exports() { + grep -E '^-(func ([A-Z]|\([^)]*\) [A-Z])|type [A-Z])' | grep -vE '^--- ' || true +} + +run_self_test() { + bash -n "${BASH_SOURCE[0]}" + + expect_detector detect_go pass 0 '{"Action":"pass","Package":"p","Test":"TestToolsList"}' + expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"} +{"Action":"fail","Package":"p","Test":"TestDispatchMarker"}' + expect_detector detect_go package-fail 1 '{"Action":"pass","Package":"p","Test":"TestA"} +{"Action":"fail","Package":"p"}' + expect_detector detect_go build 1 '{"Action":"build-fail","ImportPath":"p"}' + expect_detector detect_go skip 2 '{"Action":"pass","Package":"p","Test":"TestA"} +{"Action":"skip","Package":"p","Test":"TestToolsList"}' + expect_detector detect_go subtest-skip 2 '{"Action":"skip","Package":"p","Test":"TestToolsList/union_of_both_guards"} +{"Action":"pass","Package":"p","Test":"TestToolsList"}' + expect_detector detect_go zero 3 '{"Action":"pass","Package":"p"}' + expect_detector detect_go no-tests 3 '{"Action":"output","Package":"p","Output":"testing: warning: no tests to run\n"} +{"Action":"pass","Package":"p"}' + expect_detector detect_go nonjson 4 '{"Action":"pass",' + REQUIRE_TESTS="TestToolsList TestDispatchMarker" expect_detector detect_go missing-named 5 \ + '{"Action":"pass","Package":"p","Test":"TestToolsList"}' + REQUIRE_TESTS="TestDispatch" expect_detector detect_go prefix-is-not-the-test 5 \ + '{"Action":"pass","Package":"p","Test":"TestDispatchMarker"}' + REQUIRE_TESTS="TestDispatchMarker" expect_detector detect_go subtest-is-not-parent 5 \ + '{"Action":"pass","Package":"p","Test":"TestOuter/TestDispatchMarker"} +{"Action":"pass","Package":"p","Test":"TestOther"}' + + local ok=' ✓ tests/account/AccountContributions.test.ts (9 tests) 15ms + ✓ tests/form/RecordActionsPrompt.test.ts (8 tests) 147ms + + Test Files 2 passed (2) + Tests 17 passed (17)' + REQUIRE_FILES="tests/account/AccountContributions.test.ts tests/form/RecordActionsPrompt.test.ts" expect_detector detect_vitest pass 0 "$ok" + REQUIRE_FILES="tests/form/FormView.test.ts" expect_detector detect_vitest missing-file 5 "$ok" + expect_detector detect_vitest failed 1 ' ✓ tests/account/AccountContributions.test.ts (9 tests) 15ms + Test Files 1 failed | 1 passed (2) + Tests 1 failed | 16 passed (17)' + expect_detector detect_vitest skipped 1 ' ✓ tests/account/AccountContributions.test.ts (9 tests | 1 skipped) 15ms + Test Files 1 passed (1) + Tests 8 passed | 1 skipped (9)' + expect_detector detect_vitest no-summary 3 ' ✓ tests/account/AccountContributions.test.ts (9 tests) 15ms' + expect_detector detect_vitest zero 3 ' Tests 0 passed (0)' + + # The marker rule on planted files: a caller outside pier and bouncer is + # found, a test file and pier's own call are not. + local tmp + tmp="$(mktemp -d)" + mkdir -p "$tmp/modules/pier" "$tmp/modules/bouncer" "$tmp/modules/cabana" + printf 'package pier\nfunc f() { _ = bouncer.WithDispatch(nil, nil) }\n' >"$tmp/modules/pier/dispatch.go" + printf 'package bouncer\nfunc WithDispatch() {}\n' >"$tmp/modules/bouncer/dispatch.go" + printf 'package cabana\nfunc f() { _ = bouncer.WithDispatch(nil, nil) }\n' >"$tmp/modules/cabana/x_test.go" + if [[ -n "$(marker_callers "$tmp")" ]]; then + rm -rf "$tmp" + echo "refuse: self-test marker_callers flagged pier, bouncer or a test file" >&2 + return 1 + fi + printf 'package cabana\nfunc g() { _ = bouncer.WithDispatch(nil, nil) }\n' >"$tmp/modules/cabana/forge.go" + if [[ "$(marker_callers "$tmp")" != "modules/cabana/forge.go" ]]; then + rm -rf "$tmp" + echo "refuse: self-test marker_callers missed a caller outside pier" >&2 + return 1 + fi + + # The docker NOT RUN rule: a docker that does not answer. + mkdir -p "$tmp/bin" + printf '#!/bin/sh\nexit 1\n' >"$tmp/bin/docker" + chmod +x "$tmp/bin/docker" + expect_mode docker-not-run 0 "NOT RUN docker" --docker-gate "PATH=$tmp/bin:$PATH" PHASE197_REQUIRE_DOCKER=0 + expect_mode docker-required 1 "FAIL docker" --docker-gate "PATH=$tmp/bin:$PATH" PHASE197_REQUIRE_DOCKER=1 + expect_mode docker-stage-needs-daemon 1 "needs a reachable Docker daemon" --docker "PATH=$tmp/bin:$PATH" + rm -rf "$tmp" + + # The additive check refuses a removed export and accepts additions. + local diff_bad diff_ok line + diff_bad=$'--- a/classes/x.go\n+++ b/classes/x.go\n-func LinkedBackendUser(ctx context.Context) error {\n+func linkedBackendUser(ctx context.Context) error {' + diff_ok=$'--- a/classes/x.go\n+++ b/classes/x.go\n+func FireSubjectChanged() {\n-func helper() {}\n-\t// func Old() is gone' + if [[ -z "$(removed_exports <<<"$diff_bad")" ]]; then + echo "refuse: self-test removed_exports missed a removed exported func" >&2 + return 1 + fi + if [[ -n "$(removed_exports <<<"$diff_ok")" ]]; then + echo "refuse: self-test removed_exports refused an additive diff" >&2 + return 1 + fi + for line in '-type MCPTokenVerifier struct {' '-func (s *BackendLinks) SetLink(ctx context.Context) error {' '-func (Plugin) MCP() pact.MCP {'; do + if [[ -z "$(removed_exports <<<"$line")" ]]; then + echo "refuse: self-test removed_exports missed: $line" >&2 + return 1 + fi + done + + local mode + for mode in self-test go plugins consumers docker admin docs marker all; do + grep -q -- "^ --$mode" "${BASH_SOURCE[0]}" || { + echo "refuse: missing mode --$mode" >&2 + return 1 + } + done + echo "phase19.7 self-test passed" +} + +run_go() { + go_vet "$ROOT" ./... + go_vet "$ROOT" -tags "$MCP_TAG" ./... + go_short "$ROOT" ./... + (cd "$ROOT" && go -C examples/hello test -count=1 ./...) >/dev/null + echo "hello: examples/hello passed" + (cd "$ROOT" && go build -tags "$MCP_TAG" ./...) + echo "build: go build -tags $MCP_TAG ./..." + local sdk + sdk="$(cd "$ROOT" && go -C examples/hello list -deps ./... | grep -E 'modelcontextprotocol|google/jsonschema-go|modules/pier$' || true)" + if [[ -n "$sdk" ]]; then + printf '%s\n' "$sdk" >&2 + echo "refuse: the untagged hello binary links the MCP module or its SDK (D-30)" >&2 + return 1 + fi + echo "untagged: examples/hello links no MCP SDK" + go_pkg_named "$ROOT" "-short" "${GO_TESTS[@]}" + go_pkg_named "$ROOT" "-short -tags $MCP_TAG" "${GO_TAGGED_TESTS[@]}" + echo "phase19.7 go passed" +} + +run_marker() { + local callers deps + callers="$(marker_callers "$ROOT")" + if [[ -n "$callers" ]]; then + printf '%s\n' "$callers" >&2 + echo "refuse: a framework file outside modules/pier and modules/bouncer calls bouncer.WithDispatch (D-17)" >&2 + return 1 + fi + if ! (cd "$ROOT" && grep -q 'bouncer.WithDispatch(' modules/pier/dispatch.go); then + echo "refuse: modules/pier/dispatch.go no longer sets the marker; update the marker rule" >&2 + return 1 + fi + echo "marker: only modules/pier sets the dispatch marker" + deps="$(cd "$ROOT" && go list -deps ./modules/pact | grep -E 'modelcontextprotocol|google/jsonschema-go|summercms/modules/bouncer$' || true)" + if [[ -n "$deps" ]]; then + printf '%s\n' "$deps" >&2 + echo "refuse: modules/pact depends on the MCP SDK, jsonschema-go or bouncer" >&2 + return 1 + fi + echo "pact: no MCP SDK, jsonschema-go or bouncer dependency" + echo "phase19.7 marker passed" +} + +run_plugins() { + echo "TODO stage plugins" >&2 + return 1 +} + +run_consumers() { + echo "TODO stage consumers" >&2 + return 1 +} + +docker_up() { + command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 +} + +run_docker() { + if ! docker_up; then + echo "refuse: the docker stage needs a reachable Docker daemon (testcontainers); run it on a Docker host" >&2 + return 1 + fi + echo "TODO stage docker" >&2 + return 1 +} + +# docker_gate is the docker step of --all: run the stage when a daemon +# answers, fail with PHASE197_REQUIRE_DOCKER=1, print NOT RUN otherwise. +docker_gate() { + if docker_up; then + if bash "${BASH_SOURCE[0]}" --docker; then + echo "PASS docker" + return 0 + fi + echo "FAIL docker" + return 1 + fi + if [[ "${PHASE197_REQUIRE_DOCKER:-0}" == "1" ]]; then + echo "FAIL docker: no reachable Docker daemon and PHASE197_REQUIRE_DOCKER=1" + return 1 + fi + cat >&2 <<'EOF' +************************************************************************ +NOT RUN docker: no reachable Docker daemon on this host. +The Postgres suites of the framework, the user, OAuth server and blog +plugins and the demo's end-to-end MCP test did NOT run. Run on a Docker +host (through sudo -u golem when the socket is denied) before +/msd-verify-work: + bash scripts/check-phase19.7.sh docker +************************************************************************ +EOF + echo "NOT RUN docker (Docker-host only; see the notice above)" +} + +run_admin() { + echo "TODO stage admin" >&2 + return 1 +} + +run_docs() { + echo "TODO stage docs" >&2 + return 1 +} + +run_all() { + local stage + for stage in self-test go plugins consumers admin docs marker; do + if bash "${BASH_SOURCE[0]}" "--$stage"; then + echo "PASS $stage" + else + echo "FAIL $stage" + exit 1 + fi + done + docker_gate || exit 1 + echo "Phase 19.7 gate passed" +} + +case "${1:---all}" in + --self-test | self-test) run_self_test ;; + --go | go) run_go ;; + --plugins | plugins) run_plugins ;; + --consumers | consumers) run_consumers ;; + --docker | docker) run_docker ;; + --docker-gate) docker_gate ;; + --admin | admin) run_admin ;; + --docs | docs) run_docs ;; + --marker | marker) run_marker ;; + --all | all) run_all ;; + *) usage ;; +esac