mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 12:48:51 +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:
@@ -545,6 +545,14 @@ func (m *mockCertificateRepository) GetExpiringCertificates(ctx context.Context,
|
||||
return expiring, nil
|
||||
}
|
||||
|
||||
func (m *mockCertificateRepository) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
|
||||
versions := m.versions[certID]
|
||||
if len(versions) == 0 {
|
||||
return nil, fmt.Errorf("no versions found")
|
||||
}
|
||||
return versions[len(versions)-1], nil
|
||||
}
|
||||
|
||||
type mockJobRepository struct {
|
||||
jobs map[string]*domain.Job
|
||||
}
|
||||
@@ -1048,3 +1056,52 @@ func (m *mockAgentGroupService) DeleteAgentGroup(id string) error {
|
||||
func (m *mockAgentGroupService) ListMembers(id string) ([]domain.Agent, int64, error) {
|
||||
return []domain.Agent{}, 0, nil
|
||||
}
|
||||
|
||||
// mockRevocationRepository is a test implementation of RevocationRepository for integration tests.
|
||||
type mockRevocationRepository struct {
|
||||
revocations []*domain.CertificateRevocation
|
||||
}
|
||||
|
||||
func newMockRevocationRepository() *mockRevocationRepository {
|
||||
return &mockRevocationRepository{
|
||||
revocations: make([]*domain.CertificateRevocation, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockRevocationRepository) Create(ctx context.Context, revocation *domain.CertificateRevocation) error {
|
||||
m.revocations = append(m.revocations, revocation)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRevocationRepository) GetBySerial(ctx context.Context, serial string) (*domain.CertificateRevocation, error) {
|
||||
for _, r := range m.revocations {
|
||||
if r.SerialNumber == serial {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("revocation not found")
|
||||
}
|
||||
|
||||
func (m *mockRevocationRepository) ListAll(ctx context.Context) ([]*domain.CertificateRevocation, error) {
|
||||
return m.revocations, nil
|
||||
}
|
||||
|
||||
func (m *mockRevocationRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.CertificateRevocation, error) {
|
||||
var result []*domain.CertificateRevocation
|
||||
for _, r := range m.revocations {
|
||||
if r.CertificateID == certID {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockRevocationRepository) MarkIssuerNotified(ctx context.Context, id string) error {
|
||||
for _, r := range m.revocations {
|
||||
if r.ID == id {
|
||||
r.IssuerNotified = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("revocation not found")
|
||||
}
|
||||
|
||||
@@ -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