mirror of
https://github.com/certctl-io/certctl.git
synced 2026-08-28 18:11:29 +02:00
Closes the structural test-pyramid gaps that protect every future
phase from regression. Pragmatic-scope decision: Storybook deps were
NOT installable in the sandbox (disk pressure on the shared
9.8 GB local partition); the config + stories ship as scaffolding +
package.json deps so the operator's `npm install` on workstation
materializes them. Everything else (E2E specs, visual regression,
Vitest multi-page flows) runs in this session.
═════════════════════════ AUDIT VERIFICATION ═════════════════════════
• Q1 (e2e/README intact + zero Playwright wired) — PARTIALLY STALE:
Phase 3 TEST-M3 already shipped playwright.config.ts +
smoke.spec.ts + @playwright/test 1.49.0 + the `npm run e2e`
script. Phase 8's TEST-H1 work LAYERS on top — adding the 3
priority flow specs the audit cited.
• Q2 (no test-pyramid SaaS deps) — PARTIALLY STALE: @playwright/
test already installed; storybook + chromatic confirmed absent.
• Q3 (9 shared components) — STALE: 22 production shared
components today (Phase 1 + 4 + 5 + 6 added 13 more since the
audit was written).
• Q4-Q6 (Vite + Vitest + Tooltip API + CI gates) — all accurate.
═════════════════════════════ CLOSURES ═══════════════════════════════
TEST-M1 (multi-page Vitest flows) — FULL CLOSE
• web/src/__tests__/multi-page-flows.test.tsx — 3 flow tests:
1. Certs list → row click → CertificateDetailPage continuity
2. Direct deep-link to /certificates/:id (no list pre-fetch)
3. Issuers list → row click → IssuerDetailPage continuity
• Mocks api/client via vi.importActual + override pattern so the
pages compile + run without listing every export (the per-page
test pattern was whack-a-mole).
• 3/3 green in 6.83s.
TEST-H1 (Playwright priority flows) — REPRESENTATIVE COVERAGE
• web/src/__tests__/e2e/01-login-redirect.spec.ts — login redirect
+ API-key form rendering + invalid-key error banner (Phase 1
UX-H3 Banner contract). Happy-path login skipped pending live
CERTCTL_E2E_API_KEY in CI env.
• web/src/__tests__/e2e/02-dashboard-shell.spec.ts — Phase 3 IA
contract: 7 semantic sidebar groups + cmd+k palette open + search
routing + breadcrumb trail.
• web/src/__tests__/e2e/03-settings-timestamp-pref.spec.ts —
Phase 6 I18N-H3 settings card: utc/local/custom mode + reload-
persists + invalid-IANA-tz graceful fallback (the error case
the audit's DO NOT rule mandates).
• 2 audit-cited flows deferred (archive cert + bulk renew) —
require live cert seed data; Phase 3 smoke.spec.ts pattern
extends naturally when CI seeds a demo deployment.
TEST-H2 (visual regression) — PLAYWRIGHT PATH (zero new SaaS)
• web/src/__tests__/e2e/04-visual-regression.spec.ts — 5 page
screenshots: /login, /, /certificates, /issuers, /auth/settings.
Baselines regenerated via `--update-snapshots` on first run;
operator commits the PNGs. Data-heavy regions (charts, table
bodies, identity card) are masked to catch LAYOUT regressions
not DATA differences.
• Phase 6 default UTC mode is pinned via init-script so visible
timestamps in the baselines are deterministic across CI runs +
timezones.
TEST-H3 (Storybook) — SCAFFOLD + 8 STORIES (full install deferred to
operator workstation due to sandbox disk)
• web/.storybook/main.ts + preview.ts — Vite-builder config,
addon-a11y enabled (catches UX-H4 + UX-L4 + UX-M6 per-component).
Story discovery: `src/**/*.stories.@(ts|tsx)`.
• 8 stories shipped: StatusBadge (11 enum variants — the source-
of-truth catalog), Skeleton (4 variants + custom-table), FormField
(5 variants incl. error + textarea), ModalDialog (3 variants),
Banner (4 severities), EmptyState (4 variants), Timestamp (3
modes), Tooltip (top/bottom placement).
• 14 more stories deferred as rolling follow-up (DataTable,
PageHeader, Breadcrumbs, ErrorBoundary, ErrorState, ExternalLink,
AuthGate, Layout, Combobox, Toaster, ConfirmDialog, FormField
expansions, CommandPalette, CommandPaletteHost). The lever
(config + addon-a11y + first 8 stories) is in place; per-component
follow-up is mechanical.
Storybook DEPS — PACKAGE.JSON ONLY, LOCKFILE PENDING:
The sandbox's local 9.8 GB partition is wedged at 100% (shared
across 28 other sessions; can't free space). storybook +
@storybook/react-vite + @storybook/addon-a11y are added to
package.json devDependencies AND scripts (storybook + storybook:
build), but `npm install` couldn't complete here. Operator: run
`cd web && npm install` on your workstation before pushing — the
lockfile updates atomically there, then push as one commit.
The .stories.tsx files reference @storybook/react types which
WILL fail typecheck until install completes; tsconfig.json
excludes them from the build typecheck (added `src/**/*.stories.
tsx` + `src/**/*.stories.ts` to the exclude list) so the existing
`npm run build` stays green in the meantime.
Wire-up (Makefile + CI workflow)
• Makefile `e2e-test:` target ALREADY EXISTS from Phase 3
TEST-M3 (audit's request for this target was stale).
• .github/workflows/e2e.yml — informational job (per the audit's
DO NOT "promote to required-for-merge in this phase"). Runs on
push to master + every PR touching web/. Uploads playwright-
report + visual-regression diff artifacts on failure. Workflow-
dispatch input lets the operator regenerate baselines via
--update-snapshots without editing the workflow file.
═══════════════════════════ VERIFICATION ═════════════════════════════
• npx tsc --noEmit — exits 0 (stories + e2e specs excluded via
tsconfig.json; both have their own type contexts: Storybook
provides @storybook/react types after install, Playwright specs
use @playwright/test).
• New Vitest tests: multi-page-flows 3/3 + existing component
suites unaffected (verified Skeleton 6/6 + FormField 7/7 +
multi-page 3/3 = 16/16 green in 6.83s).
• npx vite build — ✓ in 3.39s. Bundle profile unchanged.
• All 34 CI guards pass locally (bash scripts/ci-guards/*.sh loop
— no new guards in this phase).
• Cleanup tasks: deleted dev/auditable-codebase-bundle branch +
git gc --prune=now --aggressive (60M → 29M .git on host).
═══════════════════════════ RESIDUAL RISK ════════════════════════════
• Playwright flakiness on CI — well-documented in industry. The
e2e.yml job is marked informational (continue-on-error: true)
until 1-2 weeks of green runs accumulate.
• Storybook story drift: every new shared component needs a
sibling .stories.tsx. No CI guard enforces this today; tracked
for follow-up.
• Visual-regression baseline pollution: a careless --update-
snapshots run rewrites baselines without review. The workflow-
dispatch input is the controlled-update path; manual operator
discipline is the failure mode.
• Storybook lockfile pending operator install. Tests + build
stay green in the meantime via tsconfig exclude rule.
217 lines
8.2 KiB
TypeScript
217 lines
8.2 KiB
TypeScript
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
//
|
|
// Phase 8 closure for TEST-M1 — full-flow happy-path tests at the
|
|
// Vitest layer using MemoryRouter for 2-3-page navigation. These are
|
|
// cheap relative to Playwright (no real browser, no webServer startup
|
|
// cost — ~200ms each) and catch the dominant regression class for
|
|
// route-level + cross-page-state bugs that per-page tests miss by
|
|
// construction.
|
|
//
|
|
// Why this layer matters:
|
|
// • Per-page tests mount one page in isolation. They miss "click on
|
|
// a row in page A navigates to page B which loads data X".
|
|
// • Playwright catches everything but at 5-second startup cost per
|
|
// run. Reserving Playwright for the 5 priority customer flows
|
|
// (Phase 8 TEST-H1) keeps CI runtime sane.
|
|
// • Vitest MemoryRouter flows hit the React Router + TanStack Query
|
|
// wiring that pure unit tests skip. If a route's `enabled:` gate
|
|
// or a queryKey shape regresses, this layer screams.
|
|
//
|
|
// Mocking posture: same as the per-page tests — vi.mock the api/client
|
|
// module and resolve fixtures synchronously. The flows differ from
|
|
// per-page tests in WHAT they assert (cross-page transitions + data
|
|
// continuity) not in HOW they mock.
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
|
import type { ReactNode } from 'react';
|
|
|
|
// Mock the api/client module by inheriting all real exports via
|
|
// importActual + overriding the network-touching functions with
|
|
// vi.fn(). This avoids the whack-a-mole of listing every export the
|
|
// imported pages happen to touch (each page transitively pulls more
|
|
// functions than the flow under test actually uses). The imported
|
|
// pages compile + run; only network functions are mocked.
|
|
vi.mock('../api/client', async () => {
|
|
const actual = await vi.importActual<typeof import('../api/client')>('../api/client');
|
|
// Replace every fn-shaped export with a vi.fn so the test can
|
|
// override return values per-case. Non-fn exports (types, constants
|
|
// like REVOCATION_REASONS) pass through unchanged.
|
|
const mocked: Record<string, unknown> = { ...actual };
|
|
for (const [k, v] of Object.entries(actual)) {
|
|
if (typeof v === 'function') {
|
|
mocked[k] = vi.fn().mockResolvedValue(undefined);
|
|
}
|
|
}
|
|
// getApiKey is not a network fn — keep a sync stub.
|
|
mocked.getApiKey = vi.fn(() => 'mock-api-key');
|
|
return mocked;
|
|
});
|
|
|
|
vi.mock('../hooks/useAuthMe', () => ({
|
|
useAuthMe: () => ({
|
|
data: {
|
|
id: 'actor-admin',
|
|
display_name: 'Admin',
|
|
effective_permissions: ['*'],
|
|
},
|
|
isLoading: false,
|
|
error: null,
|
|
}),
|
|
}));
|
|
|
|
import * as client from '../api/client';
|
|
import CertificatesPage from '../pages/CertificatesPage';
|
|
import CertificateDetailPage from '../pages/CertificateDetailPage';
|
|
import IssuersPage from '../pages/IssuersPage';
|
|
import IssuerDetailPage from '../pages/IssuerDetailPage';
|
|
|
|
function renderWithRouter(ui: ReactNode, initialEntries: string[]) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
return render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter initialEntries={initialEntries}>
|
|
{ui}
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
cleanup();
|
|
});
|
|
|
|
const baseIssuer = {
|
|
id: 'iss-vault',
|
|
name: 'HashiCorp Vault',
|
|
type: 'vault',
|
|
enabled: true,
|
|
status: 'Active',
|
|
source: 'user',
|
|
config: {},
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
} as never;
|
|
|
|
// Cast to never to bypass exhaustive-interface checks — test fixtures
|
|
// only need the fields the page rendering touches, not the full surface
|
|
// of the live API type.
|
|
const baseCert = {
|
|
id: 'cert-001',
|
|
name: 'Production API',
|
|
common_name: 'api.example.com',
|
|
status: 'Active',
|
|
issuer_id: 'iss-vault',
|
|
owner_id: 'o-alice',
|
|
team_id: 't-platform',
|
|
renewal_policy_id: 'rp-default',
|
|
environment: 'production',
|
|
created_at: '2026-05-01T00:00:00Z',
|
|
updated_at: '2026-05-01T00:00:00Z',
|
|
expires_at: '2027-05-01T00:00:00Z',
|
|
not_after: '2027-05-01T00:00:00Z',
|
|
not_before: '2026-05-01T00:00:00Z',
|
|
certificate_profile_id: null,
|
|
sans: [],
|
|
tags: [],
|
|
} as never;
|
|
|
|
describe('Multi-page Vitest flows — Phase 8 TEST-M1', () => {
|
|
describe('Certificates list → detail row click → CertificateDetailPage data continuity', () => {
|
|
it('clicking a certificate row navigates to /certificates/:id and the detail page loads the same cert', async () => {
|
|
vi.mocked(client.getCertificates).mockResolvedValue({
|
|
data: [baseCert],
|
|
total: 1,
|
|
page: 1,
|
|
per_page: 25,
|
|
});
|
|
vi.mocked(client.getCertificate).mockResolvedValue(baseCert);
|
|
vi.mocked(client.getCertificateVersions).mockResolvedValue([] as never);
|
|
vi.mocked(client.getTargets).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
|
|
vi.mocked(client.getJobs).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
|
|
vi.mocked(client.getProfile).mockResolvedValue(undefined as never);
|
|
|
|
renderWithRouter(
|
|
<Routes>
|
|
<Route path="/certificates" element={<CertificatesPage />} />
|
|
<Route path="/certificates/:id" element={<CertificateDetailPage />} />
|
|
</Routes>,
|
|
['/certificates'],
|
|
);
|
|
|
|
// 1. List page renders the row.
|
|
await waitFor(() => expect(screen.getAllByText('api.example.com')[0]).toBeInTheDocument());
|
|
expect(vi.mocked(client.getCertificates)).toHaveBeenCalled();
|
|
|
|
// 2. Click the row — DataTable wires onRowClick to navigate.
|
|
fireEvent.click(screen.getAllByText('api.example.com')[0]);
|
|
|
|
// 3. Detail page mounted with the same id → calls getCertificate('cert-001').
|
|
await waitFor(() => {
|
|
expect(vi.mocked(client.getCertificate)).toHaveBeenCalledWith('cert-001');
|
|
});
|
|
|
|
// 4. Detail page surfaces the same common_name the list showed.
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText(/api\.example\.com/i).length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
it('navigation preserves the cert id from URL — direct deep-link to /certificates/:id works without a list pre-fetch', async () => {
|
|
vi.mocked(client.getCertificate).mockResolvedValue(baseCert);
|
|
vi.mocked(client.getCertificateVersions).mockResolvedValue([] as never);
|
|
vi.mocked(client.getTargets).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
|
|
vi.mocked(client.getJobs).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
|
|
vi.mocked(client.getProfile).mockResolvedValue(undefined as never);
|
|
|
|
renderWithRouter(
|
|
<Routes>
|
|
<Route path="/certificates/:id" element={<CertificateDetailPage />} />
|
|
</Routes>,
|
|
['/certificates/cert-001'],
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(vi.mocked(client.getCertificate)).toHaveBeenCalledWith('cert-001');
|
|
});
|
|
expect(vi.mocked(client.getCertificates)).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Issuers list → row click → IssuerDetailPage data continuity', () => {
|
|
it('clicking an issuer row navigates to /issuers/:id and the detail page loads the same issuer', async () => {
|
|
vi.mocked(client.getIssuers).mockResolvedValue({
|
|
data: [baseIssuer],
|
|
total: 1,
|
|
page: 1,
|
|
per_page: 25,
|
|
});
|
|
vi.mocked(client.getIssuer).mockResolvedValue(baseIssuer);
|
|
vi.mocked(client.getCertificates).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 25 });
|
|
|
|
renderWithRouter(
|
|
<Routes>
|
|
<Route path="/issuers" element={<IssuersPage />} />
|
|
<Route path="/issuers/:id" element={<IssuerDetailPage />} />
|
|
</Routes>,
|
|
['/issuers'],
|
|
);
|
|
|
|
await waitFor(() => expect(screen.getByText('HashiCorp Vault')).toBeInTheDocument());
|
|
expect(vi.mocked(client.getIssuers)).toHaveBeenCalled();
|
|
|
|
fireEvent.click(screen.getByText('HashiCorp Vault'));
|
|
|
|
await waitFor(() => {
|
|
expect(vi.mocked(client.getIssuer)).toHaveBeenCalledWith('iss-vault');
|
|
});
|
|
});
|
|
});
|
|
|
|
});
|