diff --git a/package-lock.json b/package-lock.json index f33ba54..9ef1583 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sealed-secrets", - "version": "0.2.21", + "version": "0.2.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sealed-secrets", - "version": "0.2.21", + "version": "0.2.22", "license": "Apache-2.0", "dependencies": { "node-forge": "^1.3.1" diff --git a/package.json b/package.json index 86c44aa..2e980ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sealed-secrets", - "version": "0.2.21", + "version": "0.2.22", "description": "Headlamp plugin for Bitnami Sealed Secrets - manage encrypted Kubernetes secrets", "files": [ "dist", diff --git a/src/components/ControllerStatus.test.tsx b/src/components/ControllerStatus.test.tsx new file mode 100644 index 0000000..922f6c8 --- /dev/null +++ b/src/components/ControllerStatus.test.tsx @@ -0,0 +1,137 @@ +/** + * Unit tests for ControllerStatus component + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +// Mock dependencies +vi.mock('@iconify/react', () => ({ + Icon: ({ icon }: { icon: string }) => {icon}, +})); + +vi.mock('../hooks/useControllerHealth', () => ({ + useControllerHealth: vi.fn(), +})); + +vi.mock('./LoadingSkeletons', () => ({ + ControllerHealthSkeleton: () =>
Loading...
, +})); + +import { useControllerHealth } from '../hooks/useControllerHealth'; +import { ControllerStatus } from './ControllerStatus'; + +const mockUseHealth = vi.mocked(useControllerHealth); + +describe('ControllerStatus', () => { + it('should show skeleton while loading', () => { + mockUseHealth.mockReturnValue({ + health: null, + loading: true, + refresh: vi.fn(), + }); + + render(); + + expect(screen.getByTestId('skeleton')).toBeDefined(); + }); + + it('should show healthy chip when controller is healthy', () => { + mockUseHealth.mockReturnValue({ + health: { + healthy: true, + reachable: true, + version: '0.24.0', + latencyMs: 15, + }, + loading: false, + refresh: vi.fn(), + }); + + render(); + + expect(screen.getByText('Healthy')).toBeDefined(); + }); + + it('should show unhealthy chip when reachable but not healthy', () => { + mockUseHealth.mockReturnValue({ + health: { + healthy: false, + reachable: true, + error: 'HTTP 500: Internal Server Error', + }, + loading: false, + refresh: vi.fn(), + }); + + render(); + + expect(screen.getByText('Unhealthy')).toBeDefined(); + }); + + it('should show unreachable chip when not reachable', () => { + mockUseHealth.mockReturnValue({ + health: { + healthy: false, + reachable: false, + error: 'Connection refused', + }, + loading: false, + refresh: vi.fn(), + }); + + render(); + + expect(screen.getByText('Unreachable')).toBeDefined(); + }); + + it('should show latency and version when showDetails is true and healthy', () => { + mockUseHealth.mockReturnValue({ + health: { + healthy: true, + reachable: true, + version: '0.24.0', + latencyMs: 42, + }, + loading: false, + refresh: vi.fn(), + }); + + render(); + + expect(screen.getByText('42ms')).toBeDefined(); + expect(screen.getByText('v0.24.0')).toBeDefined(); + }); + + it('should not show details when showDetails is false', () => { + mockUseHealth.mockReturnValue({ + health: { + healthy: true, + reachable: true, + version: '0.24.0', + latencyMs: 42, + }, + loading: false, + refresh: vi.fn(), + }); + + render(); + + expect(screen.getByText('Healthy')).toBeDefined(); + expect(screen.queryByText('42ms')).toBeNull(); + expect(screen.queryByText('v0.24.0')).toBeNull(); + }); + + it('should pass autoRefresh and interval to hook', () => { + mockUseHealth.mockReturnValue({ + health: { healthy: true, reachable: true }, + loading: false, + refresh: vi.fn(), + }); + + render(); + + expect(mockUseHealth).toHaveBeenCalledWith(true, 5000); + }); +}); diff --git a/src/components/DecryptDialog.test.tsx b/src/components/DecryptDialog.test.tsx new file mode 100644 index 0000000..7f116cb --- /dev/null +++ b/src/components/DecryptDialog.test.tsx @@ -0,0 +1,167 @@ +/** + * Unit tests for DecryptDialog component + */ + +import { act, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock notistack +const mockEnqueueSnackbar = vi.fn(); +vi.mock('notistack', () => ({ + useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }), +})); + +// Mock iconify +vi.mock('@iconify/react', () => ({ + Icon: ({ icon }: { icon: string }) => {icon}, +})); + +// Mock headlamp +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + K8s: { + ResourceClasses: { + Secret: { + useGet: vi.fn(), + }, + }, + }, +})); + +vi.mock('../lib/SealedSecretCRD', () => ({ + SealedSecret: {}, +})); + +import { K8s } from '@kinvolk/headlamp-plugin/lib'; +import { DecryptDialog } from './DecryptDialog'; + +const mockUseGetSecret = vi.mocked(K8s.ResourceClasses.Secret.useGet); + +describe('DecryptDialog', () => { + const mockSealedSecret = { + metadata: { + name: 'my-secret', + namespace: 'default', + }, + } as never; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + // Mock clipboard + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should show "Secret Not Found" when secret does not exist', () => { + mockUseGetSecret.mockReturnValue([null, null] as never); + + render( + + ); + + expect(screen.getByText('Secret Not Found')).toBeDefined(); + }); + + it('should show "Key Not Found" when key does not exist in secret', () => { + mockUseGetSecret.mockReturnValue([{ data: { other: 'value' } }, null] as never); + + render( + + ); + + expect(screen.getByText('Key Not Found')).toBeDefined(); + expect(screen.getByText('missing-key')).toBeDefined(); + }); + + it('should decode and display base64 value', () => { + const encoded = btoa('my-secret-value'); + mockUseGetSecret.mockReturnValue([{ data: { password: encoded } }, null] as never); + + render( + + ); + + expect(screen.getByText(/Decrypted Value: password/)).toBeDefined(); + // The value should be in a text field (hidden by default as password type) + expect(screen.getByDisplayValue('my-secret-value')).toBeDefined(); + }); + + it('should show countdown timer', () => { + const encoded = btoa('value'); + mockUseGetSecret.mockReturnValue([{ data: { key: encoded } }, null] as never); + + render(); + + expect(screen.getByText(/30 seconds/)).toBeDefined(); + }); + + it('should auto-close after countdown', () => { + const encoded = btoa('value'); + mockUseGetSecret.mockReturnValue([{ data: { key: encoded } }, null] as never); + const onClose = vi.fn(); + + render(); + + // Advance 30 seconds + act(() => { + vi.advanceTimersByTime(30000); + }); + + expect(onClose).toHaveBeenCalled(); + }); + + it('should copy to clipboard', () => { + const encoded = btoa('copy-me'); + mockUseGetSecret.mockReturnValue([{ data: { key: encoded } }, null] as never); + + render(); + + // Click copy button + const copyButton = screen.getByLabelText('Copy value to clipboard'); + fireEvent.click(copyButton); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('copy-me'); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Copied to clipboard', { + variant: 'success', + }); + }); + + it('should toggle show/hide value', () => { + const encoded = btoa('toggle-me'); + mockUseGetSecret.mockReturnValue([{ data: { key: encoded } }, null] as never); + + render(); + + // Initially hidden (password type) + const showButton = screen.getByLabelText('Show secret value'); + fireEvent.click(showButton); + + // Now should show hide button + expect(screen.getByLabelText('Hide secret value')).toBeDefined(); + }); + + it('should close on Close button click', () => { + mockUseGetSecret.mockReturnValue([null, null] as never); + const onClose = vi.fn(); + + render(); + + fireEvent.click(screen.getByLabelText('Close dialog')); + expect(onClose).toHaveBeenCalled(); + }); + + it('should show security warning', () => { + const encoded = btoa('value'); + mockUseGetSecret.mockReturnValue([{ data: { key: encoded } }, null] as never); + + render(); + + expect(screen.getByText(/Security Warning/)).toBeDefined(); + }); +}); diff --git a/src/components/EncryptDialog.test.tsx b/src/components/EncryptDialog.test.tsx new file mode 100644 index 0000000..39fd562 --- /dev/null +++ b/src/components/EncryptDialog.test.tsx @@ -0,0 +1,218 @@ +/** + * Unit tests for EncryptDialog component + */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock notistack +const mockEnqueueSnackbar = vi.fn(); +vi.mock('notistack', () => ({ + useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }), +})); + +// Mock iconify +vi.mock('@iconify/react', () => ({ + Icon: ({ icon }: { icon: string }) => {icon}, +})); + +// Mock headlamp +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + K8s: { + ResourceClasses: { + Namespace: { + useList: vi + .fn() + .mockReturnValue([ + [{ metadata: { name: 'default' } }, { metadata: { name: 'production' } }], + ]), + }, + }, + }, +})); + +// Mock encryption hook +const mockEncrypt = vi.fn(); +vi.mock('../hooks/useSealedSecretEncryption', () => ({ + useSealedSecretEncryption: () => ({ + encrypt: mockEncrypt, + encrypting: false, + }), +})); + +// Mock SealedSecretCRD +vi.mock('../lib/SealedSecretCRD', () => ({ + SealedSecret: { + apiEndpoint: { + post: vi.fn(), + }, + }, +})); + +import { SealedSecret } from '../lib/SealedSecretCRD'; +import { EncryptDialog } from './EncryptDialog'; + +const mockPost = vi.mocked(SealedSecret.apiEndpoint.post); + +describe('EncryptDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockEncrypt.mockResolvedValue({ + ok: true, + value: { + sealedSecretData: { apiVersion: 'bitnami.com/v1alpha1', kind: 'SealedSecret' }, + }, + }); + mockPost.mockResolvedValue({}); + }); + + it('should render dialog when open', () => { + render(); + + expect(screen.getByText('Create Sealed Secret')).toBeDefined(); + expect(screen.getByLabelText('Secret name')).toBeDefined(); + }); + + it('should not render when closed', () => { + render(); + + expect(screen.queryByText('Create Sealed Secret')).toBeNull(); + }); + + it('should have one key-value pair by default', () => { + render(); + + expect(screen.getByLabelText('Key name 1')).toBeDefined(); + }); + + it('should add key-value pair on button click', () => { + render(); + + fireEvent.click(screen.getByLabelText('Add another key-value pair')); + + expect(screen.getByLabelText('Key name 2')).toBeDefined(); + }); + + it('should not allow removing last key-value pair', () => { + render(); + + const removeButton = screen.getByLabelText('Remove key-value pair 1'); + expect(removeButton).toHaveAttribute('disabled'); + }); + + it('should allow removing when multiple pairs exist', () => { + render(); + + // Add a pair + fireEvent.click(screen.getByLabelText('Add another key-value pair')); + + // Both remove buttons should be enabled + const removeButtons = screen.getAllByLabelText(/Remove key-value pair/); + expect(removeButtons).toHaveLength(2); + + // Remove one + fireEvent.click(removeButtons[1]); + + expect(screen.queryByLabelText('Key name 2')).toBeNull(); + }); + + it('should call encrypt and post on submit', async () => { + const onClose = vi.fn(); + render(); + + // Fill in name + const nameInput = screen.getByLabelText('Secret name'); + fireEvent.change(nameInput, { target: { value: 'my-secret' } }); + + // Fill in key-value + fireEvent.change(screen.getByLabelText('Key name 1'), { + target: { value: 'password' }, + }); + fireEvent.change(screen.getByLabelText(/Secret value for password/), { + target: { value: 'secret123' }, + }); + + // Submit + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(mockEncrypt).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'my-secret', + namespace: 'default', + scope: 'strict', + keyValues: [{ key: 'password', value: 'secret123' }], + }) + ); + }); + + await waitFor(() => { + expect(mockPost).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('SealedSecret created successfully', { + variant: 'success', + }); + }); + }); + + it('should not submit when encryption fails', async () => { + mockEncrypt.mockResolvedValue({ ok: false, error: 'Encryption failed' }); + + render(); + + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(mockEncrypt).toHaveBeenCalled(); + }); + + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('should show error when API post fails', async () => { + mockPost.mockRejectedValue(new Error('API error')); + + render(); + + fireEvent.change(screen.getByLabelText('Key name 1'), { + target: { value: 'k' }, + }); + fireEvent.change(screen.getByLabelText(/Secret value for k/), { + target: { value: 'v' }, + }); + + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(mockEnqueueSnackbar).toHaveBeenCalledWith( + expect.stringContaining('Failed to create SealedSecret'), + { variant: 'error' } + ); + }); + }); + + it('should call onClose on Cancel', () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText('Cancel creation')); + expect(onClose).toHaveBeenCalled(); + }); + + it('should show security note', () => { + render(); + + expect(screen.getByText(/Security Note/)).toBeDefined(); + expect(screen.getByText(/encrypted entirely in your browser/)).toBeDefined(); + }); + + it('should toggle password visibility', () => { + render(); + + const toggleButton = screen.getByLabelText('Show password'); + fireEvent.click(toggleButton); + + expect(screen.getByLabelText('Hide password')).toBeDefined(); + }); +}); diff --git a/src/components/EncryptDialog.tsx b/src/components/EncryptDialog.tsx index cf2096a..93c97eb 100644 --- a/src/components/EncryptDialog.tsx +++ b/src/components/EncryptDialog.tsx @@ -115,8 +115,11 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) { setScope('strict'); setKeyValues([{ key: '', value: '', showValue: false }]); onClose(); - } catch (error: any) { - enqueueSnackbar(`Failed to create SealedSecret: ${error.message}`, { variant: 'error' }); + } catch (error: unknown) { + enqueueSnackbar( + `Failed to create SealedSecret: ${error instanceof Error ? error.message : String(error)}`, + { variant: 'error' } + ); } }; diff --git a/src/components/ErrorBoundary.test.tsx b/src/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..fd3c791 --- /dev/null +++ b/src/components/ErrorBoundary.test.tsx @@ -0,0 +1,150 @@ +/** + * Unit tests for ErrorBoundary components + */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock MUI and iconify +vi.mock('@iconify/react', () => ({ + Icon: ({ icon }: { icon: string }) => {icon}, +})); + +import { ApiErrorBoundary, GenericErrorBoundary } from './ErrorBoundary'; + +// Suppress console.error from error boundaries in tests +const originalError = console.error; +beforeEach(() => { + console.error = vi.fn(); +}); + +afterEach(() => { + console.error = originalError; +}); + +function ThrowingComponent({ error }: { error: Error }): React.ReactNode { + throw error; +} + +function GoodComponent() { + return
Working fine
; +} + +describe('ErrorBoundary', () => { + describe('ApiErrorBoundary', () => { + it('should render children when no error', () => { + render( + + + + ); + + expect(screen.getByText('Working fine')).toBeDefined(); + }); + + it('should catch errors and show API error UI', () => { + render( + + + + ); + + expect(screen.getByText('API Communication Error')).toBeDefined(); + expect(screen.getByText(/API connection failed/)).toBeDefined(); + }); + + it('should show retry button that resets error', () => { + render( + + + + ); + + expect(screen.getByText('API Communication Error')).toBeDefined(); + + // Click retry + fireEvent.click(screen.getByText('Retry')); + + // After reset, it will try to render children again (which will throw again) + // The boundary should catch it again + expect(screen.getByText('API Communication Error')).toBeDefined(); + }); + + it('should render custom fallback if provided', () => { + render( + Custom fallback}> + + + ); + + expect(screen.getByText('Custom fallback')).toBeDefined(); + }); + + it('should call onReset when retry is clicked', () => { + const onReset = vi.fn(); + render( + + + + ); + + fireEvent.click(screen.getByText('Retry')); + expect(onReset).toHaveBeenCalledTimes(1); + }); + + it('should show guidance about troubleshooting', () => { + render( + + + + ); + + expect(screen.getByText(/Kubernetes cluster is accessible/)).toBeDefined(); + expect(screen.getByText(/Sealed Secrets controller is running/)).toBeDefined(); + }); + }); + + describe('GenericErrorBoundary', () => { + it('should render children when no error', () => { + render( + + + + ); + + expect(screen.getByText('Working fine')).toBeDefined(); + }); + + it('should catch errors and show generic error UI', () => { + render( + + + + ); + + expect(screen.getByText('Something Went Wrong')).toBeDefined(); + expect(screen.getByText(/Unexpected error/)).toBeDefined(); + }); + + it('should show reload button', () => { + render( + + + + ); + + expect(screen.getByText('Reload')).toBeDefined(); + }); + + it('should render custom fallback', () => { + render( + Custom error view}> + + + ); + + expect(screen.getByText('Custom error view')).toBeDefined(); + }); + }); +}); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 50f0646..8b221b3 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -63,58 +63,6 @@ abstract class BaseErrorBoundary extends Component - } - action={ - - } - > - - Cryptographic Operation Failed - - - An error occurred during encryption or decryption. This might indicate: - -
    -
  • Invalid or expired controller certificate
  • -
  • Browser cryptography compatibility issue
  • -
  • Malformed secret data
  • -
  • Controller not reachable or misconfigured
  • -
- {this.state.error && ( - - {(() => { - try { - const msg = this.state.error.message || this.state.error.toString(); - return `Error: ${String(msg)}`; - } catch (e) { - return 'Error: [Unable to display error message]'; - } - })()} - - )} -
- - ); - } -} - /** * Error boundary for API operations * diff --git a/src/components/LoadingSkeletons.test.tsx b/src/components/LoadingSkeletons.test.tsx new file mode 100644 index 0000000..210d670 --- /dev/null +++ b/src/components/LoadingSkeletons.test.tsx @@ -0,0 +1,47 @@ +/** + * Unit tests for LoadingSkeletons components + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { + ControllerHealthSkeleton, + SealedSecretDetailSkeleton, + SealedSecretListSkeleton, + SealingKeysListSkeleton, +} from './LoadingSkeletons'; + +describe('LoadingSkeletons', () => { + it('should render SealedSecretListSkeleton without errors', () => { + const { container } = render(); + expect(container.querySelector('.MuiSkeleton-root')).toBeTruthy(); + }); + + it('should render SealedSecretDetailSkeleton without errors', () => { + const { container } = render(); + expect(container.querySelector('.MuiSkeleton-root')).toBeTruthy(); + }); + + it('should render SealingKeysListSkeleton without errors', () => { + const { container } = render(); + expect(container.querySelector('.MuiSkeleton-root')).toBeTruthy(); + }); + + it('should render ControllerHealthSkeleton without errors', () => { + const { container } = render(); + expect(container.querySelector('.MuiSkeleton-root')).toBeTruthy(); + }); + + it('should render list skeleton with multiple rows', () => { + const { container } = render(); + const skeletons = container.querySelectorAll('.MuiSkeleton-root'); + expect(skeletons.length).toBe(5); + }); + + it('should render detail skeleton with multiple sections', () => { + const { container } = render(); + const skeletons = container.querySelectorAll('.MuiSkeleton-root'); + expect(skeletons.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/src/components/LoadingSkeletons.tsx b/src/components/LoadingSkeletons.tsx index f11fd4d..3e408db 100644 --- a/src/components/LoadingSkeletons.tsx +++ b/src/components/LoadingSkeletons.tsx @@ -116,22 +116,6 @@ export function SealingKeysListSkeleton() { ); } -/** - * Skeleton for certificate information - * - * Shows placeholder for certificate metadata - */ -export function CertificateInfoSkeleton() { - return ( - - - - - - - ); -} - /** * Skeleton for controller health status * diff --git a/src/components/SealedSecretDetail.test.tsx b/src/components/SealedSecretDetail.test.tsx new file mode 100644 index 0000000..2b1da10 --- /dev/null +++ b/src/components/SealedSecretDetail.test.tsx @@ -0,0 +1,262 @@ +/** + * Unit tests for SealedSecretDetail component + */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock react-router-dom +vi.mock('react-router-dom', () => ({ + useParams: vi.fn().mockReturnValue({ namespace: 'default', name: 'my-secret' }), +})); + +// Mock notistack +const mockEnqueueSnackbar = vi.fn(); +vi.mock('notistack', () => ({ + useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }), +})); + +// Mock iconify +vi.mock('@iconify/react', () => ({ + Icon: ({ icon }: { icon: string }) => {icon}, +})); + +// Mock headlamp +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + K8s: { + ResourceClasses: { + Secret: { + useGet: vi.fn().mockReturnValue([null, null]), + }, + }, + }, +})); + +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + Link: ({ children }: { children: React.ReactNode }) => {children}, + NameValueTable: ({ rows }: { rows: Array<{ name: string; value: unknown; hide?: boolean }> }) => ( + + + {rows + .filter(r => !r.hide) + .map((row, i) => ( + + + + + ))} + +
{row.name}{typeof row.value === 'string' ? row.value : <>{row.value}}
+ ), + SectionBox: ({ title, children }: { title: React.ReactNode; children: React.ReactNode }) => ( +
+
{title}
+ {children} +
+ ), + SimpleTable: ({ data }: { data: unknown[] }) => ( + + + {(data || []).map((_, i) => ( + + + + ))} + +
row
+ ), + StatusLabel: ({ children }: { children: React.ReactNode }) => {children}, +})); + +// Mock hooks and libs +vi.mock('../hooks/usePermissions', () => ({ + usePermissions: vi.fn().mockReturnValue({ + permissions: { + canCreate: true, + canRead: true, + canUpdate: true, + canDelete: true, + canList: true, + }, + loading: false, + }), +})); + +vi.mock('../lib/controller', () => ({ + getPluginConfig: vi.fn().mockReturnValue({ + controllerName: 'sealed-secrets-controller', + controllerNamespace: 'kube-system', + controllerPort: 8080, + }), + rotateSealedSecret: vi.fn(), +})); + +vi.mock('../lib/rbac', () => ({ + canDecryptSecrets: vi.fn().mockResolvedValue(true), +})); + +vi.mock('../lib/SealedSecretCRD', () => ({ + SealedSecret: { + useGet: vi.fn(), + }, +})); + +vi.mock('./DecryptDialog', () => ({ + DecryptDialog: () =>
, +})); + +vi.mock('./LoadingSkeletons', () => ({ + SealedSecretDetailSkeleton: () =>
Loading...
, +})); + +import { useParams } from 'react-router-dom'; +import { usePermissions } from '../hooks/usePermissions'; +import { rotateSealedSecret } from '../lib/controller'; +import { SealedSecret } from '../lib/SealedSecretCRD'; +import { SealedSecretDetail } from './SealedSecretDetail'; + +const mockUseGet = vi.mocked(SealedSecret.useGet); +const mockRotate = vi.mocked(rotateSealedSecret); +const mockUsePermissions = vi.mocked(usePermissions); +const mockUseParams = vi.mocked(useParams); + +describe('SealedSecretDetail', () => { + const mockSealedSecret = { + metadata: { + name: 'my-secret', + namespace: 'default', + creationTimestamp: '2024-01-01T00:00:00Z', + }, + spec: { + encryptedData: { + password: 'encrypted-value-1', + token: 'encrypted-value-2', + }, + template: { + type: 'Opaque', + metadata: {}, + }, + }, + scope: 'strict', + isSynced: true, + syncCondition: { type: 'Synced', status: 'True' }, + syncMessage: 'Secret synced successfully', + getAge: () => '2d', + jsonData: { spec: { encryptedData: {} } }, + delete: vi.fn().mockResolvedValue(undefined), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseParams.mockReturnValue({ namespace: 'default', name: 'my-secret' }); + mockUseGet.mockReturnValue([mockSealedSecret, null] as never); + mockUsePermissions.mockReturnValue({ + permissions: { + canCreate: true, + canRead: true, + canUpdate: true, + canDelete: true, + canList: true, + }, + loading: false, + error: null, + }); + mockRotate.mockResolvedValue({ ok: true, value: 'rotated' }); + }); + + it('should show skeleton when loading', () => { + mockUseGet.mockReturnValue([null, null] as never); + + render(); + + expect(screen.getByTestId('skeleton')).toBeDefined(); + }); + + it('should show error when fetch fails', () => { + mockUseGet.mockReturnValue([null, 'Not found'] as never); + + render(); + + expect(screen.getByText('Failed to load SealedSecret')).toBeDefined(); + }); + + it('should show skeleton when params are missing', () => { + mockUseParams.mockReturnValue({}); + + render(); + + expect(screen.getByTestId('skeleton')).toBeDefined(); + }); + + it('should render detail view with data', () => { + render(); + + expect(screen.getAllByText('my-secret').length).toBeGreaterThan(0); + expect(screen.getAllByText('default').length).toBeGreaterThan(0); + expect(screen.getByText('Strict')).toBeDefined(); + expect(screen.getByText('Synced')).toBeDefined(); + }); + + it('should render detail content inside drawer', () => { + render(); + + // Drawer content includes the secret name (appears in title and table) + expect(screen.getAllByText('my-secret').length).toBeGreaterThan(0); + }); + + it('should render encrypted data section', () => { + render(); + + expect(screen.getByTestId('encrypted-table')).toBeDefined(); + }); + + it('should render action buttons when user has permissions', () => { + render(); + + // Buttons are inside a MUI Drawer (portal). Check they exist in the document. + const buttons = Array.from(document.querySelectorAll('button')); + const reencryptBtn = buttons.find(b => b.textContent === 'Re-encrypt'); + const deleteBtn = buttons.find(b => b.textContent === 'Delete'); + expect(reencryptBtn || deleteBtn).toBeTruthy(); + }); + + it('should handle rotate success via Result check', async () => { + mockRotate.mockResolvedValue({ ok: true, value: 'rotated-yaml' }); + + render(); + + // Find and click Re-encrypt button (rendered in Drawer portal) + const buttons = Array.from(document.querySelectorAll('button')); + const reencryptBtn = buttons.find(b => b.textContent === 'Re-encrypt'); + if (reencryptBtn) { + fireEvent.click(reencryptBtn); + + await waitFor(() => { + expect(mockRotate).toHaveBeenCalled(); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('SealedSecret re-encrypted successfully', { + variant: 'success', + }); + }); + } + }); + + it('should handle rotate failure (Result error)', async () => { + mockRotate.mockResolvedValue({ ok: false, error: 'Rotation failed: 400' }); + + render(); + + const buttons = Array.from(document.querySelectorAll('button')); + const reencryptBtn = buttons.find(b => b.textContent === 'Re-encrypt'); + if (reencryptBtn) { + fireEvent.click(reencryptBtn); + + await waitFor(() => { + expect(mockEnqueueSnackbar).toHaveBeenCalledWith( + 'Failed to re-encrypt: Rotation failed: 400', + { variant: 'error' } + ); + }); + } + }); +}); diff --git a/src/components/SealedSecretDetail.tsx b/src/components/SealedSecretDetail.tsx index 52c5b19..a9e0d07 100644 --- a/src/components/SealedSecretDetail.tsx +++ b/src/components/SealedSecretDetail.tsx @@ -71,7 +71,7 @@ export function SealedSecretDetail() { React.useEffect(() => { let cancelled = false; if (namespace) { - canDecryptSecrets(namespace).then((result) => { + canDecryptSecrets(namespace).then(result => { if (!cancelled) setCanDecrypt(result); }); } @@ -110,8 +110,11 @@ export function SealedSecretDetail() { await sealedSecret.delete(); enqueueSnackbar('SealedSecret deleted successfully', { variant: 'success' }); window.history.back(); - } catch (error: any) { - enqueueSnackbar(`Failed to delete SealedSecret: ${error.message}`, { variant: 'error' }); + } catch (error: unknown) { + enqueueSnackbar( + `Failed to delete SealedSecret: ${error instanceof Error ? error.message : String(error)}`, + { variant: 'error' } + ); } setDeleteDialogOpen(false); }, [sealedSecret, enqueueSnackbar]); @@ -121,11 +124,17 @@ export function SealedSecretDetail() { try { const config = getPluginConfig(); const yaml = JSON.stringify(sealedSecret.jsonData); - await rotateSealedSecret(config, yaml); - enqueueSnackbar('SealedSecret re-encrypted successfully', { variant: 'success' }); - // The resource will auto-refresh via the watch - } catch (error: any) { - enqueueSnackbar(`Failed to re-encrypt: ${error.message}`, { variant: 'error' }); + const result = await rotateSealedSecret(config, yaml); + if (result.ok === false) { + enqueueSnackbar(`Failed to re-encrypt: ${result.error}`, { variant: 'error' }); + } else { + enqueueSnackbar('SealedSecret re-encrypted successfully', { variant: 'success' }); + } + } catch (error: unknown) { + enqueueSnackbar( + `Failed to re-encrypt: ${error instanceof Error ? error.message : String(error)}`, + { variant: 'error' } + ); } finally { setRotating(false); } @@ -160,7 +169,12 @@ export function SealedSecretDetail() { title={ - + {sealedSecret.metadata.name} @@ -252,11 +266,20 @@ export function SealedSecretDetail() { label: 'Actions', getter: (row: { key: string; value: string }) => canDecrypt ? ( - ) : ( - ), @@ -337,7 +360,11 @@ export function SealedSecretDetail() { /> )} - setDeleteDialogOpen(false)} aria-labelledby="delete-dialog-title"> + setDeleteDialogOpen(false)} + aria-labelledby="delete-dialog-title" + > Delete SealedSecret? Are you sure you want to delete the SealedSecret {name}? This will also diff --git a/src/components/SealedSecretList.test.tsx b/src/components/SealedSecretList.test.tsx new file mode 100644 index 0000000..ebf8f77 --- /dev/null +++ b/src/components/SealedSecretList.test.tsx @@ -0,0 +1,160 @@ +/** + * Unit tests for SealedSecretList component + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock react-router-dom +vi.mock('react-router-dom', () => ({ + useParams: vi.fn().mockReturnValue({}), +})); + +// Mock headlamp +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + Link: ({ children }: { children: React.ReactNode }) => {children}, + SectionBox: ({ title, children }: { title: string; children: React.ReactNode }) => ( +
+

{title}

+ {children} +
+ ), + SectionFilterHeader: ({ actions }: { actions?: React.ReactNode[] }) => ( +
+ {actions?.map((action, i) => ( +
{action}
+ ))} +
+ ), + SimpleTable: ({ data }: { data: unknown[] }) => ( + + + {(data || []).map((_, i) => ( + + + + ))} + +
row {i}
+ ), + StatusLabel: ({ children }: { children: React.ReactNode }) => {children}, +})); + +// Mock SealedSecretCRD +vi.mock('../lib/SealedSecretCRD', () => ({ + SealedSecret: { + useList: vi.fn(), + }, +})); + +// Mock hooks +vi.mock('../hooks/usePermissions', () => ({ + usePermission: vi.fn().mockReturnValue({ loading: false, allowed: true }), +})); + +// Mock sub-components +vi.mock('./EncryptDialog', () => ({ + EncryptDialog: () =>
, +})); + +vi.mock('./LoadingSkeletons', () => ({ + SealedSecretListSkeleton: () =>
Loading...
, +})); + +vi.mock('./SealedSecretDetail', () => ({ + SealedSecretDetail: () =>
, +})); + +vi.mock('./VersionWarning', () => ({ + VersionWarning: () =>
, +})); + +import { usePermission } from '../hooks/usePermissions'; +import { SealedSecret } from '../lib/SealedSecretCRD'; +import { SealedSecretList } from './SealedSecretList'; + +const mockUseList = vi.mocked(SealedSecret.useList); +const mockUsePermission = vi.mocked(usePermission); + +describe('SealedSecretList', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + }); + + it('should show loading skeleton', () => { + mockUseList.mockReturnValue([null, null, true] as never); + + render(); + + expect(screen.getByTestId('skeleton')).toBeDefined(); + }); + + it('should show error when fetch fails', () => { + mockUseList.mockReturnValue([null, { message: 'Failed to fetch' }, false] as never); + + render(); + + expect(screen.getByText(/Failed to load Sealed Secrets/)).toBeDefined(); + }); + + it('should show 404 hint when CRD not found', () => { + mockUseList.mockReturnValue([null, { message: '404 Not Found' }, false] as never); + + render(); + + expect(screen.getByText(/CRD not found/)).toBeDefined(); + expect(screen.getByText(/kubectl apply/)).toBeDefined(); + }); + + it('should render table with data', () => { + const mockSecrets = [ + { + metadata: { name: 'secret-1', namespace: 'default' }, + scope: 'strict', + encryptedKeysCount: 2, + isSynced: true, + getAge: () => '1d', + }, + { + metadata: { name: 'secret-2', namespace: 'prod' }, + scope: 'namespace-wide', + encryptedKeysCount: 1, + isSynced: false, + getAge: () => '3h', + }, + ]; + mockUseList.mockReturnValue([mockSecrets, null, false] as never); + + render(); + + expect(screen.getByTestId('simple-table')).toBeDefined(); + }); + + it('should show create button when user has create permission', () => { + mockUseList.mockReturnValue([[], null, false] as never); + mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + + render(); + + expect(screen.getByText('Create Sealed Secret')).toBeDefined(); + }); + + it('should hide create button when user lacks create permission', () => { + mockUseList.mockReturnValue([[], null, false] as never); + mockUsePermission.mockReturnValue({ loading: false, allowed: false }); + + render(); + + expect(screen.queryByText('Create Sealed Secret')).toBeNull(); + }); + + it('should render VersionWarning', () => { + mockUseList.mockReturnValue([[], null, false] as never); + + render(); + + expect(screen.getByTestId('version-warning')).toBeDefined(); + }); +}); diff --git a/src/components/SealingKeysView.test.tsx b/src/components/SealingKeysView.test.tsx new file mode 100644 index 0000000..0a73090 --- /dev/null +++ b/src/components/SealingKeysView.test.tsx @@ -0,0 +1,256 @@ +/** + * Unit tests for SealingKeysView component + */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock notistack +const mockEnqueueSnackbar = vi.fn(); +vi.mock('notistack', () => ({ + useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }), +})); + +// Mock headlamp +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + K8s: { + ResourceClasses: { + Secret: { + useList: vi.fn(), + }, + }, + }, +})); + +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + SectionBox: ({ + title, + children, + headerProps, + }: { + title: string; + children: React.ReactNode; + headerProps?: { actions?: React.ReactNode[] }; + }) => ( +
+

