UNPKG

@gorizond/mcp-rancher-multi

Version:

MCP server for multiple Rancher Manager backends with Fleet GitOps support

709 lines (606 loc) 20.9 kB
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { RancherClient } from "../../src/rancher-client.js"; import { RancherServerConfig } from "../../src/utils.js"; // Mock fetch globally global.fetch = vi.fn(); describe("RancherClient", () => { let client: RancherClient; let mockFetch: any; const mockConfig: RancherServerConfig = { id: "test-server", baseUrl: "https://test.rancher.com", token: "test-token", name: "Test Server", }; beforeEach(() => { vi.clearAllMocks(); mockFetch = fetch as any; client = new RancherClient(mockConfig); }); afterEach(() => { vi.restoreAllMocks(); }); describe("constructor", () => { it("should initialize with correct configuration", () => { expect(client.baseUrl).toBe("https://test.rancher.com"); expect(client.token).toBe("test-token"); expect(client.insecure).toBe(false); expect(client.caCertPemBase64).toBeUndefined(); }); it("should remove trailing slash from baseUrl", () => { const configWithSlash: RancherServerConfig = { ...mockConfig, baseUrl: "https://test.rancher.com/", }; const clientWithSlash = new RancherClient(configWithSlash); expect(clientWithSlash.baseUrl).toBe("https://test.rancher.com"); }); it("should handle insecure configuration", () => { const insecureConfig: RancherServerConfig = { ...mockConfig, insecureSkipTlsVerify: true, }; const insecureClient = new RancherClient(insecureConfig); expect(insecureClient.insecure).toBe(true); }); it("should handle caCertPemBase64 configuration", () => { const certConfig: RancherServerConfig = { ...mockConfig, caCertPemBase64: "base64-cert-data", }; const certClient = new RancherClient(certConfig); expect(certClient.caCertPemBase64).toBe("base64-cert-data"); }); it("should resolve token with environment variable pattern", () => { process.env.TEST_TOKEN = "env-token-value"; const envConfig: RancherServerConfig = { ...mockConfig, token: "${ENV:TEST_TOKEN}", }; const envClient = new RancherClient(envConfig); expect(envClient.token).toBe("env-token-value"); }); }); describe("listClusters", () => { it("should return minimal fields by default", async () => { const mockResponse = { data: [ { id: "cluster1", name: "Test Cluster 1", state: "active" }, { id: "cluster2", name: "Test Cluster 2", state: "active" }, ], }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse), }); const result = await client.listClusters(); expect(result).toEqual([ { id: "cluster1", name: "Test Cluster 1" }, { id: "cluster2", name: "Test Cluster 2" }, ]); }); it("should fetch clusters with full data when summary is disabled", async () => { const mockResponse = { data: [ { id: "cluster1", name: "Test Cluster 1", state: "active" }, { id: "cluster2", name: "Test Cluster 2", state: "active" }, ], }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse), }); const result = await client.listClusters({ summary: false }); expect(mockFetch).toHaveBeenCalledWith( "https://test.rancher.com/v3/clusters", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-token", Accept: "application/json", }), }), ); expect(result).toEqual(mockResponse.data); }); it("should handle HTTP error", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, statusText: "Unauthorized", text: () => Promise.resolve("Unauthorized access"), }); await expect(client.listClusters()).rejects.toThrow( "HTTP 401 Unauthorized", ); }); it("should handle network error", async () => { mockFetch.mockRejectedValueOnce(new Error("Network error")); await expect(client.listClusters()).rejects.toThrow("Network error"); }); it("should return compact summary with selected fields", async () => { const mockResponse = { data: [ { id: "c1", name: "Cluster One", state: "active", provider: "rke2", annotations: { "fleet.cattle.io/workspace-name": "ws1" }, status: { fleet: { ready: true } }, extra: { big: "value" }, }, ], }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse), }); const result: any = await client.listClusters({ summary: true, summaryFields: [ "id", "name", "state", "provider", "workspace", "fleet", ], }); expect(result).toEqual([ expect.objectContaining({ id: "c1", name: "Cluster One", state: "active", provider: "rke2", workspace: "ws1", fleet: { ready: true }, }), ]); expect(result[0].extra).toBeUndefined(); }); it("should strip specified keys before returning", async () => { const mockResponse = { data: [ { id: "c1", name: "Cluster One", state: "active", links: {}, actions: { restart: true }, heavy: { data: "x" }, }, ], }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse), }); const result: any = await client.listClusters({ summary: false, stripKeys: ["links", "actions"], }); expect(result[0].links).toBeUndefined(); expect(result[0].actions).toBeUndefined(); expect(result[0].heavy).toBeDefined(); }); it("should paginate clusters when autoContinue is enabled", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: [{ id: "c1", name: "one" }], pagination: { next: "/v3/clusters?page=2" }, }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: [{ id: "c2", name: "two" }], pagination: { next: null }, }), }); const result: any = await client.listClusters({ autoContinue: true, summary: true, }); expect(mockFetch).toHaveBeenCalledTimes(2); expect(mockFetch.mock.calls[1][0]).toBe( "https://test.rancher.com/v3/clusters?page=2", ); expect(result.data.map((c: any) => c.id)).toEqual(["c1", "c2"]); expect(result.pageInfo.pages).toBe(2); expect(result.pagination.next).toBeNull(); }); }); describe("listNodes", () => { it("should fetch nodes without clusterId", async () => { const mockResponse = { data: [ { id: "node1", nodeName: "test-node-1", state: "active" }, { id: "node2", nodeName: "test-node-2", state: "active" }, ], }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse), }); const result = await client.listNodes(); expect(mockFetch).toHaveBeenCalledWith( "https://test.rancher.com/v3/nodes", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-token", Accept: "application/json", }), }), ); expect(result).toEqual(mockResponse.data); }); it("should fetch nodes with clusterId", async () => { const mockResponse = { data: [ { id: "node1", nodeName: "test-node-1", clusterId: "cluster1", state: "active", }, ], }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse), }); const result = await client.listNodes("cluster1"); expect(mockFetch).toHaveBeenCalledWith( "https://test.rancher.com/v3/nodes?clusterId=cluster1", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-token", Accept: "application/json", }), }), ); expect(result).toEqual(mockResponse.data); }); it("should handle special characters in clusterId", async () => { const mockResponse = { data: [] }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse), }); await client.listNodes("cluster/with/special/chars"); expect(mockFetch).toHaveBeenCalledWith( "https://test.rancher.com/v3/nodes?clusterId=cluster%2Fwith%2Fspecial%2Fchars", expect.any(Object), ); }); }); describe("listProjects", () => { it("should fetch projects successfully", async () => { const mockResponse = { data: [ { id: "project1", name: "Test Project 1", clusterId: "cluster1" }, { id: "project2", name: "Test Project 2", clusterId: "cluster1" }, ], }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse), }); const result = await client.listProjects("cluster1"); expect(mockFetch).toHaveBeenCalledWith( "https://test.rancher.com/v3/projects?clusterId=cluster1", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-token", Accept: "application/json", }), }), ); expect(result).toEqual(mockResponse.data); }); }); describe("generateKubeconfig", () => { it("should generate kubeconfig successfully", async () => { const mockResponse = { config: "apiVersion: v1\nkind: Config\nclusters:\n- name: test-cluster", }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse), }); const result = await client.generateKubeconfig("cluster1"); expect(mockFetch).toHaveBeenCalledWith( "https://test.rancher.com/v3/clusters/cluster1?action=generateKubeconfig", expect.objectContaining({ method: "POST", headers: expect.objectContaining({ Authorization: "Bearer test-token", Accept: "application/json", "content-type": "application/json", }), }), ); expect(result).toBe(mockResponse.config); }); }); describe("k8s", () => { it("should make k8s request with leading slash", async () => { const mockResponse = { items: [] }; mockFetch.mockResolvedValueOnce({ ok: true, headers: new Map([["content-type", "application/json"]]), json: () => Promise.resolve(mockResponse), }); const result = await client.k8s("cluster1", "/api/v1/pods"); expect(mockFetch).toHaveBeenCalledWith( "https://test.rancher.com/k8s/clusters/cluster1/api/v1/pods", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-token", Accept: "application/json", }), }), ); expect(result).toEqual(mockResponse); }); it("should make k8s request without leading slash", async () => { const mockResponse = { items: [] }; mockFetch.mockResolvedValueOnce({ ok: true, headers: new Map([["content-type", "application/json"]]), json: () => Promise.resolve(mockResponse), }); const result = await client.k8s("cluster1", "api/v1/pods"); expect(mockFetch).toHaveBeenCalledWith( "https://test.rancher.com/k8s/clusters/cluster1/api/v1/pods", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-token", Accept: "application/json", }), }), ); expect(result).toEqual(mockResponse); }); it("should handle text response", async () => { const mockResponse = "plain text response"; mockFetch.mockResolvedValueOnce({ ok: true, headers: new Map([["content-type", "text/plain"]]), text: () => Promise.resolve(mockResponse), }); const result = await client.k8s("cluster1", "/api/v1/pods"); expect(result).toBe(mockResponse); }); it("should handle missing content-type header", async () => { const mockResponse = "response without content-type"; mockFetch.mockResolvedValueOnce({ ok: true, headers: new Map(), text: () => Promise.resolve(mockResponse), }); const result = await client.k8s("cluster1", "/api/v1/pods"); expect(result).toBe(mockResponse); }); it("should handle HTTP error in k8s request", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found", text: () => Promise.resolve("Resource not found"), }); await expect(client.k8s("cluster1", "/api/v1/pods")).rejects.toThrow( "K8s proxy HTTP 404 Not Found", ); }); }); describe("k8sRaw", () => { it("should append limit when missing and strip managedFields", async () => { mockFetch.mockResolvedValueOnce({ ok: true, headers: new Map([["content-type", "application/json"]]), json: () => Promise.resolve({ items: [ { metadata: { name: "p1", managedFields: [{ manager: "m" }] } }, ], metadata: { managedFields: [{ manager: "meta" }] }, }), }); const result: any = await client.k8sRaw({ clusterId: "c1", path: "/api/v1/pods", method: "GET", limit: 5, }); const calledUrl = mockFetch.mock.calls[0][0]; const headers = mockFetch.mock.calls[0][1].headers; expect(calledUrl).toContain("/api/v1/pods?limit=5"); expect(headers["content-type"]).toBeUndefined(); expect(result.items[0].metadata.managedFields).toBeUndefined(); expect(result.metadata.managedFields).toBeUndefined(); }); it("should auto-continue across pages and collect items", async () => { mockFetch .mockResolvedValueOnce({ ok: true, headers: new Map([["content-type", "application/json"]]), json: () => Promise.resolve({ kind: "PodList", metadata: { continue: "token-123", managedFields: [{ manager: "page1" }], }, items: [ { metadata: { name: "p1", managedFields: [{ manager: "m1" }] }, }, ], }), }) .mockResolvedValueOnce({ ok: true, headers: new Map([["content-type", "application/json"]]), json: () => Promise.resolve({ kind: "PodList", metadata: { resourceVersion: "2" }, items: [{ metadata: { name: "p2" } }], }), }); const result: any = await client.k8sRaw({ clusterId: "c1", path: "/api/v1/pods", method: "GET", autoContinue: true, limit: 1, }); expect(mockFetch).toHaveBeenCalledTimes(2); expect(mockFetch.mock.calls[1][0]).toContain("continue=token-123"); expect(result.items.map((i: any) => i.metadata.name)).toEqual([ "p1", "p2", ]); expect(result.pageInfo.pages).toBe(2); expect(result.pageInfo.itemsCollected).toBe(2); expect(result.metadata.continue).toBeUndefined(); }); it("should stop at maxItems and return continue token", async () => { mockFetch.mockResolvedValueOnce({ ok: true, headers: new Map([["content-type", "application/json"]]), json: () => Promise.resolve({ metadata: { continue: "next-token" }, items: [{ metadata: { name: "p1" } }, { metadata: { name: "p2" } }], }), }); const result: any = await client.k8sRaw({ clusterId: "c1", path: "/api/v1/pods", method: "GET", autoContinue: true, maxItems: 1, limit: 2, }); expect(mockFetch).toHaveBeenCalledTimes(1); expect(result.items).toHaveLength(1); expect(result.metadata.continue).toBe("next-token"); }); it("should honor custom Accept header", async () => { const accept = "application/json;as=PartialObjectMetadataList;v=v1;g=meta.k8s.io"; mockFetch.mockResolvedValueOnce({ ok: true, headers: new Map([["content-type", "application/json"]]), json: () => Promise.resolve({ items: [] }), }); await client.k8sRaw({ clusterId: "c1", path: "/api/v1/pods", method: "GET", accept, }); const headers = mockFetch.mock.calls[0][1].headers; expect(headers.Accept).toBe(accept); expect(headers["content-type"]).toBeUndefined(); }); it("should strip specified keys to compact responses", async () => { mockFetch.mockResolvedValueOnce({ ok: true, headers: new Map([["content-type", "application/json"]]), json: () => Promise.resolve({ items: [ { metadata: { name: "cfg1" }, data: { big: "AAA" }, spec: { inner: { data: "BBB", keep: "ok" } }, }, ], binaryData: { huge: "CCC" }, data: { root: "DDD" }, }), }); const result: any = await client.k8sRaw({ clusterId: "c1", path: "/api/v1/configmaps", method: "GET", stripKeys: ["data", "binaryData"], }); expect(result.items[0].data).toBeUndefined(); expect(result.items[0].spec.inner.data).toBeUndefined(); expect(result.items[0].spec.inner.keep).toBe("ok"); expect(result.binaryData).toBeUndefined(); expect(result.data).toBeUndefined(); }); }); describe("listNamespaces", () => { it("should list namespaces with items property", async () => { const mockResponse = { items: [ { metadata: { name: "default" } }, { metadata: { name: "kube-system" } }, ], }; mockFetch.mockResolvedValueOnce({ ok: true, headers: new Map([["content-type", "application/json"]]), json: () => Promise.resolve(mockResponse), }); const result = await client.listNamespaces("cluster1"); expect(mockFetch).toHaveBeenCalledWith( "https://test.rancher.com/k8s/clusters/cluster1/api/v1/namespaces", expect.any(Object), ); expect(result).toEqual(mockResponse.items); }); it("should list namespaces without items property", async () => { const mockResponse = [ { metadata: { name: "default" } }, { metadata: { name: "kube-system" } }, ]; mockFetch.mockResolvedValueOnce({ ok: true, headers: new Map([["content-type", "application/json"]]), json: () => Promise.resolve(mockResponse), }); const result = await client.listNamespaces("cluster1"); expect(result).toEqual(mockResponse); }); it("should handle null response", async () => { const mockResponse = null; mockFetch.mockResolvedValueOnce({ ok: true, headers: new Map([["content-type", "application/json"]]), json: () => Promise.resolve(mockResponse), }); const result = await client.listNamespaces("cluster1"); expect(result).toBeNull(); }); }); describe("headers", () => { it("should include extra headers", async () => { const mockResponse = { data: [] }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse), }); await client.listClusters(); expect(mockFetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-token", Accept: "application/json", }), }), ); }); }); });