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 =
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user