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);
|
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.
|
* POST a FormData payload to a cert-manager endpoint.
|
||||||
* T-09-04: credentials:'include' ensures JWT cookie is sent for ModuleGuard.
|
* 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 CertManagerPage from './page';
|
||||||
import { InspectTab } from './components/InspectTab';
|
import { InspectTab } from './components/InspectTab';
|
||||||
import { SplitTab } from './components/SplitTab';
|
import { SplitTab } from './components/SplitTab';
|
||||||
|
import { ConvertTab } from './components/ConvertTab';
|
||||||
import * as actions from './actions';
|
import * as actions from './actions';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -265,3 +266,74 @@ describe('SplitTab', () => {
|
|||||||
expect(errorEl.className).toContain('text-destructive');
|
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';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { convertCertAction, downloadBase64 } from '../actions';
|
||||||
|
|
||||||
interface ConvertTabProps {
|
interface ConvertTabProps {
|
||||||
file: File | null;
|
file: File | null;
|
||||||
@@ -8,15 +10,78 @@ interface ConvertTabProps {
|
|||||||
password: string;
|
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 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-center py-8 space-y-1">
|
{/* Target format selector */}
|
||||||
<p className="text-sm font-medium text-foreground">{t('emptyState.convert')}</p>
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-sm text-muted-foreground">{t('emptyState.convertBody')}</p>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user