diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ab5b009c..05fe9c4c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -163,7 +163,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -523,7 +522,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -570,7 +568,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3262,7 +3259,8 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3350,7 +3348,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3361,7 +3358,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3401,7 +3397,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -3782,7 +3777,6 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -3818,7 +3812,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4049,7 +4042,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4252,8 +4244,7 @@ "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "peer": true + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, "node_modules/data-urls": { "version": "6.0.0", @@ -4342,7 +4333,8 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true + "dev": true, + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -4506,7 +4498,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5399,7 +5390,6 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -5846,6 +5836,7 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6259,7 +6250,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6289,6 +6279,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6303,6 +6294,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "peer": true, "engines": { "node": ">=8" } @@ -6312,6 +6304,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "peer": true, "engines": { "node": ">=10" }, @@ -6359,7 +6352,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6369,7 +6361,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6414,7 +6405,8 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true + "dev": true, + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -6969,7 +6961,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7007,7 +6998,8 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/update-browserslist-db": { "version": "1.2.2", @@ -7098,7 +7090,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -7174,7 +7165,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -7412,7 +7402,6 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/api/__tests__/websocket.test.ts b/frontend/src/api/__tests__/websocket.test.ts new file mode 100644 index 00000000..cc3cf9b6 --- /dev/null +++ b/frontend/src/api/__tests__/websocket.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getWebSocketConnections, getWebSocketStats } from '../websocket'; +import client from '../client'; + +vi.mock('../client'); + +describe('WebSocket API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getWebSocketConnections', () => { + it('should fetch WebSocket connections', async () => { + const mockResponse = { + connections: [ + { + id: 'test-conn-1', + type: 'logs', + connected_at: '2024-01-15T10:00:00Z', + last_activity_at: '2024-01-15T10:05:00Z', + remote_addr: '192.168.1.1:12345', + user_agent: 'Mozilla/5.0', + filters: 'level=error', + }, + { + id: 'test-conn-2', + type: 'cerberus', + connected_at: '2024-01-15T10:02:00Z', + last_activity_at: '2024-01-15T10:06:00Z', + remote_addr: '192.168.1.2:54321', + user_agent: 'Chrome/90.0', + filters: 'source=waf', + }, + ], + count: 2, + }; + + vi.mocked(client.get).mockResolvedValue({ data: mockResponse }); + + const result = await getWebSocketConnections(); + + expect(client.get).toHaveBeenCalledWith('/websocket/connections'); + expect(result).toEqual(mockResponse); + expect(result.count).toBe(2); + expect(result.connections).toHaveLength(2); + }); + + it('should handle empty connections', async () => { + const mockResponse = { + connections: [], + count: 0, + }; + + vi.mocked(client.get).mockResolvedValue({ data: mockResponse }); + + const result = await getWebSocketConnections(); + + expect(result.connections).toHaveLength(0); + expect(result.count).toBe(0); + }); + + it('should handle API errors', async () => { + vi.mocked(client.get).mockRejectedValue(new Error('Network error')); + + await expect(getWebSocketConnections()).rejects.toThrow('Network error'); + }); + }); + + describe('getWebSocketStats', () => { + it('should fetch WebSocket statistics', async () => { + const mockResponse = { + total_active: 3, + logs_connections: 2, + cerberus_connections: 1, + oldest_connection: '2024-01-15T09:55:00Z', + last_updated: '2024-01-15T10:10:00Z', + }; + + vi.mocked(client.get).mockResolvedValue({ data: mockResponse }); + + const result = await getWebSocketStats(); + + expect(client.get).toHaveBeenCalledWith('/websocket/stats'); + expect(result).toEqual(mockResponse); + expect(result.total_active).toBe(3); + expect(result.logs_connections).toBe(2); + expect(result.cerberus_connections).toBe(1); + }); + + it('should handle stats with no connections', async () => { + const mockResponse = { + total_active: 0, + logs_connections: 0, + cerberus_connections: 0, + last_updated: '2024-01-15T10:10:00Z', + }; + + vi.mocked(client.get).mockResolvedValue({ data: mockResponse }); + + const result = await getWebSocketStats(); + + expect(result.total_active).toBe(0); + expect(result.oldest_connection).toBeUndefined(); + }); + + it('should handle API errors', async () => { + vi.mocked(client.get).mockRejectedValue(new Error('Server error')); + + await expect(getWebSocketStats()).rejects.toThrow('Server error'); + }); + }); +}); diff --git a/frontend/src/components/__tests__/WebSocketStatusCard.test.tsx b/frontend/src/components/__tests__/WebSocketStatusCard.test.tsx new file mode 100644 index 00000000..53bfbe5f --- /dev/null +++ b/frontend/src/components/__tests__/WebSocketStatusCard.test.tsx @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { WebSocketStatusCard } from '../WebSocketStatusCard'; +import * as websocketApi from '../../api/websocket'; + +// Mock the API functions +vi.mock('../../api/websocket'); + +// Mock date-fns to avoid timezone issues in tests +vi.mock('date-fns', () => ({ + formatDistanceToNow: vi.fn(() => '5 minutes ago'), +})); + +describe('WebSocketStatusCard', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + vi.clearAllMocks(); + }); + + const renderComponent = (props = {}) => { + return render( + + + + ); + }; + + it('should render loading state', () => { + vi.mocked(websocketApi.getWebSocketConnections).mockReturnValue( + new Promise(() => {}) // Never resolves + ); + vi.mocked(websocketApi.getWebSocketStats).mockReturnValue( + new Promise(() => {}) // Never resolves + ); + + renderComponent(); + + // Loading state shows skeleton elements + expect(screen.getAllByRole('generic').length).toBeGreaterThan(0); + }); + + it('should render with no active connections', async () => { + vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({ + connections: [], + count: 0, + }); + vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({ + total_active: 0, + logs_connections: 0, + cerberus_connections: 0, + last_updated: '2024-01-15T10:10:00Z', + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('WebSocket Connections')).toBeInTheDocument(); + }); + + expect(screen.getByText('0 Active')).toBeInTheDocument(); + expect(screen.getByText('No active WebSocket connections')).toBeInTheDocument(); + }); + + it('should render with active connections', async () => { + const mockConnections = [ + { + id: 'conn-1', + type: 'logs' as const, + connected_at: '2024-01-15T10:00:00Z', + last_activity_at: '2024-01-15T10:05:00Z', + remote_addr: '192.168.1.1:12345', + filters: 'level=error', + }, + { + id: 'conn-2', + type: 'cerberus' as const, + connected_at: '2024-01-15T10:02:00Z', + last_activity_at: '2024-01-15T10:06:00Z', + remote_addr: '192.168.1.2:54321', + filters: 'source=waf', + }, + ]; + + vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({ + connections: mockConnections, + count: 2, + }); + vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({ + total_active: 2, + logs_connections: 1, + cerberus_connections: 1, + oldest_connection: '2024-01-15T10:00:00Z', + last_updated: '2024-01-15T10:10:00Z', + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('WebSocket Connections')).toBeInTheDocument(); + }); + + expect(screen.getByText('2 Active')).toBeInTheDocument(); + expect(screen.getByText('General Logs')).toBeInTheDocument(); + expect(screen.getByText('Security Logs')).toBeInTheDocument(); + // Use getAllByText since we have two "1" values + const ones = screen.getAllByText('1'); + expect(ones).toHaveLength(2); + }); + + it('should show details when expanded', async () => { + const mockConnections = [ + { + id: 'conn-123', + type: 'logs' as const, + connected_at: '2024-01-15T10:00:00Z', + last_activity_at: '2024-01-15T10:05:00Z', + remote_addr: '192.168.1.1:12345', + filters: 'level=error', + }, + ]; + + vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({ + connections: mockConnections, + count: 1, + }); + vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({ + total_active: 1, + logs_connections: 1, + cerberus_connections: 0, + last_updated: '2024-01-15T10:10:00Z', + }); + + renderComponent({ showDetails: true }); + + await waitFor(() => { + expect(screen.getByText('WebSocket Connections')).toBeInTheDocument(); + }); + + // Check for connection details + expect(screen.getByText('Active Connections')).toBeInTheDocument(); + expect(screen.getByText(/conn-123/i)).toBeInTheDocument(); + expect(screen.getByText('192.168.1.1:12345')).toBeInTheDocument(); + expect(screen.getByText('level=error')).toBeInTheDocument(); + }); + + it('should toggle details on button click', async () => { + const user = userEvent.setup(); + const mockConnections = [ + { + id: 'conn-1', + type: 'logs' as const, + connected_at: '2024-01-15T10:00:00Z', + last_activity_at: '2024-01-15T10:05:00Z', + }, + ]; + + vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({ + connections: mockConnections, + count: 1, + }); + vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({ + total_active: 1, + logs_connections: 1, + cerberus_connections: 0, + last_updated: '2024-01-15T10:10:00Z', + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Show Details')).toBeInTheDocument(); + }); + + // Initially hidden + expect(screen.queryByText('Active Connections')).not.toBeInTheDocument(); + + // Click to show + await user.click(screen.getByText('Show Details')); + + await waitFor(() => { + expect(screen.getByText('Active Connections')).toBeInTheDocument(); + }); + + // Click to hide + await user.click(screen.getByText('Hide Details')); + + await waitFor(() => { + expect(screen.queryByText('Active Connections')).not.toBeInTheDocument(); + }); + }); + + it('should handle API errors gracefully', async () => { + vi.mocked(websocketApi.getWebSocketConnections).mockRejectedValue( + new Error('API Error') + ); + vi.mocked(websocketApi.getWebSocketStats).mockRejectedValue( + new Error('API Error') + ); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Unable to load WebSocket status')).toBeInTheDocument(); + }); + }); + + it('should display oldest connection when available', async () => { + vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({ + connections: [], + count: 1, + }); + vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({ + total_active: 1, + logs_connections: 1, + cerberus_connections: 0, + oldest_connection: '2024-01-15T09:55:00Z', + last_updated: '2024-01-15T10:10:00Z', + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Oldest Connection')).toBeInTheDocument(); + }); + + expect(screen.getByText('5 minutes ago')).toBeInTheDocument(); + }); + + it('should apply custom className', async () => { + vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({ + connections: [], + count: 0, + }); + vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({ + total_active: 0, + logs_connections: 0, + cerberus_connections: 0, + last_updated: '2024-01-15T10:10:00Z', + }); + + const { container } = renderComponent({ className: 'custom-class' }); + + await waitFor(() => { + expect(screen.getByText('WebSocket Connections')).toBeInTheDocument(); + }); + + const card = container.querySelector('.custom-class'); + expect(card).toBeInTheDocument(); + }); +});