Files
certctl/internal/ciparity/surface_parity_test.go
T
shankar0123 02438ad9e1 ci: floor raise + doc drift (Phase 3 closure — TEST-H1/H2/M1/M2/M3/M4/L1, ARCH-H3/L1/L2/L3/L4)
Twelve findings from the architecture diligence audit's Phase 3 bundle
closed in one PR. All touch the CI workflows + small doc-drift fixes
across the production Go tree + migration headers.

CI workflow changes
====================

TEST-H1 — Race detection on ./... -short
  .github/workflows/ci.yml:106 was a 9-package explicit list. Audit
  finding TEST-H1 flagged that 25+ packages (internal/auth/*,
  internal/repository/*, internal/mcp, internal/scep, internal/pkcs7,
  internal/api/router, internal/api/acme, internal/cli, internal/cms,
  internal/config, internal/deploy, internal/integration,
  internal/ratelimit, internal/secret, internal/trustanchor, all of
  cmd/) silently dropped off race coverage.
  Post-fix: 'go test -race -short ./... -count=1 -timeout 600s'.
  76 testing.Short() guards already cover testcontainers + live-DB
  integration suites, so -short keeps the long-running tests out.

TEST-H2 — Cross-platform build matrix
  New 'cross-platform-build' job in ci.yml. Matrix:
  ubuntu-latest + windows-latest + macos-latest, fail-fast: false.
  Builds cmd/server + cmd/agent + cmd/cli + cmd/mcp-server on each.
  Catches Windows-specific regressions (path separators, file
  permissions, exec.Command semantics) the pre-Phase-3 Ubuntu-only
  CI missed.

TEST-L1 — actions/setup-go cache: true (explicit)
  setup-go v5 defaults cache: true; making it explicit so a future
  setup-go upgrade can't silently flip it. Re-runs hit the Go module
  + build cache instead of recompiling cold.

TEST-M1 — Mutation-testing floor at 55%
  security-deep-scan.yml::go-mutesting step rewritten. Removed
  continue-on-error + per-package '|| true'. New post-loop check
  extracts every 'The mutation score is X.YZ' line and fails the
  step if any package drops below 0.55. Floor rationale: starter
  ratio catches major regressions without rejecting the audit's
  'this is OK' steady state; raise quarterly.

TEST-M2 — 3 advisory deep-scan gates promoted to blocking
  Removed continue-on-error: true from:
    - gosec (filtered to G201/G202/G304/G108 high-signal rules:
      SQL-injection + path-traversal + pprof-exposed)
    - osv-scanner (multi-ecosystem CVE; complements govulncheck
      which is already blocking in ci.yml)
    - trivy image scan (--severity HIGH,CRITICAL --exit-code 1)
  continue-on-error count: 15 → 11.
  ZAP / schemathesis / nuclei / testssl stay advisory because their
  false-positive rates on https://localhost:8443-targeted DAST runs
  are high.

TEST-M3 — Playwright harness stub
  web/package.json adds '@playwright/test' devDep + 'e2e' / 'e2e:install'
  npm scripts. web/playwright.config.ts ships single chromium project
  with webServer block pointing at 'npm run dev'. web/src/__tests__/
  e2e/smoke.spec.ts proves the harness wires through. The full 15-flow
  suite ships in frontend-design-audit Phase 8 (TEST-H1 in THAT audit);
  this is the wiring + a single smoke test as the regression floor.
  New Makefile target: 'make e2e-test'.

Doc/code drift fixes
====================

TEST-M4 + ARCH-L2 — Skip inventory artifact + CI guard
  scripts/skip-inventory.sh walks every t.Skip site under cmd/ +
  internal/ + deploy/test/ and emits docs/testing/skip-inventory.md
  grouped by package with file:line:expression triples. Current
  inventory: 142 t.Skip sites, 76 testing.Short() guards.
  scripts/ci-guards/skip-inventory-drift.sh regenerates and fails on
  diff (excluding the 'Last reviewed' timestamp line which drifts
  daily). The Markdown is the canonical acquisition-diligence artifact
  for 'what tests are being skipped and why.'

ARCH-H3 — MCP catalogue floor reconciliation
  Audit framing was '121 vs floor 150 — doc/code drift.' Live count
  via the test's actual regex over all 5 tool files (tools.go +
  tools_audit_fix.go + tools_auth.go + tools_auth_bundle2.go +
  tools_est.go): 155 unique 'Name: "certctl_*"' declarations.
  Pre-Phase-3 audit measured tools.go in isolation (121) and missed
  the other 4 files (+34 unique names). The test at
  internal/ciparity/surface_parity_test.go::TestSurfaceParity_MCP
  passes today (155 ≥ 150). Added a clarifying comment near
  mcpBaselineFloor explaining the measurement scope so future
  reviewers don't repeat the audit's framing error.
  STATUS: stale — no code drift, just a measurement scoping error in
  the audit.

ARCH-L1 — panic() rationale comments
  5 panic sites in production Go (excluding _test.go):
    - internal/repository/postgres/tx.go:84
    - internal/service/issuer.go:861 (mustJSON)
    - internal/service/est.go:728 (mustParseTime)
    - internal/service/acme.go:1288 (rand source failure — already documented)
    - internal/pkcs7/certrep.go:270 (OID marshal — already documented)
  Added ARCH-L1 rationale comments to the 3 sites that didn't have
  them. All 5 are defensible impossible-path / rethrow / hardcoded-
  constant guards.

ARCH-L3 — Migration IF-NOT-EXISTS carve-outs
  4 migrations skip the literal 'IF NOT EXISTS' token but ARE
  idempotent via different Postgres patterns:
    - 000014_policy_violation_severity_check.up.sql: ALTER TABLE
      ADD CONSTRAINT CHECK doesn't accept IF NOT EXISTS; idempotency
      via DROP CONSTRAINT IF EXISTS preamble.
    - 000018_audit_events_worm.up.sql: CREATE OR REPLACE FUNCTION
      + DROP TRIGGER IF EXISTS + CREATE TRIGGER + DO $$ pg_roles
      existence check. CREATE TRIGGER doesn't take IF NOT EXISTS.
    - 000030_rbac_admin_perms.up.sql: INSERT ... ON CONFLICT DO NOTHING.
    - 000039_audit_crit1_perms.up.sql: same INSERT + ON CONFLICT pattern.
  Added ARCH-L3 header comments to each explaining the carve-out so
  reviewers don't flag the missing literal token.
  STATUS: largely stale — migrations are already idempotent.

ARCH-L4 — TODO/FIXME → see #<descriptor>
  5 TODOs rewritten to the allowed 'see #<descriptor>' pattern:
    - internal/repository/postgres/auth.go:220 → see #bundle-2-scope-fk
    - internal/connector/discovery/gcpsm/gcpsm.go:547 → see #gcpsm-pagination
    - internal/service/audit.go:244 → see #audit-pagination-count
    - internal/service/job.go:295, 299 → see #validation-job-impl
  New CI guard scripts/ci-guards/no-todo-in-prod.sh grep-fails any
  new TODO/FIXME in cmd/ + internal/ (excluding _test.go); allows
  'see #N' / 'see #<descriptor>' patterns.

Sandbox limitation
==================
The 6.1 GB certctl working tree fills the sandbox volume; go1.25.10
toolchain download fails with 'no space left on device' (sandbox has
1.25.9; go.mod requires 1.25.10). Local 'go test' / 'go build' NOT
run in this commit. Operator must run 'make verify' on their
workstation before push per CLAUDE.md operating rules.

The smoke.spec.ts NOT executed in the sandbox (no chromium installed).
Operator runs 'cd web && npm install && npx playwright install
--with-deps chromium && npm run e2e' on first wire-up.

All CI guards (no-todo-in-prod, skip-inventory-drift, G-3
env-docs-drift, doc-rot-detector, and every existing guard) verified
clean by running each individually.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-TEST-H1,
        cowork/certctl-architecture-diligence-audit.html#fix-TEST-H2,
        cowork/certctl-architecture-diligence-audit.html#fix-TEST-M1,
        cowork/certctl-architecture-diligence-audit.html#fix-TEST-M2,
        cowork/certctl-architecture-diligence-audit.html#fix-TEST-M3,
        cowork/certctl-architecture-diligence-audit.html#fix-TEST-M4,
        cowork/certctl-architecture-diligence-audit.html#fix-TEST-L1,
        cowork/certctl-architecture-diligence-audit.html#fix-ARCH-H3,
        cowork/certctl-architecture-diligence-audit.html#fix-ARCH-L1,
        cowork/certctl-architecture-diligence-audit.html#fix-ARCH-L2,
        cowork/certctl-architecture-diligence-audit.html#fix-ARCH-L3,
        cowork/certctl-architecture-diligence-audit.html#fix-ARCH-L4
2026-05-13 20:10:08 +00:00

293 lines
10 KiB
Go

package ciparity
// surface_parity_test.go — per post-v2.1.0 anti-rot item 2 (Auditable
// Codebase Bundle).
//
// Three tests, all stdlib-only:
//
// 1. TestSurfaceParity_MCPToolCatalogue (HARD GATE)
// Every MCP tool name matches the `certctl_<word>(_<word>)*`
// convention; no duplicates across files; total count ≥
// mcpBaselineFloor. Catches accidental tool deletions + naming-
// convention drift.
//
// 2. TestSurfaceParity_CLICommandCatalogue (INFORMATIONAL — t.Log only)
// Walks cmd/cli/main.go's switch-case dispatcher. Per frozen
// decision 0.9, warn-only until the CLI surface stabilizes.
//
// 3. TestSurfaceParity_OpenAPI_MCPHeuristicCoverage (INFORMATIONAL)
// Reports the fraction of OpenAPI operations whose path tokens
// overlap with any MCP tool name token. Trend metric, not a gate.
//
// 4. TestSurfaceParity_Summary (INFORMATIONAL)
// One-glance summary of the four surface counts.
//
// All file paths are resolved relative to the repo root, which this
// test discovers by walking up from $PWD until it finds go.mod. Keeps
// the test runnable from any working directory.
import (
"errors"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
// mcpBaselineFloor — see header doc. Bump when a deletion is
// deliberate; the diff captures the change.
//
// Phase 3 ARCH-H3 reconciliation (2026-05-13): the audit framing
// "121 vs floor 150 — doc/code drift" was a measurement scoping error
// — the audit counted only internal/mcp/tools.go (which has 121
// AddTool calls) and missed the four sibling files listed in
// mcpToolFiles() below (tools_audit_fix.go + tools_auth.go +
// tools_auth_bundle2.go + tools_est.go) that add another 34 unique
// names. Live total: 155 unique `Name: "certctl_*"` declarations
// across the 5 files, ≥ 150. This test therefore passes today.
//
// Bumping the floor: when the catalogue legitimately grows, raise
// this constant in the same commit that adds the new tools so the
// floor tracks the ratchet. Lower only when a deletion is intentional
// and documented in surface-parity-mcp-exemptions.yaml.
const mcpBaselineFloor = 150
var (
mcpToolNameRe = regexp.MustCompile(`^certctl_[a-z][a-z0-9_]*[a-z0-9]$`)
mcpNameDeclRe = regexp.MustCompile(`Name:\s*"(certctl_[a-z0-9_]+)"`)
openapiPathRe = regexp.MustCompile(`^ (/[^:]+):\s*$`)
openapiVerbRe = regexp.MustCompile(`^ (get|post|put|delete|patch|options|head):\s*$`)
caseLiteralRe = regexp.MustCompile(`case\s+"([a-z\-]+)":`)
)
// mcpToolFiles lists the (non-test) Go files expected to register
// MCP tools.
func mcpToolFiles(repo string) []string {
base := filepath.Join(repo, "internal", "mcp")
return []string{
filepath.Join(base, "tools.go"),
filepath.Join(base, "tools_audit_fix.go"),
filepath.Join(base, "tools_auth.go"),
filepath.Join(base, "tools_auth_bundle2.go"),
filepath.Join(base, "tools_est.go"),
}
}
func findRepoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
cur := wd
for {
if _, err := os.Stat(filepath.Join(cur, "go.mod")); err == nil {
return cur
}
parent := filepath.Dir(cur)
if parent == cur {
t.Fatalf("no go.mod found from %s upward", wd)
}
cur = parent
}
}
// readFileOrSkip reads a file; on ENOENT, calls t.Skipf rather than
// failing — useful for files that may be renamed during refactors.
func readFileOrFail(t *testing.T, p string) []byte {
t.Helper()
body, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected file missing: %s (refactor? — update the test)", p)
}
t.Fatalf("read %s: %v", p, err)
}
return body
}
// TestSurfaceParity_MCPToolCatalogue is a HARD gate.
//
// Asserts:
//
// - Every MCP tool name conforms to certctl_<word>(_<word>)*.
// - No tool name is registered in more than one tools*.go file.
// - Total tools ≥ mcpBaselineFloor.
func TestSurfaceParity_MCPToolCatalogue(t *testing.T) {
repo := findRepoRoot(t)
names := map[string]string{}
for _, path := range mcpToolFiles(repo) {
body := readFileOrFail(t, path)
base := filepath.Base(path)
for _, m := range mcpNameDeclRe.FindAllStringSubmatch(string(body), -1) {
name := m[1]
if !mcpToolNameRe.MatchString(name) {
t.Errorf("MCP tool name %q in %s does not match certctl_<word>(_<word>)* — fix the name or relax the convention deliberately",
name, base)
continue
}
if other, dup := names[name]; dup {
t.Errorf("MCP tool name %q duplicated: first in %s, again in %s — pick a unique name",
name, filepath.Base(other), base)
continue
}
names[name] = path
}
}
if len(names) < mcpBaselineFloor {
t.Errorf("MCP tool count regressed: %d found, baseline floor %d. "+
"If the deletion is intentional, lower mcpBaselineFloor in this test in the SAME commit. "+
"If accidental, restore the deleted tools.",
len(names), mcpBaselineFloor)
}
t.Logf("MCP tool catalogue: %d tools (baseline floor %d)", len(names), mcpBaselineFloor)
}
// TestSurfaceParity_CLICommandCatalogue — informational only.
//
// Walks cmd/cli/main.go's switch-case dispatcher and reports the
// distinct verbs handled. Returns success regardless of contents.
// Promoted to fail-on-miss when the CLI surface stabilizes.
func TestSurfaceParity_CLICommandCatalogue(t *testing.T) {
repo := findRepoRoot(t)
body := readFileOrFail(t, filepath.Join(repo, "cmd", "cli", "main.go"))
verbs := map[string]struct{}{}
for _, m := range caseLiteralRe.FindAllStringSubmatch(string(body), -1) {
verbs[m[1]] = struct{}{}
}
if len(verbs) == 0 {
t.Fatal("CLI scanner found zero verbs — likely a refactor; update the test")
}
out := make([]string, 0, len(verbs))
for v := range verbs {
out = append(out, v)
}
sort.Strings(out)
t.Logf("CLI verb catalogue (%d distinct case literals; informational only per frozen decision 0.9):\n %s",
len(out), strings.Join(out, ", "))
}
// scanOpenAPIOps walks openapi.yaml's paths block and returns every
// (METHOD, PATH) tuple. Mirrors the parser used by
// internal/api/router/openapi_parity_test.go::scanOpenAPIOperations.
func scanOpenAPIOps(t *testing.T, path string) []string {
t.Helper()
body := readFileOrFail(t, path)
var out []string
inPaths := false
currentPath := ""
for _, line := range strings.Split(string(body), "\n") {
if strings.HasPrefix(line, "paths:") {
inPaths = true
continue
}
if inPaths && line != "" && !strings.HasPrefix(line, " ") {
inPaths = false
continue
}
if !inPaths {
continue
}
if m := openapiPathRe.FindStringSubmatch(line); m != nil {
currentPath = m[1]
continue
}
if m := openapiVerbRe.FindStringSubmatch(line); m != nil && currentPath != "" {
out = append(out, strings.ToUpper(m[1])+" "+currentPath)
}
}
return out
}
// scanRouterRoutes scans internal/api/router/router.go for route
// registrations. Uses a regex (not go/ast) to keep this package
// stdlib-only; the router.go file is large but the patterns we care
// about are stable enough that regex is sufficient.
func scanRouterRoutes(t *testing.T, path string) []string {
t.Helper()
body := readFileOrFail(t, path)
// Match r.Register("METHOD /path", ...) and r.mux.Handle("METHOD /path", ...).
registerRe := regexp.MustCompile(`r\.Register\(\s*"([A-Z]+ /[^"]+)"`)
muxHandleRe := regexp.MustCompile(`r\.mux\.Handle\(\s*"([A-Z]+ /[^"]+)"`)
seen := map[string]struct{}{}
for _, m := range registerRe.FindAllStringSubmatch(string(body), -1) {
seen[m[1]] = struct{}{}
}
for _, m := range muxHandleRe.FindAllStringSubmatch(string(body), -1) {
seen[m[1]] = struct{}{}
}
out := make([]string, 0, len(seen))
for s := range seen {
out = append(out, s)
}
return out
}
// TestSurfaceParity_OpenAPI_MCPHeuristicCoverage — informational.
//
// For each OpenAPI operation, splits the path into tokens; if any
// token appears in any MCP tool name's token set, the op is "covered."
// This is a heuristic — the actual semantic map (operationId →
// MCP tool) is not declared in the source, so we approximate.
func TestSurfaceParity_OpenAPI_MCPHeuristicCoverage(t *testing.T) {
repo := findRepoRoot(t)
specOps := scanOpenAPIOps(t, filepath.Join(repo, "api", "openapi.yaml"))
mcpTokens := map[string]struct{}{}
for _, path := range mcpToolFiles(repo) {
body := readFileOrFail(t, path)
for _, m := range mcpNameDeclRe.FindAllStringSubmatch(string(body), -1) {
for _, tok := range strings.Split(strings.TrimPrefix(m[1], "certctl_"), "_") {
mcpTokens[tok] = struct{}{}
}
}
}
covered := 0
for _, op := range specOps {
parts := strings.Split(op, " ")
if len(parts) != 2 {
continue
}
segs := strings.FieldsFunc(parts[1], func(r rune) bool {
return r == '/' || r == '{' || r == '}' || r == '-'
})
hit := false
for _, s := range segs {
if _, ok := mcpTokens[strings.ToLower(s)]; ok {
hit = true
break
}
}
if hit {
covered++
}
}
if len(specOps) == 0 {
t.Fatal("openapi.yaml scan returned zero operations — fix the test")
}
pct := (covered * 100) / len(specOps)
t.Logf("OpenAPI↔MCP heuristic coverage (informational): %d of %d ops (%d%%) share at least one path token with an MCP tool name",
covered, len(specOps), pct)
}
// TestSurfaceParity_Summary prints the four surface counts side-by-side.
func TestSurfaceParity_Summary(t *testing.T) {
repo := findRepoRoot(t)
routes := scanRouterRoutes(t, filepath.Join(repo, "internal", "api", "router", "router.go"))
specOps := scanOpenAPIOps(t, filepath.Join(repo, "api", "openapi.yaml"))
mcpCount := 0
for _, path := range mcpToolFiles(repo) {
body := readFileOrFail(t, path)
mcpCount += len(mcpNameDeclRe.FindAllStringSubmatch(string(body), -1))
}
cliBody := readFileOrFail(t, filepath.Join(repo, "cmd", "cli", "main.go"))
cliCount := len(caseLiteralRe.FindAllStringSubmatch(string(cliBody), -1))
t.Logf("Surface parity summary (informational):\n"+
" router routes : %d\n"+
" OpenAPI ops : %d\n"+
" MCP tools : %d (floor %d)\n"+
" CLI verbs : %d (warn-only — frozen decision 0.9)",
len(routes), len(specOps), mcpCount, mcpBaselineFloor, cliCount)
}