test(19.7-05): phase gate with the go and marker stages, pier test helpers, TestToolsList and TestDispatchMarker
- scripts/check-phase19.7.sh: fail-closed multi-repo gate (self-test, go, marker; plugins, consumers, docker, admin and docs are stubs until the later tasks fill them) - modules/pier/helpers_test.go: the plan 02 fixtures moved out of the smokes plus fakeGuard, fakeVerifier, fakeLinkResolver, fakePersonalResolver, routePlugin, newApp, twoInstances, sdkClient and fireSubjectChanged - TestToolsList (pier), TestDispatchMarker (bouncer, cabana with a Postgres part through the screens harness, which now keeps its app)
This commit is contained in:
124
modules/bouncer/dispatch_test.go
Normal file
124
modules/bouncer/dispatch_test.go
Normal file
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
169
modules/cabana/dispatch_marker_test.go
Normal file
169
modules/cabana/dispatch_marker_test.go
Normal file
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
925
modules/pier/helpers_test.go
Normal file
925
modules/pier/helpers_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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()}}}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
150
modules/pier/tools_list_test.go
Normal file
150
modules/pier/tools_list_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
571
scripts/check-phase19.7.sh
Executable file
571
scripts/check-phase19.7.sh
Executable file
@@ -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 <package>:<TestName>; 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 <package>:<TestName> 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
|
||||
Reference in New Issue
Block a user