From f89d6566b212f8426a9dfd59b598188e117b0c48 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 2 Jul 2026 07:39:41 +0200 Subject: [PATCH] feat(09-05): implement ConvertTab + convertCertAction + render tests - 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 --- .../(portal)/modules/cert-manager/actions.ts | 37 ++++++++++ .../cert-manager/cert-manager.test.tsx | 72 ++++++++++++++++++ .../cert-manager/components/ConvertTab.tsx | 73 ++++++++++++++++++- 3 files changed, 178 insertions(+), 4 deletions(-) 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 ba49d16..17c03a5 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts +++ b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts @@ -116,6 +116,43 @@ export function downloadBase64( 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 { + 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; +} + /** * POST a FormData payload to a cert-manager endpoint. * T-09-04: credentials:'include' ensures JWT cookie is sent for ModuleGuard. 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 29c9956..18b024d 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 { ConvertTab } from './components/ConvertTab'; import * as actions from './actions'; afterEach(() => { @@ -265,3 +266,74 @@ describe('SplitTab', () => { 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(); + 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(); + + // 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(); + + 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'); + }); +}); 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 132d17f..d398a29 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 @@ -1,6 +1,8 @@ 'use client'; +import { useState } from 'react'; import { useTranslations } from 'next-intl'; +import { convertCertAction, downloadBase64 } from '../actions'; interface ConvertTabProps { file: File | null; @@ -8,15 +10,78 @@ interface ConvertTabProps { 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 [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [targetFormat, setTargetFormat] = useState('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 (
-
-

{t('emptyState.convert')}

-

{t('emptyState.convertBody')}

+ {/* Target format selector */} +
+ +
+ + {/* Primary action button */} + + + {/* Error state */} + {error &&

{error}

} + + {/* Empty state — shown when no error */} + {!error && ( +
+

{t('emptyState.convert')}

+

{t('emptyState.convertBody')}

+
+ )}
); }