feat(09-04): SplitTab UI + splitCertsAction + render tests

- 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
This commit is contained in:
2026-07-02 07:16:18 +02:00
parent 2c4ada347c
commit 33b1bc3172
3 changed files with 190 additions and 5 deletions
@@ -1,6 +1,24 @@
export const API_URL = export const API_URL =
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; 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 // 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<SplitResponse> {
const form = new FormData();
form.append('file', file);
return postForm('split', form) as Promise<SplitResponse>;
}
/** /**
* Download a base64-encoded file as a browser download. * Download a base64-encoded file as a browser download.
* T-09-02: password is never placed in URL, console.log, or filename. * T-09-02: password is never placed in URL, console.log, or filename.
@@ -43,6 +43,7 @@ vi.mock('next-intl', () => ({
import CertManagerPage from './page'; import CertManagerPage from './page';
import { InspectTab } from './components/InspectTab'; import { InspectTab } from './components/InspectTab';
import { SplitTab } from './components/SplitTab';
import * as actions from './actions'; import * as actions from './actions';
afterEach(() => { afterEach(() => {
@@ -187,3 +188,80 @@ describe('InspectTab', () => {
expect(errorEl.className).toContain('text-destructive'); 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');
});
});
@@ -1,6 +1,8 @@
'use client'; 'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { type SplitResponse, splitCertsAction, downloadBase64 } from '../actions';
interface SplitTabProps { interface SplitTabProps {
file: File | null; file: File | null;
@@ -8,15 +10,86 @@ interface SplitTabProps {
password: string; 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 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);
}
}
const canSplit = Boolean(file);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Primary action button */}
<button
onClick={handleSplit}
disabled={loading || !canSplit}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
>
{loading ? t('actions.processing') : t('actions.split')}
</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"> <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 font-medium text-foreground">{t('emptyState.split')}</p>
<p className="text-sm text-muted-foreground">{t('emptyState.splitBody')}</p> <p className="text-sm text-muted-foreground">{t('emptyState.splitBody')}</p>
</div> </div>
)}
{/* Per-cert download list */}
{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"
>
<div className="min-w-0 flex-1">
<span className="font-medium break-all">
{cert.subject.cn || cert.filename}
</span>
<span className="ml-2 text-muted-foreground text-xs">
{cert.validity.notAfter}
</span>
</div>
<button
onClick={() =>
downloadBase64(cert.filename, cert.content, 'application/x-pem-file')
}
className="ml-3 shrink-0 bg-secondary text-secondary-foreground border border-border px-3 py-1 rounded text-xs font-medium hover:opacity-80 transition-opacity"
>
{t('actions.download')}
</button>
</li>
))}
</ul>
)}
</div> </div>
); );
} }