Files
summercms/modules/pier/guard_rules_test.go
Jakub Zych f14eae96dd test(19.7-05): framework unit tests for every validation row and the gate's admin stage
- 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
2026-10-23 14:04:16 +02:00

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