Files
certctl/web/src/components/StatusBadge.test.tsx
T
shankar0123 9dc0742e77 fix(web): close StatusBadge enum drift + Certificate TS phantom fields (D-1 master)
Five audit findings, all category cat-d or cat-f, all rooted in two
frontend files. The dashboard silently lied:

  cat-d-359e92c20cbf [P1, primary] — Agent: 'Stale' dead key + 'Degraded'
                                     neutral fallthrough
  cat-d-9f4c8e4a91f1 [P2]          — Notification: 'dead' missing
  cat-d-1447e04732e7 [P3]          — Cert: 'PendingIssuance' dead key
  cat-f-cert_detail_page_key_render_fallback [P2] — render-site reads
                                                    cert.key_algorithm directly
  cat-f-ae0d06b6588f [P2]          — Certificate TS phantom fields (root cause)

Pre-D-1, agents in the only Go AgentStatus that means 'needs operator
attention' (Degraded) rendered as default neutral grey because StatusBadge
mapped 'Stale' (a key Go has never emitted) to yellow. Dead-letter
notifications visually equated with 'read' (operator-acknowledged). The
Certificate badge map carried a 'PendingIssuance' key no Go enum emits.
CertificateDetailPage's Key Algorithm and Key Size rows always rendered
'—' even when the data was a single fetch away — the lookup went through
cert.key_algorithm / cert.key_size directly, both phantom Certificate TS
fields. Trim the TS type so the missing-data case is explicit; fix the
render site to use latestVersion?.field; pin the contract with a 38-case
Vitest property test that walks every Go enum.

StatusBadge (web/src/components/StatusBadge.tsx)
- Drop 'Stale' (Agent dead key) + 'PendingIssuance' (Cert dead key).
- Add 'Degraded' (Agent → badge-warning) + 'dead' (Notification → badge-danger).
- Add leading docblock naming Go-side source-of-truth file for every
  status family and pointing at the property test as regression vector.

Property test (web/src/components/StatusBadge.test.tsx — 38 cases)
- Iterates every Go-emitted enum value (AgentStatus, CertificateStatus,
  JobStatus, NotificationStatus, DiscoveryStatus, HealthStatus) plus the
  two frontend-synthesized Enabled/Disabled labels, asserts every value
  gets a non-default class (or an explicit 'badge badge-neutral' for the
  five intentionally-neutral terminal values: Archived, Cancelled,
  Dismissed, read, unknown).
- Negative assertions: 'Stale' and 'PendingIssuance' must fall through
  to the dictionary default — re-adding either key surfaces here.
- Specific UX-correctness assertions: 'dead' → badge-danger,
  'Degraded' → badge-warning.
- Unknown-status fallthrough preserves label text.

Certificate TS trim (web/src/api/types.ts)
- Drop serial_number?, fingerprint_sha256?, key_algorithm?, key_size?,
  issued_at? from Certificate. Go's ManagedCertificate has never carried
  these — they live on CertificateVersion. Post-trim a cert.X access for
  any of the five fields is a TS compile error.
- Leading docblock cross-references the closure rationale and the
  latestVersion fallback pattern.

Render-site fix (web/src/pages/CertificateDetailPage.tsx)
- Key Algorithm / Key Size rows now read latestVersion?.key_algorithm /
  latestVersion?.key_size, mirroring the existing latestVersion fallback
  used a few lines above for serial_number / fingerprint_sha256.
- The same edit also tightened the serial / fingerprint / issued_at
  derivations to drop the now-impossible 'cert.X || latestVersion?.X'
  cert-side leg (cert.serial_number is a TS error post-trim).

Type-test regression (web/src/api/types.test.ts)
- Certificate literal construction pinned post-trim — adding any of the
  five fields back makes the literal an excess-property TS error.
