feat(09-05): implement ConvertTab + convertCertAction + render tests
Tessera CI/CD / Tests (push) Has been cancelled
Tessera CI/CD / Build & Publish Images (push) Has been cancelled
Tessera CI/CD / Lint & Type Check (push) Has been cancelled

- 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:
2026-07-02 07:39:41 +02:00
parent 59694642dd
commit f89d6566b2
3 changed files with 178 additions and 4 deletions
@@ -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>
); );
} }