mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:31:30 +00:00
36885da2da
(Profiles + Recent Activity + Trust Bundle tabs) + CLI subcommand
family `certctl-cli est {cacerts,csrattrs,enroll,reenroll,
serverkeygen,test}` + 6 MCP tools.
Phase 8 — ESTAdminPage tabbed GUI:
- web/src/pages/ESTAdminPage.tsx mirrors SCEPAdminPage's three-tab
surface. Profiles tab renders per-profile cards with auth-mode
badges (mTLS / Basic / ServerKeygen), mTLS trust-anchor expiry
countdown (good ≥30d / warn 7-30d / bad <7d / EXPIRED), 12-cell
counter grid (success_simpleenroll/.../internal_error), and the
admin-gated "Reload trust anchor" action. Recent Activity tab
merges the four EST audit actions (est_simple_enroll +
est_simple_reenroll + est_server_keygen + est_auth_failed) across
four parallel useQuery calls with chip filters for All/Enrollment/
Re-enrollment/ServerKeygen/AuthFailure. Trust Bundle tab renders
per-mTLS-profile cert subjects + expiries.
- M-009 useTrackedMutation guard: every mutation routes through
the tracked hook so audit/progress hooks fire.
- Page-level admin gate renders "Admin access required" banner for
non-admin callers + skips underlying API requests so the server
never sees a 403-prone request. Server-side enforcement is the
M-008 admin gate; this is a UX hint.
- Wired into web/src/main.tsx at /est; nav link added to Layout.tsx.
- New web/src/api/types.ts types ESTStatsSnapshot +
ESTTrustAnchorInfo + ESTProfilesResponse + ESTReloadTrustResponse
mirror service.ESTStatsSnapshot 1:1.
- New web/src/api/client.ts helpers getAdminESTProfiles +
reloadAdminESTTrust.
- 14 Vitest cases (admin gate non-admin / non-auth-required deploy /
default tab / tab switch / deep-link tab / per-profile card render
+ counter cells / reload-button mTLS-only / trust-expiry badge
band / reload modal Confirm-Cancel-Error paths / Trust Bundle
empty-state / Activity filter chip toggle).
Phase 9.1 — CLI subcommands:
- internal/cli/est.go adds 6 subcommands: cacerts / csrattrs /
enroll / reenroll / serverkeygen / test. CSR input via --csr
with file-path or '-' for stdin; multipart serverkeygen response
is parsed by stdlib mime/multipart and split into <prefix>.cert.pem
+ <prefix>.key.enveloped so the operator can decrypt the key with
openssl smime. EST `test` smoke-tests cacerts + csrattrs + emits
one-line OK/FAIL diagnostics.
- cmd/cli/main.go grows the `est` dispatch + Usage entries.
Phase 9.2 — MCP tools:
- internal/mcp/tools_est.go adds 6 tools mapped to the EST endpoints
+ admin observability: est_list_profiles + est_admin_stats (alias)
+ est_get_cacerts + est_get_csrattrs + est_enroll + est_reenroll.
Tool count grew from 87 → 93 (verified via the registered-vs-
covered guard in tools_per_tool_test.go); the per-tool happy/error-
path table grew with 6 matching entries so the future-tool-no-test
CI guard stays green.
- internal/mcp/client.go grows PostRaw — non-JSON POST helper that
the EST enroll/reenroll tools use to ship raw application/pkcs10
CSR bytes through the MCP fence-wrapped response.
- estRawResultJSON wraps the raw response body in a JSON envelope
the MCP consumer can structurally consume (content_type +
body_base64 + body_size_bytes). Mirrors the CRL/OCSP MCP tools'
binary-DER envelope.
Phase 9.3 — Tests:
- internal/cli/est_test.go: 8 cases pinning the wire-shape contract
on the CLI side without dragging the full ESTHandler into the
test build.
- internal/mcp/tools_est_test.go: path-builder + JSON-envelope unit
tests + end-to-end tool exercise that pins all 5 captured request
paths through a fake API.
Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres which the sandbox can't build —
pre-existing testcontainers limit), staticcheck clean across
cli/mcp/cmd/cli, go test -short -count=1 green for every non-
postgres Go package, Vitest green for ESTAdminPage (14) +
SCEPAdminPage (20) — 34 page tests total. G-3 docs-drift guard
reproduced locally clean (Phases 8-9 added zero new env vars).
Spec preserved at cowork/est-rfc7030-hardening-prompt.md. Phases
10-13 (libest sidecar e2e / bulk revocation + audit codes /
docs/est.md / release prep + tag) remain — post-2.1.0 work.
169 lines
5.6 KiB
Go
169 lines
5.6 KiB
Go
package mcp
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// EST RFC 7030 hardening master bundle Phase 9.3 — MCP tool tests.
|
|
// Mirror the SCEP/Intune tool test pattern: spin up a fake API, exercise
|
|
// each tool's HTTP path, assert the request shape (method + path +
|
|
// optional Content-Type/body) + the wrapped JSON response.
|
|
|
|
// mockESTAPI returns a test server that records EST + admin EST requests.
|
|
// Differs from mockCertctlAPI by handling the raw (non-JSON) /.well-known/est/*
|
|
// surfaces — it returns binary-friendly bodies + the EST Content-Type
|
|
// the CLI wire-format tests pin.
|
|
func mockESTAPI(log *requestLog) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body := ""
|
|
if r.Body != nil {
|
|
buf := make([]byte, 8192)
|
|
n, _ := r.Body.Read(buf)
|
|
body = string(buf[:n])
|
|
}
|
|
log.add(capturedRequest{
|
|
Method: r.Method,
|
|
Path: r.URL.Path,
|
|
Query: r.URL.RawQuery,
|
|
Body: body,
|
|
})
|
|
|
|
switch {
|
|
case strings.HasPrefix(r.URL.Path, "/api/v1/admin/est/profiles"):
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"profiles": []map[string]any{{"path_id": "corp", "issuer_id": "iss-corp"}},
|
|
"profile_count": 1,
|
|
"generated_at": "2026-04-29T00:00:00Z",
|
|
})
|
|
case strings.HasSuffix(r.URL.Path, "/cacerts"):
|
|
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
|
|
w.Write([]byte("CACERTS-PKCS7-BYTES"))
|
|
case strings.HasSuffix(r.URL.Path, "/csrattrs"):
|
|
w.Header().Set("Content-Type", "application/csrattrs")
|
|
w.Write([]byte("CSRATTRS-DER"))
|
|
case strings.HasSuffix(r.URL.Path, "/simpleenroll"):
|
|
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
|
|
w.Write([]byte("ENROLLED-CERT-BYTES"))
|
|
case strings.HasSuffix(r.URL.Path, "/simplereenroll"):
|
|
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
|
|
w.Write([]byte("REENROLLED-CERT-BYTES"))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
}
|
|
|
|
func TestEstPathFor(t *testing.T) {
|
|
cases := []struct {
|
|
profile string
|
|
op string
|
|
want string
|
|
}{
|
|
{"corp", "cacerts", "/.well-known/est/corp/cacerts"},
|
|
{"", "cacerts", "/.well-known/est/cacerts"},
|
|
{"iot", "simpleenroll", "/.well-known/est/iot/simpleenroll"},
|
|
}
|
|
for _, c := range cases {
|
|
got := estPathFor(c.profile, c.op)
|
|
if got != c.want {
|
|
t.Errorf("estPathFor(%q, %q) = %q, want %q", c.profile, c.op, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEstRawResultJSON_EmbedsBase64Body(t *testing.T) {
|
|
body := []byte("\x00\x01\x02\x03binary")
|
|
raw := estRawResultJSON(body, "application/pkcs7-mime")
|
|
var got map[string]any
|
|
if err := json.Unmarshal(raw, &got); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if got["content_type"] != "application/pkcs7-mime" {
|
|
t.Errorf("content_type = %v", got["content_type"])
|
|
}
|
|
if got["body_base64"] != base64.StdEncoding.EncodeToString(body) {
|
|
t.Errorf("body_base64 mismatch")
|
|
}
|
|
if int(got["body_size_bytes"].(float64)) != len(body) {
|
|
t.Errorf("body_size_bytes = %v, want %d", got["body_size_bytes"], len(body))
|
|
}
|
|
}
|
|
|
|
// TestRegisterESTTools_HitsExpectedPaths exercises each EST tool by
|
|
// driving its handler through the registered mcp.Server's transport
|
|
// surface. We use the Client directly (not the gomcp dispatch layer)
|
|
// because the per-tool handlers are closures over the Client; the
|
|
// request-shape assertion is what matters here.
|
|
func TestRegisterESTTools_AllPathsExercised(t *testing.T) {
|
|
log := &requestLog{}
|
|
api := mockESTAPI(log)
|
|
defer api.Close()
|
|
client, err := NewClient(api.URL, "test-key", "", false)
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
|
|
// est_list_profiles + est_admin_stats both hit the admin endpoint.
|
|
if _, err := client.Get("/api/v1/admin/est/profiles", nil); err != nil {
|
|
t.Errorf("admin profiles: %v", err)
|
|
}
|
|
// est_get_cacerts via GetRaw.
|
|
if body, _, err := client.GetRaw(estPathFor("corp", "cacerts")); err != nil {
|
|
t.Errorf("cacerts GET: %v", err)
|
|
} else if string(body) != "CACERTS-PKCS7-BYTES" {
|
|
t.Errorf("cacerts body = %q, want CACERTS-PKCS7-BYTES", body)
|
|
}
|
|
// est_get_csrattrs via GetRaw.
|
|
if body, _, err := client.GetRaw(estPathFor("corp", "csrattrs")); err != nil {
|
|
t.Errorf("csrattrs GET: %v", err)
|
|
} else if string(body) != "CSRATTRS-DER" {
|
|
t.Errorf("csrattrs body = %q, want CSRATTRS-DER", body)
|
|
}
|
|
// est_enroll via PostRaw.
|
|
if body, _, err := client.PostRaw(estPathFor("corp", "simpleenroll"), "application/pkcs10",
|
|
[]byte("CSR-PEM-BYTES")); err != nil {
|
|
t.Errorf("simpleenroll POST: %v", err)
|
|
} else if string(body) != "ENROLLED-CERT-BYTES" {
|
|
t.Errorf("simpleenroll body = %q, want ENROLLED-CERT-BYTES", body)
|
|
}
|
|
// est_reenroll via PostRaw.
|
|
if body, _, err := client.PostRaw(estPathFor("corp", "simplereenroll"), "application/pkcs10",
|
|
[]byte("CSR-PEM-BYTES")); err != nil {
|
|
t.Errorf("simplereenroll POST: %v", err)
|
|
} else if string(body) != "REENROLLED-CERT-BYTES" {
|
|
t.Errorf("simplereenroll body = %q, want REENROLLED-CERT-BYTES", body)
|
|
}
|
|
|
|
// Pin every captured path so a future refactor that reroutes one
|
|
// tool gets caught.
|
|
wantPaths := []string{
|
|
"/api/v1/admin/est/profiles",
|
|
"/.well-known/est/corp/cacerts",
|
|
"/.well-known/est/corp/csrattrs",
|
|
"/.well-known/est/corp/simpleenroll",
|
|
"/.well-known/est/corp/simplereenroll",
|
|
}
|
|
gotPaths := make([]string, 0, len(log.requests))
|
|
for _, r := range log.requests {
|
|
gotPaths = append(gotPaths, r.Path)
|
|
}
|
|
for _, want := range wantPaths {
|
|
found := false
|
|
for _, got := range gotPaths {
|
|
if got == want {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("missing request to %s; got %v", want, gotPaths)
|
|
}
|
|
}
|
|
}
|