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:
shankar0123
2026-03-22 10:59:18 -04:00
parent d881403d11
commit 5d98e373e3
27 changed files with 1710 additions and 37 deletions
+141 -3
View File
@@ -12,9 +12,12 @@ import (
// CertificateService provides business logic for certificate management.
type CertificateService struct {
certRepo repository.CertificateRepository
policyService *PolicyService
auditService *AuditService
certRepo repository.CertificateRepository
revocationRepo repository.RevocationRepository
policyService *PolicyService
auditService *AuditService
notificationSvc *NotificationService
issuerRegistry map[string]IssuerConnector
}
// NewCertificateService creates a new certificate service.
@@ -30,6 +33,21 @@ func NewCertificateService(
}
}
// SetRevocationRepo sets the revocation repository (called after construction to avoid init order issues).
func (s *CertificateService) SetRevocationRepo(repo repository.RevocationRepository) {
s.revocationRepo = repo
}
// SetNotificationService sets the notification service for revocation alerts.
func (s *CertificateService) SetNotificationService(svc *NotificationService) {
s.notificationSvc = svc
}
// SetIssuerRegistry sets the issuer registry for issuer-level revocation.
func (s *CertificateService) SetIssuerRegistry(registry map[string]IssuerConnector) {
s.issuerRegistry = registry
}
// List returns a paginated list of certificates matching the filter.
func (s *CertificateService) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
certs, total, err := s.certRepo.List(ctx, filter)
@@ -333,3 +351,123 @@ func (s *CertificateService) TriggerRenewal(certID string) error {
func (s *CertificateService) TriggerDeployment(certID string, targetID string) error {
return s.TriggerDeploymentWithActor(context.Background(), certID, "api")
}
// RevokeCertificate revokes a certificate with the given reason.
// Steps:
// 1. Validate the certificate exists and is revocable
// 2. Get the latest certificate version (for serial number)
// 3. Update certificate status to Revoked
// 4. Record revocation in certificate_revocations table
// 5. Notify the issuer connector (best-effort)
// 6. Record audit event
// 7. Send revocation notification
func (s *CertificateService) RevokeCertificate(certID string, reason string) error {
return s.RevokeCertificateWithActor(context.Background(), certID, reason, "api")
}
// RevokeCertificateWithActor performs revocation with actor tracking.
func (s *CertificateService) RevokeCertificateWithActor(ctx context.Context, certID string, reason string, actor string) error {
// 1. Validate certificate exists and is revocable
cert, err := s.certRepo.Get(ctx, certID)
if err != nil {
return fmt.Errorf("failed to fetch certificate: %w", err)
}
if cert.Status == domain.CertificateStatusRevoked {
return fmt.Errorf("certificate is already revoked")
}
if cert.Status == domain.CertificateStatusArchived {
return fmt.Errorf("cannot revoke archived certificate")
}
// Validate reason code
if reason == "" {
reason = string(domain.RevocationReasonUnspecified)
}
if !domain.IsValidRevocationReason(reason) {
return fmt.Errorf("invalid revocation reason: %s", reason)
}
// 2. Get latest certificate version for serial number
version, err := s.certRepo.GetLatestVersion(ctx, certID)
if err != nil {
return fmt.Errorf("failed to get certificate version: %w", err)
}
// 3. Update certificate status to Revoked
now := time.Now()
cert.Status = domain.CertificateStatusRevoked
cert.RevokedAt = &now
cert.RevocationReason = reason
cert.UpdatedAt = now
if err := s.certRepo.Update(ctx, cert); err != nil {
return fmt.Errorf("failed to update certificate status: %w", err)
}
// 4. Record revocation in certificate_revocations table (for CRL generation)
if s.revocationRepo != nil {
revocation := &domain.CertificateRevocation{
ID: generateID("rev"),
CertificateID: certID,
SerialNumber: version.SerialNumber,
Reason: reason,
RevokedBy: actor,
RevokedAt: now,
IssuerID: cert.IssuerID,
CreatedAt: now,
}
if err := s.revocationRepo.Create(ctx, revocation); err != nil {
slog.Error("failed to record revocation for CRL", "error", err, "certificate_id", certID)
// Don't fail the overall revocation — the cert status is already updated
}
}
// 5. Notify the issuer connector (best-effort)
if s.issuerRegistry != nil {
if issuerConn, ok := s.issuerRegistry[cert.IssuerID]; ok {
if err := issuerConn.RevokeCertificate(ctx, version.SerialNumber, reason); err != nil {
slog.Error("failed to notify issuer of revocation",
"error", err,
"issuer_id", cert.IssuerID,
"serial", version.SerialNumber)
// Best-effort — don't fail the overall revocation
} else if s.revocationRepo != nil {
// Mark issuer as notified
revocations, _ := s.revocationRepo.ListByCertificate(ctx, certID)
for _, rev := range revocations {
if rev.SerialNumber == version.SerialNumber {
_ = s.revocationRepo.MarkIssuerNotified(ctx, rev.ID)
}
}
}
}
}
// 6. Record audit event
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"certificate_revoked", "certificate", certID,
map[string]interface{}{
"common_name": cert.CommonName,
"serial": version.SerialNumber,
"reason": reason,
}); err != nil {
slog.Error("failed to record audit event", "error", err)
}
// 7. Send revocation notification
if s.notificationSvc != nil {
if err := s.notificationSvc.SendRevocationNotification(ctx, cert, reason); err != nil {
slog.Error("failed to send revocation notification", "error", err, "certificate_id", certID)
}
}
return nil
}
// GetRevokedCertificates returns all revoked certificate records (for CRL generation).
func (s *CertificateService) GetRevokedCertificates() ([]*domain.CertificateRevocation, error) {
if s.revocationRepo == nil {
return nil, fmt.Errorf("revocation repository not configured")
}
return s.revocationRepo.ListAll(context.Background())
}
+12
View File
@@ -57,3 +57,15 @@ func (a *IssuerConnectorAdapter) RenewCertificate(ctx context.Context, commonNam
NotAfter: result.NotAfter,
}, nil
}
// RevokeCertificate delegates to the underlying connector's RevokeCertificate method.
func (a *IssuerConnectorAdapter) RevokeCertificate(ctx context.Context, serial string, reason string) error {
var reasonPtr *string
if reason != "" {
reasonPtr = &reason
}
return a.connector.RevokeCertificate(ctx, issuer.RevocationRequest{
Serial: serial,
Reason: reasonPtr,
})
}
+42 -1
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
@@ -23,7 +24,7 @@ type mockConnectorLayerIssuer struct {
orderStatus *issuer.OrderStatus
}
func (m *mockConnectorLayerIssuer) ValidateConfig(ctx context.Context, config []byte) error {
func (m *mockConnectorLayerIssuer) ValidateConfig(ctx context.Context, config json.RawMessage) error {
return m.validateErr
}
@@ -327,3 +328,43 @@ func TestIssuerConnectorAdapter_RenewCertificate_RequestTranslation(t *testing.T
t.Errorf("expected CSRPEM %s, got %s", csrPEM, mock.lastRenewReq.CSRPEM)
}
}
// Tests for RevokeCertificate
func TestIssuerConnectorAdapter_RevokeCertificate_Success(t *testing.T) {
ctx := context.Background()
mock := &mockConnectorLayerIssuer{}
adapter := NewIssuerConnectorAdapter(mock)
err := adapter.RevokeCertificate(ctx, "serial-123", "keyCompromise")
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
}
func TestIssuerConnectorAdapter_RevokeCertificate_Error(t *testing.T) {
ctx := context.Background()
testErr := errors.New("revocation failed at issuer")
mock := &mockConnectorLayerIssuer{revokeErr: testErr}
adapter := NewIssuerConnectorAdapter(mock)
err := adapter.RevokeCertificate(ctx, "serial-123", "keyCompromise")
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, testErr) {
t.Errorf("expected error %v, got %v", testErr, err)
}
}
func TestIssuerConnectorAdapter_RevokeCertificate_EmptyReason(t *testing.T) {
ctx := context.Background()
mock := &mockConnectorLayerIssuer{}
adapter := NewIssuerConnectorAdapter(mock)
// Empty reason should pass nil to the connector
err := adapter.RevokeCertificate(ctx, "serial-456", "")
if err != nil {
t.Fatalf("RevokeCertificate with empty reason failed: %v", err)
}
}
+45
View File
@@ -193,6 +193,51 @@ func (s *NotificationService) SendDeploymentNotification(ctx context.Context, ce
return s.sendNotification(ctx, notif)
}
// SendRevocationNotification sends a certificate revocation notification.
func (s *NotificationService) SendRevocationNotification(ctx context.Context, cert *domain.ManagedCertificate, reason string) error {
body := fmt.Sprintf(
"[REVOKED] The certificate for %s has been revoked.\n\nReason: %s\n\nThis certificate is no longer valid.",
cert.CommonName, reason,
)
notif := &domain.NotificationEvent{
ID: generateID("notif"),
CertificateID: &cert.ID,
Type: domain.NotificationTypeRevocation,
Channel: domain.NotificationChannelWebhook,
Recipient: s.resolveRecipient(ctx, cert.OwnerID),
Message: body,
Status: "pending",
CreatedAt: time.Now(),
}
if err := s.notifRepo.Create(ctx, notif); err != nil {
return fmt.Errorf("failed to create revocation notification: %w", err)
}
// Also send via email channel
emailNotif := &domain.NotificationEvent{
ID: generateID("notif"),
CertificateID: &cert.ID,
Type: domain.NotificationTypeRevocation,
Channel: domain.NotificationChannelEmail,
Recipient: s.resolveRecipient(ctx, cert.OwnerID),
Message: body,
Status: "pending",
CreatedAt: time.Now(),
}
if err := s.notifRepo.Create(ctx, emailNotif); err != nil {
slog.Error("failed to create email revocation notification", "error", err)
}
// Attempt immediate send for both
if err := s.sendNotification(ctx, notif); err != nil {
slog.Error("failed to send webhook revocation notification", "error", err)
}
return s.sendNotification(ctx, emailNotif)
}
// ProcessPendingNotifications sends all pending notifications in batch.
func (s *NotificationService) ProcessPendingNotifications(ctx context.Context) error {
filter := &repository.NotificationFilter{
+2
View File
@@ -37,6 +37,8 @@ type IssuerConnector interface {
IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error)
// RenewCertificate renews a certificate using the provided CSR PEM.
RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error)
// RevokeCertificate revokes a certificate by serial number with an optional reason.
RevokeCertificate(ctx context.Context, serial string, reason string) error
}
// IssuanceResult holds the result of a certificate issuance or renewal operation.
+410
View File
@@ -0,0 +1,410 @@
package service
import (
"context"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// helper to create a test CertificateService wired for revocation tests
func newRevocationTestService() (*CertificateService, *mockCertRepo, *mockRevocationRepo, *mockAuditRepo) {
certRepo := newMockCertificateRepository()
auditRepo := newMockAuditRepository()
policyRepo := newMockPolicyRepository()
revocationRepo := newMockRevocationRepository()
auditService := NewAuditService(auditRepo)
policyService := NewPolicyService(policyRepo, auditService)
certService := NewCertificateService(certRepo, policyService, auditService)
certService.SetRevocationRepo(revocationRepo)
return certService, certRepo, revocationRepo, auditRepo
}
func TestRevokeCertificate_Success(t *testing.T) {
svc, certRepo, revocationRepo, auditRepo := newRevocationTestService()
// Set up test data
cert := &domain.ManagedCertificate{
ID: "cert-1",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 6, 0),
}
certRepo.AddCert(cert)
// Add a certificate version with a serial number
version := &domain.CertificateVersion{
ID: "ver-1",
CertificateID: "cert-1",
SerialNumber: "ABC123",
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
CreatedAt: time.Now(),
}
certRepo.Versions["cert-1"] = []*domain.CertificateVersion{version}
// Revoke
err := svc.RevokeCertificateWithActor(context.Background(), "cert-1", "keyCompromise", "admin")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
// Verify certificate status changed
updated, _ := certRepo.Get(context.Background(), "cert-1")
if updated.Status != domain.CertificateStatusRevoked {
t.Errorf("expected status Revoked, got %s", updated.Status)
}
if updated.RevokedAt == nil {
t.Error("expected RevokedAt to be set")
}
if updated.RevocationReason != "keyCompromise" {
t.Errorf("expected reason keyCompromise, got %s", updated.RevocationReason)
}
// Verify revocation record created
if len(revocationRepo.Revocations) != 1 {
t.Fatalf("expected 1 revocation record, got %d", len(revocationRepo.Revocations))
}
rev := revocationRepo.Revocations[0]
if rev.SerialNumber != "ABC123" {
t.Errorf("expected serial ABC123, got %s", rev.SerialNumber)
}
if rev.Reason != "keyCompromise" {
t.Errorf("expected reason keyCompromise, got %s", rev.Reason)
}
if rev.RevokedBy != "admin" {
t.Errorf("expected revokedBy admin, got %s", rev.RevokedBy)
}
// Verify audit event recorded
if len(auditRepo.Events) == 0 {
t.Error("expected audit event to be recorded")
}
foundRevocationAudit := false
for _, e := range auditRepo.Events {
if e.Action == "certificate_revoked" {
foundRevocationAudit = true
}
}
if !foundRevocationAudit {
t.Error("expected certificate_revoked audit event")
}
}
func TestRevokeCertificate_DefaultReason(t *testing.T) {
svc, certRepo, revocationRepo, _ := newRevocationTestService()
cert := &domain.ManagedCertificate{
ID: "cert-2",
CommonName: "default-reason.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 6, 0),
}
certRepo.AddCert(cert)
certRepo.Versions["cert-2"] = []*domain.CertificateVersion{
{ID: "ver-2", CertificateID: "cert-2", SerialNumber: "DEF456", CreatedAt: time.Now()},
}
// Revoke with empty reason — should default to "unspecified"
err := svc.RevokeCertificateWithActor(context.Background(), "cert-2", "", "api")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
updated, _ := certRepo.Get(context.Background(), "cert-2")
if updated.RevocationReason != "unspecified" {
t.Errorf("expected default reason 'unspecified', got %s", updated.RevocationReason)
}
if len(revocationRepo.Revocations) != 1 {
t.Fatalf("expected 1 revocation, got %d", len(revocationRepo.Revocations))
}
if revocationRepo.Revocations[0].Reason != "unspecified" {
t.Errorf("expected revocation reason 'unspecified', got %s", revocationRepo.Revocations[0].Reason)
}
}
func TestRevokeCertificate_AlreadyRevoked(t *testing.T) {
svc, certRepo, _, _ := newRevocationTestService()
now := time.Now()
cert := &domain.ManagedCertificate{
ID: "cert-3",
CommonName: "already-revoked.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRevoked,
RevokedAt: &now,
RevocationReason: "keyCompromise",
ExpiresAt: time.Now().AddDate(0, 6, 0),
}
certRepo.AddCert(cert)
err := svc.RevokeCertificateWithActor(context.Background(), "cert-3", "superseded", "admin")
if err == nil {
t.Fatal("expected error for already revoked certificate")
}
if err.Error() != "certificate is already revoked" {
t.Errorf("expected 'already revoked' error, got: %v", err)
}
}
func TestRevokeCertificate_ArchivedCert(t *testing.T) {
svc, certRepo, _, _ := newRevocationTestService()
cert := &domain.ManagedCertificate{
ID: "cert-4",
CommonName: "archived.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusArchived,
ExpiresAt: time.Now().AddDate(0, 6, 0),
}
certRepo.AddCert(cert)
err := svc.RevokeCertificateWithActor(context.Background(), "cert-4", "keyCompromise", "admin")
if err == nil {
t.Fatal("expected error for archived certificate")
}
if err.Error() != "cannot revoke archived certificate" {
t.Errorf("expected 'cannot revoke archived' error, got: %v", err)
}
}
func TestRevokeCertificate_InvalidReason(t *testing.T) {
svc, certRepo, _, _ := newRevocationTestService()
cert := &domain.ManagedCertificate{
ID: "cert-5",
CommonName: "invalid-reason.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 6, 0),
}
certRepo.AddCert(cert)
err := svc.RevokeCertificateWithActor(context.Background(), "cert-5", "notAValidReason", "admin")
if err == nil {
t.Fatal("expected error for invalid reason")
}
if err.Error() != "invalid revocation reason: notAValidReason" {
t.Errorf("unexpected error: %v", err)
}
}
func TestRevokeCertificate_NotFound(t *testing.T) {
svc, _, _, _ := newRevocationTestService()
err := svc.RevokeCertificateWithActor(context.Background(), "nonexistent-cert", "keyCompromise", "admin")
if err == nil {
t.Fatal("expected error for nonexistent certificate")
}
}
func TestRevokeCertificate_NoVersion(t *testing.T) {
svc, certRepo, _, _ := newRevocationTestService()
cert := &domain.ManagedCertificate{
ID: "cert-6",
CommonName: "no-version.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 6, 0),
}
certRepo.AddCert(cert)
// No versions added — should fail
err := svc.RevokeCertificateWithActor(context.Background(), "cert-6", "keyCompromise", "admin")
if err == nil {
t.Fatal("expected error when no certificate version exists")
}
}
func TestRevokeCertificate_WithIssuerNotification(t *testing.T) {
svc, certRepo, revocationRepo, _ := newRevocationTestService()
// Wire up issuer registry with mock
mockIssuer := &mockIssuerConnector{}
svc.SetIssuerRegistry(map[string]IssuerConnector{
"iss-local": mockIssuer,
})
cert := &domain.ManagedCertificate{
ID: "cert-7",
CommonName: "issuer-notify.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 6, 0),
}
certRepo.AddCert(cert)
certRepo.Versions["cert-7"] = []*domain.CertificateVersion{
{ID: "ver-7", CertificateID: "cert-7", SerialNumber: "GHI789", CreatedAt: time.Now()},
}
err := svc.RevokeCertificateWithActor(context.Background(), "cert-7", "cessationOfOperation", "admin")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
// Verify revocation was recorded and issuer was notified
if len(revocationRepo.Revocations) != 1 {
t.Fatalf("expected 1 revocation, got %d", len(revocationRepo.Revocations))
}
if !revocationRepo.Revocations[0].IssuerNotified {
t.Error("expected issuer to be marked as notified")
}
}
func TestRevokeCertificate_WithNotificationService(t *testing.T) {
svc, certRepo, _, _ := newRevocationTestService()
// Wire up notification service
notifRepo := newMockNotificationRepository()
notifService := NewNotificationService(notifRepo, make(map[string]Notifier))
svc.SetNotificationService(notifService)
cert := &domain.ManagedCertificate{
ID: "cert-8",
CommonName: "with-notify.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusActive,
OwnerID: "owner-alice",
ExpiresAt: time.Now().AddDate(0, 6, 0),
}
certRepo.AddCert(cert)
certRepo.Versions["cert-8"] = []*domain.CertificateVersion{
{ID: "ver-8", CertificateID: "cert-8", SerialNumber: "JKL012", CreatedAt: time.Now()},
}
err := svc.RevokeCertificateWithActor(context.Background(), "cert-8", "keyCompromise", "admin")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
// Should have created revocation notifications (webhook + email)
if len(notifRepo.Notifications) < 1 {
t.Error("expected at least one revocation notification to be created")
}
foundRevocationNotif := false
for _, n := range notifRepo.Notifications {
if n.Type == domain.NotificationTypeRevocation {
foundRevocationNotif = true
}
}
if !foundRevocationNotif {
t.Error("expected Revocation type notification")
}
}
func TestRevokeCertificate_AllValidReasons(t *testing.T) {
reasons := []string{
"unspecified", "keyCompromise", "caCompromise", "affiliationChanged",
"superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn",
}
for _, reason := range reasons {
t.Run(reason, func(t *testing.T) {
svc, certRepo, _, _ := newRevocationTestService()
cert := &domain.ManagedCertificate{
ID: "cert-" + reason,
CommonName: reason + ".com",
IssuerID: "iss-local",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 6, 0),
}
certRepo.AddCert(cert)
certRepo.Versions["cert-"+reason] = []*domain.CertificateVersion{
{ID: "ver-" + reason, CertificateID: "cert-" + reason, SerialNumber: "SER-" + reason, CreatedAt: time.Now()},
}
err := svc.RevokeCertificateWithActor(context.Background(), "cert-"+reason, reason, "admin")
if err != nil {
t.Fatalf("expected no error for reason %s, got: %v", reason, err)
}
updated, _ := certRepo.Get(context.Background(), "cert-"+reason)
if updated.Status != domain.CertificateStatusRevoked {
t.Errorf("expected Revoked status, got %s", updated.Status)
}
})
}
}
func TestGetRevokedCertificates_Success(t *testing.T) {
svc, _, revocationRepo, _ := newRevocationTestService()
// Pre-populate revocation records
revocationRepo.Revocations = []*domain.CertificateRevocation{
{ID: "rev-1", CertificateID: "cert-1", SerialNumber: "SER-1", Reason: "keyCompromise", RevokedAt: time.Now()},
{ID: "rev-2", CertificateID: "cert-2", SerialNumber: "SER-2", Reason: "superseded", RevokedAt: time.Now()},
}
revocations, err := svc.GetRevokedCertificates()
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if len(revocations) != 2 {
t.Errorf("expected 2 revocations, got %d", len(revocations))
}
}
func TestGetRevokedCertificates_Empty(t *testing.T) {
svc, _, _, _ := newRevocationTestService()
revocations, err := svc.GetRevokedCertificates()
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if revocations == nil {
// nil is acceptable for empty
} else if len(revocations) != 0 {
t.Errorf("expected 0 revocations, got %d", len(revocations))
}
}
func TestGetRevokedCertificates_NoRepo(t *testing.T) {
certRepo := newMockCertificateRepository()
auditRepo := newMockAuditRepository()
policyRepo := newMockPolicyRepository()
auditService := NewAuditService(auditRepo)
policyService := NewPolicyService(policyRepo, auditService)
svc := NewCertificateService(certRepo, policyService, auditService)
// Do NOT set revocation repo
_, err := svc.GetRevokedCertificates()
if err == nil {
t.Fatal("expected error when revocation repo not configured")
}
}
func TestRevokeCertificate_HandlerInterfaceMethod(t *testing.T) {
svc, certRepo, _, _ := newRevocationTestService()
cert := &domain.ManagedCertificate{
ID: "cert-handler",
CommonName: "handler-test.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 6, 0),
}
certRepo.AddCert(cert)
certRepo.Versions["cert-handler"] = []*domain.CertificateVersion{
{ID: "ver-handler", CertificateID: "cert-handler", SerialNumber: "SER-HANDLER", CreatedAt: time.Now()},
}
// Test the handler interface method (no actor param)
err := svc.RevokeCertificate("cert-handler", "superseded")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
updated, _ := certRepo.Get(context.Background(), "cert-handler")
if updated.Status != domain.CertificateStatusRevoked {
t.Errorf("expected Revoked status, got %s", updated.Status)
}
}
+4 -5
View File
@@ -3,11 +3,10 @@ package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// mockTeamRepo is a test implementation of TeamRepository
@@ -162,8 +161,8 @@ func TestTeamService_List_RepositoryError(t *testing.T) {
t.Fatalf("expected error, got nil")
}
if !errors.Is(err, errors.New("database error")) {
t.Errorf("expected database error, got %v", err)
if !strings.Contains(err.Error(), "database error") {
t.Errorf("expected error containing 'database error', got %v", err)
}
}
@@ -281,7 +280,7 @@ func TestTeamService_Create(t *testing.T) {
t.Errorf("expected ID to be generated, got empty")
}
if !team.ID[:5] == "team-" {
if !(team.ID[:5] == "team-") {
t.Logf("note: generated ID is %s", team.ID)
}
+72
View File
@@ -103,6 +103,14 @@ func (m *mockCertRepo) GetExpiringCertificates(ctx context.Context, before time.
return expiring, nil
}
func (m *mockCertRepo) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
versions := m.Versions[certID]
if len(versions) == 0 {
return nil, errNotFound
}
return versions[len(versions)-1], nil
}
func (m *mockCertRepo) AddCert(cert *domain.ManagedCertificate) {
m.Certs[cert.ID] = cert
}
@@ -605,6 +613,13 @@ func (m *mockIssuerConnector) RenewCertificate(ctx context.Context, commonName s
return m.IssueCertificate(ctx, commonName, sans, csrPEM)
}
func (m *mockIssuerConnector) RevokeCertificate(ctx context.Context, serial string, reason string) error {
if m.Err != nil {
return m.Err
}
return nil
}
// Constructor functions for mocks
func newMockCertificateRepository() *mockCertRepo {
@@ -725,6 +740,63 @@ func (m *mockIssuerRepository) AddIssuer(issuer *domain.Issuer) {
m.issuers[issuer.ID] = issuer
}
// mockRevocationRepo is a test implementation of RevocationRepository
type mockRevocationRepo struct {
Revocations []*domain.CertificateRevocation
CreateErr error
ListErr error
}
func (m *mockRevocationRepo) Create(ctx context.Context, revocation *domain.CertificateRevocation) error {
if m.CreateErr != nil {
return m.CreateErr
}
m.Revocations = append(m.Revocations, revocation)
return nil
}
func (m *mockRevocationRepo) GetBySerial(ctx context.Context, serial string) (*domain.CertificateRevocation, error) {
for _, r := range m.Revocations {
if r.SerialNumber == serial {
return r, nil
}
}
return nil, errNotFound
}
func (m *mockRevocationRepo) ListAll(ctx context.Context) ([]*domain.CertificateRevocation, error) {
if m.ListErr != nil {
return nil, m.ListErr
}
return m.Revocations, nil
}
func (m *mockRevocationRepo) 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 *mockRevocationRepo) MarkIssuerNotified(ctx context.Context, id string) error {
for _, r := range m.Revocations {
if r.ID == id {
r.IssuerNotified = true
return nil
}
}
return errNotFound
}
func newMockRevocationRepository() *mockRevocationRepo {
return &mockRevocationRepo{
Revocations: make([]*domain.CertificateRevocation, 0),
}
}
// mockNotifier is a simple notifier for testing
type mockNotifier struct {
messages []*mockNotifierMessage