mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:01:37 +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.
437 lines
14 KiB
Go
437 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
)
|
|
|
|
func TestCreateCertificate(t *testing.T) {
|
|
ctx := context.Background()
|
|
certRepo := &mockCertRepo{
|
|
Certs: make(map[string]*domain.ManagedCertificate),
|
|
Versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
auditRepo := &mockAuditRepo{
|
|
Events: []*domain.AuditEvent{},
|
|
}
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: make(map[string]*domain.PolicyRule),
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
|
|
policyService := NewPolicyService(policyRepo, NewAuditService(auditRepo))
|
|
auditService := NewAuditService(auditRepo)
|
|
certService := NewCertificateService(certRepo, policyService, auditService)
|
|
|
|
now := time.Now()
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "cert-001",
|
|
Name: "api-prod",
|
|
CommonName: "api.example.com",
|
|
SANs: []string{"api.example.com"},
|
|
Environment: "production",
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-acme",
|
|
TargetIDs: []string{"target-1"},
|
|
RenewalPolicyID: "policy-1",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: now.AddDate(1, 0, 0),
|
|
Tags: map[string]string{"env": "prod"},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
err := certService.Create(ctx, cert, "user-1")
|
|
if err != nil {
|
|
t.Fatalf("Create failed: %v", err)
|
|
}
|
|
|
|
if len(certRepo.Certs) != 1 {
|
|
t.Errorf("expected 1 cert, got %d", len(certRepo.Certs))
|
|
}
|
|
|
|
storedCert, ok := certRepo.Certs["cert-001"]
|
|
if !ok {
|
|
t.Fatal("certificate not stored")
|
|
}
|
|
if storedCert.CommonName != "api.example.com" {
|
|
t.Errorf("expected common name api.example.com, got %s", storedCert.CommonName)
|
|
}
|
|
|
|
if len(auditRepo.Events) != 1 {
|
|
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
|
|
}
|
|
}
|
|
|
|
func TestCreateCertificate_MissingRequired(t *testing.T) {
|
|
ctx := context.Background()
|
|
certRepo := &mockCertRepo{
|
|
Certs: make(map[string]*domain.ManagedCertificate),
|
|
Versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
policyRepo := &mockPolicyRepo{Rules: make(map[string]*domain.PolicyRule)}
|
|
|
|
policyService := NewPolicyService(policyRepo, NewAuditService(auditRepo))
|
|
auditService := NewAuditService(auditRepo)
|
|
certService := NewCertificateService(certRepo, policyService, auditService)
|
|
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "cert-001",
|
|
// Missing CommonName and IssuerID
|
|
}
|
|
|
|
err := certService.Create(ctx, cert, "user-1")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing required fields")
|
|
}
|
|
}
|
|
|
|
func TestGetCertificate(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "cert-001",
|
|
CommonName: "example.com",
|
|
IssuerID: "iss-1",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: now.AddDate(1, 0, 0),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
certRepo := &mockCertRepo{
|
|
Certs: map[string]*domain.ManagedCertificate{"cert-001": cert},
|
|
Versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
policyRepo := &mockPolicyRepo{Rules: make(map[string]*domain.PolicyRule)}
|
|
|
|
policyService := NewPolicyService(policyRepo, NewAuditService(auditRepo))
|
|
auditService := NewAuditService(auditRepo)
|
|
certService := NewCertificateService(certRepo, policyService, auditService)
|
|
|
|
retrieved, err := certService.Get(ctx, "cert-001")
|
|
if err != nil {
|
|
t.Fatalf("Get failed: %v", err)
|
|
}
|
|
|
|
if retrieved.CommonName != "example.com" {
|
|
t.Errorf("expected common name example.com, got %s", retrieved.CommonName)
|
|
}
|
|
}
|
|
|
|
func TestGetCertificate_NotFound(t *testing.T) {
|
|
ctx := context.Background()
|
|
certRepo := &mockCertRepo{
|
|
Certs: make(map[string]*domain.ManagedCertificate),
|
|
Versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
policyRepo := &mockPolicyRepo{Rules: make(map[string]*domain.PolicyRule)}
|
|
|
|
policyService := NewPolicyService(policyRepo, NewAuditService(auditRepo))
|
|
auditService := NewAuditService(auditRepo)
|
|
certService := NewCertificateService(certRepo, policyService, auditService)
|
|
|
|
_, err := certService.Get(ctx, "nonexistent")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent certificate")
|
|
}
|
|
}
|
|
|
|
func TestUpdateCertificate(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
originalCert := &domain.ManagedCertificate{
|
|
ID: "cert-001",
|
|
CommonName: "example.com",
|
|
IssuerID: "iss-1",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: now.AddDate(1, 0, 0),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
certRepo := &mockCertRepo{
|
|
Certs: map[string]*domain.ManagedCertificate{"cert-001": originalCert},
|
|
Versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
|
policyRepo := &mockPolicyRepo{Rules: make(map[string]*domain.PolicyRule)}
|
|
|
|
policyService := NewPolicyService(policyRepo, NewAuditService(auditRepo))
|
|
auditService := NewAuditService(auditRepo)
|
|
certService := NewCertificateService(certRepo, policyService, auditService)
|
|
|
|
updatedCert := *originalCert
|
|
updatedCert.Status = domain.CertificateStatusExpiring
|
|
updatedCert.ExpiresAt = now.AddDate(0, 0, 5)
|
|
|
|
err := certService.Update(ctx, &updatedCert, "user-1")
|
|
if err != nil {
|
|
t.Fatalf("Update failed: %v", err)
|
|
}
|
|
|
|
stored := certRepo.Certs["cert-001"]
|
|
if stored.Status != domain.CertificateStatusExpiring {
|
|
t.Errorf("expected status Expiring, got %s", stored.Status)
|
|
}
|
|
|
|
if len(auditRepo.Events) != 1 {
|
|
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
|
|
}
|
|
}
|
|
|
|
func TestArchiveCertificate(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "cert-001",
|
|
CommonName: "example.com",
|
|
IssuerID: "iss-1",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: now.AddDate(1, 0, 0),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
certRepo := &mockCertRepo{
|
|
Certs: map[string]*domain.ManagedCertificate{"cert-001": cert},
|
|
Versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
|
policyRepo := &mockPolicyRepo{Rules: make(map[string]*domain.PolicyRule)}
|
|
|
|
policyService := NewPolicyService(policyRepo, NewAuditService(auditRepo))
|
|
auditService := NewAuditService(auditRepo)
|
|
certService := NewCertificateService(certRepo, policyService, auditService)
|
|
|
|
err := certService.Archive(ctx, "cert-001", "user-1")
|
|
if err != nil {
|
|
t.Fatalf("Archive failed: %v", err)
|
|
}
|
|
|
|
archived := certRepo.Certs["cert-001"]
|
|
if archived.Status != domain.CertificateStatusArchived {
|
|
t.Errorf("expected status Archived, got %s", archived.Status)
|
|
}
|
|
|
|
if len(auditRepo.Events) != 1 {
|
|
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
|
|
}
|
|
}
|
|
|
|
func TestGetVersions(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
|
|
version1 := &domain.CertificateVersion{
|
|
ID: "ver-1",
|
|
CertificateID: "cert-001",
|
|
SerialNumber: "serial-1",
|
|
NotBefore: now.AddDate(-1, 0, 0),
|
|
NotAfter: now,
|
|
PEMChain: "cert1-pem",
|
|
CreatedAt: now.AddDate(-1, 0, 0),
|
|
}
|
|
version2 := &domain.CertificateVersion{
|
|
ID: "ver-2",
|
|
CertificateID: "cert-001",
|
|
SerialNumber: "serial-2",
|
|
NotBefore: now,
|
|
NotAfter: now.AddDate(1, 0, 0),
|
|
PEMChain: "cert2-pem",
|
|
CreatedAt: now,
|
|
}
|
|
|
|
certRepo := &mockCertRepo{
|
|
Certs: make(map[string]*domain.ManagedCertificate),
|
|
Versions: map[string][]*domain.CertificateVersion{"cert-001": {version1, version2}},
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
policyRepo := &mockPolicyRepo{Rules: make(map[string]*domain.PolicyRule)}
|
|
|
|
policyService := NewPolicyService(policyRepo, NewAuditService(auditRepo))
|
|
auditService := NewAuditService(auditRepo)
|
|
certService := NewCertificateService(certRepo, policyService, auditService)
|
|
|
|
versions, err := certService.GetVersions(ctx, "cert-001")
|
|
if err != nil {
|
|
t.Fatalf("GetVersions failed: %v", err)
|
|
}
|
|
|
|
if len(versions) != 2 {
|
|
t.Errorf("expected 2 versions, got %d", len(versions))
|
|
}
|
|
}
|
|
|
|
func TestTriggerRenewal(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "cert-001",
|
|
CommonName: "example.com",
|
|
IssuerID: "iss-1",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: now.AddDate(0, 0, 5),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
certRepo := &mockCertRepo{
|
|
Certs: map[string]*domain.ManagedCertificate{"cert-001": cert},
|
|
Versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
|
policyRepo := &mockPolicyRepo{Rules: make(map[string]*domain.PolicyRule)}
|
|
|
|
policyService := NewPolicyService(policyRepo, NewAuditService(auditRepo))
|
|
auditService := NewAuditService(auditRepo)
|
|
certService := NewCertificateService(certRepo, policyService, auditService)
|
|
|
|
err := certService.TriggerRenewal(ctx, "cert-001", "user-1", false)
|
|
if err != nil {
|
|
t.Fatalf("TriggerRenewal failed: %v", err)
|
|
}
|
|
|
|
renewed := certRepo.Certs["cert-001"]
|
|
if renewed.Status != domain.CertificateStatusRenewalInProgress {
|
|
t.Errorf("expected status RenewalInProgress, got %s", renewed.Status)
|
|
}
|
|
|
|
if len(auditRepo.Events) != 1 {
|
|
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
|
|
}
|
|
}
|
|
|
|
// TestTriggerRenewal_ForceOverridesInProgress pins the 2026-05-05 parity-
|
|
// defaults-cleanup (P3-1) semantic: force=true clears the
|
|
// RenewalInProgress block so operators can recover stuck in-flight renewals.
|
|
// force=false (the historical default) preserves the block.
|
|
func TestTriggerRenewal_ForceOverridesInProgress(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
mkCert := func() *domain.ManagedCertificate {
|
|
return &domain.ManagedCertificate{
|
|
ID: "cert-stuck",
|
|
CommonName: "example.com",
|
|
IssuerID: "iss-1",
|
|
Status: domain.CertificateStatusRenewalInProgress,
|
|
ExpiresAt: now.AddDate(0, 0, 5),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
|
|
t.Run("force=false blocks", func(t *testing.T) {
|
|
certRepo := &mockCertRepo{
|
|
Certs: map[string]*domain.ManagedCertificate{"cert-stuck": mkCert()},
|
|
Versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
policyRepo := &mockPolicyRepo{Rules: make(map[string]*domain.PolicyRule)}
|
|
svc := NewCertificateService(certRepo, NewPolicyService(policyRepo, NewAuditService(auditRepo)), NewAuditService(auditRepo))
|
|
err := svc.TriggerRenewal(ctx, "cert-stuck", "user-1", false)
|
|
if err == nil {
|
|
t.Fatal("expected error when force=false on RenewalInProgress cert")
|
|
}
|
|
})
|
|
|
|
t.Run("force=true clears block", func(t *testing.T) {
|
|
certRepo := &mockCertRepo{
|
|
Certs: map[string]*domain.ManagedCertificate{"cert-stuck": mkCert()},
|
|
Versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
policyRepo := &mockPolicyRepo{Rules: make(map[string]*domain.PolicyRule)}
|
|
svc := NewCertificateService(certRepo, NewPolicyService(policyRepo, NewAuditService(auditRepo)), NewAuditService(auditRepo))
|
|
if err := svc.TriggerRenewal(ctx, "cert-stuck", "user-1", true); err != nil {
|
|
t.Fatalf("force=true should override RenewalInProgress: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestTriggerRenewal_Archived(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "cert-001",
|
|
CommonName: "example.com",
|
|
IssuerID: "iss-1",
|
|
Status: domain.CertificateStatusArchived,
|
|
ExpiresAt: now.AddDate(0, 0, 5),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
certRepo := &mockCertRepo{
|
|
Certs: map[string]*domain.ManagedCertificate{"cert-001": cert},
|
|
Versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
policyRepo := &mockPolicyRepo{Rules: make(map[string]*domain.PolicyRule)}
|
|
|
|
policyService := NewPolicyService(policyRepo, NewAuditService(auditRepo))
|
|
auditService := NewAuditService(auditRepo)
|
|
certService := NewCertificateService(certRepo, policyService, auditService)
|
|
|
|
err := certService.TriggerRenewal(ctx, "cert-001", "user-1", false)
|
|
if err == nil {
|
|
t.Fatal("expected error for archived certificate")
|
|
}
|
|
// Archived is a terminal state — force=true must NOT magic it open
|
|
// (parity-defaults-cleanup P3-1 semantic guarantee).
|
|
if err := certService.TriggerRenewal(ctx, "cert-001", "user-1", true); err == nil {
|
|
t.Fatal("force=true should still block archived (terminal)")
|
|
}
|
|
}
|
|
|
|
func TestListCertificates(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
cert1 := &domain.ManagedCertificate{
|
|
ID: "cert-001",
|
|
CommonName: "api.example.com",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: now.AddDate(1, 0, 0),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
cert2 := &domain.ManagedCertificate{
|
|
ID: "cert-002",
|
|
CommonName: "web.example.com",
|
|
Status: domain.CertificateStatusExpiring,
|
|
ExpiresAt: now.AddDate(0, 0, 5),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
certRepo := &mockCertRepo{
|
|
Certs: map[string]*domain.ManagedCertificate{"cert-001": cert1, "cert-002": cert2},
|
|
Versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
policyRepo := &mockPolicyRepo{Rules: make(map[string]*domain.PolicyRule)}
|
|
|
|
policyService := NewPolicyService(policyRepo, NewAuditService(auditRepo))
|
|
auditService := NewAuditService(auditRepo)
|
|
certService := NewCertificateService(certRepo, policyService, auditService)
|
|
|
|
certs, total, err := certService.ListCertificates(ctx, "", "", "", "", "", 1, 50)
|
|
if err != nil {
|
|
t.Fatalf("ListCertificates failed: %v", err)
|
|
}
|
|
|
|
if len(certs) != 2 {
|
|
t.Errorf("expected 2 certs, got %d", len(certs))
|
|
}
|
|
if total != 2 {
|
|
t.Errorf("expected total 2, got %d", total)
|
|
}
|
|
}
|