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 }) => (
+
+ ),
+ 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 ? (
-