test(phase-19.7): add Nyquist validation tests

This commit is contained in:
2026-10-24 00:38:42 +02:00
parent 505b8991ee
commit 49a3df68c8

View 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)
}
})
}