diff --git a/frontend/src/lib/k8s/api/v2/KubeList.test.ts b/frontend/src/lib/k8s/api/v2/KubeList.test.ts new file mode 100644 index 0000000000..c65e4eab62 --- /dev/null +++ b/frontend/src/lib/k8s/api/v2/KubeList.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest'; +import { KubeObjectClass, KubeObjectInterface } from '../../cluster'; +import { KubeList, KubeListUpdateEvent } from './KubeList'; + +class MockKubeObject implements KubeObjectInterface { + apiVersion = 'v1'; + kind = 'MockKubeObject'; + metadata: any = { + uid: 'mock-uid', + resourceVersion: '1', + }; + + constructor(data: Partial) { + Object.assign(this, data); + } +} + +describe('KubeList.applyUpdate', () => { + const itemClass = MockKubeObject as unknown as KubeObjectClass; + const initialList = { + kind: 'MockKubeList', + apiVersion: 'v1', + items: [ + { apiVersion: 'v1', kind: 'MockKubeObject', metadata: { uid: '1', resourceVersion: '1' } }, + ], + metadata: { + resourceVersion: '1', + }, + }; + + it('should add a new item on ADDED event', () => { + const updateEvent: KubeListUpdateEvent = { + type: 'ADDED', + object: { + apiVersion: 'v1', + kind: 'MockKubeObject', + metadata: { uid: '2', resourceVersion: '2' }, + }, + }; + + const updatedList = KubeList.applyUpdate(initialList, updateEvent, itemClass); + + expect(updatedList.items).toHaveLength(2); + expect(updatedList.items[1].metadata.uid).toBe('2'); + expect(updatedList.items[1] instanceof MockKubeObject).toBe(true); + }); + + it('should modify an existing item on MODIFIED event', () => { + const updateEvent: KubeListUpdateEvent = { + type: 'MODIFIED', + object: { + apiVersion: 'v1', + kind: 'MockKubeObject', + metadata: { uid: '1', resourceVersion: '2' }, + }, + }; + + const updatedList = KubeList.applyUpdate(initialList, updateEvent, itemClass); + + expect(updatedList.items).toHaveLength(1); + expect(updatedList.items[0].metadata.resourceVersion).toBe('2'); + expect(updatedList.items[0] instanceof MockKubeObject).toBe(true); + }); + + it('should add a new item on MODIFIED event', () => { + const updateEvent: KubeListUpdateEvent = { + type: 'MODIFIED', + object: { + apiVersion: 'v1', + kind: 'MockKubeObject', + metadata: { uid: '3', resourceVersion: '3' }, + }, + }; + + const updatedList = KubeList.applyUpdate(initialList, updateEvent, itemClass); + + expect(updatedList.items).toHaveLength(2); + expect(updatedList.items[1].metadata.uid).toBe('3'); + expect(updatedList.items[1] instanceof MockKubeObject).toBe(true); + }); + + it('should delete an existing item on DELETED event', () => { + const updateEvent: KubeListUpdateEvent = { + type: 'DELETED', + object: { + apiVersion: 'v1', + kind: 'MockKubeObject', + metadata: { uid: '1', resourceVersion: '2' }, + }, + }; + + const updatedList = KubeList.applyUpdate(initialList, updateEvent, itemClass); + + expect(updatedList.items).toHaveLength(0); + }); + + it('should log an error on ERROR event', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const updateEvent: KubeListUpdateEvent = { + type: 'ERROR', + object: { + apiVersion: 'v1', + kind: 'MockKubeObject', + metadata: { uid: '1', resourceVersion: '2' }, + }, + }; + + KubeList.applyUpdate(initialList, updateEvent, itemClass); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error in update', updateEvent); + consoleErrorSpy.mockRestore(); + }); + + it('should log an error on unknown event type', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const updateEvent: KubeListUpdateEvent = { + type: 'UNKNOWN' as any, + object: { + apiVersion: 'v1', + kind: 'MockKubeObject', + metadata: { uid: '1', resourceVersion: '2' }, + }, + }; + + KubeList.applyUpdate(initialList, updateEvent, itemClass); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Unknown update type', updateEvent); + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/frontend/src/lib/k8s/api/v2/KubeObjectEndpoint.test.ts b/frontend/src/lib/k8s/api/v2/KubeObjectEndpoint.test.ts new file mode 100644 index 0000000000..c35e877705 --- /dev/null +++ b/frontend/src/lib/k8s/api/v2/KubeObjectEndpoint.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { KubeObjectEndpoint } from './KubeObjectEndpoint'; + +describe('KubeObjectEndpoint', () => { + describe('toUrl', () => { + it('should generate URL for core resources without namespace', () => { + const endpoint = { version: 'v1', resource: 'pods' }; + const url = KubeObjectEndpoint.toUrl(endpoint); + expect(url).toBe('api/v1/pods'); + }); + + it('should generate URL for core resources with namespace', () => { + const endpoint = { version: 'v1', resource: 'pods' }; + const url = KubeObjectEndpoint.toUrl(endpoint, 'default'); + expect(url).toBe('api/v1/namespaces/default/pods'); + }); + + it('should generate URL for custom resources without namespace', () => { + const endpoint = { group: 'apps', version: 'v1', resource: 'deployments' }; + const url = KubeObjectEndpoint.toUrl(endpoint); + expect(url).toBe('apis/apps/v1/deployments'); + }); + + it('should generate URL for custom resources with namespace', () => { + const endpoint = { group: 'apps', version: 'v1', resource: 'deployments' }; + const url = KubeObjectEndpoint.toUrl(endpoint, 'default'); + expect(url).toBe('apis/apps/v1/namespaces/default/deployments'); + }); + + it('should generate URL for custom resources with empty group', () => { + const endpoint = { group: '', version: 'v1', resource: 'services' }; + const url = KubeObjectEndpoint.toUrl(endpoint); + expect(url).toBe('api/v1/services'); + }); + + it('should generate URL for custom resources with empty group and namespace', () => { + const endpoint = { group: '', version: 'v1', resource: 'services' }; + const url = KubeObjectEndpoint.toUrl(endpoint, 'default'); + expect(url).toBe('api/v1/namespaces/default/services'); + }); + }); +}); diff --git a/frontend/src/lib/k8s/api/v2/fetch.test.ts b/frontend/src/lib/k8s/api/v2/fetch.test.ts new file mode 100644 index 0000000000..b0fa8b3f20 --- /dev/null +++ b/frontend/src/lib/k8s/api/v2/fetch.test.ts @@ -0,0 +1,90 @@ +import nock from 'nock'; +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { findKubeconfigByClusterName, getUserIdFromLocalStorage } from '../../../../stateless'; +import { getToken, setToken } from '../../../auth'; +import { getClusterAuthType } from '../v1/clusterRequests'; +import { BASE_HTTP_URL, clusterFetch } from './fetch'; + +vi.mock('../../../auth', () => ({ + getToken: vi.fn(), + setToken: vi.fn(), +})); + +vi.mock('../../../../stateless', () => ({ + findKubeconfigByClusterName: vi.fn(), + getUserIdFromLocalStorage: vi.fn(), +})); + +vi.mock('../v1/clusterRequests', () => ({ + getClusterAuthType: vi.fn(), +})); + +vi.mock('../v1/tokenApi', () => ({ + refreshToken: vi.fn(), +})); + +describe('clusterFetch', () => { + const clusterName = 'test-cluster'; + const testUrl = '/test/url'; + const mockResponse = { message: 'mock response' }; + const token = 'test-token'; + const newToken = 'new-token'; + const kubeconfig = 'mock-kubeconfig'; + const userID = 'mock-user-id'; + + beforeEach(() => { + vi.resetAllMocks(); + (getToken as Mock).mockReturnValue(token); + (findKubeconfigByClusterName as Mock).mockResolvedValue(kubeconfig); + (getUserIdFromLocalStorage as Mock).mockReturnValue(userID); + (getClusterAuthType as Mock).mockReturnValue('serviceAccount'); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('Successfully makes a request', async () => { + nock(BASE_HTTP_URL).get(`/clusters/${clusterName}${testUrl}`).reply(200, mockResponse); + + const response = await clusterFetch(testUrl, { cluster: clusterName }); + const responseBody = await response.json(); + + expect(responseBody).toEqual(mockResponse); + }); + + it('Sets Authorization header with token', async () => { + nock(BASE_HTTP_URL) + .get(`/clusters/${clusterName}${testUrl}`) + .matchHeader('Authorization', `Bearer ${token}`) + .reply(200, mockResponse); + + await clusterFetch(testUrl, { cluster: clusterName }); + }); + + it('Sets KUBECONFIG and X-HEADLAMP-USER-ID headers if kubeconfig exists', async () => { + nock(BASE_HTTP_URL) + .get(`/clusters/${clusterName}${testUrl}`) + .matchHeader('KUBECONFIG', kubeconfig) + .matchHeader('X-HEADLAMP-USER-ID', userID) + .reply(200, mockResponse); + + await clusterFetch(testUrl, { cluster: clusterName }); + }); + + it('Sets new token if X-Authorization header is present in response', async () => { + nock(BASE_HTTP_URL) + .get(`/clusters/${clusterName}${testUrl}`) + .reply(200, mockResponse, { 'X-Authorization': newToken }); + + await clusterFetch(testUrl, { cluster: clusterName }); + + expect(setToken).toHaveBeenCalledWith(clusterName, newToken); + }); + + it('Throws an error if response is not ok', async () => { + nock(BASE_HTTP_URL).get(`/clusters/${clusterName}${testUrl}`).reply(500); + + await expect(clusterFetch(testUrl, { cluster: clusterName })).rejects.toThrow('Unreachable'); + }); +}); diff --git a/frontend/src/lib/k8s/api/v2/makeUrl.test.ts b/frontend/src/lib/k8s/api/v2/makeUrl.test.ts new file mode 100644 index 0000000000..2dc6c2bcf5 --- /dev/null +++ b/frontend/src/lib/k8s/api/v2/makeUrl.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { makeUrl } from './makeUrl'; + +describe('makeUrl', () => { + it('should create a URL from parts without query parameters', () => { + const urlParts = ['http://example.com', 'path', 'to', 'resource']; + const result = makeUrl(urlParts); + expect(result).toBe('http://example.com/path/to/resource'); + }); + + it('should create a URL from parts with query parameters', () => { + const urlParts = ['http://example.com', 'path', 'to', 'resource']; + const query = { key1: 'value1', key2: 'value2' }; + const result = makeUrl(urlParts, query); + expect(result).toBe('http://example.com/path/to/resource?key1=value1&key2=value2'); + }); + + it('should handle empty urlParts', () => { + const urlParts: any[] = []; + const result = makeUrl(urlParts); + expect(result).toBe(''); + }); + + it('should handle empty query parameters', () => { + const urlParts = ['http://example.com', 'path', 'to', 'resource']; + const query = {}; + const result = makeUrl(urlParts, query); + expect(result).toBe('http://example.com/path/to/resource'); + }); + + it('should replace multiple slashes with a single one', () => { + const urlParts = ['http://example.com/', '/path/', '/to/', '/resource']; + const result = makeUrl(urlParts); + expect(result).toBe('http://example.com/path/to/resource'); + }); + + it('should handle special characters in query parameters', () => { + const urlParts = ['http://example.com', 'path', 'to', 'resource']; + const query = { + 'key with spaces': 'value with spaces', + 'key&with&special&chars': 'value&with&special&chars', + }; + const result = makeUrl(urlParts, query); + expect(result).toBe( + 'http://example.com/path/to/resource?key+with+spaces=value+with+spaces&key%26with%26special%26chars=value%26with%26special%26chars' + ); + }); + + it('should handle numeric and boolean values in urlParts', () => { + const urlParts = ['http://example.com', 123, true, 'resource']; + const result = makeUrl(urlParts); + expect(result).toBe('http://example.com/123/true/resource'); + }); +});