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:
@@ -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<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.
|
||||
* 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 { 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(<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';
|
||||
|
||||
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<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 (
|
||||
<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">
|
||||
<p className="text-sm font-medium text-foreground">{t('emptyState.split')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('emptyState.splitBody')}</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user