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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user