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:
@@ -129,6 +129,31 @@ export interface FileResponse {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock('next-intl', () => ({
|
||||
import CertManagerPage from './page';
|
||||
import { InspectTab } from './components/InspectTab';
|
||||
import { SplitTab } from './components/SplitTab';
|
||||
import { MergeTab } from './components/MergeTab';
|
||||
import { ConvertTab } from './components/ConvertTab';
|
||||
import * as actions from './actions';
|
||||
|
||||
@@ -336,4 +337,94 @@ describe('ConvertTab', () => {
|
||||
);
|
||||
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;
|
||||
pemText: 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 [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [targetFormat, setTargetFormat] = useState<TargetFormat>('pem');
|
||||
|
||||
function handleFormatChange(format: TargetFormat) {
|
||||
setTargetFormat(format);
|
||||
onTargetFormatChange?.(format);
|
||||
}
|
||||
|
||||
async function handleConvert() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -54,12 +61,13 @@ export function ConvertTab({ file, pemText, password }: ConvertTabProps) {
|
||||
<select
|
||||
id="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"
|
||||
>
|
||||
<option value="pem">PEM</option>
|
||||
<option value="der">DER</option>
|
||||
<option value="p7b">P7B</option>
|
||||
<option value="pfx">PFX / PKCS12</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,22 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { mergeCertsAction, downloadBase64 } from '../actions';
|
||||
|
||||
type MergeOutputFormat = 'pem' | 'pfx';
|
||||
|
||||
interface MergeTabProps {
|
||||
file: File | null;
|
||||
pemText: string;
|
||||
/** Shared password from page.tsx (shown when PFX output is chosen) */
|
||||
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 [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 (
|
||||
<div className="space-y-4">
|
||||
{/* Selected file list */}
|
||||
{files.length > 0 && (
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
* 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'.
|
||||
*
|
||||
* 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() {
|
||||
const t = useTranslations('certManager');
|
||||
@@ -34,7 +38,15 @@ export default function CertManagerPage() {
|
||||
const [pemText, setPemText] = 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) => {
|
||||
setFile(selected);
|
||||
@@ -61,16 +73,27 @@ export default function CertManagerPage() {
|
||||
};
|
||||
|
||||
const renderActiveTab = () => {
|
||||
const props = { file, pemText, password };
|
||||
switch (activeTab) {
|
||||
case 'inspect':
|
||||
return <InspectTab {...props} />;
|
||||
return <InspectTab file={file} pemText={pemText} password={password} />;
|
||||
case 'split':
|
||||
return <SplitTab {...props} />;
|
||||
return <SplitTab file={file} pemText={pemText} password={password} />;
|
||||
case 'merge':
|
||||
return <MergeTab {...props} />;
|
||||
return (
|
||||
<MergeTab
|
||||
password={password}
|
||||
onOutputFormatChange={setMergeOutputFormat}
|
||||
/>
|
||||
);
|
||||
case 'convert':
|
||||
return <ConvertTab {...props} />;
|
||||
return (
|
||||
<ConvertTab
|
||||
file={file}
|
||||
pemText={pemText}
|
||||
password={password}
|
||||
onTargetFormatChange={setConvertOutputFormat}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user