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