feat(cert-manager): Reiter „Dateien“ mit mehreren Dateien und ein Parser für RSA und EC – Durchstich
- Ein gemeinsamer Arbeitsbereich im Browser: jede Auswahl hängt an, eine zweite Datei ersetzt nie die erste - Ein Parser (node:crypto) für RSA- und EC-Zertifikate als PEM, DER und TRUSTED CERTIFICATE, Rolle je Datei - Neue Route analyze (mehrere Dateien, 30 x 5 MiB, zusammen 20 MiB); alte Routen, Service und Web-Reiter entfernt - Test-PKI-Fixtures (RSA und EC, Ketten, Schlüssel, CSR, PFX) mit Erzeugungsskript Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,110 +1,190 @@
|
||||
export const API_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SplitResponse — mirrors CertManagerService.SplitResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CertRole = 'root' | 'intermediate' | 'end-entity';
|
||||
|
||||
export interface SplitEntry {
|
||||
index: number;
|
||||
filename: string;
|
||||
/** PEM content base64-encoded (one BEGIN CERTIFICATE block per entry) */
|
||||
content: string;
|
||||
subject: { cn: string };
|
||||
validity: { notAfter: string };
|
||||
certRole: CertRole;
|
||||
}
|
||||
|
||||
export interface SplitResponse {
|
||||
count: number;
|
||||
certs: SplitEntry[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CertDetails — mirrors the API response shape from CertManagerService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CertDetails {
|
||||
subject: { cn: string; o: string; ou: string; c: string };
|
||||
issuer: { cn: string; o: string; c: string };
|
||||
validity: { notBefore: string; notAfter: string; isExpired: boolean; daysLeft: number };
|
||||
san: string[];
|
||||
keyType: string;
|
||||
keyBits: number;
|
||||
serialNumber: string;
|
||||
signatureAlgorithm: string;
|
||||
fingerprint: { sha1: string; sha256: string };
|
||||
pemPreview: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// inspectCertAction — calls POST /modules/cert-manager/parse
|
||||
// ---------------------------------------------------------------------------
|
||||
import { toFormData, type WorkingEntry } from './working-set';
|
||||
|
||||
/**
|
||||
* Call POST /modules/cert-manager/parse.
|
||||
* - If pemText is present → JSON body { pemText, password }
|
||||
* - Otherwise → multipart FormData with file + optional password
|
||||
* Aufrufe der Zertifikat-Manager-API (quick-261009-ikt). Die Typen spiegeln 1:1
|
||||
* apps/api/src/cert-manager/cert-types.ts.
|
||||
*
|
||||
* T-09-02: password is never placed in URL, logged, or echoed.
|
||||
* T-09-04: credentials:'include' ensures JWT cookie is sent.
|
||||
* Passwoerter und Schluessel stehen nur im Koerper der Anfrage, nie in einer Adresse
|
||||
* und nie in einer Konsolenausgabe. Der Browser sendet das Sitzungs-Cookie mit.
|
||||
*/
|
||||
export async function inspectCertAction(input: {
|
||||
file?: File | null;
|
||||
pemText?: string;
|
||||
password?: string;
|
||||
}): Promise<CertDetails> {
|
||||
const { file, pemText, password } = input;
|
||||
|
||||
if (pemText) {
|
||||
// JSON path — content-type must be application/json (not multipart)
|
||||
const response = await fetch(`${API_URL}/modules/cert-manager/parse`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pemText, password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`${response.status} ${body}`.trim());
|
||||
}
|
||||
return response.json() as Promise<CertDetails>;
|
||||
} else {
|
||||
// Multipart path — let browser set Content-Type with boundary
|
||||
const form = new FormData();
|
||||
if (file) form.append('file', file);
|
||||
if (password) form.append('password', password);
|
||||
return postForm('parse', form) as Promise<CertDetails>;
|
||||
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export interface ItemSource {
|
||||
file: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type CertRole = 'end-entity' | 'intermediate' | 'root';
|
||||
|
||||
export interface CertItem {
|
||||
id: string;
|
||||
kind: 'certificate';
|
||||
role: CertRole;
|
||||
sources: ItemSource[];
|
||||
pem: string;
|
||||
baseName: string;
|
||||
cn: string;
|
||||
organization: string;
|
||||
issuerCn: string;
|
||||
issuerOrganization: string;
|
||||
notBefore: string;
|
||||
notAfter: string;
|
||||
isExpired: boolean;
|
||||
daysLeft: number;
|
||||
san: string[];
|
||||
keyType: string;
|
||||
keyBits: number | null;
|
||||
curve: string | null;
|
||||
serialNumber: string;
|
||||
sha256: string;
|
||||
sha1: string;
|
||||
isCa: boolean;
|
||||
selfSigned: boolean;
|
||||
aiaIssuerUrls: string[];
|
||||
keyId: string | null;
|
||||
csrIds: string[];
|
||||
}
|
||||
|
||||
export interface KeyItem {
|
||||
id: string;
|
||||
kind: 'privateKey';
|
||||
sources: ItemSource[];
|
||||
pem: string;
|
||||
baseName: string;
|
||||
keyType: string;
|
||||
keyBits: number | null;
|
||||
curve: string | null;
|
||||
wasEncrypted: boolean;
|
||||
certIds: string[];
|
||||
}
|
||||
|
||||
export interface CsrItem {
|
||||
id: string;
|
||||
kind: 'csr';
|
||||
sources: ItemSource[];
|
||||
pem: string;
|
||||
baseName: string;
|
||||
cn: string;
|
||||
organization: string;
|
||||
san: string[];
|
||||
keyType: string;
|
||||
keyBits: number | null;
|
||||
curve: string | null;
|
||||
keyId: string | null;
|
||||
certIds: string[];
|
||||
}
|
||||
|
||||
export type AnyItem = CertItem | KeyItem | CsrItem;
|
||||
|
||||
export interface ChainGap {
|
||||
certId: string;
|
||||
kind: 'afterLeaf' | 'afterCa';
|
||||
missingIssuerCn: string;
|
||||
aiaUrls: string[];
|
||||
}
|
||||
|
||||
export interface ChainInfo {
|
||||
headId: string;
|
||||
path: string[];
|
||||
rootId: string | null;
|
||||
complete: boolean;
|
||||
gap: ChainGap | null;
|
||||
alternatives: number;
|
||||
}
|
||||
|
||||
export interface LockedEntry {
|
||||
file: number;
|
||||
path: string;
|
||||
container: 'pkcs12' | 'privateKey';
|
||||
reason: 'passwordNeeded' | 'passwordWrong';
|
||||
}
|
||||
|
||||
export type IgnoredReason =
|
||||
| 'unknown'
|
||||
| 'nestedZip'
|
||||
| 'encryptedZip'
|
||||
| 'brokenZip'
|
||||
| 'tooLarge'
|
||||
| 'suspicious'
|
||||
| 'zipTooLarge'
|
||||
| 'tooManyEntries'
|
||||
| 'unsupportedKey';
|
||||
|
||||
export interface IgnoredEntry {
|
||||
file: number;
|
||||
path: string;
|
||||
reason: IgnoredReason;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
items: AnyItem[];
|
||||
chains: ChainInfo[];
|
||||
locked: LockedEntry[];
|
||||
ignored: IgnoredEntry[];
|
||||
}
|
||||
|
||||
/** Fehler der API mit Statuscode und (falls vorhanden) dem Fehlercode aus `{ code, message }`. */
|
||||
export class CertManagerRequestError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string | null,
|
||||
) {
|
||||
super(`cert-manager request failed (${status}${code ? ` ${code}` : ''})`);
|
||||
this.name = 'CertManagerRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// splitCertsAction — calls POST /modules/cert-manager/split
|
||||
// ---------------------------------------------------------------------------
|
||||
const KNOWN_ERROR_CODES = new Set([
|
||||
'invalidInput',
|
||||
'notACertificate',
|
||||
'noChain',
|
||||
'keyMissing',
|
||||
'keyMismatch',
|
||||
'passwordRequired',
|
||||
'formatNotPossible',
|
||||
'templateNeedsKey',
|
||||
'tooLarge',
|
||||
'aiaMissing',
|
||||
'aiaInternal',
|
||||
'aiaNotIssuer',
|
||||
'aiaUnreachable',
|
||||
'aiaTooLarge',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Upload a fullchain PEM or P7B bundle to POST /modules/cert-manager/split.
|
||||
* Returns a SplitResponse with one entry per certificate in the bundle.
|
||||
*
|
||||
* T-09-04: credentials:'include' via postForm ensures JWT cookie is sent.
|
||||
*/
|
||||
export async function splitCertsAction(file: File): Promise<SplitResponse> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return postForm('split', form) as Promise<SplitResponse>;
|
||||
/** Schluessel unter `certManager.errors.<key>`: der Fehlercode, 413 ohne Code als tooLarge, sonst generic. */
|
||||
export function certErrorKey(error: unknown): 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a base64-encoded file as a browser download.
|
||||
* T-09-02: password is never placed in URL, console.log, or filename.
|
||||
*/
|
||||
export function downloadBase64(
|
||||
filename: string,
|
||||
content: string,
|
||||
mimeType: string,
|
||||
): void {
|
||||
async function errorFromResponse(response: Response): Promise<CertManagerRequestError> {
|
||||
let code: string | null = null;
|
||||
try {
|
||||
const body = (await response.json()) as { code?: unknown };
|
||||
if (typeof body.code === 'string') code = body.code;
|
||||
} catch {
|
||||
// Antwort ohne JSON (z. B. 413 vom Proxy): nur der Status zaehlt
|
||||
}
|
||||
return new CertManagerRequestError(response.status, code);
|
||||
}
|
||||
|
||||
/** POST /modules/cert-manager/analyze: der ganze Arbeitsbereich in Reihenfolge, Antwort mit allen erkannten Teilen. */
|
||||
export async function analyzeWorkingSet(entries: WorkingEntry[]): Promise<AnalysisResult> {
|
||||
const response = await fetch(`${API_URL}/modules/cert-manager/analyze`, {
|
||||
method: 'POST',
|
||||
body: toFormData(entries),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw await errorFromResponse(response);
|
||||
return (await response.json()) as AnalysisResult;
|
||||
}
|
||||
|
||||
/** Laedt eine Base64-Datei als Browser-Download herunter. Der Dateiname enthaelt nie ein Passwort. */
|
||||
export function downloadBase64(filename: string, content: string, mimeType: string): void {
|
||||
const bytes = atob(content);
|
||||
const byteArray = new Uint8Array(bytes.length);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
@@ -118,171 +198,3 @@ export function downloadBase64(
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileResponse — mirrors CertManagerService.FileResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FileResponse {
|
||||
/** Suggested download filename, e.g. "converted.der" */
|
||||
filename: string;
|
||||
/** Base64-encoded file content */
|
||||
content: string;
|
||||
/** MIME type for the download */
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mergeCertsAction — calls POST /modules/cert-manager/merge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Upload multiple certificate files to POST /modules/cert-manager/merge.
|
||||
* The API requires at least 2 files (enforced by controller).
|
||||
* Returns a FileResponse — the caller should call downloadBase64(filename, content, mimeType).
|
||||
*
|
||||
* T-09-02: password is never placed in URL, logged, or echoed.
|
||||
* T-09-03: maxCount 20 per FilesInterceptor (enforced server-side).
|
||||
* T-09-04: credentials:'include' via postForm ensures JWT cookie is sent.
|
||||
*/
|
||||
export async function mergeCertsAction(
|
||||
files: File[],
|
||||
outputFormat: string,
|
||||
password?: string,
|
||||
): Promise<FileResponse> {
|
||||
const form = new FormData();
|
||||
files.forEach((file) => {
|
||||
form.append('files', file);
|
||||
});
|
||||
form.append('outputFormat', outputFormat);
|
||||
if (password) form.append('password', password);
|
||||
return postForm('merge', form) as Promise<FileResponse>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// convertCertAction — calls POST /modules/cert-manager/convert
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Upload a certificate and convert it to the chosen target format.
|
||||
* Returns a FileResponse — the caller should call downloadBase64(filename, content, mimeType).
|
||||
*
|
||||
* T-09-02: password is never placed in URL, logged, or echoed.
|
||||
* T-09-04: credentials:'include' via postForm ensures JWT cookie is sent.
|
||||
*/
|
||||
export async function convertCertAction(
|
||||
input: { file?: File | null; pemText?: string; password?: string },
|
||||
targetFormat: string,
|
||||
): Promise<FileResponse> {
|
||||
const { file, pemText, password } = input;
|
||||
const form = new FormData();
|
||||
if (file) form.append('file', file);
|
||||
if (pemText) form.append('pemText', pemText);
|
||||
if (password) form.append('password', password);
|
||||
form.append('targetFormat', targetFormat);
|
||||
return postForm('convert', form) as Promise<FileResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a FormData payload to a cert-manager endpoint.
|
||||
* T-09-04: credentials:'include' ensures JWT cookie is sent for ModuleGuard.
|
||||
* No manual Content-Type header — browser sets multipart boundary automatically.
|
||||
*/
|
||||
export async function postForm(
|
||||
endpoint: string,
|
||||
form: FormData,
|
||||
): Promise<unknown> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/modules/cert-manager/${endpoint}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: form,
|
||||
credentials: 'include',
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`${response.status} ${body}`.trim());
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zertifikatspaket (quick-261001-l4q) — spiegelt apps/api/src/cert-manager/cert-bundle.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BundleItemKind = 'certificate' | 'privateKey' | 'csr';
|
||||
export type BundleExportFormat =
|
||||
| 'crt'
|
||||
| 'cer'
|
||||
| 'fullchain'
|
||||
| 'p7b'
|
||||
| 'pfx'
|
||||
| 'key'
|
||||
| 'key-rsa'
|
||||
| 'key-der'
|
||||
| 'csr'
|
||||
| 'csr-der';
|
||||
|
||||
export interface BundleItem {
|
||||
id: string;
|
||||
kind: BundleItemKind;
|
||||
role?: CertRole;
|
||||
sources: string[];
|
||||
pem: string;
|
||||
baseName: string;
|
||||
cn: string;
|
||||
organization: string;
|
||||
issuerCn: string;
|
||||
notBefore: string | null;
|
||||
notAfter: string | null;
|
||||
isExpired: boolean | null;
|
||||
daysLeft: number | null;
|
||||
san: string[];
|
||||
keyType: string;
|
||||
keyBits: number;
|
||||
serialNumber: string;
|
||||
sha256: string;
|
||||
matchId: string | null;
|
||||
chainIds: string[];
|
||||
formats: BundleExportFormat[];
|
||||
}
|
||||
|
||||
export interface BundleAnalysis {
|
||||
items: BundleItem[];
|
||||
locked: string[];
|
||||
ignored: string[];
|
||||
}
|
||||
|
||||
/** Mehrere Dateien (auch ZIP) analysieren — POST /modules/cert-manager/analyze. */
|
||||
export async function analyzeBundleAction(files: File[], password?: string): Promise<BundleAnalysis> {
|
||||
const form = new FormData();
|
||||
for (const file of files) form.append('files', file);
|
||||
if (password) form.append('password', password);
|
||||
return postForm('analyze', form) as Promise<BundleAnalysis>;
|
||||
}
|
||||
|
||||
/** Ein Teil des Pakets in ein Format bringen — POST /modules/cert-manager/export (JSON). */
|
||||
export async function exportBundleItemAction(input: {
|
||||
kind: BundleItemKind;
|
||||
pem: string;
|
||||
format: BundleExportFormat;
|
||||
baseName: string;
|
||||
chain?: string[];
|
||||
keyPem?: string;
|
||||
password?: string;
|
||||
}): Promise<FileResponse> {
|
||||
const response = await fetch(`${API_URL}/modules/cert-manager/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`${response.status} ${body}`.trim());
|
||||
}
|
||||
return response.json() as Promise<FileResponse>;
|
||||
}
|
||||
|
||||
@@ -1,438 +1,70 @@
|
||||
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } 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 CertManagerPage from './page';
|
||||
|
||||
// Mock next-intl — provide certManager namespace keys
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string) => {
|
||||
const certManager: Record<string, string> = {
|
||||
'title': 'Zertifikat-Manager',
|
||||
'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.',
|
||||
'tabs.overview': 'Uebersicht',
|
||||
'tabs.inspect': 'Analysieren',
|
||||
'tabs.split': 'Aufteilen',
|
||||
'tabs.merge': 'Zusammenfuehren',
|
||||
'tabs.convert': 'Konvertieren',
|
||||
'dropZone.placeholder': 'Datei hierher ziehen oder klicken',
|
||||
'dropZone.formats': '.pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c',
|
||||
'paste.placeholder': 'PEM-Inhalt einfuegen (-----BEGIN ...)',
|
||||
'password.label': 'Passwort (PFX/P12)',
|
||||
'or': 'oder',
|
||||
'actions.inspect': 'Analysieren',
|
||||
'actions.split': 'Aufteilen',
|
||||
'actions.merge': 'Zusammenfuehren',
|
||||
'actions.convert': 'Konvertieren',
|
||||
'actions.download': 'Herunterladen',
|
||||
'actions.processing': 'Wird verarbeitet...',
|
||||
'emptyState.inspect': 'Kein Zertifikat geladen.',
|
||||
'emptyState.inspectBody': 'Lade eine Datei hoch oder fuege PEM-Text ein.',
|
||||
'emptyState.split': 'Keine Datei geladen.',
|
||||
'emptyState.splitBody': 'Lade eine Fullchain- oder P7B-Datei hoch.',
|
||||
'emptyState.merge': 'Keine Zertifikate ausgewaehlt.',
|
||||
'emptyState.mergeBody': 'Lade mindestens zwei Dateien hoch.',
|
||||
'emptyState.convert': 'Keine Datei geladen.',
|
||||
'emptyState.convertBody': 'Lade eine Datei hoch und waehle ein Ausgabeformat.',
|
||||
'error.generic': 'Verarbeitung fehlgeschlagen. Pruefe das Dateiformat oder das Passwort.',
|
||||
'error.wrongPassword': 'Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.',
|
||||
'error.unknownFormat': 'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
|
||||
};
|
||||
if (ns === 'certManager') {
|
||||
return certManager[key] ?? key;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
const mockAnalyze = vi.fn();
|
||||
|
||||
vi.mock('./actions', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./actions')>();
|
||||
return { ...actual, analyzeWorkingSet: (...args: unknown[]) => mockAnalyze(...args) };
|
||||
});
|
||||
|
||||
vi.mock('@/components/layout/page-header', () => ({
|
||||
PageHeader: ({ title, description }: { title: string; description?: string }) => (
|
||||
<header>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
</header>
|
||||
),
|
||||
}));
|
||||
|
||||
import CertManagerPage from './page';
|
||||
import { InspectTab } from './components/InspectTab';
|
||||
import { SplitTab } from './components/SplitTab';
|
||||
import { MergeTab } from './components/MergeTab';
|
||||
import { ConvertTab } from './components/ConvertTab';
|
||||
import * as actions from './actions';
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
beforeEach(() => {
|
||||
mockAnalyze.mockReset();
|
||||
mockAnalyze.mockResolvedValue({ items: [], chains: [], locked: [], ignored: [] });
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('CertManagerPage shell', () => {
|
||||
it('renders the page title and description', () => {
|
||||
describe('CertManagerPage', () => {
|
||||
it('zeigt Titel und den einen Reiter „Dateien“ als aktiven Reiter', () => {
|
||||
render(<CertManagerPage />);
|
||||
expect(screen.getByText('Zertifikat-Manager')).toBeInTheDocument();
|
||||
expect(screen.getByText('Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument();
|
||||
const tabs = screen.getAllByRole('button').filter((b) => b.hasAttribute('aria-current'));
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]).toHaveTextContent('Dateien');
|
||||
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
|
||||
// Startansicht ist der Dateien-Reiter
|
||||
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all four tab labels', () => {
|
||||
it('der Reiter zeigt die Anzahl der Dateien', async () => {
|
||||
render(<CertManagerPage />);
|
||||
// "Analysieren" appears as both tab label (nav) and InspectTab action button
|
||||
// getAllByText avoids the "multiple elements" error
|
||||
expect(screen.getAllByText('Analysieren').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Aufteilen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Zusammenfuehren')).toBeInTheDocument();
|
||||
expect(screen.getByText('Konvertieren')).toBeInTheDocument();
|
||||
const input = screen.getByTestId('cert-file-input');
|
||||
fireEvent.change(input, {
|
||||
target: {
|
||||
files: [
|
||||
new File(['a'], 'a.pem', { lastModified: 1 }),
|
||||
new File(['b'], 'b.pem', { lastModified: 2 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Dateien (2)' })).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT render the password field on initial render (no PFX file selected)', () => {
|
||||
it('ruft die Analyse nicht ohne Dateien auf', () => {
|
||||
render(<CertManagerPage />);
|
||||
// Password field label should not be in document when no PFX is selected
|
||||
expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('startet mit der Uebersicht ohne Einzeldatei-Eingabe (quick-261001-l4q)', () => {
|
||||
render(<CertManagerPage />);
|
||||
expect(screen.getByText('dropTitle')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Datei hierher ziehen oder klicken')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Inspect tab empty state when Inspect tab is active', () => {
|
||||
render(<CertManagerPage />);
|
||||
fireEvent.click(screen.getAllByText('Analysieren')[0]);
|
||||
expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Split tab empty state when Split tab is active', () => {
|
||||
render(<CertManagerPage />);
|
||||
// Click the "Aufteilen" tab button (second one in nav)
|
||||
const tabButtons = screen.getAllByText('Aufteilen');
|
||||
// The tab nav button is the one that triggers tab change
|
||||
fireEvent.click(tabButtons[0]);
|
||||
expect(screen.getByText('Keine Datei geladen.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lade eine Fullchain- oder P7B-Datei hoch.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Merge tab empty state when Merge tab is active', () => {
|
||||
render(<CertManagerPage />);
|
||||
fireEvent.click(screen.getByText('Zusammenfuehren'));
|
||||
expect(screen.getByText('Keine Zertifikate ausgewaehlt.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lade mindestens zwei Dateien hoch.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Convert tab empty state when Convert tab is active', () => {
|
||||
render(<CertManagerPage />);
|
||||
// "Konvertieren" appears once as tab label
|
||||
fireEvent.click(screen.getByText('Konvertieren'));
|
||||
expect(screen.getAllByText('Keine Datei geladen.').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Lade eine Datei hoch und waehle ein Ausgabeformat.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InspectTab — unit tests for the Analysieren result grid and error state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_CERT_DETAILS: actions.CertDetails = {
|
||||
subject: { cn: 'example.com', o: 'Acme Corp', ou: 'IT', c: 'DE' },
|
||||
issuer: { cn: 'Example CA', o: 'CA Corp', c: 'US' },
|
||||
validity: {
|
||||
notBefore: '2025-01-01T00:00:00.000Z',
|
||||
notAfter: '2026-01-01T00:00:00.000Z',
|
||||
isExpired: false,
|
||||
daysLeft: 180,
|
||||
},
|
||||
san: ['example.com', 'www.example.com'],
|
||||
keyType: 'RSA',
|
||||
keyBits: 2048,
|
||||
serialNumber: '01',
|
||||
signatureAlgorithm: 'sha256WithRSAEncryption',
|
||||
fingerprint: {
|
||||
sha1: 'AA:BB:CC:DD:EE',
|
||||
sha256: 'FF:00:11:22:33:44:55:66:77:88',
|
||||
},
|
||||
pemPreview: '-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----',
|
||||
};
|
||||
|
||||
describe('InspectTab', () => {
|
||||
it('renders CertDetails grid with subject CN and SHA-256 after successful inspect', async () => {
|
||||
vi.spyOn(actions, 'inspectCertAction').mockResolvedValue(MOCK_CERT_DETAILS);
|
||||
|
||||
render(
|
||||
<InspectTab
|
||||
file={null}
|
||||
pemText="-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"
|
||||
password=""
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click the Analysieren button
|
||||
fireEvent.click(screen.getByText('Analysieren'));
|
||||
|
||||
// Wait for the async action to complete and result grid to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// SHA-256 fingerprint is rendered
|
||||
expect(screen.getByText('FF:00:11:22:33:44:55:66:77:88')).toBeInTheDocument();
|
||||
|
||||
// Empty state is no longer shown
|
||||
expect(screen.queryByText('Kein Zertifikat geladen.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows text-destructive error when inspectCertAction rejects', async () => {
|
||||
vi.spyOn(actions, 'inspectCertAction').mockRejectedValue(
|
||||
new Error('wrong password'),
|
||||
);
|
||||
|
||||
render(
|
||||
<InspectTab
|
||||
file={null}
|
||||
pemText="test pem"
|
||||
password="wrong"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Analysieren'));
|
||||
|
||||
// Wait for the error message to appear
|
||||
await waitFor(() => {
|
||||
// The wrong-password error message should be rendered
|
||||
expect(
|
||||
screen.getByText('Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Error element should have the destructive class
|
||||
const errorEl = screen.getByText(
|
||||
'Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.',
|
||||
);
|
||||
expect(errorEl.className).toContain('text-destructive');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SplitTab — unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_SPLIT_RESPONSE: actions.SplitResponse = {
|
||||
count: 2,
|
||||
certs: [
|
||||
{
|
||||
index: 0,
|
||||
filename: 'cert-1.pem',
|
||||
content: btoa('-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----'),
|
||||
subject: { cn: 'cert1.example.com' },
|
||||
validity: { notAfter: '2027-01-01T00:00:00.000Z' },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
filename: 'cert-2.pem',
|
||||
content: btoa('-----BEGIN CERTIFICATE-----\nYYY\n-----END CERTIFICATE-----'),
|
||||
subject: { cn: 'cert2.example.com' },
|
||||
validity: { notAfter: '2027-06-01T00:00:00.000Z' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('SplitTab', () => {
|
||||
it('renders two download buttons after clicking Aufteilen with mocked splitCertsAction', async () => {
|
||||
vi.spyOn(actions, 'splitCertsAction').mockResolvedValue(MOCK_SPLIT_RESPONSE);
|
||||
|
||||
const testFile = new File(['cert data'], 'fullchain.pem', {
|
||||
type: 'application/x-pem-file',
|
||||
});
|
||||
|
||||
render(<SplitTab file={testFile} pemText="" password="" />);
|
||||
|
||||
// Initial empty state
|
||||
expect(screen.getByText('Keine Datei geladen.')).toBeInTheDocument();
|
||||
|
||||
// Click the Aufteilen button (only one in this isolated render)
|
||||
fireEvent.click(screen.getByText('Aufteilen'));
|
||||
|
||||
// Wait for the async action to resolve and per-cert list to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Herunterladen').length).toBe(2);
|
||||
});
|
||||
|
||||
// Empty state is gone
|
||||
expect(screen.queryByText('Keine Datei geladen.')).not.toBeInTheDocument();
|
||||
|
||||
// Both cert CN values are rendered
|
||||
expect(screen.getByText('cert1.example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('cert2.example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows text-destructive error when splitCertsAction rejects', async () => {
|
||||
vi.spyOn(actions, 'splitCertsAction').mockRejectedValue(
|
||||
new Error('invalid format or corrupted file'),
|
||||
);
|
||||
|
||||
const testFile = new File(['garbage'], 'bad.pem', { type: 'text/plain' });
|
||||
|
||||
render(<SplitTab file={testFile} pemText="" password="" />);
|
||||
|
||||
fireEvent.click(screen.getByText('Aufteilen'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const errorEl = screen.getByText(
|
||||
'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
|
||||
);
|
||||
expect(errorEl.className).toContain('text-destructive');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConvertTab — unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_FILE_RESPONSE: actions.FileResponse = {
|
||||
filename: 'converted.der',
|
||||
content: btoa('fake der bytes'),
|
||||
mimeType: 'application/x-x509-ca-cert',
|
||||
};
|
||||
|
||||
describe('ConvertTab', () => {
|
||||
it('renders format selector with pem, der, p7b options', () => {
|
||||
render(<ConvertTab file={null} pemText="" password="" />);
|
||||
const select = screen.getByRole('combobox');
|
||||
const optionValues = Array.from(select.querySelectorAll('option')).map(
|
||||
(o) => (o as HTMLOptionElement).value,
|
||||
);
|
||||
expect(optionValues).toContain('pem');
|
||||
expect(optionValues).toContain('der');
|
||||
expect(optionValues).toContain('p7b');
|
||||
});
|
||||
|
||||
it('calls downloadBase64 after clicking Konvertieren when convertCertAction resolves', async () => {
|
||||
vi.spyOn(actions, 'convertCertAction').mockResolvedValue(MOCK_FILE_RESPONSE);
|
||||
const downloadSpy = vi.spyOn(actions, 'downloadBase64').mockImplementation(() => {});
|
||||
|
||||
const testFile = new File(['cert data'], 'cert.pem', {
|
||||
type: 'application/x-pem-file',
|
||||
});
|
||||
|
||||
render(<ConvertTab file={testFile} pemText="" password="" />);
|
||||
|
||||
// Click the Konvertieren button
|
||||
fireEvent.click(screen.getByText('Konvertieren'));
|
||||
|
||||
// Wait for async action to resolve and downloadBase64 to be called
|
||||
await waitFor(() => {
|
||||
expect(downloadSpy).toHaveBeenCalledWith(
|
||||
MOCK_FILE_RESPONSE.filename,
|
||||
MOCK_FILE_RESPONSE.content,
|
||||
MOCK_FILE_RESPONSE.mimeType,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows text-destructive error when convertCertAction rejects with format error', async () => {
|
||||
vi.spyOn(actions, 'convertCertAction').mockRejectedValue(
|
||||
new Error('unknown format or invalid certificate'),
|
||||
);
|
||||
|
||||
const testFile = new File(['garbage'], 'bad.bin', { type: 'application/octet-stream' });
|
||||
|
||||
render(<ConvertTab file={testFile} pemText="" password="" />);
|
||||
|
||||
fireEvent.click(screen.getByText('Konvertieren'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const errorEl = screen.getByText(
|
||||
'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
|
||||
);
|
||||
expect(errorEl.className).toContain('text-destructive');
|
||||
});
|
||||
|
||||
it('renders pem, der, p7b, and pfx options in the format selector', () => {
|
||||
render(<ConvertTab file={null} pemText="" password="" />);
|
||||
const select = screen.getByRole('combobox');
|
||||
const optionValues = Array.from(select.querySelectorAll('option')).map(
|
||||
(o) => (o as HTMLOptionElement).value,
|
||||
);
|
||||
expect(optionValues).toContain('pem');
|
||||
expect(optionValues).toContain('der');
|
||||
expect(optionValues).toContain('p7b');
|
||||
expect(optionValues).toContain('pfx');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MergeTab — unit tests (CERT-03, CERT-05)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_MERGE_PEM_RESPONSE: actions.FileResponse = {
|
||||
filename: 'chain.pem',
|
||||
content: btoa('-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nYYY\n-----END CERTIFICATE-----'),
|
||||
mimeType: 'application/x-pem-file',
|
||||
};
|
||||
|
||||
describe('MergeTab', () => {
|
||||
it('disables Zusammenfuehren action button when fewer than 2 files are selected', () => {
|
||||
render(<MergeTab password="" />);
|
||||
|
||||
// The action button should be disabled (no files yet)
|
||||
const actionBtn = screen.getByTestId('merge-action-btn');
|
||||
expect(actionBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Zusammenfuehren button when 2 or more files are selected', async () => {
|
||||
render(<MergeTab password="" />);
|
||||
|
||||
const fileInput = screen.getByTestId('merge-file-input');
|
||||
const file1 = new File(['cert1'], 'cert1.pem', { type: 'application/x-pem-file' });
|
||||
const file2 = new File(['cert2'], 'cert2.pem', { type: 'application/x-pem-file' });
|
||||
|
||||
fireEvent.change(fileInput, { target: { files: [file1, file2] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('merge-action-btn')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows password field in page.tsx when PFX output is selected in MergeTab', async () => {
|
||||
// Render the full page so the shared PasswordField behaviour can be tested
|
||||
render(<CertManagerPage />);
|
||||
|
||||
// Navigate to merge tab — only one "Zusammenfuehren" visible before tab switch
|
||||
fireEvent.click(screen.getByText('Zusammenfuehren'));
|
||||
|
||||
// Change format selector to pfx
|
||||
const formatSelect = screen.getByDisplayValue('PEM-Kette');
|
||||
fireEvent.change(formatSelect, { target: { value: 'pfx' } });
|
||||
|
||||
// The shared PasswordField should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Passwort (PFX/P12)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls downloadBase64 after successful merge when >= 2 files selected', async () => {
|
||||
vi.spyOn(actions, 'mergeCertsAction').mockResolvedValue(MOCK_MERGE_PEM_RESPONSE);
|
||||
const downloadSpy = vi.spyOn(actions, 'downloadBase64').mockImplementation(() => {});
|
||||
|
||||
render(<MergeTab password="" />);
|
||||
|
||||
const fileInput = screen.getByTestId('merge-file-input');
|
||||
const file1 = new File(['cert1'], 'cert1.pem', { type: 'application/x-pem-file' });
|
||||
const file2 = new File(['cert2'], 'cert2.pem', { type: 'application/x-pem-file' });
|
||||
|
||||
fireEvent.change(fileInput, { target: { files: [file1, file2] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('merge-action-btn')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('merge-action-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(downloadSpy).toHaveBeenCalledWith(
|
||||
MOCK_MERGE_PEM_RESPONSE.filename,
|
||||
MOCK_MERGE_PEM_RESPONSE.content,
|
||||
MOCK_MERGE_PEM_RESPONSE.mimeType,
|
||||
);
|
||||
});
|
||||
expect(mockAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { convertCertAction, downloadBase64 } from '../actions';
|
||||
|
||||
interface ConvertTabProps {
|
||||
file: File | null;
|
||||
pemText: string;
|
||||
password: string;
|
||||
/** Callback to notify page.tsx when target format changes (for shared PasswordField visibility) */
|
||||
onTargetFormatChange?: (format: string) => void;
|
||||
}
|
||||
|
||||
type TargetFormat = 'pem' | 'der' | 'p7b' | 'pfx';
|
||||
|
||||
export function ConvertTab({ file, pemText, password, onTargetFormatChange }: ConvertTabProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [targetFormat, setTargetFormat] = useState<TargetFormat>('pem');
|
||||
|
||||
function handleFormatChange(format: TargetFormat) {
|
||||
setTargetFormat(format);
|
||||
onTargetFormatChange?.(format);
|
||||
}
|
||||
|
||||
async function handleConvert() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await convertCertAction({ file, pemText, password }, targetFormat);
|
||||
downloadBase64(response.filename, response.content, response.mimeType);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : '';
|
||||
if (msg.includes('password') || msg.includes('passwort')) {
|
||||
setError(t('error.wrongPassword'));
|
||||
} else if (
|
||||
msg.includes('format') ||
|
||||
msg.includes('invalid') ||
|
||||
msg.includes('unknown')
|
||||
) {
|
||||
setError(t('error.unknownFormat'));
|
||||
} else {
|
||||
setError(t('error.generic'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canConvert = Boolean(file || pemText);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Target format selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor="targetFormat" className="text-sm font-medium text-foreground">
|
||||
Ausgabeformat
|
||||
</label>
|
||||
<select
|
||||
id="targetFormat"
|
||||
value={targetFormat}
|
||||
onChange={(e) => handleFormatChange(e.target.value as TargetFormat)}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="pem">PEM</option>
|
||||
<option value="der">DER</option>
|
||||
<option value="p7b">P7B</option>
|
||||
<option value="pfx">PFX / PKCS12</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Primary action button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConvert}
|
||||
disabled={loading || !canConvert}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? t('actions.processing') : t('actions.convert')}
|
||||
</button>
|
||||
|
||||
{/* Error state */}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{/* Empty state — shown when no error */}
|
||||
{!error && (
|
||||
<div className="text-center py-8 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{t('emptyState.convert')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('emptyState.convertBody')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DropZoneProps {
|
||||
onFile: (file: File) => void;
|
||||
accept: string;
|
||||
currentFile: File | null;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function DropZone({ onFile, accept, currentFile, onClear }: DropZoneProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onFile(file);
|
||||
}
|
||||
// Allow re-selecting the same file
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
onFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{/* a11y/useSemanticElements: kein role="button"-div mehr -- die
|
||||
eigentliche Flaeche ist jetzt ein echtes <button> und traegt darum
|
||||
auch Klick UND Drag-Ereignisse (ein <div> ohne Rolle mit
|
||||
Ereignis-Handlern waere ein statisches, nicht interaktives Element
|
||||
und faellt unter die zurueckgestellten Regeln noStaticElement-
|
||||
Interactions/noNoninteractiveElementInteractions). Die "Entfernen"-
|
||||
Schaltflaeche liegt darum als Geschwister daneben, nicht mehr
|
||||
verschachtelt in der Flaechen-Schaltflaeche -- ein <button> darf
|
||||
kein weiteres <button> enthalten (ungueltiges Verschachteln). */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
|
||||
isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
{currentFile ? (
|
||||
<span className="text-sm text-foreground">
|
||||
{currentFile.name}{' '}
|
||||
<span className="text-muted-foreground">
|
||||
({(currentFile.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('dropZone.placeholder')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('dropZone.formats')}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{currentFile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onClear(); }}
|
||||
className="absolute right-2 top-2 rounded border border-border bg-card px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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';
|
||||
import de from '@/messages/de.json';
|
||||
import type { AnalysisResult, CertItem } from '../actions';
|
||||
import { CertManagerRequestError } from '../actions';
|
||||
import { useCertWorkspace } from '../use-cert-workspace';
|
||||
import { FilesTab } from './FilesTab';
|
||||
|
||||
const mockAnalyze = vi.fn();
|
||||
|
||||
vi.mock('../actions', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../actions')>();
|
||||
return { ...actual, analyzeWorkingSet: (...args: unknown[]) => mockAnalyze(...args) };
|
||||
});
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
const workspace = useCertWorkspace();
|
||||
return <FilesTab workspace={workspace} />;
|
||||
}
|
||||
|
||||
function cert(id: string, cn: string, role: CertItem['role'], files: number[], names: string[]) {
|
||||
return {
|
||||
id,
|
||||
kind: 'certificate',
|
||||
role,
|
||||
sources: files.map((file, i) => ({ file, path: names[i] })),
|
||||
pem: '',
|
||||
baseName: cn,
|
||||
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: [],
|
||||
} satisfies CertItem;
|
||||
}
|
||||
|
||||
/** Fake-Server: erkennt anhand des Dateinamens, was in der Datei „steckt“. */
|
||||
function fakeAnalyze(entries: { file: File }[]): AnalysisResult {
|
||||
const items: CertItem[] = [];
|
||||
const ignored: AnalysisResult['ignored'] = [];
|
||||
entries.forEach((entry, index) => {
|
||||
const name = entry.file.name;
|
||||
if (name.startsWith('leaf'))
|
||||
items.push(cert('c-leaf', 'www.example.test', 'end-entity', [index], [name]));
|
||||
else if (name.startsWith('inter'))
|
||||
items.push(cert('c-inter', 'Test Inter', 'intermediate', [index], [name]));
|
||||
else ignored.push({ file: index, path: name, reason: 'unknown' });
|
||||
});
|
||||
return { items, chains: [], locked: [], ignored };
|
||||
}
|
||||
|
||||
function makeFile(name: string, lastModified = 1) {
|
||||
return new File(['x'], name, { lastModified });
|
||||
}
|
||||
|
||||
function selectFiles(files: File[]) {
|
||||
const input = screen.getByTestId('cert-file-input') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockAnalyze.mockReset();
|
||||
mockAnalyze.mockImplementation(async (entries: { file: File }[]) => fakeAnalyze(entries));
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('FilesTab', () => {
|
||||
it('zwei Auswahlen nacheinander: beide bleiben, die Analyse bekommt beide (Fehlerbild des Nutzers)', async () => {
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('leaf.pem')]);
|
||||
await screen.findByText('www.example.test');
|
||||
selectFiles([makeFile('inter.pem')]);
|
||||
await screen.findByText('Test Inter');
|
||||
expect(screen.getByText('leaf.pem')).toBeInTheDocument();
|
||||
expect(screen.getByText('inter.pem')).toBeInTheDocument();
|
||||
const lastCall = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[];
|
||||
expect(lastCall.map((e) => e.file.name)).toEqual(['leaf.pem', 'inter.pem']);
|
||||
});
|
||||
|
||||
it('eine Auswahl mit zwei Dateien haengt beide an', async () => {
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('leaf.pem'), makeFile('inter.pem')]);
|
||||
await screen.findByText('Test Inter');
|
||||
expect(screen.getAllByRole('listitem').length).toBeGreaterThanOrEqual(2);
|
||||
expect(mockAnalyze).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Ablegen von Dateien haengt an', async () => {
|
||||
render(<Harness />);
|
||||
const zone = screen.getByRole('button', { name: /Dateien hierher ziehen/ });
|
||||
fireEvent.drop(zone, { dataTransfer: { files: [makeFile('leaf.pem')] } });
|
||||
await screen.findByText('www.example.test');
|
||||
fireEvent.drop(zone, { dataTransfer: { files: [makeFile('inter.pem')] } });
|
||||
await screen.findByText('Test Inter');
|
||||
expect(screen.getByText('leaf.pem')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Entfernen analysiert den Rest neu, die letzte Datei leert ohne Aufruf', async () => {
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('leaf.pem'), makeFile('inter.pem')]);
|
||||
await screen.findByText('Test Inter');
|
||||
fireEvent.click(screen.getByRole('button', { name: '„leaf.pem“ entfernen' }));
|
||||
await waitFor(() => expect(screen.queryByText('leaf.pem')).not.toBeInTheDocument());
|
||||
await waitFor(() => {
|
||||
const last = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[];
|
||||
expect(last.map((e) => e.file.name)).toEqual(['inter.pem']);
|
||||
});
|
||||
const callsBefore = mockAnalyze.mock.calls.length;
|
||||
fireEvent.click(screen.getByRole('button', { name: '„inter.pem“ entfernen' }));
|
||||
await screen.findByText(/Noch keine Dateien/);
|
||||
expect(mockAnalyze).toHaveBeenCalledTimes(callsBefore);
|
||||
expect(screen.queryByText('Test Inter')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('zeigt je Eintrag Rolle und Name der erkannten Zertifikate', async () => {
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('leaf.pem')]);
|
||||
const item = (await screen.findByText('www.example.test')).closest('li') as HTMLElement;
|
||||
expect(within(item).getByText('Serverzertifikat')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('eine nicht erkannte Datei nennt den Grund', async () => {
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('readme.txt')]);
|
||||
await screen.findByText('Darin wurde kein Zertifikat erkannt.');
|
||||
});
|
||||
|
||||
it('abgelehnte Dateien werden mit Grund aufgelistet', async () => {
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('leaf.pem', 5)]);
|
||||
await screen.findByText('www.example.test');
|
||||
selectFiles([makeFile('leaf.pem', 5)]);
|
||||
expect(await screen.findByText('„leaf.pem“ ist schon in der Liste.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('der Hinweis, dass die Liste nur in diesem Browserfenster liegt, ist immer sichtbar', () => {
|
||||
render(<Harness />);
|
||||
expect(screen.getByText(/nur in diesem Browserfenster/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('zeigt waehrend der Analyse einen Statustext', async () => {
|
||||
let resolve: (value: AnalysisResult) => void = () => {};
|
||||
mockAnalyze.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AnalysisResult>((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('leaf.pem')]);
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('Dateien werden geprüft');
|
||||
resolve({ items: [], chains: [], locked: [], ignored: [] });
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('bei einem Analysefehler: Text zum Fehlercode und „Erneut versuchen“ wiederholt den Aufruf', async () => {
|
||||
mockAnalyze.mockRejectedValueOnce(new CertManagerRequestError(413, 'tooLarge'));
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('leaf.pem')]);
|
||||
expect(await screen.findByText(/zusammen zu groß/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Erneut versuchen' }));
|
||||
await screen.findByText('www.example.test');
|
||||
expect(mockAnalyze).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('verwirft die Antwort einer aelteren Anfrage', async () => {
|
||||
const resolvers: ((value: AnalysisResult) => void)[] = [];
|
||||
mockAnalyze.mockImplementation(
|
||||
() =>
|
||||
new Promise<AnalysisResult>((r) => {
|
||||
resolvers.push(r);
|
||||
}),
|
||||
);
|
||||
render(<Harness />);
|
||||
selectFiles([makeFile('leaf.pem')]);
|
||||
selectFiles([makeFile('inter.pem')]);
|
||||
await waitFor(() => expect(resolvers).toHaveLength(2));
|
||||
const second = fakeAnalyze([{ file: makeFile('leaf.pem') }, { file: makeFile('inter.pem') }]);
|
||||
resolvers[1](second);
|
||||
await screen.findByText('Test Inter');
|
||||
resolvers[0](fakeAnalyze([{ file: makeFile('readme.txt') }]));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.getByText('Test Inter')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Darin wurde kein Zertifikat erkannt.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import type { AnyItem } from '../actions';
|
||||
import type { CertWorkspace } from '../use-cert-workspace';
|
||||
import {
|
||||
formatBytes,
|
||||
MAX_ENTRIES,
|
||||
MAX_FILE_BYTES,
|
||||
MAX_TOTAL_BYTES,
|
||||
type RejectedFile,
|
||||
type WorkingEntry,
|
||||
} from '../working-set';
|
||||
|
||||
/** Farben der Rollenmarken (wie in der frueheren Uebersicht). */
|
||||
export const ROLE_STYLES: Record<string, string> = {
|
||||
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
intermediate: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
root: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
privateKey: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300',
|
||||
csr: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
const ACCEPT = [
|
||||
'.zip',
|
||||
'.pem',
|
||||
'.crt',
|
||||
'.cer',
|
||||
'.cert',
|
||||
'.der',
|
||||
'.ca-bundle',
|
||||
'.chain',
|
||||
'.p7b',
|
||||
'.p7c',
|
||||
'.pfx',
|
||||
'.p12',
|
||||
'.csr',
|
||||
'.req',
|
||||
'.txt',
|
||||
'.key',
|
||||
].join(',');
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface FilesTabProps {
|
||||
workspace: CertWorkspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function FilesTab({ workspace }: FilesTabProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [rejected, setRejected] = useState<RejectedFile[]>([]);
|
||||
const { entries, analysis, analysisIds, status, errorKey } = workspace;
|
||||
|
||||
const add = (list: FileList | File[] | null | undefined) => {
|
||||
const files = Array.from(list ?? []);
|
||||
if (files.length === 0) return;
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
add(e.dataTransfer?.files);
|
||||
}}
|
||||
className={`flex w-full flex-col items-center gap-1 rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
dragging ? 'border-primary-strong bg-muted' : 'border-border hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">{t('files.dropTitle')}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('files.dropHint')}</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
className="hidden"
|
||||
data-testid="cert-file-input"
|
||||
tabIndex={-1}
|
||||
onChange={(e) => {
|
||||
add(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('files.limits', {
|
||||
maxFiles: MAX_ENTRIES,
|
||||
maxFile: MAX_FILE_BYTES / MB,
|
||||
maxTotal: MAX_TOTAL_BYTES / MB,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{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>
|
||||
<ul className="mt-1 space-y-0.5 text-muted-foreground">
|
||||
{rejected.map((r) => (
|
||||
<li key={`${r.name}-${r.reason}`}>
|
||||
{t(`files.rejected.${r.reason}`, {
|
||||
name: r.name,
|
||||
max: MAX_ENTRIES,
|
||||
size: (r.reason === 'tooLarge' ? MAX_FILE_BYTES : MAX_TOTAL_BYTES) / MB,
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="rounded-lg bg-muted p-3 text-xs text-muted-foreground">
|
||||
{t('files.memoryNote')}
|
||||
</p>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('files.empty')}</p>
|
||||
) : (
|
||||
<section className="space-y-3" aria-label={t('files.listTitle', { count: entries.length })}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{t('files.listTitle', { count: entries.length })}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={workspace.clear}
|
||||
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.removeAll')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === 'analyzing' && (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{t('files.analyzing')}
|
||||
</p>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div role="alert" className="rounded-lg border border-border bg-muted p-3 text-sm">
|
||||
<p className="text-foreground">{t(`errors.${errorKey ?? 'generic'}`)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={workspace.retry}
|
||||
className="mt-2 rounded border border-border px-3 py-1 text-foreground hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{t('files.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{entries.map((entry) => {
|
||||
const items = itemsOf(entry);
|
||||
const ignored = ignoredOf(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>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => workspace.remove(entry.id)}
|
||||
aria-label={t('files.remove', { name: entry.label })}
|
||||
className="shrink-0 rounded border border-border px-2 py-1 text-xs text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{ignored.map((i) => (
|
||||
<li key={`${i.path}-${i.reason}`} className="text-sm text-muted-foreground">
|
||||
{t(`files.ignored.${i.reason}`)}
|
||||
</li>
|
||||
))}
|
||||
{!analysed && status === 'analyzing' && (
|
||||
<li className="text-sm text-muted-foreground">{t('files.checking')}</li>
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { type CertDetails, inspectCertAction } from '../actions';
|
||||
|
||||
interface InspectTabProps {
|
||||
file: File | null;
|
||||
pemText: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function InspectTab({ file, pemText, password }: InspectTabProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<CertDetails | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleInspect() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const details = await inspectCertAction({ file, pemText, password });
|
||||
setResult(details);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : '';
|
||||
if (msg.includes('password') || msg.includes('passwort')) {
|
||||
setError(t('error.wrongPassword'));
|
||||
} else if (msg.includes('unknown') || msg.includes('format') || msg.includes('invalid')) {
|
||||
setError(t('error.unknownFormat'));
|
||||
} else {
|
||||
setError(t('error.generic'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canInspect = Boolean(file || pemText);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Primary action button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInspect}
|
||||
disabled={loading || !canInspect}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? t('actions.processing') : t('actions.inspect')}
|
||||
</button>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Empty state — shown when no result and no error */}
|
||||
{!result && !error && (
|
||||
<div className="text-center py-8 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{t('emptyState.inspect')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('emptyState.inspectBody')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result grid — key-value layout per UI-SPEC (grid-cols-2 gap-2 text-sm) */}
|
||||
{result && (
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<span className="font-medium text-muted-foreground">Subject CN</span>
|
||||
<span className="break-all">{result.subject.cn}</span>
|
||||
|
||||
{result.subject.o && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">Subject O</span>
|
||||
<span>{result.subject.o}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result.subject.ou && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">Subject OU</span>
|
||||
<span>{result.subject.ou}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result.subject.c && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">Subject C</span>
|
||||
<span>{result.subject.c}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="font-medium text-muted-foreground">Issuer CN</span>
|
||||
<span className="break-all">{result.issuer.cn}</span>
|
||||
|
||||
{result.issuer.o && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">Issuer O</span>
|
||||
<span>{result.issuer.o}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="font-medium text-muted-foreground">Not Before</span>
|
||||
<span>{result.validity.notBefore}</span>
|
||||
|
||||
<span className="font-medium text-muted-foreground">Not After</span>
|
||||
<span>{result.validity.notAfter}</span>
|
||||
|
||||
<span className="font-medium text-muted-foreground">Expired</span>
|
||||
<span>{result.validity.isExpired ? 'Yes' : 'No'}</span>
|
||||
|
||||
<span className="font-medium text-muted-foreground">Days Left</span>
|
||||
<span>{result.validity.daysLeft}</span>
|
||||
|
||||
<span className="font-medium text-muted-foreground">Key Type</span>
|
||||
<span>{result.keyType}</span>
|
||||
|
||||
<span className="font-medium text-muted-foreground">Key Bits</span>
|
||||
<span>{result.keyBits}</span>
|
||||
|
||||
<span className="font-medium text-muted-foreground">Serial Number</span>
|
||||
<span className="break-all">{result.serialNumber}</span>
|
||||
|
||||
<span className="font-medium text-muted-foreground">Signature Algorithm</span>
|
||||
<span>{result.signatureAlgorithm}</span>
|
||||
|
||||
<span className="font-medium text-muted-foreground">SHA-1</span>
|
||||
<span className="break-all font-mono text-xs">{result.fingerprint.sha1}</span>
|
||||
|
||||
<span className="font-medium text-muted-foreground">SHA-256</span>
|
||||
<span className="break-all font-mono text-xs">{result.fingerprint.sha256}</span>
|
||||
|
||||
{result.san.length > 0 && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">SANs</span>
|
||||
<span className="break-all">{result.san.join(', ')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock next-intl — passthrough t(key) => key (same pattern as stopwatch-widget.test.tsx)
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// Mock ../actions — MergeTab only needs mergeCertsAction and downloadBase64 to exist;
|
||||
// this test never triggers the merge action itself, only file-list management.
|
||||
vi.mock('../actions', () => ({
|
||||
mergeCertsAction: vi.fn(),
|
||||
downloadBase64: vi.fn(),
|
||||
}));
|
||||
|
||||
import { MergeTab } from './MergeTab';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('MergeTab — Dateiliste', () => {
|
||||
it('quick-260921-iwr: Entfernen der mittleren Datei laesst genau die erste und dritte Datei uebrig, in dieser Reihenfolge, mit ihren eigenen Namen (belegt den Positionsschluessel bei einer schrumpfenden Liste)', async () => {
|
||||
render(<MergeTab password="" />);
|
||||
|
||||
const fileInput = screen.getByTestId('merge-file-input');
|
||||
const file1 = new File(['cert1'], 'erste.pem', { type: 'application/x-pem-file' });
|
||||
const file2 = new File(['cert2'], 'mittlere.pem', { type: 'application/x-pem-file' });
|
||||
const file3 = new File(['cert3'], 'dritte.pem', { type: 'application/x-pem-file' });
|
||||
|
||||
fireEvent.change(fileInput, { target: { files: [file1, file2, file3] } });
|
||||
|
||||
// Alle drei Dateien sind zunaechst gelistet
|
||||
expect(screen.getByText('erste.pem')).toBeInTheDocument();
|
||||
expect(screen.getByText('mittlere.pem')).toBeInTheDocument();
|
||||
expect(screen.getByText('dritte.pem')).toBeInTheDocument();
|
||||
|
||||
// Die mittlere Datei ueber ihren Entfernen-Knopf loeschen (aria-label enthaelt den Dateinamen)
|
||||
const removeMiddleBtn = screen.getByLabelText('Remove mittlere.pem');
|
||||
fireEvent.click(removeMiddleBtn);
|
||||
|
||||
// "mittlere.pem" ist verschwunden
|
||||
expect(screen.queryByText('mittlere.pem')).not.toBeInTheDocument();
|
||||
|
||||
// Die erste und dritte Datei stehen weiterhin mit ihren eigenen Namen in der Liste,
|
||||
// in dieser Reihenfolge — kein Verwechseln durch den Positionsschluessel.
|
||||
const remainingNames = screen
|
||||
.getAllByText(/\.pem$/)
|
||||
.map((el) => el.textContent);
|
||||
expect(remainingNames).toEqual(['erste.pem', 'dritte.pem']);
|
||||
|
||||
// Die dritte Datei behaelt ihren eigenen Entfernen-Knopf (eigener Name im aria-label,
|
||||
// nicht der der geloeschten mittleren Datei).
|
||||
expect(screen.getByLabelText('Remove erste.pem')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Remove dritte.pem')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Remove mittlere.pem')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('quick-260921-iwr: Entfernen der ersten Datei laesst die zweite an ihrer eigenen Stelle mit eigenem Namen zurueck', async () => {
|
||||
render(<MergeTab password="" />);
|
||||
|
||||
const fileInput = screen.getByTestId('merge-file-input');
|
||||
const file1 = new File(['cert1'], 'a.pem', { type: 'application/x-pem-file' });
|
||||
const file2 = new File(['cert2'], 'b.pem', { type: 'application/x-pem-file' });
|
||||
|
||||
fireEvent.change(fileInput, { target: { files: [file1, file2] } });
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Remove a.pem'));
|
||||
|
||||
expect(screen.queryByText('a.pem')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('b.pem')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Remove b.pem')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { mergeCertsAction, downloadBase64 } from '../actions';
|
||||
|
||||
type MergeOutputFormat = 'pem' | 'pfx';
|
||||
|
||||
interface MergeTabProps {
|
||||
/** Shared password from page.tsx (shown when PFX output is chosen) */
|
||||
password: string;
|
||||
/** Callback to notify page.tsx when output format changes (for shared PasswordField visibility) */
|
||||
onOutputFormatChange?: (format: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* MergeTab — multi-file cert merge into PEM chain or password-protected PFX/PKCS12.
|
||||
*
|
||||
* Files are managed locally (separate from the shared single-file DropZone in page.tsx).
|
||||
* Password is shared from page.tsx via prop — the PasswordField becomes visible when
|
||||
* PFX output is selected (controlled by onOutputFormatChange → page.tsx showPassword).
|
||||
*
|
||||
* Security contract (T-09-02): password is never logged, never placed in URLs.
|
||||
* Security contract (T-09-03): server enforces 20-file max + 5 MB per file.
|
||||
*/
|
||||
export function MergeTab({ password, onOutputFormatChange }: MergeTabProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [outputFormat, setOutputFormat] = useState<MergeOutputFormat>('pem');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleFormatChange(format: MergeOutputFormat) {
|
||||
setOutputFormat(format);
|
||||
onOutputFormatChange?.(format);
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const selected = Array.from(e.target.files ?? []);
|
||||
if (selected.length > 0) {
|
||||
setFiles((prev) => [...prev, ...selected]);
|
||||
}
|
||||
// Reset input so the same file can be re-added if needed
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
async function handleMerge() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await mergeCertsAction(
|
||||
files,
|
||||
outputFormat,
|
||||
outputFormat === 'pfx' ? password : undefined,
|
||||
);
|
||||
downloadBase64(response.filename, response.content, response.mimeType);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : '';
|
||||
if (msg.includes('password') || msg.includes('passwort')) {
|
||||
setError(t('error.wrongPassword'));
|
||||
} else if (
|
||||
msg.includes('format') ||
|
||||
msg.includes('invalid') ||
|
||||
msg.includes('unknown')
|
||||
) {
|
||||
setError(t('error.unknownFormat'));
|
||||
} else {
|
||||
setError(t('error.generic'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canMerge = files.length >= 2;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Selected file list */}
|
||||
{files.length > 0 && (
|
||||
<ul className="space-y-1 rounded border border-border bg-background p-3">
|
||||
{files.map((f, i) => (
|
||||
<li key={`${f.name}-${i}`} className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground">{f.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(i)}
|
||||
className="text-xs text-muted-foreground hover:text-destructive ml-2"
|
||||
aria-label={`Remove ${f.name}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className="pt-1 text-xs text-muted-foreground">
|
||||
{files.length} {files.length === 1 ? 'Datei' : 'Dateien'} ausgewählt
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Multi-file input */}
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
|
||||
onChange={handleFileChange}
|
||||
data-testid="merge-file-input"
|
||||
className="block text-sm text-foreground cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Output format selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
htmlFor="mergeOutputFormat"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
Ausgabeformat
|
||||
</label>
|
||||
<select
|
||||
id="mergeOutputFormat"
|
||||
value={outputFormat}
|
||||
onChange={(e) => handleFormatChange(e.target.value as MergeOutputFormat)}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="pem">PEM-Kette</option>
|
||||
<option value="pfx">PFX / PKCS12</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Primary action button — disabled until >= 2 files selected */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="merge-action-btn"
|
||||
onClick={handleMerge}
|
||||
disabled={loading || !canMerge}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? t('actions.processing') : t('actions.merge')}
|
||||
</button>
|
||||
|
||||
{/* Error state */}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{/* Empty state — shown when no files and no error */}
|
||||
{files.length === 0 && !error && (
|
||||
<div className="text-center py-8 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{t('emptyState.merge')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('emptyState.mergeBody')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
||||
values ? `${key} ${JSON.stringify(values)}` : key,
|
||||
}));
|
||||
|
||||
vi.mock('../actions', () => ({
|
||||
analyzeBundleAction: vi.fn(),
|
||||
exportBundleItemAction: vi.fn(),
|
||||
downloadBase64: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
analyzeBundleAction,
|
||||
type BundleItem,
|
||||
downloadBase64,
|
||||
exportBundleItemAction,
|
||||
} from '../actions';
|
||||
import { OverviewTab } from './OverviewTab';
|
||||
|
||||
const mockAnalyze = analyzeBundleAction as ReturnType<typeof vi.fn>;
|
||||
const mockExport = exportBundleItemAction as ReturnType<typeof vi.fn>;
|
||||
const mockDownload = downloadBase64 as ReturnType<typeof vi.fn>;
|
||||
|
||||
function item(over: Partial<BundleItem>): BundleItem {
|
||||
return {
|
||||
id: 'x',
|
||||
kind: 'certificate',
|
||||
sources: ['a.pem'],
|
||||
pem: 'PEM',
|
||||
baseName: 'www.example.test',
|
||||
cn: 'www.example.test',
|
||||
organization: '',
|
||||
issuerCn: '',
|
||||
notBefore: null,
|
||||
notAfter: null,
|
||||
isExpired: null,
|
||||
daysLeft: null,
|
||||
san: [],
|
||||
keyType: 'RSA',
|
||||
keyBits: 2048,
|
||||
serialNumber: '',
|
||||
sha256: '',
|
||||
matchId: null,
|
||||
chainIds: [],
|
||||
formats: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const LEAF = item({
|
||||
id: 'leaf',
|
||||
role: 'end-entity',
|
||||
pem: 'LEAF-PEM',
|
||||
issuerCn: 'Test Intermediate CA',
|
||||
notBefore: '2026-01-01T00:00:00.000Z',
|
||||
notAfter: '2027-01-01T00:00:00.000Z',
|
||||
isExpired: false,
|
||||
daysLeft: 200,
|
||||
san: ['www.example.test', 'example.test'],
|
||||
matchId: 'key',
|
||||
chainIds: ['inter'],
|
||||
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
|
||||
});
|
||||
const INTER = item({
|
||||
id: 'inter',
|
||||
role: 'intermediate',
|
||||
pem: 'INTER-PEM',
|
||||
cn: 'Test Intermediate CA',
|
||||
isExpired: false,
|
||||
daysLeft: 900,
|
||||
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
|
||||
});
|
||||
const KEY = item({
|
||||
id: 'key',
|
||||
kind: 'privateKey',
|
||||
pem: 'KEY-PEM',
|
||||
matchId: 'leaf',
|
||||
formats: ['key', 'key-rsa', 'key-der'],
|
||||
});
|
||||
const CSR = item({
|
||||
id: 'csr',
|
||||
kind: 'csr',
|
||||
pem: 'CSR-PEM',
|
||||
matchId: 'leaf',
|
||||
formats: ['csr', 'csr-der'],
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function upload(files: File[]) {
|
||||
render(<OverviewTab />);
|
||||
fireEvent.change(screen.getByTestId('overview-file-input'), { target: { files } });
|
||||
await waitFor(() => expect(mockAnalyze).toHaveBeenCalled());
|
||||
}
|
||||
|
||||
describe('OverviewTab', () => {
|
||||
it('analysiert die abgelegten Dateien und zeigt je Teil Typ und Download-Knoepfe', async () => {
|
||||
mockAnalyze.mockResolvedValue({
|
||||
items: [LEAF, INTER, KEY, CSR],
|
||||
locked: [],
|
||||
ignored: ['.dnstxtrecord'],
|
||||
});
|
||||
const zip = new File(['zip'], 'paket.zip');
|
||||
await upload([zip]);
|
||||
|
||||
expect(mockAnalyze).toHaveBeenCalledWith([zip], undefined);
|
||||
const cards = await screen.findAllByTestId('bundle-item');
|
||||
expect(cards).toHaveLength(4);
|
||||
expect(within(cards[0]).getByText('type.end-entity')).toBeInTheDocument();
|
||||
expect(within(cards[1]).getByText('type.intermediate')).toBeInTheDocument();
|
||||
expect(within(cards[2]).getByText('type.privateKey')).toBeInTheDocument();
|
||||
expect(within(cards[3]).getByText('type.csr')).toBeInTheDocument();
|
||||
// Zuordnung und Gueltigkeit
|
||||
expect(within(cards[0]).getByText('www.example.test, example.test')).toBeInTheDocument();
|
||||
expect(within(cards[0]).getByText('valid')).toBeInTheDocument();
|
||||
expect(within(cards[2]).getByText(/type\.end-entity/)).toBeInTheDocument();
|
||||
// alle Formate als Knoepfe
|
||||
expect(
|
||||
within(cards[0])
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent),
|
||||
).toEqual(['format.crt', 'format.cer', 'format.fullchain', 'format.p7b', 'format.pfx']);
|
||||
expect(within(cards[2]).getAllByRole('button')).toHaveLength(3);
|
||||
expect(screen.getByText(/ignored/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Download schickt Kette und Schluessel mit und loest die Datei aus', async () => {
|
||||
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
|
||||
mockExport.mockResolvedValue({
|
||||
filename: 'www.example.test-fullchain.pem',
|
||||
content: 'Zm9v',
|
||||
mimeType: 'x',
|
||||
});
|
||||
await upload([new File(['x'], 'a.pem')]);
|
||||
const [leafCard] = await screen.findAllByTestId('bundle-item');
|
||||
|
||||
fireEvent.click(within(leafCard).getByText('format.fullchain'));
|
||||
await waitFor(() =>
|
||||
expect(mockDownload).toHaveBeenCalledWith('www.example.test-fullchain.pem', 'Zm9v', 'x'),
|
||||
);
|
||||
expect(mockExport).toHaveBeenCalledWith({
|
||||
kind: 'certificate',
|
||||
pem: 'LEAF-PEM',
|
||||
format: 'fullchain',
|
||||
baseName: 'www.example.test',
|
||||
chain: ['INTER-PEM'],
|
||||
keyPem: 'KEY-PEM',
|
||||
password: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('PFX fragt erst ein Passwort ab', async () => {
|
||||
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
|
||||
mockExport.mockResolvedValue({
|
||||
filename: 'www.example.test.pfx',
|
||||
content: 'Zm9v',
|
||||
mimeType: 'x',
|
||||
});
|
||||
await upload([new File(['x'], 'a.pem')]);
|
||||
const [leafCard] = await screen.findAllByTestId('bundle-item');
|
||||
|
||||
fireEvent.click(within(leafCard).getByText('format.pfx'));
|
||||
expect(mockExport).not.toHaveBeenCalled();
|
||||
expect(within(leafCard).getByText('pfxWithKey')).toBeInTheDocument();
|
||||
const download = within(leafCard).getByText('pfxDownload');
|
||||
expect(download).toBeDisabled();
|
||||
|
||||
fireEvent.change(within(leafCard).getByLabelText('pfxPassword'), {
|
||||
target: { value: 'geheim' },
|
||||
});
|
||||
fireEvent.click(download);
|
||||
await waitFor(() =>
|
||||
expect(mockExport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ format: 'pfx', password: 'geheim' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('geschuetzte PFX ohne Schluessel im Paket: Passwort eingeben und erneut pruefen', async () => {
|
||||
mockAnalyze.mockResolvedValueOnce({
|
||||
items: [{ ...LEAF, matchId: null }],
|
||||
locked: ['a.pfx'],
|
||||
ignored: [],
|
||||
});
|
||||
const pfx = new File(['x'], 'a.pfx');
|
||||
await upload([pfx]);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('lockedPassword'), { target: { value: 'pw' } });
|
||||
mockAnalyze.mockResolvedValueOnce({ items: [LEAF, KEY], locked: [], ignored: [] });
|
||||
fireEvent.click(screen.getByText('unlock'));
|
||||
await waitFor(() => expect(mockAnalyze).toHaveBeenLastCalledWith([pfx], 'pw'));
|
||||
expect(await screen.findAllByTestId('bundle-item')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('geschuetzte PFX, aber Zertifikat und Schluessel liegen schon vor: ruhiger Hinweis, Passwort optional', async () => {
|
||||
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: ['a.pfx'], ignored: [] });
|
||||
await upload([new File(['x'], 'paket.zip')]);
|
||||
|
||||
const notice = await screen.findByTestId('locked-notice');
|
||||
expect(within(notice).getByText(/lockedNotNeeded/)).toBeInTheDocument();
|
||||
expect(within(notice).queryByLabelText('lockedPassword')).not.toBeInTheDocument();
|
||||
fireEvent.click(within(notice).getByText('unlockAnyway'));
|
||||
expect(within(notice).getByLabelText('lockedPassword')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Fehler beim Pruefen wird angezeigt', async () => {
|
||||
mockAnalyze.mockRejectedValue(new Error('400'));
|
||||
await upload([new File(['x'], 'a.txt')]);
|
||||
expect(await screen.findByText('error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,402 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
analyzeBundleAction,
|
||||
type BundleAnalysis,
|
||||
type BundleExportFormat,
|
||||
type BundleItem,
|
||||
downloadBase64,
|
||||
exportBundleItemAction,
|
||||
} from '../actions';
|
||||
|
||||
/**
|
||||
* OverviewTab (quick-261001-l4q) — Zertifikatspaket vom Aussteller auf einmal
|
||||
* hochladen (mehrere Dateien oder ZIP), sehen, welche Datei was ist, und jedes
|
||||
* Teil in jedem passenden Format herunterladen. Die Erkennung macht der Server
|
||||
* (POST analyze), der Export ebenfalls (POST export) — beides zustandslos.
|
||||
*
|
||||
* T-09-02: Passwoerter leben nur im lokalen Zustand, nie in URL oder Log.
|
||||
*/
|
||||
|
||||
const ACCEPT = '.zip,.pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c,.key,.csr';
|
||||
|
||||
const ROLE_STYLES: Record<string, string> = {
|
||||
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
intermediate: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
root: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
privateKey: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300',
|
||||
csr: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
function typeKey(item: BundleItem): string {
|
||||
return item.kind === 'certificate' ? (item.role ?? 'end-entity') : item.kind;
|
||||
}
|
||||
|
||||
/** In UTC wie im Zertifikat: „bis 23:59:59 UTC“ ist bei uns schon der Folgetag — der Aussteller nennt aber dieses Datum. */
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
export function OverviewTab() {
|
||||
const t = useTranslations('certManager.overview');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [password, setPassword] = useState('');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<BundleAnalysis | null>(null);
|
||||
const [unlockOpen, setUnlockOpen] = useState(false);
|
||||
|
||||
async function analyze(next: File[], pw: string) {
|
||||
if (next.length === 0) {
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResult(await analyzeBundleAction(next, pw || undefined));
|
||||
} catch {
|
||||
setResult(null);
|
||||
setError(t('error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addFiles(list: FileList | null) {
|
||||
const added = Array.from(list ?? []);
|
||||
if (added.length === 0) return;
|
||||
const next = [...files, ...added];
|
||||
setFiles(next);
|
||||
void analyze(next, password);
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
const next = files.filter((_, i) => i !== index);
|
||||
setFiles(next);
|
||||
void analyze(next, password);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setFiles([]);
|
||||
setPassword('');
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const byId = Object.fromEntries((result?.items ?? []).map((i) => [i.id, i]));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
className="hidden"
|
||||
data-testid="overview-file-input"
|
||||
onChange={(e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
|
||||
isDragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm text-foreground">{t('dropTitle')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('dropHint')}</p>
|
||||
</button>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{files.map((file, i) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: dieselbe Datei darf zweimal in der Liste stehen; die Liste aendert sich nur durch Anhaengen/Entfernen
|
||||
key={`${file.name}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs"
|
||||
>
|
||||
{file.name}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('removeFile', { name: file.name })}
|
||||
onClick={() => removeFile(i)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button type="button" onClick={reset} className="text-xs text-muted-foreground underline">
|
||||
{t('reset')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">{t('analyzing')}</p>}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{result && result.locked.length > 0 && (
|
||||
<LockedNotice
|
||||
files={result.locked}
|
||||
// Liegen ein Zertifikat UND sein Schluessel schon einzeln vor,
|
||||
// steckt in der geschuetzten PFX nichts Neues (Aussteller-ZIP vom
|
||||
// 01.10.2026: PFX mit unbekanntem Passwort neben .pem/.key) — dann
|
||||
// nur ein ruhiger Hinweis, das Passwort ist optional.
|
||||
notNeeded={result.items.some((i) => i.kind === 'certificate' && i.matchId !== null)}
|
||||
open={unlockOpen}
|
||||
onOpen={() => setUnlockOpen(true)}
|
||||
password={password}
|
||||
onPassword={setPassword}
|
||||
loading={loading}
|
||||
onUnlock={() => void analyze(files, password)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{result && result.items.length === 0 && !loading && (
|
||||
<p className="text-sm text-muted-foreground">{t('nothingFound')}</p>
|
||||
)}
|
||||
|
||||
{result && result.items.length > 0 && (
|
||||
<ul className="space-y-4">
|
||||
{result.items.map((item) => (
|
||||
<BundleItemCard key={item.id} item={item} byId={byId} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{result && result.ignored.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ignored', { files: result.ignored.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LockedNotice(props: {
|
||||
files: string[];
|
||||
notNeeded: boolean;
|
||||
open: boolean;
|
||||
onOpen: () => void;
|
||||
password: string;
|
||||
onPassword: (value: string) => void;
|
||||
loading: boolean;
|
||||
onUnlock: () => void;
|
||||
}) {
|
||||
const t = useTranslations('certManager.overview');
|
||||
const showInput = !props.notNeeded || props.open;
|
||||
return (
|
||||
<div
|
||||
className={`space-y-2 rounded-lg border p-4 ${
|
||||
props.notNeeded ? 'border-border bg-muted/30' : 'border-status-warn/50 bg-status-warn/10'
|
||||
}`}
|
||||
data-testid="locked-notice"
|
||||
>
|
||||
<p className="text-sm text-foreground">
|
||||
{t(props.notNeeded ? 'lockedNotNeeded' : 'locked', { files: props.files.join(', ') })}
|
||||
</p>
|
||||
{!showInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onOpen}
|
||||
className="text-xs text-muted-foreground underline"
|
||||
>
|
||||
{t('unlockAnyway')}
|
||||
</button>
|
||||
)}
|
||||
{showInput && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={props.password}
|
||||
onChange={(e) => props.onPassword(e.target.value)}
|
||||
placeholder={t('lockedPassword')}
|
||||
aria-label={t('lockedPassword')}
|
||||
autoComplete="off"
|
||||
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={!props.password || props.loading}
|
||||
onClick={props.onUnlock}
|
||||
>
|
||||
{t('unlock')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BundleItemCard({ item, byId }: { item: BundleItem; byId: Record<string, BundleItem> }) {
|
||||
const t = useTranslations('certManager.overview');
|
||||
const [pfxOpen, setPfxOpen] = useState(false);
|
||||
const [pfxPassword, setPfxPassword] = useState('');
|
||||
const [busy, setBusy] = useState<BundleExportFormat | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const match = item.matchId ? byId[item.matchId] : undefined;
|
||||
const chain = item.chainIds.map((id) => byId[id]).filter(Boolean);
|
||||
|
||||
async function download(format: BundleExportFormat, password?: string) {
|
||||
setBusy(format);
|
||||
setError(null);
|
||||
try {
|
||||
const file = await exportBundleItemAction({
|
||||
kind: item.kind,
|
||||
pem: item.pem,
|
||||
format,
|
||||
baseName: item.baseName,
|
||||
chain: item.kind === 'certificate' ? chain.map((c) => c.pem) : undefined,
|
||||
keyPem: item.kind === 'certificate' && match ? match.pem : undefined,
|
||||
password,
|
||||
});
|
||||
downloadBase64(file.filename, file.content, file.mimeType);
|
||||
if (format === 'pfx') {
|
||||
setPfxOpen(false);
|
||||
setPfxPassword('');
|
||||
}
|
||||
} catch {
|
||||
setError(t('exportError'));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const status =
|
||||
item.isExpired === null
|
||||
? null
|
||||
: item.isExpired
|
||||
? { cls: 'bg-status-down/15 text-status-down-fg', text: t('expired') }
|
||||
: (item.daysLeft ?? 0) <= 30
|
||||
? {
|
||||
cls: 'bg-status-warn/15 text-status-warn-fg',
|
||||
text: t('expiresSoon', { days: item.daysLeft ?? 0 }),
|
||||
}
|
||||
: { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('valid') };
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border border-border p-4" data-testid="bundle-item">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[typeKey(item)]}`}>
|
||||
{t(`type.${typeKey(item)}`)}
|
||||
</span>
|
||||
<span className="font-medium break-all">{item.cn || item.baseName}</span>
|
||||
{status && (
|
||||
<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(`explain.${typeKey(item)}`)}</p>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
||||
{item.kind === 'certificate' && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('issuer')}</dt>
|
||||
<dd className="break-all">{item.issuerCn}</dd>
|
||||
<dt className="text-muted-foreground">{t('validity')}</dt>
|
||||
<dd>
|
||||
{formatDate(item.notBefore)} – {formatDate(item.notAfter)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{item.san.length > 0 && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('names')}</dt>
|
||||
<dd className="break-all">{item.san.join(', ')}</dd>
|
||||
</>
|
||||
)}
|
||||
{item.keyType && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('key')}</dt>
|
||||
<dd>{item.keyBits > 0 ? `${item.keyType} ${item.keyBits} Bit` : item.keyType}</dd>
|
||||
</>
|
||||
)}
|
||||
{match && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('belongsTo')}</dt>
|
||||
<dd className="break-all">
|
||||
{t(`type.${typeKey(match)}`)} {match.cn}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-muted-foreground">{t('source')}</dt>
|
||||
<dd className="break-all">{item.sources.join(', ')}</dd>
|
||||
</dl>
|
||||
|
||||
{item.kind === 'privateKey' && (
|
||||
<p className="mt-2 text-xs text-status-warn-fg">{t('keySecret')}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{item.formats.map((format) => (
|
||||
<button
|
||||
key={format}
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => (format === 'pfx' ? setPfxOpen((o) => !o) : void download(format))}
|
||||
className="rounded border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-secondary disabled:opacity-50"
|
||||
title={t(`formatHint.${format}`)}
|
||||
>
|
||||
{busy === format ? t('downloading') : t(`format.${format}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pfxOpen && (
|
||||
<div className="mt-3 space-y-2 rounded border border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{match ? t('pfxWithKey') : t('pfxWithoutKey')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={pfxPassword}
|
||||
onChange={(e) => setPfxPassword(e.target.value)}
|
||||
placeholder={t('pfxPassword')}
|
||||
aria-label={t('pfxPassword')}
|
||||
autoComplete="new-password"
|
||||
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!pfxPassword || busy !== null}
|
||||
onClick={() => void download('pfx', pfxPassword)}
|
||||
>
|
||||
{busy === 'pfx' ? t('downloading') : t('pfxDownload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface PasswordFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** When false, the component renders nothing (T-09-02: no DOM reflow) */
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export function PasswordField({ value, onChange, show }: PasswordFieldProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="cert-manager-password" className="block text-sm text-foreground">
|
||||
{t('password.label')}
|
||||
</label>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
id="cert-manager-password"
|
||||
type={revealed ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground pr-10 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed((prev) => !prev)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={revealed ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||
>
|
||||
{revealed ? (
|
||||
/* Eye-off inline SVG */
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
) : (
|
||||
/* Eye inline SVG */
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { zipSync } from 'fflate';
|
||||
import { type SplitResponse, type CertRole, splitCertsAction, downloadBase64 } from '../actions';
|
||||
import { sanitizeZipFilename } from '../zip-filename';
|
||||
|
||||
interface SplitTabProps {
|
||||
file: File | null;
|
||||
pemText: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const ROLE_STYLES: Record<CertRole, string> = {
|
||||
'root': 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
'intermediate': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
};
|
||||
|
||||
// `downloadAllAsZip` liegt ausserhalb der Komponente und kann den
|
||||
// Uebersetzungs-Hook nicht selbst aufrufen — der uebersetzte Name kommt
|
||||
// deshalb als Parameter herein (Restposten 1, quick-260921-jt4). Die
|
||||
// einzelnen Dateinamen IM Archiv stammen weiterhin unveraendert aus der API.
|
||||
function downloadAllAsZip(certs: SplitResponse['certs'], zipFilename: string) {
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
for (const cert of certs) {
|
||||
const bytes = Uint8Array.from(atob(cert.content), (c) => c.charCodeAt(0));
|
||||
// Deduplicate filenames (fflate overwrites silently otherwise)
|
||||
let name = cert.filename;
|
||||
let n = 1;
|
||||
while (name in files) {
|
||||
const base = cert.filename.replace(/\.pem$/, '');
|
||||
name = `${base}-${++n}.pem`;
|
||||
}
|
||||
files[name] = bytes;
|
||||
}
|
||||
const zipped = zipSync(files);
|
||||
const blob = new Blob([zipped], { type: 'application/zip' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
// T-JT4-05: der uebersetzte Name kann Zeichen tragen, die Windows
|
||||
// verbietet (Umlaute, Sonderzeichen) — sanitizeZipFilename schneidet ihn
|
||||
// auf das fuer eine Windows-Freigabe Zulaessige zurueck, unabhaengig davon,
|
||||
// ob der aktuelle Katalogwert zufaellig schon harmlos ist.
|
||||
a.download = sanitizeZipFilename(zipFilename);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function SplitTab({ file, pemText: _pemText, password: _password }: SplitTabProps) {
|
||||
const t = useTranslations('certManager');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<SplitResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSplit() {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await splitCertsAction(file);
|
||||
setResult(response);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : '';
|
||||
if (msg.includes('format') || msg.includes('invalid') || msg.includes('unknown')) {
|
||||
setError(t('error.unknownFormat'));
|
||||
} else {
|
||||
setError(t('error.generic'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSplit}
|
||||
disabled={loading || !file}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? t('actions.processing') : t('actions.split')}
|
||||
</button>
|
||||
|
||||
{result && result.certs.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadAllAsZip(result.certs, t('actions.zipFilename'))}
|
||||
className="border border-border px-4 py-2 rounded text-sm font-medium hover:bg-secondary transition-colors"
|
||||
>
|
||||
{t('actions.downloadZip')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{!result && !error && (
|
||||
<div className="text-center py-8 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{t('emptyState.split')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('emptyState.splitBody')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<ul className="space-y-2">
|
||||
{result.certs.map((cert) => (
|
||||
<li
|
||||
key={cert.index}
|
||||
className="flex items-center justify-between rounded bg-secondary px-3 py-2 text-sm gap-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||
<span
|
||||
className={`shrink-0 inline-block rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[cert.certRole ?? 'end-entity']}`}
|
||||
>
|
||||
{t(`certRole.${cert.certRole ?? 'end-entity'}`)}
|
||||
</span>
|
||||
<span className="font-medium truncate">
|
||||
{cert.subject.cn || cert.filename}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs shrink-0">
|
||||
{cert.validity.notAfter}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
downloadBase64(cert.filename, cert.content, 'application/x-pem-file')
|
||||
}
|
||||
className="shrink-0 border border-border px-3 py-1 rounded text-xs font-medium hover:bg-background transition-colors"
|
||||
>
|
||||
{t('actions.download')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { DropZone } from './components/DropZone';
|
||||
import { PasswordField } from './components/PasswordField';
|
||||
import { InspectTab } from './components/InspectTab';
|
||||
import { SplitTab } from './components/SplitTab';
|
||||
import { MergeTab } from './components/MergeTab';
|
||||
import { ConvertTab } from './components/ConvertTab';
|
||||
import { OverviewTab } from './components/OverviewTab';
|
||||
import { TabBar } from '@/components/accounting/tab-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { FilesTab } from './components/FilesTab';
|
||||
import { useCertWorkspace } from './use-cert-workspace';
|
||||
|
||||
type TabId = 'overview' | 'inspect' | 'split' | 'merge' | 'convert';
|
||||
|
||||
const TABS: TabId[] = ['overview', 'inspect', 'split', 'merge', 'convert'];
|
||||
|
||||
/** Returns true if the selected file is a PFX/P12 (requires decryption password) */
|
||||
function isPfxFile(file: File | null): boolean {
|
||||
if (!file) return false;
|
||||
const name = file.name.toLowerCase();
|
||||
return name.endsWith('.pfx') || name.endsWith('.p12');
|
||||
}
|
||||
type TabId = 'files';
|
||||
|
||||
/**
|
||||
* CertManagerPage — tab-based shell for certificate operations.
|
||||
* quick-261001-l4q: erster Reiter „Übersicht“ fuer ganze Zertifikatspakete.
|
||||
* Layout per UI-SPEC: max-w-4xl, shared input card, tab nav, tab content card.
|
||||
* T-09-02: password lives in local React state only; never logged or placed in URLs.
|
||||
* T-09-04: all API calls via postForm() which sends credentials:'include'.
|
||||
*
|
||||
* The shared PasswordField is shown when:
|
||||
* 1. A PFX/P12 file is selected in the shared DropZone, OR
|
||||
* 2. The active tab (Merge or Convert) has PFX chosen as the output format.
|
||||
* CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt).
|
||||
* Der erste Reiter „Dateien“ sammelt alles; die weiteren Reiter (ab Task 2) arbeiten auf dieser Liste.
|
||||
* Die Liste lebt nur im Arbeitsspeicher dieses Browserfensters; Reiterwechsel behaelt sie.
|
||||
*/
|
||||
export default function CertManagerPage() {
|
||||
const t = useTranslations('certManager');
|
||||
const workspace = useCertWorkspace();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('files');
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [pemText, setPemText] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
// Track the output format of the active Merge and Convert tabs
|
||||
// so the shared PasswordField can be shown when PFX output is chosen.
|
||||
const [mergeOutputFormat, setMergeOutputFormat] = useState('pem');
|
||||
const [convertOutputFormat, setConvertOutputFormat] = useState('pem');
|
||||
|
||||
const showPassword =
|
||||
isPfxFile(file) ||
|
||||
(activeTab === 'merge' && mergeOutputFormat === 'pfx') ||
|
||||
(activeTab === 'convert' && convertOutputFormat === 'pfx');
|
||||
|
||||
const handleFile = (selected: File) => {
|
||||
setFile(selected);
|
||||
setPemText(''); // Single active source: file XOR paste
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
const handleClearFile = () => {
|
||||
setFile(null);
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
const handlePemChange = (text: string) => {
|
||||
setPemText(text);
|
||||
if (text) {
|
||||
setFile(null); // Single active source: file XOR paste
|
||||
setPassword('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: TabId) => {
|
||||
setActiveTab(tab);
|
||||
// Tab switch: keep shared input, clear result (result cleared per tab re-render)
|
||||
};
|
||||
|
||||
const renderActiveTab = () => {
|
||||
switch (activeTab) {
|
||||
case 'overview':
|
||||
return <OverviewTab />;
|
||||
case 'inspect':
|
||||
return <InspectTab file={file} pemText={pemText} password={password} />;
|
||||
case 'split':
|
||||
return <SplitTab file={file} pemText={pemText} password={password} />;
|
||||
case 'merge':
|
||||
return <MergeTab password={password} onOutputFormatChange={setMergeOutputFormat} />;
|
||||
case 'convert':
|
||||
return (
|
||||
<ConvertTab
|
||||
file={file}
|
||||
pemText={pemText}
|
||||
password={password}
|
||||
onTargetFormatChange={setConvertOutputFormat}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
const count = workspace.entries.length;
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{
|
||||
id: 'files',
|
||||
label: count > 0 ? t('tabs.filesWithCount', { count }) : t('tabs.files'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
|
||||
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
|
||||
|
||||
{/* Tab navigation */}
|
||||
<nav className="flex gap-6 overflow-x-auto border-b border-border">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => handleTabChange(tab)}
|
||||
className={`pb-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-primary-strong font-semibold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(`tabs.${tab}`)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Shared input card — nur fuer die Einzeldatei-Werkzeuge; die Uebersicht
|
||||
(quick-261001-l4q) hat ihre eigene Mehrfach-Ablage. */}
|
||||
{activeTab !== 'overview' && (
|
||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
|
||||
{/* DropZone */}
|
||||
<DropZone
|
||||
onFile={handleFile}
|
||||
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
|
||||
currentFile={file}
|
||||
onClear={handleClearFile}
|
||||
/>
|
||||
|
||||
{/* ODER divider */}
|
||||
<div className="flex items-center gap-3">
|
||||
<hr className="flex-1 border-border" />
|
||||
<span className="text-xs text-muted-foreground">{t('or')}</span>
|
||||
<hr className="flex-1 border-border" />
|
||||
</div>
|
||||
|
||||
{/* PEM textarea */}
|
||||
<textarea
|
||||
value={pemText}
|
||||
onChange={(e) => handlePemChange(e.target.value)}
|
||||
placeholder={t('paste.placeholder')}
|
||||
rows={4}
|
||||
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-y"
|
||||
/>
|
||||
|
||||
{/* Conditional password field (T-09-02) */}
|
||||
<PasswordField value={password} onChange={setPassword} show={showPassword} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab content card */}
|
||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
|
||||
{renderActiveTab()}
|
||||
<TabBar tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||
<div className="rounded-lg bg-card p-6 shadow-sm dark:border dark:border-border">
|
||||
{activeTab === 'files' && <FilesTab workspace={workspace} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { type AnalysisResult, analyzeWorkingSet, certErrorKey } from './actions';
|
||||
import {
|
||||
addFiles as addToSet,
|
||||
type RejectedFile,
|
||||
removeEntry,
|
||||
type WorkingEntry,
|
||||
} from './working-set';
|
||||
|
||||
export type AnalysisStatus = 'idle' | 'analyzing' | 'error';
|
||||
|
||||
export interface CertWorkspace {
|
||||
entries: WorkingEntry[];
|
||||
/** Antwort der letzten Analyse; null bei leerem Arbeitsbereich */
|
||||
analysis: AnalysisResult | null;
|
||||
/** Eintrags-Kennungen zum Zeitpunkt dieser Analyse: Quelle `file` zeigt auf diesen Index */
|
||||
analysisIds: string[];
|
||||
status: AnalysisStatus;
|
||||
/** Schluessel unter certManager.errors.* */
|
||||
errorKey: string | null;
|
||||
addFiles: (files: File[]) => RejectedFile[];
|
||||
remove: (id: string) => void;
|
||||
clear: () => void;
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der gemeinsame Arbeitsbereich (D-01, D-23): lebt nur in diesem Browserfenster. Nach jeder
|
||||
* Aenderung wird der ganze Bereich neu analysiert; Antworten aelterer Anfragen werden verworfen.
|
||||
*/
|
||||
export function useCertWorkspace(): CertWorkspace {
|
||||
const [entries, setEntriesState] = useState<WorkingEntry[]>([]);
|
||||
const [analysis, setAnalysis] = useState<AnalysisResult | null>(null);
|
||||
const [analysisIds, setAnalysisIds] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState<AnalysisStatus>('idle');
|
||||
const [errorKey, setErrorKey] = useState<string | null>(null);
|
||||
|
||||
const entriesRef = useRef<WorkingEntry[]>([]);
|
||||
const requestCounter = useRef(0);
|
||||
const idCounter = useRef(0);
|
||||
|
||||
const runAnalysis = useCallback((list: WorkingEntry[]) => {
|
||||
const mine = ++requestCounter.current;
|
||||
if (list.length === 0) {
|
||||
setAnalysis(null);
|
||||
setAnalysisIds([]);
|
||||
setStatus('idle');
|
||||
setErrorKey(null);
|
||||
return;
|
||||
}
|
||||
setStatus('analyzing');
|
||||
setErrorKey(null);
|
||||
const ids = list.map((e) => e.id);
|
||||
analyzeWorkingSet(list).then(
|
||||
(result) => {
|
||||
if (mine !== requestCounter.current) return;
|
||||
setAnalysis(result);
|
||||
setAnalysisIds(ids);
|
||||
setStatus('idle');
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (mine !== requestCounter.current) return;
|
||||
setErrorKey(certErrorKey(error));
|
||||
setStatus('error');
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
const commit = useCallback(
|
||||
(next: WorkingEntry[]) => {
|
||||
entriesRef.current = next;
|
||||
setEntriesState(next);
|
||||
runAnalysis(next);
|
||||
},
|
||||
[runAnalysis],
|
||||
);
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: File[]): RejectedFile[] => {
|
||||
const result = addToSet(entriesRef.current, files, () => `entry-${++idCounter.current}`);
|
||||
if (result.entries.length !== entriesRef.current.length) commit(result.entries);
|
||||
return result.rejected;
|
||||
},
|
||||
[commit],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
(id: string) => {
|
||||
commit(removeEntry(entriesRef.current, id));
|
||||
},
|
||||
[commit],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
commit([]);
|
||||
}, [commit]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
runAnalysis(entriesRef.current);
|
||||
}, [runAnalysis]);
|
||||
|
||||
return { entries, analysis, analysisIds, status, errorKey, addFiles, remove, clear, retry };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
addFiles,
|
||||
MAX_ENTRIES,
|
||||
MAX_FILE_BYTES,
|
||||
removeEntry,
|
||||
toFormData,
|
||||
type WorkingEntry,
|
||||
} from './working-set';
|
||||
|
||||
let counter = 0;
|
||||
const nextId = () => `e${++counter}`;
|
||||
|
||||
function makeFile(name: string, size = 10, lastModified = 1): File {
|
||||
const file = new File(['x'.repeat(Math.min(size, 20))], name, { lastModified });
|
||||
if (size > 20) Object.defineProperty(file, 'size', { value: size });
|
||||
return file;
|
||||
}
|
||||
|
||||
const NONE: WorkingEntry[] = [];
|
||||
|
||||
describe('addFiles', () => {
|
||||
it('zwei Aufrufe mit je einer Datei ergeben zwei Eintraege in Aufrufreihenfolge', () => {
|
||||
const first = addFiles(NONE, [makeFile('a.pem')], nextId);
|
||||
const second = addFiles(first.entries, [makeFile('b.pem')], nextId);
|
||||
expect(second.entries.map((e) => e.label)).toEqual(['a.pem', 'b.pem']);
|
||||
expect(second.rejected).toEqual([]);
|
||||
expect(second.entries[0].origin).toBe('upload');
|
||||
expect(second.entries[0].password).toBe('');
|
||||
expect(second.entries[0].host).toBeNull();
|
||||
});
|
||||
|
||||
it('eine zweite Auswahl ersetzt die erste nie', () => {
|
||||
const first = addFiles(NONE, [makeFile('a.pem'), makeFile('b.pem')], nextId);
|
||||
const second = addFiles(first.entries, [makeFile('c.pem')], nextId);
|
||||
expect(second.entries.map((e) => e.label)).toEqual(['a.pem', 'b.pem', 'c.pem']);
|
||||
});
|
||||
|
||||
it('dieselbe Datei (Name, Groesse, Datum) wird als Doppelte abgelehnt', () => {
|
||||
const first = addFiles(NONE, [makeFile('a.pem', 10, 5)], nextId);
|
||||
const again = addFiles(first.entries, [makeFile('a.pem', 10, 5)], nextId);
|
||||
expect(again.entries).toHaveLength(1);
|
||||
expect(again.rejected).toEqual([{ name: 'a.pem', reason: 'duplicate' }]);
|
||||
const other = addFiles(first.entries, [makeFile('a.pem', 10, 6)], nextId);
|
||||
expect(other.entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('die 31. Datei wird abgelehnt', () => {
|
||||
const files = Array.from({ length: MAX_ENTRIES + 1 }, (_, i) => makeFile(`f${i}.pem`, 10, i));
|
||||
const result = addFiles(NONE, files, nextId);
|
||||
expect(result.entries).toHaveLength(MAX_ENTRIES);
|
||||
expect(result.rejected).toEqual([{ name: `f${MAX_ENTRIES}.pem`, reason: 'tooMany' }]);
|
||||
});
|
||||
|
||||
it('eine Datei ueber 5 MB wird abgelehnt', () => {
|
||||
const result = addFiles(NONE, [makeFile('big.zip', MAX_FILE_BYTES + 1)], nextId);
|
||||
expect(result.entries).toEqual([]);
|
||||
expect(result.rejected).toEqual([{ name: 'big.zip', reason: 'tooLarge' }]);
|
||||
});
|
||||
|
||||
it('ein Satz ueber 10 MB gesamt wird abgelehnt', () => {
|
||||
const four = MAX_FILE_BYTES - 1000;
|
||||
const first = addFiles(NONE, [makeFile('a.zip', four, 1), makeFile('b.zip', four, 2)], nextId);
|
||||
expect(first.entries).toHaveLength(2);
|
||||
const third = addFiles(first.entries, [makeFile('c.zip', four, 3)], nextId);
|
||||
expect(third.entries).toHaveLength(2);
|
||||
expect(third.rejected).toEqual([{ name: 'c.zip', reason: 'totalTooLarge' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeEntry', () => {
|
||||
it('entfernt nur den genannten Eintrag und behaelt die Reihenfolge', () => {
|
||||
const { entries } = addFiles(
|
||||
NONE,
|
||||
[makeFile('a.pem'), makeFile('b.pem'), makeFile('c.pem')],
|
||||
nextId,
|
||||
);
|
||||
const after = removeEntry(entries, entries[1].id);
|
||||
expect(after.map((e) => e.label)).toEqual(['a.pem', 'c.pem']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toFormData', () => {
|
||||
it('haengt die Dateien als `files` in Reihenfolge der Eintraege an', () => {
|
||||
const { entries } = addFiles(NONE, [makeFile('b.pem'), makeFile('a.pem')], nextId);
|
||||
const form = toFormData(entries);
|
||||
const names = form.getAll('files').map((f) => (f as File).name);
|
||||
expect(names).toEqual(['b.pem', 'a.pem']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Arbeitsbereich des Zertifikat-Managers (quick-261009-ikt, D-01, D-17, D-23).
|
||||
*
|
||||
* Reine Funktionen ohne React: eine Liste von Eintraegen, die nur wachsen oder gezielt
|
||||
* schrumpfen kann. Eine zweite Auswahl haengt an, sie ersetzt nie die erste.
|
||||
* Die Liste lebt nur im Arbeitsspeicher dieses Browserfensters (D-11): nichts davon wird
|
||||
* gespeichert, auch Passwoerter nicht.
|
||||
*/
|
||||
|
||||
export const MAX_ENTRIES = 30;
|
||||
export const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
export const MAX_TOTAL_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_PASTE_CHARS = 256_000;
|
||||
|
||||
export type EntryOrigin = 'upload' | 'paste' | 'fetched';
|
||||
|
||||
export interface WorkingEntry {
|
||||
id: string;
|
||||
file: File;
|
||||
label: string;
|
||||
origin: EntryOrigin;
|
||||
/** Nur fuer nachgeladene Zertifikate: der Server, von dem sie kamen */
|
||||
host: string | null;
|
||||
/** Passwort fuer diese Datei (ab Task 4); bleibt im Arbeitsspeicher */
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type RejectReason = 'duplicate' | 'tooMany' | 'tooLarge' | 'totalTooLarge';
|
||||
|
||||
export interface RejectedFile {
|
||||
name: string;
|
||||
reason: RejectReason;
|
||||
}
|
||||
|
||||
export interface AddFilesResult {
|
||||
entries: WorkingEntry[];
|
||||
rejected: RejectedFile[];
|
||||
}
|
||||
|
||||
function sameFile(a: File, b: File): boolean {
|
||||
return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
|
||||
}
|
||||
|
||||
/** Haengt Dateien an. Abgelehnte Dateien stehen mit Grund in `rejected`, die Liste bleibt sonst unveraendert. */
|
||||
export function addFiles(
|
||||
entries: WorkingEntry[],
|
||||
files: File[],
|
||||
nextId: () => string,
|
||||
): AddFilesResult {
|
||||
const next = [...entries];
|
||||
const rejected: RejectedFile[] = [];
|
||||
let total = next.reduce((sum, e) => sum + e.file.size, 0);
|
||||
|
||||
for (const file of files) {
|
||||
if (next.some((e) => e.origin === 'upload' && sameFile(e.file, file))) {
|
||||
rejected.push({ name: file.name, reason: 'duplicate' });
|
||||
} else if (file.size > MAX_FILE_BYTES) {
|
||||
rejected.push({ name: file.name, reason: 'tooLarge' });
|
||||
} else if (next.length >= MAX_ENTRIES) {
|
||||
rejected.push({ name: file.name, reason: 'tooMany' });
|
||||
} else if (total + file.size > MAX_TOTAL_BYTES) {
|
||||
rejected.push({ name: file.name, reason: 'totalTooLarge' });
|
||||
} else {
|
||||
next.push({
|
||||
id: nextId(),
|
||||
file,
|
||||
label: file.name,
|
||||
origin: 'upload',
|
||||
host: null,
|
||||
password: '',
|
||||
});
|
||||
total += file.size;
|
||||
}
|
||||
}
|
||||
return { entries: next, rejected };
|
||||
}
|
||||
|
||||
/** Entfernt einen Eintrag; die Reihenfolge der uebrigen bleibt. */
|
||||
export function removeEntry(entries: WorkingEntry[], id: string): WorkingEntry[] {
|
||||
return entries.filter((e) => e.id !== id);
|
||||
}
|
||||
|
||||
/** Multipart-Koerper fuer die Analyse: `files` in der Reihenfolge der Eintraege. */
|
||||
export function toFormData(entries: WorkingEntry[]): FormData {
|
||||
const form = new FormData();
|
||||
for (const entry of entries) form.append('files', entry.file, entry.file.name);
|
||||
return form;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1).replace('.', ',')} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} MB`;
|
||||
}
|
||||
Reference in New Issue
Block a user