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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user