# Changelog All notable changes to certctl are documented in this file. Dates use ISO 8601. Versions follow [Semantic Versioning](https://semver.org/). ## [unreleased] — 2026-04-27 ### Bundle I (Coverage Audit Closure — QA Doc Cleanup): H-007 + H-008 closed > Applied Patches 1–7 from `coverage-audit-2026-04-27/tables/qa-doc-patches.md` to bring `docs/qa-test-guide.md` and `deploy/test/qa_test.go` back in sync with the code at HEAD. Acquisition-readiness QA-doc score lifts 2.5 → 4.0. `docs/qa-test-guide.md` updates: - **Patch 1 — Headline.** "covers all 54 Parts" → "49 of 56 Parts" + 4-not-yet-automated callout (Parts 23, 24, 55, 56). - **Patch 2 — Totals line.** Replaced the static "~164 automated subtests" prose with a verified-2026-04-27 breakdown + recompute commands so the line stops drifting on every release. - **Patch 3 — Coverage Map.** Added rows for Parts 23 (S/MIME & EKU), 24 (OCSP/CRL), 55 (Agent Soft-Retirement), 56 (Notification Retry & Dead-Letter) — each annotated "0 (NOT AUTOMATED)" with a `docs/testing-guide.md::Part N` pointer. - **Patch 4 — What This Test Does NOT Cover.** New "Not Yet Automated (Parts 23, 24, 55, 56)" subsection enumerating the gaps and their manual-test rationale. - **Patch 5 — Seed Data Reference.** Re-anchored against authoritative HEAD `migrations/seed_demo.sql` counts: **32 certs (already correct), 12 agents (was 9 — 8 named ag-* + server-scanner sentinel + 3 cloud-discovery sentinels), 13 issuers (was 9), 8 targets (already correct), 4 network scan targets (already correct).** Replaced narrow ID enumerations with `sed | grep` recompute commands so future seed additions don't silently drift the doc. Added a maintenance-note pointer to the proposed CI guard (Strengthening #6). Bundle I's Phase 0 recon discovered the original patch's anticipated counts (66 certs, 18 agents) were themselves drifted — the patch's recompute commands used overbroad regex that matched mc-* IDs across non-managed-certificates tables; corrected on the fly. - **Patch 6 — Version History.** Added v1.2 entry citing Parts 55–56 documentation and Parts 23–24 not-yet-automated surfacing. - Bonus fix: the integration_test comparison row "32 certs, 8 agents" → "32 certs, 12 agents, 13 issuers, 8 targets, realistic history". `deploy/test/qa_test.go` updates (Patch 7): - 4 new `t.Run("PartN_*", …)` blocks for Parts 23, 24, 55, 56. Each calls `t.Skip` with a `docs/testing-guide.md::Part N` pointer + automation-candidates list. The Skip-with-rationale form keeps Part numbering consistent in test output, makes the manual-test pointer machine-readable, and surfaces the gap to maintainers. Replacing each Skip with a real test body is gap-backlog work; this commit only closes the doc-vs-test drift. Verification gates met: - `grep -cE '^## Part [0-9]+:' docs/testing-guide.md` == 56 ✓ - `grep -cE 't\.Run\("Part[0-9]+_' deploy/test/qa_test.go` == 53 ✓ (49 live + 4 new Skip stubs) - `go vet -tags qa ./deploy/test/...` clean - `go test -tags qa -run='__nope__' ./deploy/test/...` PASS (compile) - The full `go test -tags qa -run='TestQA/Part(23|24|55|56)' -v` SKIP-grep gate requires the live demo stack and is operator-runnable; the test bodies trivially `t.Skip` when reached. Audit deliverable updates: `findings.yaml` flips H-007 (`-0014`) and H-008 (`-0015`) status `open` → `closed` with closure_note + corrected counts; `gap-backlog.md` strikethroughs both rows + adds Bundle I closure-log entry; `tables/qa-doc-drift.md` gains a "PATCHES APPLIED 2026-04-27" header marker (preserved as audit-time snapshot, not retro-edited); `acquisition-readiness.md` "QA documentation rigor" criterion: 2.5 → 4.0; `coverage-audit-closure-plan.md` checklist ticks Bundle I. ### Bundle 0.7 (Coverage Audit Closure): cmd/agent key-handling regression coverage — C-008 closed > Phase 0 of the 2026-04-27 coverage audit's closure plan triggered a halt-condition: `cmd/agent/keymem.go`'s two security-critical functions were at 0.0% / 11.1% line coverage despite being defense-in-depth for agent private-key memory hygiene (Bundle 9 / Audit L-002 + L-003 — agent edition). Bundle 0.7 was inserted before Bundle J as mandatory; this entry closes finding **C-008** (`CRTCTL-COVAUDIT-2026-04-27-0034`). `cmd/agent/keymem_test.go` (~510 LoC, 17 top-level test functions) ships: - **`marshalAgentKeyAndZeroize` regression coverage** — happy path, nil-key guard (asserts `onDER` is NOT invoked), upstream error propagation via `errors.Is`, and the **DER-buffer-zeroized-after-return invariant** verified observably: capture the slice header inside `onDER` (sharing the backing array, NOT a deep copy), then assert every byte reads `0x00` after the function returns. Pinned for both the happy path AND the `onDER`-error path. A future refactor that drops the `defer clear(der)` line would break the test even if the simpler assertions still pass. Also adds a "contract violator" defense test: a buggy caller that retains the slice past `onDER` reads zeros, not the private scalar. - **`ensureAgentKeyDirSecure` regression coverage** — 13-row table-driven matrix covering empty/dot/root refuse with documented error wrap, create-with-0700, create-nested-0700, accept-existing-0700 (no-op short-circuit), tighten 0750/0755/0777 to 0700, accept-existing-0500/0400 (owner-only-no-write `mode&0o077 == 0` branch, no chmod), `filepath.Clean` normalization (trailing slash + dot prefix). Plus PathIsAFile (documents current behavior — function chmod's a file path silently, not a correctness bug per current call sites but a hardening candidate filed against any future refactor), Idempotent, Concurrent (`-race` clean across 8 goroutines), Stat/Mkdir/Chmod error-propagation paths (root-required ones `t.Skip` cleanly on non-root CI rather than being absent), and Format-includes-cleaned-path debuggability assertion. - **End-to-end smoke** (`TestKeymem_AgentMainFlowSmoke`) replaying `cmd/agent/main.go`'s composition: `ensureAgentKeyDirSecure` → `marshalAgentKeyAndZeroize`. Coverage delta: | | Pre-Bundle-0.7 | Post-Bundle-0.7 | Gate | Met? | |---|---|---|---|---| | `cmd/agent/keymem.go::marshalAgentKeyAndZeroize` | 0.0% | **85.7%** | ≥85% | ✓ | | `cmd/agent/keymem.go::ensureAgentKeyDirSecure` | 11.1% | **94.4%** | ≥85% | ✓ | | `cmd/agent` overall | 54.3% | **57.7%** (+3.4pp) | (≥75% stretch) | △ partial | Verification: `go test -race -count=3 ./cmd/agent/...` clean (0 races); `gofmt -l` clean; `go vet ./cmd/agent/...` clean; `staticcheck ./cmd/agent/...` clean. The cmd/agent overall ≥75% stretch target is unachievable from a keymem-only test file (the package's bulk — `Run`, `main`, `executeCSRJob`, `executeDeploymentJob`, `verifyAndReportDeployment` — is unrelated to key-handling and dominates the denominator); the remaining lift is tracked as a follow-on cmd/agent flow-test bundle. Audit deliverable updates: `coverage-audit-2026-04-27/findings.yaml` flips C-008 `open` → `closed` with closure note + post-Bundle coverage numbers; `gap-backlog.md` adds a closure log entry and partial-closure note on H-006; `coverage-matrix.md` updates the cmd/agent row from "NOT MEASURED" to 57.7%; `coverage-report.md::Phase 0 Results` appends a Bundle 0.7 closure block with the coverage delta table and pinned-invariant list; `coverage-audit-closure-plan.md` checklist ticks Bundle 0.7. **Bundle J (ACME failure-mode coverage) unblocked.** ### Bundle H (M-029 Drain — AUDIT FULLY CLOSED): 1 audit finding closed across 3 passes > Closes the last remaining open finding from the 2026-04-25 audit. **Score: 54/55 → 55/55 (100%); deferred 7/7 (100%); AUDIT CLOSED.** The M-029 frontend per-page migration backlog was framed by Bundle 8 as incremental ("closes per-PR as each page ships"); Bundle H shipped all three passes end-to-end across 9 merged commits to master rather than spread per-PR. #### Pass 1: useMutation → useTrackedMutation (56 sites, 6 batches) All 56 bare `useMutation` call sites in `web/src/` migrated to the Bundle 8 wrapper, which enforces the M-009 invalidation contract per-site via a discriminated-union type (`invalidates: QueryKey[] | 'noop'`). The wrapper invalidates BEFORE invoking the caller's onSuccess, so user code drops the redundant `qc.invalidateQueries` calls and lets the wrapper's contract become the source of truth. | Batch | Pages migrated | Sites | Commit | |---|---|---|---| | 1 | AgentsPage, CertificatesPage, DigestPage, IssuerDetailPage | 4 | `08ffbad` | | 2 | DashboardPage, DiscoveryPage, NotificationsPage, TargetDetailPage, TargetsPage | 10 | `73c6883` | | 3 | HealthMonitorPage, AgentGroupsPage, JobsPage | 9 | `64c6cd0` | | 4 | OwnersPage, PoliciesPage, ProfilesPage, RenewalPoliciesPage, TeamsPage | 15 | `d5541fe` | | 5 | IssuersPage, NetworkScanPage | 8 | `1c960ff` | | 6 | CertificateDetailPage, OnboardingWizard | 10 | `1baefd4` | Total Pass 1: **56 → 0 bare `useMutation` sites**; 0 → 61 `useTrackedMutation` sites. (Pass 1's count grew net positive because some 5-mutation pages collapsed two `qc.invalidateQueries` calls into one `invalidates` array literal.) After Pass 1 completed, `0266f2b` tightened the `.github/workflows/ci.yml` M-009 guard from a soft-budget gate (`useMutation ≤ invalidations + 5`) to a hard-zero invariant: any bare `useMutation` call in `web/src/` outside `web/src/hooks/useTrackedMutation.ts` (the wrapper itself) fails CI immediately. Strictly stronger than the prior +5 budget; failure mode also improves — operators get the exact `file:line` of the offending bare call instead of a count delta. #### Pass 2: useState pagination → useListParams (1 site, 1 commit) Bundle 8's recon estimate of ~14 list pages turned out to be wrong: **only `CertificatesPage` had real UI-driven pagination state** (`setPage`/`setPerPage` with 7 filter `useState` hooks). Most other pages either fetch filter-dropdown sidecars with hardcoded `per_page` (not pagination) or were already using `useSearchParams` directly. `99f52a6` collapses CertificatesPage's 9 useState hooks (statusFilter, envFilter, issuerFilter, ownerFilter, profileFilter, teamFilter, expiresBefore, sortBy, page, perPage) into a single `useListParams({ pageSize: 50 })` call. Effect: - All 8 filter onChange handlers now call `setFilter('', value)`. - `setFilter` automatically resets page to 1 on every filter / sort change, so the manual `setPage(1)` calls at three sites (team / expires_before / sort) are no longer needed — the F-1 contract is now hook-enforced. - Pagination handler simplified: `onPerPageChange: setPageSize` (the hook drops the page param from the URL when pageSize changes). - All filter / sort / pagination state is now URL-resident (`?filter[status]=Active&page=2&page_size=50`) — deep-link + browser-back correct. The existing CertificatesPage.test.tsx F-1 contract tests (5 cases: getCertificates params for team_id, expires_before, sort, plus page-reset on filter and per_page change) all continue to pass against the new shape. #### Pass 3: Per-page render + XSS-hardening test files for the 14 T-1-deferred pages (3 batches) Each new test: - Renders the page with mock data containing `` payloads in every text-rendering field. - Asserts `document.querySelectorAll('script[data-xss=""]')` is empty post-render. - Asserts `window.__xss_pwned__` stays undefined (no global side-effect from the script body). - Asserts `document.body.textContent` contains the literal `