#
tokens: 44258/50000 3/144 files (page 5/5)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 5 of 5. Use http://codebase.md/manusa/kubernetes-mcp-server?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .github
│   ├── dependabot.yml
│   └── workflows
│       ├── build.yaml
│       ├── release-image.yml
│       └── release.yaml
├── .gitignore
├── AGENTS.md
├── build
│   ├── keycloak.mk
│   ├── kind.mk
│   └── tools.mk
├── CLAUDE.md
├── cmd
│   └── kubernetes-mcp-server
│       ├── main_test.go
│       └── main.go
├── dev
│   └── config
│       ├── cert-manager
│       │   └── selfsigned-issuer.yaml
│       ├── ingress
│       │   └── nginx-ingress.yaml
│       ├── keycloak
│       │   ├── client-scopes
│       │   │   ├── groups.json
│       │   │   ├── mcp-openshift.json
│       │   │   └── mcp-server.json
│       │   ├── clients
│       │   │   ├── mcp-client.json
│       │   │   ├── mcp-server-update.json
│       │   │   ├── mcp-server.json
│       │   │   └── openshift.json
│       │   ├── deployment.yaml
│       │   ├── ingress.yaml
│       │   ├── mappers
│       │   │   ├── groups-membership.json
│       │   │   ├── mcp-server-audience.json
│       │   │   ├── openshift-audience.json
│       │   │   └── username.json
│       │   ├── rbac.yaml
│       │   ├── realm
│       │   │   ├── realm-create.json
│       │   │   └── realm-events-config.json
│       │   └── users
│       │       └── mcp.json
│       └── kind
│           └── cluster.yaml
├── Dockerfile
├── docs
│   └── images
│       ├── kubernetes-mcp-server-github-copilot.jpg
│       └── vibe-coding.jpg
├── go.mod
├── go.sum
├── hack
│   └── generate-placeholder-ca.sh
├── internal
│   ├── test
│   │   ├── env.go
│   │   ├── kubernetes.go
│   │   ├── mcp.go
│   │   ├── mock_server.go
│   │   └── test.go
│   └── tools
│       └── update-readme
│           └── main.go
├── LICENSE
├── Makefile
├── npm
│   ├── kubernetes-mcp-server
│   │   ├── bin
│   │   │   └── index.js
│   │   └── package.json
│   ├── kubernetes-mcp-server-darwin-amd64
│   │   └── package.json
│   ├── kubernetes-mcp-server-darwin-arm64
│   │   └── package.json
│   ├── kubernetes-mcp-server-linux-amd64
│   │   └── package.json
│   ├── kubernetes-mcp-server-linux-arm64
│   │   └── package.json
│   ├── kubernetes-mcp-server-windows-amd64
│   │   └── package.json
│   └── kubernetes-mcp-server-windows-arm64
│       └── package.json
├── pkg
│   ├── api
│   │   ├── toolsets_test.go
│   │   └── toolsets.go
│   ├── config
│   │   ├── config_default_overrides.go
│   │   ├── config_default.go
│   │   ├── config_test.go
│   │   ├── config.go
│   │   ├── provider_config_test.go
│   │   └── provider_config.go
│   ├── helm
│   │   └── helm.go
│   ├── http
│   │   ├── authorization_test.go
│   │   ├── authorization.go
│   │   ├── http_test.go
│   │   ├── http.go
│   │   ├── middleware.go
│   │   ├── sts_test.go
│   │   ├── sts.go
│   │   └── wellknown.go
│   ├── kubernetes
│   │   ├── accesscontrol_clientset.go
│   │   ├── accesscontrol_restmapper.go
│   │   ├── accesscontrol.go
│   │   ├── common_test.go
│   │   ├── configuration.go
│   │   ├── events.go
│   │   ├── impersonate_roundtripper.go
│   │   ├── kubernetes_derived_test.go
│   │   ├── kubernetes.go
│   │   ├── manager_test.go
│   │   ├── manager.go
│   │   ├── namespaces.go
│   │   ├── nodes.go
│   │   ├── openshift.go
│   │   ├── pods.go
│   │   ├── provider_kubeconfig_test.go
│   │   ├── provider_kubeconfig.go
│   │   ├── provider_registry_test.go
│   │   ├── provider_registry.go
│   │   ├── provider_single_test.go
│   │   ├── provider_single.go
│   │   ├── provider_test.go
│   │   ├── provider.go
│   │   ├── resources.go
│   │   └── token.go
│   ├── kubernetes-mcp-server
│   │   └── cmd
│   │       ├── root_test.go
│   │       ├── root.go
│   │       └── testdata
│   │           ├── empty-config.toml
│   │           └── valid-config.toml
│   ├── mcp
│   │   ├── common_test.go
│   │   ├── configuration_test.go
│   │   ├── events_test.go
│   │   ├── helm_test.go
│   │   ├── m3labs.go
│   │   ├── mcp_middleware_test.go
│   │   ├── mcp_test.go
│   │   ├── mcp_tools_test.go
│   │   ├── mcp.go
│   │   ├── modules.go
│   │   ├── namespaces_test.go
│   │   ├── nodes_test.go
│   │   ├── pods_exec_test.go
│   │   ├── pods_test.go
│   │   ├── pods_top_test.go
│   │   ├── resources_test.go
│   │   ├── testdata
│   │   │   ├── helm-chart-no-op
│   │   │   │   └── Chart.yaml
│   │   │   ├── helm-chart-secret
│   │   │   │   ├── Chart.yaml
│   │   │   │   └── templates
│   │   │   │       └── secret.yaml
│   │   │   ├── toolsets-config-tools.json
│   │   │   ├── toolsets-core-tools.json
│   │   │   ├── toolsets-full-tools-multicluster-enum.json
│   │   │   ├── toolsets-full-tools-multicluster.json
│   │   │   ├── toolsets-full-tools-openshift.json
│   │   │   ├── toolsets-full-tools.json
│   │   │   └── toolsets-helm-tools.json
│   │   ├── tool_filter_test.go
│   │   ├── tool_filter.go
│   │   ├── tool_mutator_test.go
│   │   ├── tool_mutator.go
│   │   └── toolsets_test.go
│   ├── output
│   │   ├── output_test.go
│   │   └── output.go
│   ├── toolsets
│   │   ├── config
│   │   │   ├── configuration.go
│   │   │   └── toolset.go
│   │   ├── core
│   │   │   ├── events.go
│   │   │   ├── namespaces.go
│   │   │   ├── nodes.go
│   │   │   ├── pods.go
│   │   │   ├── resources.go
│   │   │   └── toolset.go
│   │   ├── helm
│   │   │   ├── helm.go
│   │   │   └── toolset.go
│   │   ├── toolsets_test.go
│   │   └── toolsets.go
│   └── version
│       └── version.go
├── python
│   ├── kubernetes_mcp_server
│   │   ├── __init__.py
│   │   ├── __main__.py
│   │   └── kubernetes_mcp_server.py
│   ├── pyproject.toml
│   └── README.md
├── README.md
└── smithery.yaml
```

# Files

--------------------------------------------------------------------------------
/pkg/mcp/resources_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"regexp"
  5 | 	"strings"
  6 | 	"testing"
  7 | 
  8 | 	"github.com/mark3labs/mcp-go/mcp"
  9 | 	corev1 "k8s.io/api/core/v1"
 10 | 	v1 "k8s.io/api/rbac/v1"
 11 | 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 12 | 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 13 | 	"k8s.io/apimachinery/pkg/runtime/schema"
 14 | 	"k8s.io/client-go/dynamic"
 15 | 	"sigs.k8s.io/yaml"
 16 | 
 17 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
 18 | 	"github.com/containers/kubernetes-mcp-server/pkg/config"
 19 | 	"github.com/containers/kubernetes-mcp-server/pkg/output"
 20 | )
 21 | 
 22 | func TestResourcesList(t *testing.T) {
 23 | 	testCase(t, func(c *mcpContext) {
 24 | 		c.withEnvTest()
 25 | 		t.Run("resources_list with missing apiVersion returns error", func(t *testing.T) {
 26 | 			toolResult, _ := c.callTool("resources_list", map[string]interface{}{})
 27 | 			if !toolResult.IsError {
 28 | 				t.Fatalf("call tool should fail")
 29 | 			}
 30 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to list resources, missing argument apiVersion" {
 31 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 32 | 			}
 33 | 		})
 34 | 		t.Run("resources_list with missing kind returns error", func(t *testing.T) {
 35 | 			toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1"})
 36 | 			if !toolResult.IsError {
 37 | 				t.Fatalf("call tool should fail")
 38 | 			}
 39 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to list resources, missing argument kind" {
 40 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 41 | 			}
 42 | 		})
 43 | 		t.Run("resources_list with invalid apiVersion returns error", func(t *testing.T) {
 44 | 			toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod"})
 45 | 			if !toolResult.IsError {
 46 | 				t.Fatalf("call tool should fail")
 47 | 			}
 48 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to list resources, invalid argument apiVersion" {
 49 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 50 | 			}
 51 | 		})
 52 | 		t.Run("resources_list with nonexistent apiVersion returns error", func(t *testing.T) {
 53 | 			toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom"})
 54 | 			if !toolResult.IsError {
 55 | 				t.Fatalf("call tool should fail")
 56 | 			}
 57 | 			if toolResult.Content[0].(mcp.TextContent).Text != `failed to list resources: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` {
 58 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 59 | 			}
 60 | 		})
 61 | 		namespaces, err := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"})
 62 | 		t.Run("resources_list returns namespaces", func(t *testing.T) {
 63 | 			if err != nil {
 64 | 				t.Fatalf("call tool failed %v", err)
 65 | 				return
 66 | 			}
 67 | 			if namespaces.IsError {
 68 | 				t.Fatalf("call tool failed")
 69 | 				return
 70 | 			}
 71 | 		})
 72 | 		var decodedNamespaces []unstructured.Unstructured
 73 | 		err = yaml.Unmarshal([]byte(namespaces.Content[0].(mcp.TextContent).Text), &decodedNamespaces)
 74 | 		t.Run("resources_list has yaml content", func(t *testing.T) {
 75 | 			if err != nil {
 76 | 				t.Fatalf("invalid tool result content %v", err)
 77 | 			}
 78 | 		})
 79 | 		t.Run("resources_list returns more than 2 items", func(t *testing.T) {
 80 | 			if len(decodedNamespaces) < 3 {
 81 | 				t.Fatalf("invalid namespace count, expected >2, got %v", len(decodedNamespaces))
 82 | 			}
 83 | 		})
 84 | 
 85 | 		// Test label selector functionality
 86 | 		t.Run("resources_list with label selector returns filtered pods", func(t *testing.T) {
 87 | 
 88 | 			// List pods with label selector
 89 | 			result, err := c.callTool("resources_list", map[string]interface{}{
 90 | 				"apiVersion":    "v1",
 91 | 				"kind":          "Pod",
 92 | 				"namespace":     "default",
 93 | 				"labelSelector": "app=nginx",
 94 | 			})
 95 | 
 96 | 			if err != nil {
 97 | 				t.Fatalf("call tool failed %v", err)
 98 | 				return
 99 | 			}
100 | 			if result.IsError {
101 | 				t.Fatalf("call tool failed")
102 | 				return
103 | 			}
104 | 
105 | 			var decodedPods []unstructured.Unstructured
106 | 			err = yaml.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &decodedPods)
107 | 			if err != nil {
108 | 				t.Fatalf("invalid tool result content %v", err)
109 | 				return
110 | 			}
111 | 
112 | 			// Verify only the pod with matching label is returned
113 | 			if len(decodedPods) != 1 {
114 | 				t.Fatalf("expected 1 pod, got %d", len(decodedPods))
115 | 				return
116 | 			}
117 | 
118 | 			if decodedPods[0].GetName() != "a-pod-in-default" {
119 | 				t.Fatalf("expected pod-with-label, got %s", decodedPods[0].GetName())
120 | 				return
121 | 			}
122 | 
123 | 			// Test that multiple label selectors work
124 | 			result, err = c.callTool("resources_list", map[string]interface{}{
125 | 				"apiVersion":    "v1",
126 | 				"kind":          "Pod",
127 | 				"namespace":     "default",
128 | 				"labelSelector": "test-label=test-value,another=value",
129 | 			})
130 | 
131 | 			if err != nil {
132 | 				t.Fatalf("call tool failed %v", err)
133 | 				return
134 | 			}
135 | 			if result.IsError {
136 | 				t.Fatalf("call tool failed")
137 | 				return
138 | 			}
139 | 
140 | 			err = yaml.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &decodedPods)
141 | 			if err != nil {
142 | 				t.Fatalf("invalid tool result content %v", err)
143 | 				return
144 | 			}
145 | 
146 | 			// Verify no pods match multiple label selector
147 | 			if len(decodedPods) != 0 {
148 | 				t.Fatalf("expected 0 pods, got %d", len(decodedPods))
149 | 				return
150 | 			}
151 | 		})
152 | 	})
153 | }
154 | 
155 | func TestResourcesListDenied(t *testing.T) {
156 | 	deniedResourcesServer := test.Must(config.ReadToml([]byte(`
157 | 		denied_resources = [
158 | 			{ version = "v1", kind = "Secret" },
159 | 			{ group = "rbac.authorization.k8s.io", version = "v1" }
160 | 		]
161 | 	`)))
162 | 	testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
163 | 		c.withEnvTest()
164 | 		deniedByKind, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Secret"})
165 | 		t.Run("resources_list (denied by kind) has error", func(t *testing.T) {
166 | 			if !deniedByKind.IsError {
167 | 				t.Fatalf("call tool should fail")
168 | 			}
169 | 		})
170 | 		t.Run("resources_list (denied by kind) describes denial", func(t *testing.T) {
171 | 			expectedMessage := "failed to list resources: resource not allowed: /v1, Kind=Secret"
172 | 			if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
173 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
174 | 			}
175 | 		})
176 | 		deniedByGroup, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role"})
177 | 		t.Run("resources_list (denied by group) has error", func(t *testing.T) {
178 | 			if !deniedByGroup.IsError {
179 | 				t.Fatalf("call tool should fail")
180 | 			}
181 | 		})
182 | 		t.Run("resources_list (denied by group) describes denial", func(t *testing.T) {
183 | 			expectedMessage := "failed to list resources: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
184 | 			if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
185 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
186 | 			}
187 | 		})
188 | 		allowedResource, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"})
189 | 		t.Run("resources_list (not denied) returns list", func(t *testing.T) {
190 | 			if allowedResource.IsError {
191 | 				t.Fatalf("call tool should not fail")
192 | 			}
193 | 		})
194 | 	})
195 | }
196 | 
197 | func TestResourcesListAsTable(t *testing.T) {
198 | 	testCaseWithContext(t, &mcpContext{listOutput: output.Table, before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) {
199 | 		c.withEnvTest()
200 | 		kc := c.newKubernetesClient()
201 | 		_, _ = kc.CoreV1().ConfigMaps("default").Create(t.Context(), &corev1.ConfigMap{
202 | 			ObjectMeta: metav1.ObjectMeta{Name: "a-configmap-to-list-as-table", Labels: map[string]string{"resource": "config-map"}},
203 | 			Data:       map[string]string{"key": "value"},
204 | 		}, metav1.CreateOptions{})
205 | 		configMapList, err := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap"})
206 | 		t.Run("resources_list returns ConfigMap list", func(t *testing.T) {
207 | 			if err != nil {
208 | 				t.Fatalf("call tool failed %v", err)
209 | 			}
210 | 			if configMapList.IsError {
211 | 				t.Fatalf("call tool failed")
212 | 			}
213 | 		})
214 | 		outConfigMapList := configMapList.Content[0].(mcp.TextContent).Text
215 | 		t.Run("resources_list returns column headers for ConfigMap list", func(t *testing.T) {
216 | 			expectedHeaders := "NAMESPACE\\s+APIVERSION\\s+KIND\\s+NAME\\s+DATA\\s+AGE\\s+LABELS"
217 | 			if m, e := regexp.MatchString(expectedHeaders, outConfigMapList); !m || e != nil {
218 | 				t.Fatalf("Expected headers '%s' not found in output:\n%s", expectedHeaders, outConfigMapList)
219 | 			}
220 | 		})
221 | 		t.Run("resources_list returns formatted row for a-configmap-to-list-as-table", func(t *testing.T) {
222 | 			expectedRow := "(?<namespace>default)\\s+" +
223 | 				"(?<apiVersion>v1)\\s+" +
224 | 				"(?<kind>ConfigMap)\\s+" +
225 | 				"(?<name>a-configmap-to-list-as-table)\\s+" +
226 | 				"(?<data>1)\\s+" +
227 | 				"(?<age>(\\d+m)?(\\d+s)?)\\s+" +
228 | 				"(?<labels>resource=config-map)"
229 | 			if m, e := regexp.MatchString(expectedRow, outConfigMapList); !m || e != nil {
230 | 				t.Fatalf("Expected row '%s' not found in output:\n%s", expectedRow, outConfigMapList)
231 | 			}
232 | 		})
233 | 		// Custom Resource List
234 | 		_, _ = dynamic.NewForConfigOrDie(envTestRestConfig).
235 | 			Resource(schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}).
236 | 			Namespace("default").
237 | 			Create(c.ctx, &unstructured.Unstructured{Object: map[string]interface{}{
238 | 				"apiVersion": "route.openshift.io/v1",
239 | 				"kind":       "Route",
240 | 				"metadata": map[string]interface{}{
241 | 					"name": "an-openshift-route-to-list-as-table",
242 | 				},
243 | 			}}, metav1.CreateOptions{})
244 | 		routeList, err := c.callTool("resources_list", map[string]interface{}{"apiVersion": "route.openshift.io/v1", "kind": "Route"})
245 | 		t.Run("resources_list returns Route list", func(t *testing.T) {
246 | 			if err != nil {
247 | 				t.Fatalf("call tool failed %v", err)
248 | 			}
249 | 			if routeList.IsError {
250 | 				t.Fatalf("call tool failed")
251 | 			}
252 | 		})
253 | 		outRouteList := routeList.Content[0].(mcp.TextContent).Text
254 | 		t.Run("resources_list returns column headers for Route list", func(t *testing.T) {
255 | 			expectedHeaders := "NAMESPACE\\s+APIVERSION\\s+KIND\\s+NAME\\s+AGE\\s+LABELS"
256 | 			if m, e := regexp.MatchString(expectedHeaders, outRouteList); !m || e != nil {
257 | 				t.Fatalf("Expected headers '%s' not found in output:\n%s", expectedHeaders, outRouteList)
258 | 			}
259 | 		})
260 | 		t.Run("resources_list returns formatted row for an-openshift-route-to-list-as-table", func(t *testing.T) {
261 | 			expectedRow := "(?<namespace>default)\\s+" +
262 | 				"(?<apiVersion>route.openshift.io/v1)\\s+" +
263 | 				"(?<kind>Route)\\s+" +
264 | 				"(?<name>an-openshift-route-to-list-as-table)\\s+" +
265 | 				"(?<age>(\\d+m)?(\\d+s)?)\\s+" +
266 | 				"(?<labels><none>)"
267 | 			if m, e := regexp.MatchString(expectedRow, outRouteList); !m || e != nil {
268 | 				t.Fatalf("Expected row '%s' not found in output:\n%s", expectedRow, outRouteList)
269 | 			}
270 | 		})
271 | 	})
272 | }
273 | 
274 | func TestResourcesGet(t *testing.T) {
275 | 	testCase(t, func(c *mcpContext) {
276 | 		c.withEnvTest()
277 | 		t.Run("resources_get with missing apiVersion returns error", func(t *testing.T) {
278 | 			toolResult, _ := c.callTool("resources_get", map[string]interface{}{})
279 | 			if !toolResult.IsError {
280 | 				t.Fatalf("call tool should fail")
281 | 				return
282 | 			}
283 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, missing argument apiVersion" {
284 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
285 | 				return
286 | 			}
287 | 		})
288 | 		t.Run("resources_get with missing kind returns error", func(t *testing.T) {
289 | 			toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1"})
290 | 			if !toolResult.IsError {
291 | 				t.Fatalf("call tool should fail")
292 | 				return
293 | 			}
294 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, missing argument kind" {
295 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
296 | 				return
297 | 			}
298 | 		})
299 | 		t.Run("resources_get with invalid apiVersion returns error", func(t *testing.T) {
300 | 			toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod", "name": "a-pod"})
301 | 			if !toolResult.IsError {
302 | 				t.Fatalf("call tool should fail")
303 | 				return
304 | 			}
305 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, invalid argument apiVersion" {
306 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
307 | 				return
308 | 			}
309 | 		})
310 | 		t.Run("resources_get with nonexistent apiVersion returns error", func(t *testing.T) {
311 | 			toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom", "name": "a-custom"})
312 | 			if !toolResult.IsError {
313 | 				t.Fatalf("call tool should fail")
314 | 				return
315 | 			}
316 | 			if toolResult.Content[0].(mcp.TextContent).Text != `failed to get resource: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` {
317 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
318 | 				return
319 | 			}
320 | 		})
321 | 		t.Run("resources_get with missing name returns error", func(t *testing.T) {
322 | 			toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"})
323 | 			if !toolResult.IsError {
324 | 				t.Fatalf("call tool should fail")
325 | 				return
326 | 			}
327 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, missing argument name" {
328 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
329 | 				return
330 | 			}
331 | 		})
332 | 		namespace, err := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "default"})
333 | 		t.Run("resources_get returns namespace", func(t *testing.T) {
334 | 			if err != nil {
335 | 				t.Fatalf("call tool failed %v", err)
336 | 				return
337 | 			}
338 | 			if namespace.IsError {
339 | 				t.Fatalf("call tool failed")
340 | 				return
341 | 			}
342 | 		})
343 | 		var decodedNamespace unstructured.Unstructured
344 | 		err = yaml.Unmarshal([]byte(namespace.Content[0].(mcp.TextContent).Text), &decodedNamespace)
345 | 		t.Run("resources_get has yaml content", func(t *testing.T) {
346 | 			if err != nil {
347 | 				t.Fatalf("invalid tool result content %v", err)
348 | 				return
349 | 			}
350 | 		})
351 | 		t.Run("resources_get returns default namespace", func(t *testing.T) {
352 | 			if decodedNamespace.GetName() != "default" {
353 | 				t.Fatalf("invalid namespace name, expected default, got %v", decodedNamespace.GetName())
354 | 				return
355 | 			}
356 | 		})
357 | 	})
358 | }
359 | 
360 | func TestResourcesGetDenied(t *testing.T) {
361 | 	deniedResourcesServer := test.Must(config.ReadToml([]byte(`
362 | 		denied_resources = [
363 | 			{ version = "v1", kind = "Secret" },
364 | 			{ group = "rbac.authorization.k8s.io", version = "v1" }
365 | 		]
366 | 	`)))
367 | 	testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
368 | 		c.withEnvTest()
369 | 		kc := c.newKubernetesClient()
370 | 		_, _ = kc.CoreV1().Secrets("default").Create(c.ctx, &corev1.Secret{
371 | 			ObjectMeta: metav1.ObjectMeta{Name: "denied-secret"},
372 | 		}, metav1.CreateOptions{})
373 | 		_, _ = kc.RbacV1().Roles("default").Create(c.ctx, &v1.Role{
374 | 			ObjectMeta: metav1.ObjectMeta{Name: "denied-role"},
375 | 		}, metav1.CreateOptions{})
376 | 		deniedByKind, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Secret", "namespace": "default", "name": "denied-secret"})
377 | 		t.Run("resources_get (denied by kind) has error", func(t *testing.T) {
378 | 			if !deniedByKind.IsError {
379 | 				t.Fatalf("call tool should fail")
380 | 			}
381 | 		})
382 | 		t.Run("resources_get (denied by kind) describes denial", func(t *testing.T) {
383 | 			expectedMessage := "failed to get resource: resource not allowed: /v1, Kind=Secret"
384 | 			if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
385 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
386 | 			}
387 | 		})
388 | 		deniedByGroup, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "namespace": "default", "name": "denied-role"})
389 | 		t.Run("resources_get (denied by group) has error", func(t *testing.T) {
390 | 			if !deniedByGroup.IsError {
391 | 				t.Fatalf("call tool should fail")
392 | 			}
393 | 		})
394 | 		t.Run("resources_get (denied by group) describes denial", func(t *testing.T) {
395 | 			expectedMessage := "failed to get resource: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
396 | 			if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
397 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
398 | 			}
399 | 		})
400 | 		allowedResource, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "default"})
401 | 		t.Run("resources_get (not denied) returns resource", func(t *testing.T) {
402 | 			if allowedResource.IsError {
403 | 				t.Fatalf("call tool should not fail")
404 | 			}
405 | 		})
406 | 	})
407 | }
408 | 
409 | func TestResourcesCreateOrUpdate(t *testing.T) {
410 | 	testCase(t, func(c *mcpContext) {
411 | 		c.withEnvTest()
412 | 		t.Run("resources_create_or_update with nil resource returns error", func(t *testing.T) {
413 | 			toolResult, _ := c.callTool("resources_create_or_update", map[string]interface{}{})
414 | 			if toolResult.IsError != true {
415 | 				t.Fatalf("call tool should fail")
416 | 				return
417 | 			}
418 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to create or update resources, missing argument resource" {
419 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
420 | 				return
421 | 			}
422 | 		})
423 | 		t.Run("resources_create_or_update with empty resource returns error", func(t *testing.T) {
424 | 			toolResult, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": ""})
425 | 			if toolResult.IsError != true {
426 | 				t.Fatalf("call tool should fail")
427 | 				return
428 | 			}
429 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to create or update resources, missing argument resource" {
430 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
431 | 				return
432 | 			}
433 | 		})
434 | 		client := c.newKubernetesClient()
435 | 		configMapYaml := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: a-cm-created-or-updated\n  namespace: default\n"
436 | 		resourcesCreateOrUpdateCm1, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapYaml})
437 | 		t.Run("resources_create_or_update with valid namespaced yaml resource returns success", func(t *testing.T) {
438 | 			if err != nil {
439 | 				t.Fatalf("call tool failed %v", err)
440 | 				return
441 | 			}
442 | 			if resourcesCreateOrUpdateCm1.IsError {
443 | 				t.Errorf("call tool failed")
444 | 				return
445 | 			}
446 | 		})
447 | 		var decodedCreateOrUpdateCm1 []unstructured.Unstructured
448 | 		err = yaml.Unmarshal([]byte(resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text), &decodedCreateOrUpdateCm1)
449 | 		t.Run("resources_create_or_update with valid namespaced yaml resource returns yaml content", func(t *testing.T) {
450 | 			if err != nil {
451 | 				t.Errorf("invalid tool result content %v", err)
452 | 				return
453 | 			}
454 | 			if !strings.HasPrefix(resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text, "# The following resources (YAML) have been created or updated successfully") {
455 | 				t.Errorf("Excpected success message, got %v", resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text)
456 | 				return
457 | 			}
458 | 			if len(decodedCreateOrUpdateCm1) != 1 {
459 | 				t.Errorf("invalid resource count, expected 1, got %v", len(decodedCreateOrUpdateCm1))
460 | 				return
461 | 			}
462 | 			if decodedCreateOrUpdateCm1[0].GetName() != "a-cm-created-or-updated" {
463 | 				t.Errorf("invalid resource name, expected a-cm-created-or-updated, got %v", decodedCreateOrUpdateCm1[0].GetName())
464 | 				return
465 | 			}
466 | 			if decodedCreateOrUpdateCm1[0].GetUID() == "" {
467 | 				t.Errorf("invalid uid, got %v", decodedCreateOrUpdateCm1[0].GetUID())
468 | 				return
469 | 			}
470 | 		})
471 | 		t.Run("resources_create_or_update with valid namespaced yaml resource creates ConfigMap", func(t *testing.T) {
472 | 			cm, _ := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-cm-created-or-updated", metav1.GetOptions{})
473 | 			if cm == nil {
474 | 				t.Fatalf("ConfigMap not found")
475 | 				return
476 | 			}
477 | 		})
478 | 		configMapJson := "{\"apiVersion\": \"v1\", \"kind\": \"ConfigMap\", \"metadata\": {\"name\": \"a-cm-created-or-updated-2\", \"namespace\": \"default\"}}"
479 | 		resourcesCreateOrUpdateCm2, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapJson})
480 | 		t.Run("resources_create_or_update with valid namespaced json resource returns success", func(t *testing.T) {
481 | 			if err != nil {
482 | 				t.Fatalf("call tool failed %v", err)
483 | 				return
484 | 			}
485 | 			if resourcesCreateOrUpdateCm2.IsError {
486 | 				t.Fatalf("call tool failed")
487 | 				return
488 | 			}
489 | 		})
490 | 		t.Run("resources_create_or_update with valid namespaced json resource creates config map", func(t *testing.T) {
491 | 			cm, _ := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-cm-created-or-updated-2", metav1.GetOptions{})
492 | 			if cm == nil {
493 | 				t.Fatalf("ConfigMap not found")
494 | 				return
495 | 			}
496 | 		})
497 | 		customResourceDefinitionJson := `
498 |           {
499 |             "apiVersion": "apiextensions.k8s.io/v1",
500 |             "kind": "CustomResourceDefinition",
501 |             "metadata": {"name": "customs.example.com"},
502 |             "spec": {
503 |               "group": "example.com",
504 |               "versions": [{
505 |                 "name": "v1","served": true,"storage": true,
506 |                 "schema": {"openAPIV3Schema": {"type": "object"}}
507 |               }],
508 |               "scope": "Namespaced",
509 |               "names": {"plural": "customs","singular": "custom","kind": "Custom"}
510 |             }
511 |           }`
512 | 		resourcesCreateOrUpdateCrd, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customResourceDefinitionJson})
513 | 		t.Run("resources_create_or_update with valid cluster-scoped json resource returns success", func(t *testing.T) {
514 | 			if err != nil {
515 | 				t.Fatalf("call tool failed %v", err)
516 | 				return
517 | 			}
518 | 			if resourcesCreateOrUpdateCrd.IsError {
519 | 				t.Fatalf("call tool failed")
520 | 				return
521 | 			}
522 | 		})
523 | 		t.Run("resources_create_or_update with valid cluster-scoped json resource creates custom resource definition", func(t *testing.T) {
524 | 			apiExtensionsV1Client := c.newApiExtensionsClient()
525 | 			_, err = apiExtensionsV1Client.CustomResourceDefinitions().Get(c.ctx, "customs.example.com", metav1.GetOptions{})
526 | 			if err != nil {
527 | 				t.Fatalf("custom resource definition not found")
528 | 				return
529 | 			}
530 | 		})
531 | 		c.crdWaitUntilReady("customs.example.com")
532 | 		customJson := "{\"apiVersion\": \"example.com/v1\", \"kind\": \"Custom\", \"metadata\": {\"name\": \"a-custom-resource\"}}"
533 | 		resourcesCreateOrUpdateCustom, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customJson})
534 | 		t.Run("resources_create_or_update with valid namespaced json resource returns success", func(t *testing.T) {
535 | 			if err != nil {
536 | 				t.Fatalf("call tool failed %v", err)
537 | 				return
538 | 			}
539 | 			if resourcesCreateOrUpdateCustom.IsError {
540 | 				t.Fatalf("call tool failed, got: %v", resourcesCreateOrUpdateCustom.Content)
541 | 				return
542 | 			}
543 | 		})
544 | 		t.Run("resources_create_or_update with valid namespaced json resource creates custom resource", func(t *testing.T) {
545 | 			dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
546 | 			_, err = dynamicClient.
547 | 				Resource(schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "customs"}).
548 | 				Namespace("default").
549 | 				Get(c.ctx, "a-custom-resource", metav1.GetOptions{})
550 | 			if err != nil {
551 | 				t.Fatalf("custom resource not found")
552 | 				return
553 | 			}
554 | 		})
555 | 		customJsonUpdated := "{\"apiVersion\": \"example.com/v1\", \"kind\": \"Custom\", \"metadata\": {\"name\": \"a-custom-resource\",\"annotations\": {\"updated\": \"true\"}}}"
556 | 		resourcesCreateOrUpdateCustomUpdated, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customJsonUpdated})
557 | 		t.Run("resources_create_or_update with valid namespaced json resource updates custom resource", func(t *testing.T) {
558 | 			if err != nil {
559 | 				t.Fatalf("call tool failed %v", err)
560 | 				return
561 | 			}
562 | 			if resourcesCreateOrUpdateCustomUpdated.IsError {
563 | 				t.Fatalf("call tool failed")
564 | 				return
565 | 			}
566 | 		})
567 | 		t.Run("resources_create_or_update with valid namespaced json resource updates custom resource", func(t *testing.T) {
568 | 			dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
569 | 			customResource, _ := dynamicClient.
570 | 				Resource(schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "customs"}).
571 | 				Namespace("default").
572 | 				Get(c.ctx, "a-custom-resource", metav1.GetOptions{})
573 | 			if customResource == nil {
574 | 				t.Fatalf("custom resource not found")
575 | 				return
576 | 			}
577 | 			annotations := customResource.GetAnnotations()
578 | 			if annotations == nil || annotations["updated"] != "true" {
579 | 				t.Fatalf("custom resource not updated")
580 | 				return
581 | 			}
582 | 		})
583 | 	})
584 | }
585 | 
586 | func TestResourcesCreateOrUpdateDenied(t *testing.T) {
587 | 	deniedResourcesServer := test.Must(config.ReadToml([]byte(`
588 | 		denied_resources = [
589 | 			{ version = "v1", kind = "Secret" },
590 | 			{ group = "rbac.authorization.k8s.io", version = "v1" }
591 | 		]
592 | 	`)))
593 | 	testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
594 | 		c.withEnvTest()
595 | 		secretYaml := "apiVersion: v1\nkind: Secret\nmetadata:\n  name: a-denied-secret\n  namespace: default\n"
596 | 		deniedByKind, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": secretYaml})
597 | 		t.Run("resources_create_or_update (denied by kind) has error", func(t *testing.T) {
598 | 			if !deniedByKind.IsError {
599 | 				t.Fatalf("call tool should fail")
600 | 			}
601 | 		})
602 | 		t.Run("resources_create_or_update (denied by kind) describes denial", func(t *testing.T) {
603 | 			expectedMessage := "failed to create or update resources: resource not allowed: /v1, Kind=Secret"
604 | 			if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
605 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
606 | 			}
607 | 		})
608 | 		roleYaml := "apiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n  name: a-denied-role\n  namespace: default\n"
609 | 		deniedByGroup, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": roleYaml})
610 | 		t.Run("resources_create_or_update (denied by group) has error", func(t *testing.T) {
611 | 			if !deniedByGroup.IsError {
612 | 				t.Fatalf("call tool should fail")
613 | 			}
614 | 		})
615 | 		t.Run("resources_create_or_update (denied by group) describes denial", func(t *testing.T) {
616 | 			expectedMessage := "failed to create or update resources: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
617 | 			if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
618 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
619 | 			}
620 | 		})
621 | 		configMapYaml := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: a-cm-created-or-updated\n  namespace: default\n"
622 | 		allowedResource, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapYaml})
623 | 		t.Run("resources_create_or_update (not denied) creates or updates resource", func(t *testing.T) {
624 | 			if allowedResource.IsError {
625 | 				t.Fatalf("call tool should not fail")
626 | 			}
627 | 		})
628 | 	})
629 | }
630 | 
631 | func TestResourcesDelete(t *testing.T) {
632 | 	testCase(t, func(c *mcpContext) {
633 | 		c.withEnvTest()
634 | 		t.Run("resources_delete with missing apiVersion returns error", func(t *testing.T) {
635 | 			toolResult, _ := c.callTool("resources_delete", map[string]interface{}{})
636 | 			if !toolResult.IsError {
637 | 				t.Fatalf("call tool should fail")
638 | 				return
639 | 			}
640 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, missing argument apiVersion" {
641 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
642 | 				return
643 | 			}
644 | 		})
645 | 		t.Run("resources_delete with missing kind returns error", func(t *testing.T) {
646 | 			toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1"})
647 | 			if !toolResult.IsError {
648 | 				t.Fatalf("call tool should fail")
649 | 				return
650 | 			}
651 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, missing argument kind" {
652 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
653 | 				return
654 | 			}
655 | 		})
656 | 		t.Run("resources_delete with invalid apiVersion returns error", func(t *testing.T) {
657 | 			toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod", "name": "a-pod"})
658 | 			if !toolResult.IsError {
659 | 				t.Fatalf("call tool should fail")
660 | 				return
661 | 			}
662 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, invalid argument apiVersion" {
663 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
664 | 				return
665 | 			}
666 | 		})
667 | 		t.Run("resources_delete with nonexistent apiVersion returns error", func(t *testing.T) {
668 | 			toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom", "name": "a-custom"})
669 | 			if !toolResult.IsError {
670 | 				t.Fatalf("call tool should fail")
671 | 				return
672 | 			}
673 | 			if toolResult.Content[0].(mcp.TextContent).Text != `failed to delete resource: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` {
674 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
675 | 				return
676 | 			}
677 | 		})
678 | 		t.Run("resources_delete with missing name returns error", func(t *testing.T) {
679 | 			toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"})
680 | 			if !toolResult.IsError {
681 | 				t.Fatalf("call tool should fail")
682 | 				return
683 | 			}
684 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, missing argument name" {
685 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
686 | 				return
687 | 			}
688 | 		})
689 | 		t.Run("resources_delete with nonexistent resource returns error", func(t *testing.T) {
690 | 			toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "nonexistent-configmap"})
691 | 			if !toolResult.IsError {
692 | 				t.Fatalf("call tool should fail")
693 | 				return
694 | 			}
695 | 			if toolResult.Content[0].(mcp.TextContent).Text != `failed to delete resource: configmaps "nonexistent-configmap" not found` {
696 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
697 | 				return
698 | 			}
699 | 		})
700 | 		resourcesDeleteCm, err := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "a-configmap-to-delete"})
701 | 		t.Run("resources_delete with valid namespaced resource returns success", func(t *testing.T) {
702 | 			if err != nil {
703 | 				t.Fatalf("call tool failed %v", err)
704 | 				return
705 | 			}
706 | 			if resourcesDeleteCm.IsError {
707 | 				t.Fatalf("call tool failed")
708 | 				return
709 | 			}
710 | 			if resourcesDeleteCm.Content[0].(mcp.TextContent).Text != "Resource deleted successfully" {
711 | 				t.Fatalf("invalid tool result content got: %v", resourcesDeleteCm.Content[0].(mcp.TextContent).Text)
712 | 				return
713 | 			}
714 | 		})
715 | 		client := c.newKubernetesClient()
716 | 		t.Run("resources_delete with valid namespaced resource deletes ConfigMap", func(t *testing.T) {
717 | 			_, err := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-configmap-to-delete", metav1.GetOptions{})
718 | 			if err == nil {
719 | 				t.Fatalf("ConfigMap not deleted")
720 | 				return
721 | 			}
722 | 		})
723 | 		resourcesDeleteNamespace, err := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "ns-to-delete"})
724 | 		t.Run("resources_delete with valid namespaced resource returns success", func(t *testing.T) {
725 | 			if err != nil {
726 | 				t.Fatalf("call tool failed %v", err)
727 | 				return
728 | 			}
729 | 			if resourcesDeleteNamespace.IsError {
730 | 				t.Fatalf("call tool failed")
731 | 				return
732 | 			}
733 | 			if resourcesDeleteNamespace.Content[0].(mcp.TextContent).Text != "Resource deleted successfully" {
734 | 				t.Fatalf("invalid tool result content got: %v", resourcesDeleteNamespace.Content[0].(mcp.TextContent).Text)
735 | 				return
736 | 			}
737 | 		})
738 | 		t.Run("resources_delete with valid namespaced resource deletes Namespace", func(t *testing.T) {
739 | 			ns, err := client.CoreV1().Namespaces().Get(c.ctx, "ns-to-delete", metav1.GetOptions{})
740 | 			if err == nil && ns != nil && ns.DeletionTimestamp == nil {
741 | 				t.Fatalf("Namespace not deleted")
742 | 				return
743 | 			}
744 | 		})
745 | 	})
746 | }
747 | 
748 | func TestResourcesDeleteDenied(t *testing.T) {
749 | 	deniedResourcesServer := test.Must(config.ReadToml([]byte(`
750 | 		denied_resources = [
751 | 			{ version = "v1", kind = "Secret" },
752 | 			{ group = "rbac.authorization.k8s.io", version = "v1" }
753 | 		]
754 | 	`)))
755 | 	testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
756 | 		c.withEnvTest()
757 | 		kc := c.newKubernetesClient()
758 | 		_, _ = kc.CoreV1().ConfigMaps("default").Create(c.ctx, &corev1.ConfigMap{
759 | 			ObjectMeta: metav1.ObjectMeta{Name: "allowed-configmap-to-delete"},
760 | 		}, metav1.CreateOptions{})
761 | 		deniedByKind, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Secret", "namespace": "default", "name": "denied-secret"})
762 | 		t.Run("resources_delete (denied by kind) has error", func(t *testing.T) {
763 | 			if !deniedByKind.IsError {
764 | 				t.Fatalf("call tool should fail")
765 | 			}
766 | 		})
767 | 		t.Run("resources_delete (denied by kind) describes denial", func(t *testing.T) {
768 | 			expectedMessage := "failed to delete resource: resource not allowed: /v1, Kind=Secret"
769 | 			if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
770 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
771 | 			}
772 | 		})
773 | 		deniedByGroup, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "namespace": "default", "name": "denied-role"})
774 | 		t.Run("resources_delete (denied by group) has error", func(t *testing.T) {
775 | 			if !deniedByGroup.IsError {
776 | 				t.Fatalf("call tool should fail")
777 | 			}
778 | 		})
779 | 		t.Run("resources_delete (denied by group) describes denial", func(t *testing.T) {
780 | 			expectedMessage := "failed to delete resource: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
781 | 			if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
782 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
783 | 			}
784 | 		})
785 | 		allowedResource, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "allowed-configmap-to-delete"})
786 | 		t.Run("resources_delete (not denied) deletes resource", func(t *testing.T) {
787 | 			if allowedResource.IsError {
788 | 				t.Fatalf("call tool should not fail")
789 | 			}
790 | 		})
791 | 	})
792 | }
793 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/pods_test.go:
--------------------------------------------------------------------------------

```go
   1 | package mcp
   2 | 
   3 | import (
   4 | 	"regexp"
   5 | 	"strings"
   6 | 	"testing"
   7 | 
   8 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
   9 | 	"github.com/containers/kubernetes-mcp-server/pkg/config"
  10 | 	"github.com/containers/kubernetes-mcp-server/pkg/output"
  11 | 
  12 | 	"github.com/mark3labs/mcp-go/mcp"
  13 | 	corev1 "k8s.io/api/core/v1"
  14 | 	rbacv1 "k8s.io/api/rbac/v1"
  15 | 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  16 | 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
  17 | 	"k8s.io/apimachinery/pkg/runtime/schema"
  18 | 	"k8s.io/client-go/dynamic"
  19 | 	"sigs.k8s.io/yaml"
  20 | )
  21 | 
  22 | func TestPodsListInAllNamespaces(t *testing.T) {
  23 | 	testCase(t, func(c *mcpContext) {
  24 | 		c.withEnvTest()
  25 | 		toolResult, err := c.callTool("pods_list", map[string]interface{}{})
  26 | 		t.Run("pods_list returns pods list", func(t *testing.T) {
  27 | 			if err != nil {
  28 | 				t.Fatalf("call tool failed %v", err)
  29 | 			}
  30 | 			if toolResult.IsError {
  31 | 				t.Fatalf("call tool failed")
  32 | 			}
  33 | 		})
  34 | 		var decoded []unstructured.Unstructured
  35 | 		err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
  36 | 		t.Run("pods_list has yaml content", func(t *testing.T) {
  37 | 			if err != nil {
  38 | 				t.Fatalf("invalid tool result content %v", err)
  39 | 			}
  40 | 		})
  41 | 		t.Run("pods_list returns 3 items", func(t *testing.T) {
  42 | 			if len(decoded) != 3 {
  43 | 				t.Fatalf("invalid pods count, expected 3, got %v", len(decoded))
  44 | 			}
  45 | 		})
  46 | 		t.Run("pods_list returns pod in ns-1", func(t *testing.T) {
  47 | 			if decoded[1].GetName() != "a-pod-in-ns-1" {
  48 | 				t.Fatalf("invalid pod name, expected a-pod-in-ns-1, got %v", decoded[1].GetName())
  49 | 			}
  50 | 			if decoded[1].GetNamespace() != "ns-1" {
  51 | 				t.Fatalf("invalid pod namespace, expected ns-1, got %v", decoded[1].GetNamespace())
  52 | 			}
  53 | 		})
  54 | 		t.Run("pods_list returns pod in ns-2", func(t *testing.T) {
  55 | 			if decoded[2].GetName() != "a-pod-in-ns-2" {
  56 | 				t.Fatalf("invalid pod name, expected a-pod-in-ns-2, got %v", decoded[2].GetName())
  57 | 			}
  58 | 			if decoded[2].GetNamespace() != "ns-2" {
  59 | 				t.Fatalf("invalid pod namespace, expected ns-2, got %v", decoded[2].GetNamespace())
  60 | 			}
  61 | 		})
  62 | 		t.Run("pods_list omits managed fields", func(t *testing.T) {
  63 | 			if decoded[1].GetManagedFields() != nil {
  64 | 				t.Fatalf("managed fields should be omitted, got %v", decoded[0].GetManagedFields())
  65 | 			}
  66 | 		})
  67 | 	})
  68 | }
  69 | 
  70 | func TestPodsListInAllNamespacesUnauthorized(t *testing.T) {
  71 | 	testCase(t, func(c *mcpContext) {
  72 | 		c.withEnvTest()
  73 | 		defer restoreAuth(c.ctx)
  74 | 		client := c.newKubernetesClient()
  75 | 		// Authorize user only for default/configured namespace
  76 | 		r, _ := client.RbacV1().Roles("default").Create(c.ctx, &rbacv1.Role{
  77 | 			ObjectMeta: metav1.ObjectMeta{Name: "allow-pods-list"},
  78 | 			Rules: []rbacv1.PolicyRule{{
  79 | 				Verbs:     []string{"get", "list"},
  80 | 				APIGroups: []string{""},
  81 | 				Resources: []string{"pods"},
  82 | 			}},
  83 | 		}, metav1.CreateOptions{})
  84 | 		_, _ = client.RbacV1().RoleBindings("default").Create(c.ctx, &rbacv1.RoleBinding{
  85 | 			ObjectMeta: metav1.ObjectMeta{Name: "allow-pods-list"},
  86 | 			Subjects:   []rbacv1.Subject{{Kind: "User", Name: envTestUser.Name}},
  87 | 			RoleRef:    rbacv1.RoleRef{Kind: "Role", Name: r.Name},
  88 | 		}, metav1.CreateOptions{})
  89 | 		// Deny cluster by removing cluster rule
  90 | 		_ = client.RbacV1().ClusterRoles().Delete(c.ctx, "allow-all", metav1.DeleteOptions{})
  91 | 		toolResult, err := c.callTool("pods_list", map[string]interface{}{})
  92 | 		t.Run("pods_list returns pods list for default namespace only", func(t *testing.T) {
  93 | 			if err != nil {
  94 | 				t.Fatalf("call tool failed %v", err)
  95 | 				return
  96 | 			}
  97 | 			if toolResult.IsError {
  98 | 				t.Fatalf("call tool failed %v", toolResult.Content)
  99 | 				return
 100 | 			}
 101 | 		})
 102 | 		var decoded []unstructured.Unstructured
 103 | 		err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
 104 | 		t.Run("pods_list has yaml content", func(t *testing.T) {
 105 | 			if err != nil {
 106 | 				t.Fatalf("invalid tool result content %v", err)
 107 | 				return
 108 | 			}
 109 | 		})
 110 | 		t.Run("pods_list returns 1 items", func(t *testing.T) {
 111 | 			if len(decoded) != 1 {
 112 | 				t.Fatalf("invalid pods count, expected 1, got %v", len(decoded))
 113 | 				return
 114 | 			}
 115 | 		})
 116 | 		t.Run("pods_list returns pod in default", func(t *testing.T) {
 117 | 			if decoded[0].GetName() != "a-pod-in-default" {
 118 | 				t.Fatalf("invalid pod name, expected a-pod-in-default, got %v", decoded[0].GetName())
 119 | 				return
 120 | 			}
 121 | 			if decoded[0].GetNamespace() != "default" {
 122 | 				t.Fatalf("invalid pod namespace, expected default, got %v", decoded[0].GetNamespace())
 123 | 				return
 124 | 			}
 125 | 		})
 126 | 	})
 127 | }
 128 | 
 129 | func TestPodsListInNamespace(t *testing.T) {
 130 | 	testCase(t, func(c *mcpContext) {
 131 | 		c.withEnvTest()
 132 | 		t.Run("pods_list_in_namespace with nil namespace returns error", func(t *testing.T) {
 133 | 			toolResult, _ := c.callTool("pods_list_in_namespace", map[string]interface{}{})
 134 | 			if toolResult.IsError != true {
 135 | 				t.Fatalf("call tool should fail")
 136 | 				return
 137 | 			}
 138 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to list pods in namespace, missing argument namespace" {
 139 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 140 | 				return
 141 | 			}
 142 | 		})
 143 | 		toolResult, err := c.callTool("pods_list_in_namespace", map[string]interface{}{
 144 | 			"namespace": "ns-1",
 145 | 		})
 146 | 		t.Run("pods_list_in_namespace returns pods list", func(t *testing.T) {
 147 | 			if err != nil {
 148 | 				t.Fatalf("call tool failed %v", err)
 149 | 			}
 150 | 			if toolResult.IsError {
 151 | 				t.Fatalf("call tool failed")
 152 | 			}
 153 | 		})
 154 | 		var decoded []unstructured.Unstructured
 155 | 		err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
 156 | 		t.Run("pods_list_in_namespace has yaml content", func(t *testing.T) {
 157 | 			if err != nil {
 158 | 				t.Fatalf("invalid tool result content %v", err)
 159 | 			}
 160 | 		})
 161 | 		t.Run("pods_list_in_namespace returns 1 items", func(t *testing.T) {
 162 | 			if len(decoded) != 1 {
 163 | 				t.Fatalf("invalid pods count, expected 1, got %v", len(decoded))
 164 | 			}
 165 | 		})
 166 | 		t.Run("pods_list_in_namespace returns pod in ns-1", func(t *testing.T) {
 167 | 			if decoded[0].GetName() != "a-pod-in-ns-1" {
 168 | 				t.Errorf("invalid pod name, expected a-pod-in-ns-1, got %v", decoded[0].GetName())
 169 | 			}
 170 | 			if decoded[0].GetNamespace() != "ns-1" {
 171 | 				t.Errorf("invalid pod namespace, expected ns-1, got %v", decoded[0].GetNamespace())
 172 | 			}
 173 | 		})
 174 | 		t.Run("pods_list_in_namespace omits managed fields", func(t *testing.T) {
 175 | 			if decoded[0].GetManagedFields() != nil {
 176 | 				t.Fatalf("managed fields should be omitted, got %v", decoded[0].GetManagedFields())
 177 | 			}
 178 | 		})
 179 | 	})
 180 | }
 181 | 
 182 | func TestPodsListDenied(t *testing.T) {
 183 | 	deniedResourcesServer := test.Must(config.ReadToml([]byte(`
 184 | 		denied_resources = [ { version = "v1", kind = "Pod" } ]
 185 | 	`)))
 186 | 	testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
 187 | 		c.withEnvTest()
 188 | 		podsList, _ := c.callTool("pods_list", map[string]interface{}{})
 189 | 		t.Run("pods_list has error", func(t *testing.T) {
 190 | 			if !podsList.IsError {
 191 | 				t.Fatalf("call tool should fail")
 192 | 			}
 193 | 		})
 194 | 		t.Run("pods_list describes denial", func(t *testing.T) {
 195 | 			expectedMessage := "failed to list pods in all namespaces: resource not allowed: /v1, Kind=Pod"
 196 | 			if podsList.Content[0].(mcp.TextContent).Text != expectedMessage {
 197 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsList.Content[0].(mcp.TextContent).Text)
 198 | 			}
 199 | 		})
 200 | 		podsListInNamespace, _ := c.callTool("pods_list_in_namespace", map[string]interface{}{"namespace": "ns-1"})
 201 | 		t.Run("pods_list_in_namespace has error", func(t *testing.T) {
 202 | 			if !podsListInNamespace.IsError {
 203 | 				t.Fatalf("call tool should fail")
 204 | 			}
 205 | 		})
 206 | 		t.Run("pods_list_in_namespace describes denial", func(t *testing.T) {
 207 | 			expectedMessage := "failed to list pods in namespace ns-1: resource not allowed: /v1, Kind=Pod"
 208 | 			if podsListInNamespace.Content[0].(mcp.TextContent).Text != expectedMessage {
 209 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsListInNamespace.Content[0].(mcp.TextContent).Text)
 210 | 			}
 211 | 		})
 212 | 	})
 213 | }
 214 | 
 215 | func TestPodsListAsTable(t *testing.T) {
 216 | 	testCaseWithContext(t, &mcpContext{listOutput: output.Table}, func(c *mcpContext) {
 217 | 		c.withEnvTest()
 218 | 		podsList, err := c.callTool("pods_list", map[string]interface{}{})
 219 | 		t.Run("pods_list returns pods list", func(t *testing.T) {
 220 | 			if err != nil {
 221 | 				t.Fatalf("call tool failed %v", err)
 222 | 			}
 223 | 			if podsList.IsError {
 224 | 				t.Fatalf("call tool failed")
 225 | 			}
 226 | 		})
 227 | 		outPodsList := podsList.Content[0].(mcp.TextContent).Text
 228 | 		t.Run("pods_list returns table with 1 header and 3 rows", func(t *testing.T) {
 229 | 			lines := strings.Count(outPodsList, "\n")
 230 | 			if lines != 4 {
 231 | 				t.Fatalf("invalid line count, expected 4 (1 header, 3 row), got %v", lines)
 232 | 			}
 233 | 		})
 234 | 		t.Run("pods_list_in_namespace returns column headers", func(t *testing.T) {
 235 | 			expectedHeaders := "NAMESPACE\\s+APIVERSION\\s+KIND\\s+NAME\\s+READY\\s+STATUS\\s+RESTARTS\\s+AGE\\s+IP\\s+NODE\\s+NOMINATED NODE\\s+READINESS GATES\\s+LABELS"
 236 | 			if m, e := regexp.MatchString(expectedHeaders, outPodsList); !m || e != nil {
 237 | 				t.Fatalf("Expected headers '%s' not found in output:\n%s", expectedHeaders, outPodsList)
 238 | 			}
 239 | 		})
 240 | 		t.Run("pods_list_in_namespace returns formatted row for a-pod-in-ns-1", func(t *testing.T) {
 241 | 			expectedRow := "(?<namespace>ns-1)\\s+" +
 242 | 				"(?<apiVersion>v1)\\s+" +
 243 | 				"(?<kind>Pod)\\s+" +
 244 | 				"(?<name>a-pod-in-ns-1)\\s+" +
 245 | 				"(?<ready>0\\/1)\\s+" +
 246 | 				"(?<status>Pending)\\s+" +
 247 | 				"(?<restarts>0)\\s+" +
 248 | 				"(?<age>(\\d+m)?(\\d+s)?)\\s+" +
 249 | 				"(?<ip><none>)\\s+" +
 250 | 				"(?<node><none>)\\s+" +
 251 | 				"(?<nominated_node><none>)\\s+" +
 252 | 				"(?<readiness_gates><none>)\\s+" +
 253 | 				"(?<labels><none>)"
 254 | 			if m, e := regexp.MatchString(expectedRow, outPodsList); !m || e != nil {
 255 | 				t.Fatalf("Expected row '%s' not found in output:\n%s", expectedRow, outPodsList)
 256 | 			}
 257 | 		})
 258 | 		t.Run("pods_list_in_namespace returns formatted row for a-pod-in-default", func(t *testing.T) {
 259 | 			expectedRow := "(?<namespace>default)\\s+" +
 260 | 				"(?<apiVersion>v1)\\s+" +
 261 | 				"(?<kind>Pod)\\s+" +
 262 | 				"(?<name>a-pod-in-default)\\s+" +
 263 | 				"(?<ready>0\\/1)\\s+" +
 264 | 				"(?<status>Pending)\\s+" +
 265 | 				"(?<restarts>0)\\s+" +
 266 | 				"(?<age>(\\d+m)?(\\d+s)?)\\s+" +
 267 | 				"(?<ip><none>)\\s+" +
 268 | 				"(?<node><none>)\\s+" +
 269 | 				"(?<nominated_node><none>)\\s+" +
 270 | 				"(?<readiness_gates><none>)\\s+" +
 271 | 				"(?<labels>app=nginx)"
 272 | 			if m, e := regexp.MatchString(expectedRow, outPodsList); !m || e != nil {
 273 | 				t.Fatalf("Expected row '%s' not found in output:\n%s", expectedRow, outPodsList)
 274 | 			}
 275 | 		})
 276 | 		podsListInNamespace, err := c.callTool("pods_list_in_namespace", map[string]interface{}{
 277 | 			"namespace": "ns-1",
 278 | 		})
 279 | 		t.Run("pods_list_in_namespace returns pods list", func(t *testing.T) {
 280 | 			if err != nil {
 281 | 				t.Fatalf("call tool failed %v", err)
 282 | 				return
 283 | 			}
 284 | 			if podsListInNamespace.IsError {
 285 | 				t.Fatalf("call tool failed")
 286 | 			}
 287 | 		})
 288 | 		outPodsListInNamespace := podsListInNamespace.Content[0].(mcp.TextContent).Text
 289 | 		t.Run("pods_list_in_namespace returns table with 1 header and 1 row", func(t *testing.T) {
 290 | 			lines := strings.Count(outPodsListInNamespace, "\n")
 291 | 			if lines != 2 {
 292 | 				t.Fatalf("invalid line count, expected 2 (1 header, 1 row), got %v", lines)
 293 | 			}
 294 | 		})
 295 | 		t.Run("pods_list_in_namespace returns column headers", func(t *testing.T) {
 296 | 			expectedHeaders := "NAMESPACE\\s+APIVERSION\\s+KIND\\s+NAME\\s+READY\\s+STATUS\\s+RESTARTS\\s+AGE\\s+IP\\s+NODE\\s+NOMINATED NODE\\s+READINESS GATES\\s+LABELS"
 297 | 			if m, e := regexp.MatchString(expectedHeaders, outPodsListInNamespace); !m || e != nil {
 298 | 				t.Fatalf("Expected headers '%s' not found in output:\n%s", expectedHeaders, outPodsListInNamespace)
 299 | 			}
 300 | 		})
 301 | 		t.Run("pods_list_in_namespace returns formatted row", func(t *testing.T) {
 302 | 			expectedRow := "(?<namespace>ns-1)\\s+" +
 303 | 				"(?<apiVersion>v1)\\s+" +
 304 | 				"(?<kind>Pod)\\s+" +
 305 | 				"(?<name>a-pod-in-ns-1)\\s+" +
 306 | 				"(?<ready>0\\/1)\\s+" +
 307 | 				"(?<status>Pending)\\s+" +
 308 | 				"(?<restarts>0)\\s+" +
 309 | 				"(?<age>(\\d+m)?(\\d+s)?)\\s+" +
 310 | 				"(?<ip><none>)\\s+" +
 311 | 				"(?<node><none>)\\s+" +
 312 | 				"(?<nominated_node><none>)\\s+" +
 313 | 				"(?<readiness_gates><none>)\\s+" +
 314 | 				"(?<labels><none>)"
 315 | 			if m, e := regexp.MatchString(expectedRow, outPodsListInNamespace); !m || e != nil {
 316 | 				t.Fatalf("Expected row '%s' not found in output:\n%s", expectedRow, outPodsListInNamespace)
 317 | 			}
 318 | 		})
 319 | 	})
 320 | }
 321 | 
 322 | func TestPodsGet(t *testing.T) {
 323 | 	testCase(t, func(c *mcpContext) {
 324 | 		c.withEnvTest()
 325 | 		t.Run("pods_get with nil name returns error", func(t *testing.T) {
 326 | 			toolResult, _ := c.callTool("pods_get", map[string]interface{}{})
 327 | 			if toolResult.IsError != true {
 328 | 				t.Fatalf("call tool should fail")
 329 | 				return
 330 | 			}
 331 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to get pod, missing argument name" {
 332 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 333 | 				return
 334 | 			}
 335 | 		})
 336 | 		t.Run("pods_get with not found name returns error", func(t *testing.T) {
 337 | 			toolResult, _ := c.callTool("pods_get", map[string]interface{}{"name": "not-found"})
 338 | 			if toolResult.IsError != true {
 339 | 				t.Fatalf("call tool should fail")
 340 | 				return
 341 | 			}
 342 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to get pod not-found in namespace : pods \"not-found\" not found" {
 343 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 344 | 				return
 345 | 			}
 346 | 		})
 347 | 		podsGetNilNamespace, err := c.callTool("pods_get", map[string]interface{}{
 348 | 			"name": "a-pod-in-default",
 349 | 		})
 350 | 		t.Run("pods_get with name and nil namespace returns pod", func(t *testing.T) {
 351 | 			if err != nil {
 352 | 				t.Fatalf("call tool failed %v", err)
 353 | 				return
 354 | 			}
 355 | 			if podsGetNilNamespace.IsError {
 356 | 				t.Fatalf("call tool failed")
 357 | 				return
 358 | 			}
 359 | 		})
 360 | 		var decodedNilNamespace unstructured.Unstructured
 361 | 		err = yaml.Unmarshal([]byte(podsGetNilNamespace.Content[0].(mcp.TextContent).Text), &decodedNilNamespace)
 362 | 		t.Run("pods_get with name and nil namespace has yaml content", func(t *testing.T) {
 363 | 			if err != nil {
 364 | 				t.Fatalf("invalid tool result content %v", err)
 365 | 				return
 366 | 			}
 367 | 		})
 368 | 		t.Run("pods_get with name and nil namespace returns pod in default", func(t *testing.T) {
 369 | 			if decodedNilNamespace.GetName() != "a-pod-in-default" {
 370 | 				t.Fatalf("invalid pod name, expected a-pod-in-default, got %v", decodedNilNamespace.GetName())
 371 | 				return
 372 | 			}
 373 | 			if decodedNilNamespace.GetNamespace() != "default" {
 374 | 				t.Fatalf("invalid pod namespace, expected default, got %v", decodedNilNamespace.GetNamespace())
 375 | 				return
 376 | 			}
 377 | 		})
 378 | 		t.Run("pods_get with name and nil namespace omits managed fields", func(t *testing.T) {
 379 | 			if decodedNilNamespace.GetManagedFields() != nil {
 380 | 				t.Fatalf("managed fields should be omitted, got %v", decodedNilNamespace.GetManagedFields())
 381 | 				return
 382 | 			}
 383 | 		})
 384 | 		podsGetInNamespace, err := c.callTool("pods_get", map[string]interface{}{
 385 | 			"namespace": "ns-1",
 386 | 			"name":      "a-pod-in-ns-1",
 387 | 		})
 388 | 		t.Run("pods_get with name and namespace returns pod", func(t *testing.T) {
 389 | 			if err != nil {
 390 | 				t.Fatalf("call tool failed %v", err)
 391 | 				return
 392 | 			}
 393 | 			if podsGetInNamespace.IsError {
 394 | 				t.Fatalf("call tool failed")
 395 | 				return
 396 | 			}
 397 | 		})
 398 | 		var decodedInNamespace unstructured.Unstructured
 399 | 		err = yaml.Unmarshal([]byte(podsGetInNamespace.Content[0].(mcp.TextContent).Text), &decodedInNamespace)
 400 | 		t.Run("pods_get with name and namespace has yaml content", func(t *testing.T) {
 401 | 			if err != nil {
 402 | 				t.Fatalf("invalid tool result content %v", err)
 403 | 				return
 404 | 			}
 405 | 		})
 406 | 		t.Run("pods_get with name and namespace returns pod in ns-1", func(t *testing.T) {
 407 | 			if decodedInNamespace.GetName() != "a-pod-in-ns-1" {
 408 | 				t.Fatalf("invalid pod name, expected a-pod-in-ns-1, got %v", decodedInNamespace.GetName())
 409 | 				return
 410 | 			}
 411 | 			if decodedInNamespace.GetNamespace() != "ns-1" {
 412 | 				t.Fatalf("invalid pod namespace, ns-1 ns-1, got %v", decodedInNamespace.GetNamespace())
 413 | 				return
 414 | 			}
 415 | 		})
 416 | 	})
 417 | }
 418 | 
 419 | func TestPodsGetDenied(t *testing.T) {
 420 | 	deniedResourcesServer := test.Must(config.ReadToml([]byte(`
 421 | 		denied_resources = [ { version = "v1", kind = "Pod" } ]
 422 | 	`)))
 423 | 	testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
 424 | 		c.withEnvTest()
 425 | 		podsGet, _ := c.callTool("pods_get", map[string]interface{}{"name": "a-pod-in-default"})
 426 | 		t.Run("pods_get has error", func(t *testing.T) {
 427 | 			if !podsGet.IsError {
 428 | 				t.Fatalf("call tool should fail")
 429 | 			}
 430 | 		})
 431 | 		t.Run("pods_get describes denial", func(t *testing.T) {
 432 | 			expectedMessage := "failed to get pod a-pod-in-default in namespace : resource not allowed: /v1, Kind=Pod"
 433 | 			if podsGet.Content[0].(mcp.TextContent).Text != expectedMessage {
 434 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsGet.Content[0].(mcp.TextContent).Text)
 435 | 			}
 436 | 		})
 437 | 	})
 438 | }
 439 | 
 440 | func TestPodsDelete(t *testing.T) {
 441 | 	testCase(t, func(c *mcpContext) {
 442 | 		c.withEnvTest()
 443 | 		// Errors
 444 | 		t.Run("pods_delete with nil name returns error", func(t *testing.T) {
 445 | 			toolResult, _ := c.callTool("pods_delete", map[string]interface{}{})
 446 | 			if toolResult.IsError != true {
 447 | 				t.Errorf("call tool should fail")
 448 | 				return
 449 | 			}
 450 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete pod, missing argument name" {
 451 | 				t.Errorf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 452 | 				return
 453 | 			}
 454 | 		})
 455 | 		t.Run("pods_delete with not found name returns error", func(t *testing.T) {
 456 | 			toolResult, _ := c.callTool("pods_delete", map[string]interface{}{"name": "not-found"})
 457 | 			if toolResult.IsError != true {
 458 | 				t.Errorf("call tool should fail")
 459 | 				return
 460 | 			}
 461 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete pod not-found in namespace : pods \"not-found\" not found" {
 462 | 				t.Errorf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 463 | 				return
 464 | 			}
 465 | 		})
 466 | 		// Default/nil Namespace
 467 | 		kc := c.newKubernetesClient()
 468 | 		_, _ = kc.CoreV1().Pods("default").Create(c.ctx, &corev1.Pod{
 469 | 			ObjectMeta: metav1.ObjectMeta{Name: "a-pod-to-delete"},
 470 | 			Spec:       corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
 471 | 		}, metav1.CreateOptions{})
 472 | 		podsDeleteNilNamespace, err := c.callTool("pods_delete", map[string]interface{}{
 473 | 			"name": "a-pod-to-delete",
 474 | 		})
 475 | 		t.Run("pods_delete with name and nil namespace returns success", func(t *testing.T) {
 476 | 			if err != nil {
 477 | 				t.Errorf("call tool failed %v", err)
 478 | 				return
 479 | 			}
 480 | 			if podsDeleteNilNamespace.IsError {
 481 | 				t.Errorf("call tool failed")
 482 | 				return
 483 | 			}
 484 | 			if podsDeleteNilNamespace.Content[0].(mcp.TextContent).Text != "Pod deleted successfully" {
 485 | 				t.Errorf("invalid tool result content, got %v", podsDeleteNilNamespace.Content[0].(mcp.TextContent).Text)
 486 | 				return
 487 | 			}
 488 | 		})
 489 | 		t.Run("pods_delete with name and nil namespace deletes Pod", func(t *testing.T) {
 490 | 			p, pErr := kc.CoreV1().Pods("default").Get(c.ctx, "a-pod-to-delete", metav1.GetOptions{})
 491 | 			if pErr == nil && p != nil && p.DeletionTimestamp == nil {
 492 | 				t.Errorf("Pod not deleted")
 493 | 				return
 494 | 			}
 495 | 		})
 496 | 		// Provided Namespace
 497 | 		_, _ = kc.CoreV1().Pods("ns-1").Create(c.ctx, &corev1.Pod{
 498 | 			ObjectMeta: metav1.ObjectMeta{Name: "a-pod-to-delete-in-ns-1"},
 499 | 			Spec:       corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
 500 | 		}, metav1.CreateOptions{})
 501 | 		podsDeleteInNamespace, err := c.callTool("pods_delete", map[string]interface{}{
 502 | 			"namespace": "ns-1",
 503 | 			"name":      "a-pod-to-delete-in-ns-1",
 504 | 		})
 505 | 		t.Run("pods_delete with name and namespace returns success", func(t *testing.T) {
 506 | 			if err != nil {
 507 | 				t.Errorf("call tool failed %v", err)
 508 | 				return
 509 | 			}
 510 | 			if podsDeleteInNamespace.IsError {
 511 | 				t.Errorf("call tool failed")
 512 | 				return
 513 | 			}
 514 | 			if podsDeleteInNamespace.Content[0].(mcp.TextContent).Text != "Pod deleted successfully" {
 515 | 				t.Errorf("invalid tool result content, got %v", podsDeleteInNamespace.Content[0].(mcp.TextContent).Text)
 516 | 				return
 517 | 			}
 518 | 		})
 519 | 		t.Run("pods_delete with name and namespace deletes Pod", func(t *testing.T) {
 520 | 			p, pErr := kc.CoreV1().Pods("ns-1").Get(c.ctx, "a-pod-to-delete-in-ns-1", metav1.GetOptions{})
 521 | 			if pErr == nil && p != nil && p.DeletionTimestamp == nil {
 522 | 				t.Errorf("Pod not deleted")
 523 | 				return
 524 | 			}
 525 | 		})
 526 | 		// Managed Pod
 527 | 		managedLabels := map[string]string{
 528 | 			"app.kubernetes.io/managed-by": "kubernetes-mcp-server",
 529 | 			"app.kubernetes.io/name":       "a-manged-pod-to-delete",
 530 | 		}
 531 | 		_, _ = kc.CoreV1().Pods("default").Create(c.ctx, &corev1.Pod{
 532 | 			ObjectMeta: metav1.ObjectMeta{Name: "a-managed-pod-to-delete", Labels: managedLabels},
 533 | 			Spec:       corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
 534 | 		}, metav1.CreateOptions{})
 535 | 		_, _ = kc.CoreV1().Services("default").Create(c.ctx, &corev1.Service{
 536 | 			ObjectMeta: metav1.ObjectMeta{Name: "a-managed-service-to-delete", Labels: managedLabels},
 537 | 			Spec:       corev1.ServiceSpec{Selector: managedLabels, Ports: []corev1.ServicePort{{Port: 80}}},
 538 | 		}, metav1.CreateOptions{})
 539 | 		podsDeleteManaged, err := c.callTool("pods_delete", map[string]interface{}{
 540 | 			"name": "a-managed-pod-to-delete",
 541 | 		})
 542 | 		t.Run("pods_delete with managed pod returns success", func(t *testing.T) {
 543 | 			if err != nil {
 544 | 				t.Errorf("call tool failed %v", err)
 545 | 				return
 546 | 			}
 547 | 			if podsDeleteManaged.IsError {
 548 | 				t.Errorf("call tool failed")
 549 | 				return
 550 | 			}
 551 | 			if podsDeleteManaged.Content[0].(mcp.TextContent).Text != "Pod deleted successfully" {
 552 | 				t.Errorf("invalid tool result content, got %v", podsDeleteManaged.Content[0].(mcp.TextContent).Text)
 553 | 				return
 554 | 			}
 555 | 		})
 556 | 		t.Run("pods_delete with managed pod deletes Pod and Service", func(t *testing.T) {
 557 | 			p, pErr := kc.CoreV1().Pods("default").Get(c.ctx, "a-managed-pod-to-delete", metav1.GetOptions{})
 558 | 			if pErr == nil && p != nil && p.DeletionTimestamp == nil {
 559 | 				t.Errorf("Pod not deleted")
 560 | 				return
 561 | 			}
 562 | 			s, sErr := kc.CoreV1().Services("default").Get(c.ctx, "a-managed-service-to-delete", metav1.GetOptions{})
 563 | 			if sErr == nil && s != nil && s.DeletionTimestamp == nil {
 564 | 				t.Errorf("Service not deleted")
 565 | 				return
 566 | 			}
 567 | 		})
 568 | 	})
 569 | }
 570 | 
 571 | func TestPodsDeleteDenied(t *testing.T) {
 572 | 	deniedResourcesServer := test.Must(config.ReadToml([]byte(`
 573 | 		denied_resources = [ { version = "v1", kind = "Pod" } ]
 574 | 	`)))
 575 | 	testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
 576 | 		c.withEnvTest()
 577 | 		podsDelete, _ := c.callTool("pods_delete", map[string]interface{}{"name": "a-pod-in-default"})
 578 | 		t.Run("pods_delete has error", func(t *testing.T) {
 579 | 			if !podsDelete.IsError {
 580 | 				t.Fatalf("call tool should fail")
 581 | 			}
 582 | 		})
 583 | 		t.Run("pods_delete describes denial", func(t *testing.T) {
 584 | 			expectedMessage := "failed to delete pod a-pod-in-default in namespace : resource not allowed: /v1, Kind=Pod"
 585 | 			if podsDelete.Content[0].(mcp.TextContent).Text != expectedMessage {
 586 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsDelete.Content[0].(mcp.TextContent).Text)
 587 | 			}
 588 | 		})
 589 | 	})
 590 | }
 591 | 
 592 | func TestPodsDeleteInOpenShift(t *testing.T) {
 593 | 	testCaseWithContext(t, &mcpContext{before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) {
 594 | 		managedLabels := map[string]string{
 595 | 			"app.kubernetes.io/managed-by": "kubernetes-mcp-server",
 596 | 			"app.kubernetes.io/name":       "a-manged-pod-to-delete",
 597 | 		}
 598 | 		kc := c.newKubernetesClient()
 599 | 		_, _ = kc.CoreV1().Pods("default").Create(c.ctx, &corev1.Pod{
 600 | 			ObjectMeta: metav1.ObjectMeta{Name: "a-managed-pod-to-delete-in-openshift", Labels: managedLabels},
 601 | 			Spec:       corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
 602 | 		}, metav1.CreateOptions{})
 603 | 		dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
 604 | 		_, _ = dynamicClient.Resource(schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}).
 605 | 			Namespace("default").Create(c.ctx, &unstructured.Unstructured{Object: map[string]interface{}{
 606 | 			"apiVersion": "route.openshift.io/v1",
 607 | 			"kind":       "Route",
 608 | 			"metadata": map[string]interface{}{
 609 | 				"name":   "a-managed-route-to-delete",
 610 | 				"labels": managedLabels,
 611 | 			},
 612 | 		}}, metav1.CreateOptions{})
 613 | 		podsDeleteManagedOpenShift, err := c.callTool("pods_delete", map[string]interface{}{
 614 | 			"name": "a-managed-pod-to-delete-in-openshift",
 615 | 		})
 616 | 		t.Run("pods_delete with managed pod in OpenShift returns success", func(t *testing.T) {
 617 | 			if err != nil {
 618 | 				t.Errorf("call tool failed %v", err)
 619 | 				return
 620 | 			}
 621 | 			if podsDeleteManagedOpenShift.IsError {
 622 | 				t.Errorf("call tool failed")
 623 | 				return
 624 | 			}
 625 | 			if podsDeleteManagedOpenShift.Content[0].(mcp.TextContent).Text != "Pod deleted successfully" {
 626 | 				t.Errorf("invalid tool result content, got %v", podsDeleteManagedOpenShift.Content[0].(mcp.TextContent).Text)
 627 | 				return
 628 | 			}
 629 | 		})
 630 | 		t.Run("pods_delete with managed pod in OpenShift deletes Pod and Route", func(t *testing.T) {
 631 | 			p, pErr := kc.CoreV1().Pods("default").Get(c.ctx, "a-managed-pod-to-delete-in-openshift", metav1.GetOptions{})
 632 | 			if pErr == nil && p != nil && p.DeletionTimestamp == nil {
 633 | 				t.Errorf("Pod not deleted")
 634 | 				return
 635 | 			}
 636 | 			r, rErr := dynamicClient.
 637 | 				Resource(schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}).
 638 | 				Namespace("default").Get(c.ctx, "a-managed-route-to-delete", metav1.GetOptions{})
 639 | 			if rErr == nil && r != nil && r.GetDeletionTimestamp() == nil {
 640 | 				t.Errorf("Route not deleted")
 641 | 				return
 642 | 			}
 643 | 		})
 644 | 	})
 645 | }
 646 | 
 647 | func TestPodsLog(t *testing.T) {
 648 | 	testCase(t, func(c *mcpContext) {
 649 | 		c.withEnvTest()
 650 | 		t.Run("pods_log with nil name returns error", func(t *testing.T) {
 651 | 			toolResult, _ := c.callTool("pods_log", map[string]interface{}{})
 652 | 			if toolResult.IsError != true {
 653 | 				t.Fatalf("call tool should fail")
 654 | 				return
 655 | 			}
 656 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to get pod log, missing argument name" {
 657 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 658 | 				return
 659 | 			}
 660 | 		})
 661 | 		t.Run("pods_log with not found name returns error", func(t *testing.T) {
 662 | 			toolResult, _ := c.callTool("pods_log", map[string]interface{}{"name": "not-found"})
 663 | 			if toolResult.IsError != true {
 664 | 				t.Fatalf("call tool should fail")
 665 | 				return
 666 | 			}
 667 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to get pod not-found log in namespace : pods \"not-found\" not found" {
 668 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 669 | 				return
 670 | 			}
 671 | 		})
 672 | 		podsLogNilNamespace, err := c.callTool("pods_log", map[string]interface{}{
 673 | 			"name": "a-pod-in-default",
 674 | 		})
 675 | 		t.Run("pods_log with name and nil namespace returns pod log", func(t *testing.T) {
 676 | 			if err != nil {
 677 | 				t.Fatalf("call tool failed %v", err)
 678 | 				return
 679 | 			}
 680 | 			if podsLogNilNamespace.IsError {
 681 | 				t.Fatalf("call tool failed")
 682 | 				return
 683 | 			}
 684 | 		})
 685 | 		podsLogInNamespace, err := c.callTool("pods_log", map[string]interface{}{
 686 | 			"namespace": "ns-1",
 687 | 			"name":      "a-pod-in-ns-1",
 688 | 		})
 689 | 		t.Run("pods_log with name and namespace returns pod log", func(t *testing.T) {
 690 | 			if err != nil {
 691 | 				t.Fatalf("call tool failed %v", err)
 692 | 				return
 693 | 			}
 694 | 			if podsLogInNamespace.IsError {
 695 | 				t.Fatalf("call tool failed")
 696 | 				return
 697 | 			}
 698 | 		})
 699 | 		podsContainerLogInNamespace, err := c.callTool("pods_log", map[string]interface{}{
 700 | 			"namespace": "ns-1",
 701 | 			"name":      "a-pod-in-ns-1",
 702 | 			"container": "nginx",
 703 | 		})
 704 | 		t.Run("pods_log with name, container and namespace returns pod log", func(t *testing.T) {
 705 | 			if err != nil {
 706 | 				t.Fatalf("call tool failed %v", err)
 707 | 				return
 708 | 			}
 709 | 			if podsContainerLogInNamespace.IsError {
 710 | 				t.Fatalf("call tool failed")
 711 | 				return
 712 | 			}
 713 | 		})
 714 | 		toolResult, err := c.callTool("pods_log", map[string]interface{}{
 715 | 			"namespace": "ns-1",
 716 | 			"name":      "a-pod-in-ns-1",
 717 | 			"container": "a-not-existing-container",
 718 | 		})
 719 | 		t.Run("pods_log with non existing container returns error", func(t *testing.T) {
 720 | 			if toolResult.IsError != true {
 721 | 				t.Fatalf("call tool should fail")
 722 | 				return
 723 | 			}
 724 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to get pod a-pod-in-ns-1 log in namespace ns-1: container a-not-existing-container is not valid for pod a-pod-in-ns-1" {
 725 | 				t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 726 | 				return
 727 | 			}
 728 | 		})
 729 | 		podsPreviousLogInNamespace, err := c.callTool("pods_log", map[string]interface{}{
 730 | 			"namespace": "ns-1",
 731 | 			"name":      "a-pod-in-ns-1",
 732 | 			"previous":  true,
 733 | 		})
 734 | 		t.Run("pods_log with previous=true returns previous pod log", func(t *testing.T) {
 735 | 			if err != nil {
 736 | 				t.Fatalf("call tool failed %v", err)
 737 | 				return
 738 | 			}
 739 | 			if podsPreviousLogInNamespace.IsError {
 740 | 				t.Fatalf("call tool failed")
 741 | 				return
 742 | 			}
 743 | 		})
 744 | 		podsPreviousLogFalse, err := c.callTool("pods_log", map[string]interface{}{
 745 | 			"namespace": "ns-1",
 746 | 			"name":      "a-pod-in-ns-1",
 747 | 			"previous":  false,
 748 | 		})
 749 | 		t.Run("pods_log with previous=false returns current pod log", func(t *testing.T) {
 750 | 			if err != nil {
 751 | 				t.Fatalf("call tool failed %v", err)
 752 | 				return
 753 | 			}
 754 | 			if podsPreviousLogFalse.IsError {
 755 | 				t.Fatalf("call tool failed")
 756 | 				return
 757 | 			}
 758 | 		})
 759 | 
 760 | 		// Test with tail parameter
 761 | 		podsTailLines, err := c.callTool("pods_log", map[string]interface{}{
 762 | 			"namespace": "ns-1",
 763 | 			"name":      "a-pod-in-ns-1",
 764 | 			"tail":      50,
 765 | 		})
 766 | 		t.Run("pods_log with tail=50 returns pod log", func(t *testing.T) {
 767 | 			if err != nil {
 768 | 				t.Fatalf("call tool failed %v", err)
 769 | 				return
 770 | 			}
 771 | 			if podsTailLines.IsError {
 772 | 				t.Fatalf("call tool failed")
 773 | 				return
 774 | 			}
 775 | 		})
 776 | 
 777 | 		// Test with invalid tail parameter
 778 | 		podsInvalidTailLines, _ := c.callTool("pods_log", map[string]interface{}{
 779 | 			"namespace": "ns-1",
 780 | 			"name":      "a-pod-in-ns-1",
 781 | 			"tail":      "invalid",
 782 | 		})
 783 | 		t.Run("pods_log with invalid tail returns error", func(t *testing.T) {
 784 | 			if !podsInvalidTailLines.IsError {
 785 | 				t.Fatalf("call tool should fail")
 786 | 				return
 787 | 			}
 788 | 			expectedErrorMsg := "failed to parse tail parameter: expected integer"
 789 | 			if errMsg := podsInvalidTailLines.Content[0].(mcp.TextContent).Text; !strings.Contains(errMsg, expectedErrorMsg) {
 790 | 				t.Fatalf("unexpected error message, expected to contain '%s', got '%s'", expectedErrorMsg, errMsg)
 791 | 				return
 792 | 			}
 793 | 		})
 794 | 	})
 795 | }
 796 | 
 797 | func TestPodsLogDenied(t *testing.T) {
 798 | 	deniedResourcesServer := test.Must(config.ReadToml([]byte(`
 799 | 		denied_resources = [ { version = "v1", kind = "Pod" } ]
 800 | 	`)))
 801 | 	testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
 802 | 		c.withEnvTest()
 803 | 		podsLog, _ := c.callTool("pods_log", map[string]interface{}{"name": "a-pod-in-default"})
 804 | 		t.Run("pods_log has error", func(t *testing.T) {
 805 | 			if !podsLog.IsError {
 806 | 				t.Fatalf("call tool should fail")
 807 | 			}
 808 | 		})
 809 | 		t.Run("pods_log describes denial", func(t *testing.T) {
 810 | 			expectedMessage := "failed to get pod a-pod-in-default log in namespace : resource not allowed: /v1, Kind=Pod"
 811 | 			if podsLog.Content[0].(mcp.TextContent).Text != expectedMessage {
 812 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsLog.Content[0].(mcp.TextContent).Text)
 813 | 			}
 814 | 		})
 815 | 	})
 816 | }
 817 | 
 818 | func TestPodsRun(t *testing.T) {
 819 | 	testCase(t, func(c *mcpContext) {
 820 | 		c.withEnvTest()
 821 | 		t.Run("pods_run with nil image returns error", func(t *testing.T) {
 822 | 			toolResult, _ := c.callTool("pods_run", map[string]interface{}{})
 823 | 			if toolResult.IsError != true {
 824 | 				t.Errorf("call tool should fail")
 825 | 				return
 826 | 			}
 827 | 			if toolResult.Content[0].(mcp.TextContent).Text != "failed to run pod, missing argument image" {
 828 | 				t.Errorf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 829 | 				return
 830 | 			}
 831 | 		})
 832 | 		podsRunNilNamespace, err := c.callTool("pods_run", map[string]interface{}{"image": "nginx"})
 833 | 		t.Run("pods_run with image and nil namespace runs pod", func(t *testing.T) {
 834 | 			if err != nil {
 835 | 				t.Errorf("call tool failed %v", err)
 836 | 				return
 837 | 			}
 838 | 			if podsRunNilNamespace.IsError {
 839 | 				t.Errorf("call tool failed")
 840 | 				return
 841 | 			}
 842 | 		})
 843 | 		var decodedNilNamespace []unstructured.Unstructured
 844 | 		err = yaml.Unmarshal([]byte(podsRunNilNamespace.Content[0].(mcp.TextContent).Text), &decodedNilNamespace)
 845 | 		t.Run("pods_run with image and nil namespace has yaml content", func(t *testing.T) {
 846 | 			if err != nil {
 847 | 				t.Errorf("invalid tool result content %v", err)
 848 | 				return
 849 | 			}
 850 | 		})
 851 | 		t.Run("pods_run with image and nil namespace returns 1 item (Pod)", func(t *testing.T) {
 852 | 			if len(decodedNilNamespace) != 1 {
 853 | 				t.Errorf("invalid pods count, expected 1, got %v", len(decodedNilNamespace))
 854 | 				return
 855 | 			}
 856 | 			if decodedNilNamespace[0].GetKind() != "Pod" {
 857 | 				t.Errorf("invalid pod kind, expected Pod, got %v", decodedNilNamespace[0].GetKind())
 858 | 				return
 859 | 			}
 860 | 		})
 861 | 		t.Run("pods_run with image and nil namespace returns pod in default", func(t *testing.T) {
 862 | 			if decodedNilNamespace[0].GetNamespace() != "default" {
 863 | 				t.Errorf("invalid pod namespace, expected default, got %v", decodedNilNamespace[0].GetNamespace())
 864 | 				return
 865 | 			}
 866 | 		})
 867 | 		t.Run("pods_run with image and nil namespace returns pod with random name", func(t *testing.T) {
 868 | 			if !strings.HasPrefix(decodedNilNamespace[0].GetName(), "kubernetes-mcp-server-run-") {
 869 | 				t.Errorf("invalid pod name, expected random, got %v", decodedNilNamespace[0].GetName())
 870 | 				return
 871 | 			}
 872 | 		})
 873 | 		t.Run("pods_run with image and nil namespace returns pod with labels", func(t *testing.T) {
 874 | 			labels := decodedNilNamespace[0].Object["metadata"].(map[string]interface{})["labels"].(map[string]interface{})
 875 | 			if labels["app.kubernetes.io/name"] == "" {
 876 | 				t.Errorf("invalid labels, expected app.kubernetes.io/name, got %v", labels)
 877 | 				return
 878 | 			}
 879 | 			if labels["app.kubernetes.io/component"] == "" {
 880 | 				t.Errorf("invalid labels, expected app.kubernetes.io/component, got %v", labels)
 881 | 				return
 882 | 			}
 883 | 			if labels["app.kubernetes.io/managed-by"] != "kubernetes-mcp-server" {
 884 | 				t.Errorf("invalid labels, expected app.kubernetes.io/managed-by, got %v", labels)
 885 | 				return
 886 | 			}
 887 | 			if labels["app.kubernetes.io/part-of"] != "kubernetes-mcp-server-run-sandbox" {
 888 | 				t.Errorf("invalid labels, expected app.kubernetes.io/part-of, got %v", labels)
 889 | 				return
 890 | 			}
 891 | 		})
 892 | 		t.Run("pods_run with image and nil namespace returns pod with nginx container", func(t *testing.T) {
 893 | 			containers := decodedNilNamespace[0].Object["spec"].(map[string]interface{})["containers"].([]interface{})
 894 | 			if containers[0].(map[string]interface{})["image"] != "nginx" {
 895 | 				t.Errorf("invalid container name, expected nginx, got %v", containers[0].(map[string]interface{})["image"])
 896 | 				return
 897 | 			}
 898 | 		})
 899 | 
 900 | 		podsRunNamespaceAndPort, err := c.callTool("pods_run", map[string]interface{}{"image": "nginx", "port": 80})
 901 | 		t.Run("pods_run with image, namespace, and port runs pod", func(t *testing.T) {
 902 | 			if err != nil {
 903 | 				t.Errorf("call tool failed %v", err)
 904 | 				return
 905 | 			}
 906 | 			if podsRunNamespaceAndPort.IsError {
 907 | 				t.Errorf("call tool failed")
 908 | 				return
 909 | 			}
 910 | 		})
 911 | 		var decodedNamespaceAndPort []unstructured.Unstructured
 912 | 		err = yaml.Unmarshal([]byte(podsRunNamespaceAndPort.Content[0].(mcp.TextContent).Text), &decodedNamespaceAndPort)
 913 | 		t.Run("pods_run with image, namespace, and port has yaml content", func(t *testing.T) {
 914 | 			if err != nil {
 915 | 				t.Errorf("invalid tool result content %v", err)
 916 | 				return
 917 | 			}
 918 | 		})
 919 | 		t.Run("pods_run with image, namespace, and port returns 2 items (Pod + Service)", func(t *testing.T) {
 920 | 			if len(decodedNamespaceAndPort) != 2 {
 921 | 				t.Errorf("invalid pods count, expected 2, got %v", len(decodedNamespaceAndPort))
 922 | 				return
 923 | 			}
 924 | 			if decodedNamespaceAndPort[0].GetKind() != "Pod" {
 925 | 				t.Errorf("invalid pod kind, expected Pod, got %v", decodedNamespaceAndPort[0].GetKind())
 926 | 				return
 927 | 			}
 928 | 			if decodedNamespaceAndPort[1].GetKind() != "Service" {
 929 | 				t.Errorf("invalid service kind, expected Service, got %v", decodedNamespaceAndPort[1].GetKind())
 930 | 				return
 931 | 			}
 932 | 		})
 933 | 		t.Run("pods_run with image, namespace, and port returns pod with port", func(t *testing.T) {
 934 | 			containers := decodedNamespaceAndPort[0].Object["spec"].(map[string]interface{})["containers"].([]interface{})
 935 | 			ports := containers[0].(map[string]interface{})["ports"].([]interface{})
 936 | 			if ports[0].(map[string]interface{})["containerPort"] != int64(80) {
 937 | 				t.Errorf("invalid container port, expected 80, got %v", ports[0].(map[string]interface{})["containerPort"])
 938 | 				return
 939 | 			}
 940 | 		})
 941 | 		t.Run("pods_run with image, namespace, and port returns service with port and selector", func(t *testing.T) {
 942 | 			ports := decodedNamespaceAndPort[1].Object["spec"].(map[string]interface{})["ports"].([]interface{})
 943 | 			if ports[0].(map[string]interface{})["port"] != int64(80) {
 944 | 				t.Errorf("invalid service port, expected 80, got %v", ports[0].(map[string]interface{})["port"])
 945 | 				return
 946 | 			}
 947 | 			if ports[0].(map[string]interface{})["targetPort"] != int64(80) {
 948 | 				t.Errorf("invalid service target port, expected 80, got %v", ports[0].(map[string]interface{})["targetPort"])
 949 | 				return
 950 | 			}
 951 | 			selector := decodedNamespaceAndPort[1].Object["spec"].(map[string]interface{})["selector"].(map[string]interface{})
 952 | 			if selector["app.kubernetes.io/name"] == "" {
 953 | 				t.Errorf("invalid service selector, expected app.kubernetes.io/name, got %v", selector)
 954 | 				return
 955 | 			}
 956 | 			if selector["app.kubernetes.io/managed-by"] != "kubernetes-mcp-server" {
 957 | 				t.Errorf("invalid service selector, expected app.kubernetes.io/managed-by, got %v", selector)
 958 | 				return
 959 | 			}
 960 | 			if selector["app.kubernetes.io/part-of"] != "kubernetes-mcp-server-run-sandbox" {
 961 | 				t.Errorf("invalid service selector, expected app.kubernetes.io/part-of, got %v", selector)
 962 | 				return
 963 | 			}
 964 | 		})
 965 | 	})
 966 | }
 967 | 
 968 | func TestPodsRunDenied(t *testing.T) {
 969 | 	deniedResourcesServer := test.Must(config.ReadToml([]byte(`
 970 | 		denied_resources = [ { version = "v1", kind = "Pod" } ]
 971 | 	`)))
 972 | 	testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
 973 | 		c.withEnvTest()
 974 | 		podsRun, _ := c.callTool("pods_run", map[string]interface{}{"image": "nginx"})
 975 | 		t.Run("pods_run has error", func(t *testing.T) {
 976 | 			if !podsRun.IsError {
 977 | 				t.Fatalf("call tool should fail")
 978 | 			}
 979 | 		})
 980 | 		t.Run("pods_run describes denial", func(t *testing.T) {
 981 | 			expectedMessage := "failed to run pod  in namespace : resource not allowed: /v1, Kind=Pod"
 982 | 			if podsRun.Content[0].(mcp.TextContent).Text != expectedMessage {
 983 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsRun.Content[0].(mcp.TextContent).Text)
 984 | 			}
 985 | 		})
 986 | 	})
 987 | }
 988 | 
 989 | func TestPodsRunInOpenShift(t *testing.T) {
 990 | 	testCaseWithContext(t, &mcpContext{before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) {
 991 | 		t.Run("pods_run with image, namespace, and port returns route with port", func(t *testing.T) {
 992 | 			podsRunInOpenShift, err := c.callTool("pods_run", map[string]interface{}{"image": "nginx", "port": 80})
 993 | 			if err != nil {
 994 | 				t.Errorf("call tool failed %v", err)
 995 | 				return
 996 | 			}
 997 | 			if podsRunInOpenShift.IsError {
 998 | 				t.Errorf("call tool failed")
 999 | 				return
1000 | 			}
1001 | 			var decodedPodServiceRoute []unstructured.Unstructured
1002 | 			err = yaml.Unmarshal([]byte(podsRunInOpenShift.Content[0].(mcp.TextContent).Text), &decodedPodServiceRoute)
1003 | 			if err != nil {
1004 | 				t.Errorf("invalid tool result content %v", err)
1005 | 				return
1006 | 			}
1007 | 			if len(decodedPodServiceRoute) != 3 {
1008 | 				t.Errorf("invalid pods count, expected 3, got %v", len(decodedPodServiceRoute))
1009 | 				return
1010 | 			}
1011 | 			if decodedPodServiceRoute[2].GetKind() != "Route" {
1012 | 				t.Errorf("invalid route kind, expected Route, got %v", decodedPodServiceRoute[2].GetKind())
1013 | 				return
1014 | 			}
1015 | 			targetPort := decodedPodServiceRoute[2].Object["spec"].(map[string]interface{})["port"].(map[string]interface{})["targetPort"].(int64)
1016 | 			if targetPort != 80 {
1017 | 				t.Errorf("invalid route target port, expected 80, got %v", targetPort)
1018 | 				return
1019 | 			}
1020 | 		})
1021 | 	})
1022 | }
1023 | 
1024 | func TestPodsListWithLabelSelector(t *testing.T) {
1025 | 	testCase(t, func(c *mcpContext) {
1026 | 		c.withEnvTest()
1027 | 		kc := c.newKubernetesClient()
1028 | 		// Create pods with labels
1029 | 		_, _ = kc.CoreV1().Pods("default").Create(c.ctx, &corev1.Pod{
1030 | 			ObjectMeta: metav1.ObjectMeta{
1031 | 				Name:   "pod-with-labels",
1032 | 				Labels: map[string]string{"app": "test", "env": "dev"},
1033 | 			},
1034 | 			Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
1035 | 		}, metav1.CreateOptions{})
1036 | 		_, _ = kc.CoreV1().Pods("ns-1").Create(c.ctx, &corev1.Pod{
1037 | 			ObjectMeta: metav1.ObjectMeta{
1038 | 				Name:   "another-pod-with-labels",
1039 | 				Labels: map[string]string{"app": "test", "env": "prod"},
1040 | 			},
1041 | 			Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
1042 | 		}, metav1.CreateOptions{})
1043 | 
1044 | 		// Test pods_list with label selector
1045 | 		t.Run("pods_list with label selector returns filtered pods", func(t *testing.T) {
1046 | 			toolResult, err := c.callTool("pods_list", map[string]interface{}{
1047 | 				"labelSelector": "app=test",
1048 | 			})
1049 | 			if err != nil {
1050 | 				t.Fatalf("call tool failed %v", err)
1051 | 				return
1052 | 			}
1053 | 			if toolResult.IsError {
1054 | 				t.Fatalf("call tool failed")
1055 | 				return
1056 | 			}
1057 | 			var decoded []unstructured.Unstructured
1058 | 			err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
1059 | 			if err != nil {
1060 | 				t.Fatalf("invalid tool result content %v", err)
1061 | 				return
1062 | 			}
1063 | 			if len(decoded) != 2 {
1064 | 				t.Fatalf("invalid pods count, expected 2, got %v", len(decoded))
1065 | 				return
1066 | 			}
1067 | 		})
1068 | 
1069 | 		// Test pods_list_in_namespace with label selector
1070 | 		t.Run("pods_list_in_namespace with label selector returns filtered pods", func(t *testing.T) {
1071 | 			toolResult, err := c.callTool("pods_list_in_namespace", map[string]interface{}{
1072 | 				"namespace":     "ns-1",
1073 | 				"labelSelector": "env=prod",
1074 | 			})
1075 | 			if err != nil {
1076 | 				t.Fatalf("call tool failed %v", err)
1077 | 				return
1078 | 			}
1079 | 			if toolResult.IsError {
1080 | 				t.Fatalf("call tool failed")
1081 | 				return
1082 | 			}
1083 | 			var decoded []unstructured.Unstructured
1084 | 			err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
1085 | 			if err != nil {
1086 | 				t.Fatalf("invalid tool result content %v", err)
1087 | 				return
1088 | 			}
1089 | 			if len(decoded) != 1 {
1090 | 				t.Fatalf("invalid pods count, expected 1, got %v", len(decoded))
1091 | 				return
1092 | 			}
1093 | 			if decoded[0].GetName() != "another-pod-with-labels" {
1094 | 				t.Fatalf("invalid pod name, expected another-pod-with-labels, got %v", decoded[0].GetName())
1095 | 				return
1096 | 			}
1097 | 		})
1098 | 
1099 | 		// Test multiple label selectors
1100 | 		t.Run("pods_list with multiple label selectors returns filtered pods", func(t *testing.T) {
1101 | 			toolResult, err := c.callTool("pods_list", map[string]interface{}{
1102 | 				"labelSelector": "app=test,env=prod",
1103 | 			})
1104 | 			if err != nil {
1105 | 				t.Fatalf("call tool failed %v", err)
1106 | 				return
1107 | 			}
1108 | 			if toolResult.IsError {
1109 | 				t.Fatalf("call tool failed")
1110 | 				return
1111 | 			}
1112 | 			var decoded []unstructured.Unstructured
1113 | 			err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
1114 | 			if err != nil {
1115 | 				t.Fatalf("invalid tool result content %v", err)
1116 | 				return
1117 | 			}
1118 | 			if len(decoded) != 1 {
1119 | 				t.Fatalf("invalid pods count, expected 1, got %v", len(decoded))
1120 | 				return
1121 | 			}
1122 | 			if decoded[0].GetName() != "another-pod-with-labels" {
1123 | 				t.Fatalf("invalid pod name, expected another-pod-with-labels, got %v", decoded[0].GetName())
1124 | 				return
1125 | 			}
1126 | 		})
1127 | 	})
1128 | }
1129 | 
```

--------------------------------------------------------------------------------
/pkg/http/http_test.go:
--------------------------------------------------------------------------------

```go
   1 | package http
   2 | 
   3 | import (
   4 | 	"bufio"
   5 | 	"bytes"
   6 | 	"context"
   7 | 	"crypto/rand"
   8 | 	"crypto/rsa"
   9 | 	"flag"
  10 | 	"fmt"
  11 | 	"io"
  12 | 	"net"
  13 | 	"net/http"
  14 | 	"net/http/httptest"
  15 | 	"os"
  16 | 	"regexp"
  17 | 	"strconv"
  18 | 	"strings"
  19 | 	"testing"
  20 | 	"time"
  21 | 
  22 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
  23 | 	"github.com/coreos/go-oidc/v3/oidc"
  24 | 	"github.com/coreos/go-oidc/v3/oidc/oidctest"
  25 | 	"golang.org/x/sync/errgroup"
  26 | 	"k8s.io/klog/v2"
  27 | 	"k8s.io/klog/v2/textlogger"
  28 | 
  29 | 	"github.com/containers/kubernetes-mcp-server/pkg/config"
  30 | 	"github.com/containers/kubernetes-mcp-server/pkg/mcp"
  31 | )
  32 | 
  33 | type httpContext struct {
  34 | 	klogState       klog.State
  35 | 	mockServer      *test.MockServer
  36 | 	LogBuffer       bytes.Buffer
  37 | 	HttpAddress     string             // HTTP server address
  38 | 	timeoutCancel   context.CancelFunc // Release resources if test completes before the timeout
  39 | 	StopServer      context.CancelFunc
  40 | 	WaitForShutdown func() error
  41 | 	StaticConfig    *config.StaticConfig
  42 | 	OidcProvider    *oidc.Provider
  43 | }
  44 | 
  45 | const tokenReviewSuccessful = `
  46 | 	{
  47 | 		"kind": "TokenReview",
  48 | 		"apiVersion": "authentication.k8s.io/v1",
  49 | 		"spec": {"token": "valid-token"},
  50 | 		"status": {
  51 | 			"authenticated": true,
  52 | 			"user": {
  53 | 				"username": "test-user",
  54 | 				"groups": ["system:authenticated"]
  55 | 			}
  56 | 		}
  57 | 	}`
  58 | 
  59 | func (c *httpContext) beforeEach(t *testing.T) {
  60 | 	t.Helper()
  61 | 	http.DefaultClient.Timeout = 10 * time.Second
  62 | 	if c.StaticConfig == nil {
  63 | 		c.StaticConfig = config.Default()
  64 | 	}
  65 | 	c.mockServer = test.NewMockServer()
  66 | 	// Fake Kubernetes configuration
  67 | 	c.StaticConfig.KubeConfig = c.mockServer.KubeconfigFile(t)
  68 | 	// Capture logging
  69 | 	c.klogState = klog.CaptureState()
  70 | 	flags := flag.NewFlagSet("test", flag.ContinueOnError)
  71 | 	klog.InitFlags(flags)
  72 | 	_ = flags.Set("v", "5")
  73 | 	klog.SetLogger(textlogger.NewLogger(textlogger.NewConfig(textlogger.Verbosity(5), textlogger.Output(&c.LogBuffer))))
  74 | 	// Start server in random port
  75 | 	ln, err := net.Listen("tcp", "0.0.0.0:0")
  76 | 	if err != nil {
  77 | 		t.Fatalf("Failed to find random port for HTTP server: %v", err)
  78 | 	}
  79 | 	c.HttpAddress = ln.Addr().String()
  80 | 	if randomPortErr := ln.Close(); randomPortErr != nil {
  81 | 		t.Fatalf("Failed to close random port listener: %v", randomPortErr)
  82 | 	}
  83 | 	c.StaticConfig.Port = fmt.Sprintf("%d", ln.Addr().(*net.TCPAddr).Port)
  84 | 	mcpServer, err := mcp.NewServer(mcp.Configuration{StaticConfig: c.StaticConfig})
  85 | 	if err != nil {
  86 | 		t.Fatalf("Failed to create MCP server: %v", err)
  87 | 	}
  88 | 	var timeoutCtx, cancelCtx context.Context
  89 | 	timeoutCtx, c.timeoutCancel = context.WithTimeout(t.Context(), 10*time.Second)
  90 | 	group, gc := errgroup.WithContext(timeoutCtx)
  91 | 	cancelCtx, c.StopServer = context.WithCancel(gc)
  92 | 	group.Go(func() error { return Serve(cancelCtx, mcpServer, c.StaticConfig, c.OidcProvider, nil) })
  93 | 	c.WaitForShutdown = group.Wait
  94 | 	// Wait for HTTP server to start (using net)
  95 | 	for i := 0; i < 10; i++ {
  96 | 		conn, err := net.Dial("tcp", c.HttpAddress)
  97 | 		if err == nil {
  98 | 			_ = conn.Close()
  99 | 			break
 100 | 		}
 101 | 		time.Sleep(50 * time.Millisecond) // Wait before retrying
 102 | 	}
 103 | }
 104 | 
 105 | func (c *httpContext) afterEach(t *testing.T) {
 106 | 	t.Helper()
 107 | 	c.mockServer.Close()
 108 | 	c.StopServer()
 109 | 	err := c.WaitForShutdown()
 110 | 	if err != nil {
 111 | 		t.Errorf("HTTP server did not shut down gracefully: %v", err)
 112 | 	}
 113 | 	c.timeoutCancel()
 114 | 	c.klogState.Restore()
 115 | 	_ = os.Setenv("KUBECONFIG", "")
 116 | }
 117 | 
 118 | func testCase(t *testing.T, test func(c *httpContext)) {
 119 | 	testCaseWithContext(t, &httpContext{}, test)
 120 | }
 121 | 
 122 | func testCaseWithContext(t *testing.T, httpCtx *httpContext, test func(c *httpContext)) {
 123 | 	httpCtx.beforeEach(t)
 124 | 	t.Cleanup(func() { httpCtx.afterEach(t) })
 125 | 	test(httpCtx)
 126 | }
 127 | 
 128 | type OidcTestServer struct {
 129 | 	*rsa.PrivateKey
 130 | 	*oidc.Provider
 131 | 	*httptest.Server
 132 | 	TokenEndpointHandler http.HandlerFunc
 133 | }
 134 | 
 135 | func NewOidcTestServer(t *testing.T) (oidcTestServer *OidcTestServer) {
 136 | 	t.Helper()
 137 | 	var err error
 138 | 	oidcTestServer = &OidcTestServer{}
 139 | 	oidcTestServer.PrivateKey, err = rsa.GenerateKey(rand.Reader, 2048)
 140 | 	if err != nil {
 141 | 		t.Fatalf("failed to generate private key for oidc: %v", err)
 142 | 	}
 143 | 	oidcServer := &oidctest.Server{
 144 | 		Algorithms: []string{oidc.RS256, oidc.ES256},
 145 | 		PublicKeys: []oidctest.PublicKey{
 146 | 			{
 147 | 				PublicKey: oidcTestServer.Public(),
 148 | 				KeyID:     "test-oidc-key-id",
 149 | 				Algorithm: oidc.RS256,
 150 | 			},
 151 | 		},
 152 | 	}
 153 | 	oidcTestServer.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 154 | 		if r.URL.Path == "/token" && oidcTestServer.TokenEndpointHandler != nil {
 155 | 			oidcTestServer.TokenEndpointHandler.ServeHTTP(w, r)
 156 | 			return
 157 | 		}
 158 | 		oidcServer.ServeHTTP(w, r)
 159 | 	}))
 160 | 	oidcServer.SetIssuer(oidcTestServer.URL)
 161 | 	oidcTestServer.Provider, err = oidc.NewProvider(t.Context(), oidcTestServer.URL)
 162 | 	if err != nil {
 163 | 		t.Fatalf("failed to create OIDC provider: %v", err)
 164 | 	}
 165 | 	return
 166 | }
 167 | 
 168 | func TestGracefulShutdown(t *testing.T) {
 169 | 	testCase(t, func(ctx *httpContext) {
 170 | 		ctx.StopServer()
 171 | 		err := ctx.WaitForShutdown()
 172 | 		t.Run("Stops gracefully", func(t *testing.T) {
 173 | 			if err != nil {
 174 | 				t.Errorf("Expected graceful shutdown, but got error: %v", err)
 175 | 			}
 176 | 		})
 177 | 		t.Run("Stops on context cancel", func(t *testing.T) {
 178 | 			if !strings.Contains(ctx.LogBuffer.String(), "Context cancelled, initiating graceful shutdown") {
 179 | 				t.Errorf("Context cancelled, initiating graceful shutdown, got: %s", ctx.LogBuffer.String())
 180 | 			}
 181 | 		})
 182 | 		t.Run("Starts server shutdown", func(t *testing.T) {
 183 | 			if !strings.Contains(ctx.LogBuffer.String(), "Shutting down HTTP server gracefully") {
 184 | 				t.Errorf("Expected graceful shutdown log, got: %s", ctx.LogBuffer.String())
 185 | 			}
 186 | 		})
 187 | 		t.Run("Server shutdown completes", func(t *testing.T) {
 188 | 			if !strings.Contains(ctx.LogBuffer.String(), "HTTP server shutdown complete") {
 189 | 				t.Errorf("Expected HTTP server shutdown completed log, got: %s", ctx.LogBuffer.String())
 190 | 			}
 191 | 		})
 192 | 	})
 193 | }
 194 | 
 195 | func TestSseTransport(t *testing.T) {
 196 | 	testCase(t, func(ctx *httpContext) {
 197 | 		sseResp, sseErr := http.Get(fmt.Sprintf("http://%s/sse", ctx.HttpAddress))
 198 | 		t.Cleanup(func() { _ = sseResp.Body.Close() })
 199 | 		t.Run("Exposes SSE endpoint at /sse", func(t *testing.T) {
 200 | 			if sseErr != nil {
 201 | 				t.Fatalf("Failed to get SSE endpoint: %v", sseErr)
 202 | 			}
 203 | 			if sseResp.StatusCode != http.StatusOK {
 204 | 				t.Errorf("Expected HTTP 200 OK, got %d", sseResp.StatusCode)
 205 | 			}
 206 | 		})
 207 | 		t.Run("SSE endpoint returns text/event-stream content type", func(t *testing.T) {
 208 | 			if sseResp.Header.Get("Content-Type") != "text/event-stream" {
 209 | 				t.Errorf("Expected Content-Type text/event-stream, got %s", sseResp.Header.Get("Content-Type"))
 210 | 			}
 211 | 		})
 212 | 		responseReader := bufio.NewReader(sseResp.Body)
 213 | 		event, eventErr := responseReader.ReadString('\n')
 214 | 		endpoint, endpointErr := responseReader.ReadString('\n')
 215 | 		t.Run("SSE endpoint returns stream with messages endpoint", func(t *testing.T) {
 216 | 			if eventErr != nil {
 217 | 				t.Fatalf("Failed to read SSE response body (event): %v", eventErr)
 218 | 			}
 219 | 			if event != "event: endpoint\n" {
 220 | 				t.Errorf("Expected SSE event 'endpoint', got %s", event)
 221 | 			}
 222 | 			if endpointErr != nil {
 223 | 				t.Fatalf("Failed to read SSE response body (endpoint): %v", endpointErr)
 224 | 			}
 225 | 			if !strings.HasPrefix(endpoint, "data: /message?sessionId=") {
 226 | 				t.Errorf("Expected SSE data: '/message', got %s", endpoint)
 227 | 			}
 228 | 		})
 229 | 		messageResp, messageErr := http.Post(
 230 | 			fmt.Sprintf("http://%s/message?sessionId=%s", ctx.HttpAddress, strings.TrimSpace(endpoint[25:])),
 231 | 			"application/json",
 232 | 			bytes.NewBufferString("{}"),
 233 | 		)
 234 | 		t.Cleanup(func() { _ = messageResp.Body.Close() })
 235 | 		t.Run("Exposes message endpoint at /message", func(t *testing.T) {
 236 | 			if messageErr != nil {
 237 | 				t.Fatalf("Failed to get message endpoint: %v", messageErr)
 238 | 			}
 239 | 			if messageResp.StatusCode != http.StatusAccepted {
 240 | 				t.Errorf("Expected HTTP 202 OK, got %d", messageResp.StatusCode)
 241 | 			}
 242 | 		})
 243 | 	})
 244 | }
 245 | 
 246 | func TestStreamableHttpTransport(t *testing.T) {
 247 | 	testCase(t, func(ctx *httpContext) {
 248 | 		mcpGetResp, mcpGetErr := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress))
 249 | 		t.Cleanup(func() { _ = mcpGetResp.Body.Close() })
 250 | 		t.Run("Exposes MCP GET endpoint at /mcp", func(t *testing.T) {
 251 | 			if mcpGetErr != nil {
 252 | 				t.Fatalf("Failed to get MCP endpoint: %v", mcpGetErr)
 253 | 			}
 254 | 			if mcpGetResp.StatusCode != http.StatusOK {
 255 | 				t.Errorf("Expected HTTP 200 OK, got %d", mcpGetResp.StatusCode)
 256 | 			}
 257 | 		})
 258 | 		t.Run("MCP GET endpoint returns text/event-stream content type", func(t *testing.T) {
 259 | 			if mcpGetResp.Header.Get("Content-Type") != "text/event-stream" {
 260 | 				t.Errorf("Expected Content-Type text/event-stream (GET), got %s", mcpGetResp.Header.Get("Content-Type"))
 261 | 			}
 262 | 		})
 263 | 		mcpPostResp, mcpPostErr := http.Post(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), "application/json", bytes.NewBufferString("{}"))
 264 | 		t.Cleanup(func() { _ = mcpPostResp.Body.Close() })
 265 | 		t.Run("Exposes MCP POST endpoint at /mcp", func(t *testing.T) {
 266 | 			if mcpPostErr != nil {
 267 | 				t.Fatalf("Failed to post to MCP endpoint: %v", mcpPostErr)
 268 | 			}
 269 | 			if mcpPostResp.StatusCode != http.StatusOK {
 270 | 				t.Errorf("Expected HTTP 200 OK, got %d", mcpPostResp.StatusCode)
 271 | 			}
 272 | 		})
 273 | 		t.Run("MCP POST endpoint returns application/json content type", func(t *testing.T) {
 274 | 			if mcpPostResp.Header.Get("Content-Type") != "application/json" {
 275 | 				t.Errorf("Expected Content-Type application/json (POST), got %s", mcpPostResp.Header.Get("Content-Type"))
 276 | 			}
 277 | 		})
 278 | 	})
 279 | }
 280 | 
 281 | func TestHealthCheck(t *testing.T) {
 282 | 	testCase(t, func(ctx *httpContext) {
 283 | 		t.Run("Exposes health check endpoint at /healthz", func(t *testing.T) {
 284 | 			resp, err := http.Get(fmt.Sprintf("http://%s/healthz", ctx.HttpAddress))
 285 | 			if err != nil {
 286 | 				t.Fatalf("Failed to get health check endpoint: %v", err)
 287 | 			}
 288 | 			t.Cleanup(func() { _ = resp.Body.Close })
 289 | 			if resp.StatusCode != http.StatusOK {
 290 | 				t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
 291 | 			}
 292 | 		})
 293 | 	})
 294 | 	// Health exposed even when require Authorization
 295 | 	testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
 296 | 		resp, err := http.Get(fmt.Sprintf("http://%s/healthz", ctx.HttpAddress))
 297 | 		if err != nil {
 298 | 			t.Fatalf("Failed to get health check endpoint with OAuth: %v", err)
 299 | 		}
 300 | 		t.Cleanup(func() { _ = resp.Body.Close() })
 301 | 		t.Run("Health check with OAuth returns HTTP 200 OK", func(t *testing.T) {
 302 | 			if resp.StatusCode != http.StatusOK {
 303 | 				t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
 304 | 			}
 305 | 		})
 306 | 	})
 307 | }
 308 | 
 309 | func TestWellKnownReverseProxy(t *testing.T) {
 310 | 	cases := []string{
 311 | 		".well-known/oauth-authorization-server",
 312 | 		".well-known/oauth-protected-resource",
 313 | 		".well-known/openid-configuration",
 314 | 	}
 315 | 	// With No Authorization URL configured
 316 | 	testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
 317 | 		for _, path := range cases {
 318 | 			resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
 319 | 			t.Cleanup(func() { _ = resp.Body.Close() })
 320 | 			t.Run("Protected resource '"+path+"' without Authorization URL returns 404 - Not Found", func(t *testing.T) {
 321 | 				if err != nil {
 322 | 					t.Fatalf("Failed to get %s endpoint: %v", path, err)
 323 | 				}
 324 | 				if resp.StatusCode != http.StatusNotFound {
 325 | 					t.Errorf("Expected HTTP 404 Not Found, got %d", resp.StatusCode)
 326 | 				}
 327 | 			})
 328 | 		}
 329 | 	})
 330 | 	// With Authorization URL configured but invalid payload
 331 | 	invalidPayloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 332 | 		w.Header().Set("Content-Type", "application/json")
 333 | 		_, _ = w.Write([]byte(`NOT A JSON PAYLOAD`))
 334 | 	}))
 335 | 	t.Cleanup(invalidPayloadServer.Close)
 336 | 	invalidPayloadConfig := &config.StaticConfig{
 337 | 		AuthorizationURL:        invalidPayloadServer.URL,
 338 | 		RequireOAuth:            true,
 339 | 		ValidateToken:           true,
 340 | 		ClusterProviderStrategy: config.ClusterProviderKubeConfig,
 341 | 	}
 342 | 	testCaseWithContext(t, &httpContext{StaticConfig: invalidPayloadConfig}, func(ctx *httpContext) {
 343 | 		for _, path := range cases {
 344 | 			resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
 345 | 			t.Cleanup(func() { _ = resp.Body.Close() })
 346 | 			t.Run("Protected resource '"+path+"' with invalid Authorization URL payload returns 500 - Internal Server Error", func(t *testing.T) {
 347 | 				if err != nil {
 348 | 					t.Fatalf("Failed to get %s endpoint: %v", path, err)
 349 | 				}
 350 | 				if resp.StatusCode != http.StatusInternalServerError {
 351 | 					t.Errorf("Expected HTTP 500 Internal Server Error, got %d", resp.StatusCode)
 352 | 				}
 353 | 			})
 354 | 		}
 355 | 	})
 356 | 	// With Authorization URL configured and valid payload
 357 | 	testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 358 | 		if !strings.HasPrefix(r.URL.EscapedPath(), "/.well-known/") {
 359 | 			http.NotFound(w, r)
 360 | 			return
 361 | 		}
 362 | 		w.Header().Set("Content-Type", "application/json")
 363 | 		_, _ = w.Write([]byte(`{"issuer": "https://example.com","scopes_supported":["mcp-server"]}`))
 364 | 	}))
 365 | 	t.Cleanup(testServer.Close)
 366 | 	staticConfig := &config.StaticConfig{
 367 | 		AuthorizationURL:        testServer.URL,
 368 | 		RequireOAuth:            true,
 369 | 		ValidateToken:           true,
 370 | 		ClusterProviderStrategy: config.ClusterProviderKubeConfig,
 371 | 	}
 372 | 	testCaseWithContext(t, &httpContext{StaticConfig: staticConfig}, func(ctx *httpContext) {
 373 | 		for _, path := range cases {
 374 | 			resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
 375 | 			t.Cleanup(func() { _ = resp.Body.Close() })
 376 | 			t.Run("Exposes "+path+" endpoint", func(t *testing.T) {
 377 | 				if err != nil {
 378 | 					t.Fatalf("Failed to get %s endpoint: %v", path, err)
 379 | 				}
 380 | 				if resp.StatusCode != http.StatusOK {
 381 | 					t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
 382 | 				}
 383 | 			})
 384 | 			t.Run(path+" returns application/json content type", func(t *testing.T) {
 385 | 				if resp.Header.Get("Content-Type") != "application/json" {
 386 | 					t.Errorf("Expected Content-Type application/json, got %s", resp.Header.Get("Content-Type"))
 387 | 				}
 388 | 			})
 389 | 		}
 390 | 	})
 391 | }
 392 | 
 393 | func TestWellKnownHeaderPropagation(t *testing.T) {
 394 | 	cases := []string{
 395 | 		".well-known/oauth-authorization-server",
 396 | 		".well-known/oauth-protected-resource",
 397 | 		".well-known/openid-configuration",
 398 | 	}
 399 | 	var receivedRequestHeaders http.Header
 400 | 	testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 401 | 		if !strings.HasPrefix(r.URL.EscapedPath(), "/.well-known/") {
 402 | 			http.NotFound(w, r)
 403 | 			return
 404 | 		}
 405 | 		// Capture headers received from the proxy
 406 | 		receivedRequestHeaders = r.Header.Clone()
 407 | 		// Set response headers that should be propagated back
 408 | 		w.Header().Set("Content-Type", "application/json")
 409 | 		w.Header().Set("Access-Control-Allow-Origin", "https://example.com")
 410 | 		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
 411 | 		w.Header().Set("Cache-Control", "no-cache")
 412 | 		w.Header().Set("X-Custom-Backend-Header", "backend-value")
 413 | 		_, _ = w.Write([]byte(`{"issuer": "https://example.com"}`))
 414 | 	}))
 415 | 	t.Cleanup(testServer.Close)
 416 | 	staticConfig := &config.StaticConfig{
 417 | 		AuthorizationURL:        testServer.URL,
 418 | 		RequireOAuth:            true,
 419 | 		ValidateToken:           true,
 420 | 		ClusterProviderStrategy: config.ClusterProviderKubeConfig,
 421 | 	}
 422 | 	testCaseWithContext(t, &httpContext{StaticConfig: staticConfig}, func(ctx *httpContext) {
 423 | 		for _, path := range cases {
 424 | 			receivedRequestHeaders = nil
 425 | 			req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path), nil)
 426 | 			if err != nil {
 427 | 				t.Fatalf("Failed to create request: %v", err)
 428 | 			}
 429 | 			// Add various headers to test propagation
 430 | 			req.Header.Set("Origin", "https://example.com")
 431 | 			req.Header.Set("User-Agent", "Test-Agent/1.0")
 432 | 			req.Header.Set("Accept", "application/json")
 433 | 			req.Header.Set("Accept-Language", "en-US")
 434 | 			req.Header.Set("X-Custom-Header", "custom-value")
 435 | 			req.Header.Set("Referer", "https://example.com/page")
 436 | 
 437 | 			resp, err := http.DefaultClient.Do(req)
 438 | 			if err != nil {
 439 | 				t.Fatalf("Failed to get %s endpoint: %v", path, err)
 440 | 			}
 441 | 			t.Cleanup(func() { _ = resp.Body.Close() })
 442 | 
 443 | 			t.Run("Well-known proxy propagates Origin header to backend for "+path, func(t *testing.T) {
 444 | 				if receivedRequestHeaders == nil {
 445 | 					t.Fatal("Backend did not receive any headers")
 446 | 				}
 447 | 				if receivedRequestHeaders.Get("Origin") != "https://example.com" {
 448 | 					t.Errorf("Expected Origin header 'https://example.com', got '%s'", receivedRequestHeaders.Get("Origin"))
 449 | 				}
 450 | 			})
 451 | 
 452 | 			t.Run("Well-known proxy propagates User-Agent header to backend for "+path, func(t *testing.T) {
 453 | 				if receivedRequestHeaders.Get("User-Agent") != "Test-Agent/1.0" {
 454 | 					t.Errorf("Expected User-Agent header 'Test-Agent/1.0', got '%s'", receivedRequestHeaders.Get("User-Agent"))
 455 | 				}
 456 | 			})
 457 | 
 458 | 			t.Run("Well-known proxy propagates Accept header to backend for "+path, func(t *testing.T) {
 459 | 				if receivedRequestHeaders.Get("Accept") != "application/json" {
 460 | 					t.Errorf("Expected Accept header 'application/json', got '%s'", receivedRequestHeaders.Get("Accept"))
 461 | 				}
 462 | 			})
 463 | 
 464 | 			t.Run("Well-known proxy propagates Accept-Language header to backend for "+path, func(t *testing.T) {
 465 | 				if receivedRequestHeaders.Get("Accept-Language") != "en-US" {
 466 | 					t.Errorf("Expected Accept-Language header 'en-US', got '%s'", receivedRequestHeaders.Get("Accept-Language"))
 467 | 				}
 468 | 			})
 469 | 
 470 | 			t.Run("Well-known proxy propagates custom headers to backend for "+path, func(t *testing.T) {
 471 | 				if receivedRequestHeaders.Get("X-Custom-Header") != "custom-value" {
 472 | 					t.Errorf("Expected X-Custom-Header 'custom-value', got '%s'", receivedRequestHeaders.Get("X-Custom-Header"))
 473 | 				}
 474 | 			})
 475 | 
 476 | 			t.Run("Well-known proxy propagates Referer header to backend for "+path, func(t *testing.T) {
 477 | 				if receivedRequestHeaders.Get("Referer") != "https://example.com/page" {
 478 | 					t.Errorf("Expected Referer header 'https://example.com/page', got '%s'", receivedRequestHeaders.Get("Referer"))
 479 | 				}
 480 | 			})
 481 | 
 482 | 			t.Run("Well-known proxy returns Access-Control-Allow-Origin from backend for "+path, func(t *testing.T) {
 483 | 				if resp.Header.Get("Access-Control-Allow-Origin") != "https://example.com" {
 484 | 					t.Errorf("Expected Access-Control-Allow-Origin header 'https://example.com', got '%s'", resp.Header.Get("Access-Control-Allow-Origin"))
 485 | 				}
 486 | 			})
 487 | 
 488 | 			t.Run("Well-known proxy returns Access-Control-Allow-Methods from backend for "+path, func(t *testing.T) {
 489 | 				if resp.Header.Get("Access-Control-Allow-Methods") != "GET, POST, OPTIONS" {
 490 | 					t.Errorf("Expected Access-Control-Allow-Methods header 'GET, POST, OPTIONS', got '%s'", resp.Header.Get("Access-Control-Allow-Methods"))
 491 | 				}
 492 | 			})
 493 | 
 494 | 			t.Run("Well-known proxy returns Cache-Control from backend for "+path, func(t *testing.T) {
 495 | 				if resp.Header.Get("Cache-Control") != "no-cache" {
 496 | 					t.Errorf("Expected Cache-Control header 'no-cache', got '%s'", resp.Header.Get("Cache-Control"))
 497 | 				}
 498 | 			})
 499 | 
 500 | 			t.Run("Well-known proxy returns custom response headers from backend for "+path, func(t *testing.T) {
 501 | 				if resp.Header.Get("X-Custom-Backend-Header") != "backend-value" {
 502 | 					t.Errorf("Expected X-Custom-Backend-Header 'backend-value', got '%s'", resp.Header.Get("X-Custom-Backend-Header"))
 503 | 				}
 504 | 			})
 505 | 		}
 506 | 	})
 507 | }
 508 | 
 509 | func TestWellKnownOverrides(t *testing.T) {
 510 | 	cases := []string{
 511 | 		".well-known/oauth-authorization-server",
 512 | 		".well-known/oauth-protected-resource",
 513 | 		".well-known/openid-configuration",
 514 | 	}
 515 | 	testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 516 | 		if !strings.HasPrefix(r.URL.EscapedPath(), "/.well-known/") {
 517 | 			http.NotFound(w, r)
 518 | 			return
 519 | 		}
 520 | 		w.Header().Set("Content-Type", "application/json")
 521 | 		_, _ = w.Write([]byte(`
 522 | 			{
 523 | 				"issuer": "https://localhost",
 524 | 				"registration_endpoint": "https://localhost/clients-registrations/openid-connect",
 525 | 				"require_request_uri_registration": true,
 526 | 				"scopes_supported":["scope-1", "scope-2"]
 527 | 			}`))
 528 | 	}))
 529 | 	t.Cleanup(testServer.Close)
 530 | 	baseConfig := config.StaticConfig{
 531 | 		AuthorizationURL:        testServer.URL,
 532 | 		RequireOAuth:            true,
 533 | 		ValidateToken:           true,
 534 | 		ClusterProviderStrategy: config.ClusterProviderKubeConfig,
 535 | 	}
 536 | 	// With Dynamic Client Registration disabled
 537 | 	disableDynamicRegistrationConfig := baseConfig
 538 | 	disableDynamicRegistrationConfig.DisableDynamicClientRegistration = true
 539 | 	testCaseWithContext(t, &httpContext{StaticConfig: &disableDynamicRegistrationConfig}, func(ctx *httpContext) {
 540 | 		for _, path := range cases {
 541 | 			resp, _ := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
 542 | 			t.Cleanup(func() { _ = resp.Body.Close() })
 543 | 			body, err := io.ReadAll(resp.Body)
 544 | 			if err != nil {
 545 | 				t.Fatalf("Failed to read response body: %v", err)
 546 | 			}
 547 | 			t.Run("DisableDynamicClientRegistration removes registration_endpoint field", func(t *testing.T) {
 548 | 				if strings.Contains(string(body), "registration_endpoint") {
 549 | 					t.Error("Expected registration_endpoint to be removed, but it was found in the response")
 550 | 				}
 551 | 			})
 552 | 			t.Run("DisableDynamicClientRegistration sets require_request_uri_registration = false", func(t *testing.T) {
 553 | 				if !strings.Contains(string(body), `"require_request_uri_registration":false`) {
 554 | 					t.Error("Expected require_request_uri_registration to be false, but it was not found in the response")
 555 | 				}
 556 | 			})
 557 | 			t.Run("DisableDynamicClientRegistration includes/preserves scopes_supported", func(t *testing.T) {
 558 | 				if !strings.Contains(string(body), `"scopes_supported":["scope-1","scope-2"]`) {
 559 | 					t.Error("Expected scopes_supported to be present, but it was not found in the response")
 560 | 				}
 561 | 			})
 562 | 		}
 563 | 	})
 564 | 	// With overrides for OAuth scopes (client/frontend)
 565 | 	oAuthScopesConfig := baseConfig
 566 | 	oAuthScopesConfig.OAuthScopes = []string{"openid", "mcp-server"}
 567 | 	testCaseWithContext(t, &httpContext{StaticConfig: &oAuthScopesConfig}, func(ctx *httpContext) {
 568 | 		for _, path := range cases {
 569 | 			resp, _ := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
 570 | 			t.Cleanup(func() { _ = resp.Body.Close() })
 571 | 			body, err := io.ReadAll(resp.Body)
 572 | 			if err != nil {
 573 | 				t.Fatalf("Failed to read response body: %v", err)
 574 | 			}
 575 | 			t.Run("OAuthScopes overrides scopes_supported", func(t *testing.T) {
 576 | 				if !strings.Contains(string(body), `"scopes_supported":["openid","mcp-server"]`) {
 577 | 					t.Errorf("Expected scopes_supported to be overridden, but original was preserved, response: %s", string(body))
 578 | 				}
 579 | 			})
 580 | 			t.Run("OAuthScopes preserves other fields", func(t *testing.T) {
 581 | 				if !strings.Contains(string(body), `"issuer":"https://localhost"`) {
 582 | 					t.Errorf("Expected issuer to be preserved, but got: %s", string(body))
 583 | 				}
 584 | 				if !strings.Contains(string(body), `"registration_endpoint":"https://localhost`) {
 585 | 					t.Errorf("Expected registration_endpoint to be preserved, but got: %s", string(body))
 586 | 				}
 587 | 				if !strings.Contains(string(body), `"require_request_uri_registration":true`) {
 588 | 					t.Error("Expected require_request_uri_registration to be true, but it was not found in the response")
 589 | 				}
 590 | 			})
 591 | 		}
 592 | 	})
 593 | }
 594 | 
 595 | func TestMiddlewareLogging(t *testing.T) {
 596 | 	testCase(t, func(ctx *httpContext) {
 597 | 		_, _ = http.Get(fmt.Sprintf("http://%s/.well-known/oauth-protected-resource", ctx.HttpAddress))
 598 | 		t.Run("Logs HTTP requests and responses", func(t *testing.T) {
 599 | 			if !strings.Contains(ctx.LogBuffer.String(), "GET /.well-known/oauth-protected-resource 404") {
 600 | 				t.Errorf("Expected log entry for GET /.well-known/oauth-protected-resource, got: %s", ctx.LogBuffer.String())
 601 | 			}
 602 | 		})
 603 | 		t.Run("Logs HTTP request duration", func(t *testing.T) {
 604 | 			expected := `"GET /.well-known/oauth-protected-resource 404 (.+)"`
 605 | 			m := regexp.MustCompile(expected).FindStringSubmatch(ctx.LogBuffer.String())
 606 | 			if len(m) != 2 {
 607 | 				t.Fatalf("Expected log entry to contain duration, got %s", ctx.LogBuffer.String())
 608 | 			}
 609 | 			duration, err := time.ParseDuration(m[1])
 610 | 			if err != nil {
 611 | 				t.Fatalf("Failed to parse duration from log entry: %v", err)
 612 | 			}
 613 | 			if duration < 0 {
 614 | 				t.Errorf("Expected duration to be non-negative, got %v", duration)
 615 | 			}
 616 | 		})
 617 | 	})
 618 | }
 619 | 
 620 | func TestAuthorizationUnauthorized(t *testing.T) {
 621 | 	// Missing Authorization header
 622 | 	testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
 623 | 		resp, err := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress))
 624 | 		if err != nil {
 625 | 			t.Fatalf("Failed to get protected endpoint: %v", err)
 626 | 		}
 627 | 		t.Cleanup(func() { _ = resp.Body.Close })
 628 | 		t.Run("Protected resource with MISSING Authorization header returns 401 - Unauthorized", func(t *testing.T) {
 629 | 			if resp.StatusCode != 401 {
 630 | 				t.Errorf("Expected HTTP 401, got %d", resp.StatusCode)
 631 | 			}
 632 | 		})
 633 | 		t.Run("Protected resource with MISSING Authorization header returns WWW-Authenticate header", func(t *testing.T) {
 634 | 			authHeader := resp.Header.Get("WWW-Authenticate")
 635 | 			expected := `Bearer realm="Kubernetes MCP Server", error="missing_token"`
 636 | 			if authHeader != expected {
 637 | 				t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
 638 | 			}
 639 | 		})
 640 | 		t.Run("Protected resource with MISSING Authorization header logs error", func(t *testing.T) {
 641 | 			if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - missing or invalid bearer token") {
 642 | 				t.Errorf("Expected log entry for missing or invalid bearer token, got: %s", ctx.LogBuffer.String())
 643 | 			}
 644 | 		})
 645 | 	})
 646 | 	// Authorization header without Bearer prefix
 647 | 	testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
 648 | 		req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
 649 | 		if err != nil {
 650 | 			t.Fatalf("Failed to create request: %v", err)
 651 | 		}
 652 | 		req.Header.Set("Authorization", "Basic YWxhZGRpbjpvcGVuc2VzYW1l")
 653 | 		resp, err := http.DefaultClient.Do(req)
 654 | 		if err != nil {
 655 | 			t.Fatalf("Failed to get protected endpoint: %v", err)
 656 | 		}
 657 | 		t.Cleanup(func() { _ = resp.Body.Close })
 658 | 		t.Run("Protected resource with INCOMPATIBLE Authorization header returns WWW-Authenticate header", func(t *testing.T) {
 659 | 			authHeader := resp.Header.Get("WWW-Authenticate")
 660 | 			expected := `Bearer realm="Kubernetes MCP Server", error="missing_token"`
 661 | 			if authHeader != expected {
 662 | 				t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
 663 | 			}
 664 | 		})
 665 | 		t.Run("Protected resource with INCOMPATIBLE Authorization header logs error", func(t *testing.T) {
 666 | 			if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - missing or invalid bearer token") {
 667 | 				t.Errorf("Expected log entry for missing or invalid bearer token, got: %s", ctx.LogBuffer.String())
 668 | 			}
 669 | 		})
 670 | 	})
 671 | 	// Invalid Authorization header
 672 | 	testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
 673 | 		req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
 674 | 		if err != nil {
 675 | 			t.Fatalf("Failed to create request: %v", err)
 676 | 		}
 677 | 		req.Header.Set("Authorization", "Bearer "+strings.ReplaceAll(tokenBasicNotExpired, ".", ".invalid"))
 678 | 		resp, err := http.DefaultClient.Do(req)
 679 | 		if err != nil {
 680 | 			t.Fatalf("Failed to get protected endpoint: %v", err)
 681 | 		}
 682 | 		t.Cleanup(func() { _ = resp.Body.Close })
 683 | 		t.Run("Protected resource with INVALID Authorization header returns 401 - Unauthorized", func(t *testing.T) {
 684 | 			if resp.StatusCode != 401 {
 685 | 				t.Errorf("Expected HTTP 401, got %d", resp.StatusCode)
 686 | 			}
 687 | 		})
 688 | 		t.Run("Protected resource with INVALID Authorization header returns WWW-Authenticate header", func(t *testing.T) {
 689 | 			authHeader := resp.Header.Get("WWW-Authenticate")
 690 | 			expected := `Bearer realm="Kubernetes MCP Server", error="invalid_token"`
 691 | 			if authHeader != expected {
 692 | 				t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
 693 | 			}
 694 | 		})
 695 | 		t.Run("Protected resource with INVALID Authorization header logs error", func(t *testing.T) {
 696 | 			if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") ||
 697 | 				!strings.Contains(ctx.LogBuffer.String(), "error: failed to parse JWT token: illegal base64 data") {
 698 | 				t.Errorf("Expected log entry for JWT validation error, got: %s", ctx.LogBuffer.String())
 699 | 			}
 700 | 		})
 701 | 	})
 702 | 	// Expired Authorization Bearer token
 703 | 	testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
 704 | 		req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
 705 | 		if err != nil {
 706 | 			t.Fatalf("Failed to create request: %v", err)
 707 | 		}
 708 | 		req.Header.Set("Authorization", "Bearer "+tokenBasicExpired)
 709 | 		resp, err := http.DefaultClient.Do(req)
 710 | 		if err != nil {
 711 | 			t.Fatalf("Failed to get protected endpoint: %v", err)
 712 | 		}
 713 | 		t.Cleanup(func() { _ = resp.Body.Close })
 714 | 		t.Run("Protected resource with EXPIRED Authorization header returns 401 - Unauthorized", func(t *testing.T) {
 715 | 			if resp.StatusCode != 401 {
 716 | 				t.Errorf("Expected HTTP 401, got %d", resp.StatusCode)
 717 | 			}
 718 | 		})
 719 | 		t.Run("Protected resource with EXPIRED Authorization header returns WWW-Authenticate header", func(t *testing.T) {
 720 | 			authHeader := resp.Header.Get("WWW-Authenticate")
 721 | 			expected := `Bearer realm="Kubernetes MCP Server", error="invalid_token"`
 722 | 			if authHeader != expected {
 723 | 				t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
 724 | 			}
 725 | 		})
 726 | 		t.Run("Protected resource with EXPIRED Authorization header logs error", func(t *testing.T) {
 727 | 			if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") ||
 728 | 				!strings.Contains(ctx.LogBuffer.String(), "validation failed, token is expired (exp)") {
 729 | 				t.Errorf("Expected log entry for JWT validation error, got: %s", ctx.LogBuffer.String())
 730 | 			}
 731 | 		})
 732 | 	})
 733 | 	// Invalid audience claim Bearer token
 734 | 	testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "expected-audience", ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
 735 | 		req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
 736 | 		if err != nil {
 737 | 			t.Fatalf("Failed to create request: %v", err)
 738 | 		}
 739 | 		req.Header.Set("Authorization", "Bearer "+tokenBasicExpired)
 740 | 		resp, err := http.DefaultClient.Do(req)
 741 | 		if err != nil {
 742 | 			t.Fatalf("Failed to get protected endpoint: %v", err)
 743 | 		}
 744 | 		t.Cleanup(func() { _ = resp.Body.Close })
 745 | 		t.Run("Protected resource with INVALID AUDIENCE Authorization header returns 401 - Unauthorized", func(t *testing.T) {
 746 | 			if resp.StatusCode != 401 {
 747 | 				t.Errorf("Expected HTTP 401, got %d", resp.StatusCode)
 748 | 			}
 749 | 		})
 750 | 		t.Run("Protected resource with INVALID AUDIENCE Authorization header returns WWW-Authenticate header", func(t *testing.T) {
 751 | 			authHeader := resp.Header.Get("WWW-Authenticate")
 752 | 			expected := `Bearer realm="Kubernetes MCP Server", audience="expected-audience", error="invalid_token"`
 753 | 			if authHeader != expected {
 754 | 				t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
 755 | 			}
 756 | 		})
 757 | 		t.Run("Protected resource with INVALID AUDIENCE Authorization header logs error", func(t *testing.T) {
 758 | 			if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") ||
 759 | 				!strings.Contains(ctx.LogBuffer.String(), "invalid audience claim (aud)") {
 760 | 				t.Errorf("Expected log entry for JWT validation error, got: %s", ctx.LogBuffer.String())
 761 | 			}
 762 | 		})
 763 | 	})
 764 | 	// Failed OIDC validation
 765 | 	oidcTestServer := NewOidcTestServer(t)
 766 | 	t.Cleanup(oidcTestServer.Close)
 767 | 	testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
 768 | 		req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
 769 | 		if err != nil {
 770 | 			t.Fatalf("Failed to create request: %v", err)
 771 | 		}
 772 | 		req.Header.Set("Authorization", "Bearer "+tokenBasicNotExpired)
 773 | 		resp, err := http.DefaultClient.Do(req)
 774 | 		if err != nil {
 775 | 			t.Fatalf("Failed to get protected endpoint: %v", err)
 776 | 		}
 777 | 		t.Cleanup(func() { _ = resp.Body.Close })
 778 | 		t.Run("Protected resource with INVALID OIDC Authorization header returns 401 - Unauthorized", func(t *testing.T) {
 779 | 			if resp.StatusCode != 401 {
 780 | 				t.Errorf("Expected HTTP 401, got %d", resp.StatusCode)
 781 | 			}
 782 | 		})
 783 | 		t.Run("Protected resource with INVALID OIDC Authorization header returns WWW-Authenticate header", func(t *testing.T) {
 784 | 			authHeader := resp.Header.Get("WWW-Authenticate")
 785 | 			expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="invalid_token"`
 786 | 			if authHeader != expected {
 787 | 				t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
 788 | 			}
 789 | 		})
 790 | 		t.Run("Protected resource with INVALID OIDC Authorization header logs error", func(t *testing.T) {
 791 | 			if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") ||
 792 | 				!strings.Contains(ctx.LogBuffer.String(), "OIDC token validation error: failed to verify signature") {
 793 | 				t.Errorf("Expected log entry for OIDC validation error, got: %s", ctx.LogBuffer.String())
 794 | 			}
 795 | 		})
 796 | 	})
 797 | 	// Failed Kubernetes TokenReview
 798 | 	rawClaims := `{
 799 | 		"iss": "` + oidcTestServer.URL + `",
 800 | 		"exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `,
 801 | 		"aud": "mcp-server"
 802 | 	}`
 803 | 	validOidcToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, rawClaims)
 804 | 	testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
 805 | 		req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
 806 | 		if err != nil {
 807 | 			t.Fatalf("Failed to create request: %v", err)
 808 | 		}
 809 | 		req.Header.Set("Authorization", "Bearer "+validOidcToken)
 810 | 		resp, err := http.DefaultClient.Do(req)
 811 | 		if err != nil {
 812 | 			t.Fatalf("Failed to get protected endpoint: %v", err)
 813 | 		}
 814 | 		t.Cleanup(func() { _ = resp.Body.Close })
 815 | 		t.Run("Protected resource with INVALID KUBERNETES Authorization header returns 401 - Unauthorized", func(t *testing.T) {
 816 | 			if resp.StatusCode != 401 {
 817 | 				t.Errorf("Expected HTTP 401, got %d", resp.StatusCode)
 818 | 			}
 819 | 		})
 820 | 		t.Run("Protected resource with INVALID KUBERNETES Authorization header returns WWW-Authenticate header", func(t *testing.T) {
 821 | 			authHeader := resp.Header.Get("WWW-Authenticate")
 822 | 			expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="invalid_token"`
 823 | 			if authHeader != expected {
 824 | 				t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
 825 | 			}
 826 | 		})
 827 | 		t.Run("Protected resource with INVALID KUBERNETES Authorization header logs error", func(t *testing.T) {
 828 | 			if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") ||
 829 | 				!strings.Contains(ctx.LogBuffer.String(), "kubernetes API token validation error: failed to create token review") {
 830 | 				t.Errorf("Expected log entry for Kubernetes TokenReview error, got: %s", ctx.LogBuffer.String())
 831 | 			}
 832 | 		})
 833 | 	})
 834 | }
 835 | 
 836 | func TestAuthorizationRequireOAuthFalse(t *testing.T) {
 837 | 	testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: false, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
 838 | 		resp, err := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress))
 839 | 		if err != nil {
 840 | 			t.Fatalf("Failed to get protected endpoint: %v", err)
 841 | 		}
 842 | 		t.Cleanup(func() { _ = resp.Body.Close() })
 843 | 		t.Run("Protected resource with MISSING Authorization header returns 200 - OK)", func(t *testing.T) {
 844 | 			if resp.StatusCode != http.StatusOK {
 845 | 				t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
 846 | 			}
 847 | 		})
 848 | 	})
 849 | }
 850 | 
 851 | func TestAuthorizationRawToken(t *testing.T) {
 852 | 	cases := []struct {
 853 | 		audience      string
 854 | 		validateToken bool
 855 | 	}{
 856 | 		{"", false},           // No audience, no validation
 857 | 		{"", true},            // No audience, validation enabled
 858 | 		{"mcp-server", false}, // Audience set, no validation
 859 | 		{"mcp-server", true},  // Audience set, validation enabled
 860 | 	}
 861 | 	for _, c := range cases {
 862 | 		testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: c.audience, ValidateToken: c.validateToken, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
 863 | 			tokenReviewed := false
 864 | 			ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 865 | 				if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" {
 866 | 					w.Header().Set("Content-Type", "application/json")
 867 | 					_, _ = w.Write([]byte(tokenReviewSuccessful))
 868 | 					tokenReviewed = true
 869 | 					return
 870 | 				}
 871 | 			}))
 872 | 			req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
 873 | 			if err != nil {
 874 | 				t.Fatalf("Failed to create request: %v", err)
 875 | 			}
 876 | 			req.Header.Set("Authorization", "Bearer "+tokenBasicNotExpired)
 877 | 			resp, err := http.DefaultClient.Do(req)
 878 | 			if err != nil {
 879 | 				t.Fatalf("Failed to get protected endpoint: %v", err)
 880 | 			}
 881 | 			t.Cleanup(func() { _ = resp.Body.Close() })
 882 | 			t.Run(fmt.Sprintf("Protected resource with audience = '%s' and validate-token = '%t', with VALID Authorization header returns 200 - OK", c.audience, c.validateToken), func(t *testing.T) {
 883 | 				if resp.StatusCode != http.StatusOK {
 884 | 					t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
 885 | 				}
 886 | 			})
 887 | 			t.Run(fmt.Sprintf("Protected resource with audience = '%s' and validate-token = '%t', with VALID Authorization header performs token validation accordingly", c.audience, c.validateToken), func(t *testing.T) {
 888 | 				if tokenReviewed == true && !c.validateToken {
 889 | 					t.Errorf("Expected token review to be skipped when validate-token is false, but it was performed")
 890 | 				}
 891 | 				if tokenReviewed == false && c.validateToken {
 892 | 					t.Errorf("Expected token review to be performed when validate-token is true, but it was skipped")
 893 | 				}
 894 | 			})
 895 | 		})
 896 | 	}
 897 | 
 898 | }
 899 | 
 900 | func TestAuthorizationOidcToken(t *testing.T) {
 901 | 	oidcTestServer := NewOidcTestServer(t)
 902 | 	t.Cleanup(oidcTestServer.Close)
 903 | 	rawClaims := `{
 904 | 		"iss": "` + oidcTestServer.URL + `",
 905 | 		"exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `,
 906 | 		"aud": "mcp-server"
 907 | 	}`
 908 | 	validOidcToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, rawClaims)
 909 | 	cases := []bool{false, true}
 910 | 	for _, validateToken := range cases {
 911 | 		testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: validateToken, ClusterProviderStrategy: config.ClusterProviderKubeConfig}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
 912 | 			tokenReviewed := false
 913 | 			ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 914 | 				if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" {
 915 | 					w.Header().Set("Content-Type", "application/json")
 916 | 					_, _ = w.Write([]byte(tokenReviewSuccessful))
 917 | 					tokenReviewed = true
 918 | 					return
 919 | 				}
 920 | 			}))
 921 | 			req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
 922 | 			if err != nil {
 923 | 				t.Fatalf("Failed to create request: %v", err)
 924 | 			}
 925 | 			req.Header.Set("Authorization", "Bearer "+validOidcToken)
 926 | 			resp, err := http.DefaultClient.Do(req)
 927 | 			if err != nil {
 928 | 				t.Fatalf("Failed to get protected endpoint: %v", err)
 929 | 			}
 930 | 			t.Cleanup(func() { _ = resp.Body.Close() })
 931 | 			t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC Authorization header returns 200 - OK", validateToken), func(t *testing.T) {
 932 | 				if resp.StatusCode != http.StatusOK {
 933 | 					t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
 934 | 				}
 935 | 			})
 936 | 			t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC Authorization header performs token validation accordingly", validateToken), func(t *testing.T) {
 937 | 				if tokenReviewed == true && !validateToken {
 938 | 					t.Errorf("Expected token review to be skipped when validate-token is false, but it was performed")
 939 | 				}
 940 | 				if tokenReviewed == false && validateToken {
 941 | 					t.Errorf("Expected token review to be performed when validate-token is true, but it was skipped")
 942 | 				}
 943 | 			})
 944 | 		})
 945 | 	}
 946 | }
 947 | 
 948 | func TestAuthorizationOidcTokenExchange(t *testing.T) {
 949 | 	oidcTestServer := NewOidcTestServer(t)
 950 | 	t.Cleanup(oidcTestServer.Close)
 951 | 	rawClaims := `{
 952 | 		"iss": "` + oidcTestServer.URL + `",
 953 | 		"exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `,
 954 | 		"aud": "%s"
 955 | 	}`
 956 | 	validOidcClientToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256,
 957 | 		fmt.Sprintf(rawClaims, "mcp-server"))
 958 | 	validOidcBackendToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256,
 959 | 		fmt.Sprintf(rawClaims, "backend-audience"))
 960 | 	oidcTestServer.TokenEndpointHandler = func(w http.ResponseWriter, r *http.Request) {
 961 | 		w.Header().Set("Content-Type", "application/json")
 962 | 		_, _ = fmt.Fprintf(w, `{"access_token":"%s","token_type":"Bearer","expires_in":253402297199}`, validOidcBackendToken)
 963 | 	}
 964 | 	cases := []bool{false, true}
 965 | 	for _, validateToken := range cases {
 966 | 		staticConfig := &config.StaticConfig{
 967 | 			RequireOAuth:            true,
 968 | 			OAuthAudience:           "mcp-server",
 969 | 			ValidateToken:           validateToken,
 970 | 			StsClientId:             "test-sts-client-id",
 971 | 			StsClientSecret:         "test-sts-client-secret",
 972 | 			StsAudience:             "backend-audience",
 973 | 			StsScopes:               []string{"backend-scope"},
 974 | 			ClusterProviderStrategy: config.ClusterProviderKubeConfig,
 975 | 		}
 976 | 		testCaseWithContext(t, &httpContext{StaticConfig: staticConfig, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
 977 | 			tokenReviewed := false
 978 | 			ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 979 | 				if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" {
 980 | 					w.Header().Set("Content-Type", "application/json")
 981 | 					_, _ = w.Write([]byte(tokenReviewSuccessful))
 982 | 					tokenReviewed = true
 983 | 					return
 984 | 				}
 985 | 			}))
 986 | 			req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
 987 | 			if err != nil {
 988 | 				t.Fatalf("Failed to create request: %v", err)
 989 | 			}
 990 | 			req.Header.Set("Authorization", "Bearer "+validOidcClientToken)
 991 | 			resp, err := http.DefaultClient.Do(req)
 992 | 			if err != nil {
 993 | 				t.Fatalf("Failed to get protected endpoint: %v", err)
 994 | 			}
 995 | 			t.Cleanup(func() { _ = resp.Body.Close() })
 996 | 			t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC EXCHANGE Authorization header returns 200 - OK", validateToken), func(t *testing.T) {
 997 | 				if resp.StatusCode != http.StatusOK {
 998 | 					t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
 999 | 				}
1000 | 			})
1001 | 			t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC EXCHANGE Authorization header performs token validation accordingly", validateToken), func(t *testing.T) {
1002 | 				if tokenReviewed == true && !validateToken {
1003 | 					t.Errorf("Expected token review to be skipped when validate-token is false, but it was performed")
1004 | 				}
1005 | 				if tokenReviewed == false && validateToken {
1006 | 					t.Errorf("Expected token review to be performed when validate-token is true, but it was skipped")
1007 | 				}
1008 | 			})
1009 | 		})
1010 | 	}
1011 | }
1012 | 
```
Page 5/5FirstPrevNextLast