mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:11:38 +00:00
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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { POLICY_TYPES, POLICY_SEVERITIES } from './types';
|
||||
import type { Agent } from './types';
|
||||
import type { Agent, Certificate, CertificateVersion } from './types';
|
||||
|
||||
/**
|
||||
* Regression tests for the policy enum tuples.
|
||||
@@ -132,3 +132,91 @@ describe('Agent interface (I-004 retirement)', () => {
|
||||
expect(active.retired_reason).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* D-5 (cat-f-ae0d06b6588f, master): Certificate TS phantom-fields trim.
|
||||
*
|
||||
* Pre-D-5 the Certificate interface declared `serial_number`,
|
||||
* `fingerprint_sha256`, `key_algorithm`, `key_size`, and `issued_at` as
|
||||
* optional. These fields were never emitted by Go's `ManagedCertificate`
|
||||
* (internal/domain/certificate.go) — they live on `CertificateVersion`,
|
||||
* which is the per-issuance record fetched from
|
||||
* /api/v1/certificates/{id}/versions. The optional declarations made
|
||||
* `cert.serial_number` always-undefined on list responses, and downstream
|
||||
* consumers (CertificateDetailPage's Key Algorithm / Key Size rows in
|
||||
* particular) silently rendered '—' for every cert despite the data
|
||||
* being available a single fetch away.
|
||||
*
|
||||
* Post-D-5 the TS type makes the missing-data case explicit: a
|
||||
* `cert.serial_number` access becomes a TS compile error, forcing every
|
||||
* consumer to acknowledge the version-fallback pattern. This regression
|
||||
* test pins the trim — if a future PR re-adds any of the five phantom
|
||||
* fields to Certificate (e.g. via merge conflict, copy-paste, or a
|
||||
* codegen run that regenerates from a stale OpenAPI spec), the
|
||||
* compile-fail block here will surface it.
|
||||
*/
|
||||
describe('Certificate interface (D-5 phantom-fields trim)', () => {
|
||||
it('does NOT declare per-issuance fields — those live on CertificateVersion', () => {
|
||||
// Construct a fully-populated Certificate. If a future PR re-adds
|
||||
// any of the five phantom fields (serial_number, fingerprint_sha256,
|
||||
// key_algorithm, key_size, issued_at) to the interface, every
|
||||
// omission in this literal becomes "missing required field" and
|
||||
// the test fails to compile. Conversely, attempting to set any of
|
||||
// the five fields on the literal is a TS error today (excess
|
||||
// property), so the negative-assertion block below also fails to
|
||||
// compile if someone re-adds them as optional.
|
||||
const cert: Certificate = {
|
||||
id: 'mc-test',
|
||||
name: 'test',
|
||||
common_name: 'test.example.com',
|
||||
sans: [],
|
||||
status: 'Active',
|
||||
environment: 'production',
|
||||
issuer_id: 'iss-test',
|
||||
owner_id: 'o-test',
|
||||
team_id: 't-test',
|
||||
renewal_policy_id: 'rp-default',
|
||||
certificate_profile_id: 'cp-default',
|
||||
expires_at: '2027-01-01T00:00:00Z',
|
||||
tags: {},
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
expect(cert.id).toBe('mc-test');
|
||||
|
||||
// Excess-property check: each of these MUST be a TS error if
|
||||
// uncommented. Keep them in the test as documentation of what's
|
||||
// intentionally absent. (We can't directly assert "type does not
|
||||
// have property X" without a type-level helper, but the literal
|
||||
// construction above plus tsc --noEmit in CI is the binding check.)
|
||||
//
|
||||
// const broken: Certificate = { ...cert, serial_number: '01:02' }; // ❌ TS2353
|
||||
// const broken2: Certificate = { ...cert, key_algorithm: 'EC' }; // ❌ TS2353
|
||||
// const broken3: Certificate = { ...cert, key_size: 256 }; // ❌ TS2353
|
||||
// const broken4: Certificate = { ...cert, fingerprint_sha256: '' };// ❌ TS2353
|
||||
// const broken5: Certificate = { ...cert, issued_at: '...' }; // ❌ TS2353
|
||||
});
|
||||
|
||||
it('CertificateVersion still carries the per-issuance fields', () => {
|
||||
// The other half of the contract: the trimmed fields didn't go to
|
||||
// /dev/null — they live (and have always lived) on CertificateVersion.
|
||||
// If a refactor removes them from CertificateVersion too, the
|
||||
// CertificateDetailPage fallback path breaks. Pin both halves.
|
||||
const v: CertificateVersion = {
|
||||
id: 'mcv-test',
|
||||
certificate_id: 'mc-test',
|
||||
serial_number: '01:02:03',
|
||||
fingerprint_sha256: 'a'.repeat(64),
|
||||
pem_chain: '-----BEGIN CERTIFICATE-----\n...',
|
||||
csr_pem: '-----BEGIN CERTIFICATE REQUEST-----\n...',
|
||||
not_before: '2026-01-01T00:00:00Z',
|
||||
not_after: '2027-01-01T00:00:00Z',
|
||||
key_algorithm: 'ECDSA',
|
||||
key_size: 256,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
expect(v.serial_number).toBe('01:02:03');
|
||||
expect(v.key_algorithm).toBe('ECDSA');
|
||||
expect(v.key_size).toBe(256);
|
||||
});
|
||||
});
|
||||
|
||||
+12
-5
@@ -1,3 +1,15 @@
|
||||
// D-5 (cat-f-ae0d06b6588f, master): the five per-issuance fields
|
||||
// (serial_number, fingerprint_sha256, key_algorithm, key_size,
|
||||
// issued_at) USED to live here as optional. They were never emitted
|
||||
// by Go's `ManagedCertificate` (internal/domain/certificate.go) — they
|
||||
// live on `CertificateVersion` (per-issuance evidence) and are fetched
|
||||
// via getCertificateVersions(id). Render-site consumers (notably
|
||||
// CertificateDetailPage) use `latestVersion?.field` as the canonical
|
||||
// access path. Pre-D-5 the optional declaration silently returned
|
||||
// `undefined` on every list response, so consumers who didn't know
|
||||
// about the version-fallback pattern rendered '—' for every cert; now
|
||||
// the missing-data case is explicit at the type level (a `cert.X`
|
||||
// access for one of these fields is a TS compile error).
|
||||
export interface Certificate {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -10,11 +22,6 @@ export interface Certificate {
|
||||
team_id: string;
|
||||
renewal_policy_id: string;
|
||||
certificate_profile_id: string;
|
||||
serial_number?: string;
|
||||
fingerprint_sha256?: string;
|
||||
key_algorithm?: string;
|
||||
key_size?: number;
|
||||
issued_at?: string;
|
||||
expires_at: string;
|
||||
revoked_at?: string;
|
||||
revocation_reason?: string;
|
||||
|
||||
Reference in New Issue
Block a user