feat(cert-manager): Hersteller-ZIP, PKCS#7, eingefügter Text, Analysieren und Aufteilen
- ZIP wird an den Anfangsbytes erkannt und mit Grenzen geöffnet (eine Ebene, Verhältnis, Gesamtgröße, verschlüsselte Einträge) - PKCS#7 als PEM und DER, auch für EC, über den ASN.1-Lauf - Eingefügter PEM-Text als eigener Eintrag im Reiter Dateien - Neue Reiter Analysieren und Aufteilen auf dem gemeinsamen Arbeitsbereich Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,14 +43,19 @@ beforeEach(() => {
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('CertManagerPage', () => {
|
||||
it('zeigt Titel und die Reiter „Dateien“ und „Zusammenführen“, „Dateien“ ist aktiv', () => {
|
||||
it('zeigt Titel und die Reiter in der Reihenfolge Dateien, Analysieren, Aufteilen, Zusammenführen', () => {
|
||||
render(<CertManagerPage />);
|
||||
expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument();
|
||||
const nav = screen.getByRole('navigation');
|
||||
const tabs = within(nav).getAllByRole('button');
|
||||
expect(tabs.map((b) => b.textContent)).toEqual(['Dateien', 'Zusammenführen']);
|
||||
expect(tabs.map((b) => b.textContent)).toEqual([
|
||||
'Dateien',
|
||||
'Analysieren',
|
||||
'Aufteilen',
|
||||
'Zusammenführen',
|
||||
]);
|
||||
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
|
||||
expect(tabs[1]).not.toHaveAttribute('aria-current');
|
||||
for (const tab of tabs.slice(1)) expect(tab).not.toHaveAttribute('aria-current');
|
||||
// Startansicht ist der Dateien-Reiter
|
||||
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -84,6 +89,16 @@ describe('CertManagerPage', () => {
|
||||
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'Analysieren',
|
||||
'Aufteilen',
|
||||
])('%s mit leerem Arbeitsbereich: Hinweis auf den Reiter „Dateien“', (tab) => {
|
||||
render(<CertManagerPage />);
|
||||
fireEvent.click(screen.getByRole('button', { name: tab }));
|
||||
expect(screen.getByText(/Noch keine Dateien\. Laden Sie Ihre Zertifikate/)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('cert-file-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mit Dateien zeigt Zusammenführen die Grundlage, und die Liste bleibt beim Reiterwechsel', async () => {
|
||||
render(<CertManagerPage />);
|
||||
fireEvent.change(screen.getByTestId('cert-file-input'), {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { cleanup, render as rtlRender, screen, within } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import de from '@/messages/de.json';
|
||||
import type { AnalysisResult, CertItem, ChainInfo } from '../actions';
|
||||
import type { CertWorkspace } from '../use-cert-workspace';
|
||||
import type { WorkingEntry } from '../working-set';
|
||||
import { AnalyzeTab } from './AnalyzeTab';
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function cert(over: Partial<CertItem> & Pick<CertItem, 'id' | 'cn' | 'role'>): CertItem {
|
||||
return {
|
||||
kind: 'certificate',
|
||||
sources: [{ file: 0, path: 'a.pem' }],
|
||||
pem: `PEM-${over.id}`,
|
||||
baseName: over.cn,
|
||||
organization: '',
|
||||
issuerCn: 'Test Inter',
|
||||
issuerOrganization: '',
|
||||
notBefore: '2026-01-01T00:00:00.000Z',
|
||||
notAfter: '2126-03-15T23:59:59.000Z',
|
||||
isExpired: false,
|
||||
daysLeft: 36000,
|
||||
san: ['www.example.test', 'example.test'],
|
||||
keyType: 'RSA',
|
||||
keyBits: 2048,
|
||||
curve: null,
|
||||
serialNumber: 'AB12',
|
||||
sha256: 'AA:BB:CC',
|
||||
sha1: '11:22:33',
|
||||
isCa: over.role !== 'end-entity',
|
||||
selfSigned: over.role === 'root',
|
||||
aiaIssuerUrls: [],
|
||||
keyId: null,
|
||||
csrIds: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function entry(id: string, name: string, label = name): WorkingEntry {
|
||||
return {
|
||||
id,
|
||||
file: new File(['x'], name),
|
||||
label,
|
||||
origin: 'upload',
|
||||
host: null,
|
||||
password: '',
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(
|
||||
analysis: AnalysisResult | null,
|
||||
entries: WorkingEntry[] = [],
|
||||
analysisIds: string[] = [],
|
||||
): CertWorkspace {
|
||||
return {
|
||||
entries,
|
||||
analysis,
|
||||
analysisIds,
|
||||
status: 'idle',
|
||||
errorKey: null,
|
||||
addFiles: () => [],
|
||||
addText: () => null,
|
||||
remove: () => {},
|
||||
clear: () => {},
|
||||
retry: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const leaf = cert({
|
||||
id: 'c-leaf',
|
||||
cn: 'www.example.test',
|
||||
role: 'end-entity',
|
||||
sources: [
|
||||
{ file: 0, path: 'a.pem' },
|
||||
{ file: 1, path: 'vendor.zip/kopie/a.pem' },
|
||||
],
|
||||
});
|
||||
const inter = cert({
|
||||
id: 'c-inter',
|
||||
cn: 'Test Inter',
|
||||
role: 'intermediate',
|
||||
issuerCn: 'Test Root',
|
||||
sources: [{ file: 1, path: 'vendor.zip/Intermediate/CA.crt' }],
|
||||
});
|
||||
const ec = cert({
|
||||
id: 'c-ec',
|
||||
cn: 'ec.example.test',
|
||||
role: 'end-entity',
|
||||
keyType: 'EC',
|
||||
keyBits: 256,
|
||||
curve: 'P-256',
|
||||
isExpired: true,
|
||||
daysLeft: -4,
|
||||
notAfter: '2026-10-05T10:00:00.000Z',
|
||||
});
|
||||
const soon = cert({ id: 'c-soon', cn: 'bald.example.test', role: 'end-entity', daysLeft: 12 });
|
||||
|
||||
const chains: ChainInfo[] = [
|
||||
{
|
||||
headId: 'c-leaf',
|
||||
path: ['c-leaf', 'c-inter'],
|
||||
rootId: null,
|
||||
complete: false,
|
||||
alternatives: 0,
|
||||
gap: { certId: 'c-inter', kind: 'afterCa', missingIssuerCn: 'Test Root', aiaUrls: [] },
|
||||
},
|
||||
{
|
||||
headId: 'c-ec',
|
||||
path: ['c-ec'],
|
||||
rootId: null,
|
||||
complete: false,
|
||||
alternatives: 0,
|
||||
gap: { certId: 'c-ec', kind: 'afterLeaf', missingIssuerCn: 'Inter EC', aiaUrls: [] },
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('AnalyzeTab', () => {
|
||||
const analysis: AnalysisResult = {
|
||||
items: [leaf, ec, soon, inter],
|
||||
chains,
|
||||
locked: [],
|
||||
ignored: [
|
||||
{ file: 1, path: 'vendor.zip/inner.zip', reason: 'nestedZip' },
|
||||
{ file: 1, path: 'vendor.zip/readme.txt', reason: 'unknown' },
|
||||
],
|
||||
};
|
||||
const entries = [entry('e1', 'a.pem'), entry('e2', 'vendor.zip')];
|
||||
|
||||
it('zeigt zuerst die Ketten, dann die Zertifikate, dann Nicht verwendetes', () => {
|
||||
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||
const headings = screen.getAllByRole('heading').map((h) => h.textContent);
|
||||
expect(headings).toEqual(['Ketten', 'Zertifikate', 'Nicht verwendet']);
|
||||
const chainsRegion = screen.getByRole('region', { name: 'Ketten' });
|
||||
expect(within(chainsRegion).getByText(/Zwischenzertifikat fehlt/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('eine Karte je Zertifikat mit Rolle, Name, Aussteller, Gueltigkeit in UTC, Namen, Schluessel', () => {
|
||||
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||
const cards = screen.getAllByTestId('cert-card');
|
||||
expect(cards).toHaveLength(4);
|
||||
const first = cards[0];
|
||||
expect(within(first).getByText('Serverzertifikat')).toBeInTheDocument();
|
||||
expect(within(first).getByText('www.example.test')).toBeInTheDocument();
|
||||
expect(within(first).getByText('Test Inter')).toBeInTheDocument();
|
||||
expect(within(first).getByText('www.example.test, example.test')).toBeInTheDocument();
|
||||
expect(within(first).getByText('RSA, 2048 Bit')).toBeInTheDocument();
|
||||
expect(within(first).getByText('AB12')).toBeInTheDocument();
|
||||
expect(within(first).getByText('AA:BB:CC')).toBeInTheDocument();
|
||||
expect(within(first).getByText('11:22:33')).toBeInTheDocument();
|
||||
// 23:59:59 UTC bleibt der 15. Maerz (nicht der Folgetag in Berlin)
|
||||
expect(first).toHaveTextContent('15.03.2126');
|
||||
});
|
||||
|
||||
it('EC-Zertifikat nennt die Kurve, abgelaufen und bald ablaufend sind unterscheidbar', () => {
|
||||
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||
const ecCard = screen
|
||||
.getAllByTestId('cert-card')
|
||||
.find((c) => c.textContent?.includes('ec.example.test')) as HTMLElement;
|
||||
expect(within(ecCard).getByText('EC, Kurve P-256')).toBeInTheDocument();
|
||||
expect(within(ecCard).getByText('Abgelaufen')).toBeInTheDocument();
|
||||
const soonCard = screen
|
||||
.getAllByTestId('cert-card')
|
||||
.find((c) => c.textContent?.includes('bald.example.test')) as HTMLElement;
|
||||
expect(within(soonCard).getByText('Läuft in 12 Tagen ab')).toBeInTheDocument();
|
||||
const okCard = screen.getAllByTestId('cert-card')[0];
|
||||
expect(within(okCard).getByText('Gültig', { selector: 'span' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('nennt die Quellen mit Datei und ZIP-Pfad', () => {
|
||||
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||
const card = screen.getAllByTestId('cert-card')[0];
|
||||
expect(within(card).getByText('a.pem, vendor.zip/kopie/a.pem')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('eingefuegter Text erscheint mit seiner Beschriftung als Quelle', () => {
|
||||
const pasted = entry('e9', 'pasted-1.pem', 'Eingefügter Text 1');
|
||||
const a: AnalysisResult = {
|
||||
items: [
|
||||
cert({
|
||||
id: 'c-p',
|
||||
cn: 'p.example.test',
|
||||
role: 'end-entity',
|
||||
sources: [{ file: 0, path: 'pasted-1.pem' }],
|
||||
}),
|
||||
],
|
||||
chains: [],
|
||||
locked: [],
|
||||
ignored: [],
|
||||
};
|
||||
render(<AnalyzeTab workspace={workspace(a, [pasted], ['e9'])} />);
|
||||
expect(screen.getByText('Eingefügter Text 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('listet uebersprungene Eintraege mit Pfad und Grund', () => {
|
||||
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||
const region = screen.getByRole('region', { name: 'Nicht verwendet' });
|
||||
const items = within(region).getAllByRole('listitem');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toHaveTextContent('vendor.zip/inner.zip');
|
||||
expect(items[0]).toHaveTextContent('Ein ZIP in einem ZIP wird nicht geöffnet.');
|
||||
expect(items[1]).toHaveTextContent('Darin wurde kein Zertifikat erkannt.');
|
||||
});
|
||||
|
||||
it('ohne erkanntes Zertifikat und ohne Ausgelassenes: ruhiger Hinweis', () => {
|
||||
render(
|
||||
<AnalyzeTab workspace={workspace({ items: [], chains: [], locked: [], ignored: [] })} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('In den Dateien wurde noch kein Zertifikat erkannt.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enthaelt keine Pfeilzeichen und keine ALL-CAPS-Beschriftungen', () => {
|
||||
const { container } = render(
|
||||
<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />,
|
||||
);
|
||||
expect(container.textContent).not.toMatch(/[→←·]/);
|
||||
expect(container.innerHTML).not.toContain('uppercase');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { CertItem } from '../actions';
|
||||
import type { CertWorkspace } from '../use-cert-workspace';
|
||||
import { ChainView } from './ChainView';
|
||||
import { ItemCard } from './ItemCard';
|
||||
|
||||
interface AnalyzeTabProps {
|
||||
workspace: CertWorkspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiter „Analysieren“: arbeitet auf dem gemeinsamen Arbeitsbereich (D-01). Zuerst die Ketten
|
||||
* (eine je Serverzertifikat), dann jedes Zertifikat im Einzelnen, zuletzt, was uebersprungen wurde.
|
||||
*/
|
||||
export function AnalyzeTab({ workspace }: AnalyzeTabProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const { analysis, entries, analysisIds, status } = workspace;
|
||||
|
||||
if (!analysis) {
|
||||
return status === 'analyzing' ? (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{t('files.analyzing')}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const certs = analysis.items.filter((i): i is CertItem => i.kind === 'certificate');
|
||||
|
||||
if (certs.length === 0 && analysis.ignored.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{t('analyze.nothingFound')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">{t('analyze.intro')}</p>
|
||||
|
||||
{analysis.chains.length > 0 && (
|
||||
<section className="space-y-3" aria-label={t('analyze.chainsTitle')}>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('analyze.chainsTitle')}</h2>
|
||||
{analysis.chains.map((chain) => (
|
||||
<ChainView key={chain.headId} chain={chain} certs={certs} />
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{certs.length > 0 && (
|
||||
<section className="space-y-3" aria-label={t('analyze.certificatesTitle')}>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{t('analyze.certificatesTitle')}
|
||||
</h2>
|
||||
<ul className="space-y-4">
|
||||
{certs.map((cert) => (
|
||||
<ItemCard key={cert.id} cert={cert} entries={entries} analysisIds={analysisIds} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{analysis.ignored.length > 0 && (
|
||||
<section className="space-y-2" aria-label={t('analyze.ignoredTitle')}>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('analyze.ignoredTitle')}</h2>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
{analysis.ignored.map((i) => (
|
||||
<li key={`${i.file}-${i.path}-${i.reason}`}>
|
||||
<span className="break-all text-foreground">{i.path}</span>{' '}
|
||||
{t(`files.ignored.${i.reason}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -215,4 +215,79 @@ describe('FilesTab', () => {
|
||||
expect(screen.getByText('Test Inter')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Darin wurde kein Zertifikat erkannt.')).not.toBeInTheDocument();
|
||||
});
|
||||
it('ein ZIP zeigt die enthaltenen Teile nach Pfad mit Rolle und Name', async () => {
|
||||
mockAnalyze.mockResolvedValueOnce({
|
||||
items: [
|
||||
cert('c-srv', 'www.example.test', 'end-entity', [0], ['vendor.zip/ServerCertificate.crt']),
|
||||
cert('c-ca', 'Test Inter', 'intermediate', [0], ['vendor.zip/Intermediate/CA.crt']),
|
||||
],
|
||||
chains: [],
|
||||
locked: [],
|
||||
ignored: [{ file: 0, path: 'vendor.zip/inner.zip', reason: 'nestedZip' }],
|
||||
} satisfies AnalysisResult);
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('vendor.zip')]);
|
||||
const srvPath = await screen.findByText('vendor.zip/ServerCertificate.crt');
|
||||
const group = srvPath.closest('li') as HTMLElement;
|
||||
expect(within(group).getByText('www.example.test')).toBeInTheDocument();
|
||||
expect(within(group).getByText('Serverzertifikat')).toBeInTheDocument();
|
||||
const caGroup = screen.getByText('vendor.zip/Intermediate/CA.crt').closest('li') as HTMLElement;
|
||||
expect(within(caGroup).getByText('Zwischenzertifikat')).toBeInTheDocument();
|
||||
const nested = screen.getByText('vendor.zip/inner.zip').closest('li') as HTMLElement;
|
||||
expect(
|
||||
within(nested).getByText('Ein ZIP in einem ZIP wird nicht geöffnet.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['nestedZip', 'Ein ZIP in einem ZIP wird nicht geöffnet.'],
|
||||
['encryptedZip', 'Der ZIP-Inhalt ist mit einem Passwort geschützt und wird übersprungen.'],
|
||||
['tooLarge', 'Die Datei ist zu groß und wird übersprungen.'],
|
||||
['suspicious', 'Die Datei ist ungewöhnlich stark gepackt und wird übersprungen.'],
|
||||
['zipTooLarge', 'Das ZIP enthält zu viele Daten und wird nicht geöffnet.'],
|
||||
['tooManyEntries', 'Das ZIP enthält zu viele Dateien und wird nicht geöffnet.'],
|
||||
['brokenZip', 'Das ZIP lässt sich nicht lesen.'],
|
||||
] as const)('Grund %s wird als Text gezeigt', async (reason, text) => {
|
||||
mockAnalyze.mockResolvedValueOnce({
|
||||
items: [],
|
||||
chains: [],
|
||||
locked: [],
|
||||
ignored: [{ file: 0, path: 'v.zip/x', reason }],
|
||||
} satisfies AnalysisResult);
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('v.zip')]);
|
||||
expect(await screen.findByText(text)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('der eingeklappte Bereich „PEM-Text einfügen“ fügt „Eingefügter Text 1“ als Texteintrag hinzu', async () => {
|
||||
render(<Harness />);
|
||||
expect(screen.queryByLabelText('Eingefügter PEM-Text')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'PEM-Text einfügen' }));
|
||||
const area = screen.getByLabelText('Eingefügter PEM-Text');
|
||||
const add = screen.getByRole('button', { name: 'Hinzufügen' });
|
||||
expect(add).toBeDisabled();
|
||||
fireEvent.change(area, { target: { value: '-----BEGIN CERTIFICATE-----\nabc\n' } });
|
||||
fireEvent.click(add);
|
||||
expect(await screen.findByText('Eingefügter Text 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Eingefügter Text')).toBeInTheDocument();
|
||||
const sent = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[];
|
||||
expect(sent.map((e) => e.file.name)).toEqual(['pasted-1.pem']);
|
||||
// Bereich ist wieder zu, der naechste Text wird Nummer 2
|
||||
expect(screen.queryByLabelText('Eingefügter PEM-Text')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'PEM-Text einfügen' }));
|
||||
fireEvent.change(screen.getByLabelText('Eingefügter PEM-Text'), { target: { value: 'zwei' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hinzufügen' }));
|
||||
expect(await screen.findByText('Eingefügter Text 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('zu langer eingefügter Text wird mit Grund abgelehnt', () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'PEM-Text einfügen' }));
|
||||
fireEvent.change(screen.getByLabelText('Eingefügter PEM-Text'), {
|
||||
target: { value: 'a'.repeat(256_001) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hinzufügen' }));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('länger als');
|
||||
expect(mockAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import type { AnyItem } from '../actions';
|
||||
import type { AnyItem, IgnoredEntry } from '../actions';
|
||||
import type { CertWorkspace } from '../use-cert-workspace';
|
||||
import {
|
||||
formatBytes,
|
||||
MAX_ENTRIES,
|
||||
MAX_FILE_BYTES,
|
||||
MAX_PASTE_CHARS,
|
||||
MAX_TOTAL_BYTES,
|
||||
type RejectedFile,
|
||||
type WorkingEntry,
|
||||
@@ -51,6 +52,13 @@ function itemName(item: AnyItem): string {
|
||||
return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName;
|
||||
}
|
||||
|
||||
/** Was in einer Datei (bei ZIP: unter einem Pfad im ZIP) erkannt oder uebersprungen wurde. */
|
||||
interface PartGroup {
|
||||
path: string;
|
||||
items: AnyItem[];
|
||||
ignored: IgnoredEntry[];
|
||||
}
|
||||
|
||||
interface FilesTabProps {
|
||||
workspace: CertWorkspace;
|
||||
}
|
||||
@@ -64,6 +72,8 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [rejected, setRejected] = useState<RejectedFile[]>([]);
|
||||
const [pasteOpen, setPasteOpen] = useState(false);
|
||||
const [pasteText, setPasteText] = useState('');
|
||||
const { entries, analysis, analysisIds, status, errorKey } = workspace;
|
||||
|
||||
const add = (list: FileList | File[] | null | undefined) => {
|
||||
@@ -72,12 +82,36 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
||||
setRejected(workspace.addFiles(files));
|
||||
};
|
||||
|
||||
const itemsOf = (entry: WorkingEntry): AnyItem[] =>
|
||||
(analysis?.items ?? []).filter((item) =>
|
||||
item.sources.some((s) => analysisIds[s.file] === entry.id),
|
||||
);
|
||||
const ignoredOf = (entry: WorkingEntry) =>
|
||||
(analysis?.ignored ?? []).filter((i) => analysisIds[i.file] === entry.id);
|
||||
const addPasted = () => {
|
||||
const problem = workspace.addText(pasteText, (n) => t('files.pastedLabel', { n }));
|
||||
setRejected(problem ? [problem] : []);
|
||||
if (!problem) {
|
||||
setPasteText('');
|
||||
setPasteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Teile eines Eintrags nach Pfad gruppiert: bei einem ZIP je enthaltene Datei, sonst eine Gruppe. */
|
||||
const groupsOf = (entry: WorkingEntry): PartGroup[] => {
|
||||
const groups = new Map<string, PartGroup>();
|
||||
const group = (path: string): PartGroup => {
|
||||
let g = groups.get(path);
|
||||
if (!g) {
|
||||
g = { path, items: [], ignored: [] };
|
||||
groups.set(path, g);
|
||||
}
|
||||
return g;
|
||||
};
|
||||
for (const item of analysis?.items ?? []) {
|
||||
for (const source of item.sources) {
|
||||
if (analysisIds[source.file] === entry.id) group(source.path).items.push(item);
|
||||
}
|
||||
}
|
||||
for (const ignored of analysis?.ignored ?? []) {
|
||||
if (analysisIds[ignored.file] === entry.id) group(ignored.path).ignored.push(ignored);
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => a.path.localeCompare(b.path));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -123,6 +157,40 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={pasteOpen}
|
||||
aria-controls="cert-paste-area"
|
||||
onClick={() => setPasteOpen((open) => !open)}
|
||||
className="rounded border border-border px-3 py-1 text-sm text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{t('files.pasteToggle')}
|
||||
</button>
|
||||
{pasteOpen && (
|
||||
<div id="cert-paste-area" className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('files.pasteHint')}</p>
|
||||
<textarea
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
aria-label={t('files.pasteLabel')}
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full rounded border border-border bg-background px-3 py-2 font-mono text-xs text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPasted}
|
||||
disabled={pasteText.trim() === ''}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('files.pasteAdd')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rejected.length > 0 && (
|
||||
<div role="alert" className="rounded-lg border border-border bg-muted p-3 text-sm">
|
||||
<p className="font-medium text-foreground">{t('files.rejectedTitle')}</p>
|
||||
@@ -133,6 +201,7 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
||||
name: r.name,
|
||||
max: MAX_ENTRIES,
|
||||
size: (r.reason === 'tooLarge' ? MAX_FILE_BYTES : MAX_TOTAL_BYTES) / MB,
|
||||
chars: MAX_PASTE_CHARS,
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
@@ -181,16 +250,16 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
||||
|
||||
<ul className="space-y-2">
|
||||
{entries.map((entry) => {
|
||||
const items = itemsOf(entry);
|
||||
const ignored = ignoredOf(entry);
|
||||
const groups = groupsOf(entry);
|
||||
const analysed = analysisIds.includes(entry.id);
|
||||
return (
|
||||
<li key={entry.id} className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="break-all text-sm font-medium text-foreground">{entry.label}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatBytes(entry.file.size)}
|
||||
<p className="flex flex-wrap gap-x-3 text-xs text-muted-foreground">
|
||||
<span>{formatBytes(entry.file.size)}</span>
|
||||
{entry.origin === 'paste' && <span>{t('files.originPaste')}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -202,25 +271,37 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
||||
{t('files.removeShort')}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[roleKey(item)]}`}
|
||||
>
|
||||
{t(`roles.${roleKey(item)}`)}
|
||||
</span>
|
||||
<span className="break-all text-foreground">{itemName(item)}</span>
|
||||
{item.kind === 'certificate' && item.isExpired && (
|
||||
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-300">
|
||||
{t('files.expired')}
|
||||
</span>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{groups.map((g) => (
|
||||
<li key={g.path}>
|
||||
{g.path !== entry.file.name && (
|
||||
<p className="break-all text-xs text-muted-foreground">{g.path}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{ignored.map((i) => (
|
||||
<li key={`${i.path}-${i.reason}`} className="text-sm text-muted-foreground">
|
||||
{t(`files.ignored.${i.reason}`)}
|
||||
<ul className="space-y-1">
|
||||
{g.items.map((item) => (
|
||||
<li key={item.id} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[roleKey(item)]}`}
|
||||
>
|
||||
{t(`roles.${roleKey(item)}`)}
|
||||
</span>
|
||||
<span className="break-all text-foreground">{itemName(item)}</span>
|
||||
{item.kind === 'certificate' && item.isExpired && (
|
||||
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-300">
|
||||
{t('files.expired')}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{g.ignored.map((i) => (
|
||||
<li
|
||||
key={`${i.path}-${i.reason}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t(`files.ignored.${i.reason}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
{!analysed && status === 'analyzing' && (
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import type { CertItem, ItemSource } from '../actions';
|
||||
import type { WorkingEntry } from '../working-set';
|
||||
import { ROLE_STYLES } from './FilesTab';
|
||||
|
||||
interface ItemCardProps {
|
||||
cert: CertItem;
|
||||
/** Eintraege des Arbeitsbereichs und ihre Kennungen zum Analysezeitpunkt, fuer die Namen der Quellen */
|
||||
entries: WorkingEntry[];
|
||||
analysisIds: string[];
|
||||
}
|
||||
|
||||
/** Anzeigename einer Quelle: Pfad (bei ZIP "zip/eintrag"), bei eingefuegtem Text die Beschriftung. */
|
||||
function sourceLabel(source: ItemSource, entries: WorkingEntry[], analysisIds: string[]): string {
|
||||
const entry = entries.find((e) => e.id === analysisIds[source.file]);
|
||||
if (entry && source.path === entry.file.name) return entry.label;
|
||||
return source.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Karte je Zertifikat im Reiter „Analysieren“ (D-23): Rolle, Name, Gueltigkeit, Namen,
|
||||
* Schluessel, Seriennummer, Fingerabdruecke und woher das Zertifikat kam.
|
||||
* Daten kommen in UTC, wie im Zertifikat; der Aussteller nennt dasselbe Datum.
|
||||
*/
|
||||
export function ItemCard({ cert, entries, analysisIds }: ItemCardProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const format = useFormatter();
|
||||
const date = (iso: string) =>
|
||||
format.dateTime(new Date(iso), { dateStyle: 'medium', timeZone: 'UTC' });
|
||||
|
||||
const status = cert.isExpired
|
||||
? { cls: 'bg-status-down/15 text-status-down-fg', text: t('analyze.expired') }
|
||||
: cert.daysLeft <= 30
|
||||
? {
|
||||
cls: 'bg-status-warn/15 text-status-warn-fg',
|
||||
text: t('analyze.expiresSoon', { days: cert.daysLeft }),
|
||||
}
|
||||
: { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('analyze.valid') };
|
||||
|
||||
const keyText =
|
||||
cert.keyType === 'RSA'
|
||||
? t('analyze.keyRsa', { bits: cert.keyBits ?? 0 })
|
||||
: cert.keyType === 'EC'
|
||||
? t('analyze.keyEc', { curve: cert.curve ?? '' })
|
||||
: cert.keyType;
|
||||
|
||||
const sources = cert.sources.map((s) => sourceLabel(s, entries, analysisIds));
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border border-border p-4" data-testid="cert-card">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[cert.role]}`}>
|
||||
{t(`roles.${cert.role}`)}
|
||||
</span>
|
||||
<span className="break-all font-medium text-foreground">{cert.cn || cert.baseName}</span>
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${status.cls}`}>
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t(`analyze.explain.${cert.role}`)}</p>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
||||
<dt className="text-muted-foreground">{t('analyze.issuer')}</dt>
|
||||
<dd className="break-all text-foreground">{cert.issuerCn || t('chain.unknownIssuer')}</dd>
|
||||
<dt className="text-muted-foreground">{t('analyze.validity')}</dt>
|
||||
<dd className="text-foreground">
|
||||
{t('analyze.validRange', { from: date(cert.notBefore), to: date(cert.notAfter) })}
|
||||
</dd>
|
||||
{cert.san.length > 0 && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('analyze.names')}</dt>
|
||||
<dd className="break-all text-foreground">{cert.san.join(', ')}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-muted-foreground">{t('analyze.key')}</dt>
|
||||
<dd className="text-foreground">{keyText}</dd>
|
||||
<dt className="text-muted-foreground">{t('analyze.serial')}</dt>
|
||||
<dd className="break-all font-mono text-xs text-foreground">{cert.serialNumber}</dd>
|
||||
<dt className="text-muted-foreground">{t('analyze.sha256')}</dt>
|
||||
<dd className="break-all font-mono text-xs text-foreground">{cert.sha256}</dd>
|
||||
<dt className="text-muted-foreground">{t('analyze.sha1')}</dt>
|
||||
<dd className="break-all font-mono text-xs text-foreground">{cert.sha1}</dd>
|
||||
<dt className="text-muted-foreground">{t('analyze.source')}</dt>
|
||||
<dd className="break-all text-foreground">{sources.join(', ')}</dd>
|
||||
</dl>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ function workspace(items: CertItem[], chains: ChainInfo[]): CertWorkspace {
|
||||
status: 'idle',
|
||||
errorKey: null,
|
||||
addFiles: () => [],
|
||||
addText: () => null,
|
||||
remove: () => {},
|
||||
clear: () => {},
|
||||
retry: () => {},
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { cleanup, fireEvent, render as rtlRender, screen, within } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import de from '@/messages/de.json';
|
||||
import type { AnalysisResult, AnyItem, CertItem } from '../actions';
|
||||
import type { CertWorkspace } from '../use-cert-workspace';
|
||||
import { dedupeNames, SplitTab } from './SplitTab';
|
||||
|
||||
const mockDownload = vi.fn();
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
vi.mock('../actions', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../actions')>();
|
||||
return { ...actual, downloadBase64: (...args: unknown[]) => mockDownload(...args) };
|
||||
});
|
||||
|
||||
vi.mock('fflate', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fflate')>();
|
||||
return { ...actual, zipSync: vi.fn(actual.zipSync) };
|
||||
});
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function cert(id: string, cn: string, role: CertItem['role'], baseName = cn): CertItem {
|
||||
return {
|
||||
id,
|
||||
kind: 'certificate',
|
||||
role,
|
||||
sources: [{ file: 0, path: `${id}.pem` }],
|
||||
pem: `-----BEGIN CERTIFICATE-----\n${id}\n-----END CERTIFICATE-----\n`,
|
||||
baseName,
|
||||
cn,
|
||||
organization: '',
|
||||
issuerCn: '',
|
||||
issuerOrganization: '',
|
||||
notBefore: '2026-01-01T00:00:00.000Z',
|
||||
notAfter: '2126-01-01T00:00:00.000Z',
|
||||
isExpired: false,
|
||||
daysLeft: 36000,
|
||||
san: [],
|
||||
keyType: 'RSA',
|
||||
keyBits: 2048,
|
||||
curve: null,
|
||||
serialNumber: '01',
|
||||
sha256: '',
|
||||
sha1: '',
|
||||
isCa: role !== 'end-entity',
|
||||
selfSigned: role === 'root',
|
||||
aiaIssuerUrls: [],
|
||||
keyId: null,
|
||||
csrIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(items: AnyItem[] | null): CertWorkspace {
|
||||
const analysis: AnalysisResult | null = items
|
||||
? { items, chains: [], locked: [], ignored: [] }
|
||||
: null;
|
||||
return {
|
||||
entries: [],
|
||||
analysis,
|
||||
analysisIds: [],
|
||||
status: 'idle',
|
||||
errorKey: null,
|
||||
addFiles: () => [],
|
||||
addText: () => null,
|
||||
remove: () => {},
|
||||
clear: () => {},
|
||||
retry: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockDownload.mockReset();
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const items: AnyItem[] = [
|
||||
cert('c-leaf', 'www.example.test', 'end-entity'),
|
||||
cert('c-inter', 'Test Inter', 'intermediate', 'Test_Inter'),
|
||||
];
|
||||
|
||||
describe('SplitTab', () => {
|
||||
it('jedes Teil als Zeile mit Rolle, Name und Dateiname', () => {
|
||||
render(<SplitTab workspace={workspace(items)} />);
|
||||
const rows = screen.getAllByRole('listitem');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(within(rows[0]).getByText('Serverzertifikat')).toBeInTheDocument();
|
||||
expect(within(rows[0]).getByText('www.example.test.crt')).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText('Zwischenzertifikat')).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText('Test_Inter.crt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Herunterladen gibt das PEM des Teils als Base64 an downloadBase64, ohne Aufruf der API', () => {
|
||||
render(<SplitTab workspace={workspace(items)} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '„www.example.test.crt“ herunterladen' }));
|
||||
expect(mockDownload).toHaveBeenCalledTimes(1);
|
||||
const [name, content, mime] = mockDownload.mock.calls[0];
|
||||
expect(name).toBe('www.example.test.crt');
|
||||
expect(atob(content)).toBe(items[0].pem);
|
||||
expect(mime).toBe('application/x-pem-file');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Schluessel und Anfragen bekommen .key und .csr (Zeilen sind unabhaengig von der Art)', () => {
|
||||
const key: AnyItem = {
|
||||
id: 'k-1',
|
||||
kind: 'privateKey',
|
||||
sources: [],
|
||||
pem: 'KEY',
|
||||
baseName: 'schluessel',
|
||||
keyType: 'RSA',
|
||||
keyBits: 2048,
|
||||
curve: null,
|
||||
wasEncrypted: false,
|
||||
certIds: [],
|
||||
};
|
||||
const csr: AnyItem = {
|
||||
id: 'r-1',
|
||||
kind: 'csr',
|
||||
sources: [],
|
||||
pem: 'CSR',
|
||||
baseName: 'anfrage',
|
||||
cn: 'www.example.test',
|
||||
organization: '',
|
||||
san: [],
|
||||
keyType: 'RSA',
|
||||
keyBits: 2048,
|
||||
curve: null,
|
||||
keyId: null,
|
||||
certIds: [],
|
||||
};
|
||||
render(<SplitTab workspace={workspace([items[0], key, csr])} />);
|
||||
expect(screen.getByText('schluessel.key')).toBeInTheDocument();
|
||||
expect(screen.getByText('anfrage.csr')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('„Alle als ZIP herunterladen“ packt alle Teile mit durchnummerierten gleichen Namen', async () => {
|
||||
const fflate = await import('fflate');
|
||||
const click = vi.fn();
|
||||
const createObjectURL = vi.fn(() => 'blob:x');
|
||||
const revoke = vi.fn();
|
||||
vi.stubGlobal('URL', { ...URL, createObjectURL, revokeObjectURL: revoke });
|
||||
const realCreate = document.createElement.bind(document);
|
||||
let anchor: HTMLAnchorElement | null = null;
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
|
||||
const el = realCreate(tag);
|
||||
if (tag === 'a') {
|
||||
anchor = el as HTMLAnchorElement;
|
||||
el.click = click;
|
||||
}
|
||||
return el;
|
||||
});
|
||||
const dup = [
|
||||
cert('c-1', 'x', 'end-entity', 'gleich'),
|
||||
cert('c-2', 'y', 'end-entity', 'gleich'),
|
||||
cert('c-3', 'z', 'root', 'Wurzel'),
|
||||
];
|
||||
render(<SplitTab workspace={workspace(dup)} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Alle als ZIP herunterladen' }));
|
||||
expect(fflate.zipSync).toHaveBeenCalledTimes(1);
|
||||
const files = (fflate.zipSync as unknown as { mock: { calls: [Record<string, Uint8Array>][] } })
|
||||
.mock.calls[0][0];
|
||||
expect(Object.keys(files)).toEqual(['gleich.crt', 'gleich-2.crt', 'Wurzel.crt']);
|
||||
expect(new TextDecoder().decode(files['gleich-2.crt'])).toBe(dup[1].pem);
|
||||
expect(click).toHaveBeenCalled();
|
||||
expect((anchor as HTMLAnchorElement | null)?.download).toBe('Zertifikate.zip');
|
||||
expect(revoke).toHaveBeenCalledWith('blob:x');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bei nur einem Teil gibt es keinen ZIP-Knopf', () => {
|
||||
render(<SplitTab workspace={workspace([items[0]])} />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Alle als ZIP herunterladen' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ohne erkannte Teile: ruhiger Hinweis', () => {
|
||||
render(<SplitTab workspace={workspace([])} />);
|
||||
expect(screen.getByText(/noch nichts erkannt/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedupeNames', () => {
|
||||
it('nummeriert wiederholte Namen vor der Endung', () => {
|
||||
expect(dedupeNames(['a.crt', 'a.crt', 'b.crt', 'a.crt'])).toEqual([
|
||||
'a.crt',
|
||||
'a-2.crt',
|
||||
'b.crt',
|
||||
'a-3.crt',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { zipSync } from 'fflate';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { type AnyItem, downloadBase64 } from '../actions';
|
||||
import type { CertWorkspace } from '../use-cert-workspace';
|
||||
import { sanitizeZipFilename } from '../zip-filename';
|
||||
import { ROLE_STYLES } from './FilesTab';
|
||||
|
||||
interface SplitTabProps {
|
||||
workspace: CertWorkspace;
|
||||
}
|
||||
|
||||
const MIME_PEM = 'application/x-pem-file';
|
||||
|
||||
/** Dateiname im natuerlichen Format des Teils; Zertifikat .crt, Schluessel .key, Anfrage .csr. */
|
||||
function fileNameOf(item: AnyItem): string {
|
||||
const ext = item.kind === 'certificate' ? 'crt' : item.kind === 'privateKey' ? 'key' : 'csr';
|
||||
return `${item.baseName}.${ext}`;
|
||||
}
|
||||
|
||||
function roleKey(item: AnyItem): string {
|
||||
return item.kind === 'certificate' ? item.role : item.kind;
|
||||
}
|
||||
|
||||
function itemName(item: AnyItem): string {
|
||||
return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName;
|
||||
}
|
||||
|
||||
function toBase64(text: string): string {
|
||||
return btoa(text);
|
||||
}
|
||||
|
||||
/** Gleiche Namen bekommen -2, -3 vor der Endung (fflate ueberschriebe sie sonst stillschweigend). */
|
||||
export function dedupeNames(names: string[]): string[] {
|
||||
const used = new Set<string>();
|
||||
return names.map((name) => {
|
||||
let candidate = name;
|
||||
let n = 1;
|
||||
const dot = name.lastIndexOf('.');
|
||||
const base = dot > 0 ? name.slice(0, dot) : name;
|
||||
const ext = dot > 0 ? name.slice(dot) : '';
|
||||
while (used.has(candidate)) candidate = `${base}-${++n}${ext}`;
|
||||
used.add(candidate);
|
||||
return candidate;
|
||||
});
|
||||
}
|
||||
|
||||
function downloadZip(items: AnyItem[], zipFilename: string) {
|
||||
const names = dedupeNames(items.map(fileNameOf));
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
items.forEach((item, i) => {
|
||||
files[names[i]] = new TextEncoder().encode(item.pem);
|
||||
});
|
||||
const blob = new Blob([zipSync(files) as BlobPart], { type: 'application/zip' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
// Der uebersetzte Name kann Zeichen tragen, die Windows verbietet: auf das Zulaessige zurueckschneiden.
|
||||
anchor.download = sanitizeZipFilename(zipFilename);
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiter „Aufteilen“: jedes erkannte Teil einzeln in seinem natuerlichen Format herunterladen,
|
||||
* oder alle zusammen als ZIP. Rein im Browser aus den Daten der Analyse; kein Aufruf der API.
|
||||
*/
|
||||
export function SplitTab({ workspace }: SplitTabProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const items = workspace.analysis?.items ?? [];
|
||||
|
||||
if (!workspace.analysis) {
|
||||
return workspace.status === 'analyzing' ? (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{t('files.analyzing')}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{t('split.nothingFound')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t('split.intro')}</p>
|
||||
{items.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadZip(items, t('split.zipFilename'))}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('split.downloadAll')}
|
||||
</button>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`shrink-0 rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[roleKey(item)]}`}
|
||||
>
|
||||
{t(`roles.${roleKey(item)}`)}
|
||||
</span>
|
||||
<span className="break-all font-medium text-foreground">{itemName(item)}</span>
|
||||
<span className="break-all text-xs text-muted-foreground">{fileNameOf(item)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadBase64(fileNameOf(item), toBase64(item.pem), MIME_PEM)}
|
||||
aria-label={t('split.downloadFor', { name: fileNameOf(item) })}
|
||||
className="shrink-0 rounded border border-border px-3 py-1 text-xs font-medium text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{t('split.download')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { TabBar } from '@/components/accounting/tab-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AnalyzeTab } from './components/AnalyzeTab';
|
||||
import { EmptyWorkspace } from './components/EmptyWorkspace';
|
||||
import { FilesTab } from './components/FilesTab';
|
||||
import { MergeTab } from './components/MergeTab';
|
||||
import { SplitTab } from './components/SplitTab';
|
||||
import { useCertWorkspace } from './use-cert-workspace';
|
||||
|
||||
type TabId = 'files' | 'merge';
|
||||
type TabId = 'files' | 'analyze' | 'split' | 'merge';
|
||||
|
||||
/**
|
||||
* CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt).
|
||||
@@ -27,6 +29,8 @@ export default function CertManagerPage() {
|
||||
id: 'files',
|
||||
label: count > 0 ? t('tabs.filesWithCount', { count }) : t('tabs.files'),
|
||||
},
|
||||
{ id: 'analyze', label: t('tabs.analyze') },
|
||||
{ id: 'split', label: t('tabs.split') },
|
||||
{ id: 'merge', label: t('tabs.merge') },
|
||||
];
|
||||
|
||||
@@ -42,6 +46,8 @@ export default function CertManagerPage() {
|
||||
{activeTab !== 'files' && count > 0 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t('workspace.basis', { count })}</p>
|
||||
{activeTab === 'analyze' && <AnalyzeTab workspace={workspace} />}
|
||||
{activeTab === 'split' && <SplitTab workspace={workspace} />}
|
||||
{activeTab === 'merge' && <MergeTab workspace={workspace} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { type AnalysisResult, analyzeWorkingSet, certErrorKey } from './actions';
|
||||
import {
|
||||
addText as addTextToSet,
|
||||
addFiles as addToSet,
|
||||
type RejectedFile,
|
||||
removeEntry,
|
||||
@@ -21,6 +22,8 @@ export interface CertWorkspace {
|
||||
/** Schluessel unter certManager.errors.* */
|
||||
errorKey: string | null;
|
||||
addFiles: (files: File[]) => RejectedFile[];
|
||||
/** Eingefuegten PEM-Text als Eintrag anhaengen; `label` liefert die Beschriftung in der Sprache der Oberflaeche */
|
||||
addText: (text: string, label?: (n: number) => string) => RejectedFile | null;
|
||||
remove: (id: string) => void;
|
||||
clear: () => void;
|
||||
retry: () => void;
|
||||
@@ -86,6 +89,20 @@ export function useCertWorkspace(): CertWorkspace {
|
||||
[commit],
|
||||
);
|
||||
|
||||
const addText = useCallback(
|
||||
(text: string, label?: (n: number) => string): RejectedFile | null => {
|
||||
const result = addTextToSet(
|
||||
entriesRef.current,
|
||||
text,
|
||||
() => `entry-${++idCounter.current}`,
|
||||
label,
|
||||
);
|
||||
if (result.entries.length !== entriesRef.current.length) commit(result.entries);
|
||||
return result.rejected;
|
||||
},
|
||||
[commit],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
(id: string) => {
|
||||
commit(removeEntry(entriesRef.current, id));
|
||||
@@ -101,5 +118,16 @@ export function useCertWorkspace(): CertWorkspace {
|
||||
runAnalysis(entriesRef.current);
|
||||
}, [runAnalysis]);
|
||||
|
||||
return { entries, analysis, analysisIds, status, errorKey, addFiles, remove, clear, retry };
|
||||
return {
|
||||
entries,
|
||||
analysis,
|
||||
analysisIds,
|
||||
status,
|
||||
errorKey,
|
||||
addFiles,
|
||||
addText,
|
||||
remove,
|
||||
clear,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
addFiles,
|
||||
addText,
|
||||
MAX_ENTRIES,
|
||||
MAX_FILE_BYTES,
|
||||
MAX_PASTE_CHARS,
|
||||
removeEntry,
|
||||
toFormData,
|
||||
type WorkingEntry,
|
||||
@@ -68,6 +70,59 @@ describe('addFiles', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('addText', () => {
|
||||
it('trimmt, legt pasted-1.pem an und beschriftet „Eingefügter Text 1“', () => {
|
||||
const r = addText(NONE, ' -----BEGIN CERTIFICATE-----\nabc\n ', nextId);
|
||||
expect(r.rejected).toBeNull();
|
||||
expect(r.entries).toHaveLength(1);
|
||||
const [e] = r.entries;
|
||||
expect(e.file.name).toBe('pasted-1.pem');
|
||||
expect(e.label).toBe('Eingefügter Text 1');
|
||||
expect(e.origin).toBe('paste');
|
||||
expect(e.host).toBeNull();
|
||||
expect(e.password).toBe('');
|
||||
});
|
||||
|
||||
it('der Inhalt der Datei ist der getrimmte Text', async () => {
|
||||
const r = addText(NONE, '\n hallo \n', nextId);
|
||||
expect(await r.entries[0].file.text()).toBe('hallo');
|
||||
});
|
||||
|
||||
it('der naechste Text heisst pasted-2.pem, auch nach dem Entfernen des ersten', () => {
|
||||
const one = addText(NONE, 'eins', nextId);
|
||||
const two = addText(one.entries, 'zwei', nextId);
|
||||
expect(two.entries.map((e) => e.file.name)).toEqual(['pasted-1.pem', 'pasted-2.pem']);
|
||||
const without = removeEntry(two.entries, two.entries[0].id);
|
||||
const three = addText(without, 'drei', nextId);
|
||||
expect(three.entries.map((e) => e.file.name)).toEqual(['pasted-2.pem', 'pasted-3.pem']);
|
||||
});
|
||||
|
||||
it('die Beschriftung kommt aus der uebergebenen Funktion (Sprache)', () => {
|
||||
const r = addText(NONE, 'x', nextId, (n) => `Pasted text ${n}`);
|
||||
expect(r.entries[0].label).toBe('Pasted text 1');
|
||||
});
|
||||
|
||||
it('leerer oder nur aus Leerraum bestehender Text aendert nichts', () => {
|
||||
const r = addText(NONE, ' \n ', nextId);
|
||||
expect(r.entries).toEqual([]);
|
||||
expect(r.rejected).toBeNull();
|
||||
});
|
||||
|
||||
it('mehr als 256 000 Zeichen werden abgelehnt', () => {
|
||||
const r = addText(NONE, 'a'.repeat(MAX_PASTE_CHARS + 1), nextId);
|
||||
expect(r.entries).toEqual([]);
|
||||
expect(r.rejected).toEqual({ name: 'Eingefügter Text 1', reason: 'pasteTooLarge' });
|
||||
});
|
||||
|
||||
it('eingefuegte Texte zaehlen zu den 30 Eintraegen', () => {
|
||||
const files = Array.from({ length: MAX_ENTRIES }, (_, i) => makeFile(`f${i}.pem`, 10, i));
|
||||
const full = addFiles(NONE, files, nextId);
|
||||
const r = addText(full.entries, 'noch einer', nextId);
|
||||
expect(r.entries).toHaveLength(MAX_ENTRIES);
|
||||
expect(r.rejected).toEqual({ name: 'Eingefügter Text 1', reason: 'tooMany' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeEntry', () => {
|
||||
it('entfernt nur den genannten Eintrag und behaelt die Reihenfolge', () => {
|
||||
const { entries } = addFiles(
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface WorkingEntry {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type RejectReason = 'duplicate' | 'tooMany' | 'tooLarge' | 'totalTooLarge';
|
||||
export type RejectReason = 'duplicate' | 'tooMany' | 'tooLarge' | 'totalTooLarge' | 'pasteTooLarge';
|
||||
|
||||
export interface RejectedFile {
|
||||
name: string;
|
||||
@@ -75,6 +75,53 @@ export function addFiles(
|
||||
return { entries: next, rejected };
|
||||
}
|
||||
|
||||
const PASTED_NAME = /^pasted-(\d+)\.pem$/;
|
||||
|
||||
export interface AddTextResult {
|
||||
entries: WorkingEntry[];
|
||||
rejected: RejectedFile | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haengt eingefuegten PEM-Text als eigenen Eintrag an (D-23): `pasted-<n>.pem`, Beschriftung
|
||||
* „Eingefuegter Text <n>“. Die Nummer zaehlt weiter, auch wenn ein frueherer Text entfernt wurde.
|
||||
* Leerer Text aendert nichts. Der Text zaehlt wie eine Datei zu den 30 Eintraegen und zur Gesamtgroesse.
|
||||
*/
|
||||
export function addText(
|
||||
entries: WorkingEntry[],
|
||||
text: string,
|
||||
nextId: () => string,
|
||||
labelFor: (n: number) => string = (n) => `Eingefügter Text ${n}`,
|
||||
): AddTextResult {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === '') return { entries, rejected: null };
|
||||
|
||||
const used = entries
|
||||
.filter((e) => e.origin === 'paste')
|
||||
.map((e) => Number(PASTED_NAME.exec(e.file.name)?.[1] ?? 0));
|
||||
const n = Math.max(0, ...used) + 1;
|
||||
const label = labelFor(n);
|
||||
const file = new File([trimmed], `pasted-${n}.pem`, {
|
||||
type: 'text/plain',
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
if (trimmed.length > MAX_PASTE_CHARS) {
|
||||
return { entries, rejected: { name: label, reason: 'pasteTooLarge' } };
|
||||
}
|
||||
if (entries.length >= MAX_ENTRIES) {
|
||||
return { entries, rejected: { name: label, reason: 'tooMany' } };
|
||||
}
|
||||
const total = entries.reduce((sum, e) => sum + e.file.size, 0);
|
||||
if (total + file.size > MAX_TOTAL_BYTES) {
|
||||
return { entries, rejected: { name: label, reason: 'totalTooLarge' } };
|
||||
}
|
||||
return {
|
||||
entries: [...entries, { id: nextId(), file, label, origin: 'paste', host: null, password: '' }],
|
||||
rejected: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Entfernt einen Eintrag; die Reihenfolge der uebrigen bleibt. */
|
||||
export function removeEntry(entries: WorkingEntry[], id: string): WorkingEntry[] {
|
||||
return entries.filter((e) => e.id !== id);
|
||||
|
||||
Reference in New Issue
Block a user