From 74ea23b24a2125019786866ff3af17250125f431 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Wed, 29 Oct 2025 17:56:53 +0100 Subject: [PATCH] test(resources): update generic resources tests to use testify and improve readability Signed-off-by: Marc Nuri --- pkg/mcp/common_test.go | 18 + pkg/mcp/resources_test.go | 928 +++++++++++++++----------------------- 2 files changed, 383 insertions(+), 563 deletions(-) diff --git a/pkg/mcp/common_test.go b/pkg/mcp/common_test.go index e9c49758..da258a62 100644 --- a/pkg/mcp/common_test.go +++ b/pkg/mcp/common_test.go @@ -449,3 +449,21 @@ func (s *BaseMcpSuite) InitMcpClient() { s.Require().NoError(err, "Expected no error creating MCP server") s.McpClient = test.NewMcpClient(s.T(), s.mcpServer.ServeHTTP(nil)) } + +// CrdWaitUntilReady waits for a CRD to be established +func (s *BaseMcpSuite) CrdWaitUntilReady(name string) { + apiExtensionClient := apiextensionsv1.NewForConfigOrDie(envTestRestConfig) + watcher, err := apiExtensionClient.CustomResourceDefinitions().Watch(s.T().Context(), metav1.ListOptions{ + FieldSelector: "metadata.name=" + name, + }) + s.Require().NoError(err, "failed to watch CRD") + _, err = toolswatch.UntilWithoutRetry(s.T().Context(), watcher, func(event watch.Event) (bool, error) { + for _, c := range event.Object.(*apiextensionsv1spec.CustomResourceDefinition).Status.Conditions { + if c.Type == apiextensionsv1spec.Established && c.Status == apiextensionsv1spec.ConditionTrue { + return true, nil + } + } + return false, nil + }) + s.Require().NoError(err, "failed to wait for CRD") +} diff --git a/pkg/mcp/resources_test.go b/pkg/mcp/resources_test.go index 3aa7b875..83401377 100644 --- a/pkg/mcp/resources_test.go +++ b/pkg/mcp/resources_test.go @@ -5,193 +5,140 @@ import ( "strings" "testing" + "github.com/BurntSushi/toml" "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/suite" corev1 "k8s.io/api/core/v1" v1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" "sigs.k8s.io/yaml" - "github.com/containers/kubernetes-mcp-server/internal/test" - "github.com/containers/kubernetes-mcp-server/pkg/config" "github.com/containers/kubernetes-mcp-server/pkg/output" ) -func TestResourcesList(t *testing.T) { - testCase(t, func(c *mcpContext) { - c.withEnvTest() - t.Run("resources_list with missing apiVersion returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_list", map[string]interface{}{}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - } - if toolResult.Content[0].(mcp.TextContent).Text != "failed to list resources, missing argument apiVersion" { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - } - }) - t.Run("resources_list with missing kind returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1"}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - } - if toolResult.Content[0].(mcp.TextContent).Text != "failed to list resources, missing argument kind" { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - } - }) - t.Run("resources_list with invalid apiVersion returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod"}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - } - if toolResult.Content[0].(mcp.TextContent).Text != "failed to list resources, invalid argument apiVersion" { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - } - }) - t.Run("resources_list with nonexistent apiVersion returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom"}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - } - if toolResult.Content[0].(mcp.TextContent).Text != `failed to list resources: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - } - }) - namespaces, err := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"}) - t.Run("resources_list returns namespaces", func(t *testing.T) { - if err != nil { - t.Fatalf("call tool failed %v", err) - return - } - if namespaces.IsError { - t.Fatalf("call tool failed") - return - } +type ResourcesSuite struct { + BaseMcpSuite +} + +func (s *ResourcesSuite) TestResourcesList() { + s.InitMcpClient() + s.Run("resources_list with missing apiVersion returns error", func() { + toolResult, _ := s.CallTool("resources_list", map[string]interface{}{}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf("failed to list resources, missing argument apiVersion", toolResult.Content[0].(mcp.TextContent).Text, + "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_list with missing kind returns error", func() { + toolResult, _ := s.CallTool("resources_list", map[string]interface{}{"apiVersion": "v1"}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf("failed to list resources, missing argument kind", toolResult.Content[0].(mcp.TextContent).Text, + "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_list with invalid apiVersion returns error", func() { + toolResult, _ := s.CallTool("resources_list", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod"}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf("failed to list resources, invalid argument apiVersion", toolResult.Content[0].(mcp.TextContent).Text, + "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_list with nonexistent apiVersion returns error", func() { + toolResult, _ := s.CallTool("resources_list", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom"}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf(`failed to list resources: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"`, + toolResult.Content[0].(mcp.TextContent).Text, "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_list(apiVersion=v1, kind=Namespace) returns namespaces", func() { + namespaces, err := s.CallTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"}) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(namespaces.IsError, "call tool failed") }) var decodedNamespaces []unstructured.Unstructured err = yaml.Unmarshal([]byte(namespaces.Content[0].(mcp.TextContent).Text), &decodedNamespaces) - t.Run("resources_list has yaml content", func(t *testing.T) { - if err != nil { - t.Fatalf("invalid tool result content %v", err) - } + s.Run("has yaml content", func() { + s.Nilf(err, "invalid tool result content %v", err) }) - t.Run("resources_list returns more than 2 items", func(t *testing.T) { - if len(decodedNamespaces) < 3 { - t.Fatalf("invalid namespace count, expected >2, got %v", len(decodedNamespaces)) - } + s.Run("returns more than 2 items", func() { + s.Truef(len(decodedNamespaces) >= 3, "invalid namespace count, expected >2, got %v", len(decodedNamespaces)) }) - - // Test label selector functionality - t.Run("resources_list with label selector returns filtered pods", func(t *testing.T) { - - // List pods with label selector - result, err := c.callTool("resources_list", map[string]interface{}{ + }) + s.Run("resources_list with label selector returns filtered pods", func() { + s.Run("list pods with app=nginx label", func() { + result, err := s.CallTool("resources_list", map[string]interface{}{ "apiVersion": "v1", "kind": "Pod", "namespace": "default", "labelSelector": "app=nginx", }) - - if err != nil { - t.Fatalf("call tool failed %v", err) - return - } - if result.IsError { - t.Fatalf("call tool failed") - return - } + s.Nilf(err, "call tool failed %v", err) + s.Falsef(result.IsError, "call tool failed") var decodedPods []unstructured.Unstructured err = yaml.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &decodedPods) - if err != nil { - t.Fatalf("invalid tool result content %v", err) - return - } - - // Verify only the pod with matching label is returned - if len(decodedPods) != 1 { - t.Fatalf("expected 1 pod, got %d", len(decodedPods)) - return - } + s.Nilf(err, "invalid tool result content %v", err) - if decodedPods[0].GetName() != "a-pod-in-default" { - t.Fatalf("expected pod-with-label, got %s", decodedPods[0].GetName()) - return - } - - // Test that multiple label selectors work - result, err = c.callTool("resources_list", map[string]interface{}{ + s.Lenf(decodedPods, 1, "expected 1 pod, got %d", len(decodedPods)) + s.Equalf("a-pod-in-default", decodedPods[0].GetName(), "expected a-pod-in-default, got %s", decodedPods[0].GetName()) + }) + s.Run("list pods with multiple label selectors", func() { + result, err := s.CallTool("resources_list", map[string]interface{}{ "apiVersion": "v1", "kind": "Pod", "namespace": "default", "labelSelector": "test-label=test-value,another=value", }) + s.Nilf(err, "call tool failed %v", err) + s.Falsef(result.IsError, "call tool failed") - if err != nil { - t.Fatalf("call tool failed %v", err) - return - } - if result.IsError { - t.Fatalf("call tool failed") - return - } - + var decodedPods []unstructured.Unstructured err = yaml.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &decodedPods) - if err != nil { - t.Fatalf("invalid tool result content %v", err) - return - } + s.Nilf(err, "invalid tool result content %v", err) - // Verify no pods match multiple label selector - if len(decodedPods) != 0 { - t.Fatalf("expected 0 pods, got %d", len(decodedPods)) - return - } + s.Lenf(decodedPods, 0, "expected 0 pods, got %d", len(decodedPods)) }) }) } -func TestResourcesListDenied(t *testing.T) { - deniedResourcesServer := test.Must(config.ReadToml([]byte(` +func (s *ResourcesSuite) TestResourcesListDenied() { + s.Require().NoError(toml.Unmarshal([]byte(` denied_resources = [ { version = "v1", kind = "Secret" }, { group = "rbac.authorization.k8s.io", version = "v1" } ] - `))) - testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) { - c.withEnvTest() - deniedByKind, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Secret"}) - t.Run("resources_list (denied by kind) has error", func(t *testing.T) { - if !deniedByKind.IsError { - t.Fatalf("call tool should fail") - } - }) - t.Run("resources_list (denied by kind) describes denial", func(t *testing.T) { + `), s.Cfg), "Expected to parse denied resources config") + s.InitMcpClient() + s.Run("resources_list (denied by kind)", func() { + deniedByKind, err := s.CallTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Secret"}) + s.Run("has error", func() { + s.Truef(deniedByKind.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes denial", func() { expectedMessage := "failed to list resources: resource not allowed: /v1, Kind=Secret" - if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage { - t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text) - } + s.Equalf(expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text) }) - deniedByGroup, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role"}) - t.Run("resources_list (denied by group) has error", func(t *testing.T) { - if !deniedByGroup.IsError { - t.Fatalf("call tool should fail") - } + }) + s.Run("resources_list (denied by group)", func() { + deniedByGroup, err := s.CallTool("resources_list", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role"}) + s.Run("has error", func() { + s.Truef(deniedByGroup.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") }) - t.Run("resources_list (denied by group) describes denial", func(t *testing.T) { + s.Run("describes denial", func() { expectedMessage := "failed to list resources: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role" - if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage { - t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text) - } - }) - allowedResource, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"}) - t.Run("resources_list (not denied) returns list", func(t *testing.T) { - if allowedResource.IsError { - t.Fatalf("call tool should not fail") - } + s.Equalf(expectedMessage, deniedByGroup.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, deniedByGroup.Content[0].(mcp.TextContent).Text) }) }) + s.Run("resources_list (not denied) returns list", func() { + allowedResource, _ := s.CallTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"}) + s.Falsef(allowedResource.IsError, "call tool should not fail") + }) } func TestResourcesListAsTable(t *testing.T) { @@ -271,229 +218,156 @@ func TestResourcesListAsTable(t *testing.T) { }) } -func TestResourcesGet(t *testing.T) { - testCase(t, func(c *mcpContext) { - c.withEnvTest() - t.Run("resources_get with missing apiVersion returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_get", map[string]interface{}{}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - return - } - if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, missing argument apiVersion" { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - return - } - }) - t.Run("resources_get with missing kind returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1"}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - return - } - if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, missing argument kind" { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - return - } - }) - t.Run("resources_get with invalid apiVersion returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod", "name": "a-pod"}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - return - } - if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, invalid argument apiVersion" { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - return - } - }) - t.Run("resources_get with nonexistent apiVersion returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom", "name": "a-custom"}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - return - } - if toolResult.Content[0].(mcp.TextContent).Text != `failed to get resource: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - return - } - }) - t.Run("resources_get with missing name returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - return - } - if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, missing argument name" { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - return - } - }) - namespace, err := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "default"}) - t.Run("resources_get returns namespace", func(t *testing.T) { - if err != nil { - t.Fatalf("call tool failed %v", err) - return - } - if namespace.IsError { - t.Fatalf("call tool failed") - return - } +func (s *ResourcesSuite) TestResourcesGet() { + s.InitMcpClient() + s.Run("resources_get with missing apiVersion returns error", func() { + toolResult, _ := s.CallTool("resources_get", map[string]interface{}{}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf("failed to get resource, missing argument apiVersion", toolResult.Content[0].(mcp.TextContent).Text, + "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_get with missing kind returns error", func() { + toolResult, _ := s.CallTool("resources_get", map[string]interface{}{"apiVersion": "v1"}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf("failed to get resource, missing argument kind", toolResult.Content[0].(mcp.TextContent).Text, + "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_get with invalid apiVersion returns error", func() { + toolResult, _ := s.CallTool("resources_get", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod", "name": "a-pod"}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf("failed to get resource, invalid argument apiVersion", toolResult.Content[0].(mcp.TextContent).Text, + "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_get with nonexistent apiVersion returns error", func() { + toolResult, _ := s.CallTool("resources_get", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom", "name": "a-custom"}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf(`failed to get resource: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"`, + toolResult.Content[0].(mcp.TextContent).Text, "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_get with missing name returns error", func() { + toolResult, _ := s.CallTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf("failed to get resource, missing argument name", toolResult.Content[0].(mcp.TextContent).Text, + "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_get returns namespace", func() { + namespace, err := s.CallTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "default"}) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(namespace.IsError, "call tool failed") }) var decodedNamespace unstructured.Unstructured err = yaml.Unmarshal([]byte(namespace.Content[0].(mcp.TextContent).Text), &decodedNamespace) - t.Run("resources_get has yaml content", func(t *testing.T) { - if err != nil { - t.Fatalf("invalid tool result content %v", err) - return - } + s.Run("has yaml content", func() { + s.Nilf(err, "invalid tool result content %v", err) }) - t.Run("resources_get returns default namespace", func(t *testing.T) { - if decodedNamespace.GetName() != "default" { - t.Fatalf("invalid namespace name, expected default, got %v", decodedNamespace.GetName()) - return - } + s.Run("returns default namespace", func() { + s.Equalf("default", decodedNamespace.GetName(), "invalid namespace name, expected default, got %v", decodedNamespace.GetName()) }) }) } -func TestResourcesGetDenied(t *testing.T) { - deniedResourcesServer := test.Must(config.ReadToml([]byte(` +func (s *ResourcesSuite) TestResourcesGetDenied() { + s.Require().NoError(toml.Unmarshal([]byte(` denied_resources = [ { version = "v1", kind = "Secret" }, { group = "rbac.authorization.k8s.io", version = "v1" } ] - `))) - testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) { - c.withEnvTest() - kc := c.newKubernetesClient() - _, _ = kc.CoreV1().Secrets("default").Create(c.ctx, &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "denied-secret"}, - }, metav1.CreateOptions{}) - _, _ = kc.RbacV1().Roles("default").Create(c.ctx, &v1.Role{ - ObjectMeta: metav1.ObjectMeta{Name: "denied-role"}, - }, metav1.CreateOptions{}) - deniedByKind, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Secret", "namespace": "default", "name": "denied-secret"}) - t.Run("resources_get (denied by kind) has error", func(t *testing.T) { - if !deniedByKind.IsError { - t.Fatalf("call tool should fail") - } - }) - t.Run("resources_get (denied by kind) describes denial", func(t *testing.T) { + `), s.Cfg), "Expected to parse denied resources config") + s.InitMcpClient() + kc := kubernetes.NewForConfigOrDie(envTestRestConfig) + _, _ = kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "denied-secret"}, + }, metav1.CreateOptions{}) + _, _ = kc.RbacV1().Roles("default").Create(s.T().Context(), &v1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: "denied-role"}, + }, metav1.CreateOptions{}) + s.Run("resources_get (denied by kind)", func() { + deniedByKind, err := s.CallTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Secret", "namespace": "default", "name": "denied-secret"}) + s.Run("has error", func() { + s.Truef(deniedByKind.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes denial", func() { expectedMessage := "failed to get resource: resource not allowed: /v1, Kind=Secret" - if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage { - t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text) - } + s.Equalf(expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text) }) - deniedByGroup, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "namespace": "default", "name": "denied-role"}) - t.Run("resources_get (denied by group) has error", func(t *testing.T) { - if !deniedByGroup.IsError { - t.Fatalf("call tool should fail") - } + }) + s.Run("resources_get (denied by group)", func() { + deniedByGroup, err := s.CallTool("resources_get", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "namespace": "default", "name": "denied-role"}) + s.Run("has error", func() { + s.Truef(deniedByGroup.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") }) - t.Run("resources_get (denied by group) describes denial", func(t *testing.T) { + s.Run("describes denial", func() { expectedMessage := "failed to get resource: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role" - if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage { - t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text) - } - }) - allowedResource, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "default"}) - t.Run("resources_get (not denied) returns resource", func(t *testing.T) { - if allowedResource.IsError { - t.Fatalf("call tool should not fail") - } + s.Equalf(expectedMessage, deniedByGroup.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, deniedByGroup.Content[0].(mcp.TextContent).Text) }) }) + s.Run("resources_get (not denied) returns resource", func() { + allowedResource, err := s.CallTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "default"}) + s.Falsef(allowedResource.IsError, "call tool should not fail") + s.Nilf(err, "call tool should not return error object") + }) } -func TestResourcesCreateOrUpdate(t *testing.T) { - testCase(t, func(c *mcpContext) { - c.withEnvTest() - t.Run("resources_create_or_update with nil resource returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_create_or_update", map[string]interface{}{}) - if toolResult.IsError != true { - t.Fatalf("call tool should fail") - return - } - if toolResult.Content[0].(mcp.TextContent).Text != "failed to create or update resources, missing argument resource" { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - return - } - }) - t.Run("resources_create_or_update with empty resource returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": ""}) - if toolResult.IsError != true { - t.Fatalf("call tool should fail") - return - } - if toolResult.Content[0].(mcp.TextContent).Text != "failed to create or update resources, missing argument resource" { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - return - } - }) - client := c.newKubernetesClient() +func (s *ResourcesSuite) TestResourcesCreateOrUpdate() { + s.InitMcpClient() + client := kubernetes.NewForConfigOrDie(envTestRestConfig) + + s.Run("resources_create_or_update with nil resource returns error", func() { + toolResult, _ := s.CallTool("resources_create_or_update", map[string]interface{}{}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf("failed to create or update resources, missing argument resource", toolResult.Content[0].(mcp.TextContent).Text, + "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_create_or_update with empty resource returns error", func() { + toolResult, _ := s.CallTool("resources_create_or_update", map[string]interface{}{"resource": ""}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf("failed to create or update resources, missing argument resource", toolResult.Content[0].(mcp.TextContent).Text, + "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + + s.Run("resources_create_or_update with valid namespaced yaml resource", func() { configMapYaml := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a-cm-created-or-updated\n namespace: default\n" - resourcesCreateOrUpdateCm1, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapYaml}) - t.Run("resources_create_or_update with valid namespaced yaml resource returns success", func(t *testing.T) { - if err != nil { - t.Fatalf("call tool failed %v", err) - return - } - if resourcesCreateOrUpdateCm1.IsError { - t.Errorf("call tool failed") - return - } + resourcesCreateOrUpdateCm1, err := s.CallTool("resources_create_or_update", map[string]interface{}{"resource": configMapYaml}) + s.Run("returns success", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(resourcesCreateOrUpdateCm1.IsError, "call tool failed") }) var decodedCreateOrUpdateCm1 []unstructured.Unstructured err = yaml.Unmarshal([]byte(resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text), &decodedCreateOrUpdateCm1) - t.Run("resources_create_or_update with valid namespaced yaml resource returns yaml content", func(t *testing.T) { - if err != nil { - t.Errorf("invalid tool result content %v", err) - return - } - if !strings.HasPrefix(resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text, "# The following resources (YAML) have been created or updated successfully") { - t.Errorf("Excpected success message, got %v", resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text) - return - } - if len(decodedCreateOrUpdateCm1) != 1 { - t.Errorf("invalid resource count, expected 1, got %v", len(decodedCreateOrUpdateCm1)) - return - } - if decodedCreateOrUpdateCm1[0].GetName() != "a-cm-created-or-updated" { - t.Errorf("invalid resource name, expected a-cm-created-or-updated, got %v", decodedCreateOrUpdateCm1[0].GetName()) - return - } - if decodedCreateOrUpdateCm1[0].GetUID() == "" { - t.Errorf("invalid uid, got %v", decodedCreateOrUpdateCm1[0].GetUID()) - return - } - }) - t.Run("resources_create_or_update with valid namespaced yaml resource creates ConfigMap", func(t *testing.T) { - cm, _ := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-cm-created-or-updated", metav1.GetOptions{}) - if cm == nil { - t.Fatalf("ConfigMap not found") - return - } + s.Run("returns yaml content", func() { + s.Nilf(err, "invalid tool result content %v", err) + s.Truef(strings.HasPrefix(resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text, "# The following resources (YAML) have been created or updated successfully"), + "Expected success message, got %v", resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text) + s.Lenf(decodedCreateOrUpdateCm1, 1, "invalid resource count, expected 1, got %v", len(decodedCreateOrUpdateCm1)) + s.Equalf("a-cm-created-or-updated", decodedCreateOrUpdateCm1[0].GetName(), + "invalid resource name, expected a-cm-created-or-updated, got %v", decodedCreateOrUpdateCm1[0].GetName()) + s.NotEmptyf(decodedCreateOrUpdateCm1[0].GetUID(), "invalid uid, got %v", decodedCreateOrUpdateCm1[0].GetUID()) + }) + s.Run("creates ConfigMap", func() { + cm, _ := client.CoreV1().ConfigMaps("default").Get(s.T().Context(), "a-cm-created-or-updated", metav1.GetOptions{}) + s.NotNil(cm, "ConfigMap not found") }) + }) + + s.Run("resources_create_or_update with valid namespaced json resource", func() { configMapJson := "{\"apiVersion\": \"v1\", \"kind\": \"ConfigMap\", \"metadata\": {\"name\": \"a-cm-created-or-updated-2\", \"namespace\": \"default\"}}" - resourcesCreateOrUpdateCm2, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapJson}) - t.Run("resources_create_or_update with valid namespaced json resource returns success", func(t *testing.T) { - if err != nil { - t.Fatalf("call tool failed %v", err) - return - } - if resourcesCreateOrUpdateCm2.IsError { - t.Fatalf("call tool failed") - return - } + resourcesCreateOrUpdateCm2, err := s.CallTool("resources_create_or_update", map[string]interface{}{"resource": configMapJson}) + s.Run("returns success", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(resourcesCreateOrUpdateCm2.IsError, "call tool failed") }) - t.Run("resources_create_or_update with valid namespaced json resource creates config map", func(t *testing.T) { - cm, _ := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-cm-created-or-updated-2", metav1.GetOptions{}) - if cm == nil { - t.Fatalf("ConfigMap not found") - return - } + s.Run("creates config map", func() { + cm, _ := client.CoreV1().ConfigMaps("default").Get(s.T().Context(), "a-cm-created-or-updated-2", metav1.GetOptions{}) + s.NotNil(cm, "ConfigMap not found") }) + }) + + s.Run("resources_create_or_update with valid cluster-scoped json resource", func() { customResourceDefinitionJson := ` { "apiVersion": "apiextensions.k8s.io/v1", @@ -509,284 +383,212 @@ func TestResourcesCreateOrUpdate(t *testing.T) { "names": {"plural": "customs","singular": "custom","kind": "Custom"} } }` - resourcesCreateOrUpdateCrd, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customResourceDefinitionJson}) - t.Run("resources_create_or_update with valid cluster-scoped json resource returns success", func(t *testing.T) { - if err != nil { - t.Fatalf("call tool failed %v", err) - return - } - if resourcesCreateOrUpdateCrd.IsError { - t.Fatalf("call tool failed") - return - } + resourcesCreateOrUpdateCrd, err := s.CallTool("resources_create_or_update", map[string]interface{}{"resource": customResourceDefinitionJson}) + s.Run("returns success", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(resourcesCreateOrUpdateCrd.IsError, "call tool failed") }) - t.Run("resources_create_or_update with valid cluster-scoped json resource creates custom resource definition", func(t *testing.T) { - apiExtensionsV1Client := c.newApiExtensionsClient() - _, err = apiExtensionsV1Client.CustomResourceDefinitions().Get(c.ctx, "customs.example.com", metav1.GetOptions{}) - if err != nil { - t.Fatalf("custom resource definition not found") - return - } + s.Run("creates custom resource definition", func() { + apiExtensionsV1Client := apiextensionsv1.NewForConfigOrDie(envTestRestConfig) + _, err = apiExtensionsV1Client.CustomResourceDefinitions().Get(s.T().Context(), "customs.example.com", metav1.GetOptions{}) + s.Nilf(err, "custom resource definition not found") }) - c.crdWaitUntilReady("customs.example.com") + s.CrdWaitUntilReady("customs.example.com") + }) + + s.Run("resources_create_or_update creates custom resource", func() { customJson := "{\"apiVersion\": \"example.com/v1\", \"kind\": \"Custom\", \"metadata\": {\"name\": \"a-custom-resource\"}}" - resourcesCreateOrUpdateCustom, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customJson}) - t.Run("resources_create_or_update with valid namespaced json resource returns success", func(t *testing.T) { - if err != nil { - t.Fatalf("call tool failed %v", err) - return - } - if resourcesCreateOrUpdateCustom.IsError { - t.Fatalf("call tool failed, got: %v", resourcesCreateOrUpdateCustom.Content) - return - } + resourcesCreateOrUpdateCustom, err := s.CallTool("resources_create_or_update", map[string]interface{}{"resource": customJson}) + s.Run("returns success", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(resourcesCreateOrUpdateCustom.IsError, "call tool failed, got: %v", resourcesCreateOrUpdateCustom.Content) }) - t.Run("resources_create_or_update with valid namespaced json resource creates custom resource", func(t *testing.T) { + s.Run("creates custom resource", func() { dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig) _, err = dynamicClient. Resource(schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "customs"}). Namespace("default"). - Get(c.ctx, "a-custom-resource", metav1.GetOptions{}) - if err != nil { - t.Fatalf("custom resource not found") - return - } + Get(s.T().Context(), "a-custom-resource", metav1.GetOptions{}) + s.Nilf(err, "custom resource not found") }) + }) + + s.Run("resources_create_or_update with valid namespaced json resource", func() { customJsonUpdated := "{\"apiVersion\": \"example.com/v1\", \"kind\": \"Custom\", \"metadata\": {\"name\": \"a-custom-resource\",\"annotations\": {\"updated\": \"true\"}}}" - resourcesCreateOrUpdateCustomUpdated, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customJsonUpdated}) - t.Run("resources_create_or_update with valid namespaced json resource updates custom resource", func(t *testing.T) { - if err != nil { - t.Fatalf("call tool failed %v", err) - return - } - if resourcesCreateOrUpdateCustomUpdated.IsError { - t.Fatalf("call tool failed") - return - } + resourcesCreateOrUpdateCustomUpdated, err := s.CallTool("resources_create_or_update", map[string]interface{}{"resource": customJsonUpdated}) + s.Run("returns success", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(resourcesCreateOrUpdateCustomUpdated.IsError, "call tool failed") }) - t.Run("resources_create_or_update with valid namespaced json resource updates custom resource", func(t *testing.T) { + s.Run("updates custom resource", func() { dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig) customResource, _ := dynamicClient. Resource(schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "customs"}). Namespace("default"). - Get(c.ctx, "a-custom-resource", metav1.GetOptions{}) - if customResource == nil { - t.Fatalf("custom resource not found") - return - } + Get(s.T().Context(), "a-custom-resource", metav1.GetOptions{}) + s.NotNil(customResource, "custom resource not found") annotations := customResource.GetAnnotations() - if annotations == nil || annotations["updated"] != "true" { - t.Fatalf("custom resource not updated") - return - } + s.Require().NotNil(annotations, "annotations should not be nil") + s.Equalf("true", annotations["updated"], "custom resource not updated") }) }) } -func TestResourcesCreateOrUpdateDenied(t *testing.T) { - deniedResourcesServer := test.Must(config.ReadToml([]byte(` +func (s *ResourcesSuite) TestResourcesCreateOrUpdateDenied() { + s.Require().NoError(toml.Unmarshal([]byte(` denied_resources = [ { version = "v1", kind = "Secret" }, { group = "rbac.authorization.k8s.io", version = "v1" } ] - `))) - testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) { - c.withEnvTest() + `), s.Cfg), "Expected to parse denied resources config") + s.InitMcpClient() + s.Run("resources_create_or_update (denied by kind)", func() { secretYaml := "apiVersion: v1\nkind: Secret\nmetadata:\n name: a-denied-secret\n namespace: default\n" - deniedByKind, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": secretYaml}) - t.Run("resources_create_or_update (denied by kind) has error", func(t *testing.T) { - if !deniedByKind.IsError { - t.Fatalf("call tool should fail") - } + deniedByKind, err := s.CallTool("resources_create_or_update", map[string]interface{}{"resource": secretYaml}) + s.Run("has error", func() { + s.Truef(deniedByKind.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") }) - t.Run("resources_create_or_update (denied by kind) describes denial", func(t *testing.T) { + s.Run("describes denial", func() { expectedMessage := "failed to create or update resources: resource not allowed: /v1, Kind=Secret" - if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage { - t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text) - } + s.Equalf(expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text) }) + }) + s.Run("resources_create_or_update (denied by group)", func() { roleYaml := "apiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n name: a-denied-role\n namespace: default\n" - deniedByGroup, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": roleYaml}) - t.Run("resources_create_or_update (denied by group) has error", func(t *testing.T) { - if !deniedByGroup.IsError { - t.Fatalf("call tool should fail") - } + deniedByGroup, err := s.CallTool("resources_create_or_update", map[string]interface{}{"resource": roleYaml}) + s.Run("has error", func() { + s.Truef(deniedByGroup.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") }) - t.Run("resources_create_or_update (denied by group) describes denial", func(t *testing.T) { + s.Run("describes denial", func() { expectedMessage := "failed to create or update resources: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role" - if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage { - t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text) - } + s.Equalf(expectedMessage, deniedByGroup.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, deniedByGroup.Content[0].(mcp.TextContent).Text) }) + }) + s.Run("resources_create_or_update (not denied) creates or updates resource", func() { configMapYaml := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a-cm-created-or-updated\n namespace: default\n" - allowedResource, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapYaml}) - t.Run("resources_create_or_update (not denied) creates or updates resource", func(t *testing.T) { - if allowedResource.IsError { - t.Fatalf("call tool should not fail") - } - }) + allowedResource, err := s.CallTool("resources_create_or_update", map[string]interface{}{"resource": configMapYaml}) + s.Falsef(allowedResource.IsError, "call tool should not fail") + s.Nilf(err, "call tool should not return error object") }) } -func TestResourcesDelete(t *testing.T) { - testCase(t, func(c *mcpContext) { - c.withEnvTest() - t.Run("resources_delete with missing apiVersion returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_delete", map[string]interface{}{}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - return - } - if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, missing argument apiVersion" { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - return - } - }) - t.Run("resources_delete with missing kind returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1"}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - return - } - if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, missing argument kind" { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - return - } - }) - t.Run("resources_delete with invalid apiVersion returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod", "name": "a-pod"}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - return - } - if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, invalid argument apiVersion" { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - return - } - }) - t.Run("resources_delete with nonexistent apiVersion returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom", "name": "a-custom"}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - return - } - if toolResult.Content[0].(mcp.TextContent).Text != `failed to delete resource: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - return - } - }) - t.Run("resources_delete with missing name returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - return - } - if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, missing argument name" { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - return - } - }) - t.Run("resources_delete with nonexistent resource returns error", func(t *testing.T) { - toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "nonexistent-configmap"}) - if !toolResult.IsError { - t.Fatalf("call tool should fail") - return - } - if toolResult.Content[0].(mcp.TextContent).Text != `failed to delete resource: configmaps "nonexistent-configmap" not found` { - t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) - return - } - }) - resourcesDeleteCm, err := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "a-configmap-to-delete"}) - t.Run("resources_delete with valid namespaced resource returns success", func(t *testing.T) { - if err != nil { - t.Fatalf("call tool failed %v", err) - return - } - if resourcesDeleteCm.IsError { - t.Fatalf("call tool failed") - return - } - if resourcesDeleteCm.Content[0].(mcp.TextContent).Text != "Resource deleted successfully" { - t.Fatalf("invalid tool result content got: %v", resourcesDeleteCm.Content[0].(mcp.TextContent).Text) - return - } - }) - client := c.newKubernetesClient() - t.Run("resources_delete with valid namespaced resource deletes ConfigMap", func(t *testing.T) { - _, err := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-configmap-to-delete", metav1.GetOptions{}) - if err == nil { - t.Fatalf("ConfigMap not deleted") - return - } - }) - resourcesDeleteNamespace, err := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "ns-to-delete"}) - t.Run("resources_delete with valid namespaced resource returns success", func(t *testing.T) { - if err != nil { - t.Fatalf("call tool failed %v", err) - return - } - if resourcesDeleteNamespace.IsError { - t.Fatalf("call tool failed") - return - } - if resourcesDeleteNamespace.Content[0].(mcp.TextContent).Text != "Resource deleted successfully" { - t.Fatalf("invalid tool result content got: %v", resourcesDeleteNamespace.Content[0].(mcp.TextContent).Text) - return - } +func (s *ResourcesSuite) TestResourcesDelete() { + s.InitMcpClient() + client := kubernetes.NewForConfigOrDie(envTestRestConfig) + + s.Run("resources_delete with missing apiVersion returns error", func() { + toolResult, _ := s.CallTool("resources_delete", map[string]interface{}{}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf("failed to delete resource, missing argument apiVersion", toolResult.Content[0].(mcp.TextContent).Text, + "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_delete with missing kind returns error", func() { + toolResult, _ := s.CallTool("resources_delete", map[string]interface{}{"apiVersion": "v1"}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf("failed to delete resource, missing argument kind", toolResult.Content[0].(mcp.TextContent).Text, + "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_delete with invalid apiVersion returns error", func() { + toolResult, _ := s.CallTool("resources_delete", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod", "name": "a-pod"}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf("failed to delete resource, invalid argument apiVersion", toolResult.Content[0].(mcp.TextContent).Text, + "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_delete with nonexistent apiVersion returns error", func() { + toolResult, _ := s.CallTool("resources_delete", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom", "name": "a-custom"}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf(`failed to delete resource: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"`, + toolResult.Content[0].(mcp.TextContent).Text, "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_delete with missing name returns error", func() { + toolResult, _ := s.CallTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf("failed to delete resource, missing argument name", toolResult.Content[0].(mcp.TextContent).Text, + "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + s.Run("resources_delete with nonexistent resource returns error", func() { + toolResult, _ := s.CallTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "nonexistent-configmap"}) + s.Truef(toolResult.IsError, "call tool should fail") + s.Equalf(`failed to delete resource: configmaps "nonexistent-configmap" not found`, + toolResult.Content[0].(mcp.TextContent).Text, "invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text) + }) + + s.Run("resources_delete with valid namespaced resource", func() { + resourcesDeleteCm, err := s.CallTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "a-configmap-to-delete"}) + s.Run("returns success", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(resourcesDeleteCm.IsError, "call tool failed") + s.Equalf("Resource deleted successfully", resourcesDeleteCm.Content[0].(mcp.TextContent).Text, + "invalid tool result content got: %v", resourcesDeleteCm.Content[0].(mcp.TextContent).Text) + }) + s.Run("deletes ConfigMap", func() { + _, err := client.CoreV1().ConfigMaps("default").Get(s.T().Context(), "a-configmap-to-delete", metav1.GetOptions{}) + s.Error(err, "ConfigMap not deleted") }) - t.Run("resources_delete with valid namespaced resource deletes Namespace", func(t *testing.T) { - ns, err := client.CoreV1().Namespaces().Get(c.ctx, "ns-to-delete", metav1.GetOptions{}) - if err == nil && ns != nil && ns.DeletionTimestamp == nil { - t.Fatalf("Namespace not deleted") - return - } + }) + + s.Run("resources_delete with valid cluster scoped resource", func() { + resourcesDeleteNamespace, err := s.CallTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "ns-to-delete"}) + s.Run("returns success", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(resourcesDeleteNamespace.IsError, "call tool failed") + s.Equalf("Resource deleted successfully", resourcesDeleteNamespace.Content[0].(mcp.TextContent).Text, + "invalid tool result content got: %v", resourcesDeleteNamespace.Content[0].(mcp.TextContent).Text) + }) + s.Run(" deletes Namespace", func() { + ns, err := client.CoreV1().Namespaces().Get(s.T().Context(), "ns-to-delete", metav1.GetOptions{}) + s.Truef(err != nil || (ns != nil && ns.DeletionTimestamp != nil), "Namespace not deleted") }) }) } -func TestResourcesDeleteDenied(t *testing.T) { - deniedResourcesServer := test.Must(config.ReadToml([]byte(` +func (s *ResourcesSuite) TestResourcesDeleteDenied() { + s.Require().NoError(toml.Unmarshal([]byte(` denied_resources = [ { version = "v1", kind = "Secret" }, { group = "rbac.authorization.k8s.io", version = "v1" } ] - `))) - testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) { - c.withEnvTest() - kc := c.newKubernetesClient() - _, _ = kc.CoreV1().ConfigMaps("default").Create(c.ctx, &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: "allowed-configmap-to-delete"}, - }, metav1.CreateOptions{}) - deniedByKind, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Secret", "namespace": "default", "name": "denied-secret"}) - t.Run("resources_delete (denied by kind) has error", func(t *testing.T) { - if !deniedByKind.IsError { - t.Fatalf("call tool should fail") - } - }) - t.Run("resources_delete (denied by kind) describes denial", func(t *testing.T) { + `), s.Cfg), "Expected to parse denied resources config") + s.InitMcpClient() + kc := kubernetes.NewForConfigOrDie(envTestRestConfig) + _, _ = kc.CoreV1().ConfigMaps("default").Create(s.T().Context(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "allowed-configmap-to-delete"}, + }, metav1.CreateOptions{}) + s.Run("resources_delete (denied by kind)", func() { + deniedByKind, err := s.CallTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Secret", "namespace": "default", "name": "denied-secret"}) + s.Run("has error", func() { + s.Truef(deniedByKind.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes denial", func() { expectedMessage := "failed to delete resource: resource not allowed: /v1, Kind=Secret" - if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage { - t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text) - } + s.Equalf(expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text) }) - deniedByGroup, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "namespace": "default", "name": "denied-role"}) - t.Run("resources_delete (denied by group) has error", func(t *testing.T) { - if !deniedByGroup.IsError { - t.Fatalf("call tool should fail") - } + }) + s.Run("resources_delete (denied by group)", func() { + deniedByGroup, err := s.CallTool("resources_delete", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "namespace": "default", "name": "denied-role"}) + s.Run("has error", func() { + s.Truef(deniedByGroup.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") }) - t.Run("resources_delete (denied by group) describes denial", func(t *testing.T) { + s.Run("describes denial", func() { expectedMessage := "failed to delete resource: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role" - if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage { - t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text) - } - }) - allowedResource, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "allowed-configmap-to-delete"}) - t.Run("resources_delete (not denied) deletes resource", func(t *testing.T) { - if allowedResource.IsError { - t.Fatalf("call tool should not fail") - } + s.Equalf(expectedMessage, deniedByGroup.Content[0].(mcp.TextContent).Text, + "expected descriptive error '%s', got %v", expectedMessage, deniedByGroup.Content[0].(mcp.TextContent).Text) }) }) + s.Run("resources_delete (not denied) deletes resource", func() { + allowedResource, err := s.CallTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "allowed-configmap-to-delete"}) + s.Falsef(allowedResource.IsError, "call tool should not fail") + s.Nilf(err, "call tool should not return error object") + }) +} + +func TestResources(t *testing.T) { + suite.Run(t, new(ResourcesSuite)) }