{title}

+
+ {headerProps?.actions?.map((action, i) => ( +
{action}
+ ))} +
+ {children} +
+ ), + SimpleTable: ({ + data, + columns, + }: { + data: unknown[]; + columns: Array<{ label: string; getter: (row: unknown) => React.ReactNode }>; + }) => ( + + + + {columns.map((col, i) => ( + + ))} + + + + {data.map((row, i) => ( + + {columns.map((col, j) => ( + + ))} + + ))} + +
{col.label}
{col.getter(row)}
+ ), + StatusLabel: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('../lib/controller', () => ({ + getPluginConfig: vi.fn().mockReturnValue({ + controllerName: 'sealed-secrets-controller', + controllerNamespace: 'kube-system', + controllerPort: 8080, + }), + fetchPublicCertificate: vi.fn(), +})); + +vi.mock('../lib/crypto', () => ({ + parseCertificateInfo: vi.fn().mockReturnValue({ ok: false, error: 'no cert' }), + isCertificateExpiringSoon: vi.fn().mockReturnValue(false), +})); + +vi.mock('./ControllerStatus', () => ({ + ControllerStatus: () =>
Status
, +})); + +vi.mock('./LoadingSkeletons', () => ({ + SealingKeysListSkeleton: () =>
Loading...
, +})); + +import { K8s } from '@kinvolk/headlamp-plugin/lib'; +import { fetchPublicCertificate } from '../lib/controller'; +import { SealingKeysView } from './SealingKeysView'; + +const mockUseList = vi.mocked(K8s.ResourceClasses.Secret.useList); +const mockFetchCert = vi.mocked(fetchPublicCertificate); + +describe('SealingKeysView', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should show loading skeleton', () => { + mockUseList.mockReturnValue([null, null, true] as never); + + render(); + + expect(screen.getByTestId('skeleton')).toBeDefined(); + }); + + it('should show empty message when no sealing keys found', () => { + mockUseList.mockReturnValue([[], null, false] as never); + + render(); + + expect(screen.getByText(/No sealing keys found/)).toBeDefined(); + }); + + it('should render sealing keys table', () => { + const secrets = [ + { + metadata: { + name: 'sealed-secrets-key-abc', + labels: { 'sealedsecrets.bitnami.com/sealed-secrets-key': 'active' }, + creationTimestamp: '2024-01-01T00:00:00Z', + }, + data: {}, + }, + { + metadata: { + name: 'sealed-secrets-key-old', + labels: { 'sealedsecrets.bitnami.com/sealed-secrets-key': 'compromised' }, + creationTimestamp: '2023-06-01T00:00:00Z', + }, + data: {}, + }, + ]; + mockUseList.mockReturnValue([secrets, null, false] as never); + + render(); + + expect(screen.getByTestId('keys-table')).toBeDefined(); + expect(screen.getByText('sealed-secrets-key-abc')).toBeDefined(); + expect(screen.getByText('sealed-secrets-key-old')).toBeDefined(); + }); + + it('should filter non-sealing-key secrets', () => { + const secrets = [ + { + metadata: { + name: 'sealing-key', + labels: { 'sealedsecrets.bitnami.com/sealed-secrets-key': 'active' }, + creationTimestamp: '2024-01-01T00:00:00Z', + }, + data: {}, + }, + { + metadata: { + name: 'other-secret', + labels: {}, + creationTimestamp: '2024-01-01T00:00:00Z', + }, + data: {}, + }, + ]; + mockUseList.mockReturnValue([secrets, null, false] as never); + + render(); + + expect(screen.getByText('sealing-key')).toBeDefined(); + expect(screen.queryByText('other-secret')).toBeNull(); + }); + + it('should sort active keys before compromised', () => { + const secrets = [ + { + metadata: { + name: 'compromised-key', + labels: { 'sealedsecrets.bitnami.com/sealed-secrets-key': 'compromised' }, + creationTimestamp: '2024-06-01T00:00:00Z', + }, + data: {}, + }, + { + metadata: { + name: 'active-key', + labels: { 'sealedsecrets.bitnami.com/sealed-secrets-key': 'active' }, + creationTimestamp: '2024-01-01T00:00:00Z', + }, + data: {}, + }, + ]; + mockUseList.mockReturnValue([secrets, null, false] as never); + + render(); + + const rows = screen.getAllByRole('row'); + // First data row should be active key (after header row) + expect(rows[1].textContent).toContain('active-key'); + }); + + it('should show download certificate button', () => { + mockUseList.mockReturnValue([[], null, false] as never); + + render(); + + expect(screen.getByText('Download Public Certificate')).toBeDefined(); + }); + + it('should handle certificate download failure', async () => { + mockUseList.mockReturnValue([[], null, false] as never); + mockFetchCert.mockResolvedValue({ ok: false, error: 'Network error' }); + + render(); + + fireEvent.click(screen.getByText('Download Public Certificate')); + + await waitFor(() => { + expect(mockEnqueueSnackbar).toHaveBeenCalledWith( + expect.stringContaining('Failed to download certificate'), + { variant: 'error' } + ); + }); + }); + + it('should call fetchPublicCertificate on download click', async () => { + mockUseList.mockReturnValue([[], null, false] as never); + mockFetchCert.mockResolvedValue({ ok: true, value: 'cert-pem' as never }); + + // Mock Blob/URL to prevent DOM issues + global.URL.createObjectURL = vi.fn().mockReturnValue('blob:url'); + global.URL.revokeObjectURL = vi.fn(); + + render(); + + fireEvent.click(screen.getByText('Download Public Certificate')); + + await waitFor(() => { + expect(mockFetchCert).toHaveBeenCalled(); + }); + }); + + it('should show ControllerStatus in header', () => { + mockUseList.mockReturnValue([[], null, false] as never); + + render(); + + expect(screen.getByTestId('controller-status')).toBeDefined(); + }); +}); diff --git a/src/components/SealingKeysView.tsx b/src/components/SealingKeysView.tsx index 22a96d3..eb58dc5 100644 --- a/src/components/SealingKeysView.tsx +++ b/src/components/SealingKeysView.tsx @@ -94,8 +94,11 @@ export function SealingKeysView() { document.body.removeChild(a); URL.revokeObjectURL(url); enqueueSnackbar('Certificate downloaded', { variant: 'success' }); - } catch (error: any) { - enqueueSnackbar(`Failed to create download: ${error.message}`, { variant: 'error' }); + } catch (error: unknown) { + enqueueSnackbar( + `Failed to create download: ${error instanceof Error ? error.message : String(error)}`, + { variant: 'error' } + ); } }; @@ -189,7 +192,12 @@ export function SealingKeysView() { return ( {expiryDate} - + ({certInfo.daysUntilExpiry} days) diff --git a/src/components/SecretDetailsSection.test.tsx b/src/components/SecretDetailsSection.test.tsx new file mode 100644 index 0000000..0cba0fb --- /dev/null +++ b/src/components/SecretDetailsSection.test.tsx @@ -0,0 +1,190 @@ +/** + * Unit tests for SecretDetailsSection component + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +// Mock headlamp +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + K8s: { ResourceClasses: {} }, +})); + +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + Link: ({ children, ...props }: { children: React.ReactNode }) => ( + + {children} + + ), + NameValueTable: ({ rows }: { rows: Array<{ name: string; value: unknown }> }) => ( + + + {rows.map((row, i) => ( + + + + + ))} + +
{row.name}{typeof row.value === 'string' ? row.value : <>{row.value}}
+ ), + SectionBox: ({ title, children }: { title: string; children: React.ReactNode }) => ( +
+

{title}

+ {children} +
+ ), + StatusLabel: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('../lib/SealedSecretCRD', () => ({ + SealedSecret: { + useGet: vi.fn(), + }, +})); + +import { SealedSecret } from '../lib/SealedSecretCRD'; +import { SecretDetailsSection } from './SecretDetailsSection'; + +const mockUseGet = vi.mocked(SealedSecret.useGet); + +describe('SecretDetailsSection', () => { + it('should return null when Secret has no SealedSecret owner', () => { + const resource = { + kind: 'Secret', + metadata: { + name: 'my-secret', + namespace: 'default', + ownerReferences: [ + { kind: 'Deployment', apiVersion: 'apps/v1', name: 'my-deploy', uid: '123' }, + ], + }, + }; + + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('should return null when Secret has no owner references', () => { + const resource = { + kind: 'Secret', + metadata: { + name: 'my-secret', + namespace: 'default', + }, + }; + + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('should show loading text when SealedSecret is still loading', () => { + mockUseGet.mockReturnValue([null, null] as never); + + const resource = { + kind: 'Secret', + metadata: { + name: 'my-secret', + namespace: 'default', + ownerReferences: [ + { + kind: 'SealedSecret', + apiVersion: 'bitnami.com/v1alpha1', + name: 'my-sealed-secret', + uid: '456', + }, + ], + }, + }; + + render(); + + expect(screen.getByText('Sealed Secret')).toBeDefined(); + expect(screen.getByText('Loading SealedSecret information...')).toBeDefined(); + }); + + it('should display SealedSecret info when loaded', () => { + const mockSealedSecret = { + metadata: { + name: 'my-sealed-secret', + namespace: 'default', + }, + scope: 'strict', + isSynced: true, + }; + mockUseGet.mockReturnValue([mockSealedSecret, null] as never); + + const resource = { + kind: 'Secret', + metadata: { + name: 'my-secret', + namespace: 'default', + ownerReferences: [ + { + kind: 'SealedSecret', + apiVersion: 'bitnami.com/v1alpha1', + name: 'my-sealed-secret', + uid: '789', + }, + ], + }, + }; + + render(); + + expect(screen.getByText('Sealed Secret')).toBeDefined(); + expect(screen.getByText('my-sealed-secret')).toBeDefined(); + expect(screen.getByText('Synced')).toBeDefined(); + }); + + it('should show Not Synced status for unsynced SealedSecret', () => { + const mockSealedSecret = { + metadata: { name: 'ss', namespace: 'default' }, + scope: 'namespace-wide', + isSynced: false, + }; + mockUseGet.mockReturnValue([mockSealedSecret, null] as never); + + const resource = { + kind: 'Secret', + metadata: { + name: 'my-secret', + namespace: 'default', + ownerReferences: [ + { + kind: 'SealedSecret', + apiVersion: 'bitnami.com/v1alpha1', + name: 'ss', + uid: '111', + }, + ], + }, + }; + + render(); + + expect(screen.getByText('Not Synced')).toBeDefined(); + }); + + it('should filter by correct apiVersion', () => { + const resource = { + kind: 'Secret', + metadata: { + name: 'my-secret', + namespace: 'default', + ownerReferences: [ + { + kind: 'SealedSecret', + apiVersion: 'wrong-api/v1', + name: 'wrong-ss', + uid: '222', + }, + ], + }, + }; + + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); +}); diff --git a/src/components/SecretDetailsSection.tsx b/src/components/SecretDetailsSection.tsx index 78ef473..984505b 100644 --- a/src/components/SecretDetailsSection.tsx +++ b/src/components/SecretDetailsSection.tsx @@ -14,8 +14,24 @@ import { import React from 'react'; import { SealedSecret } from '../lib/SealedSecretCRD'; +interface OwnerReference { + kind: string; + apiVersion: string; + name: string; + uid: string; +} + +interface SecretResource { + kind?: string; + metadata?: { + name?: string; + namespace?: string; + ownerReferences?: OwnerReference[]; + }; +} + interface SecretDetailsSectionProps { - resource: any; // The Secret resource + resource: SecretResource; } /** @@ -24,7 +40,7 @@ interface SecretDetailsSectionProps { export function SecretDetailsSection({ resource }: SecretDetailsSectionProps) { // Check if this Secret is owned by a SealedSecret const ownerRef = resource.metadata?.ownerReferences?.find( - (ref: any) => ref.kind === 'SealedSecret' && ref.apiVersion === 'bitnami.com/v1alpha1' + ref => ref.kind === 'SealedSecret' && ref.apiVersion === 'bitnami.com/v1alpha1' ); if (!ownerRef) { diff --git a/src/components/SettingsPage.test.tsx b/src/components/SettingsPage.test.tsx new file mode 100644 index 0000000..8c35914 --- /dev/null +++ b/src/components/SettingsPage.test.tsx @@ -0,0 +1,144 @@ +/** + * Unit tests for SettingsPage component + */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock notistack +const mockEnqueueSnackbar = vi.fn(); +vi.mock('notistack', () => ({ + useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }), +})); + +// Mock controller +vi.mock('../lib/controller', () => ({ + getPluginConfig: vi.fn().mockReturnValue({ + controllerName: 'sealed-secrets-controller', + controllerNamespace: 'kube-system', + controllerPort: 8080, + }), + savePluginConfig: vi.fn(), +})); + +// Mock sub-components +vi.mock('./ControllerStatus', () => ({ + ControllerStatus: () =>
Status
, +})); + +vi.mock('./VersionWarning', () => ({ + VersionWarning: () =>
Version
, +})); + +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + SectionBox: ({ title, children }: { title: string; children: React.ReactNode }) => ( +
+

{title}

+ {children} +
+ ), +})); + +import { savePluginConfig } from '../lib/controller'; +import { SettingsPage } from './SettingsPage'; + +const mockSave = vi.mocked(savePluginConfig); + +describe('SettingsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render settings form with default values', () => { + render(); + + expect(screen.getByText('Sealed Secrets Plugin Settings')).toBeDefined(); + expect(screen.getByDisplayValue('sealed-secrets-controller')).toBeDefined(); + expect(screen.getByDisplayValue('kube-system')).toBeDefined(); + expect(screen.getByDisplayValue('8080')).toBeDefined(); + }); + + it('should render ControllerStatus and VersionWarning', () => { + render(); + + expect(screen.getByTestId('controller-status')).toBeDefined(); + expect(screen.getByTestId('version-warning')).toBeDefined(); + }); + + it('should save config on Save button click', () => { + render(); + + fireEvent.click(screen.getByText('Save Settings')); + + expect(mockSave).toHaveBeenCalledWith({ + controllerName: 'sealed-secrets-controller', + controllerNamespace: 'kube-system', + controllerPort: 8080, + }); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Settings saved successfully', { + variant: 'success', + }); + }); + + it('should reset to defaults on Reset button click', () => { + render(); + + // Change a value first + const nameInput = screen.getByDisplayValue('sealed-secrets-controller'); + fireEvent.change(nameInput, { target: { value: 'custom-name' } }); + expect(screen.getByDisplayValue('custom-name')).toBeDefined(); + + // Reset + fireEvent.click(screen.getByText('Reset to Defaults')); + + expect(screen.getByDisplayValue('sealed-secrets-controller')).toBeDefined(); + expect(screen.getByDisplayValue('kube-system')).toBeDefined(); + expect(screen.getByDisplayValue('8080')).toBeDefined(); + }); + + it('should call onDataChange when form fields change', () => { + const onDataChange = vi.fn(); + render(); + + const nameInput = screen.getByDisplayValue('sealed-secrets-controller'); + fireEvent.change(nameInput, { target: { value: 'new-controller' } }); + + expect(onDataChange).toHaveBeenCalledWith( + expect.objectContaining({ + controllerName: 'new-controller', + }) + ); + }); + + it('should call onDataChange on save', () => { + const onDataChange = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Save Settings')); + + expect(onDataChange).toHaveBeenCalled(); + }); + + it('should use data props for initial values when provided', () => { + render( + + ); + + expect(screen.getByDisplayValue('from-props')).toBeDefined(); + expect(screen.getByDisplayValue('custom-ns')).toBeDefined(); + expect(screen.getByDisplayValue('9090')).toBeDefined(); + }); + + it('should show default values info section', () => { + render(); + + expect(screen.getByText('Default Values')).toBeDefined(); + }); +}); diff --git a/src/components/VersionWarning.test.tsx b/src/components/VersionWarning.test.tsx new file mode 100644 index 0000000..24860aa --- /dev/null +++ b/src/components/VersionWarning.test.tsx @@ -0,0 +1,141 @@ +/** + * Unit tests for VersionWarning component + */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock SealedSecretCRD +vi.mock('../lib/SealedSecretCRD', () => ({ + SealedSecret: { + detectApiVersion: vi.fn(), + DEFAULT_VERSION: 'bitnami.com/v1alpha1', + }, +})); + +import { SealedSecret } from '../lib/SealedSecretCRD'; +import { VersionWarning } from './VersionWarning'; + +const mockDetectVersion = vi.mocked(SealedSecret.detectApiVersion); + +describe('VersionWarning', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should show nothing while loading', () => { + mockDetectVersion.mockReturnValue(new Promise(() => {})); + + const { container } = render(); + + expect(container.innerHTML).toBe(''); + }); + + it('should show nothing on default version detection', async () => { + mockDetectVersion.mockResolvedValue({ + ok: true, + value: 'bitnami.com/v1alpha1', + }); + + const { container } = render(); + + await waitFor(() => { + // Should render null for default version without showDetails + expect(container.innerHTML).toBe(''); + }); + }); + + it('should show info alert for non-default version', async () => { + mockDetectVersion.mockResolvedValue({ + ok: true, + value: 'bitnami.com/v1alpha2', + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('API Version Detected')).toBeDefined(); + }); + + expect(screen.getByText('bitnami.com/v1alpha2')).toBeDefined(); + }); + + it('should show error with retry button on detection failure', async () => { + mockDetectVersion.mockResolvedValue({ + ok: false, + error: 'CRD not found', + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('API Version Detection Failed')).toBeDefined(); + }); + + expect(screen.getByText(/CRD not found/)).toBeDefined(); + expect(screen.getByText('Retry')).toBeDefined(); + }); + + it('should retry on button click', async () => { + mockDetectVersion + .mockResolvedValueOnce({ ok: false, error: 'error' }) + .mockResolvedValueOnce({ ok: true, value: 'bitnami.com/v1alpha1' }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Retry')).toBeDefined(); + }); + + fireEvent.click(screen.getByText('Retry')); + + await waitFor(() => { + expect(mockDetectVersion).toHaveBeenCalledTimes(2); + }); + }); + + it('should show installation hint when CRD not found', async () => { + mockDetectVersion.mockResolvedValue({ + ok: false, + error: 'CRD not found', + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/kubectl apply/)).toBeDefined(); + }); + }); + + it('should show success alert when showDetails is true and default version detected', async () => { + mockDetectVersion.mockResolvedValue({ + ok: true, + value: 'bitnami.com/v1alpha1', + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('API Version Detected')).toBeDefined(); + expect(screen.getByText('bitnami.com/v1alpha1')).toBeDefined(); + }); + }); + + it('should not auto-detect when autoDetect is false', () => { + render(); + + expect(mockDetectVersion).not.toHaveBeenCalled(); + }); + + it('should handle unexpected exceptions', async () => { + mockDetectVersion.mockRejectedValue(new Error('Unexpected')); + + render(); + + await waitFor(() => { + expect(screen.getByText('API Version Detection Failed')).toBeDefined(); + expect(screen.getByText(/Unexpected/)).toBeDefined(); + }); + }); +}); diff --git a/src/hooks/useControllerHealth.test.ts b/src/hooks/useControllerHealth.test.ts new file mode 100644 index 0000000..8696289 --- /dev/null +++ b/src/hooks/useControllerHealth.test.ts @@ -0,0 +1,187 @@ +/** + * Unit tests for useControllerHealth hook + */ + +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock controller module +vi.mock('../lib/controller', () => ({ + checkControllerHealth: vi.fn(), + getPluginConfig: vi.fn().mockReturnValue({ + controllerName: 'sealed-secrets-controller', + controllerNamespace: 'kube-system', + controllerPort: 8080, + }), +})); + +import { checkControllerHealth } from '../lib/controller'; +import { useControllerHealth } from './useControllerHealth'; + +const mockCheckHealth = vi.mocked(checkControllerHealth); + +describe('useControllerHealth', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should start in loading state', () => { + mockCheckHealth.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useControllerHealth()); + + expect(result.current.loading).toBe(true); + expect(result.current.health).toBe(null); + }); + + it('should fetch health on mount', async () => { + mockCheckHealth.mockResolvedValue({ + ok: true, + value: { + healthy: true, + reachable: true, + version: '0.24.0', + latencyMs: 42, + }, + }); + + const { result } = renderHook(() => useControllerHealth()); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.health).toEqual({ + healthy: true, + reachable: true, + version: '0.24.0', + latencyMs: 42, + }); + }); + + it('should handle error result', async () => { + mockCheckHealth.mockResolvedValue({ + ok: false, + error: 'Controller unreachable', + }); + + const { result } = renderHook(() => useControllerHealth()); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.health).toEqual({ + healthy: false, + reachable: false, + error: 'Controller unreachable', + }); + }); + + it('should auto-refresh at specified interval', async () => { + mockCheckHealth.mockResolvedValue({ + ok: true, + value: { healthy: true, reachable: true }, + }); + + renderHook(() => useControllerHealth(true, 10000)); + + // Initial call + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(mockCheckHealth).toHaveBeenCalledTimes(1); + + // Advance timer by refresh interval + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + + expect(mockCheckHealth).toHaveBeenCalledTimes(2); + + // Another interval + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + + expect(mockCheckHealth).toHaveBeenCalledTimes(3); + }); + + it('should not auto-refresh by default', async () => { + mockCheckHealth.mockResolvedValue({ + ok: true, + value: { healthy: true, reachable: true }, + }); + + renderHook(() => useControllerHealth()); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(mockCheckHealth).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(60000); + await vi.runAllTimersAsync(); + }); + + // Still just 1 call - no auto-refresh + expect(mockCheckHealth).toHaveBeenCalledTimes(1); + }); + + it('should provide manual refresh function', async () => { + mockCheckHealth.mockResolvedValue({ + ok: true, + value: { healthy: true, reachable: true }, + }); + + const { result } = renderHook(() => useControllerHealth()); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(mockCheckHealth).toHaveBeenCalledTimes(1); + + // Manual refresh + await act(async () => { + result.current.refresh(); + await vi.runAllTimersAsync(); + }); + + expect(mockCheckHealth).toHaveBeenCalledTimes(2); + }); + + it('should cleanup interval on unmount', async () => { + mockCheckHealth.mockResolvedValue({ + ok: true, + value: { healthy: true, reachable: true }, + }); + + const { unmount } = renderHook(() => useControllerHealth(true, 5000)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(mockCheckHealth).toHaveBeenCalledTimes(1); + + unmount(); + + // Advance time - no more calls after unmount + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + + expect(mockCheckHealth).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useControllerHealth.ts b/src/hooks/useControllerHealth.ts index 91e1595..a7d3863 100644 --- a/src/hooks/useControllerHealth.ts +++ b/src/hooks/useControllerHealth.ts @@ -37,16 +37,14 @@ export function useControllerHealth(autoRefresh = false, refreshIntervalMs = 300 const config = getPluginConfig(); const result = await checkControllerHealth(config); - if (result.ok) { - setHealth(result.value); - } else if (result.ok === false) { - // Even on error, checkControllerHealth returns a status - // This shouldn't happen, but handle gracefully + if (result.ok === false) { setHealth({ healthy: false, reachable: false, error: result.error, }); + } else { + setHealth(result.value); } setLoading(false); diff --git a/src/hooks/usePermissions.test.ts b/src/hooks/usePermissions.test.ts new file mode 100644 index 0000000..60c0f63 --- /dev/null +++ b/src/hooks/usePermissions.test.ts @@ -0,0 +1,216 @@ +/** + * Unit tests for usePermissions hooks + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock rbac module +vi.mock('../lib/rbac', () => ({ + checkSealedSecretPermissions: vi.fn(), +})); + +import { checkSealedSecretPermissions } from '../lib/rbac'; +import { usePermission, usePermissions } from './usePermissions'; + +const mockCheckPerms = vi.mocked(checkSealedSecretPermissions); + +describe('usePermissions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('usePermissions', () => { + it('should start in loading state', () => { + mockCheckPerms.mockReturnValue(new Promise(() => {})); // never resolves + + const { result } = renderHook(() => usePermissions('default')); + + expect(result.current.loading).toBe(true); + expect(result.current.permissions).toBe(null); + expect(result.current.error).toBe(null); + }); + + it('should transition to loaded with permissions', async () => { + mockCheckPerms.mockResolvedValue({ + ok: true, + value: { + canCreate: true, + canRead: true, + canUpdate: false, + canDelete: false, + canList: true, + }, + }); + + const { result } = renderHook(() => usePermissions('default')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.permissions).toEqual({ + canCreate: true, + canRead: true, + canUpdate: false, + canDelete: false, + canList: true, + }); + expect(result.current.error).toBe(null); + }); + + it('should set error state on failure', async () => { + mockCheckPerms.mockResolvedValue({ + ok: false, + error: 'Permission check failed', + }); + + const { result } = renderHook(() => usePermissions('default')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.permissions).toBe(null); + expect(result.current.error).toBe('Permission check failed'); + }); + + it('should re-fetch when namespace changes', async () => { + mockCheckPerms.mockResolvedValue({ + ok: true, + value: { + canCreate: true, + canRead: true, + canUpdate: true, + canDelete: true, + canList: true, + }, + }); + + const { result, rerender } = renderHook(({ ns }: { ns: string }) => usePermissions(ns), { + initialProps: { ns: 'default' }, + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(mockCheckPerms).toHaveBeenCalledWith('default'); + + rerender({ ns: 'production' }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(mockCheckPerms).toHaveBeenCalledWith('production'); + expect(mockCheckPerms).toHaveBeenCalledTimes(2); + }); + + it('should handle unmount cancellation', async () => { + let resolvePromise: (value: unknown) => void; + mockCheckPerms.mockReturnValue( + new Promise(resolve => { + resolvePromise = resolve; + }) + ); + + const { result, unmount } = renderHook(() => usePermissions('default')); + + expect(result.current.loading).toBe(true); + + // Unmount before promise resolves + unmount(); + + // Resolve after unmount - should not cause errors + resolvePromise!({ + ok: true, + value: { + canCreate: true, + canRead: true, + canUpdate: true, + canDelete: true, + canList: true, + }, + }); + }); + + it('should work without namespace (cluster-wide)', async () => { + mockCheckPerms.mockResolvedValue({ + ok: true, + value: { + canCreate: false, + canRead: true, + canUpdate: false, + canDelete: false, + canList: true, + }, + }); + + const { result } = renderHook(() => usePermissions()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(mockCheckPerms).toHaveBeenCalledWith(undefined); + }); + }); + + describe('usePermission', () => { + it('should return specific permission', async () => { + mockCheckPerms.mockResolvedValue({ + ok: true, + value: { + canCreate: true, + canRead: true, + canUpdate: false, + canDelete: false, + canList: true, + }, + }); + + const { result } = renderHook(() => usePermission('default', 'canCreate')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.allowed).toBe(true); + }); + + it('should return false when permission is denied', async () => { + mockCheckPerms.mockResolvedValue({ + ok: true, + value: { + canCreate: false, + canRead: true, + canUpdate: false, + canDelete: false, + canList: true, + }, + }); + + const { result } = renderHook(() => usePermission('default', 'canCreate')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.allowed).toBe(false); + }); + + it('should return false when permissions are null (loading/error)', () => { + mockCheckPerms.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => usePermission('default', 'canCreate')); + + expect(result.current.loading).toBe(true); + expect(result.current.allowed).toBe(false); + }); + }); +}); diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index 9e7824d..91d6b1c 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -85,53 +85,3 @@ export function usePermission( return { loading, allowed }; } - -/** - * Hook to check if user has any write permissions - * - * Returns true if user can create, update, or delete. - * Useful for showing/hiding entire sections of UI. - * - * @param namespace Optional namespace to check - * @returns Object with loading state and hasWriteAccess flag - * - * @example - * const { loading, hasWriteAccess } = useHasWriteAccess('default'); - * if (hasWriteAccess) { - * // Show management UI - * } - */ -export function useHasWriteAccess(namespace?: string) { - const { loading, permissions } = usePermissions(namespace); - - const hasWriteAccess = - permissions?.canCreate || permissions?.canUpdate || permissions?.canDelete || false; - - return { loading, hasWriteAccess }; -} - -/** - * Hook to check if user has read-only access - * - * Returns true if user can read/list but cannot create/update/delete. - * - * @param namespace Optional namespace to check - * @returns Object with loading state and isReadOnly flag - * - * @example - * const { loading, isReadOnly } = useIsReadOnly('default'); - * if (isReadOnly) { - * // Show read-only warning - * } - */ -export function useIsReadOnly(namespace?: string) { - const { loading, permissions } = usePermissions(namespace); - - const isReadOnly = - (permissions?.canRead || permissions?.canList) && - !permissions?.canCreate && - !permissions?.canUpdate && - !permissions?.canDelete; - - return { loading, isReadOnly }; -} diff --git a/src/hooks/useSealedSecretEncryption.test.tsx b/src/hooks/useSealedSecretEncryption.test.tsx new file mode 100644 index 0000000..21e5195 --- /dev/null +++ b/src/hooks/useSealedSecretEncryption.test.tsx @@ -0,0 +1,402 @@ +/** + * Unit tests for useSealedSecretEncryption hook + */ + +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock dependencies +const mockEnqueueSnackbar = vi.fn(); +vi.mock('notistack', () => ({ + useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }), +})); + +vi.mock('../lib/controller', () => ({ + getPluginConfig: vi.fn().mockReturnValue({ + controllerName: 'sealed-secrets-controller', + controllerNamespace: 'kube-system', + controllerPort: 8080, + }), + fetchPublicCertificate: vi.fn(), +})); + +vi.mock('../lib/crypto', () => ({ + parsePublicKeyFromCert: vi.fn(), + encryptKeyValues: vi.fn(), + parseCertificateInfo: vi.fn(), + isCertificateExpiringSoon: vi.fn(), +})); + +vi.mock('../lib/validators', () => ({ + validateSecretName: vi.fn().mockReturnValue({ valid: true }), + validateSecretKey: vi.fn().mockReturnValue({ valid: true }), + validateSecretValue: vi.fn().mockReturnValue({ valid: true }), +})); + +import { fetchPublicCertificate } from '../lib/controller'; +import { + encryptKeyValues, + isCertificateExpiringSoon, + parseCertificateInfo, + parsePublicKeyFromCert, +} from '../lib/crypto'; +import { validateSecretKey, validateSecretName, validateSecretValue } from '../lib/validators'; +import { useSealedSecretEncryption } from './useSealedSecretEncryption'; + +const mockFetchCert = vi.mocked(fetchPublicCertificate); +const mockParseKey = vi.mocked(parsePublicKeyFromCert); +const mockEncryptKV = vi.mocked(encryptKeyValues); +const mockParseCertInfo = vi.mocked(parseCertificateInfo); +const mockIsExpiringSoon = vi.mocked(isCertificateExpiringSoon); +const mockValidateName = vi.mocked(validateSecretName); +const mockValidateKey = vi.mocked(validateSecretKey); +const mockValidateValue = vi.mocked(validateSecretValue); + +describe('useSealedSecretEncryption', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default happy path mocks + mockFetchCert.mockResolvedValue({ ok: true, value: 'fake-cert' as never }); + mockParseKey.mockReturnValue({ ok: true, value: {} as never }); + mockEncryptKV.mockReturnValue({ + ok: true, + value: { password: 'encrypted' } as never, + }); + mockParseCertInfo.mockReturnValue({ + ok: true, + value: { + validFrom: new Date(), + validTo: new Date(Date.now() + 365 * 86400000), + isExpired: false, + daysUntilExpiry: 365, + issuer: 'CN=test', + subject: 'CN=test', + fingerprint: 'abc', + serialNumber: '01', + }, + }); + mockIsExpiringSoon.mockReturnValue(false); + mockValidateName.mockReturnValue({ valid: true }); + mockValidateKey.mockReturnValue({ valid: true }); + mockValidateValue.mockReturnValue({ valid: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should start with encrypting = false', () => { + const { result } = renderHook(() => useSealedSecretEncryption()); + expect(result.current.encrypting).toBe(false); + }); + + it('should return error when name validation fails', async () => { + mockValidateName.mockReturnValue({ valid: false, error: 'Name is required' }); + + const { result } = renderHook(() => useSealedSecretEncryption()); + + let encryptResult: unknown; + await act(async () => { + encryptResult = await result.current.encrypt({ + name: '', + namespace: 'default', + scope: 'strict', + keyValues: [{ key: 'k', value: 'v' }], + }); + }); + + expect((encryptResult as { ok: boolean }).ok).toBe(false); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Name is required', { variant: 'error' }); + }); + + it('should return error when key validation fails', async () => { + mockValidateKey.mockReturnValue({ valid: false, error: 'Key name is required' }); + + const { result } = renderHook(() => useSealedSecretEncryption()); + + let encryptResult: unknown; + await act(async () => { + encryptResult = await result.current.encrypt({ + name: 'my-secret', + namespace: 'default', + scope: 'strict', + keyValues: [{ key: '', value: 'v' }], + }); + }); + + expect((encryptResult as { ok: boolean }).ok).toBe(false); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith( + expect.stringContaining('Key name is required'), + { variant: 'error' } + ); + }); + + it('should return error when value validation fails', async () => { + mockValidateValue.mockReturnValue({ valid: false, error: 'Value is required' }); + + const { result } = renderHook(() => useSealedSecretEncryption()); + + let encryptResult: unknown; + await act(async () => { + encryptResult = await result.current.encrypt({ + name: 'my-secret', + namespace: 'default', + scope: 'strict', + keyValues: [{ key: 'pass', value: '' }], + }); + }); + + expect((encryptResult as { ok: boolean }).ok).toBe(false); + }); + + it('should return error for empty keyValues', async () => { + const { result } = renderHook(() => useSealedSecretEncryption()); + + let encryptResult: unknown; + await act(async () => { + encryptResult = await result.current.encrypt({ + name: 'my-secret', + namespace: 'default', + scope: 'strict', + keyValues: [], + }); + }); + + expect((encryptResult as { ok: boolean }).ok).toBe(false); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('At least one key-value pair is required', { + variant: 'error', + }); + }); + + it('should return error when certificate fetch fails', async () => { + mockFetchCert.mockResolvedValue({ ok: false, error: 'Controller unreachable' }); + + const { result } = renderHook(() => useSealedSecretEncryption()); + + let encryptResult: unknown; + await act(async () => { + encryptResult = await result.current.encrypt({ + name: 'my-secret', + namespace: 'default', + scope: 'strict', + keyValues: [{ key: 'k', value: 'v' }], + }); + }); + + expect((encryptResult as { ok: boolean }).ok).toBe(false); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith( + expect.stringContaining('Failed to fetch certificate'), + { variant: 'error' } + ); + }); + + it('should warn when certificate is expired', async () => { + mockParseCertInfo.mockReturnValue({ + ok: true, + value: { + validFrom: new Date('2020-01-01'), + validTo: new Date('2021-01-01'), + isExpired: true, + daysUntilExpiry: -500, + issuer: 'CN=test', + subject: 'CN=test', + fingerprint: 'abc', + serialNumber: '01', + }, + }); + + const { result } = renderHook(() => useSealedSecretEncryption()); + + await act(async () => { + await result.current.encrypt({ + name: 'my-secret', + namespace: 'default', + scope: 'strict', + keyValues: [{ key: 'k', value: 'v' }], + }); + }); + + expect(mockEnqueueSnackbar).toHaveBeenCalledWith(expect.stringContaining('expired'), { + variant: 'warning', + }); + }); + + it('should warn when certificate is expiring soon', async () => { + mockIsExpiringSoon.mockReturnValue(true); + mockParseCertInfo.mockReturnValue({ + ok: true, + value: { + validFrom: new Date(), + validTo: new Date(Date.now() + 10 * 86400000), + isExpired: false, + daysUntilExpiry: 10, + issuer: 'CN=test', + subject: 'CN=test', + fingerprint: 'abc', + serialNumber: '01', + }, + }); + + const { result } = renderHook(() => useSealedSecretEncryption()); + + await act(async () => { + await result.current.encrypt({ + name: 'my-secret', + namespace: 'default', + scope: 'strict', + keyValues: [{ key: 'k', value: 'v' }], + }); + }); + + expect(mockEnqueueSnackbar).toHaveBeenCalledWith(expect.stringContaining('expires in'), { + variant: 'warning', + }); + }); + + it('should return error when public key parsing fails', async () => { + mockParseKey.mockReturnValue({ ok: false, error: 'Invalid cert' }); + + const { result } = renderHook(() => useSealedSecretEncryption()); + + let encryptResult: unknown; + await act(async () => { + encryptResult = await result.current.encrypt({ + name: 'my-secret', + namespace: 'default', + scope: 'strict', + keyValues: [{ key: 'k', value: 'v' }], + }); + }); + + expect((encryptResult as { ok: boolean }).ok).toBe(false); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith( + expect.stringContaining('Invalid certificate'), + { variant: 'error' } + ); + }); + + it('should return error when encryption fails', async () => { + mockEncryptKV.mockReturnValue({ ok: false, error: 'Encryption failed' }); + + const { result } = renderHook(() => useSealedSecretEncryption()); + + let encryptResult: unknown; + await act(async () => { + encryptResult = await result.current.encrypt({ + name: 'my-secret', + namespace: 'default', + scope: 'strict', + keyValues: [{ key: 'k', value: 'v' }], + }); + }); + + expect((encryptResult as { ok: boolean }).ok).toBe(false); + }); + + it('should return SealedSecret data on success', async () => { + const { result } = renderHook(() => useSealedSecretEncryption()); + + let encryptResult: { ok: boolean; value?: { sealedSecretData: unknown } }; + await act(async () => { + encryptResult = await result.current.encrypt({ + name: 'my-secret', + namespace: 'default', + scope: 'strict', + keyValues: [{ key: 'password', value: 'secret' }], + }); + }); + + expect(encryptResult!.ok).toBe(true); + if (encryptResult!.ok) { + const data = encryptResult!.value!.sealedSecretData as Record; + expect(data.apiVersion).toBe('bitnami.com/v1alpha1'); + expect(data.kind).toBe('SealedSecret'); + expect((data.metadata as Record).name).toBe('my-secret'); + expect((data.metadata as Record).namespace).toBe('default'); + } + }); + + it('should add namespace-wide scope annotation', async () => { + const { result } = renderHook(() => useSealedSecretEncryption()); + + let encryptResult: { + ok: boolean; + value?: { sealedSecretData: { metadata: { annotations: Record } } }; + }; + await act(async () => { + encryptResult = await result.current.encrypt({ + name: 'my-secret', + namespace: 'default', + scope: 'namespace-wide', + keyValues: [{ key: 'k', value: 'v' }], + }); + }); + + expect(encryptResult!.ok).toBe(true); + if (encryptResult!.ok) { + expect( + encryptResult!.value!.sealedSecretData.metadata.annotations[ + 'sealedsecrets.bitnami.com/namespace-wide' + ] + ).toBe('true'); + } + }); + + it('should add cluster-wide scope annotation', async () => { + const { result } = renderHook(() => useSealedSecretEncryption()); + + let encryptResult: { + ok: boolean; + value?: { sealedSecretData: { metadata: { annotations: Record } } }; + }; + await act(async () => { + encryptResult = await result.current.encrypt({ + name: 'my-secret', + namespace: 'default', + scope: 'cluster-wide', + keyValues: [{ key: 'k', value: 'v' }], + }); + }); + + expect(encryptResult!.ok).toBe(true); + if (encryptResult!.ok) { + expect( + encryptResult!.value!.sealedSecretData.metadata.annotations[ + 'sealedsecrets.bitnami.com/cluster-wide' + ] + ).toBe('true'); + } + }); + + it('should set encrypting state during encryption', async () => { + let resolveEncrypt: (value: unknown) => void; + mockFetchCert.mockReturnValue( + new Promise(resolve => { + resolveEncrypt = resolve; + }) + ); + + const { result } = renderHook(() => useSealedSecretEncryption()); + + let encryptPromise: Promise; + act(() => { + encryptPromise = result.current.encrypt({ + name: 'my-secret', + namespace: 'default', + scope: 'strict', + keyValues: [{ key: 'k', value: 'v' }], + }); + }); + + // Should be encrypting + expect(result.current.encrypting).toBe(true); + + // Resolve the cert fetch + await act(async () => { + resolveEncrypt!({ ok: true, value: 'cert' }); + await encryptPromise; + }); + + expect(result.current.encrypting).toBe(false); + }); +}); diff --git a/src/hooks/useSealedSecretEncryption.ts b/src/hooks/useSealedSecretEncryption.ts index c89b7fe..ea574b9 100644 --- a/src/hooks/useSealedSecretEncryption.ts +++ b/src/hooks/useSealedSecretEncryption.ts @@ -31,12 +31,31 @@ export interface EncryptionRequest { keyValues: Array<{ key: string; value: string }>; } +/** + * Shape of the SealedSecret manifest constructed for API submission + */ +interface SealedSecretManifest { + apiVersion: string; + kind: string; + metadata: { + name: string; + namespace: string; + annotations: Record; + }; + spec: { + encryptedData: Record; + template: { + metadata: Record; + }; + }; +} + /** * Result of successful encryption */ export interface EncryptionResult { /** The complete SealedSecret object ready to apply */ - sealedSecretData: any; + sealedSecretData: SealedSecretManifest; /** Information about the certificate used */ certificateInfo?: CertificateInfo; } @@ -158,7 +177,7 @@ export function useSealedSecretEncryption() { } // Step 6: Construct the SealedSecret object - const sealedSecretData: any = { + const sealedSecretData: SealedSecretManifest = { apiVersion: 'bitnami.com/v1alpha1', kind: 'SealedSecret', metadata: { @@ -186,8 +205,8 @@ export function useSealedSecretEncryption() { sealedSecretData, certificateInfo: certInfo, }); - } catch (error: any) { - const errorMsg = error.message || 'Unknown encryption error'; + } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : String(error); enqueueSnackbar(errorMsg, { variant: 'error' }); return Err(errorMsg); } finally { diff --git a/src/index.test.tsx b/src/index.test.tsx new file mode 100644 index 0000000..b67ae91 --- /dev/null +++ b/src/index.test.tsx @@ -0,0 +1,117 @@ +/** + * Unit tests for plugin entry point + * + * Verifies that all registration functions are called at module load + */ + +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +// Mock registration functions +const mockRegisterRoute = vi.fn(); +const mockRegisterSidebarEntry = vi.fn(); +const mockRegisterDetailsViewSection = vi.fn(); +const mockRegisterPluginSettings = vi.fn(); + +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + registerRoute: mockRegisterRoute, + registerSidebarEntry: mockRegisterSidebarEntry, + registerDetailsViewSection: mockRegisterDetailsViewSection, + registerPluginSettings: mockRegisterPluginSettings, +})); + +// Mock all component imports to avoid deep dependency resolution +vi.mock('./components/ErrorBoundary', () => ({ + ApiErrorBoundary: ({ children }: { children: React.ReactNode }) => children, + GenericErrorBoundary: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock('./components/SealedSecretList', () => ({ + SealedSecretList: () => null, +})); + +vi.mock('./components/SealingKeysView', () => ({ + SealingKeysView: () => null, +})); + +vi.mock('./components/SecretDetailsSection', () => ({ + SecretDetailsSection: () => null, +})); + +vi.mock('./components/SettingsPage', () => ({ + SettingsPage: () => null, +})); + +import React from 'react'; + +describe('Plugin Entry Point', () => { + beforeAll(async () => { + // Import the module to trigger side effects (registrations) + // @ts-expect-error - dynamic import not supported by base tsconfig module setting + await import('./index'); + }); + + it('should register sidebar entries', () => { + // Main entry + 2 children = 3 sidebar entries + expect(mockRegisterSidebarEntry).toHaveBeenCalledTimes(3); + + // Main "Sealed Secrets" entry + expect(mockRegisterSidebarEntry).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'sealed-secrets', + label: 'Sealed Secrets', + url: '/sealedsecrets', + parent: null, + }) + ); + + // "All Sealed Secrets" child + expect(mockRegisterSidebarEntry).toHaveBeenCalledWith( + expect.objectContaining({ + parent: 'sealed-secrets', + name: 'sealed-secrets-list', + }) + ); + + // "Sealing Keys" child + expect(mockRegisterSidebarEntry).toHaveBeenCalledWith( + expect.objectContaining({ + parent: 'sealed-secrets', + name: 'sealing-keys', + }) + ); + }); + + it('should register routes', () => { + // List route + Keys route = 2 + expect(mockRegisterRoute).toHaveBeenCalledTimes(2); + + // List/detail view route + expect(mockRegisterRoute).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/sealedsecrets/:namespace?/:name?', + name: 'sealedsecret', + }) + ); + + // Keys route + expect(mockRegisterRoute).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/sealedsecrets/keys', + }) + ); + }); + + it('should register details view section for Secret resources', () => { + expect(mockRegisterDetailsViewSection).toHaveBeenCalledTimes(1); + expect(mockRegisterDetailsViewSection).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should register plugin settings', () => { + expect(mockRegisterPluginSettings).toHaveBeenCalledTimes(1); + expect(mockRegisterPluginSettings).toHaveBeenCalledWith( + 'sealed-secrets', + expect.any(Function), + true + ); + }); +}); diff --git a/src/lib/SealedSecretCRD.ts b/src/lib/SealedSecretCRD.ts index e7764c8..5568f2e 100644 --- a/src/lib/SealedSecretCRD.ts +++ b/src/lib/SealedSecretCRD.ts @@ -14,6 +14,12 @@ import { SealedSecretStatus, } from '../types'; +interface CRDVersion { + name: string; + storage?: boolean; + served?: boolean; +} + /** * SealedSecret CRD class * Represents a Bitnami Sealed Secret resource in the cluster @@ -128,7 +134,7 @@ export class SealedSecret extends KubeObject { ); // Find the storage version (the version used for persistence) - const storageVersion = crd.spec?.versions?.find((v: any) => v.storage === true); + const storageVersion = crd.spec?.versions?.find((v: CRDVersion) => v.storage === true); if (storageVersion) { const version = `${crd.spec.group}/${storageVersion.name}`; @@ -137,7 +143,7 @@ export class SealedSecret extends KubeObject { } // Fallback to first served version if no storage version found - const servedVersion = crd.spec?.versions?.find((v: any) => v.served === true); + const servedVersion = crd.spec?.versions?.find((v: CRDVersion) => v.served === true); if (servedVersion) { const version = `${crd.spec.group}/${servedVersion.name}`; this.detectedVersion = version; diff --git a/src/lib/controller.test.ts b/src/lib/controller.test.ts new file mode 100644 index 0000000..80772ed --- /dev/null +++ b/src/lib/controller.test.ts @@ -0,0 +1,289 @@ +/** + * Unit tests for controller API helpers + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + checkControllerHealth, + fetchPublicCertificate, + getPluginConfig, + rotateSealedSecret, + savePluginConfig, +} from './controller'; + +// Mock retry to avoid real delays +vi.mock('./retry', () => ({ + retryWithBackoff: vi.fn((fn: () => Promise) => fn()), +})); + +describe('controller', () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + localStorage.clear(); + }); + + afterEach(() => { + global.fetch = originalFetch; + localStorage.clear(); + vi.restoreAllMocks(); + }); + + describe('getPluginConfig / savePluginConfig', () => { + it('should return default config when no stored config', () => { + const config = getPluginConfig(); + expect(config.controllerName).toBe('sealed-secrets-controller'); + expect(config.controllerNamespace).toBe('kube-system'); + expect(config.controllerPort).toBe(8080); + }); + + it('should round-trip saved config', () => { + const custom = { + controllerName: 'my-controller', + controllerNamespace: 'sealed-secrets', + controllerPort: 9090, + }; + savePluginConfig(custom); + const loaded = getPluginConfig(); + expect(loaded).toEqual(custom); + }); + + it('should return default config on invalid JSON', () => { + localStorage.setItem('sealed-secrets-plugin-config', 'not json'); + const config = getPluginConfig(); + expect(config.controllerName).toBe('sealed-secrets-controller'); + }); + + it('should overwrite previous config', () => { + savePluginConfig({ + controllerName: 'first', + controllerNamespace: 'ns1', + controllerPort: 1111, + }); + savePluginConfig({ + controllerName: 'second', + controllerNamespace: 'ns2', + controllerPort: 2222, + }); + const config = getPluginConfig(); + expect(config.controllerName).toBe('second'); + }); + }); + + describe('fetchPublicCertificate', () => { + it('should return certificate on success', async () => { + const certPEM = '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----'; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(certPEM), + }); + + const config = getPluginConfig(); + const result = await fetchPublicCertificate(config); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe(certPEM); + } + expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/v1/cert.pem')); + }); + + it('should return error on HTTP failure', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + }); + + const config = getPluginConfig(); + const result = await fetchPublicCertificate(config); + + expect(result.ok).toBe(false); + if (result.ok === false) { + expect(result.error).toContain('Unable to fetch controller certificate'); + } + }); + + it('should return error on network failure', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + const config = getPluginConfig(); + const result = await fetchPublicCertificate(config); + + expect(result.ok).toBe(false); + if (result.ok === false) { + expect(result.error).toContain('Unable to fetch controller certificate'); + } + }); + }); + + describe('checkControllerHealth', () => { + it('should return healthy status on 200', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'X-Controller-Version': '0.24.0' }), + }); + + const config = getPluginConfig(); + const result = await checkControllerHealth(config); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.healthy).toBe(true); + expect(result.value.reachable).toBe(true); + expect(result.value.version).toBe('0.24.0'); + expect(result.value.latencyMs).toBeDefined(); + } + }); + + it('should return unhealthy reachable on non-200', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: new Headers(), + }); + + const config = getPluginConfig(); + const result = await checkControllerHealth(config); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.healthy).toBe(false); + expect(result.value.reachable).toBe(true); + expect(result.value.error).toContain('500'); + } + }); + + it('should return unreachable on network error', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Connection refused')); + + const config = getPluginConfig(); + const result = await checkControllerHealth(config); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.healthy).toBe(false); + expect(result.value.reachable).toBe(false); + expect(result.value.error).toBe('Connection refused'); + } + }); + + it('should handle timeout (AbortError)', async () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + global.fetch = vi.fn().mockRejectedValue(abortError); + + const config = getPluginConfig(); + const result = await checkControllerHealth(config); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.healthy).toBe(false); + expect(result.value.reachable).toBe(false); + expect(result.value.error).toContain('timed out'); + } + }); + + it('should return undefined version when header is absent', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers(), + }); + + const config = getPluginConfig(); + const result = await checkControllerHealth(config); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.version).toBeUndefined(); + } + }); + + it('should use correct healthz endpoint', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers(), + }); + + const config = { + controllerName: 'my-ss', + controllerNamespace: 'my-ns', + controllerPort: 9090, + }; + await checkControllerHealth(config); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/v1/namespaces/my-ns/services/http:my-ss:9090/proxy/healthz', + expect.objectContaining({ method: 'GET' }) + ); + }); + }); + + describe('rotateSealedSecret', () => { + it('should return rotated YAML on success', async () => { + const rotatedYaml = '{"apiVersion":"bitnami.com/v1alpha1","kind":"SealedSecret"}'; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(rotatedYaml), + }); + + const config = getPluginConfig(); + const result = await rotateSealedSecret(config, '{"old":"data"}'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe(rotatedYaml); + } + }); + + it('should return error on HTTP failure', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + }); + + const config = getPluginConfig(); + const result = await rotateSealedSecret(config, '{"data":"test"}'); + + expect(result.ok).toBe(false); + if (result.ok === false) { + expect(result.error).toContain('Unable to rotate'); + } + }); + + it('should return error on network failure', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('fetch failed')); + + const config = getPluginConfig(); + const result = await rotateSealedSecret(config, '{}'); + + expect(result.ok).toBe(false); + if (result.ok === false) { + expect(result.error).toContain('Unable to rotate'); + } + }); + + it('should POST to rotate endpoint with JSON content type', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve('rotated'), + }); + + const config = getPluginConfig(); + const yaml = '{"test":"data"}'; + await rotateSealedSecret(config, yaml); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/rotate'), + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: yaml, + }) + ); + }); + }); +}); diff --git a/src/lib/controller.ts b/src/lib/controller.ts index cec6c5e..ef311af 100644 --- a/src/lib/controller.ts +++ b/src/lib/controller.ts @@ -27,7 +27,7 @@ export interface ControllerHealthStatus { /** * Build the controller proxy URL */ -export function getControllerProxyURL(config: PluginConfig, path: string): string { +function getControllerProxyURL(config: PluginConfig, path: string): string { const { controllerNamespace, controllerName, controllerPort } = config; return `/api/v1/namespaces/${controllerNamespace}/services/http:${controllerName}:${controllerPort}/proxy${path}`; } @@ -77,38 +77,6 @@ export async function fetchPublicCertificate( }); } -/** - * Verify that a SealedSecret can be decrypted by the controller - * - * @param config Plugin configuration - * @param sealedSecretYaml YAML or JSON of the SealedSecret - * @returns Result containing verification status or error message - */ -export async function verifySealedSecret( - config: PluginConfig, - sealedSecretYaml: string -): AsyncResult { - const url = getControllerProxyURL(config, '/v1/verify'); - - const result = await tryCatchAsync(async () => { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: sealedSecretYaml, - }); - - return response.ok; - }); - - if (result.ok === false) { - return Err(`Verification failed: ${result.error.message}`); - } - - return result; -} - /** * Rotate (re-encrypt) a SealedSecret with the current active key * @@ -218,14 +186,14 @@ export async function checkControllerHealth( version, latencyMs, }); - } catch (error: any) { + } catch (error: unknown) { const latencyMs = Date.now() - startTime; // Determine error type let errorMessage = 'Controller unreachable'; - if (error.name === 'AbortError') { + if (error instanceof Error && error.name === 'AbortError') { errorMessage = 'Request timed out after 5 seconds'; - } else if (error.message) { + } else if (error instanceof Error) { errorMessage = error.message; } diff --git a/src/lib/crypto.test.ts b/src/lib/crypto.test.ts new file mode 100644 index 0000000..4098a17 --- /dev/null +++ b/src/lib/crypto.test.ts @@ -0,0 +1,297 @@ +/** + * Unit tests for client-side encryption utilities + */ + +import forge from 'node-forge'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { PEMCertificate, PlaintextValue } from '../types'; +import { + encryptKeyValues, + isCertificateExpiringSoon, + parseCertificateInfo, + parsePublicKeyFromCert, +} from './crypto'; + +// Generate a real self-signed cert for testing +let validPEM: PEMCertificate; +let expiredPEM: PEMCertificate; +let expiringSoonPEM: PEMCertificate; + +beforeAll(() => { + // Generate RSA key pair + const keys = forge.pki.rsa.generateKeyPair(2048); + + // Valid cert (expires in 365 days) + const validCert = forge.pki.createCertificate(); + validCert.publicKey = keys.publicKey; + validCert.serialNumber = '01'; + validCert.validity.notBefore = new Date(); + validCert.validity.notAfter = new Date(); + validCert.validity.notAfter.setFullYear(validCert.validity.notAfter.getFullYear() + 1); + validCert.setSubject([{ name: 'commonName', value: 'test-controller' }]); + validCert.setIssuer([{ name: 'commonName', value: 'test-issuer' }]); + validCert.sign(keys.privateKey, forge.md.sha256.create()); + validPEM = PEMCertificate(forge.pki.certificateToPem(validCert)); + + // Expired cert + const expiredCert = forge.pki.createCertificate(); + expiredCert.publicKey = keys.publicKey; + expiredCert.serialNumber = '02'; + expiredCert.validity.notBefore = new Date('2020-01-01'); + expiredCert.validity.notAfter = new Date('2021-01-01'); + expiredCert.setSubject([{ name: 'commonName', value: 'expired-controller' }]); + expiredCert.setIssuer([{ name: 'commonName', value: 'test-issuer' }]); + expiredCert.sign(keys.privateKey, forge.md.sha256.create()); + expiredPEM = PEMCertificate(forge.pki.certificateToPem(expiredCert)); + + // Expiring soon cert (15 days from now) + const expiringSoonCert = forge.pki.createCertificate(); + expiringSoonCert.publicKey = keys.publicKey; + expiringSoonCert.serialNumber = '03'; + expiringSoonCert.validity.notBefore = new Date(); + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + 15); + expiringSoonCert.validity.notAfter = expiryDate; + expiringSoonCert.setSubject([{ name: 'commonName', value: 'expiring-controller' }]); + expiringSoonCert.setIssuer([{ name: 'commonName', value: 'test-issuer' }]); + expiringSoonCert.sign(keys.privateKey, forge.md.sha256.create()); + expiringSoonPEM = PEMCertificate(forge.pki.certificateToPem(expiringSoonCert)); +}); + +describe('crypto', () => { + describe('parsePublicKeyFromCert', () => { + it('should parse valid PEM certificate', () => { + const result = parsePublicKeyFromCert(validPEM); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBeDefined(); + expect(result.value.n).toBeDefined(); // RSA modulus + expect(result.value.e).toBeDefined(); // RSA exponent + } + }); + + it('should return error for invalid PEM', () => { + const result = parsePublicKeyFromCert(PEMCertificate('not a cert')); + expect(result.ok).toBe(false); + if (result.ok === false) { + expect(result.error).toContain('Failed to parse certificate'); + } + }); + + it('should return error for empty string', () => { + const result = parsePublicKeyFromCert(PEMCertificate('')); + expect(result.ok).toBe(false); + }); + + it('should return error for malformed PEM markers', () => { + const result = parsePublicKeyFromCert( + PEMCertificate('-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----') + ); + expect(result.ok).toBe(false); + }); + }); + + describe('encryptKeyValues', () => { + it('should encrypt key-value pairs with strict scope', () => { + const keyResult = parsePublicKeyFromCert(validPEM); + expect(keyResult.ok).toBe(true); + if (!keyResult.ok) return; + + const result = encryptKeyValues( + keyResult.value, + [{ key: 'password', value: PlaintextValue('secret123') }], + 'default', + 'my-secret', + 'strict' + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toHaveProperty('password'); + // Should be base64 encoded + expect(() => forge.util.decode64(result.value.password)).not.toThrow(); + } + }); + + it('should encrypt with namespace-wide scope', () => { + const keyResult = parsePublicKeyFromCert(validPEM); + if (!keyResult.ok) return; + + const result = encryptKeyValues( + keyResult.value, + [{ key: 'token', value: PlaintextValue('abc') }], + 'prod', + 'my-secret', + 'namespace-wide' + ); + + expect(result.ok).toBe(true); + }); + + it('should encrypt with cluster-wide scope', () => { + const keyResult = parsePublicKeyFromCert(validPEM); + if (!keyResult.ok) return; + + const result = encryptKeyValues( + keyResult.value, + [{ key: 'key', value: PlaintextValue('val') }], + 'ns', + 'name', + 'cluster-wide' + ); + + expect(result.ok).toBe(true); + }); + + it('should encrypt multiple keys', () => { + const keyResult = parsePublicKeyFromCert(validPEM); + if (!keyResult.ok) return; + + const result = encryptKeyValues( + keyResult.value, + [ + { key: 'username', value: PlaintextValue('admin') }, + { key: 'password', value: PlaintextValue('secret') }, + { key: 'token', value: PlaintextValue('abc123') }, + ], + 'default', + 'my-secret', + 'strict' + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(Object.keys(result.value)).toHaveLength(3); + expect(result.value).toHaveProperty('username'); + expect(result.value).toHaveProperty('password'); + expect(result.value).toHaveProperty('token'); + } + }); + + it('should produce different ciphertext for same plaintext (randomness)', () => { + const keyResult = parsePublicKeyFromCert(validPEM); + if (!keyResult.ok) return; + + const encrypt = () => + encryptKeyValues( + keyResult.value, + [{ key: 'key', value: PlaintextValue('same-value') }], + 'ns', + 'name', + 'strict' + ); + + const result1 = encrypt(); + const result2 = encrypt(); + + expect(result1.ok).toBe(true); + expect(result2.ok).toBe(true); + if (result1.ok && result2.ok) { + expect(result1.value.key).not.toBe(result2.value.key); + } + }); + + it('should handle empty key-value array', () => { + const keyResult = parsePublicKeyFromCert(validPEM); + if (!keyResult.ok) return; + + const result = encryptKeyValues(keyResult.value, [], 'ns', 'name', 'strict'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(Object.keys(result.value)).toHaveLength(0); + } + }); + }); + + describe('parseCertificateInfo', () => { + it('should parse valid certificate info', () => { + const result = parseCertificateInfo(validPEM); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.validFrom).toBeInstanceOf(Date); + expect(result.value.validTo).toBeInstanceOf(Date); + expect(result.value.isExpired).toBe(false); + expect(result.value.daysUntilExpiry).toBeGreaterThan(0); + expect(result.value.subject).toContain('test-controller'); + expect(result.value.issuer).toContain('test-issuer'); + expect(result.value.fingerprint).toBeDefined(); + expect(result.value.serialNumber).toBeDefined(); + } + }); + + it('should detect expired certificate', () => { + const result = parseCertificateInfo(expiredPEM); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.isExpired).toBe(true); + expect(result.value.daysUntilExpiry).toBeLessThan(0); + } + }); + + it('should calculate days until expiry for expiring-soon cert', () => { + const result = parseCertificateInfo(expiringSoonPEM); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.isExpired).toBe(false); + expect(result.value.daysUntilExpiry).toBeLessThanOrEqual(15); + expect(result.value.daysUntilExpiry).toBeGreaterThanOrEqual(14); + } + }); + + it('should return error for invalid PEM', () => { + const result = parseCertificateInfo(PEMCertificate('not a cert')); + expect(result.ok).toBe(false); + if (result.ok === false) { + expect(result.error).toContain('Failed to parse certificate info'); + } + }); + + it('should compute SHA-256 fingerprint', () => { + const result = parseCertificateInfo(validPEM); + expect(result.ok).toBe(true); + if (result.ok) { + // Fingerprint should be uppercase hex + expect(result.value.fingerprint).toMatch(/^[0-9A-F]+$/); + expect(result.value.fingerprint.length).toBe(64); // SHA-256 = 32 bytes = 64 hex chars + } + }); + }); + + describe('isCertificateExpiringSoon', () => { + it('should return true when within threshold', () => { + const result = parseCertificateInfo(expiringSoonPEM); + expect(result.ok).toBe(true); + if (result.ok) { + expect(isCertificateExpiringSoon(result.value, 30)).toBe(true); + } + }); + + it('should return false when not within threshold', () => { + const result = parseCertificateInfo(validPEM); + expect(result.ok).toBe(true); + if (result.ok) { + expect(isCertificateExpiringSoon(result.value, 30)).toBe(false); + } + }); + + it('should return false for expired certificate', () => { + const result = parseCertificateInfo(expiredPEM); + expect(result.ok).toBe(true); + if (result.ok) { + expect(isCertificateExpiringSoon(result.value, 30)).toBe(false); + } + }); + + it('should work with custom thresholds', () => { + const result = parseCertificateInfo(expiringSoonPEM); + expect(result.ok).toBe(true); + if (result.ok) { + // 15-day cert should be within 20-day threshold + expect(isCertificateExpiringSoon(result.value, 20)).toBe(true); + // But not within 10-day threshold + expect(isCertificateExpiringSoon(result.value, 10)).toBe(false); + } + }); + }); +}); diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 30618de..98db41c 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -52,7 +52,7 @@ export function parsePublicKeyFromCert( * @param scope The encryption scope * @returns Result containing base64-encoded encrypted value or error message */ -export function encryptValue( +function encryptValue( publicKey: forge.pki.rsa.PublicKey, value: PlaintextValue, namespace: string, @@ -98,7 +98,7 @@ export function encryptValue( const tag = (cipher.mode as any).tag.getBytes(); // Construct the sealed secret format: - // [2-byte length of encrypted session key][encrypted session key][IV][encrypted value][auth tag] + // [2-byte key length][encrypted key][IV][ciphertext][auth tag] const sessionKeyLength = encryptedSessionKey.length; const lengthBytes = String.fromCharCode((sessionKeyLength >> 8) & 0xff) + @@ -145,17 +145,6 @@ export function encryptKeyValues( return Ok(encryptedData); } -/** - * Validate a PEM certificate - * - * @param pemCert PEM-encoded certificate string (branded type) - * @returns true if certificate is valid, false otherwise - */ -export function validateCertificate(pemCert: PEMCertificate): boolean { - const result = parsePublicKeyFromCert(pemCert); - return result.ok; -} - /** * Parse certificate and extract metadata * diff --git a/src/lib/rbac.test.ts b/src/lib/rbac.test.ts new file mode 100644 index 0000000..45a9409 --- /dev/null +++ b/src/lib/rbac.test.ts @@ -0,0 +1,197 @@ +/** + * Unit tests for RBAC permission checking + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { canDecryptSecrets, checkSealedSecretPermissions } from './rbac'; + +describe('rbac', () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe('checkSealedSecretPermissions', () => { + it('should return all true when all permissions are allowed', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ status: { allowed: true } }), + }); + + const result = await checkSealedSecretPermissions('default'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.canCreate).toBe(true); + expect(result.value.canRead).toBe(true); + expect(result.value.canUpdate).toBe(true); + expect(result.value.canDelete).toBe(true); + expect(result.value.canList).toBe(true); + } + }); + + it('should return all false when all permissions are denied', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ status: { allowed: false } }), + }); + + const result = await checkSealedSecretPermissions('default'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.canCreate).toBe(false); + expect(result.value.canRead).toBe(false); + expect(result.value.canUpdate).toBe(false); + expect(result.value.canDelete).toBe(false); + expect(result.value.canList).toBe(false); + } + }); + + it('should handle mixed permissions', async () => { + let callCount = 0; + global.fetch = vi.fn().mockImplementation(() => { + callCount++; + // create=true, get=false, update=true, delete=false, list=true + const allowed = callCount % 2 !== 0; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ status: { allowed } }), + }); + }); + + const result = await checkSealedSecretPermissions('default'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.canCreate).toBe(true); + expect(result.value.canRead).toBe(false); + expect(result.value.canUpdate).toBe(true); + expect(result.value.canDelete).toBe(false); + expect(result.value.canList).toBe(true); + } + }); + + it('should make 5 SelfSubjectAccessReview requests', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ status: { allowed: true } }), + }); + + await checkSealedSecretPermissions('test-ns'); + + expect(global.fetch).toHaveBeenCalledTimes(5); + }); + + it('should send correct request body structure', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ status: { allowed: true } }), + }); + + await checkSealedSecretPermissions('my-ns'); + + // Check the first call (create) + const calls = (global.fetch as ReturnType).mock.calls; + const firstCallBody = JSON.parse(calls[0][1].body); + expect(firstCallBody.apiVersion).toBe('authorization.k8s.io/v1'); + expect(firstCallBody.kind).toBe('SelfSubjectAccessReview'); + expect(firstCallBody.spec.resourceAttributes.resource).toBe('sealedsecrets'); + expect(firstCallBody.spec.resourceAttributes.group).toBe('bitnami.com'); + expect(firstCallBody.spec.resourceAttributes.namespace).toBe('my-ns'); + expect(firstCallBody.spec.resourceAttributes.verb).toBe('create'); + }); + + it('should omit namespace when not provided', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ status: { allowed: true } }), + }); + + await checkSealedSecretPermissions(); + + const calls = (global.fetch as ReturnType).mock.calls; + const firstCallBody = JSON.parse(calls[0][1].body); + expect(firstCallBody.spec.resourceAttributes.namespace).toBeUndefined(); + }); + + it('should return false when fetch fails for individual checks', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + }); + + const result = await checkSealedSecretPermissions('default'); + + expect(result.ok).toBe(true); + if (result.ok) { + // Individual failures return false (assume no permission) + expect(result.value.canCreate).toBe(false); + } + }); + + it('should return Err when Promise.all rejects', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + const result = await checkSealedSecretPermissions('default'); + + // The tryCatchAsync in checkPermission catches this, so individual results are false + // But if the outer try/catch catches, we get Err + // With current implementation, individual failures return false, not Err + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.canCreate).toBe(false); + } + }); + }); + + describe('canDecryptSecrets', () => { + it('should return true when get secrets is allowed', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ status: { allowed: true } }), + }); + + const result = await canDecryptSecrets('default'); + expect(result).toBe(true); + }); + + it('should return false when get secrets is denied', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ status: { allowed: false } }), + }); + + const result = await canDecryptSecrets('default'); + expect(result).toBe(false); + }); + + it('should return false on error', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('network error')); + + const result = await canDecryptSecrets('default'); + expect(result).toBe(false); + }); + + it('should check secrets resource with get verb', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ status: { allowed: true } }), + }); + + await canDecryptSecrets('prod'); + + const body = JSON.parse((global.fetch as ReturnType).mock.calls[0][1].body); + expect(body.spec.resourceAttributes.resource).toBe('secrets'); + expect(body.spec.resourceAttributes.verb).toBe('get'); + expect(body.spec.resourceAttributes.namespace).toBe('prod'); + }); + }); +}); diff --git a/src/lib/rbac.ts b/src/lib/rbac.ts index 6f01b81..be568b1 100644 --- a/src/lib/rbac.ts +++ b/src/lib/rbac.ts @@ -51,8 +51,12 @@ export async function checkSealedSecretPermissions( canDelete, canList, }); - } catch (error: any) { - return Err(`Failed to check SealedSecret permissions: ${error.message}`); + } catch (error: unknown) { + return Err( + `Failed to check SealedSecret permissions: ${ + error instanceof Error ? error.message : String(error) + }` + ); } } @@ -70,20 +74,6 @@ export async function canDecryptSecrets(namespace: string): Promise { } } -/** - * Check if user can view sealing keys (requires get permission on Secrets in controller namespace) - * - * @param controllerNamespace Namespace where sealed-secrets controller is running - * @returns true if user has permission to get Secrets in controller namespace - */ -export async function canViewSealingKeys(controllerNamespace: string): Promise { - try { - return await checkPermission('get', 'secrets', '', controllerNamespace); - } catch { - return false; - } -} - /** * Check a specific permission using SelfSubjectAccessReview * @@ -130,36 +120,3 @@ async function checkPermission( // Return false on error (assume no permission) return result.ok ? result.value : false; } - -/** - * Check permissions for multiple namespaces - * - * Useful for multi-namespace views to determine which namespaces the user - * can interact with. - * - * @param namespaces Array of namespace names to check - * @returns Map of namespace to permissions - */ -export async function checkMultiNamespacePermissions( - namespaces: string[] -): AsyncResult, string> { - try { - const results = await Promise.all( - namespaces.map(async ns => { - const perms = await checkSealedSecretPermissions(ns); - return { namespace: ns, permissions: perms }; - }) - ); - - const permissionsMap: Record = {}; - for (const { namespace, permissions } of results) { - if (permissions.ok) { - permissionsMap[namespace] = permissions.value; - } - } - - return Ok(permissionsMap); - } catch (error: any) { - return Err(`Failed to check multi-namespace permissions: ${error.message}`); - } -} diff --git a/src/lib/retry.ts b/src/lib/retry.ts index 4e8239a..2ec3853 100644 --- a/src/lib/retry.ts +++ b/src/lib/retry.ts @@ -133,54 +133,3 @@ export async function retryWithBackoff( // Should never reach here, but TypeScript needs it return Err(`Operation failed after ${opts.maxAttempts} attempts:\n${errors.join('\n')}`); } - -/** - * Predicate to check if error is a network error (retryable) - * - * @param error Error to check - * @returns true if error is network-related - */ -export function isNetworkError(error: Error): boolean { - const message = error.message.toLowerCase(); - return ( - message.includes('network') || - message.includes('timeout') || - message.includes('fetch') || - message.includes('connection') || - message.includes('econnrefused') || - message.includes('enotfound') - ); -} - -/** - * Predicate to check if HTTP error is retryable (5xx, 429, 408) - * - * @param error Error to check - * @returns true if HTTP status is retryable - */ -export function isRetryableHttpError(error: Error): boolean { - const message = error.message; - - // Check for 5xx server errors - if (/5\d{2}/.test(message)) { - return true; - } - - // Check for specific retryable status codes - return ( - message.includes('429') || // Too Many Requests - message.includes('408') || // Request Timeout - message.includes('503') || // Service Unavailable - message.includes('504') - ); // Gateway Timeout -} - -/** - * Combined predicate for network and HTTP errors - * - * @param error Error to check - * @returns true if error is retryable - */ -export function isRetryableError(error: Error): boolean { - return isNetworkError(error) || isRetryableHttpError(error); -} diff --git a/src/lib/validators.test.ts b/src/lib/validators.test.ts index aa41005..e20f2c6 100644 --- a/src/lib/validators.test.ts +++ b/src/lib/validators.test.ts @@ -15,7 +15,6 @@ const localStorageMock = { import { describe, expect, it } from 'vitest'; import { - isValidNamespace, validatePEMCertificate, validateSecretKey, validateSecretName, @@ -80,27 +79,6 @@ describe('validators', () => { }); }); - describe('validateNamespace', () => { - it('should accept valid namespace names', () => { - expect(isValidNamespace('default')).toBe(true); - expect(isValidNamespace('kube-system')).toBe(true); - expect(isValidNamespace('my-namespace')).toBe(true); - expect(isValidNamespace('ns-123')).toBe(true); - }); - - it('should reject invalid namespace names', () => { - expect(isValidNamespace('')).toBe(false); - expect(isValidNamespace('My-Namespace')).toBe(false); - expect(isValidNamespace('-namespace')).toBe(false); - expect(isValidNamespace('namespace-')).toBe(false); - expect(isValidNamespace('namespace_name')).toBe(false); - }); - - it('should reject namespaces exceeding 253 characters', () => { - expect(isValidNamespace('x'.repeat(254))).toBe(false); - }); - }); - describe('validateSecretKey', () => { it('should accept valid secret keys', () => { expect(validateSecretKey('password').valid).toBe(true); diff --git a/src/lib/validators.ts b/src/lib/validators.ts index edab99a..3016038 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -5,51 +5,6 @@ * and runtime type checking for SealedSecret objects. */ -import { SealedSecretInterface, SealedSecretScope } from '../types'; -import { SealedSecret } from './SealedSecretCRD'; - -/** - * Runtime type guard for SealedSecret - * - * @param obj Object to check - * @returns true if obj is a SealedSecret instance - */ -export function isSealedSecret(obj: any): obj is SealedSecret { - return ( - obj instanceof SealedSecret && - obj.jsonData && - 'spec' in obj.jsonData && - 'encryptedData' in obj.jsonData.spec - ); -} - -/** - * Validate SealedSecret structure - * - * @param obj Object to validate - * @returns true if obj has valid SealedSecret structure - */ -export function validateSealedSecretInterface(obj: any): obj is SealedSecretInterface { - return ( - typeof obj === 'object' && - obj !== null && - 'spec' in obj && - typeof obj.spec === 'object' && - 'encryptedData' in obj.spec && - typeof obj.spec.encryptedData === 'object' - ); -} - -/** - * Validate scope value - * - * @param value Value to check - * @returns true if value is a valid SealedSecretScope - */ -export function isSealedSecretScope(value: any): value is SealedSecretScope { - return ['strict', 'namespace-wide', 'cluster-wide'].includes(value); -} - /** * Validate Kubernetes resource name * @@ -61,7 +16,7 @@ export function isSealedSecretScope(value: any): value is SealedSecretScope { * @param name Name to validate * @returns true if valid Kubernetes resource name */ -export function isValidK8sName(name: string): boolean { +function isValidK8sName(name: string): boolean { if (!name || name.length === 0 || name.length > 253) { return false; } @@ -76,7 +31,7 @@ export function isValidK8sName(name: string): boolean { * @param key Key to validate * @returns true if valid Kubernetes key */ -export function isValidK8sKey(key: string): boolean { +function isValidK8sKey(key: string): boolean { if (!key || key.length === 0 || key.length > 253) { return false; } @@ -93,7 +48,7 @@ export function isValidK8sKey(key: string): boolean { * @param value String to validate * @returns true if valid PEM format */ -export function isValidPEM(value: string): boolean { +function isValidPEM(value: string): boolean { if (!value || typeof value !== 'string') { return false; } @@ -103,28 +58,6 @@ export function isValidPEM(value: string): boolean { return pemRegex.test(value.trim()); } -/** - * Validate that a value is not empty - * - * @param value Value to check - * @returns true if value is non-empty string - */ -export function isNonEmpty(value: string): boolean { - return typeof value === 'string' && value.trim().length > 0; -} - -/** - * Validate namespace name - * - * Same rules as resource names - * - * @param namespace Namespace to validate - * @returns true if valid namespace name - */ -export function isValidNamespace(namespace: string): boolean { - return isValidK8sName(namespace); -} - /** * Validation result with error message */ @@ -223,29 +156,3 @@ export function validatePEMCertificate(pem: string): ValidationResult { return { valid: true }; } - -/** - * Validate plugin configuration - * - * @param config Configuration to validate - * @returns Validation result with error message if invalid - */ -export function validatePluginConfig(config: { - controllerName?: string; - controllerNamespace?: string; - controllerPort?: number; -}): ValidationResult { - if (!config.controllerName || !isValidK8sName(config.controllerName)) { - return { valid: false, error: 'Invalid controller name' }; - } - - if (!config.controllerNamespace || !isValidNamespace(config.controllerNamespace)) { - return { valid: false, error: 'Invalid controller namespace' }; - } - - if (!config.controllerPort || config.controllerPort < 1 || config.controllerPort > 65535) { - return { valid: false, error: 'Invalid controller port (must be 1-65535)' }; - } - - return { valid: true }; -} diff --git a/src/types.ts b/src/types.ts index b9f5ee5..a34f3c7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,17 +75,6 @@ export function PlaintextValue(value: string): PlaintextValue { return value as PlaintextValue; } -/** - * Create a branded encrypted value - * This is typically used by encryption functions - * - * @example - * return Ok(EncryptedValue(encryptedString)); - */ -export function EncryptedValue(value: string): EncryptedValue { - return value as EncryptedValue; -} - /** * Create a branded base64 string * @@ -106,17 +95,6 @@ export function PEMCertificate(value: string): PEMCertificate { return value as PEMCertificate; } -/** - * Unwrap a branded type to get the raw string - * Use sparingly - only when you need the raw value - * - * @example - * const rawValue = unwrap(plaintextValue); - */ -export function unwrap(value: T): string { - return value; -} - /** * Helper to create a success result * @@ -196,7 +174,7 @@ export interface SealedSecretSpec { /** * SealedSecret status condition */ -export interface SealedSecretCondition { +interface SealedSecretCondition { type: string; status: 'True' | 'False' | 'Unknown'; lastTransitionTime?: string; @@ -233,15 +211,6 @@ export interface PluginConfig { controllerPort: number; } -/** - * Default plugin configuration - */ -export const DEFAULT_CONFIG: PluginConfig = { - controllerName: 'sealed-secrets-controller', - controllerNamespace: 'kube-system', - controllerPort: 8080, -}; - /** * Key-value pair for encryption dialog */ @@ -250,16 +219,6 @@ export interface SecretKeyValue { value: string; } -/** - * Encryption request parameters - */ -export interface EncryptionRequest { - name: string; - namespace: string; - scope: SealedSecretScope; - keyValues: SecretKeyValue[]; -} - /** * Certificate information extracted from PEM certificate */