Files
tessera-ctl/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx
T
schalli 8b15a0099f 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)
2026-07-02 07:52:52 +02:00

431 lines
16 KiB
TypeScript

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<string, string> = {
'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(<CertManagerPage />);
expect(screen.getByText('Zertifikat-Manager')).toBeInTheDocument();
expect(screen.getByText('Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.')).toBeInTheDocument();
});
it('renders all four tab labels', () => {
render(<CertManagerPage />);
// "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(<CertManagerPage />);
// 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(<CertManagerPage />);
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(<CertManagerPage />);
// 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(<CertManagerPage />);
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(<CertManagerPage />);
// "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(
<InspectTab
file={null}
pemText="-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"
password=""
/>,
);
// 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(
<InspectTab
file={null}
pemText="test pem"
password="wrong"
/>,
);
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(<SplitTab file={testFile} pemText="" password="" />);
// 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(<SplitTab file={testFile} pemText="" password="" />);
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(<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');
});
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(<ConvertTab file={testFile} pemText="" password="" />);
// 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(<ConvertTab file={testFile} pemText="" password="" />);
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(<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,
);
});
});
});