fix(cert-manager): Reiter zeigen nur noch passende Auswertungen, Aufteilen schützt Schlüssel

- Ausgabe-Reiter bieten nur an, was zur aktuellen Dateiliste passt: nach dem Entfernen
  einer Datei oder einer fehlgeschlagenen Prüfung kommt Hinweis oder Fehler mit
  „Erneut versuchen“ statt der alten Auswertung (WR-02)
- Aufteilen: ein in der Datei geschützter Schlüssel bleibt geschützt (Passwort für die
  Ausgabe, auch in der ZIP); ohne Schutz nur ausdrücklich und mit sichtbarem Hinweis;
  Schlüssel heißen nach ihrem Zertifikat (WR-03, IN-05)
- Meldungen: tooManyItems, aiaNotAllowed, buildFailed (statt „Dateien konnten nicht
  geprüft werden“ beim Erstellen), protectionTooExpensive; Hinweis zum Passwort
  genauer (IN-03, IN-05)
- Download-Adresse wird erst nach einer Sekunde widerrufen (IN-04)
- Anleitungen und Änderungsliste: Grenzen, Aufteilen mit Schlüsselschutz, Umlaut-Passwörter

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 17:10:45 +02:00
parent ff2226dfbd
commit 4b87249a4a
23 changed files with 758 additions and 113 deletions
@@ -109,7 +109,8 @@ export type IgnoredReason =
| 'suspicious'
| 'zipTooLarge'
| 'tooManyEntries'
| 'unsupportedKey';
| 'unsupportedKey'
| 'protectionTooExpensive';
export interface IgnoredEntry {
file: number;
@@ -195,20 +196,29 @@ const KNOWN_ERROR_CODES = new Set([
'formatNotPossible',
'templateNeedsKey',
'tooLarge',
'tooManyItems',
'aiaMissing',
'aiaNotAllowed',
'aiaInternal',
'aiaNotIssuer',
'aiaUnreachable',
'aiaTooLarge',
]);
/** Schluessel unter `certManager.errors.<key>`: der Fehlercode, 413 ohne Code als tooLarge, sonst generic. */
export function certErrorKey(error: unknown): string {
/**
* Schluessel unter `certManager.errors.<key>`: der Fehlercode, 413 ohne Code als tooLarge, sonst
* `fallback`. Die Pruefung der Dateien nimmt `generic` („Dateien konnten nicht geprueft werden“),
* das Erstellen einer Ausgabe `buildFailed`, damit der Text zur Aktion passt (Review IN-03).
*/
export function certErrorKey(
error: unknown,
fallback: 'generic' | 'buildFailed' = 'generic',
): string {
if (error instanceof CertManagerRequestError) {
if (error.code && KNOWN_ERROR_CODES.has(error.code)) return error.code;
if (error.status === 413) return 'tooLarge';
}
return 'generic';
return fallback;
}
async function errorFromResponse(response: Response): Promise<CertManagerRequestError> {
@@ -282,5 +292,6 @@ export function downloadBase64(filename: string, content: string, mimeType: stri
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
// Nicht sofort widerrufen: manche Browser brechen den Download sonst ab (Review IN-04).
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
@@ -0,0 +1,38 @@
'use client';
import { useTranslations } from 'next-intl';
import type { AnalysisResult } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
/**
* Die Analyse, auf der ein Ausgabe-Reiter arbeiten darf: nur die, die zu den aktuellen Eintraegen
* passt (`workspace.fresh`). Ist sie veraltet (Datei entfernt, Passwort geaendert, neue Pruefung laeuft
* oder ist fehlgeschlagen), ist das Ergebnis null und der Reiter zeigt `AnalysisPending` statt Dateien.
*/
export function freshAnalysis(workspace: CertWorkspace): AnalysisResult | null {
return workspace.fresh ? workspace.analysis : null;
}
/** Statt des Reiterinhalts, solange keine passende Analyse vorliegt: Hinweis zur laufenden Pruefung oder Fehler mit Knopf. */
export function AnalysisPending({ workspace }: { workspace: CertWorkspace }) {
const t = useTranslations('certManager');
if (workspace.status === 'error') {
return (
<div role="alert" className="space-y-3 rounded-lg border border-border bg-muted p-3 text-sm">
<p className="text-foreground">{t(`errors.${workspace.errorKey ?? 'generic'}`)}</p>
<p className="text-muted-foreground">{t('workspace.stale')}</p>
<button type="button" onClick={workspace.retry} className="btn btn-secondary">
{t('files.retry')}
</button>
</div>
);
}
if (workspace.status === 'analyzing') {
return (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
);
}
return null;
}
@@ -66,6 +66,7 @@ function workspace(
analysis,
analysisIds,
status: 'idle',
fresh: analysis !== null,
errorKey: null,
addFiles: () => [],
addText: () => null,
@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl';
import type { CertItem, CsrItem, KeyItem } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { AnalysisPending, freshAnalysis } from './AnalysisPending';
import { ChainView } from './ChainView';
import { ItemCard } from './ItemCard';
@@ -16,15 +17,10 @@ interface AnalyzeTabProps {
*/
export function AnalyzeTab({ workspace }: AnalyzeTabProps) {
const t = useTranslations('certManager');
const { analysis, entries, analysisIds, status } = workspace;
const { entries, analysisIds } = workspace;
const analysis = freshAnalysis(workspace);
if (!analysis) {
return status === 'analyzing' ? (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
) : null;
}
if (!analysis) return <AnalysisPending workspace={workspace} />;
const certs = analysis.items.filter((i): i is CertItem => i.kind === 'certificate');
const keys = analysis.items.filter((i): i is KeyItem => i.kind === 'privateKey');
@@ -91,6 +91,7 @@ function workspace(items: AnyItem[]): CertWorkspace {
analysis,
analysisIds: [],
status: 'idle',
fresh: analysis !== null,
errorKey: null,
addFiles: () => [],
addText: () => null,
@@ -12,6 +12,7 @@ import {
type KeyItem,
} from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { AnalysisPending, freshAnalysis } from './AnalysisPending';
import { PasswordInput } from './PasswordInput';
interface ConvertTabProps {
@@ -56,18 +57,13 @@ export function ConvertTab({ workspace }: ConvertTabProps) {
const [busy, setBusy] = useState(false);
const [errorKey, setErrorKey] = useState<string | null>(null);
const items = workspace.analysis?.items ?? [];
const analysis = freshAnalysis(workspace);
const items = analysis?.items ?? [];
const certs = items.filter((i): i is CertItem => i.kind === 'certificate');
const keys = items.filter((i): i is KeyItem => i.kind === 'privateKey');
const csrs = items.filter((i): i is CsrItem => i.kind === 'csr');
if (!workspace.analysis) {
return workspace.status === 'analyzing' ? (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
) : null;
}
if (!analysis) return <AnalysisPending workspace={workspace} />;
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">{t('convert.nothingFound')}</p>;
}
@@ -109,7 +105,7 @@ export function ConvertTab({ workspace }: ConvertTabProps) {
const result = await buildOutput(request);
for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType);
} catch (error) {
setErrorKey(certErrorKey(error));
setErrorKey(certErrorKey(error, 'buildFailed'));
} finally {
setBusy(false);
}
@@ -142,6 +142,17 @@ interface FilesTabProps {
workspace: CertWorkspace;
}
/** Schluessel fuer die Liste abgewiesener Dateien: gleiche Namen mit gleichem Grund bekommen eine laufende Nummer. */
function withUniqueKeys(list: RejectedFile[]): { key: string; rejected: RejectedFile }[] {
const seen = new Map<string, number>();
return list.map((rejected) => {
const base = `${rejected.name}-${rejected.reason}`;
const n = (seen.get(base) ?? 0) + 1;
seen.set(base, n);
return { key: `${base}-${n}`, rejected };
});
}
/**
* Reiter „Dateien“: der einzige Ort zum Hinzufuegen (D-01). Jede Auswahl haengt an die Liste an;
* eine zweite Datei ersetzt nie die erste. Pro Eintrag steht, was Tessera darin erkannt hat.
@@ -282,8 +293,8 @@ export function FilesTab({ workspace }: FilesTabProps) {
<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>
<ul className="mt-1 space-y-0.5 text-muted-foreground">
{rejected.map((r) => (
<li key={`${r.name}-${r.reason}`}>
{withUniqueKeys(rejected).map(({ key, rejected: r }) => (
<li key={key}>
{t(`files.rejected.${r.reason}`, {
name: r.name,
max: MAX_ENTRIES,
@@ -75,6 +75,7 @@ function workspace(items: (CertItem | KeyItem)[], chains: ChainInfo[]): CertWork
analysis,
analysisIds: [],
status: 'idle',
fresh: analysis !== null,
errorKey: null,
addFiles: () => [],
addText: () => null,
@@ -11,6 +11,7 @@ import {
type KeyItem,
} from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { AnalysisPending, freshAnalysis } from './AnalysisPending';
import { ChainView } from './ChainView';
import { type PfxEncryption, PfxOptions, pfxPasswordReady } from './PfxOptions';
@@ -39,21 +40,12 @@ export function MergeTab({ workspace }: MergeTabProps) {
const [busy, setBusy] = useState<BuildContent | null>(null);
const [errorKey, setErrorKey] = useState<string | null>(null);
const certs = (workspace.analysis?.items ?? []).filter(
(i): i is CertItem => i.kind === 'certificate',
);
const keys = (workspace.analysis?.items ?? []).filter(
(i): i is KeyItem => i.kind === 'privateKey',
);
const chains = workspace.analysis?.chains ?? [];
const analysis = freshAnalysis(workspace);
const certs = (analysis?.items ?? []).filter((i): i is CertItem => i.kind === 'certificate');
const keys = (analysis?.items ?? []).filter((i): i is KeyItem => i.kind === 'privateKey');
const chains = analysis?.chains ?? [];
if (workspace.status === 'analyzing' && !workspace.analysis) {
return (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
);
}
if (!analysis) return <AnalysisPending workspace={workspace} />;
if (chains.length === 0) {
return <p className="text-sm text-muted-foreground">{t('merge.noCertificates')}</p>;
}
@@ -96,7 +88,7 @@ export function MergeTab({ workspace }: MergeTabProps) {
);
for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType);
} catch (error) {
setErrorKey(certErrorKey(error));
setErrorKey(certErrorKey(error, 'buildFailed'));
} finally {
setBusy(null);
}
@@ -1,4 +1,11 @@
import { cleanup, fireEvent, render as rtlRender, screen, within } from '@testing-library/react';
import {
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -68,6 +75,7 @@ function workspace(items: AnyItem[] | null): CertWorkspace {
analysis,
analysisIds: [],
status: 'idle',
fresh: analysis !== null,
errorKey: null,
addFiles: () => [],
addText: () => null,
@@ -106,10 +114,10 @@ describe('SplitTab', () => {
expect(within(rows[1]).getByText('Test_Inter.crt')).toBeInTheDocument();
});
it('Herunterladen gibt das PEM des Teils als Base64 an downloadBase64, ohne Aufruf der API', () => {
it('Herunterladen gibt das PEM des Teils als Base64 an downloadBase64, ohne Aufruf der API', async () => {
render(<SplitTab workspace={workspace(items)} />);
fireEvent.click(screen.getByRole('button', { name: '„www.example.test.crt“ herunterladen' }));
expect(mockDownload).toHaveBeenCalledTimes(1);
await waitFor(() => 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);
@@ -173,14 +181,16 @@ describe('SplitTab', () => {
];
render(<SplitTab workspace={workspace(dup)} />);
fireEvent.click(screen.getByRole('button', { name: 'Alle als ZIP herunterladen' }));
expect(fflate.zipSync).toHaveBeenCalledTimes(1);
await waitFor(() => 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');
// nicht sofort widerrufen (Review IN-04), sondern nach einer kurzen Wartezeit
expect(revoke).not.toHaveBeenCalled();
await waitFor(() => expect(revoke).toHaveBeenCalledWith('blob:x'), { timeout: 3000 });
expect(mockFetch).not.toHaveBeenCalled();
});
@@ -207,3 +217,170 @@ describe('dedupeNames', () => {
]);
});
});
function keyItem(over: Partial<Extract<AnyItem, { kind: 'privateKey' }>> = {}): AnyItem {
return {
id: 'k-1',
kind: 'privateKey',
sources: [],
pem: '-----BEGIN PRIVATE KEY-----\nGEHEIM\n-----END PRIVATE KEY-----\n',
baseName: 'schluessel',
keyType: 'RSA',
keyBits: 2048,
curve: null,
wasEncrypted: false,
certIds: [],
...over,
};
}
/** Review WR-03: ein geschuetzter Schluessel bleibt beim Aufteilen geschuetzt. */
describe('SplitTab: Schluessel', () => {
const protectedKey = keyItem({ wasEncrypted: true, certIds: ['c-leaf'] });
function jsonResponse(content: string) {
return {
ok: true,
json: async () => ({
files: [{ filename: 'x.key', content, mimeType: 'application/x-pem-file' }],
chainComplete: true,
missingIssuerCn: null,
}),
};
}
it('war ein Schluessel geschuetzt, ist der Schutz vorgewaehlt und die Schluesselknoepfe warten auf ein Passwort', () => {
render(<SplitTab workspace={workspace([items[0], protectedKey])} />);
expect(
screen.getByRole('checkbox', { name: 'Schlüssel mit einem Passwort schützen' }),
).toBeChecked();
expect(
screen.getByText(/Bitte geben Sie ein Passwort für die Schlüssel ein/),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: '„www.example.test.key“ herunterladen' }),
).toBeDisabled();
expect(screen.getByRole('button', { name: 'Alle als ZIP herunterladen' })).toBeDisabled();
// Zertifikate bleiben frei
expect(
screen.getByRole('button', { name: '„www.example.test.crt“ herunterladen' }),
).toBeEnabled();
});
it('mit Passwort kommt die Ausgabe verschluesselt von der API, das Klartext-PEM geht nicht in den Download', async () => {
mockFetch.mockResolvedValue(jsonResponse(btoa('ENCRYPTED-BY-API')));
render(<SplitTab workspace={workspace([items[0], protectedKey])} />);
fireEvent.change(screen.getByLabelText('Passwort für die Schlüssel'), {
target: { value: 'Neu-Pass' },
});
fireEvent.click(screen.getByRole('button', { name: '„www.example.test.key“ herunterladen' }));
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
const [name, content] = mockDownload.mock.calls[0];
expect(name).toBe('www.example.test.key');
expect(atob(content)).toBe('ENCRYPTED-BY-API');
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
expect(body).toMatchObject({ content: 'key', format: 'pkcs8', password: 'Neu-Pass' });
expect(atob(content)).not.toContain('GEHEIM');
});
it('ZIP: Schluessel verschluesselt aus der API, Zertifikate unveraendert', async () => {
const fflate = await import('fflate');
vi.stubGlobal('URL', {
...URL,
createObjectURL: vi.fn(() => 'blob:z'),
revokeObjectURL: vi.fn(),
});
const realCreate = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = realCreate(tag);
if (tag === 'a') el.click = vi.fn();
return el;
});
mockFetch.mockResolvedValue(jsonResponse(btoa('ENCRYPTED-BY-API')));
render(<SplitTab workspace={workspace([items[0], protectedKey])} />);
fireEvent.change(screen.getByLabelText('Passwort für die Schlüssel'), {
target: { value: 'Neu-Pass' },
});
fireEvent.click(screen.getByRole('button', { name: 'Alle als ZIP herunterladen' }));
await waitFor(() => expect(fflate.zipSync).toHaveBeenCalled());
const files = (
fflate.zipSync as unknown as { mock: { calls: [Record<string, Uint8Array>][] } }
).mock.calls.at(-1)?.[0] as Record<string, Uint8Array>;
expect(Object.keys(files)).toEqual(['www.example.test.crt', 'www.example.test.key']);
expect(new TextDecoder().decode(files['www.example.test.key'])).toBe('ENCRYPTED-BY-API');
});
it('Schutz abgeschaltet: sichtbarer Hinweis, Klartext-Schluessel ohne API-Aufruf', async () => {
render(<SplitTab workspace={workspace([items[0], protectedKey])} />);
fireEvent.click(
screen.getByRole('checkbox', { name: 'Schlüssel mit einem Passwort schützen' }),
);
expect(screen.getByRole('alert')).toHaveTextContent(/unverschlüsselt gespeichert/);
fireEvent.click(screen.getByRole('button', { name: '„www.example.test.key“ herunterladen' }));
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
expect(atob(mockDownload.mock.calls[0][1])).toContain('GEHEIM');
expect(mockFetch).not.toHaveBeenCalled();
});
it('ein Schluessel, der nie geschuetzt war, braucht kein Passwort und zeigt keine Warnung', async () => {
render(<SplitTab workspace={workspace([items[0], keyItem()])} />);
expect(
screen.getByRole('checkbox', { name: 'Schlüssel mit einem Passwort schützen' }),
).not.toBeChecked();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '„schluessel.key“ herunterladen' }));
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
});
it('mehrere Schluessel tragen verschiedene Dateinamen (nach ihrem Zertifikat, sonst nummeriert)', () => {
const two = [
items[0],
keyItem({ id: 'k-1', certIds: ['c-leaf'] }),
keyItem({ id: 'k-2' }),
keyItem({ id: 'k-3' }),
];
render(<SplitTab workspace={workspace(two)} />);
expect(screen.getByText('www.example.test.key')).toBeInTheDocument();
expect(screen.getByText('schluessel.key')).toBeInTheDocument();
expect(screen.getByText('schluessel-2.key')).toBeInTheDocument();
});
it('ein Fehler der API erscheint als Meldung, kein Download', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
render(<SplitTab workspace={workspace([protectedKey])} />);
fireEvent.change(screen.getByLabelText('Passwort für die Schlüssel'), {
target: { value: 'x' },
});
fireEvent.click(screen.getByRole('button', { name: '„schluessel.key“ herunterladen' }));
await waitFor(() =>
expect(screen.getByRole('alert')).toHaveTextContent(/nicht erstellt werden/),
);
expect(mockDownload).not.toHaveBeenCalled();
});
});
/** Review WR-02: ohne passende Analyse bieten die Reiter keine Dateien an. */
describe('SplitTab: veraltete Analyse', () => {
it('bietet keine Downloads an, solange die Analyse nicht zu den Dateien passt', () => {
const ws = { ...workspace(items), fresh: false, status: 'analyzing' as const };
render(<SplitTab workspace={ws} />);
expect(screen.queryByRole('button', { name: /herunterladen/ })).not.toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Dateien werden geprüft');
});
it('nach einem Fehler: Meldung und Erneut versuchen statt Dateien', () => {
const retry = vi.fn();
const ws = {
...workspace(items),
fresh: false,
status: 'error' as const,
errorKey: 'generic',
retry,
};
render(<SplitTab workspace={ws} />);
expect(screen.queryByRole('button', { name: /herunterladen/ })).not.toBeInTheDocument();
expect(screen.getByRole('alert')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Erneut versuchen' }));
expect(retry).toHaveBeenCalled();
});
});
@@ -2,10 +2,20 @@
import { zipSync } from 'fflate';
import { useTranslations } from 'next-intl';
import { type AnyItem, downloadBase64 } from '../actions';
import { useState } from 'react';
import {
type AnyItem,
buildOutput,
type CertItem,
certErrorKey,
downloadBase64,
type KeyItem,
} from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { sanitizeZipFilename } from '../zip-filename';
import { AnalysisPending, freshAnalysis } from './AnalysisPending';
import { ROLE_STYLES } from './FilesTab';
import { PasswordInput } from './PasswordInput';
interface SplitTabProps {
workspace: CertWorkspace;
@@ -13,10 +23,17 @@ interface SplitTabProps {
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}`;
/**
* Dateiname im natuerlichen Format des Teils; Zertifikat .crt, Schluessel .key, Anfrage .csr.
* Ein Schluessel heisst nach seinem Zertifikat, wenn es eines gibt (sonst tragen mehrere Schluessel
* denselben Namen), eine Anfrage nach ihrem Namen.
*/
function fileNameOf(item: AnyItem, certs: CertItem[]): string {
if (item.kind === 'privateKey') {
const owner = certs.find((c) => item.certIds.includes(c.id));
return `${owner?.baseName ?? item.baseName}.key`;
}
return `${item.baseName}.${item.kind === 'certificate' ? 'crt' : 'csr'}`;
}
function roleKey(item: AnyItem): string {
@@ -31,6 +48,13 @@ function toBase64(text: string): string {
return btoa(text);
}
function fromBase64(content: string): Uint8Array {
const bytes = atob(content);
const out = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) out[i] = bytes.charCodeAt(i);
return out;
}
/** Gleiche Namen bekommen -2, -3 vor der Endung (fflate ueberschriebe sie sonst stillschweigend). */
export function dedupeNames(names: string[]): string[] {
const used = new Set<string>();
@@ -46,12 +70,7 @@ export function dedupeNames(names: string[]): string[] {
});
}
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);
});
function downloadZipBytes(files: Record<string, Uint8Array>, zipFilename: string) {
const blob = new Blob([zipSync(files) as BlobPart], { type: 'application/zip' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
@@ -59,40 +78,138 @@ function downloadZip(items: AnyItem[], zipFilename: string) {
// Der uebersetzte Name kann Zeichen tragen, die Windows verbietet: auf das Zulaessige zurueckschneiden.
anchor.download = sanitizeZipFilename(zipFilename);
anchor.click();
URL.revokeObjectURL(url);
// Nicht sofort widerrufen: manche Browser brechen den Download sonst ab (Review IN-04).
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/**
* 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.
* oder alle zusammen als ZIP. Zertifikate und Anfragen kommen direkt aus den Daten der Analyse.
*
* Private Schluessel (Review WR-03): Die Analyse liefert sie entschluesselt. War ein Schluessel in der
* Datei durch ein Passwort geschuetzt, bleibt der Schutz hier standardmaessig bestehen: die Ausgabe
* wird mit einem Passwort verschluesselt (die API baut sie, das Passwort geht nur in diese Anfrage).
* Wer ausdruecklich ohne Schutz speichern will, schaltet ihn ab; ein sichtbarer Hinweis sagt dann, dass
* die Schluesseldateien unverschluesselt sind.
*/
export function SplitTab({ workspace }: SplitTabProps) {
const t = useTranslations('certManager');
const items = workspace.analysis?.items ?? [];
const [protectChoice, setProtectChoice] = useState<boolean | null>(null);
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [errorKey, setErrorKey] = useState<string | null>(null);
if (!workspace.analysis) {
return workspace.status === 'analyzing' ? (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
) : null;
}
const analysis = freshAnalysis(workspace);
if (!analysis) return <AnalysisPending workspace={workspace} />;
const items = analysis.items;
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">{t('split.nothingFound')}</p>;
}
const certs = items.filter((i): i is CertItem => i.kind === 'certificate');
const keys = items.filter((i): i is KeyItem => i.kind === 'privateKey');
const anyWasProtected = keys.some((k) => k.wasEncrypted);
const protect = protectChoice ?? anyWasProtected;
const keysBlocked = keys.length > 0 && protect && password === '';
const names = dedupeNames(items.map((item) => fileNameOf(item, certs)));
const nameOf = (item: AnyItem) => names[items.indexOf(item)];
/** Datei eines Teils als Base64: Schluessel mit Schutz kommen verschluesselt von der API. */
const contentOf = async (item: AnyItem): Promise<string> => {
if (item.kind === 'privateKey' && protect) {
const result = await buildOutput({
content: 'key',
format: 'pkcs8',
keyPem: item.pem,
password,
});
return result.files[0].content;
}
return toBase64(item.pem);
};
const run = async (action: () => Promise<void>) => {
setBusy(true);
setErrorKey(null);
try {
await action();
} catch (error) {
setErrorKey(certErrorKey(error, 'buildFailed'));
} finally {
setBusy(false);
}
};
const downloadOne = (item: AnyItem) =>
run(async () => {
downloadBase64(nameOf(item), await contentOf(item), MIME_PEM);
});
const downloadAll = () =>
run(async () => {
const contents = await Promise.all(items.map(contentOf));
const files: Record<string, Uint8Array> = {};
items.forEach((item, i) => {
files[nameOf(item)] = fromBase64(contents[i]);
});
downloadZipBytes(files, t('split.zipFilename'));
});
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('split.intro')}</p>
{keys.length > 0 && (
<div className="space-y-3 rounded-lg border border-border p-3">
<p className="text-xs text-muted-foreground">{t('split.keyNote')}</p>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={protect}
onChange={(e) => {
setProtectChoice(e.target.checked);
if (!e.target.checked) setPassword('');
}}
/>
{t('split.protectKeys')}
</label>
{protect && (
<PasswordInput label={t('split.keyPassword')} value={password} onChange={setPassword} />
)}
{protect && password === '' && (
<p role="status" className="text-xs text-muted-foreground">
{t('split.passwordNeeded')}
</p>
)}
{!protect && anyWasProtected && (
<p
role="alert"
className="rounded border border-border bg-muted p-2 text-xs font-medium text-foreground"
>
{t('split.keysWereProtected')}
</p>
)}
</div>
)}
{items.length > 1 && (
<button
type="button"
onClick={() => downloadZip(items, t('split.zipFilename'))}
disabled={busy || keysBlocked}
onClick={downloadAll}
className="btn btn-secondary"
>
{t('split.downloadAll')}
{busy ? t('split.busy') : t('split.downloadAll')}
</button>
)}
{errorKey && (
<p
role="alert"
className="rounded-lg border border-border bg-muted p-3 text-sm text-foreground"
>
{t(`errors.${errorKey}`)}
</p>
)}
<ul className="space-y-2">
{items.map((item) => (
<li
@@ -106,13 +223,19 @@ export function SplitTab({ workspace }: SplitTabProps) {
{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>
<span className="break-all text-xs text-muted-foreground">{nameOf(item)}</span>
{item.kind === 'privateKey' && (
<span className="text-xs text-muted-foreground">
{protect ? t('split.keyProtected') : t('split.keyPlain')}
</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"
disabled={busy || (item.kind === 'privateKey' && keysBlocked)}
onClick={() => downloadOne(item)}
aria-label={t('split.downloadFor', { name: nameOf(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 disabled:opacity-50"
>
{t('split.download')}
</button>
@@ -93,6 +93,7 @@ function workspace(items: (CertItem | KeyItem)[], chains: ChainInfo[]): CertWork
analysis,
analysisIds: [],
status: 'idle',
fresh: analysis !== null,
errorKey: null,
addFiles: () => [],
addText: () => null,
@@ -14,6 +14,7 @@ import {
} from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { sanitizeZipFilename } from '../zip-filename';
import { AnalysisPending, freshAnalysis } from './AnalysisPending';
import { ChainView } from './ChainView';
import { type PfxEncryption, PfxOptions, pfxPasswordReady } from './PfxOptions';
@@ -68,21 +69,12 @@ export function TemplatesTab({ workspace }: TemplatesTabProps) {
const [shown, setShown] = useState<{ id: TemplateId; snippet: string } | null>(null);
const [copied, setCopied] = useState(false);
const certs = (workspace.analysis?.items ?? []).filter(
(i): i is CertItem => i.kind === 'certificate',
);
const keys = (workspace.analysis?.items ?? []).filter(
(i): i is KeyItem => i.kind === 'privateKey',
);
const chains = workspace.analysis?.chains ?? [];
const analysis = freshAnalysis(workspace);
const certs = (analysis?.items ?? []).filter((i): i is CertItem => i.kind === 'certificate');
const keys = (analysis?.items ?? []).filter((i): i is KeyItem => i.kind === 'privateKey');
const chains = analysis?.chains ?? [];
if (workspace.status === 'analyzing' && !workspace.analysis) {
return (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
);
}
if (!analysis) return <AnalysisPending workspace={workspace} />;
if (chains.length === 0) {
return <p className="text-sm text-muted-foreground">{t('merge.noCertificates')}</p>;
}
@@ -148,7 +140,7 @@ export function TemplatesTab({ workspace }: TemplatesTabProps) {
}
if (snippet) setShown({ id: template.id, snippet });
} catch (error) {
setErrorKey(certErrorKey(error));
setErrorKey(certErrorKey(error, 'buildFailed'));
} finally {
setBusy(null);
}
@@ -0,0 +1,138 @@
import { cleanup, fireEvent, render as rtlRender, screen } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
import type { AnalysisResult, CertItem, KeyItem } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { AnalyzeTab } from './AnalyzeTab';
import { ConvertTab } from './ConvertTab';
import { MergeTab } from './MergeTab';
import { SplitTab } from './SplitTab';
import { TemplatesTab } from './TemplatesTab';
/**
* Review WR-02: nach dem Entfernen einer Datei oder einer fehlgeschlagenen Pruefung darf kein
* Ausgabe-Reiter mehr Zertifikate oder Schluessel der alten Analyse zum Herunterladen anbieten.
*/
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
afterEach(cleanup);
const leaf: CertItem = {
id: 'c-leaf',
kind: 'certificate',
role: 'end-entity',
sources: [{ file: 0, path: 'a.pem' }],
pem: 'PEM',
baseName: 'www',
cn: 'www.example.test',
organization: '',
issuerCn: 'CA',
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: false,
selfSigned: false,
aiaIssuerUrls: [],
keyId: 'k-1',
csrIds: [],
};
const key: KeyItem = {
id: 'k-1',
kind: 'privateKey',
sources: [{ file: 1, path: 'k.pem' }],
pem: 'KEYPEM',
baseName: 'schluessel',
keyType: 'RSA',
keyBits: 2048,
curve: null,
wasEncrypted: false,
certIds: ['c-leaf'],
};
const analysis: AnalysisResult = {
items: [leaf, key],
chains: [
{
headId: 'c-leaf',
path: ['c-leaf'],
rootId: null,
complete: false,
gap: null,
alternatives: 0,
},
],
locked: [],
ignored: [],
};
function stale(over: Partial<CertWorkspace>): CertWorkspace {
return {
entries: [],
analysis,
analysisIds: [],
status: 'analyzing',
fresh: false,
errorKey: null,
addFiles: () => [],
addText: () => null,
addFetched: () => 'added',
setPassword: () => {},
remove: () => {},
clear: () => {},
retry: () => {},
...over,
};
}
const TABS = [
['Analysieren', AnalyzeTab],
['Aufteilen', SplitTab],
['Zusammenführen', MergeTab],
['Konvertieren', ConvertTab],
['Vorlagen', TemplatesTab],
] as const;
describe.each(TABS)('%s mit veralteter Analyse', (_name, Tab) => {
it('laufende Pruefung: nur der Hinweis, keine Downloads, keine Zertifikate', () => {
render(<Tab workspace={stale({ status: 'analyzing' })} />);
expect(screen.getByRole('status')).toHaveTextContent('Dateien werden geprüft');
expect(
screen.queryByRole('button', { name: /herunterladen|Download/i }),
).not.toBeInTheDocument();
expect(screen.queryByText('www.example.test')).not.toBeInTheDocument();
});
it('fehlgeschlagene Pruefung: Meldung mit Erneut versuchen, keine Downloads', () => {
const retry = vi.fn();
render(<Tab workspace={stale({ status: 'error', errorKey: 'generic', retry })} />);
expect(screen.getByRole('alert')).toHaveTextContent('nicht geprüft werden');
expect(
screen.queryByRole('button', { name: /herunterladen|Download/i }),
).not.toBeInTheDocument();
expect(screen.queryByText('www.example.test')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Erneut versuchen' }));
expect(retry).toHaveBeenCalledTimes(1);
});
it('passende Analyse: der Reiter zeigt wieder seinen Inhalt', () => {
render(<Tab workspace={stale({ status: 'idle', fresh: true })} />);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,94 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CertManagerRequestError } from './actions';
import { useCertWorkspace } from './use-cert-workspace';
const mockAnalyze = vi.fn();
vi.mock('./actions', async (importOriginal) => {
const actual = await importOriginal<typeof import('./actions')>();
return { ...actual, analyzeWorkingSet: (...args: unknown[]) => mockAnalyze(...args) };
});
const EMPTY = { items: [], chains: [], locked: [], ignored: [] };
const file = (name: string) => new File([name], name, { lastModified: 1 });
beforeEach(() => {
mockAnalyze.mockReset();
});
/** Review WR-02: eine Analyse zaehlt nur, solange sie zu den aktuellen Eintraegen passt. */
describe('useCertWorkspace: fresh', () => {
async function withTwoFiles() {
mockAnalyze.mockResolvedValue(EMPTY);
const hook = renderHook(() => useCertWorkspace());
act(() => {
hook.result.current.addFiles([file('a.pem'), file('b.pem')]);
});
await waitFor(() => expect(hook.result.current.fresh).toBe(true));
return hook;
}
it('ist wahr, wenn die Analyse zu den Eintraegen passt', async () => {
const { result } = await withTwoFiles();
expect(result.current.status).toBe('idle');
expect(result.current.analysis).not.toBeNull();
});
it('ist falsch ohne Eintraege', () => {
const { result } = renderHook(() => useCertWorkspace());
expect(result.current.fresh).toBe(false);
});
it('wird beim Entfernen sofort falsch, noch bevor die neue Antwort da ist', async () => {
const { result } = await withTwoFiles();
let release: (value: typeof EMPTY) => void = () => {};
mockAnalyze.mockReturnValue(new Promise((resolve) => (release = resolve)));
act(() => {
result.current.remove(result.current.entries[0].id);
});
// Die alte Analyse ist noch da, gilt aber nicht mehr
expect(result.current.analysis).not.toBeNull();
expect(result.current.fresh).toBe(false);
expect(result.current.status).toBe('analyzing');
await act(async () => {
release(EMPTY);
});
await waitFor(() => expect(result.current.fresh).toBe(true));
expect(result.current.entries).toHaveLength(1);
});
it('bleibt falsch, wenn die neue Pruefung scheitert (die alte Analyse wird nicht weiter angeboten)', async () => {
const { result } = await withTwoFiles();
mockAnalyze.mockRejectedValue(new CertManagerRequestError(500, null));
act(() => {
result.current.remove(result.current.entries[0].id);
});
await waitFor(() => expect(result.current.status).toBe('error'));
expect(result.current.analysis).not.toBeNull(); // Dateien-Reiter zeigt die letzte bekannte Analyse
expect(result.current.fresh).toBe(false);
});
it('Erneut versuchen stellt fresh nach Erfolg wieder her', async () => {
const { result } = await withTwoFiles();
mockAnalyze.mockRejectedValueOnce(new CertManagerRequestError(500, null));
act(() => {
result.current.remove(result.current.entries[0].id);
});
await waitFor(() => expect(result.current.status).toBe('error'));
mockAnalyze.mockResolvedValue(EMPTY);
act(() => {
result.current.retry();
});
await waitFor(() => expect(result.current.fresh).toBe(true));
});
it('eine Passwortaenderung macht die Analyse bis zur neuen Antwort ungueltig', async () => {
const { result } = await withTwoFiles();
mockAnalyze.mockReturnValue(new Promise(() => {}));
act(() => {
result.current.setPassword(result.current.entries[0].id, 'neu');
});
expect(result.current.fresh).toBe(false);
});
});
@@ -23,6 +23,13 @@ export interface CertWorkspace {
/** Eintrags-Kennungen zum Zeitpunkt dieser Analyse: Quelle `file` zeigt auf diesen Index */
analysisIds: string[];
status: AnalysisStatus;
/**
* Wahr, nur wenn `analysis` genau zu den aktuellen Eintraegen gehoert: keine Analyse laeuft, die
* letzte war erfolgreich und kennt dieselben Eintraege in derselben Reihenfolge. Die Ausgabe-
* Reiter bieten nur dann Dateien an (sonst blieben Zertifikate oder Schluessel einer gerade
* entfernten Datei herunterladbar, Review WR-02).
*/
fresh: boolean;
/** Schluessel unter certManager.errors.* */
errorKey: string | null;
addFiles: (files: File[]) => RejectedFile[];
@@ -147,11 +154,18 @@ export function useCertWorkspace(): CertWorkspace {
runAnalysis(entriesRef.current);
}, [runAnalysis]);
const fresh =
analysis !== null &&
status === 'idle' &&
analysisIds.length === entries.length &&
analysisIds.every((id, i) => id === entries[i].id);
return {
entries,
analysis,
analysisIds,
status,
fresh,
errorKey,
addFiles,
addText,