From 49a3df68c80e5a26b2aa10085f07fb445b3abdeb Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 24 Oct 2026 00:38:42 +0200 Subject: [PATCH] test(phase-19.7): add Nyquist validation tests --- .../pier/backend_scope_unavailable_test.go | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 modules/pier/backend_scope_unavailable_test.go diff --git a/modules/pier/backend_scope_unavailable_test.go b/modules/pier/backend_scope_unavailable_test.go new file mode 100644 index 00000000..2948c2b5 --- /dev/null +++ b/modules/pier/backend_scope_unavailable_test.go @@ -0,0 +1,228 @@ +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) + } + }) +}