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 =
|
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">
|
||||||
<div className="text-center py-8 space-y-1">
|
{/* Primary action button */}
|
||||||
<p className="text-sm font-medium text-foreground">{t('emptyState.inspect')}</p>
|
<button
|
||||||
<p className="text-sm text-muted-foreground">{t('emptyState.inspectBody')}</p>
|
onClick={handleInspect}
|
||||||
</div>
|
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>
|
</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';
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
// Polyfill ResizeObserver for jsdom (needed by react-grid-layout)
|
// Polyfill ResizeObserver for jsdom (needed by react-grid-layout)
|
||||||
|
|||||||
Reference in New Issue
Block a user