test(phase-19.7): add Nyquist validation tests
This commit is contained in:
228
modules/pier/backend_scope_unavailable_test.go
Normal file
228
modules/pier/backend_scope_unavailable_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user