mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 13:38:56 +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:
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user