- pact/mcp.go: HasMCP, MCP, MCPTool, MCPPrompt, MCPGuard, MCPGuards, MCPSubject, MCPDispatch, MCPTokenVerifier, MCPGrant, ErrMCPTokenInvalid, MCPPersonalTokenResolver, MCPLinkedBackendResolver, LinkedBackendUser, MCPSubjectChanged and its kinds, FrontendPermission, HasFrontendPermissions - capabilities.go: AdminActionPrompt, AdminRecordAction.Prompt, AdminRecordActionInput.Value, HasAccountContributions, AccountContribution, AccountAction, AccountActionInput, AccountActionResult - discovery tests, MCPGuards registry test and a go list -deps assertion that pact names neither the MCP SDK, jsonschema-go nor bouncer - pact README and docs/plugins/registration.md rows
651 lines
23 KiB
Go
651 lines
23 KiB
Go
package pact
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os/exec"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
"github.com/go-gormigrate/gormigrate/v2"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type configOnly struct{}
|
|
|
|
func (configOnly) ConfigFS() fs.FS {
|
|
return fstest.MapFS{"config/config.yaml": {Data: []byte("k: 1\n")}}
|
|
}
|
|
|
|
type commandsOnly struct{}
|
|
|
|
func (commandsOnly) Commands() []bonfire.Command {
|
|
return []bonfire.Command{{Name: "demo:run"}}
|
|
}
|
|
|
|
type bothCaps struct {
|
|
configOnly
|
|
commandsOnly
|
|
}
|
|
|
|
type neither struct{}
|
|
|
|
type migrationsOnly struct{}
|
|
|
|
func (migrationsOnly) Migrations() []*gormigrate.Migration {
|
|
return []*gormigrate.Migration{{ID: "202609170001_create_users"}}
|
|
}
|
|
|
|
func TestOptionalCapabilitiesDiscoveredByTypeAssertion(t *testing.T) {
|
|
plugins := []any{configOnly{}, commandsOnly{}, bothCaps{}, neither{}, extraMessage{}, migrationsOnly{}}
|
|
|
|
var configs, commands, messages, migrations, none int
|
|
for _, p := range plugins {
|
|
_, hasCfg := p.(HasConfig)
|
|
_, hasCmd := p.(HasCommands)
|
|
_, hasMsg := p.(OptionalMessage)
|
|
_, hasMig := p.(HasMigrations)
|
|
if hasCfg {
|
|
configs++
|
|
}
|
|
if hasCmd {
|
|
commands++
|
|
}
|
|
if hasMsg {
|
|
messages++
|
|
}
|
|
if hasMig {
|
|
migrations++
|
|
}
|
|
if !hasCfg && !hasCmd && !hasMsg && !hasMig {
|
|
none++
|
|
}
|
|
}
|
|
if configs != 2 {
|
|
t.Fatalf("HasConfig count = %d, want 2", configs)
|
|
}
|
|
if commands != 2 {
|
|
t.Fatalf("HasCommands count = %d, want 2", commands)
|
|
}
|
|
if messages != 1 {
|
|
t.Fatalf("OptionalMessage count = %d, want 1", messages)
|
|
}
|
|
if migrations != 1 {
|
|
t.Fatalf("HasMigrations count = %d, want 1", migrations)
|
|
}
|
|
if none != 1 {
|
|
t.Fatalf("neither count = %d, want 1", none)
|
|
}
|
|
}
|
|
|
|
func TestHasMigrationsReturnsOrderedSet(t *testing.T) {
|
|
var p HasMigrations = migrationsOnly{}
|
|
set := p.Migrations()
|
|
if len(set) != 1 || set[0].ID != "202609170001_create_users" {
|
|
t.Fatalf("Migrations = %+v", set)
|
|
}
|
|
}
|
|
|
|
func TestHasConfigReturnsFilesystem(t *testing.T) {
|
|
var p HasConfig = configOnly{}
|
|
fsys := p.ConfigFS()
|
|
if fsys == nil {
|
|
t.Fatal("ConfigFS returned nil")
|
|
}
|
|
raw, err := fs.ReadFile(fsys, "config/config.yaml")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(raw) != "k: 1\n" {
|
|
t.Fatalf("ConfigFS body = %q", raw)
|
|
}
|
|
}
|
|
|
|
func TestHasCommandsReturnsNamespacedCommand(t *testing.T) {
|
|
var p HasCommands = commandsOnly{}
|
|
cmds := p.Commands()
|
|
if len(cmds) != 1 || cmds[0].Name != "demo:run" {
|
|
t.Fatalf("Commands = %+v", cmds)
|
|
}
|
|
}
|
|
|
|
func TestOptionalMessageContract(t *testing.T) {
|
|
var msg OptionalMessage = extraMessage{s: "hello-from-optional"}
|
|
if msg.Message() != "hello-from-optional" {
|
|
t.Fatalf("Message = %q", msg.Message())
|
|
}
|
|
}
|
|
|
|
type extraMessage struct{ s string }
|
|
|
|
func (e extraMessage) Message() string { return e.s }
|
|
|
|
type routesOnly struct{}
|
|
|
|
func (routesOnly) Routes(Router) error { return nil }
|
|
|
|
type middlewareOnly struct{}
|
|
|
|
func (middlewareOnly) Middlewares() map[string]Middleware {
|
|
return map[string]Middleware{"jwt.auth": nil}
|
|
}
|
|
|
|
type modelsOnly struct{}
|
|
|
|
func (modelsOnly) Models() []any { return []any{struct{}{}} }
|
|
|
|
func TestHTTPCapabilitiesDiscoveredByTypeAssertion(t *testing.T) {
|
|
var r HasRoutes = routesOnly{}
|
|
if err := r.Routes(nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var m HasMiddleware = middlewareOnly{}
|
|
if _, ok := m.Middlewares()["jwt.auth"]; !ok {
|
|
t.Fatal("missing jwt.auth")
|
|
}
|
|
var models HasModels = modelsOnly{}
|
|
if len(models.Models()) != 1 {
|
|
t.Fatalf("Models = %+v", models.Models())
|
|
}
|
|
}
|
|
|
|
// relationHooks implements every relation child hook with the documented
|
|
// signature and records the calls.
|
|
type relationHooks struct{ calls *[]string }
|
|
|
|
func (h relationHooks) record(name, relation string, parent, child any) error {
|
|
*h.calls = append(*h.calls, name+":"+relation+":"+parent.(string)+":"+child.(string))
|
|
if relation == "fail" {
|
|
return errors.New(name + " refused")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h relationHooks) RelationBeforeCreate(_ context.Context, relation string, parent, child any) error {
|
|
return h.record("BeforeCreate", relation, parent, child)
|
|
}
|
|
func (h relationHooks) RelationAfterCreate(_ context.Context, relation string, parent, child any) error {
|
|
return h.record("AfterCreate", relation, parent, child)
|
|
}
|
|
func (h relationHooks) RelationBeforeUpdate(_ context.Context, relation string, parent, child any) error {
|
|
return h.record("BeforeUpdate", relation, parent, child)
|
|
}
|
|
func (h relationHooks) RelationAfterUpdate(_ context.Context, relation string, parent, child any) error {
|
|
return h.record("AfterUpdate", relation, parent, child)
|
|
}
|
|
func (h relationHooks) RelationBeforeDelete(_ context.Context, relation string, parent, child any) error {
|
|
return h.record("BeforeDelete", relation, parent, child)
|
|
}
|
|
func (h relationHooks) RelationAfterDelete(_ context.Context, relation string, parent, child any) error {
|
|
return h.record("AfterDelete", relation, parent, child)
|
|
}
|
|
|
|
var (
|
|
_ RelationBeforeCreate = relationHooks{}
|
|
_ RelationAfterCreate = relationHooks{}
|
|
_ RelationBeforeUpdate = relationHooks{}
|
|
_ RelationAfterUpdate = relationHooks{}
|
|
_ RelationBeforeDelete = relationHooks{}
|
|
_ RelationAfterDelete = relationHooks{}
|
|
)
|
|
|
|
// TestRelationHookInterfaces: each of the six optional relation child hooks
|
|
// is discovered by a type assertion on the controller value, receives the
|
|
// relation name, the parent and the child, and passes its error back; a
|
|
// controller without them asserts false.
|
|
func TestRelationHookInterfaces(t *testing.T) {
|
|
var calls []string
|
|
var ctl any = relationHooks{calls: &calls}
|
|
hooks := []func(context.Context, string, any, any) error{}
|
|
if h, ok := ctl.(RelationBeforeCreate); ok {
|
|
hooks = append(hooks, h.RelationBeforeCreate)
|
|
}
|
|
if h, ok := ctl.(RelationAfterCreate); ok {
|
|
hooks = append(hooks, h.RelationAfterCreate)
|
|
}
|
|
if h, ok := ctl.(RelationBeforeUpdate); ok {
|
|
hooks = append(hooks, h.RelationBeforeUpdate)
|
|
}
|
|
if h, ok := ctl.(RelationAfterUpdate); ok {
|
|
hooks = append(hooks, h.RelationAfterUpdate)
|
|
}
|
|
if h, ok := ctl.(RelationBeforeDelete); ok {
|
|
hooks = append(hooks, h.RelationBeforeDelete)
|
|
}
|
|
if h, ok := ctl.(RelationAfterDelete); ok {
|
|
hooks = append(hooks, h.RelationAfterDelete)
|
|
}
|
|
if len(hooks) != 6 {
|
|
t.Fatalf("discovered %d hooks, want 6", len(hooks))
|
|
}
|
|
for _, hook := range hooks {
|
|
if err := hook(t.Context(), "parts", "gadget", "part"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := hook(t.Context(), "fail", "gadget", "part"); err == nil {
|
|
t.Fatal("hook error was not passed back")
|
|
}
|
|
}
|
|
want := "BeforeCreate:parts:gadget:part,BeforeCreate:fail:gadget:part,AfterCreate:parts:gadget:part,AfterCreate:fail:gadget:part," +
|
|
"BeforeUpdate:parts:gadget:part,BeforeUpdate:fail:gadget:part,AfterUpdate:parts:gadget:part,AfterUpdate:fail:gadget:part," +
|
|
"BeforeDelete:parts:gadget:part,BeforeDelete:fail:gadget:part,AfterDelete:parts:gadget:part,AfterDelete:fail:gadget:part"
|
|
if got := strings.Join(calls, ","); got != want {
|
|
t.Fatalf("calls = %s", got)
|
|
}
|
|
var plain any = struct{}{}
|
|
for name, ok := range map[string]bool{
|
|
"BeforeCreate": func() bool { _, ok := plain.(RelationBeforeCreate); return ok }(),
|
|
"AfterCreate": func() bool { _, ok := plain.(RelationAfterCreate); return ok }(),
|
|
"BeforeUpdate": func() bool { _, ok := plain.(RelationBeforeUpdate); return ok }(),
|
|
"AfterUpdate": func() bool { _, ok := plain.(RelationAfterUpdate); return ok }(),
|
|
"BeforeDelete": func() bool { _, ok := plain.(RelationBeforeDelete); return ok }(),
|
|
"AfterDelete": func() bool { _, ok := plain.(RelationAfterDelete); return ok }(),
|
|
} {
|
|
if ok {
|
|
t.Fatalf("a plain value implements Relation%s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRowStateValues pins the wire values of the fixed row-state set: the
|
|
// admin SPA and plugin CSS match on these exact strings.
|
|
func TestRowStateValues(t *testing.T) {
|
|
got := []RowState{RowStateDeleted, RowStateNegative, RowStateDisabled}
|
|
want := []string{"deleted", "negative", "disabled"}
|
|
for i, state := range got {
|
|
if string(state) != want[i] {
|
|
t.Fatalf("row state %d = %q, want %q", i, state, want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
type formSeams struct{}
|
|
|
|
func (formSeams) FormVirtualFields() []string { return []string{"password", "password_confirmation"} }
|
|
|
|
func (formSeams) FormRules(_ context.Context, op string) map[string]string {
|
|
if op == "create" {
|
|
return map[string]string{"password": "required|confirmed"}
|
|
}
|
|
return map[string]string{"password": "nullable|confirmed"}
|
|
}
|
|
|
|
func TestFormSeamsDiscoveredByTypeAssertion(t *testing.T) {
|
|
var ctl any = formSeams{}
|
|
virtual, ok := ctl.(FormVirtualFields)
|
|
if !ok || len(virtual.FormVirtualFields()) != 2 {
|
|
t.Fatalf("FormVirtualFields = %v ok=%v", virtual, ok)
|
|
}
|
|
rules, ok := ctl.(FormRules)
|
|
if !ok {
|
|
t.Fatal("FormRules is not implemented")
|
|
}
|
|
if got := rules.FormRules(context.Background(), "create")["password"]; got != "required|confirmed" {
|
|
t.Fatalf("create rules = %q", got)
|
|
}
|
|
if got := rules.FormRules(context.Background(), "update")["password"]; got != "nullable|confirmed" {
|
|
t.Fatalf("update rules = %q", got)
|
|
}
|
|
if _, ok := any(neither{}).(FormVirtualFields); ok {
|
|
t.Fatal("a plugin without the method implements FormVirtualFields")
|
|
}
|
|
}
|
|
|
|
// sampleRoster is a controller value that implements every admin contract of
|
|
// Phase 12.1: declared bulk and record actions, row states and controller
|
|
// filter choices.
|
|
type sampleRoster struct{ ran *[]string }
|
|
|
|
func (s sampleRoster) AdminBulkActions() []AdminBulkAction {
|
|
return []AdminBulkAction{{
|
|
Name: "activate", Label: "acme::lang.activate", Confirm: "acme::lang.activate_confirm",
|
|
Permissions: []string{"acme.manage"},
|
|
Run: func(_ context.Context, in AdminBulkActionInput) (AdminBulkActionResult, error) {
|
|
*s.ran = append(*s.ran, "bulk")
|
|
return AdminBulkActionResult{Message: "acme::lang.activated", Affected: len(in.Records)}, nil
|
|
},
|
|
}}
|
|
}
|
|
|
|
func (s sampleRoster) AdminRecordActions() []AdminRecordAction {
|
|
return []AdminRecordAction{{
|
|
Name: "reinstate", Label: "acme::lang.reinstate",
|
|
Applies: func(_ context.Context, record any) (bool, error) {
|
|
if record == nil {
|
|
return false, errors.New("no record")
|
|
}
|
|
return record.(string) == "banned", nil
|
|
},
|
|
Run: func(_ context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) {
|
|
*s.ran = append(*s.ran, "record")
|
|
return AdminRecordActionResult{Message: "acme::lang.reinstated"}, nil
|
|
},
|
|
}}
|
|
}
|
|
|
|
func (sampleRoster) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]RowState, error) {
|
|
out := make([][]RowState, len(records))
|
|
for i, record := range records {
|
|
if record.(string) == "banned" {
|
|
out[i] = []RowState{RowStateNegative}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (sampleRoster) FilterOptions(scope string) []Option {
|
|
if scope != "tagged" {
|
|
return nil
|
|
}
|
|
return []Option{{Value: "1", Label: "acme::lang.tag"}}
|
|
}
|
|
|
|
var (
|
|
_ HasAdminBulkActions = sampleRoster{}
|
|
_ HasAdminRecordActions = sampleRoster{}
|
|
_ ListRowStates = sampleRoster{}
|
|
_ FilterOptions = sampleRoster{}
|
|
)
|
|
|
|
// TestPhase121ContractsOnSampleController: the bulk action, record action,
|
|
// row state and filter choice contracts are discovered by a type assertion
|
|
// on a controller value and carry exactly the documented fields.
|
|
func TestPhase121ContractsOnSampleController(t *testing.T) {
|
|
var ran []string
|
|
var ctl any = sampleRoster{ran: &ran}
|
|
|
|
bulk, ok := ctl.(HasAdminBulkActions)
|
|
if !ok || len(bulk.AdminBulkActions()) != 1 {
|
|
t.Fatal("HasAdminBulkActions is not discovered")
|
|
}
|
|
action := bulk.AdminBulkActions()[0]
|
|
result, err := action.Run(t.Context(), AdminBulkActionInput{Records: []any{"a", "b"}})
|
|
if err != nil || result.Affected != 2 || result.Message != "acme::lang.activated" {
|
|
t.Fatalf("bulk result = %+v err=%v", result, err)
|
|
}
|
|
if action.Name != "activate" || action.Confirm == "" || len(action.Permissions) != 1 {
|
|
t.Fatalf("bulk action = %+v", action)
|
|
}
|
|
// The input of a bulk action is the loaded records and nothing else: an id
|
|
// list would let a plugin skip the list scope.
|
|
if n := reflect.TypeOf(AdminBulkActionInput{}).NumField(); n != 1 {
|
|
t.Fatalf("AdminBulkActionInput has %d fields, want only Records", n)
|
|
}
|
|
|
|
record, ok := ctl.(HasAdminRecordActions)
|
|
if !ok || len(record.AdminRecordActions()) != 1 {
|
|
t.Fatal("HasAdminRecordActions is not discovered")
|
|
}
|
|
one := record.AdminRecordActions()[0]
|
|
for input, want := range map[string]bool{"banned": true, "active": false} {
|
|
if applies, err := one.Applies(t.Context(), input); err != nil || applies != want {
|
|
t.Fatalf("Applies(%s) = %v err=%v", input, applies, err)
|
|
}
|
|
}
|
|
if _, err := one.Applies(t.Context(), nil); err == nil {
|
|
t.Fatal("Applies did not pass its error back")
|
|
}
|
|
out, err := one.Run(t.Context(), AdminRecordActionInput{RecordID: 7, Record: "banned"})
|
|
if err != nil || out.Message != "acme::lang.reinstated" || one.Confirm != "" {
|
|
t.Fatalf("record result = %+v err=%v", out, err)
|
|
}
|
|
// RecordID, Record and (phase 19.7) the prompt Value: still no id list.
|
|
if fields := reflect.TypeOf(AdminRecordActionInput{}).NumField(); fields != 3 {
|
|
t.Fatalf("AdminRecordActionInput has %d fields, want RecordID, Record and Value", fields)
|
|
}
|
|
if strings.Join(ran, ",") != "bulk,record" {
|
|
t.Fatalf("ran = %v", ran)
|
|
}
|
|
|
|
states, ok := ctl.(ListRowStates)
|
|
if !ok {
|
|
t.Fatal("ListRowStates is not discovered")
|
|
}
|
|
got, err := states.ListRowStates(t.Context(), nil, []any{"active", "banned"})
|
|
if err != nil || len(got) != 2 || got[0] != nil || len(got[1]) != 1 || got[1][0] != RowStateNegative {
|
|
t.Fatalf("row states = %v err=%v", got, err)
|
|
}
|
|
// The fixed set has exactly three values, each its own string.
|
|
set := map[RowState]bool{RowStateDeleted: true, RowStateNegative: true, RowStateDisabled: true}
|
|
if len(set) != 3 || string(RowStateDeleted) != "deleted" || string(RowStateNegative) != "negative" || string(RowStateDisabled) != "disabled" {
|
|
t.Fatalf("row state set = %v", set)
|
|
}
|
|
|
|
options, ok := ctl.(FilterOptions)
|
|
if !ok || len(options.FilterOptions("tagged")) != 1 || options.FilterOptions("other") != nil {
|
|
t.Fatal("FilterOptions is not discovered or answers an unknown scope")
|
|
}
|
|
|
|
var plain any = neither{}
|
|
for name, implemented := range map[string]bool{
|
|
"HasAdminBulkActions": func() bool { _, ok := plain.(HasAdminBulkActions); return ok }(),
|
|
"HasAdminRecordActions": func() bool { _, ok := plain.(HasAdminRecordActions); return ok }(),
|
|
"ListRowStates": func() bool { _, ok := plain.(ListRowStates); return ok }(),
|
|
"FilterOptions": func() bool { _, ok := plain.(FilterOptions); return ok }(),
|
|
} {
|
|
if implemented {
|
|
t.Fatalf("a plain value implements %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// samplePier is a plugin value that implements the phase 19.7 contracts:
|
|
// MCP tools and prompts, frontend permission codes and a My account
|
|
// contribution with a prompted record action on another controller.
|
|
type samplePier struct{}
|
|
|
|
type samplePostInput struct {
|
|
ID uint `path:"id"`
|
|
Query string `query:"q"`
|
|
Title string `json:"title" jsonschema:"the post title"`
|
|
}
|
|
|
|
func (samplePier) MCP() MCP {
|
|
ro := true
|
|
return MCP{
|
|
Tools: []MCPTool{{
|
|
Name: "show_post", Title: "Show a post", Description: "Returns one post.",
|
|
Guard: "backend", Method: "GET", Path: "/_blog/api/v1/posts/{id}",
|
|
Input: samplePostInput{}, Permissions: []string{"acme.blog.access_posts"}, ReadOnly: &ro,
|
|
}},
|
|
Prompts: []MCPPrompt{{
|
|
Name: "draft_post", Title: "Draft a post", Guard: "backend",
|
|
Arguments: []MCPPromptArgument{{Name: "topic", Required: true}},
|
|
Render: func(_ context.Context, args map[string]string) ([]MCPPromptMessage, error) {
|
|
return []MCPPromptMessage{{Role: "user", Text: "Draft a post about " + args["topic"]}}, nil
|
|
},
|
|
}},
|
|
}
|
|
}
|
|
|
|
func (samplePier) FrontendPermissions() []FrontendPermission {
|
|
return []FrontendPermission{{Code: "acme.blog.comment", Label: "acme::lang.comment", Tab: "acme::lang.tab"}}
|
|
}
|
|
|
|
func (samplePier) AccountContributions() []AccountContribution {
|
|
return []AccountContribution{{
|
|
Name: "site_link", Label: "acme::lang.site_link", Description: "acme::lang.site_link_hint",
|
|
Status: func(_ context.Context, adminID uint) (string, error) { return "linked", nil },
|
|
Actions: []AccountAction{{
|
|
Name: "link", Label: "acme::lang.link", Confirm: "acme::lang.link_confirm",
|
|
Applies: func(_ context.Context, adminID uint) (bool, error) { return adminID != 0, nil },
|
|
Run: func(_ context.Context, in AccountActionInput) (AccountActionResult, error) {
|
|
return AccountActionResult{Message: "acme::lang.linked", URL: "https://blog.example.com/link"}, nil
|
|
},
|
|
}},
|
|
}}
|
|
}
|
|
|
|
func (samplePier) AdminActionExtensions() []AdminActionExtension {
|
|
return []AdminActionExtension{{
|
|
Controller: "summercms.cabana.users",
|
|
RecordActions: []AdminRecordAction{{
|
|
Name: "link_site_account", Label: "acme::lang.link_site_account",
|
|
Prompt: &AdminActionPrompt{Label: "acme::lang.site_login", MaxLength: 64},
|
|
Run: func(_ context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) {
|
|
return AdminRecordActionResult{Message: "linked " + in.Value}, nil
|
|
},
|
|
}},
|
|
}}
|
|
}
|
|
|
|
var (
|
|
_ HasMCP = samplePier{}
|
|
_ HasFrontendPermissions = samplePier{}
|
|
_ HasAccountContributions = samplePier{}
|
|
_ HasAdminActionExtensions = samplePier{}
|
|
)
|
|
|
|
// sampleGuard is a guard provider with a fixed name.
|
|
type sampleGuard struct{ name string }
|
|
|
|
func (g sampleGuard) MCPGuardName() string { return g.name }
|
|
func (sampleGuard) MCPTokenGuarded(middleware []string) bool {
|
|
return strings.Contains(strings.Join(middleware, " "), "api_token")
|
|
}
|
|
func (sampleGuard) MCPRequirements(middleware []string) (MCPRequirements, error) {
|
|
return MCPRequirements{Scopes: []string{"read"}}, nil
|
|
}
|
|
func (sampleGuard) MCPAllows(context.Context, MCPSubject, MCPRequirements, []string) (bool, error) {
|
|
return true, nil
|
|
}
|
|
func (sampleGuard) MCPDispatch(context.Context, MCPSubject, string) (MCPDispatch, error) {
|
|
return MCPDispatch{Principal: "principal", Guards: []string{"api_token"}}, nil
|
|
}
|
|
|
|
// TestPhase197ContractsOnSamplePlugin: the MCP, frontend permission, account
|
|
// contribution and prompted record action contracts are discovered by type
|
|
// assertion on a plugin value and carry the documented fields.
|
|
func TestPhase197ContractsOnSamplePlugin(t *testing.T) {
|
|
var plugin any = samplePier{}
|
|
|
|
mcp, ok := plugin.(HasMCP)
|
|
if !ok {
|
|
t.Fatal("HasMCP is not discovered")
|
|
}
|
|
decl := mcp.MCP()
|
|
if len(decl.Tools) != 1 || len(decl.Prompts) != 1 {
|
|
t.Fatalf("declaration = %+v", decl)
|
|
}
|
|
tool := decl.Tools[0]
|
|
if tool.Name != "show_post" || tool.Guard != "backend" || tool.Method != "GET" || tool.Path != "/_blog/api/v1/posts/{id}" || tool.ReadOnly == nil || !*tool.ReadOnly || tool.Destructive != nil {
|
|
t.Fatalf("tool = %+v", tool)
|
|
}
|
|
if _, isStruct := tool.Input.(samplePostInput); !isStruct || tool.Output != nil {
|
|
t.Fatalf("tool input/output = %T %T", tool.Input, tool.Output)
|
|
}
|
|
prompt := decl.Prompts[0]
|
|
msgs, err := prompt.Render(t.Context(), map[string]string{"topic": "beaches"})
|
|
if err != nil || len(msgs) != 1 || msgs[0].Role != "user" || !strings.HasSuffix(msgs[0].Text, "beaches") || !prompt.Arguments[0].Required {
|
|
t.Fatalf("prompt = %+v msgs=%v err=%v", prompt, msgs, err)
|
|
}
|
|
|
|
fp, ok := plugin.(HasFrontendPermissions)
|
|
if !ok || len(fp.FrontendPermissions()) != 1 || fp.FrontendPermissions()[0].Code != "acme.blog.comment" {
|
|
t.Fatal("HasFrontendPermissions is not discovered")
|
|
}
|
|
|
|
ac, ok := plugin.(HasAccountContributions)
|
|
if !ok || len(ac.AccountContributions()) != 1 {
|
|
t.Fatal("HasAccountContributions is not discovered")
|
|
}
|
|
section := ac.AccountContributions()[0]
|
|
status, err := section.Status(t.Context(), 7)
|
|
if err != nil || status != "linked" || section.Name != "site_link" || len(section.Actions) != 1 {
|
|
t.Fatalf("section = %+v status=%q err=%v", section, status, err)
|
|
}
|
|
action := section.Actions[0]
|
|
if applies, err := action.Applies(t.Context(), 0); err != nil || applies {
|
|
t.Fatalf("Applies(0) = %v err=%v", applies, err)
|
|
}
|
|
res, err := action.Run(t.Context(), AccountActionInput{AdminID: 7})
|
|
if err != nil || res.URL == "" || res.Message != "acme::lang.linked" {
|
|
t.Fatalf("account action result = %+v err=%v", res, err)
|
|
}
|
|
// The input of an account action is the signed-in admin and nothing else.
|
|
if n := reflect.TypeOf(AccountActionInput{}).NumField(); n != 1 {
|
|
t.Fatalf("AccountActionInput has %d fields, want only AdminID", n)
|
|
}
|
|
|
|
ext := plugin.(HasAdminActionExtensions).AdminActionExtensions()[0].RecordActions[0]
|
|
if ext.Prompt == nil || ext.Prompt.Label == "" || ext.Prompt.MaxLength != 64 {
|
|
t.Fatalf("prompted action = %+v", ext)
|
|
}
|
|
out, err := ext.Run(t.Context(), AdminRecordActionInput{RecordID: 3, Value: "editor"})
|
|
if err != nil || out.Message != "linked editor" {
|
|
t.Fatalf("prompted run = %+v err=%v", out, err)
|
|
}
|
|
if len(MCPSubjectChanged{Guard: "backend", UserID: 7, Kind: MCPSubjectLink}.Kind) == 0 || MCPSubjectPermissions == MCPSubjectRevoked || MCPSubjectDeleted == "" {
|
|
t.Fatal("subject change kinds")
|
|
}
|
|
if grant := (MCPGrant{UserID: 1, Scopes: []string{"read"}}); grant.ExpiresAt.IsZero() != true || !errors.Is(fmt.Errorf("wrap: %w", ErrMCPTokenInvalid), ErrMCPTokenInvalid) {
|
|
t.Fatal("grant and sentinel")
|
|
}
|
|
var _ LinkedBackendUser = LinkedBackendUser{ID: 1, Login: "admin", DisplayName: "Admin"}
|
|
var _ MCPSubject = MCPSubject{Guard: "user", UserID: 1, Scopes: []string{"read"}, ClientID: "cli", TokenID: 0}
|
|
}
|
|
|
|
// TestMCPGuardsRegistry: register, duplicate and empty names refused with a
|
|
// pact error naming the guard, lookup, and sorted names.
|
|
func TestMCPGuardsRegistry(t *testing.T) {
|
|
reg := NewMCPGuards()
|
|
for _, name := range []string{"user", "backend"} {
|
|
if err := reg.Register(sampleGuard{name: name}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
err := reg.Register(sampleGuard{name: "user"})
|
|
if err == nil || !strings.Contains(err.Error(), `pact: mcp guard "user"`) {
|
|
t.Fatalf("duplicate: %v", err)
|
|
}
|
|
if err := reg.Register(sampleGuard{}); err == nil || !strings.HasPrefix(err.Error(), "pact: ") {
|
|
t.Fatalf("empty name: %v", err)
|
|
}
|
|
if err := reg.Register(nil); err == nil || !strings.HasPrefix(err.Error(), "pact: ") {
|
|
t.Fatalf("nil provider: %v", err)
|
|
}
|
|
if got := reg.Names(); strings.Join(got, ",") != "backend,user" {
|
|
t.Fatalf("Names = %v", got)
|
|
}
|
|
g, ok := reg.Lookup("backend")
|
|
if !ok || g.MCPGuardName() != "backend" || !g.MCPTokenGuarded([]string{"user.api_token"}) {
|
|
t.Fatalf("Lookup(backend) = %v %v", g, ok)
|
|
}
|
|
if _, ok := reg.Lookup("other"); ok {
|
|
t.Fatal("Lookup found an unregistered guard")
|
|
}
|
|
d, err := g.MCPDispatch(t.Context(), MCPSubject{Guard: "backend", UserID: 1}, "GET")
|
|
if err != nil || d.Principal != "principal" || len(d.Guards) != 1 {
|
|
t.Fatalf("dispatch = %+v err=%v", d, err)
|
|
}
|
|
var nilReg *MCPGuards
|
|
if _, ok := nilReg.Lookup("user"); ok || nilReg.Names() != nil || nilReg.Register(sampleGuard{name: "x"}) == nil {
|
|
t.Fatal("nil registry is not inert")
|
|
}
|
|
}
|
|
|
|
// TestPactStaysSDKFree: the package's dependency list names neither the MCP
|
|
// SDK, the JSON schema library nor bouncer, so plugins compile without them.
|
|
func TestPactStaysSDKFree(t *testing.T) {
|
|
goTool, err := exec.LookPath("go")
|
|
if err != nil {
|
|
t.Skip("go tool not on PATH")
|
|
}
|
|
out, err := exec.Command(goTool, "list", "-deps", ".").Output()
|
|
if err != nil {
|
|
t.Fatalf("go list -deps: %v", err)
|
|
}
|
|
for _, dep := range strings.Split(string(out), "\n") {
|
|
for _, banned := range []string{"modelcontextprotocol", "jsonschema-go", "modules/bouncer"} {
|
|
if strings.Contains(dep, banned) {
|
|
t.Fatalf("pact depends on %s", dep)
|
|
}
|
|
}
|
|
}
|
|
}
|