package pier import ( "context" "database/sql" "encoding/json" "errors" "io/fs" "net/http" "os" "path/filepath" "testing" "testing/fstest" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "github.com/google/jsonschema-go/jsonschema" _ "github.com/jackc/pgx/v5/stdlib" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/testcontainers/testcontainers-go/modules/postgres" ) // scopeFixtureController is the one admin controller cabana.Activate needs // before it publishes the backend resolver BackendPrincipal reads. type scopeFixtureController struct{} func (scopeFixtureController) ID() string { return "acme.scopes.items" } func (scopeFixtureController) ModelName() string { return "Item" } func (scopeFixtureController) ConfigDir() string { return "controllers/items" } func (scopeFixtureController) NewRecord() any { return &scopeItem{} } // scopeItem is the fixture controller's model. type scopeItem struct { ID uint `gorm:"column:id;primaryKey"` } func (scopeItem) TableName() string { return "acme_scope_items" } type scopeFixturePlugin struct{} func (scopeFixturePlugin) ID() string { return "acme.scopes" } func (scopeFixturePlugin) Requires() []string { return nil } func (scopeFixturePlugin) Register(*backpack.App) error { return nil } func (scopeFixturePlugin) Boot(*backpack.App) error { return nil } func (scopeFixturePlugin) AdminControllers() []pact.AdminController { return []pact.AdminController{scopeFixtureController{}} } func (scopeFixturePlugin) AdminFS() fs.FS { return fstest.MapFS{ "controllers/items/config_list.yaml": {Data: []byte("list: ~/plugins/acme/scopes/models/item/columns.yaml\nmodelClass: Item\ntitle: Items\n")}, "models/item/columns.yaml": {Data: []byte("columns:\n id:\n label: ID\n")}, } } // okHost serves every compiled route with 200 {"ok":true}. type okHost struct{} func (okHost) Routes() []RouteInfo { return nil } func (okHost) Handler(string, string) (http.Handler, bool) { return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok":true}`)) }), true } func (okHost) MountStream(string, string, http.Handler, ...string) error { return nil } func (okHost) MountJSON(string, string, http.Handler) error { return nil } func (okHost) Owner(string, string) (string, bool) { return "", false } // scopeApp starts Postgres, migrates the backend admin tables and activates // cabana on a fresh app so cabana.BackendPrincipal and ScopeLimitFor are real. func scopeApp(t *testing.T) (*backpack.App, *sql.DB) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() ctr, err := postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("pier"), postgres.WithUsername("pier"), postgres.WithPassword("pier"), postgres.BasicWaitStrategies()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = ctr.Terminate(context.Background()) }) dsn, err := ctr.ConnectionString(ctx, "sslmode=disable") if err != nil { t.Fatal(err) } db, err := sql.Open("pgx", dsn) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) gdb, err := lagoon.Use(ctx, db) if err != nil { t.Fatal(err) } if err := lagoon.Migrate(gdb, nil); err != nil { t.Fatal(err) } dir := t.TempDir() files := map[string]string{ "app.yaml": "name: acme\nurl: http://acme.test\nlocale: en\nfallback_locale: en\n", "http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n", } 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", "SUMMER_ADMIN__JWT__SECRET=summercms-test-only-admin-hs256-secret"}}) if err != nil { t.Fatal(err) } app := backpack.New(cfg) if err := lagoon.Publish(app, db, gdb); err != nil { t.Fatal(err) } if _, err := cabana.Activate(app, []party.Plugin{scopeFixturePlugin{}}); err != nil { t.Fatal(err) } return app, db } func seedScopeAdmin(t *testing.T, db *sql.DB, login string, active bool) uint { t.Helper() var id uint if err := db.QueryRow(`INSERT INTO backend_users (login, email, password, permissions, is_activated, is_superuser) VALUES ($1, $1 || '@acme.test', 'x', '{}', $2, TRUE) RETURNING id`, login, active).Scan(&id); err != nil { t.Fatal(err) } return id } // TestBackendScopeUnavailable pins RA-IN-05 against the real cabana: an // admin personal token whose stored scopes resolve to no registered scope // is refused with errScopeUnavailable (not errSubjectUnavailable), both at // dispatch and in the listing check. func TestBackendScopeUnavailable(t *testing.T) { if testing.Short() { t.Skip("requires testcontainers postgres") } app, db := scopeApp(t) active := seedScopeAdmin(t, db, "active", true) inactive := seedScopeAdmin(t, db, "inactive", false) g := backendGuard{app: app} ctx := context.Background() gone := []string{"removed.plugin:scope"} pat := func(user uint, scopes []string) pact.MCPSubject { return pact.MCPSubject{Guard: BackendGuard, UserID: user, TokenID: 9, Scopes: scopes} } schema := &jsonschema.Schema{Type: "object"} resolved, err := schema.Resolve(nil) if err != nil { t.Fatal(err) } tl := &tool{name: "acme_items", method: http.MethodGet, pattern: "/api/acme/items", guardName: BackendGuard, guard: g, schema: schema, resolved: resolved} d := &dispatcher{host: okHost{}, app: app, cfg: Config{CallTimeout: 10 * time.Second, MaxResultBytes: DefaultMaxResultBytes}} call := func(subject pact.MCPSubject) *mcp.CallToolResult { return d.call(ctx, Identity{Backend: &subject}, callInfo{}, tl, json.RawMessage(`{}`)) } text := func(res *mcp.CallToolResult) string { if len(res.Content) == 0 { return "" } tc, _ := res.Content[0].(*mcp.TextContent) if tc == nil { return "" } return tc.Text } t.Run("unknown scopes refuse with errScopeUnavailable", func(t *testing.T) { _, err := g.MCPDispatch(ctx, pat(active, gone), http.MethodGet) if !errors.Is(err, errScopeUnavailable) || errors.Is(err, errSubjectUnavailable) { t.Fatalf("MCPDispatch err = %v", err) } res := call(pat(active, gone)) if !res.IsError || text(res) != "pier: the token's scopes no longer grant this route" { t.Fatalf("tool call = %+v %q", res, text(res)) } ok, err := g.MCPAllowsMethod(ctx, pat(active, gone), pact.MCPRequirements{}, nil, http.MethodGet) if ok || err != nil { t.Fatalf("MCPAllowsMethod = %v, %v, want false, nil", ok, err) } }) t.Run("a known scope dispatches", func(t *testing.T) { disp, err := g.MCPDispatch(ctx, pat(active, []string{"admin:read"}), http.MethodGet) if err != nil || disp.Principal == nil { t.Fatalf("MCPDispatch = %+v, %v", disp, err) } ok, err := g.MCPAllowsMethod(ctx, pat(active, []string{"admin:read"}), pact.MCPRequirements{}, nil, http.MethodGet) if !ok || err != nil { t.Fatalf("MCPAllowsMethod = %v, %v", ok, err) } if res := call(pat(active, []string{"admin:read"})); res.IsError { t.Fatalf("tool call = %q", text(res)) } }) t.Run("a deactivated or missing admin is still unavailable", func(t *testing.T) { for _, user := range []uint{inactive, 999999} { for _, scopes := range [][]string{gone, {"admin:read"}} { if _, err := g.MCPDispatch(ctx, pat(user, scopes), http.MethodGet); !errors.Is(err, errSubjectUnavailable) { t.Fatalf("user %d scopes %v: err = %v", user, scopes, err) } if res := call(pat(user, scopes)); text(res) != "pier: the caller's account is unknown or deactivated" { t.Fatalf("user %d tool call = %q", user, text(res)) } } } }) t.Run("an OAuth subject skips the scope check", func(t *testing.T) { subject := pact.MCPSubject{Guard: BackendGuard, UserID: active, ClientID: "c1", TokenID: 9, Scopes: gone} if _, err := g.MCPDispatch(ctx, subject, http.MethodGet); err != nil { t.Fatalf("MCPDispatch err = %v", err) } ok, err := g.MCPAllowsMethod(ctx, subject, pact.MCPRequirements{}, nil, http.MethodGet) if !ok || err != nil { t.Fatalf("MCPAllowsMethod = %v, %v", ok, err) } }) }