mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 02:08:53 +00:00
feat: M15a — certificate revocation API, CRL endpoint, and revocation notifications
Implements core revocation infrastructure: POST /api/v1/certificates/{id}/revoke
with all 8 RFC 5280 reason codes, JSON-formatted CRL at GET /api/v1/crl, webhook
and email revocation notifications, best-effort issuer notification, and immutable
revocation audit trail. Includes 48 new tests across service, handler, integration,
and domain layers (600+ total). Fixes 3 pre-existing test bugs (team_test error
matching, agent_group delete status code, team handler per_page validation).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -39,10 +40,17 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
||||
"iss-local": service.NewIssuerConnectorAdapter(localCA),
|
||||
}
|
||||
|
||||
revocationRepo := newMockRevocationRepository()
|
||||
|
||||
auditService := service.NewAuditService(auditRepo)
|
||||
policyService := service.NewPolicyService(policyRepo, auditService)
|
||||
certificateService := service.NewCertificateService(certRepo, policyService, auditService)
|
||||
notificationService := service.NewNotificationService(notifRepo, make(map[string]service.Notifier))
|
||||
|
||||
// Wire revocation dependencies
|
||||
certificateService.SetRevocationRepo(revocationRepo)
|
||||
certificateService.SetNotificationService(notificationService)
|
||||
certificateService.SetIssuerRegistry(issuerRegistry)
|
||||
renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notificationService, issuerRegistry, "server")
|
||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||
@@ -671,3 +679,114 @@ func TestM11bEndpoints(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestRevocationEndpoints exercises the revocation API endpoints through a full integration stack.
|
||||
func TestRevocationEndpoints(t *testing.T) {
|
||||
server, certRepo, _, _ := setupTestServer(t)
|
||||
|
||||
// Create a test certificate with a version
|
||||
now := time.Now()
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-revoke-test",
|
||||
Name: "Revocation Test Cert",
|
||||
CommonName: "revoke-test.example.com",
|
||||
SANs: []string{},
|
||||
Environment: "test",
|
||||
OwnerID: "owner-test",
|
||||
TeamID: "team-test",
|
||||
IssuerID: "iss-local",
|
||||
RenewalPolicyID: "policy-1",
|
||||
Status: domain.CertificateStatusActive,
|
||||
ExpiresAt: now.AddDate(0, 6, 0),
|
||||
Tags: map[string]string{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
certRepo.certs["mc-revoke-test"] = cert
|
||||
certRepo.versions["mc-revoke-test"] = []*domain.CertificateVersion{
|
||||
{
|
||||
ID: "cv-revoke-test",
|
||||
CertificateID: "mc-revoke-test",
|
||||
SerialNumber: "REVOKE-SERIAL-001",
|
||||
NotBefore: now,
|
||||
NotAfter: now.AddDate(1, 0, 0),
|
||||
CreatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
||||
body := bytes.NewBufferString(`{"reason":"keyCompromise"}`)
|
||||
resp, err := http.Post(server.URL+"/api/v1/certificates/mc-revoke-test/revoke", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
if result["status"] != "revoked" {
|
||||
t.Errorf("expected status 'revoked', got %s", result["status"])
|
||||
}
|
||||
|
||||
// Verify certificate status updated
|
||||
if cert.Status != domain.CertificateStatusRevoked {
|
||||
t.Errorf("expected Revoked status, got %s", cert.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_AlreadyRevoked", func(t *testing.T) {
|
||||
body := bytes.NewBufferString(`{"reason":"superseded"}`)
|
||||
resp, err := http.Post(server.URL+"/api/v1/certificates/mc-revoke-test/revoke", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for already revoked, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RevokeCertificate_NotFound", func(t *testing.T) {
|
||||
resp, err := http.Post(server.URL+"/api/v1/certificates/mc-nonexistent/revoke", "application/json", strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCRL_Success", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/crl")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var crl map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&crl)
|
||||
|
||||
if crl["version"] != float64(1) {
|
||||
t.Errorf("expected CRL version 1, got %v", crl["version"])
|
||||
}
|
||||
|
||||
// Should have at least 1 entry from the revocation above
|
||||
total, _ := crl["total"].(float64)
|
||||
if total < 1 {
|
||||
t.Errorf("expected at least 1 CRL entry, got %v", total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user