From 33b1bc31723dfdc35800c2627abe927959ee4dfd Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 2 Jul 2026 07:16:18 +0200 Subject: [PATCH] feat(09-04): SplitTab UI + splitCertsAction + render tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions.ts: export SplitEntry + SplitResponse interfaces; add splitCertsAction(file) → POST /split - SplitTab.tsx: Aufteilen button (disabled without file); per-cert download list (bg-secondary rows) each row: subject.cn, validity.notAfter, Herunterladen button → downloadBase64 empty state / error state (text-destructive) matching InspectTab pattern - cert-manager.test.tsx: 2 new SplitTab tests (success: 2 download buttons; error: text-destructive) - All 11 cert-manager web tests green; production files type-clean --- .../(portal)/modules/cert-manager/actions.ts | 34 ++++++++ .../cert-manager/cert-manager.test.tsx | 78 +++++++++++++++++ .../cert-manager/components/SplitTab.tsx | 83 +++++++++++++++++-- 3 files changed, 190 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts index 563ed97..ba49d16 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts +++ b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts @@ -1,6 +1,24 @@ export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; +// --------------------------------------------------------------------------- +// SplitResponse — mirrors CertManagerService.SplitResponse +// --------------------------------------------------------------------------- + +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 }; +} + +export interface SplitResponse { + count: number; + certs: SplitEntry[]; +} + // --------------------------------------------------------------------------- // CertDetails — mirrors the API response shape from CertManagerService // --------------------------------------------------------------------------- @@ -59,6 +77,22 @@ export async function inspectCertAction(input: { } } +// --------------------------------------------------------------------------- +// splitCertsAction — calls POST /modules/cert-manager/split +// --------------------------------------------------------------------------- + +/** + * 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 { + const form = new FormData(); + form.append('file', file); + return postForm('split', form) as Promise; +} + /** * Download a base64-encoded file as a browser download. * T-09-02: password is never placed in URL, console.log, or filename. diff --git a/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx index 4713df3..29c9956 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx @@ -43,6 +43,7 @@ vi.mock('next-intl', () => ({ import CertManagerPage from './page'; import { InspectTab } from './components/InspectTab'; +import { SplitTab } from './components/SplitTab'; import * as actions from './actions'; afterEach(() => { @@ -187,3 +188,80 @@ describe('InspectTab', () => { 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(); + + // 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(); + + 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'); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx index 27b6fce..58a5117 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx @@ -1,6 +1,8 @@ 'use client'; +import { useState } from 'react'; import { useTranslations } from 'next-intl'; +import { type SplitResponse, splitCertsAction, downloadBase64 } from '../actions'; interface SplitTabProps { file: File | null; @@ -8,15 +10,86 @@ interface SplitTabProps { password: string; } -export function SplitTab({ file: _file, pemText: _pemText, password: _password }: SplitTabProps) { +export function SplitTab({ file, pemText: _pemText, password: _password }: SplitTabProps) { const t = useTranslations('certManager'); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(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); + } + } + + const canSplit = Boolean(file); return (
-
-

{t('emptyState.split')}

-

{t('emptyState.splitBody')}

-
+ {/* Primary action button */} + + + {/* Error state */} + {error && ( +

{error}

+ )} + + {/* Empty state — shown when no result and no error */} + {!result && !error && ( +
+

{t('emptyState.split')}

+

{t('emptyState.splitBody')}

+
+ )} + + {/* Per-cert download list */} + {result && ( +
    + {result.certs.map((cert) => ( +
  • +
    + + {cert.subject.cn || cert.filename} + + + {cert.validity.notAfter} + +
    + +
  • + ))} +
+ )}
); }