mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:01:36 +00:00
5d98e373e3
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>
411 lines
13 KiB
Go
411 lines
13 KiB
Go
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)
|
|
}
|
|
}
|