8b15a0099f
- 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)
431 lines
16 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|
|
});
|