From 64a8e725e7da3f26c8e5c521a31771913ca14507 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 23:55:41 +0200 Subject: [PATCH] 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 --- .../(portal)/modules/cert-manager/actions.ts | 58 ++++++++ .../cert-manager/cert-manager.test.tsx | 92 +++++++++++- .../cert-manager/components/InspectTab.tsx | 131 +++++++++++++++++- apps/web/src/test/setup.ts | 11 ++ 4 files changed, 285 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts index b9fe80e..563ed97 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts +++ b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts @@ -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 { + 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; + } 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; + } +} + /** * Download a base64-encoded file as a browser download. * T-09-02: password is never placed in URL, console.log, or filename. diff --git a/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx index 501a37f..4713df3 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx @@ -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(); - 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( + , + ); + + // 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( + , + ); + + 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'); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx index 5063a4f..699e17a 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx @@ -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(null); + const [error, setError] = useState(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 (
-
-

{t('emptyState.inspect')}

-

{t('emptyState.inspectBody')}

-
+ {/* Primary action button */} + + + {/* Error state */} + {error && ( +

{error}

+ )} + + {/* Empty state — shown when no result and no error */} + {!result && !error && ( +
+

{t('emptyState.inspect')}

+

{t('emptyState.inspectBody')}

+
+ )} + + {/* Result grid — key-value layout per UI-SPEC (grid-cols-2 gap-2 text-sm) */} + {result && ( +
+ Subject CN + {result.subject.cn} + + {result.subject.o && ( + <> + Subject O + {result.subject.o} + + )} + + {result.subject.ou && ( + <> + Subject OU + {result.subject.ou} + + )} + + {result.subject.c && ( + <> + Subject C + {result.subject.c} + + )} + + Issuer CN + {result.issuer.cn} + + {result.issuer.o && ( + <> + Issuer O + {result.issuer.o} + + )} + + Not Before + {result.validity.notBefore} + + Not After + {result.validity.notAfter} + + Expired + {result.validity.isExpired ? 'Yes' : 'No'} + + Days Left + {result.validity.daysLeft} + + Key Type + {result.keyType} + + Key Bits + {result.keyBits} + + Serial Number + {result.serialNumber} + + Signature Algorithm + {result.signatureAlgorithm} + + SHA-1 + {result.fingerprint.sha1} + + SHA-256 + {result.fingerprint.sha256} + + {result.san.length > 0 && ( + <> + SANs + {result.san.join(', ')} + + )} +
+ )}
); } diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index b015b24..31c91e1 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -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)