mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:31:34 +00:00
9dc0742e77
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.
131 lines
6.6 KiB
TypeScript
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');
|
|
});
|
|
});
|