import { cleanup, render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; // Mock next-intl — provide certManager namespace keys vi.mock('next-intl', () => ({ useTranslations: (ns: string) => (key: string) => { const certManager: Record = { 'title': 'Zertifikat-Manager', 'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.', 'tabs.inspect': 'Analysieren', 'tabs.split': 'Aufteilen', 'tabs.merge': 'Zusammenfuehren', 'tabs.convert': 'Konvertieren', 'dropZone.placeholder': 'Datei hierher ziehen oder klicken', 'dropZone.formats': '.pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c', 'paste.placeholder': 'PEM-Inhalt einfuegen (-----BEGIN ...)', 'password.label': 'Passwort (PFX/P12)', 'or': 'oder', 'actions.inspect': 'Analysieren', 'actions.split': 'Aufteilen', 'actions.merge': 'Zusammenfuehren', 'actions.convert': 'Konvertieren', 'actions.download': 'Herunterladen', 'actions.processing': 'Wird verarbeitet...', 'emptyState.inspect': 'Kein Zertifikat geladen.', 'emptyState.inspectBody': 'Lade eine Datei hoch oder fuege PEM-Text ein.', 'emptyState.split': 'Keine Datei geladen.', 'emptyState.splitBody': 'Lade eine Fullchain- oder P7B-Datei hoch.', 'emptyState.merge': 'Keine Zertifikate ausgewaehlt.', 'emptyState.mergeBody': 'Lade mindestens zwei Dateien hoch.', 'emptyState.convert': 'Keine Datei geladen.', 'emptyState.convertBody': 'Lade eine Datei hoch und waehle ein Ausgabeformat.', 'error.generic': 'Verarbeitung fehlgeschlagen. Pruefe das Dateiformat oder das Passwort.', 'error.wrongPassword': 'Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.', 'error.unknownFormat': 'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.', }; if (ns === 'certManager') { return certManager[key] ?? key; } return key; }, })); 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'; afterEach(() => { cleanup(); vi.restoreAllMocks(); }); describe('CertManagerPage shell', () => { it('renders the page title and description', () => { render(); expect(screen.getByText('Zertifikat-Manager')).toBeInTheDocument(); expect(screen.getByText('Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.')).toBeInTheDocument(); }); it('renders all four tab labels', () => { render(); // "Analysieren" appears as both tab label (nav) and InspectTab action button // getAllByText avoids the "multiple elements" error expect(screen.getAllByText('Analysieren').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('Aufteilen')).toBeInTheDocument(); expect(screen.getByText('Zusammenfuehren')).toBeInTheDocument(); expect(screen.getByText('Konvertieren')).toBeInTheDocument(); }); it('does NOT render the password field on initial render (no PFX file selected)', () => { render(); // Password field label should not be in document when no PFX is selected expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument(); }); it('shows Inspect tab empty state on initial render', () => { render(); expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument(); expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument(); }); it('shows Split tab empty state when Split tab is active', () => { render(); // Click the "Aufteilen" tab button (second one in nav) const tabButtons = screen.getAllByText('Aufteilen'); // The tab nav button is the one that triggers tab change fireEvent.click(tabButtons[0]); expect(screen.getByText('Keine Datei geladen.')).toBeInTheDocument(); expect(screen.getByText('Lade eine Fullchain- oder P7B-Datei hoch.')).toBeInTheDocument(); }); it('shows Merge tab empty state when Merge tab is active', () => { render(); fireEvent.click(screen.getByText('Zusammenfuehren')); expect(screen.getByText('Keine Zertifikate ausgewaehlt.')).toBeInTheDocument(); expect(screen.getByText('Lade mindestens zwei Dateien hoch.')).toBeInTheDocument(); }); it('shows Convert tab empty state when Convert tab is active', () => { render(); // "Konvertieren" appears once as tab label fireEvent.click(screen.getByText('Konvertieren')); expect(screen.getAllByText('Keine Datei geladen.').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('Lade eine Datei hoch und waehle ein Ausgabeformat.')).toBeInTheDocument(); }); }); // --------------------------------------------------------------------------- // InspectTab — unit tests for the Analysieren result grid and error state // --------------------------------------------------------------------------- const MOCK_CERT_DETAILS: actions.CertDetails = { subject: { cn: 'example.com', o: 'Acme Corp', ou: 'IT', c: 'DE' }, issuer: { cn: 'Example CA', o: 'CA Corp', c: 'US' }, validity: { notBefore: '2025-01-01T00:00:00.000Z', notAfter: '2026-01-01T00:00:00.000Z', isExpired: false, daysLeft: 180, }, san: ['example.com', 'www.example.com'], keyType: 'RSA', keyBits: 2048, serialNumber: '01', signatureAlgorithm: 'sha256WithRSAEncryption', fingerprint: { sha1: 'AA:BB:CC:DD:EE', sha256: 'FF:00:11:22:33:44:55:66:77:88', }, pemPreview: '-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----', }; describe('InspectTab', () => { it('renders CertDetails grid with subject CN and SHA-256 after successful inspect', async () => { vi.spyOn(actions, 'inspectCertAction').mockResolvedValue(MOCK_CERT_DETAILS); render( , ); // Click the Analysieren button fireEvent.click(screen.getByText('Analysieren')); // Wait for the async action to complete and result grid to render await waitFor(() => { expect(screen.getByText('example.com')).toBeInTheDocument(); }); // SHA-256 fingerprint is rendered expect(screen.getByText('FF:00:11:22:33:44:55:66:77:88')).toBeInTheDocument(); // Empty state is no longer shown expect(screen.queryByText('Kein Zertifikat geladen.')).not.toBeInTheDocument(); }); it('shows text-destructive error when inspectCertAction rejects', async () => { vi.spyOn(actions, 'inspectCertAction').mockRejectedValue( new Error('wrong password'), ); render( , ); fireEvent.click(screen.getByText('Analysieren')); // Wait for the error message to appear await waitFor(() => { // The wrong-password error message should be rendered expect( screen.getByText('Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.'), ).toBeInTheDocument(); }); // Error element should have the destructive class const errorEl = screen.getByText( 'Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.', ); expect(errorEl.className).toContain('text-destructive'); }); }); // --------------------------------------------------------------------------- // SplitTab — unit tests // --------------------------------------------------------------------------- const MOCK_SPLIT_RESPONSE: actions.SplitResponse = { count: 2, certs: [ { index: 0, filename: 'cert-1.pem', content: btoa('-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----'), subject: { cn: 'cert1.example.com' }, validity: { notAfter: '2027-01-01T00:00:00.000Z' }, }, { index: 1, filename: 'cert-2.pem', content: btoa('-----BEGIN CERTIFICATE-----\nYYY\n-----END CERTIFICATE-----'), subject: { cn: 'cert2.example.com' }, validity: { notAfter: '2027-06-01T00:00:00.000Z' }, }, ], }; describe('SplitTab', () => { it('renders two download buttons after clicking Aufteilen with mocked splitCertsAction', async () => { vi.spyOn(actions, 'splitCertsAction').mockResolvedValue(MOCK_SPLIT_RESPONSE); const testFile = new File(['cert data'], 'fullchain.pem', { type: 'application/x-pem-file', }); render(); // Initial empty state expect(screen.getByText('Keine Datei geladen.')).toBeInTheDocument(); // Click the Aufteilen button (only one in this isolated render) fireEvent.click(screen.getByText('Aufteilen')); // Wait for the async action to resolve and per-cert list to appear await waitFor(() => { expect(screen.getAllByText('Herunterladen').length).toBe(2); }); // Empty state is gone expect(screen.queryByText('Keine Datei geladen.')).not.toBeInTheDocument(); // Both cert CN values are rendered expect(screen.getByText('cert1.example.com')).toBeInTheDocument(); expect(screen.getByText('cert2.example.com')).toBeInTheDocument(); }); it('shows text-destructive error when splitCertsAction rejects', async () => { vi.spyOn(actions, 'splitCertsAction').mockRejectedValue( new Error('invalid format or corrupted file'), ); const testFile = new File(['garbage'], 'bad.pem', { type: 'text/plain' }); render(); fireEvent.click(screen.getByText('Aufteilen')); 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'); }); }); // --------------------------------------------------------------------------- // 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'); }); 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, ); }); }); });