diff --git a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts index 17c03a5..36cdd2f 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts +++ b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts @@ -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 { + 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; +} + // --------------------------------------------------------------------------- // convertCertAction — calls POST /modules/cert-manager/convert // --------------------------------------------------------------------------- diff --git a/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx index 18b024d..cbb09dd 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx @@ -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(); + 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(); + + // 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(); + + 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(); + + // 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(); + + 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, + ); + }); + }); }); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx index d398a29..15e0954 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx @@ -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(null); const [targetFormat, setTargetFormat] = useState('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) { diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx index 024cd2f..e313089 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx @@ -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([]); + const [outputFormat, setOutputFormat] = useState('pem'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + function handleFormatChange(format: MergeOutputFormat) { + setOutputFormat(format); + onOutputFormatChange?.(format); + } + + function handleFileChange(e: React.ChangeEvent) { + 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 (
-
-

{t('emptyState.merge')}

-

{t('emptyState.mergeBody')}

+ {/* Selected file list */} + {files.length > 0 && ( +
    + {files.map((f, i) => ( +
  • + {f.name} + +
  • + ))} +
  • + {files.length} {files.length === 1 ? 'Datei' : 'Dateien'} ausgewählt +
  • +
+ )} + + {/* Multi-file input */} +
+
+ + {/* Output format selector */} +
+ + +
+ + {/* Primary action button — disabled until >= 2 files selected */} + + + {/* Error state */} + {error &&

{error}

} + + {/* Empty state — shown when no files and no error */} + {files.length === 0 && !error && ( +
+

{t('emptyState.merge')}

+

{t('emptyState.mergeBody')}

+
+ )}
); } diff --git a/apps/web/src/app/(portal)/modules/cert-manager/page.tsx b/apps/web/src/app/(portal)/modules/cert-manager/page.tsx index a1219a7..443eb42 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/page.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/page.tsx @@ -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 ; + return ; case 'split': - return ; + return ; case 'merge': - return ; + return ( + + ); case 'convert': - return ; + return ( + + ); } };