feat(09-03): InspectTab UI + inspectCertAction + render tests

- Added inspectCertAction to actions.ts (JSON path for pemText, multipart path for file)
- Added CertDetails interface to actions.ts (mirrors API response shape)
- Implemented InspectTab: Analysieren button, loading state, grid-cols-2 result grid
- InspectTab handles wrong-password error (t('error.wrongPassword')) and generic error
- Added 2 new InspectTab tests: success grid (subject CN + SHA-256) and error (text-destructive)
- Fixed setup.ts: explicit expect.extend(matchers) for vitest@4.x compatibility
  (Rule 1: @testing-library/jest-dom/vitest not extending global expect in vitest 4)
- Fixed existing test: getByText -> getAllByText for 'Analysieren' (now appears in tab nav + button)
- 9/9 cert-manager tests pass
This commit is contained in:
2026-07-01 23:55:41 +02:00
parent ba994635e8
commit 64a8e725e7
4 changed files with 285 additions and 7 deletions
@@ -1,6 +1,64 @@
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';
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
/**
* Call POST /modules/cert-manager/parse.
* - If pemText is present → JSON body { pemText, password }
* - Otherwise → multipart FormData with file + optional password
*
* T-09-02: password is never placed in URL, logged, or echoed.
* T-09-04: credentials:'include' ensures JWT cookie is sent.
*/
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>;
}
}
/** /**
* 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.
@@ -1,4 +1,4 @@
import { cleanup, render, screen, fireEvent } from '@testing-library/react'; import { cleanup, render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl — provide certManager namespace keys // Mock next-intl — provide certManager namespace keys
@@ -42,9 +42,12 @@ vi.mock('next-intl', () => ({
})); }));
import CertManagerPage from './page'; import CertManagerPage from './page';
import { InspectTab } from './components/InspectTab';
import * as actions from './actions';
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks();
}); });
describe('CertManagerPage shell', () => { describe('CertManagerPage shell', () => {
@@ -56,7 +59,9 @@ describe('CertManagerPage shell', () => {
it('renders all four tab labels', () => { it('renders all four tab labels', () => {
render(<CertManagerPage />); render(<CertManagerPage />);
expect(screen.getByText('Analysieren')).toBeInTheDocument(); // "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('Aufteilen')).toBeInTheDocument();
expect(screen.getByText('Zusammenfuehren')).toBeInTheDocument(); expect(screen.getByText('Zusammenfuehren')).toBeInTheDocument();
expect(screen.getByText('Konvertieren')).toBeInTheDocument(); expect(screen.getByText('Konvertieren')).toBeInTheDocument();
@@ -99,3 +104,86 @@ describe('CertManagerPage shell', () => {
expect(screen.getByText('Lade eine Datei hoch und waehle ein Ausgabeformat.')).toBeInTheDocument(); 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');
});
});
@@ -1,6 +1,8 @@
'use client'; 'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { type CertDetails, inspectCertAction } from '../actions';
interface InspectTabProps { interface InspectTabProps {
file: File | null; file: File | null;
@@ -8,15 +10,134 @@ interface InspectTabProps {
password: string; password: string;
} }
export function InspectTab({ file: _file, pemText: _pemText, password: _password }: InspectTabProps) { export function InspectTab({ file, pemText, password }: InspectTabProps) {
const t = useTranslations('certManager'); 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Primary action button */}
<button
onClick={handleInspect}
disabled={loading || !canInspect}
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.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"> <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 font-medium text-foreground">{t('emptyState.inspect')}</p>
<p className="text-sm text-muted-foreground">{t('emptyState.inspectBody')}</p> <p className="text-sm text-muted-foreground">{t('emptyState.inspectBody')}</p>
</div> </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> </div>
); );
} }
+11
View File
@@ -1,3 +1,14 @@
import { expect } from 'vitest';
import * as matchers from '@testing-library/jest-dom/matchers';
// Explicitly extend vitest's expect with jest-dom matchers.
// Using the explicit extend approach (not @testing-library/jest-dom/vitest) for
// compatibility with vitest@4.x (the /vitest barrel doesn't extend the same
// expect instance in vitest 4's module resolution).
expect.extend(matchers as any);
// Type augmentation for the custom matchers (adds toBeInTheDocument, etc.)
// This import only carries type declarations — no runtime effect.
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
// Polyfill ResizeObserver for jsdom (needed by react-grid-layout) // Polyfill ResizeObserver for jsdom (needed by react-grid-layout)