feat(09-06): MergeTab multi-file UI + PFX convert option + render tests

- actions.ts: mergeCertsAction(files, outputFormat, password?) builds FormData with
  multiple file fields; delegates to postForm('merge', ...) (T-09-02/T-09-04)
- MergeTab.tsx: multi-file state (local), file input (multiple), output selector
  (pem|pfx), Zusammenfuehren button disabled when < 2 files (data-testid for tests),
  onOutputFormatChange callback to page.tsx for shared PasswordField visibility
- ConvertTab.tsx: gains pfx option + onTargetFormatChange callback (same pattern)
- page.tsx: lifts mergeOutputFormat + convertOutputFormat state; showPassword now
  also true when active tab's output format is 'pfx'; passes callbacks to tabs
- cert-manager.test.tsx: 5 new tests — MergeTab disabled/enabled by file count,
  shared PasswordField appears on pfx output, downloadBase64 called on success;
  ConvertTab pfx option present; all 19/19 web tests green
- All production cert-manager files type-clean (pre-existing test type issues unchanged)
This commit is contained in:
2026-07-02 07:52:52 +02:00
parent 6326064ad3
commit 8b15a0099f
5 changed files with 299 additions and 15 deletions
@@ -129,6 +129,31 @@ export interface FileResponse {
mimeType: string; mimeType: string;
} }
// ---------------------------------------------------------------------------
// mergeCertsAction — calls POST /modules/cert-manager/merge
// ---------------------------------------------------------------------------
/**
* Upload multiple certificate files to POST /modules/cert-manager/merge.
* The API requires at least 2 files (enforced by controller).
* 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-03: maxCount 20 per FilesInterceptor (enforced server-side).
* T-09-04: credentials:'include' via postForm ensures JWT cookie is sent.
*/
export async function mergeCertsAction(
files: File[],
outputFormat: string,
password?: string,
): Promise<FileResponse> {
const form = new FormData();
files.forEach((file) => form.append('files', file));
form.append('outputFormat', outputFormat);
if (password) form.append('password', password);
return postForm('merge', form) as Promise<FileResponse>;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// convertCertAction — calls POST /modules/cert-manager/convert // convertCertAction — calls POST /modules/cert-manager/convert
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -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 { MergeTab } from './components/MergeTab';
import { ConvertTab } from './components/ConvertTab'; import { ConvertTab } from './components/ConvertTab';
import * as actions from './actions'; import * as actions from './actions';
@@ -336,4 +337,94 @@ describe('ConvertTab', () => {
); );
expect(errorEl.className).toContain('text-destructive'); expect(errorEl.className).toContain('text-destructive');
}); });
it('renders pem, der, p7b, and pfx options in the format selector', () => {
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');
expect(optionValues).toContain('pfx');
});
});
// ---------------------------------------------------------------------------
// MergeTab — unit tests (CERT-03, CERT-05)
// ---------------------------------------------------------------------------
const MOCK_MERGE_PEM_RESPONSE: actions.FileResponse = {
filename: 'chain.pem',
content: btoa('-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nYYY\n-----END CERTIFICATE-----'),
mimeType: 'application/x-pem-file',
};
describe('MergeTab', () => {
it('disables Zusammenfuehren action button when fewer than 2 files are selected', () => {
render(<MergeTab password="" />);
// The action button should be disabled (no files yet)
const actionBtn = screen.getByTestId('merge-action-btn');
expect(actionBtn).toBeDisabled();
});
it('enables Zusammenfuehren button when 2 or more files are selected', async () => {
render(<MergeTab password="" />);
const fileInput = screen.getByTestId('merge-file-input');
const file1 = new File(['cert1'], 'cert1.pem', { type: 'application/x-pem-file' });
const file2 = new File(['cert2'], 'cert2.pem', { type: 'application/x-pem-file' });
fireEvent.change(fileInput, { target: { files: [file1, file2] } });
await waitFor(() => {
expect(screen.getByTestId('merge-action-btn')).not.toBeDisabled();
});
});
it('shows password field in page.tsx when PFX output is selected in MergeTab', async () => {
// Render the full page so the shared PasswordField behaviour can be tested
render(<CertManagerPage />);
// Navigate to merge tab — only one "Zusammenfuehren" visible before tab switch
fireEvent.click(screen.getByText('Zusammenfuehren'));
// Change format selector to pfx
const formatSelect = screen.getByDisplayValue('PEM-Kette');
fireEvent.change(formatSelect, { target: { value: 'pfx' } });
// The shared PasswordField should now be visible
await waitFor(() => {
expect(screen.getByText('Passwort (PFX/P12)')).toBeInTheDocument();
});
});
it('calls downloadBase64 after successful merge when >= 2 files selected', async () => {
vi.spyOn(actions, 'mergeCertsAction').mockResolvedValue(MOCK_MERGE_PEM_RESPONSE);
const downloadSpy = vi.spyOn(actions, 'downloadBase64').mockImplementation(() => {});
render(<MergeTab password="" />);
const fileInput = screen.getByTestId('merge-file-input');
const file1 = new File(['cert1'], 'cert1.pem', { type: 'application/x-pem-file' });
const file2 = new File(['cert2'], 'cert2.pem', { type: 'application/x-pem-file' });
fireEvent.change(fileInput, { target: { files: [file1, file2] } });
await waitFor(() => {
expect(screen.getByTestId('merge-action-btn')).not.toBeDisabled();
});
fireEvent.click(screen.getByTestId('merge-action-btn'));
await waitFor(() => {
expect(downloadSpy).toHaveBeenCalledWith(
MOCK_MERGE_PEM_RESPONSE.filename,
MOCK_MERGE_PEM_RESPONSE.content,
MOCK_MERGE_PEM_RESPONSE.mimeType,
);
});
});
}); });
@@ -8,16 +8,23 @@ interface ConvertTabProps {
file: File | null; file: File | null;
pemText: string; pemText: string;
password: string; password: string;
/** Callback to notify page.tsx when target format changes (for shared PasswordField visibility) */
onTargetFormatChange?: (format: string) => void;
} }
type TargetFormat = 'pem' | 'der' | 'p7b'; type TargetFormat = 'pem' | 'der' | 'p7b' | 'pfx';
export function ConvertTab({ file, pemText, password }: ConvertTabProps) { export function ConvertTab({ file, pemText, password, onTargetFormatChange }: ConvertTabProps) {
const t = useTranslations('certManager'); const t = useTranslations('certManager');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [targetFormat, setTargetFormat] = useState<TargetFormat>('pem'); const [targetFormat, setTargetFormat] = useState<TargetFormat>('pem');
function handleFormatChange(format: TargetFormat) {
setTargetFormat(format);
onTargetFormatChange?.(format);
}
async function handleConvert() { async function handleConvert() {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -54,12 +61,13 @@ export function ConvertTab({ file, pemText, password }: ConvertTabProps) {
<select <select
id="targetFormat" id="targetFormat"
value={targetFormat} value={targetFormat}
onChange={(e) => setTargetFormat(e.target.value as TargetFormat)} onChange={(e) => handleFormatChange(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" 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="pem">PEM</option>
<option value="der">DER</option> <option value="der">DER</option>
<option value="p7b">P7B</option> <option value="p7b">P7B</option>
<option value="pfx">PFX / PKCS12</option>
</select> </select>
</div> </div>
@@ -1,22 +1,159 @@
'use client'; 'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { mergeCertsAction, downloadBase64 } from '../actions';
type MergeOutputFormat = 'pem' | 'pfx';
interface MergeTabProps { interface MergeTabProps {
file: File | null; /** Shared password from page.tsx (shown when PFX output is chosen) */
pemText: string;
password: string; password: string;
/** Callback to notify page.tsx when output format changes (for shared PasswordField visibility) */
onOutputFormatChange?: (format: string) => void;
} }
export function MergeTab({ file: _file, pemText: _pemText, password: _password }: MergeTabProps) { /**
* MergeTab — multi-file cert merge into PEM chain or password-protected PFX/PKCS12.
*
* Files are managed locally (separate from the shared single-file DropZone in page.tsx).
* Password is shared from page.tsx via prop — the PasswordField becomes visible when
* PFX output is selected (controlled by onOutputFormatChange → page.tsx showPassword).
*
* Security contract (T-09-02): password is never logged, never placed in URLs.
* Security contract (T-09-03): server enforces 20-file max + 5 MB per file.
*/
export function MergeTab({ password, onOutputFormatChange }: MergeTabProps) {
const t = useTranslations('certManager'); const t = useTranslations('certManager');
const [files, setFiles] = useState<File[]>([]);
const [outputFormat, setOutputFormat] = useState<MergeOutputFormat>('pem');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleFormatChange(format: MergeOutputFormat) {
setOutputFormat(format);
onOutputFormatChange?.(format);
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const selected = Array.from(e.target.files ?? []);
if (selected.length > 0) {
setFiles((prev) => [...prev, ...selected]);
}
// Reset input so the same file can be re-added if needed
e.target.value = '';
}
function removeFile(index: number) {
setFiles((prev) => prev.filter((_, i) => i !== index));
}
async function handleMerge() {
setLoading(true);
setError(null);
try {
const response = await mergeCertsAction(
files,
outputFormat,
outputFormat === 'pfx' ? password : undefined,
);
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 canMerge = files.length >= 2;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="text-center py-8 space-y-1"> {/* Selected file list */}
<p className="text-sm font-medium text-foreground">{t('emptyState.merge')}</p> {files.length > 0 && (
<p className="text-sm text-muted-foreground">{t('emptyState.mergeBody')}</p> <ul className="space-y-1 rounded border border-border bg-background p-3">
{files.map((f, i) => (
<li key={`${f.name}-${i}`} className="flex items-center justify-between text-sm">
<span className="text-foreground">{f.name}</span>
<button
type="button"
onClick={() => removeFile(i)}
className="text-xs text-muted-foreground hover:text-destructive ml-2"
aria-label={`Remove ${f.name}`}
>
✕
</button>
</li>
))}
<li className="pt-1 text-xs text-muted-foreground">
{files.length} {files.length === 1 ? 'Datei' : 'Dateien'} ausgewählt
</li>
</ul>
)}
{/* Multi-file input */}
<div>
<input
type="file"
multiple
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
onChange={handleFileChange}
data-testid="merge-file-input"
className="block text-sm text-foreground cursor-pointer"
/>
</div> </div>
{/* Output format selector */}
<div className="flex items-center gap-3">
<label
htmlFor="mergeOutputFormat"
className="text-sm font-medium text-foreground"
>
Ausgabeformat
</label>
<select
id="mergeOutputFormat"
value={outputFormat}
onChange={(e) => handleFormatChange(e.target.value as MergeOutputFormat)}
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-Kette</option>
<option value="pfx">PFX / PKCS12</option>
</select>
</div>
{/* Primary action button — disabled until >= 2 files selected */}
<button
type="button"
data-testid="merge-action-btn"
onClick={handleMerge}
disabled={loading || !canMerge}
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.merge')}
</button>
{/* Error state */}
{error && <p className="text-sm text-destructive">{error}</p>}
{/* Empty state — shown when no files and no error */}
{files.length === 0 && !error && (
<div className="text-center py-8 space-y-1">
<p className="text-sm font-medium text-foreground">{t('emptyState.merge')}</p>
<p className="text-sm text-muted-foreground">{t('emptyState.mergeBody')}</p>
</div>
)}
</div> </div>
); );
} }
@@ -25,6 +25,10 @@ function isPfxFile(file: File | null): boolean {
* Layout per UI-SPEC: max-w-4xl, shared input card, tab nav, tab content card. * Layout per UI-SPEC: max-w-4xl, shared input card, tab nav, tab content card.
* T-09-02: password lives in local React state only; never logged or placed in URLs. * T-09-02: password lives in local React state only; never logged or placed in URLs.
* T-09-04: all API calls via postForm() which sends credentials:'include'. * T-09-04: all API calls via postForm() which sends credentials:'include'.
*
* The shared PasswordField is shown when:
* 1. A PFX/P12 file is selected in the shared DropZone, OR
* 2. The active tab (Merge or Convert) has PFX chosen as the output format.
*/ */
export default function CertManagerPage() { export default function CertManagerPage() {
const t = useTranslations('certManager'); const t = useTranslations('certManager');
@@ -34,7 +38,15 @@ export default function CertManagerPage() {
const [pemText, setPemText] = useState(''); const [pemText, setPemText] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const showPassword = isPfxFile(file); // Track the output format of the active Merge and Convert tabs
// so the shared PasswordField can be shown when PFX output is chosen.
const [mergeOutputFormat, setMergeOutputFormat] = useState('pem');
const [convertOutputFormat, setConvertOutputFormat] = useState('pem');
const showPassword =
isPfxFile(file) ||
(activeTab === 'merge' && mergeOutputFormat === 'pfx') ||
(activeTab === 'convert' && convertOutputFormat === 'pfx');
const handleFile = (selected: File) => { const handleFile = (selected: File) => {
setFile(selected); setFile(selected);
@@ -61,16 +73,27 @@ export default function CertManagerPage() {
}; };
const renderActiveTab = () => { const renderActiveTab = () => {
const props = { file, pemText, password };
switch (activeTab) { switch (activeTab) {
case 'inspect': case 'inspect':
return <InspectTab {...props} />; return <InspectTab file={file} pemText={pemText} password={password} />;
case 'split': case 'split':
return <SplitTab {...props} />; return <SplitTab file={file} pemText={pemText} password={password} />;
case 'merge': case 'merge':
return <MergeTab {...props} />; return (
<MergeTab
password={password}
onOutputFormatChange={setMergeOutputFormat}
/>
);
case 'convert': case 'convert':
return <ConvertTab {...props} />; return (
<ConvertTab
file={file}
pemText={pemText}
password={password}
onTargetFormatChange={setConvertOutputFormat}
/>
);
} }
}; };