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:
@@ -1,6 +1,64 @@
|
||||
export const API_URL =
|
||||
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.
|
||||
* 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';
|
||||
|
||||
// Mock next-intl — provide certManager namespace keys
|
||||
@@ -42,9 +42,12 @@ vi.mock('next-intl', () => ({
|
||||
}));
|
||||
|
||||
import CertManagerPage from './page';
|
||||
import { InspectTab } from './components/InspectTab';
|
||||
import * as actions from './actions';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('CertManagerPage shell', () => {
|
||||
@@ -56,7 +59,9 @@ describe('CertManagerPage shell', () => {
|
||||
|
||||
it('renders all four tab labels', () => {
|
||||
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('Zusammenfuehren')).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();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { type CertDetails, inspectCertAction } from '../actions';
|
||||
|
||||
interface InspectTabProps {
|
||||
file: File | null;
|
||||
@@ -8,15 +10,134 @@ interface InspectTabProps {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function InspectTab({ file: _file, pemText: _pemText, password: _password }: InspectTabProps) {
|
||||
export function InspectTab({ file, pemText, password }: InspectTabProps) {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<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 text-muted-foreground">{t('emptyState.inspectBody')}</p>
|
||||
</div>
|
||||
{/* 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">
|
||||
<p className="text-sm font-medium text-foreground">{t('emptyState.inspect')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('emptyState.inspectBody')}</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
// Polyfill ResizeObserver for jsdom (needed by react-grid-layout)
|
||||
|
||||
Reference in New Issue
Block a user