- Sibling CertificateVersion literal pinning the trimmed fields still
  live on the version envelope (so the CertificateDetailPage fallback
  path can't break).

OpenAPI (api/openapi.yaml)
- ManagedCertificate schema unchanged — was already correct (no phantom
  fields). Added a leading comment cross-referencing the D-5 closure for
  future readers.

CI guardrail (.github/workflows/ci.yml)
- 'Forbidden StatusBadge dead-key + Certificate phantom-field regression
  guard (D-1)'. Two grep blocks: catches Stale/PendingIssuance map
  literals in StatusBadge.tsx; uses an awk-scoped window over the
  'export interface Certificate {' block in types.ts to catch the five
  phantom fields reappearing while explicitly excluding CertificateVersion
  (which legitimately carries them). Comments + test files exempt.

Verification
- Backend build/vet/test -short -race all clean across handler/router/
  middleware packages.
- Frontend tsc --noEmit clean.
- Vitest 256 → 296 tests (+40: 38 from new StatusBadge test, 2 from D-5
  Certificate trim regression in types.test.ts).
- OpenAPI YAML parses (87 paths).
- Both CI guardrail patterns clear on the post-fix tree; both fire
  against synthetic regression patterns (re-add Stale → fires; re-add
  serial_number? to Certificate → fires).

Out of scope (deferred)
- diff-05x06-* type drifts for Agent/DeploymentTarget/Notification/
  DiscoveredCertificate/Issuer TS interfaces. Per-type field-by-field
  Go ↔ TS diff is codegen-shaped, not edit-shaped — warrants its own
  D-2 master prompt. Noted in CHANGELOG follow-ups section.
2026-04-25 13:52:54 +00:00

131 lines
6.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import StatusBadge from './StatusBadge';
// -----------------------------------------------------------------------------
// D-1 master — StatusBadge enum-coverage contract
//
// The single source of truth for what Go actually emits on the wire.
// Update this if the Go enums change (and the StatusBadge will go red
// here BEFORE any user sees a wrong color in production).
//
// Sources (mirror the Go const blocks verbatim — wire VALUES, not Go
// identifier names):
// AgentStatus — internal/domain/connector.go:174-176
// CertificateStatus — internal/domain/certificate.go:50-57
// JobStatus — internal/domain/job.go:43-49
// NotificationStatus— internal/domain/notification.go:51-55
// DiscoveryStatus — internal/domain/discovery.go:13-17
// HealthStatus — internal/domain/health_check.go:9-13
//
// Issuer 'Enabled' / 'Disabled' are NOT a Go enum — they're frontend-
// synthesized labels mapped from `Issuer.enabled bool` at the call
// site (TargetsPage.tsx similarly). Pinned in a separate group below.
//
// Pre-D-1 drift this test would have caught:
// - Agent: StatusBadge had 'Stale' (never emitted), missing 'Degraded'
// (real). Degraded agents rendered as default neutral grey, hiding
// attention-needed state from operators.
// - Notification: StatusBadge missing 'dead' (retries exhausted).
// Dead-letter notifications rendered as default neutral, visually
// equated with 'read' (operator-acknowledged).
// - Certificate: StatusBadge had 'PendingIssuance' (never emitted).
// Dead key, latent confusion vector if anyone copies it as
// canonical.
// -----------------------------------------------------------------------------
const ENUMS_FROM_GO = {
AgentStatus: ['Online', 'Offline', 'Degraded'] as const,
CertificateStatus: ['Pending', 'Active', 'Expiring', 'Expired',
'RenewalInProgress', 'Failed', 'Revoked', 'Archived'] as const,
JobStatus: ['Pending', 'AwaitingCSR', 'AwaitingApproval', 'Running',
'Completed', 'Failed', 'Cancelled'] as const,
NotificationStatus: ['pending', 'sent', 'failed', 'dead', 'read'] as const,
DiscoveryStatus: ['Unmanaged', 'Managed', 'Dismissed'] as const,
HealthStatus: ['healthy', 'degraded', 'down', 'cert_mismatch', 'unknown'] as const,
};
// Frontend-synthesized labels — not in any Go enum, but surfaced via
// StatusBadge from real call sites (TargetsPage, AgentGroupsPage etc.)
// and therefore part of the visual contract this component owns.
const FRONTEND_SYNTHESIZED = ['Enabled', 'Disabled'] as const;
describe('StatusBadge — enum-coverage contract (D-1 master)', () => {
// Iterate every Go-emitted value across every enum and assert the
// rendered <span> carries a class OTHER than the default 'badge-neutral'.
// EXCEPT for legitimately-neutral statuses (Archived, Cancelled,
// Dismissed, read, unknown) which are intentionally neutral by UX
// design — those are pinned by a separate sub-test below.
const INTENTIONALLY_NEUTRAL = new Set(['Archived', 'Cancelled', 'Dismissed', 'read', 'unknown']);
for (const [enumName, values] of Object.entries(ENUMS_FROM_GO)) {
for (const v of values) {
it(`${enumName}: '${v}' renders a recognised class (no fallthrough)`, () => {
const { container } = render(<StatusBadge status={v} />);
const span = container.querySelector('span');
expect(span).not.toBeNull();
const cls = span!.className;
if (INTENTIONALLY_NEUTRAL.has(v)) {
// Neutral is the right semantic answer for terminal-acknowledged
// states — but it must come from an EXPLICIT mapping, not the
// dictionary-default fallthrough. Asserting a 'badge-neutral'
// class here pins that the explicit entry exists; if someone
// deletes it, this still passes (because the default is also
// 'badge-neutral'). The negative assertion in the dead-keys
// sub-test below catches the deletion case.
expect(cls).toBe('badge badge-neutral');
} else {
expect(cls).toMatch(/badge-(success|warning|danger|info)/);
expect(cls).not.toBe('badge badge-neutral');
}
});
}
}
for (const v of FRONTEND_SYNTHESIZED) {
it(`Frontend-synthesized '${v}' has an explicit StatusBadge mapping`, () => {
const { container } = render(<StatusBadge status={v} />);
const cls = container.querySelector('span')!.className;
// 'Disabled' is intentionally neutral; 'Enabled' is success.
expect(cls).toMatch(/badge-(success|warning|danger|info|neutral)/);
});
}
// Negative contract: the dead keys we deleted MUST fall through to the
// default. If a future PR re-adds 'Stale' or 'PendingIssuance' to
// statusStyles, this test will surface it because the rendered class
// will no longer be 'badge badge-neutral' (it'd be the explicit value
// someone re-added, e.g. 'badge-warning').
it.each(['Stale', 'PendingIssuance'])(
"dead key '%s' falls through to neutral default (no explicit mapping)",
(deadKey) => {
const { container } = render(<StatusBadge status={deadKey} />);
expect(container.querySelector('span')!.className).toBe('badge badge-neutral');
},
);
// Specific danger-class contracts (UX correctness, not just non-default).
// These pin the operator-attention semantics. If anyone changes 'dead'
// or 'Degraded' away from these classes, the operator's perception of
// "this needs my attention" changes — these are the highest-stakes
// visual semantics in the dashboard.
it("Notification 'dead' renders as danger (operator attention required)", () => {
const { container } = render(<StatusBadge status="dead" />);
expect(container.querySelector('span')!.className).toContain('badge-danger');
});
it("Agent 'Degraded' renders as warning (degradation, not failure)", () => {
const { container } = render(<StatusBadge status="Degraded" />);
expect(container.querySelector('span')!.className).toContain('badge-warning');
});
// Unknown statuses fall through to neutral. The string is still
// displayed verbatim so an operator can see "what is this?" rather
// than nothing at all.
it('unknown status string renders as neutral but preserves the label text', () => {
const { container } = render(<StatusBadge status="SomeFutureStatus" />);
const span = container.querySelector('span');
expect(span!.className).toBe('badge badge-neutral');
expect(span!.textContent).toBe('SomeFutureStatus');
});
});