mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:41:30 +00:00
0e06f6c4fc
Closes findings P3-1 and P3-2 from the 2026-05-05 CLI/API/MCP↔GUI parity
audit (cowork/cli-gui-parity-audit-2026-05-05/RESULTS.md). Both findings
flagged hidden defaults that the CLI was sending without exposing them
to operators: `force=false` baked into every renew payload, and a silent
fallback to `reason="unspecified"` whenever --reason was omitted.
P3-1 — promote --force on `certs renew` (full end-to-end plumbing)
The pre-2026-05-05 CLI sent `{"force": false}` in the renew body. The
API handler never decoded it — a textbook "lying field" per the
operator's CLAUDE.md "complete path, not the easy path" rule: the body
field stored a value, claimed to do something, and silently did nothing
because the wire never reached the consumer. Adding a --force flag that
also went unread would have created another lying field.
This commit takes the complete path:
service.CertificateService.TriggerRenewal grew a `force bool` parameter
(internal/service/certificate.go). When force=true, the
RenewalInProgress block is overridden so operators can recover stuck
in-flight renewals where a previous job hung without releasing the
status flag. Archived and Expired remain terminal blockers regardless
of force — those are semantic dead-ends that --force should not paper
over (archived = decommissioned, expired = issue a new cert instead of
renewing a dead one).
handler.CertificateHandler.TriggerRenewal parses force from
?force=true (or ?force=1) query param, OR {"force": true} JSON body,
whichever the client picks. Defaults to false. Passes through to the
service.
internal/cli/client.go::RenewCertificate(id, force bool) sends
?force=true on the URL when --force is set. The historical hardcoded
`{"force": false}` body is gone — no more lying field.
cmd/cli/main.go dispatches `certs renew <id> [--force]` (ID-first
flag-second convention matches the existing `agents retire <id>
[--force]`).
P3-2 — require --reason on `certs revoke` (Option A: strict refusal)
The pre-2026-05-05 CLI dropped to `--reason unspecified` whenever the
operator omitted the flag. Compliance reporting (RFC 5280 §5.3.1, PCI-
DSS §3.6, HIPAA §164.312) relies on the reason code being meaningful;
silent fallback defeats the audit trail because every revocation looks
identical.
cmd/cli/main.go dispatch refuses to send when --reason is empty,
prints the canonical RFC 5280 §5.3.1 reason-code menu, and exits
non-zero.
internal/cli/client.go exposes ValidRevokeReasons() returning the
canonical camelCase list (unspecified, keyCompromise, caCompromise,
affiliationChanged, superseded, cessationOfOperation, certificateHold,
removeFromCRL, privilegeWithdrawn, aaCompromise) and
NormalizeRevokeReason() that accepts both camelCase and snake_case
inputs and normalises to the canonical wire form. Off-list reasons
are rejected at dispatch with the menu re-printed.
Test pins:
internal/cli/client_test.go::TestClient_RenewCertificate_ForceFlag —
--force=true sends ?force=true with empty body; --force=false sends
no query and no body.
internal/cli/client_test.go::TestNormalizeRevokeReason +
TestValidRevokeReasons — canonical-camelCase + snake_case + reject-
off-enum behaviour.
cmd/cli/dispatch_test.go::TestHandleCerts_Revoke_RequiresReason +
TestHandleCerts_Revoke_RejectsUnknownReason +
TestHandleCerts_Renew_ForceFlag — dispatch-layer pins for the same
contracts.
internal/api/handler/certificate_handler_test.go::TestTriggerRenewal_
ForceQueryParam — query-param passthrough (no-flag, force=true,
force=1, force=false) flows through to the service-layer parameter.
internal/service/certificate_test.go::TestTriggerRenewal_
ForceOverridesInProgress — force=false preserves the
RenewalInProgress block; force=true clears it.
Existing TestTriggerRenewal_Archived extended to assert force=true
still blocks Archived (terminal-state guarantee).
Docs: docs/reference/cli.md updated with the --force example for renew
and the strict --reason semantics for revoke (including snake_case
input acceptance).
Acceptance gate (verified):
- go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/...
./cmd/mcp-server/... clean.
- go vet ./... clean.
- go test -short -count=1 ./... pass repo-wide.
- bash scripts/ci-guards/openapi-handler-parity.sh clean
(router 178, OpenAPI 144, exceptions 36 — unchanged; we add
parameter parsing, not routes).
- gofmt -l clean.
871 lines
28 KiB
Go
871 lines
28 KiB
Go
package cli
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/rsa"
|
||
"crypto/tls"
|
||
"crypto/x509"
|
||
"crypto/x509/pkix"
|
||
"encoding/json"
|
||
"encoding/pem"
|
||
"math/big"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func TestClient_ListCertificates(t *testing.T) {
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "GET" || r.URL.Path != "/api/v1/certificates" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"data": []map[string]interface{}{
|
||
{
|
||
"id": "mc-1",
|
||
"common_name": "example.com",
|
||
"status": "Active",
|
||
"expires_at": "2025-12-31T00:00:00Z",
|
||
"issuer_id": "iss-local",
|
||
},
|
||
},
|
||
"total": 1,
|
||
})
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "table", "", false)
|
||
err := client.ListCertificates([]string{})
|
||
if err != nil {
|
||
t.Fatalf("ListCertificates failed: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClient_GetCertificate(t *testing.T) {
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "GET" || r.URL.Path != "/api/v1/certificates/mc-1" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"id": "mc-1",
|
||
"common_name": "example.com",
|
||
"status": "Active",
|
||
"expires_at": "2025-12-31T00:00:00Z",
|
||
"issuer_id": "iss-local",
|
||
})
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "json", "", false)
|
||
err := client.GetCertificate("mc-1")
|
||
if err != nil {
|
||
t.Fatalf("GetCertificate failed: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClient_RenewCertificate(t *testing.T) {
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" || r.URL.Path != "/api/v1/certificates/mc-1/renew" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"job_id": "job-123",
|
||
"status": "Pending",
|
||
})
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "table", "", false)
|
||
err := client.RenewCertificate("mc-1", false)
|
||
if err != nil {
|
||
t.Fatalf("RenewCertificate failed: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestClient_RenewCertificate_ForceFlag pins the 2026-05-05 parity-defaults-
|
||
// cleanup (P3-1) wire: the CLI sends `?force=true` on the renew URL when
|
||
// the operator passes --force. The pre-2026-05-05 hardcoded `force=false`
|
||
// body field is gone (the API never read it — it was a "lying field").
|
||
func TestClient_RenewCertificate_ForceFlag(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
force bool
|
||
wantQuery string
|
||
}{
|
||
{"no-force", false, ""},
|
||
{"force-true", true, "force=true"},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
var gotQuery string
|
||
var gotBody string
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
gotQuery = r.URL.RawQuery
|
||
buf := make([]byte, 1024)
|
||
n, _ := r.Body.Read(buf)
|
||
gotBody = string(buf[:n])
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{"job_id": "job-1"})
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "table", "", false)
|
||
if err := client.RenewCertificate("mc-1", tc.force); err != nil {
|
||
t.Fatalf("RenewCertificate: %v", err)
|
||
}
|
||
if gotQuery != tc.wantQuery {
|
||
t.Errorf("query: got %q want %q", gotQuery, tc.wantQuery)
|
||
}
|
||
// Body must be empty — the lying `force=false` field is gone.
|
||
if gotBody != "" {
|
||
t.Errorf("body should be empty (no lying force field), got %q", gotBody)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestNormalizeRevokeReason pins the 2026-05-05 parity-defaults-cleanup
|
||
// (P3-2) reason-code validator: canonical camelCase passes through, snake_
|
||
// case normalises to camelCase, anything else returns ok=false.
|
||
func TestNormalizeRevokeReason(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
in string
|
||
want string
|
||
wantOK bool
|
||
}{
|
||
{"keyCompromise", "keyCompromise", true},
|
||
{"key_compromise", "keyCompromise", true},
|
||
{"superseded", "superseded", true},
|
||
{"cessation_of_operation", "cessationOfOperation", true},
|
||
{"unspecified", "unspecified", true},
|
||
{"BogusReason", "BogusReason", false},
|
||
{"", "", false},
|
||
{"key compromise", "key compromise", false},
|
||
} {
|
||
t.Run(tc.in, func(t *testing.T) {
|
||
got, ok := NormalizeRevokeReason(tc.in)
|
||
if got != tc.want || ok != tc.wantOK {
|
||
t.Errorf("NormalizeRevokeReason(%q) = (%q, %v), want (%q, %v)",
|
||
tc.in, got, ok, tc.want, tc.wantOK)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestValidRevokeReasons pins that the canonical RFC 5280 §5.3.1 reason
|
||
// list is non-empty and contains the operator-critical entries (the help
|
||
// menu printed when --reason is missing depends on this).
|
||
func TestValidRevokeReasons(t *testing.T) {
|
||
got := ValidRevokeReasons()
|
||
if len(got) < 9 {
|
||
t.Errorf("expected ≥9 RFC 5280 §5.3.1 reasons, got %d: %v", len(got), got)
|
||
}
|
||
want := []string{"keyCompromise", "caCompromise", "superseded",
|
||
"cessationOfOperation", "certificateHold", "privilegeWithdrawn"}
|
||
for _, w := range want {
|
||
found := false
|
||
for _, g := range got {
|
||
if g == w {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("missing canonical reason %q in %v", w, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestClient_RevokeCertificate(t *testing.T) {
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" || r.URL.Path != "/api/v1/certificates/mc-1/revoke" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"status": "revoked",
|
||
})
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "table", "", false)
|
||
err := client.RevokeCertificate("mc-1", "cessationOfOperation")
|
||
if err != nil {
|
||
t.Fatalf("RevokeCertificate failed: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClient_BulkRevokeCertificates(t *testing.T) {
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" || r.URL.Path != "/api/v1/certificates/bulk-revoke" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
// Verify request body contains expected fields
|
||
var body map[string]interface{}
|
||
json.NewDecoder(r.Body).Decode(&body)
|
||
if body["reason"] != "keyCompromise" {
|
||
t.Errorf("expected reason keyCompromise, got %v", body["reason"])
|
||
}
|
||
if body["profile_id"] != "prof-tls" {
|
||
t.Errorf("expected profile_id prof-tls, got %v", body["profile_id"])
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"total_matched": 3,
|
||
"total_revoked": 2,
|
||
"total_skipped": 1,
|
||
"total_failed": 0,
|
||
})
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "table", "", false)
|
||
err := client.BulkRevokeCertificates([]string{
|
||
"--reason", "keyCompromise",
|
||
"--profile-id", "prof-tls",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("BulkRevokeCertificates failed: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClient_ListAgents(t *testing.T) {
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "GET" || r.URL.Path != "/api/v1/agents" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"data": []map[string]interface{}{
|
||
{
|
||
"id": "ag-1",
|
||
"hostname": "agent1.example.com",
|
||
"status": "Online",
|
||
"os": "linux",
|
||
"architecture": "amd64",
|
||
"ip_address": "192.168.1.1",
|
||
},
|
||
},
|
||
"total": 1,
|
||
})
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "table", "", false)
|
||
err := client.ListAgents([]string{})
|
||
if err != nil {
|
||
t.Fatalf("ListAgents failed: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClient_GetAgent(t *testing.T) {
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "GET" || r.URL.Path != "/api/v1/agents/ag-1" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"id": "ag-1",
|
||
"hostname": "agent1.example.com",
|
||
"status": "Online",
|
||
"os": "linux",
|
||
"architecture": "amd64",
|
||
"ip_address": "192.168.1.1",
|
||
})
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "json", "", false)
|
||
err := client.GetAgent("ag-1")
|
||
if err != nil {
|
||
t.Fatalf("GetAgent failed: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClient_ListJobs(t *testing.T) {
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "GET" || r.URL.Path != "/api/v1/jobs" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"data": []map[string]interface{}{
|
||
{
|
||
"id": "job-1",
|
||
"type": "Renewal",
|
||
"certificate_id": "mc-1",
|
||
"status": "Completed",
|
||
"attempts": 1,
|
||
"max_attempts": 3,
|
||
},
|
||
},
|
||
"total": 1,
|
||
})
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "table", "", false)
|
||
err := client.ListJobs([]string{})
|
||
if err != nil {
|
||
t.Fatalf("ListJobs failed: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClient_GetJob(t *testing.T) {
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "GET" || r.URL.Path != "/api/v1/jobs/job-1" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"id": "job-1",
|
||
"type": "Renewal",
|
||
"certificate_id": "mc-1",
|
||
"status": "Completed",
|
||
"attempts": 1,
|
||
"max_attempts": 3,
|
||
})
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "json", "", false)
|
||
err := client.GetJob("job-1")
|
||
if err != nil {
|
||
t.Fatalf("GetJob failed: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClient_CancelJob(t *testing.T) {
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" || r.URL.Path != "/api/v1/jobs/job-1/cancel" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "table", "", false)
|
||
err := client.CancelJob("job-1")
|
||
if err != nil {
|
||
t.Fatalf("CancelJob failed: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClient_GetStatus(t *testing.T) {
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "GET" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
|
||
if r.URL.Path == "/health" {
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"status": "healthy",
|
||
"timestamp": time.Now().Format(time.RFC3339),
|
||
})
|
||
} else if r.URL.Path == "/api/v1/stats/summary" {
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"data": map[string]interface{}{
|
||
"total_certificates": 10,
|
||
"total_agents": 5,
|
||
},
|
||
})
|
||
}
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "table", "", false)
|
||
err := client.GetStatus()
|
||
if err != nil {
|
||
t.Fatalf("GetStatus failed: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestParsePEMCertificates(t *testing.T) {
|
||
// Generate a self-signed test certificate
|
||
cert := generateTestCert()
|
||
|
||
// Encode it to PEM
|
||
pemBlock := &pem.Block{
|
||
Type: "CERTIFICATE",
|
||
Bytes: cert.Raw,
|
||
}
|
||
pemData := pem.EncodeToMemory(pemBlock)
|
||
|
||
// Parse it back
|
||
certs, err := parsePEMCertificates(pemData)
|
||
if err != nil {
|
||
t.Fatalf("parsePEMCertificates failed: %v", err)
|
||
}
|
||
|
||
if len(certs) != 1 {
|
||
t.Fatalf("expected 1 certificate, got %d", len(certs))
|
||
}
|
||
|
||
if certs[0].Subject.CommonName != "test.example.com" {
|
||
t.Fatalf("expected CommonName 'test.example.com', got %s", certs[0].Subject.CommonName)
|
||
}
|
||
}
|
||
|
||
func TestParsePEMCertificates_Multiple(t *testing.T) {
|
||
// Generate two test certificates
|
||
cert1 := generateTestCert()
|
||
cert2 := generateTestCert()
|
||
|
||
// Encode both to PEM
|
||
block1 := &pem.Block{Type: "CERTIFICATE", Bytes: cert1.Raw}
|
||
block2 := &pem.Block{Type: "CERTIFICATE", Bytes: cert2.Raw}
|
||
|
||
pemData := append(pem.EncodeToMemory(block1), pem.EncodeToMemory(block2)...)
|
||
|
||
// Parse them back
|
||
certs, err := parsePEMCertificates(pemData)
|
||
if err != nil {
|
||
t.Fatalf("parsePEMCertificates failed: %v", err)
|
||
}
|
||
|
||
if len(certs) != 2 {
|
||
t.Fatalf("expected 2 certificates, got %d", len(certs))
|
||
}
|
||
}
|
||
|
||
func TestParsePEMCertificates_NoCertificates(t *testing.T) {
|
||
pemData := []byte("no certificates here")
|
||
|
||
_, err := parsePEMCertificates(pemData)
|
||
if err == nil {
|
||
t.Fatal("expected error for empty PEM data")
|
||
}
|
||
}
|
||
|
||
func TestClient_AuthHeader(t *testing.T) {
|
||
var authHeader string
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
authHeader = r.Header.Get("Authorization")
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{"data": []interface{}{}})
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "testkey123", "json", "", false)
|
||
client.do("GET", "/api/v1/certificates", nil, nil)
|
||
|
||
if authHeader != "Bearer testkey123" {
|
||
t.Fatalf("expected 'Bearer testkey123', got '%s'", authHeader)
|
||
}
|
||
}
|
||
|
||
// TestClient_ImportCertificates_MissingRequiredFlags verifies the CLI
|
||
// import command rejects invocations missing any of the four required
|
||
// flags (--owner-id, --team-id, --renewal-policy-id, --issuer-id)
|
||
// before any network call is attempted. This is the C-001 scope-expansion
|
||
// closure for the CLI layer: the handler now requires all six cert
|
||
// fields, so the importer must collect ownership / team / policy /
|
||
// issuer up front rather than hard-coding iss-local and letting the
|
||
// server 400 on every POST.
|
||
func TestClient_ImportCertificates_MissingRequiredFlags(t *testing.T) {
|
||
var requestCount int
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
requestCount++
|
||
w.WriteHeader(http.StatusOK)
|
||
}))
|
||
defer server.Close()
|
||
|
||
cases := []struct {
|
||
name string
|
||
args []string
|
||
missing string
|
||
}{
|
||
{
|
||
name: "missing owner-id",
|
||
args: []string{"--team-id", "t-platform", "--renewal-policy-id", "rp-default", "--issuer-id", "iss-local", "certs.pem"},
|
||
missing: "--owner-id",
|
||
},
|
||
{
|
||
name: "missing team-id",
|
||
args: []string{"--owner-id", "o-alice", "--renewal-policy-id", "rp-default", "--issuer-id", "iss-local", "certs.pem"},
|
||
missing: "--team-id",
|
||
},
|
||
{
|
||
name: "missing renewal-policy-id",
|
||
args: []string{"--owner-id", "o-alice", "--team-id", "t-platform", "--issuer-id", "iss-local", "certs.pem"},
|
||
missing: "--renewal-policy-id",
|
||
},
|
||
{
|
||
name: "missing issuer-id",
|
||
args: []string{"--owner-id", "o-alice", "--team-id", "t-platform", "--renewal-policy-id", "rp-default", "certs.pem"},
|
||
missing: "--issuer-id",
|
||
},
|
||
{
|
||
name: "no flags at all",
|
||
args: []string{"certs.pem"},
|
||
missing: "--owner-id",
|
||
},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
client, _ := NewClient(server.URL, "", "table", "", false)
|
||
err := client.ImportCertificates(tc.args)
|
||
if err == nil {
|
||
t.Fatalf("expected error for %s, got nil", tc.name)
|
||
}
|
||
msg := err.Error()
|
||
if !containsStr(msg, tc.missing) {
|
||
t.Fatalf("expected error to name %q, got: %v", tc.missing, err)
|
||
}
|
||
if !containsStr(msg, "required") {
|
||
t.Fatalf("expected error message to mention 'required', got: %v", err)
|
||
}
|
||
})
|
||
}
|
||
|
||
if requestCount != 0 {
|
||
t.Fatalf("expected zero HTTP requests before flag validation, got %d", requestCount)
|
||
}
|
||
}
|
||
|
||
// TestClient_ImportCertificates_MissingPositionalArgs verifies the
|
||
// import command errors out when flags are present but no PEM file
|
||
// paths follow them.
|
||
func TestClient_ImportCertificates_MissingPositionalArgs(t *testing.T) {
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path)
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "table", "", false)
|
||
err := client.ImportCertificates([]string{
|
||
"--owner-id", "o-alice",
|
||
"--team-id", "t-platform",
|
||
"--renewal-policy-id", "rp-default",
|
||
"--issuer-id", "iss-local",
|
||
})
|
||
if err == nil {
|
||
t.Fatal("expected error when no PEM file paths are supplied")
|
||
}
|
||
if !containsStr(err.Error(), "PEM file") {
|
||
t.Fatalf("expected error to mention 'PEM file', got: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestClient_ImportCertificates_SixFieldPayload verifies the happy
|
||
// path: given all four required flags plus a PEM file, the importer
|
||
// POSTs a request containing all six required fields plus the
|
||
// name-template–resolved name. The httptest handler decodes the
|
||
// request body and asserts every required field is populated with
|
||
// the values supplied via flags.
|
||
func TestClient_ImportCertificates_SixFieldPayload(t *testing.T) {
|
||
// Generate a test cert and write it to a temp PEM file.
|
||
cert := generateTestCert()
|
||
pemBlock := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
|
||
pemPath := filepath.Join(t.TempDir(), "test.pem")
|
||
if err := os.WriteFile(pemPath, pem.EncodeToMemory(pemBlock), 0o600); err != nil {
|
||
t.Fatalf("write temp PEM: %v", err)
|
||
}
|
||
|
||
var gotBody map[string]interface{}
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != "POST" || r.URL.Path != "/api/v1/certificates" {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||
t.Errorf("decode request body: %v", err)
|
||
}
|
||
w.WriteHeader(http.StatusCreated)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"id":"mc-imported"}`))
|
||
}))
|
||
defer server.Close()
|
||
|
||
client, _ := NewClient(server.URL, "", "table", "", false)
|
||
err := client.ImportCertificates([]string{
|
||
"--owner-id", "o-alice",
|
||
"--team-id", "t-platform",
|
||
"--renewal-policy-id", "rp-default",
|
||
"--issuer-id", "iss-local",
|
||
"--name-template", "imported-{cn}",
|
||
pemPath,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("ImportCertificates failed: %v", err)
|
||
}
|
||
|
||
// Verify every required field from the six-field contract is present.
|
||
required := []struct {
|
||
field string
|
||
want interface{}
|
||
}{
|
||
{"name", "imported-test.example.com"},
|
||
{"common_name", "test.example.com"},
|
||
{"issuer_id", "iss-local"},
|
||
{"owner_id", "o-alice"},
|
||
{"team_id", "t-platform"},
|
||
{"renewal_policy_id", "rp-default"},
|
||
}
|
||
for _, r := range required {
|
||
got, ok := gotBody[r.field]
|
||
if !ok {
|
||
t.Errorf("payload missing required field %q (body: %+v)", r.field, gotBody)
|
||
continue
|
||
}
|
||
if got != r.want {
|
||
t.Errorf("field %q = %v, want %v", r.field, got, r.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// containsStr is a tiny substring helper so the test file doesn't
|
||
// need a `strings` import dependency aside from what's already there.
|
||
func containsStr(haystack, needle string) bool {
|
||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||
if haystack[i:i+len(needle)] == needle {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Helper function to generate a test certificate
|
||
func generateTestCert() *x509.Certificate {
|
||
now := time.Now()
|
||
template := &x509.Certificate{
|
||
SerialNumber: big.NewInt(1),
|
||
Subject: pkix.Name{
|
||
CommonName: "test.example.com",
|
||
},
|
||
NotBefore: now,
|
||
NotAfter: now.Add(365 * 24 * time.Hour),
|
||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||
BasicConstraintsValid: true,
|
||
DNSNames: []string{"test.example.com", "*.test.example.com"},
|
||
}
|
||
|
||
privateKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||
certBytes, _ := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||
cert, _ := x509.ParseCertificate(certBytes)
|
||
|
||
return cert
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// HTTPS-Everywhere milestone (v2.2, §3.2 + §7 Phase 5):
|
||
// The CLI binary now talks HTTPS-only to the control plane. These tests pin the
|
||
// three contracts the milestone requires every client binary (agent, CLI, MCP)
|
||
// to satisfy in lock-step:
|
||
// (a) CA bundle load success — PEM loads, RootCAs + MinVersion=TLS1.3 wired
|
||
// through the injected *http.Transport so the httpClient actually uses them.
|
||
// (b) CA bundle load failure — missing file and malformed/empty PEM each fail
|
||
// loud with a pinned substring so operators get a useful diagnostic instead
|
||
// of a later TLS-handshake-error mystery.
|
||
// (c) End-to-end TLS round-trip — an httptest.NewTLSServer whose own cert is
|
||
// written out as the CA bundle validates that every TLS-config knob is
|
||
// actually reaching the wire, not just surviving into the struct.
|
||
// Each of the three client binaries pins the same three contracts against its
|
||
// own NewClient signature; drifting any of them in isolation is exactly what
|
||
// this suite is here to catch. The error-string substrings below must stay in
|
||
// sync with the fmt.Errorf messages in internal/cli/client.go:NewClient.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
// writeCABundle PEM-encodes a DER cert and writes it to a temp file under the
|
||
// test's own TempDir. Returns the absolute path of the written bundle so test
|
||
// callers can pass it straight into NewClient(..., caBundlePath, ...).
|
||
func writeCABundle(t *testing.T, dir string, certDER []byte, filename string) string {
|
||
t.Helper()
|
||
path := filepath.Join(dir, filename)
|
||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
|
||
t.Fatalf("writing CA bundle to %q: %v", path, err)
|
||
}
|
||
return path
|
||
}
|
||
|
||
// TestNewClient_CABundle_Success pins the happy path: a valid PEM CA bundle
|
||
// loads, populates RootCAs on the client's TLS config, and leaves
|
||
// MinVersion=TLS1.3 intact. Regression guard: if a future edit accidentally
|
||
// swaps the transport after TLS config setup (or forgets to re-attach the
|
||
// *tls.Config to *http.Transport), this test catches it before ops does.
|
||
func TestNewClient_CABundle_Success(t *testing.T) {
|
||
cert := generateTestCert()
|
||
tmp := t.TempDir()
|
||
bundlePath := writeCABundle(t, tmp, cert.Raw, "ca.pem")
|
||
|
||
client, err := NewClient("https://certctl-server:8443", "test-key", "table", bundlePath, false)
|
||
if err != nil {
|
||
t.Fatalf("NewClient with valid CA bundle err=%v want nil", err)
|
||
}
|
||
if client == nil {
|
||
t.Fatal("NewClient returned nil client on happy path")
|
||
}
|
||
|
||
transport, ok := client.httpClient.Transport.(*http.Transport)
|
||
if !ok {
|
||
t.Fatalf("httpClient.Transport type=%T want *http.Transport (TLS config injection broke)", client.httpClient.Transport)
|
||
}
|
||
if transport.TLSClientConfig == nil {
|
||
t.Fatal("transport.TLSClientConfig is nil; TLS config must be set on every client")
|
||
}
|
||
if transport.TLSClientConfig.RootCAs == nil {
|
||
t.Fatal("transport.TLSClientConfig.RootCAs is nil; CA bundle path was ignored")
|
||
}
|
||
if transport.TLSClientConfig.MinVersion != tls.VersionTLS13 {
|
||
t.Errorf("MinVersion=%d want tls.VersionTLS13 (%d); HTTPS-Everywhere requires TLS1.3 floor",
|
||
transport.TLSClientConfig.MinVersion, tls.VersionTLS13)
|
||
}
|
||
if transport.TLSClientConfig.InsecureSkipVerify {
|
||
t.Error("InsecureSkipVerify=true with insecure=false arg; flag wiring crossed")
|
||
}
|
||
}
|
||
|
||
// TestNewClient_CABundle_MissingFile pins the fail-loud path for a nonexistent
|
||
// bundle path. The error surface must include "reading CA bundle" so operators
|
||
// see the right diagnostic instead of a downstream TLS-handshake-error.
|
||
func TestNewClient_CABundle_MissingFile(t *testing.T) {
|
||
_, err := NewClient("https://certctl-server:8443", "test-key", "table", "/nonexistent/path/ca.pem", false)
|
||
if err == nil {
|
||
t.Fatal("NewClient with missing CA bundle err=nil; must fail loud so operators see the right diagnostic")
|
||
}
|
||
if !containsStr(err.Error(), "reading CA bundle") {
|
||
t.Errorf("err=%q must contain %q so operators can locate the misconfigured path", err.Error(), "reading CA bundle")
|
||
}
|
||
}
|
||
|
||
// TestNewClient_CABundle_EmptyPEM pins the fail-loud path for a file whose
|
||
// contents are not valid PEM certificate data. AppendCertsFromPEM returning
|
||
// false is the signal we need to surface — otherwise the client would silently
|
||
// ship with an empty cert pool and every TLS handshake would fail downstream.
|
||
func TestNewClient_CABundle_EmptyPEM(t *testing.T) {
|
||
tmp := t.TempDir()
|
||
garbagePath := filepath.Join(tmp, "garbage.pem")
|
||
if err := os.WriteFile(garbagePath, []byte("not a pem certificate, just bytes"), 0o600); err != nil {
|
||
t.Fatalf("writing garbage file: %v", err)
|
||
}
|
||
|
||
_, err := NewClient("https://certctl-server:8443", "test-key", "table", garbagePath, false)
|
||
if err == nil {
|
||
t.Fatal("NewClient with malformed PEM err=nil; must fail loud, not silently skip")
|
||
}
|
||
if !containsStr(err.Error(), "no valid PEM-encoded certificates") {
|
||
t.Errorf("err=%q must contain %q so operators know the file parsed but held no certs",
|
||
err.Error(), "no valid PEM-encoded certificates")
|
||
}
|
||
}
|
||
|
||
// TestNewClient_TLSRoundTrip validates that the TLS config knobs we set on
|
||
// NewClient actually reach the wire. An httptest.NewTLSServer signs its own
|
||
// self-signed leaf; we PEM-encode that server cert, write it as the CA bundle,
|
||
// and issue a real HTTPS call through ListCertificates. A successful round-trip
|
||
// proves RootCAs + MinVersion are flowing through *http.Transport into the
|
||
// dialer, not just surviving into the client struct.
|
||
func TestNewClient_TLSRoundTrip(t *testing.T) {
|
||
var handlerHit int
|
||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "GET" && r.URL.Path == "/api/v1/certificates" {
|
||
handlerHit++
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"data": []map[string]interface{}{},
|
||
"total": 0,
|
||
})
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNotFound)
|
||
}))
|
||
defer server.Close()
|
||
|
||
serverCert := server.Certificate()
|
||
if serverCert == nil {
|
||
t.Fatal("httptest.NewTLSServer.Certificate() returned nil; cannot build CA bundle")
|
||
}
|
||
|
||
tmp := t.TempDir()
|
||
bundlePath := writeCABundle(t, tmp, serverCert.Raw, "server-ca.pem")
|
||
|
||
client, err := NewClient(server.URL, "test-key", "table", bundlePath, false)
|
||
if err != nil {
|
||
t.Fatalf("NewClient(TLS server) err=%v want nil", err)
|
||
}
|
||
if err := client.ListCertificates([]string{}); err != nil {
|
||
t.Fatalf("ListCertificates over HTTPS err=%v; TLS config must reach the wire", err)
|
||
}
|
||
if handlerHit != 1 {
|
||
t.Errorf("handlerHit=%d want 1; request did not reach the TLS server", handlerHit)
|
||
}
|
||
}
|
||
|
||
// TestNewClient_InsecureSkipVerify pins the dev-only escape hatch: an untrusted
|
||
// TLS server (cert NOT in the client's root pool) must be reachable when
|
||
// insecure=true. This is the only path in the control plane that disables
|
||
// certificate verification; it's documented in docs/tls.md and gated by the
|
||
// CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY env var so it never slips into
|
||
// production silently.
|
||
func TestNewClient_InsecureSkipVerify(t *testing.T) {
|
||
var handlerHit int
|
||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
handlerHit++
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"data": []map[string]interface{}{},
|
||
"total": 0,
|
||
})
|
||
}))
|
||
defer server.Close()
|
||
|
||
// No CA bundle → system roots, which will NOT trust the self-signed
|
||
// httptest cert. insecure=true is the only thing keeping this call from
|
||
// failing with an x509-unknown-authority error.
|
||
client, err := NewClient(server.URL, "test-key", "table", "", true)
|
||
if err != nil {
|
||
t.Fatalf("NewClient(insecure=true) err=%v want nil", err)
|
||
}
|
||
|
||
transport, ok := client.httpClient.Transport.(*http.Transport)
|
||
if !ok {
|
||
t.Fatalf("httpClient.Transport type=%T want *http.Transport", client.httpClient.Transport)
|
||
}
|
||
if !transport.TLSClientConfig.InsecureSkipVerify {
|
||
t.Fatal("insecure=true arg did not set TLSClientConfig.InsecureSkipVerify; flag wiring broken")
|
||
}
|
||
if transport.TLSClientConfig.MinVersion != tls.VersionTLS13 {
|
||
t.Errorf("MinVersion=%d want tls.VersionTLS13 even with insecure=true (TLS1.3 floor is not optional)",
|
||
transport.TLSClientConfig.MinVersion)
|
||
}
|
||
|
||
if err := client.ListCertificates([]string{}); err != nil {
|
||
t.Fatalf("ListCertificates(insecure=true) err=%v; escape hatch must still complete the round-trip", err)
|
||
}
|
||
if handlerHit != 1 {
|
||
t.Errorf("handlerHit=%d want 1; insecure round-trip did not reach the server", handlerHit)
|
||
}
|
||
}
|