- pier: TestCatalog, TestDispatch, TestGuardRules, TestSessions (Redis
across two instances and the memory store), TestFanOut; the store clock
and stateless-server seams in export_test.go
- surf: TestStreamingRoute (throttle, CORS profile and access-log pattern on
a stream route) and TestPierMount in both builds; the streaming smoke
restores the mount hook it replaces
- wristband TestMCPScopes; cabana TestAccountContributions (Postgres part:
transaction and unchanged My account payloads), TestRecordActionPrompt,
TestScopeLimitForMethods; TestRecordActionPromptRun pins the empty Value
of an action without a prompt
- vitest: 403 and failed-load cases for My account contributions, the
cancelled prompt
- gate: every name required (tagged runs included), account.go unchanged
since 51d088a, admin stage live
139 lines
6.2 KiB
Go
139 lines
6.2 KiB
Go
package pier_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
)
|
|
|
|
// permGuard parses acme.perm:<code> middleware into required permission
|
|
// codes and allows a subject only when its granted codes cover the route's
|
|
// codes and the tool's (the union, D-16).
|
|
func permGuard(granted map[uint][]string) *fakeGuard {
|
|
return &fakeGuard{
|
|
parse: func(mw []string) (pact.MCPRequirements, error) {
|
|
var req pact.MCPRequirements
|
|
for _, m := range mw {
|
|
if code, ok := strings.CutPrefix(m, "acme.perm:"); ok {
|
|
if code == "" {
|
|
return pact.MCPRequirements{}, errors.New("acme.perm names no code")
|
|
}
|
|
req.Permissions = append(req.Permissions, code)
|
|
}
|
|
}
|
|
return req, nil
|
|
},
|
|
allow: func(s pact.MCPSubject, req pact.MCPRequirements, perms []string) bool {
|
|
for _, code := range append(slices.Clone(req.Permissions), perms...) {
|
|
if !slices.Contains(granted[s.UserID], code) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
},
|
|
}
|
|
}
|
|
|
|
// methodDenyGuard is a method-aware provider that refuses one method, as an
|
|
// admin token whose scopes allow GET only does.
|
|
type methodDenyGuard struct {
|
|
methodGuard
|
|
deny string
|
|
}
|
|
|
|
func (g methodDenyGuard) MCPAllowsMethod(ctx context.Context, s pact.MCPSubject, req pact.MCPRequirements, perms []string, method string) (bool, error) {
|
|
ok, err := g.methodGuard.MCPAllowsMethod(ctx, s, req, perms, method)
|
|
return ok && method != g.deny, err
|
|
}
|
|
|
|
// TestGuardRules (D-15, D-16, D-22): the route's middleware requirements and
|
|
// the tool's Permissions are both handed to the provider (the union decides);
|
|
// a provider that rejects a route's middleware fails boot; a route the
|
|
// provider does not call token-guarded binds only with a declared Guard; a
|
|
// method-aware provider is asked with each tool's own method, so a GET-only
|
|
// scope hides write tools (the built-in backend provider does this with
|
|
// cabana.ScopeLimitFor, pinned by cabana's TestScopeLimitForMethods).
|
|
func TestGuardRules(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
rules := testRoute{method: http.MethodGet, path: "/rules/a", mw: []string{"acme.auth", "acme.perm:p.a"}, h: noContent}
|
|
|
|
t.Run("requirements union with tool permissions", func(t *testing.T) {
|
|
guard := permGuard(map[uint][]string{7: {"p.a", "p.b"}, 8: {"p.a"}})
|
|
plugin := routePlugin{id: "test.rules", factory: "acme.perm", routes: []testRoute{rules},
|
|
tools: []pact.MCPTool{
|
|
{Name: "both", Method: "GET", Path: "/rules/a", Input: struct{}{}, Permissions: []string{"p.b"}},
|
|
}}
|
|
plain := routePlugin{id: "test.plain", guard: "plain", routes: []testRoute{{method: http.MethodGet, path: "/rules/plain", mw: []string{"acme.auth", "acme.perm:p.a"}, h: noContent}},
|
|
tools: []pact.MCPTool{{Name: "route_only", Guard: "acme", Method: "GET", Path: "/rules/plain", Input: struct{}{}}}}
|
|
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), plugin, plain)
|
|
if got := listNames(t, ctx, sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})); !slices.Equal(got, []string{"plain_route_only", "rules_both"}) {
|
|
t.Fatalf("user 7 (p.a, p.b) lists %v", got)
|
|
}
|
|
if got := listNames(t, ctx, sdkClient(t, ctx, in.url(), "oas_at_other", clientOpts{})); !slices.Equal(got, []string{"plain_route_only"}) {
|
|
t.Fatalf("user 8 (p.a) lists %v", got)
|
|
}
|
|
var saw bool
|
|
for _, c := range guard.recorded() {
|
|
if slices.Equal(c.req.Permissions, []string{"p.a"}) && slices.Equal(c.perms, []string{"p.b"}) {
|
|
saw = true
|
|
}
|
|
}
|
|
if !saw {
|
|
t.Fatalf("the provider never saw the route's p.a with the tool's p.b: %+v", guard.recorded())
|
|
}
|
|
})
|
|
|
|
t.Run("rejected middleware fails boot", func(t *testing.T) {
|
|
guard := permGuard(nil)
|
|
bad := routePlugin{id: "test.rules", factory: "acme.perm", routes: []testRoute{{method: http.MethodGet, path: "/rules/bad", mw: []string{"acme.auth", "acme.perm:"}, h: noContent}},
|
|
tools: []pact.MCPTool{{Name: "bad", Method: "GET", Path: "/rules/bad", Input: struct{}{}}}}
|
|
_, err := mountPier(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), []party.Plugin{bad})
|
|
if err == nil || !strings.Contains(err.Error(), "rejects the route's middleware") || !strings.Contains(err.Error(), "names no code") {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("not token-guarded needs a declared guard", func(t *testing.T) {
|
|
guard := &fakeGuard{notToken: true}
|
|
route := testRoute{method: http.MethodGet, path: "/rules/session", mw: []string{"acme.auth"}, h: noContent}
|
|
implicit := routePlugin{id: "test.rules", routes: []testRoute{route}, tools: []pact.MCPTool{{Name: "s", Method: "GET", Path: "/rules/session", Input: struct{}{}}}}
|
|
if _, err := mountPier(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), []party.Plugin{implicit}); err == nil || !strings.Contains(err.Error(), "not token-guarded") {
|
|
t.Fatalf("implicit binding: err = %v", err)
|
|
}
|
|
declared := implicit
|
|
declared.tools = []pact.MCPTool{{Name: "s", Guard: "acme", Method: "GET", Path: "/rules/session", Input: struct{}{}}}
|
|
if _, err := mountPier(t, newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{notToken: true}}}), []party.Plugin{declared}); err != nil {
|
|
t.Fatalf("declared guard: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("method-aware provider sees each tool's method", func(t *testing.T) {
|
|
guard := methodDenyGuard{methodGuard: methodGuard{fakeGuard: &fakeGuard{}}, deny: http.MethodPost}
|
|
plugin := routePlugin{id: "test.rules",
|
|
routes: []testRoute{guarded(http.MethodGet, "/rules/items"), guarded(http.MethodPost, "/rules/items")},
|
|
tools: []pact.MCPTool{
|
|
{Name: "list", Method: "GET", Path: "/rules/items", Input: struct{}{}},
|
|
{Name: "create", Method: "POST", Path: "/rules/items", Input: struct{}{}},
|
|
}}
|
|
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), plugin)
|
|
if got := listNames(t, ctx, sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})); !slices.Equal(got, []string{"rules_list"}) {
|
|
t.Fatalf("lists %v, want the GET tool only", got)
|
|
}
|
|
methods := map[string]bool{}
|
|
for _, c := range guard.recorded() {
|
|
methods[c.method] = true
|
|
}
|
|
if !methods[http.MethodGet] || !methods[http.MethodPost] || methods[""] {
|
|
t.Fatalf("provider asked with methods %v", methods)
|
|
}
|
|
})
|
|
}
|