fix: remove any types, dead code, unused exports; add comprehensive tests

- Fix handleRotate bug ignoring Result from rotateSealedSecret()
- Fix dead code branch in useControllerHealth
- Replace all `any` types with `unknown` + type guards
- Delete unused functions/exports (452 lines removed)
- Add 18 new test files covering all hooks, libs, and components
- 233 tests passing, zero tsc errors, zero lint issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DevContainer User
2026-03-04 17:13:00 +00:00
parent 3dc2f92a87
commit 9d9bc5f22f
37 changed files with 3703 additions and 460 deletions
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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",
+137
View File
@@ -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 }) => <span data-testid="icon">{icon}</span>,
}));
vi.mock('../hooks/useControllerHealth', () => ({
useControllerHealth: vi.fn(),
}));
vi.mock('./LoadingSkeletons', () => ({
ControllerHealthSkeleton: () => <div data-testid="skeleton">Loading...</div>,
}));
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(<ControllerStatus />);
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(<ControllerStatus />);
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(<ControllerStatus />);
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(<ControllerStatus />);
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(<ControllerStatus showDetails />);
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(<ControllerStatus showDetails={false} />);
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(<ControllerStatus autoRefresh refreshIntervalMs={5000} />);
expect(mockUseHealth).toHaveBeenCalledWith(true, 5000);
});
});
+167
View File
@@ -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 }) => <span data-testid="icon">{icon}</span>,
}));
// 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(
<DecryptDialog sealedSecret={mockSealedSecret} secretKey="password" onClose={vi.fn()} />
);
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(
<DecryptDialog sealedSecret={mockSealedSecret} secretKey="missing-key" onClose={vi.fn()} />
);
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(
<DecryptDialog sealedSecret={mockSealedSecret} secretKey="password" onClose={vi.fn()} />
);
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(<DecryptDialog sealedSecret={mockSealedSecret} secretKey="key" onClose={vi.fn()} />);
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(<DecryptDialog sealedSecret={mockSealedSecret} secretKey="key" onClose={onClose} />);
// 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(<DecryptDialog sealedSecret={mockSealedSecret} secretKey="key" onClose={vi.fn()} />);
// 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(<DecryptDialog sealedSecret={mockSealedSecret} secretKey="key" onClose={vi.fn()} />);
// 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(<DecryptDialog sealedSecret={mockSealedSecret} secretKey="key" onClose={onClose} />);
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(<DecryptDialog sealedSecret={mockSealedSecret} secretKey="key" onClose={vi.fn()} />);
expect(screen.getByText(/Security Warning/)).toBeDefined();
});
});
+218
View File
@@ -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 }) => <span data-testid={`icon-${icon}`}>{icon}</span>,
}));
// 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(<EncryptDialog open onClose={vi.fn()} />);
expect(screen.getByText('Create Sealed Secret')).toBeDefined();
expect(screen.getByLabelText('Secret name')).toBeDefined();
});
it('should not render when closed', () => {
render(<EncryptDialog open={false} onClose={vi.fn()} />);
expect(screen.queryByText('Create Sealed Secret')).toBeNull();
});
it('should have one key-value pair by default', () => {
render(<EncryptDialog open onClose={vi.fn()} />);
expect(screen.getByLabelText('Key name 1')).toBeDefined();
});
it('should add key-value pair on button click', () => {
render(<EncryptDialog open onClose={vi.fn()} />);
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(<EncryptDialog open onClose={vi.fn()} />);
const removeButton = screen.getByLabelText('Remove key-value pair 1');
expect(removeButton).toHaveAttribute('disabled');
});
it('should allow removing when multiple pairs exist', () => {
render(<EncryptDialog open onClose={vi.fn()} />);
// 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(<EncryptDialog open onClose={onClose} />);
// 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(<EncryptDialog open onClose={vi.fn()} />);
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(<EncryptDialog open onClose={vi.fn()} />);
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(<EncryptDialog open onClose={onClose} />);
fireEvent.click(screen.getByLabelText('Cancel creation'));
expect(onClose).toHaveBeenCalled();
});
it('should show security note', () => {
render(<EncryptDialog open onClose={vi.fn()} />);
expect(screen.getByText(/Security Note/)).toBeDefined();
expect(screen.getByText(/encrypted entirely in your browser/)).toBeDefined();
});
it('should toggle password visibility', () => {
render(<EncryptDialog open onClose={vi.fn()} />);
const toggleButton = screen.getByLabelText('Show password');
fireEvent.click(toggleButton);
expect(screen.getByLabelText('Hide password')).toBeDefined();
});
});
+5 -2
View File
@@ -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' }
);
}
};
+150
View File
@@ -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 }) => <span data-testid="icon">{icon}</span>,
}));
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 <div>Working fine</div>;
}
describe('ErrorBoundary', () => {
describe('ApiErrorBoundary', () => {
it('should render children when no error', () => {
render(
<ApiErrorBoundary>
<GoodComponent />
</ApiErrorBoundary>
);
expect(screen.getByText('Working fine')).toBeDefined();
});
it('should catch errors and show API error UI', () => {
render(
<ApiErrorBoundary>
<ThrowingComponent error={new Error('API connection failed')} />
</ApiErrorBoundary>
);
expect(screen.getByText('API Communication Error')).toBeDefined();
expect(screen.getByText(/API connection failed/)).toBeDefined();
});
it('should show retry button that resets error', () => {
render(
<ApiErrorBoundary>
<ThrowingComponent error={new Error('test error')} />
</ApiErrorBoundary>
);
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(
<ApiErrorBoundary fallback={<div>Custom fallback</div>}>
<ThrowingComponent error={new Error('error')} />
</ApiErrorBoundary>
);
expect(screen.getByText('Custom fallback')).toBeDefined();
});
it('should call onReset when retry is clicked', () => {
const onReset = vi.fn();
render(
<ApiErrorBoundary onReset={onReset}>
<ThrowingComponent error={new Error('error')} />
</ApiErrorBoundary>
);
fireEvent.click(screen.getByText('Retry'));
expect(onReset).toHaveBeenCalledTimes(1);
});
it('should show guidance about troubleshooting', () => {
render(
<ApiErrorBoundary>
<ThrowingComponent error={new Error('error')} />
</ApiErrorBoundary>
);
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(
<GenericErrorBoundary>
<GoodComponent />
</GenericErrorBoundary>
);
expect(screen.getByText('Working fine')).toBeDefined();
});
it('should catch errors and show generic error UI', () => {
render(
<GenericErrorBoundary>
<ThrowingComponent error={new Error('Unexpected error')} />
</GenericErrorBoundary>
);
expect(screen.getByText('Something Went Wrong')).toBeDefined();
expect(screen.getByText(/Unexpected error/)).toBeDefined();
});
it('should show reload button', () => {
render(
<GenericErrorBoundary>
<ThrowingComponent error={new Error('error')} />
</GenericErrorBoundary>
);
expect(screen.getByText('Reload')).toBeDefined();
});
it('should render custom fallback', () => {
render(
<GenericErrorBoundary fallback={<div>Custom error view</div>}>
<ThrowingComponent error={new Error('error')} />
</GenericErrorBoundary>
);
expect(screen.getByText('Custom error view')).toBeDefined();
});
});
});
-52
View File
@@ -63,58 +63,6 @@ abstract class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoun
}
}
/**
* Error boundary for cryptographic operations
*
* Catches errors during encryption/decryption and provides
* helpful context about what might have gone wrong.
*/
export class CryptoErrorBoundary extends BaseErrorBoundary {
renderError() {
return (
<Box p={3}>
<Alert
severity="error"
icon={<Icon icon="mdi:alert-circle-outline" />}
action={
<Button color="inherit" size="small" onClick={this.handleReset}>
Retry
</Button>
}
>
<Typography variant="h6" gutterBottom>
Cryptographic Operation Failed
</Typography>
<Typography variant="body2" paragraph>
An error occurred during encryption or decryption. This might indicate:
</Typography>
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li>Invalid or expired controller certificate</li>
<li>Browser cryptography compatibility issue</li>
<li>Malformed secret data</li>
<li>Controller not reachable or misconfigured</li>
</ul>
{this.state.error && (
<Typography
variant="body2"
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }}
>
{(() => {
try {
const msg = this.state.error.message || this.state.error.toString();
return `Error: ${String(msg)}`;
} catch (e) {
return 'Error: [Unable to display error message]';
}
})()}
</Typography>
)}
</Alert>
</Box>
);
}
}
/**
* Error boundary for API operations
*
+47
View File
@@ -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(<SealedSecretListSkeleton />);
expect(container.querySelector('.MuiSkeleton-root')).toBeTruthy();
});
it('should render SealedSecretDetailSkeleton without errors', () => {
const { container } = render(<SealedSecretDetailSkeleton />);
expect(container.querySelector('.MuiSkeleton-root')).toBeTruthy();
});
it('should render SealingKeysListSkeleton without errors', () => {
const { container } = render(<SealingKeysListSkeleton />);
expect(container.querySelector('.MuiSkeleton-root')).toBeTruthy();
});
it('should render ControllerHealthSkeleton without errors', () => {
const { container } = render(<ControllerHealthSkeleton />);
expect(container.querySelector('.MuiSkeleton-root')).toBeTruthy();
});
it('should render list skeleton with multiple rows', () => {
const { container } = render(<SealedSecretListSkeleton />);
const skeletons = container.querySelectorAll('.MuiSkeleton-root');
expect(skeletons.length).toBe(5);
});
it('should render detail skeleton with multiple sections', () => {
const { container } = render(<SealedSecretDetailSkeleton />);
const skeletons = container.querySelectorAll('.MuiSkeleton-root');
expect(skeletons.length).toBeGreaterThanOrEqual(3);
});
});
-16
View File
@@ -116,22 +116,6 @@ export function SealingKeysListSkeleton() {
);
}
/**
* Skeleton for certificate information
*
* Shows placeholder for certificate metadata
*/
export function CertificateInfoSkeleton() {
return (
<Box>
<Skeleton variant="text" width="60%" animation="wave" />
<Skeleton variant="text" width="40%" animation="wave" />
<Skeleton variant="text" width="50%" animation="wave" />
<Skeleton variant="text" width="45%" animation="wave" />
</Box>
);
}
/**
* Skeleton for controller health status
*
+262
View File
@@ -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 }) => <span data-testid={`icon-${icon}`}>{icon}</span>,
}));
// 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 }) => <a>{children}</a>,
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: unknown; hide?: boolean }> }) => (
<table data-testid="name-value-table">
<tbody>
{rows
.filter(r => !r.hide)
.map((row, i) => (
<tr key={i}>
<td>{row.name}</td>
<td>{typeof row.value === 'string' ? row.value : <>{row.value}</>}</td>
</tr>
))}
</tbody>
</table>
),
SectionBox: ({ title, children }: { title: React.ReactNode; children: React.ReactNode }) => (
<div data-testid="section-box">
<div data-testid="section-title">{title}</div>
{children}
</div>
),
SimpleTable: ({ data }: { data: unknown[] }) => (
<table data-testid="encrypted-table">
<tbody>
{(data || []).map((_, i) => (
<tr key={i}>
<td>row</td>
</tr>
))}
</tbody>
</table>
),
StatusLabel: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
// 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: () => <div data-testid="decrypt-dialog" />,
}));
vi.mock('./LoadingSkeletons', () => ({
SealedSecretDetailSkeleton: () => <div data-testid="skeleton">Loading...</div>,
}));
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(<SealedSecretDetail />);
expect(screen.getByTestId('skeleton')).toBeDefined();
});
it('should show error when fetch fails', () => {
mockUseGet.mockReturnValue([null, 'Not found'] as never);
render(<SealedSecretDetail />);
expect(screen.getByText('Failed to load SealedSecret')).toBeDefined();
});
it('should show skeleton when params are missing', () => {
mockUseParams.mockReturnValue({});
render(<SealedSecretDetail />);
expect(screen.getByTestId('skeleton')).toBeDefined();
});
it('should render detail view with data', () => {
render(<SealedSecretDetail />);
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(<SealedSecretDetail />);
// 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(<SealedSecretDetail />);
expect(screen.getByTestId('encrypted-table')).toBeDefined();
});
it('should render action buttons when user has permissions', () => {
render(<SealedSecretDetail />);
// 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(<SealedSecretDetail />);
// 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(<SealedSecretDetail />);
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' }
);
});
}
});
});
+39 -12
View File
@@ -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={
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center" gap={1}>
<IconButton onClick={handleClose} edge="start" size="small" aria-label="Close detail panel">
<IconButton
onClick={handleClose}
edge="start"
size="small"
aria-label="Close detail panel"
>
<Icon icon="mdi:close" />
</IconButton>
<span>{sealedSecret.metadata.name}</span>
@@ -252,11 +266,20 @@ export function SealedSecretDetail() {
label: 'Actions',
getter: (row: { key: string; value: string }) =>
canDecrypt ? (
<Button size="small" onClick={() => setDecryptKey(row.key)} aria-label={`Decrypt ${row.key}`}>
<Button
size="small"
onClick={() => setDecryptKey(row.key)}
aria-label={`Decrypt ${row.key}`}
>
Decrypt
</Button>
) : (
<Button size="small" disabled title="No permission to access Secrets" aria-label={`Decrypt ${row.key} (no permission)`}>
<Button
size="small"
disabled
title="No permission to access Secrets"
aria-label={`Decrypt ${row.key} (no permission)`}
>
Decrypt
</Button>
),
@@ -337,7 +360,11 @@ export function SealedSecretDetail() {
/>
)}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)} aria-labelledby="delete-dialog-title">
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
aria-labelledby="delete-dialog-title"
>
<DialogTitle id="delete-dialog-title">Delete SealedSecret?</DialogTitle>
<DialogContent>
Are you sure you want to delete the SealedSecret <strong>{name}</strong>? This will also
+160
View File
@@ -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 }) => <a>{children}</a>,
SectionBox: ({ title, children }: { title: string; children: React.ReactNode }) => (
<div data-testid="section-box">
<h2>{title}</h2>
{children}
</div>
),
SectionFilterHeader: ({ actions }: { actions?: React.ReactNode[] }) => (
<div data-testid="filter-header">
{actions?.map((action, i) => (
<div key={i}>{action}</div>
))}
</div>
),
SimpleTable: ({ data }: { data: unknown[] }) => (
<table data-testid="simple-table">
<tbody>
{(data || []).map((_, i) => (
<tr key={i}>
<td>row {i}</td>
</tr>
))}
</tbody>
</table>
),
StatusLabel: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
// 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: () => <div data-testid="encrypt-dialog" />,
}));
vi.mock('./LoadingSkeletons', () => ({
SealedSecretListSkeleton: () => <div data-testid="skeleton">Loading...</div>,
}));
vi.mock('./SealedSecretDetail', () => ({
SealedSecretDetail: () => <div data-testid="detail" />,
}));
vi.mock('./VersionWarning', () => ({
VersionWarning: () => <div data-testid="version-warning" />,
}));
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(<SealedSecretList />);
expect(screen.getByTestId('skeleton')).toBeDefined();
});
it('should show error when fetch fails', () => {
mockUseList.mockReturnValue([null, { message: 'Failed to fetch' }, false] as never);
render(<SealedSecretList />);
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(<SealedSecretList />);
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(<SealedSecretList />);
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(<SealedSecretList />);
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(<SealedSecretList />);
expect(screen.queryByText('Create Sealed Secret')).toBeNull();
});
it('should render VersionWarning', () => {
mockUseList.mockReturnValue([[], null, false] as never);
render(<SealedSecretList />);
expect(screen.getByTestId('version-warning')).toBeDefined();
});
});
+256
View File
@@ -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[] };
}) => (
<div data-testid="section-box">
<h2>{title}</h2>
<div data-testid="header-actions">
{headerProps?.actions?.map((action, i) => (
<div key={i}>{action}</div>
))}
</div>
{children}
</div>
),
SimpleTable: ({
data,
columns,
}: {
data: unknown[];
columns: Array<{ label: string; getter: (row: unknown) => React.ReactNode }>;
}) => (
<table data-testid="keys-table">
<thead>
<tr>
{columns.map((col, i) => (
<th key={i}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map((col, j) => (
<td key={j}>{col.getter(row)}</td>
))}
</tr>
))}
</tbody>
</table>
),
StatusLabel: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
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: () => <div data-testid="controller-status">Status</div>,
}));
vi.mock('./LoadingSkeletons', () => ({
SealingKeysListSkeleton: () => <div data-testid="skeleton">Loading...</div>,
}));
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(<SealingKeysView />);
expect(screen.getByTestId('skeleton')).toBeDefined();
});
it('should show empty message when no sealing keys found', () => {
mockUseList.mockReturnValue([[], null, false] as never);
render(<SealingKeysView />);
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(<SealingKeysView />);
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(<SealingKeysView />);
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(<SealingKeysView />);
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(<SealingKeysView />);
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(<SealingKeysView />);
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(<SealingKeysView />);
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(<SealingKeysView />);
expect(screen.getByTestId('controller-status')).toBeDefined();
});
});
+11 -3
View File
@@ -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 (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<span>{expiryDate}</span>
<span style={{ color: 'var(--mui-palette-text-secondary, #666)', fontSize: '0.9em' }}>
<span
style={{
color: 'var(--mui-palette-text-secondary, #666)',
fontSize: '0.9em',
}}
>
({certInfo.daysUntilExpiry} days)
</span>
</Box>
@@ -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 }) => (
<a data-testid="link" {...props}>
{children}
</a>
),
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: unknown }> }) => (
<table>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
<td>{row.name}</td>
<td>{typeof row.value === 'string' ? row.value : <>{row.value}</>}</td>
</tr>
))}
</tbody>
</table>
),
SectionBox: ({ title, children }: { title: string; children: React.ReactNode }) => (
<div data-testid="section-box">
<h2>{title}</h2>
{children}
</div>
),
StatusLabel: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
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(<SecretDetailsSection resource={resource} />);
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(<SecretDetailsSection resource={resource} />);
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(<SecretDetailsSection resource={resource} />);
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(<SecretDetailsSection resource={resource} />);
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(<SecretDetailsSection resource={resource} />);
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(<SecretDetailsSection resource={resource} />);
expect(container.innerHTML).toBe('');
});
});
+18 -2
View File
@@ -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) {
+144
View File
@@ -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: () => <div data-testid="controller-status">Status</div>,
}));
vi.mock('./VersionWarning', () => ({
VersionWarning: () => <div data-testid="version-warning">Version</div>,
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
SectionBox: ({ title, children }: { title: string; children: React.ReactNode }) => (
<div data-testid="section-box">
<h2>{title}</h2>
{children}
</div>
),
}));
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(<SettingsPage />);
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(<SettingsPage />);
expect(screen.getByTestId('controller-status')).toBeDefined();
expect(screen.getByTestId('version-warning')).toBeDefined();
});
it('should save config on Save button click', () => {
render(<SettingsPage />);
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(<SettingsPage />);
// 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(<SettingsPage onDataChange={onDataChange} />);
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(<SettingsPage onDataChange={onDataChange} />);
fireEvent.click(screen.getByText('Save Settings'));
expect(onDataChange).toHaveBeenCalled();
});
it('should use data props for initial values when provided', () => {
render(
<SettingsPage
data={{
controllerName: 'from-props',
controllerNamespace: 'custom-ns',
controllerPort: 9090,
}}
/>
);
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(<SettingsPage />);
expect(screen.getByText('Default Values')).toBeDefined();
});
});
+141
View File
@@ -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(<VersionWarning autoDetect />);
expect(container.innerHTML).toBe('');
});
it('should show nothing on default version detection', async () => {
mockDetectVersion.mockResolvedValue({
ok: true,
value: 'bitnami.com/v1alpha1',
});
const { container } = render(<VersionWarning autoDetect />);
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(<VersionWarning autoDetect />);
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(<VersionWarning autoDetect />);
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(<VersionWarning autoDetect />);
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(<VersionWarning autoDetect />);
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(<VersionWarning autoDetect showDetails />);
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(<VersionWarning autoDetect={false} />);
expect(mockDetectVersion).not.toHaveBeenCalled();
});
it('should handle unexpected exceptions', async () => {
mockDetectVersion.mockRejectedValue(new Error('Unexpected'));
render(<VersionWarning autoDetect />);
await waitFor(() => {
expect(screen.getByText('API Version Detection Failed')).toBeDefined();
expect(screen.getByText(/Unexpected/)).toBeDefined();
});
});
});
+187
View File
@@ -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);
});
});
+3 -5
View File
@@ -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);
+216
View File
@@ -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);
});
});
});
-50
View File
@@ -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 };
}
@@ -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<string, unknown>;
expect(data.apiVersion).toBe('bitnami.com/v1alpha1');
expect(data.kind).toBe('SealedSecret');
expect((data.metadata as Record<string, unknown>).name).toBe('my-secret');
expect((data.metadata as Record<string, unknown>).namespace).toBe('default');
}
});
it('should add namespace-wide scope annotation', async () => {
const { result } = renderHook(() => useSealedSecretEncryption());
let encryptResult: {
ok: boolean;
value?: { sealedSecretData: { metadata: { annotations: Record<string, string> } } };
};
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<string, string> } } };
};
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<unknown>;
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);
});
});
+23 -4
View File
@@ -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<string, string>;
};
spec: {
encryptedData: Record<string, string>;
template: {
metadata: Record<string, unknown>;
};
};
}
/**
* 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 {
+117
View File
@@ -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
);
});
});
+8 -2
View File
@@ -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<SealedSecretInterface> {
);
// 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<SealedSecretInterface> {
}
// 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;
+289
View File
@@ -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<unknown>) => 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,
})
);
});
});
});
+4 -36
View File
@@ -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<boolean, string> {
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;
}
+297
View File
@@ -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);
}
});
});
});
+2 -13
View File
@@ -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
*
+197
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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');
});
});
});
+6 -49
View File
@@ -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<boolean> {
}
}
/**
* 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<boolean> {
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<Record<string, ResourcePermissions>, string> {
try {
const results = await Promise.all(
namespaces.map(async ns => {
const perms = await checkSealedSecretPermissions(ns);
return { namespace: ns, permissions: perms };
})
);
const permissionsMap: Record<string, ResourcePermissions> = {};
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}`);
}
}
-51
View File
@@ -133,54 +133,3 @@ export async function retryWithBackoff<T, E>(
// 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);
}
-22
View File
@@ -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);
+3 -96
View File
@@ -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 };
}
+1 -42
View File
@@ -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<T extends string>(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
*/