feat(09-05): implement ConvertTab + convertCertAction + render tests
- Add FileResponse interface to actions.ts - Add convertCertAction(input, targetFormat): builds FormData with file/pemText/password + targetFormat, calls postForm convert endpoint - Implement ConvertTab: native select for pem/der/p7b targetFormat, Konvertieren button with loading swap, error classification, empty state - On success: calls downloadBase64(filename, content, mimeType) - 3 new ConvertTab tests: format selector options, downloadBase64 invoked on success, text-destructive error on format rejection - All 14 web cert-manager tests green
This commit is contained in:
@@ -116,6 +116,43 @@ export function downloadBase64(
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileResponse — mirrors CertManagerService.FileResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FileResponse {
|
||||
/** Suggested download filename, e.g. "converted.der" */
|
||||
filename: string;
|
||||
/** Base64-encoded file content */
|
||||
content: string;
|
||||
/** MIME type for the download */
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// convertCertAction — calls POST /modules/cert-manager/convert
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Upload a certificate and convert it to the chosen target format.
|
||||
* Returns a FileResponse — the caller should call downloadBase64(filename, content, mimeType).
|
||||
*
|
||||
* T-09-02: password is never placed in URL, logged, or echoed.
|
||||
* T-09-04: credentials:'include' via postForm ensures JWT cookie is sent.
|
||||
*/
|
||||
export async function convertCertAction(
|
||||
input: { file?: File | null; pemText?: string; password?: string },
|
||||
targetFormat: string,
|
||||
): Promise<FileResponse> {
|
||||
const { file, pemText, password } = input;
|
||||
const form = new FormData();
|
||||
if (file) form.append('file', file);
|
||||
if (pemText) form.append('pemText', pemText);
|
||||
if (password) form.append('password', password);
|
||||
form.append('targetFormat', targetFormat);
|
||||
return postForm('convert', form) as Promise<FileResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a FormData payload to a cert-manager endpoint.
|
||||
* T-09-04: credentials:'include' ensures JWT cookie is sent for ModuleGuard.
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock('next-intl', () => ({
|
||||
import CertManagerPage from './page';
|
||||
import { InspectTab } from './components/InspectTab';
|
||||
import { SplitTab } from './components/SplitTab';
|
||||
import { ConvertTab } from './components/ConvertTab';
|
||||
import * as actions from './actions';
|
||||
|
||||
afterEach(() => {
|
||||
@@ -265,3 +266,74 @@ describe('SplitTab', () => {
|
||||
expect(errorEl.className).toContain('text-destructive');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConvertTab — unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_FILE_RESPONSE: actions.FileResponse = {
|
||||
filename: 'converted.der',
|
||||
content: btoa('fake der bytes'),
|
||||
mimeType: 'application/x-x509-ca-cert',
|
||||
};
|
||||
|
||||
describe('ConvertTab', () => {
|
||||
it('renders format selector with pem, der, p7b options', () => {
|
||||
render(<ConvertTab file={null} pemText="" password="" />);
|
||||
const select = screen.getByRole('combobox');
|
||||
const optionValues = Array.from(select.querySelectorAll('option')).map(
|
||||
(o) => (o as HTMLOptionElement).value,
|
||||
);
|
||||
expect(optionValues).toContain('pem');
|
||||
expect(optionValues).toContain('der');
|
||||
expect(optionValues).toContain('p7b');
|
||||
});
|
||||
|
||||
it('calls downloadBase64 after clicking Konvertieren when convertCertAction resolves', async () => {
|
||||
vi.spyOn(actions, 'convertCertAction').mockResolvedValue(MOCK_FILE_RESPONSE);
|
||||
const downloadSpy = vi.spyOn(actions, 'downloadBase64').mockImplementation(() => {});
|
||||
|
||||
const testFile = new File(['cert data'], 'cert.pem', {
|
||||
type: 'application/x-pem-file',
|
||||
});
|
||||
|
||||
render(<ConvertTab file={testFile} pemText="" password="" />);
|
||||
|
||||
// Click the Konvertieren button
|
||||
fireEvent.click(screen.getByText('Konvertieren'));
|
||||
|
||||
// Wait for async action to resolve and downloadBase64 to be called
|
||||
await waitFor(() => {
|
||||
expect(downloadSpy).toHaveBeenCalledWith(
|
||||
MOCK_FILE_RESPONSE.filename,
|
||||
MOCK_FILE_RESPONSE.content,
|
||||
MOCK_FILE_RESPONSE.mimeType,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows text-destructive error when convertCertAction rejects with format error', async () => {
|
||||
vi.spyOn(actions, 'convertCertAction').mockRejectedValue(
|
||||
new Error('unknown format or invalid certificate'),
|
||||
);
|
||||
|
||||
const testFile = new File(['garbage'], 'bad.bin', { type: 'application/octet-stream' });
|
||||
|
||||
render(<ConvertTab file={testFile} pemText="" password="" />);
|
||||
|
||||
fireEvent.click(screen.getByText('Konvertieren'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const errorEl = screen.getByText(
|
||||
'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
|
||||
);
|
||||
expect(errorEl.className).toContain('text-destructive');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { convertCertAction, downloadBase64 } from '../actions';
|
||||
|
||||
interface ConvertTabProps {
|
||||
file: File | null;
|
||||
@@ -8,15 +10,78 @@ interface ConvertTabProps {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function ConvertTab({ file: _file, pemText: _pemText, password: _password }: ConvertTabProps) {
|
||||
type TargetFormat = 'pem' | 'der' | 'p7b';
|
||||
|
||||
export function ConvertTab({ file, pemText, password }: ConvertTabProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [targetFormat, setTargetFormat] = useState<TargetFormat>('pem');
|
||||
|
||||
async function handleConvert() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await convertCertAction({ file, pemText, password }, targetFormat);
|
||||
downloadBase64(response.filename, response.content, response.mimeType);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : '';
|
||||
if (msg.includes('password') || msg.includes('passwort')) {
|
||||
setError(t('error.wrongPassword'));
|
||||
} else if (
|
||||
msg.includes('format') ||
|
||||
msg.includes('invalid') ||
|
||||
msg.includes('unknown')
|
||||
) {
|
||||
setError(t('error.unknownFormat'));
|
||||
} else {
|
||||
setError(t('error.generic'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canConvert = Boolean(file || pemText);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Target format selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor="targetFormat" className="text-sm font-medium text-foreground">
|
||||
Ausgabeformat
|
||||
</label>
|
||||
<select
|
||||
id="targetFormat"
|
||||
value={targetFormat}
|
||||
onChange={(e) => setTargetFormat(e.target.value as TargetFormat)}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="pem">PEM</option>
|
||||
<option value="der">DER</option>
|
||||
<option value="p7b">P7B</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Primary action button */}
|
||||
<button
|
||||
onClick={handleConvert}
|
||||
disabled={loading || !canConvert}
|
||||
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{loading ? t('actions.processing') : t('actions.convert')}
|
||||
</button>
|
||||
|
||||
{/* Error state */}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{/* Empty state — shown when no error */}
|
||||
{!error && (
|
||||
<div className="text-center py-8 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{t('emptyState.convert')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('emptyState.convertBody')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user