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;
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user