mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:01:30 +00:00
c015cab2f4
Rewrote docs/features.md from scratch as authoritative feature inventory (1255 lines, every claim verified against source files). Audited README.md and architecture.md against repo — fixed 19 stale references: K8s Secrets status, issuer counts, dashboard page counts, CI thresholds, missing connectors in Mermaid diagrams, OpenAPI operation count, GetCACertPEM behavior, and V2/V4 roadmap accuracy. Also includes related fixes discovered during audit: - Scheduler skips expired/failed/revoked certs from auto-renewal - Seed demo expiry dates moved outside 31-day scheduler query window - Agent pages use correct last_heartbeat_at field name Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1388 lines
42 KiB
Go
1388 lines
42 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
func TestCheckExpiringCertificates_SendsThresholdAlerts(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
notifier := newMockNotifier()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{
|
|
"Email": notifier,
|
|
})
|
|
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Create a cert expiring in 10 days
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-expiring",
|
|
Name: "Test Cert",
|
|
CommonName: "test.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-test",
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(0, 0, 10),
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
// Create policy with thresholds
|
|
policy := &domain.RenewalPolicy{
|
|
ID: "rp-standard",
|
|
Name: "Standard",
|
|
RenewalWindowDays: 30,
|
|
AutoRenew: true,
|
|
MaxRetries: 3,
|
|
RetryInterval: 300,
|
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
policyRepo.AddPolicy(policy)
|
|
|
|
// Run expiry check
|
|
err := svc.CheckExpiringCertificates(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
|
}
|
|
|
|
// Verify alerts were sent
|
|
if len(notifRepo.Notifications) < 1 {
|
|
t.Errorf("expected at least 1 alert, got %d", len(notifRepo.Notifications))
|
|
}
|
|
|
|
// Verify renewal job was created
|
|
if len(jobRepo.Jobs) < 1 {
|
|
t.Errorf("expected renewal job to be created")
|
|
}
|
|
|
|
hasRenewalJob := false
|
|
for _, job := range jobRepo.Jobs {
|
|
if job.Type == domain.JobTypeRenewal {
|
|
hasRenewalJob = true
|
|
break
|
|
}
|
|
}
|
|
if !hasRenewalJob {
|
|
t.Errorf("expected renewal job in jobs")
|
|
}
|
|
}
|
|
|
|
func TestCheckExpiringCertificates_DeduplicatesAlerts(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
notifier := newMockNotifier()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{
|
|
"Email": notifier,
|
|
})
|
|
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Create cert
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-dedup",
|
|
Name: "Test Cert",
|
|
CommonName: "test.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-test",
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(0, 0, 10),
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
// Create policy
|
|
policy := &domain.RenewalPolicy{
|
|
ID: "rp-standard",
|
|
Name: "Standard",
|
|
RenewalWindowDays: 30,
|
|
AutoRenew: true,
|
|
MaxRetries: 3,
|
|
RetryInterval: 300,
|
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
policyRepo.AddPolicy(policy)
|
|
|
|
// Add existing threshold alert notification
|
|
existingNotif := &domain.NotificationEvent{
|
|
ID: "notif-existing",
|
|
CertificateID: &cert.ID,
|
|
Type: domain.NotificationTypeExpirationWarning,
|
|
Channel: domain.NotificationChannelEmail,
|
|
Recipient: "owner-1",
|
|
Message: "Alert [threshold:7]",
|
|
Status: "sent",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
notifRepo.AddNotification(existingNotif)
|
|
|
|
// Run first check
|
|
_ = svc.CheckExpiringCertificates(ctx)
|
|
|
|
initialCount := notifier.getSentCount()
|
|
|
|
// Run second check - should deduplicate
|
|
_ = svc.CheckExpiringCertificates(ctx)
|
|
|
|
finalCount := notifier.getSentCount()
|
|
|
|
// Should not send duplicate alerts
|
|
if finalCount > initialCount {
|
|
t.Errorf("expected deduplication, but sent new alerts: initial=%d, final=%d", initialCount, finalCount)
|
|
}
|
|
}
|
|
|
|
func TestCheckExpiringCertificates_SkipsRenewalInProgress(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Create cert with RenewalInProgress status
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-in-progress",
|
|
Name: "Test Cert",
|
|
CommonName: "test.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-test",
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusRenewalInProgress,
|
|
ExpiresAt: time.Now().AddDate(0, 0, 10),
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
// Create policy
|
|
policy := &domain.RenewalPolicy{
|
|
ID: "rp-standard",
|
|
Name: "Standard",
|
|
RenewalWindowDays: 30,
|
|
AutoRenew: true,
|
|
MaxRetries: 3,
|
|
RetryInterval: 300,
|
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
policyRepo.AddPolicy(policy)
|
|
|
|
// Run check
|
|
err := svc.CheckExpiringCertificates(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
|
}
|
|
|
|
// Should not create renewal job for cert already renewing
|
|
for _, job := range jobRepo.Jobs {
|
|
if job.Type == domain.JobTypeRenewal {
|
|
t.Errorf("should not create renewal job for cert with RenewalInProgress status")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckExpiringCertificates_SkipsExpiredFailedRevoked(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Test that certs in Expired, Failed, and Revoked states do not get renewal jobs
|
|
for _, tc := range []struct {
|
|
name string
|
|
status domain.CertificateStatus
|
|
}{
|
|
{"Expired", domain.CertificateStatusExpired},
|
|
{"Failed", domain.CertificateStatusFailed},
|
|
{"Revoked", domain.CertificateStatusRevoked},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-" + strings.ToLower(string(tc.status)),
|
|
Name: "Test " + string(tc.status),
|
|
CommonName: "test.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-test",
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: tc.status,
|
|
ExpiresAt: time.Now().AddDate(0, 0, 10),
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
policy := &domain.RenewalPolicy{
|
|
ID: "rp-standard",
|
|
Name: "Standard",
|
|
RenewalWindowDays: 30,
|
|
AutoRenew: true,
|
|
MaxRetries: 3,
|
|
RetryInterval: 300,
|
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
policyRepo.AddPolicy(policy)
|
|
|
|
err := svc.CheckExpiringCertificates(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
|
}
|
|
|
|
for _, job := range jobRepo.Jobs {
|
|
if job.Type == domain.JobTypeRenewal {
|
|
t.Errorf("should not create renewal job for cert with %s status", tc.status)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckExpiringCertificates_UpdatesStatusToExpiring(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Create active cert that will become expiring
|
|
// Use an issuer NOT in the registry so no renewal job is created (which would override status)
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-expiring-status",
|
|
Name: "Test Cert",
|
|
CommonName: "test.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-unregistered",
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(0, 0, 5), // 5 days, within 30-day threshold
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
// Create policy with AutoRenew: false so we only test status transition
|
|
policy := &domain.RenewalPolicy{
|
|
ID: "rp-standard",
|
|
Name: "Standard",
|
|
RenewalWindowDays: 30,
|
|
AutoRenew: false,
|
|
MaxRetries: 3,
|
|
RetryInterval: 300,
|
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
policyRepo.AddPolicy(policy)
|
|
|
|
// Run check
|
|
_ = svc.CheckExpiringCertificates(ctx)
|
|
|
|
// Verify status was updated to Expiring
|
|
updated, _ := certRepo.Get(ctx, cert.ID)
|
|
if updated.Status != domain.CertificateStatusExpiring {
|
|
t.Errorf("expected status Expiring, got %s", updated.Status)
|
|
}
|
|
}
|
|
|
|
func TestCheckExpiringCertificates_UpdatesStatusToExpired(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Create cert that is already expired
|
|
// Use an issuer NOT in the registry so no renewal job is created (which would override status)
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-expired-status",
|
|
Name: "Test Cert",
|
|
CommonName: "test.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-unregistered",
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(0, 0, -1), // Already expired
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
// Create policy with AutoRenew: false so we only test status transition
|
|
policy := &domain.RenewalPolicy{
|
|
ID: "rp-standard",
|
|
Name: "Standard",
|
|
RenewalWindowDays: 30,
|
|
AutoRenew: false,
|
|
MaxRetries: 3,
|
|
RetryInterval: 300,
|
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
policyRepo.AddPolicy(policy)
|
|
|
|
// Run check
|
|
_ = svc.CheckExpiringCertificates(ctx)
|
|
|
|
// Verify status was updated to Expired
|
|
updated, _ := certRepo.Get(ctx, cert.ID)
|
|
if updated.Status != domain.CertificateStatusExpired {
|
|
t.Errorf("expected status Expired, got %s", updated.Status)
|
|
}
|
|
}
|
|
|
|
func TestCheckExpiringCertificates_CreatesRenewalJob(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Create expiring cert with registered issuer
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-job-create",
|
|
Name: "Test Cert",
|
|
CommonName: "test.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-test", // Registered issuer
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(0, 0, 20),
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
// Create policy
|
|
policy := &domain.RenewalPolicy{
|
|
ID: "rp-standard",
|
|
Name: "Standard",
|
|
RenewalWindowDays: 30,
|
|
AutoRenew: true,
|
|
MaxRetries: 3,
|
|
RetryInterval: 300,
|
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
policyRepo.AddPolicy(policy)
|
|
|
|
// Run check
|
|
_ = svc.CheckExpiringCertificates(ctx)
|
|
|
|
// Verify renewal job was created
|
|
hasRenewalJob := false
|
|
for _, job := range jobRepo.Jobs {
|
|
if job.Type == domain.JobTypeRenewal && job.Status == domain.JobStatusPending {
|
|
hasRenewalJob = true
|
|
break
|
|
}
|
|
}
|
|
if !hasRenewalJob {
|
|
t.Errorf("expected renewal job to be created")
|
|
}
|
|
}
|
|
|
|
func TestCheckExpiringCertificates_SkipsWithoutIssuer(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
// Empty issuer registry
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Create cert with unregistered issuer
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-no-issuer",
|
|
Name: "Test Cert",
|
|
CommonName: "test.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-missing", // Not in registry
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(0, 0, 20),
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
// Create policy
|
|
policy := &domain.RenewalPolicy{
|
|
ID: "rp-standard",
|
|
Name: "Standard",
|
|
RenewalWindowDays: 30,
|
|
AutoRenew: true,
|
|
MaxRetries: 3,
|
|
RetryInterval: 300,
|
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
policyRepo.AddPolicy(policy)
|
|
|
|
// Run check
|
|
_ = svc.CheckExpiringCertificates(ctx)
|
|
|
|
// Should not create renewal job without issuer
|
|
for _, job := range jobRepo.Jobs {
|
|
if job.Type == domain.JobTypeRenewal {
|
|
t.Errorf("should not create renewal job for cert with missing issuer")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckExpiringCertificates_SkipsDuplicateJobs(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Create cert
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-dup-job",
|
|
Name: "Test Cert",
|
|
CommonName: "test.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-test",
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(0, 0, 20),
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
// Create policy
|
|
policy := &domain.RenewalPolicy{
|
|
ID: "rp-standard",
|
|
Name: "Standard",
|
|
RenewalWindowDays: 30,
|
|
AutoRenew: true,
|
|
MaxRetries: 3,
|
|
RetryInterval: 300,
|
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
policyRepo.AddPolicy(policy)
|
|
|
|
// Add existing renewal job
|
|
existingJob := &domain.Job{
|
|
ID: "job-existing",
|
|
CertificateID: cert.ID,
|
|
Type: domain.JobTypeRenewal,
|
|
Status: domain.JobStatusPending,
|
|
MaxAttempts: 3,
|
|
ScheduledAt: time.Now(),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
jobRepo.AddJob(existingJob)
|
|
|
|
// Run first check
|
|
_ = svc.CheckExpiringCertificates(ctx)
|
|
|
|
// Run second check
|
|
_ = svc.CheckExpiringCertificates(ctx)
|
|
|
|
// Should have only 1 renewal job
|
|
renewalCount := 0
|
|
for _, job := range jobRepo.Jobs {
|
|
if job.Type == domain.JobTypeRenewal {
|
|
renewalCount++
|
|
}
|
|
}
|
|
if renewalCount > 1 {
|
|
t.Errorf("expected 1 renewal job, got %d (duplicate prevention failed)", renewalCount)
|
|
}
|
|
}
|
|
|
|
func TestProcessRenewalJob(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{
|
|
"Email": newMockNotifier(),
|
|
})
|
|
|
|
issuerConnector := &mockIssuerConnector{}
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-test", issuerConnector)
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Create certificate
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-renewal",
|
|
Name: "Test Cert",
|
|
CommonName: "test.example.com",
|
|
SANs: []string{"www.test.example.com"},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-test",
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusActive,
|
|
TargetIDs: []string{"target-1", "target-2"},
|
|
ExpiresAt: time.Now().AddDate(0, 0, 30),
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
// Create renewal job
|
|
job := &domain.Job{
|
|
ID: "job-renewal-1",
|
|
CertificateID: cert.ID,
|
|
Type: domain.JobTypeRenewal,
|
|
Status: domain.JobStatusPending,
|
|
MaxAttempts: 3,
|
|
ScheduledAt: time.Now(),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
jobRepo.AddJob(job)
|
|
|
|
// Process renewal job
|
|
err := svc.ProcessRenewalJob(ctx, job)
|
|
if err != nil {
|
|
t.Fatalf("ProcessRenewalJob failed: %v", err)
|
|
}
|
|
|
|
// Verify cert was updated
|
|
updated, _ := certRepo.Get(ctx, cert.ID)
|
|
if updated.Status != domain.CertificateStatusActive {
|
|
t.Errorf("expected cert status Active, got %s", updated.Status)
|
|
}
|
|
|
|
if updated.LastRenewalAt == nil {
|
|
t.Errorf("expected LastRenewalAt to be set")
|
|
}
|
|
|
|
// Verify certificate version was created
|
|
if len(certRepo.Versions[cert.ID]) != 1 {
|
|
t.Errorf("expected 1 certificate version, got %d", len(certRepo.Versions[cert.ID]))
|
|
}
|
|
|
|
// Verify deployment jobs were created
|
|
deploymentCount := 0
|
|
for _, j := range jobRepo.Jobs {
|
|
if j.Type == domain.JobTypeDeployment {
|
|
deploymentCount++
|
|
}
|
|
}
|
|
if deploymentCount != 2 {
|
|
t.Errorf("expected 2 deployment jobs (one per target), got %d", deploymentCount)
|
|
}
|
|
|
|
// Verify job was marked as completed
|
|
completedJob, _ := jobRepo.Get(ctx, job.ID)
|
|
if completedJob.Status != domain.JobStatusCompleted {
|
|
t.Errorf("expected job status Completed, got %s", completedJob.Status)
|
|
}
|
|
}
|
|
|
|
func TestProcessRenewalJob_IssuerFailure(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{
|
|
"Email": newMockNotifier(),
|
|
})
|
|
|
|
// Create issuer that will fail
|
|
issuerConnector := &mockIssuerConnector{
|
|
Err: fmt.Errorf("issuer service unavailable"),
|
|
}
|
|
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-test", issuerConnector)
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Create certificate
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-renewal-fail",
|
|
Name: "Test Cert",
|
|
CommonName: "test.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-test",
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(0, 0, 30),
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
// Create renewal job
|
|
job := &domain.Job{
|
|
ID: "job-renewal-fail",
|
|
CertificateID: cert.ID,
|
|
Type: domain.JobTypeRenewal,
|
|
Status: domain.JobStatusPending,
|
|
MaxAttempts: 3,
|
|
ScheduledAt: time.Now(),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
jobRepo.AddJob(job)
|
|
|
|
// Process renewal job (should fail)
|
|
err := svc.ProcessRenewalJob(ctx, job)
|
|
if err == nil {
|
|
t.Fatalf("expected ProcessRenewalJob to fail")
|
|
}
|
|
|
|
// Verify job was marked as failed
|
|
failedJob, _ := jobRepo.Get(ctx, job.ID)
|
|
if failedJob.Status != domain.JobStatusFailed {
|
|
t.Errorf("expected job status Failed, got %s", failedJob.Status)
|
|
}
|
|
|
|
if failedJob.LastError == nil || !strings.Contains(*failedJob.LastError, "issuer service unavailable") {
|
|
t.Errorf("expected error message in job, got: %v", failedJob.LastError)
|
|
}
|
|
|
|
// Verify failure notification was sent
|
|
if len(notifRepo.Notifications) < 1 {
|
|
t.Errorf("expected failure notification to be created")
|
|
}
|
|
|
|
foundFailureNotif := false
|
|
for _, notif := range notifRepo.Notifications {
|
|
if notif.Type == domain.NotificationTypeRenewalFailure {
|
|
foundFailureNotif = true
|
|
break
|
|
}
|
|
}
|
|
if !foundFailureNotif {
|
|
t.Errorf("expected RenewalFailure notification type")
|
|
}
|
|
}
|
|
|
|
func TestRetryFailedJobs(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Create failed job with attempts < max_attempts
|
|
failedJob := &domain.Job{
|
|
ID: "job-failed-1",
|
|
CertificateID: "mc-test",
|
|
Type: domain.JobTypeRenewal,
|
|
Status: domain.JobStatusFailed,
|
|
Attempts: 1,
|
|
MaxAttempts: 3,
|
|
LastError: stringPtr("temporary failure"),
|
|
ScheduledAt: time.Now(),
|
|
CreatedAt: time.Now().AddDate(0, 0, -1),
|
|
}
|
|
jobRepo.AddJob(failedJob)
|
|
|
|
// Create other job types that should be ignored
|
|
otherJob := &domain.Job{
|
|
ID: "job-other",
|
|
CertificateID: "mc-test",
|
|
Type: domain.JobTypeDeployment,
|
|
Status: domain.JobStatusFailed,
|
|
Attempts: 1,
|
|
MaxAttempts: 3,
|
|
ScheduledAt: time.Now(),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
jobRepo.AddJob(otherJob)
|
|
|
|
// Retry failed jobs
|
|
err := svc.RetryFailedJobs(ctx, 3)
|
|
if err != nil {
|
|
t.Fatalf("RetryFailedJobs failed: %v", err)
|
|
}
|
|
|
|
// Verify failed renewal job was reset to pending
|
|
retried, _ := jobRepo.Get(ctx, failedJob.ID)
|
|
if retried.Status != domain.JobStatusPending {
|
|
t.Errorf("expected job status Pending after retry, got %s", retried.Status)
|
|
}
|
|
|
|
// Verify other job type was not touched
|
|
other, _ := jobRepo.Get(ctx, otherJob.ID)
|
|
if other.Status != domain.JobStatusFailed {
|
|
t.Errorf("expected non-renewal job to stay Failed, got %s", other.Status)
|
|
}
|
|
}
|
|
|
|
func TestProcessRenewalJob_NoCertificate(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-test", &mockIssuerConnector{})
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Create job with non-existent certificate
|
|
job := &domain.Job{
|
|
ID: "job-no-cert",
|
|
CertificateID: "mc-missing",
|
|
Type: domain.JobTypeRenewal,
|
|
Status: domain.JobStatusPending,
|
|
MaxAttempts: 3,
|
|
ScheduledAt: time.Now(),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
jobRepo.AddJob(job)
|
|
|
|
// Process renewal job
|
|
err := svc.ProcessRenewalJob(ctx, job)
|
|
if err == nil {
|
|
t.Fatalf("expected ProcessRenewalJob to fail for missing certificate")
|
|
}
|
|
|
|
// Verify job was marked as failed
|
|
failedJob, _ := jobRepo.Get(ctx, job.ID)
|
|
if failedJob.Status != domain.JobStatusFailed {
|
|
t.Errorf("expected job status Failed, got %s", failedJob.Status)
|
|
}
|
|
}
|
|
|
|
// --- ARI (RFC 9773) Scheduler Integration Tests ---
|
|
|
|
func TestCheckExpiringCertificates_ARI_ShouldRenewNow(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
// ARI says renew now: window started in the past
|
|
ariConnector := &mockIssuerConnector{
|
|
getRenewalInfoResult: &RenewalInfoResult{
|
|
SuggestedWindowStart: time.Now().Add(-24 * time.Hour),
|
|
SuggestedWindowEnd: time.Now().Add(48 * time.Hour),
|
|
},
|
|
}
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-acme", ariConnector)
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Create cert expiring in 20 days with a cert version (needed for ARI lookup)
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-ari-renew",
|
|
Name: "ARI Cert",
|
|
CommonName: "ari.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-acme",
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(0, 0, 20),
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
|
{ID: "cv-1", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
|
}
|
|
|
|
policy := &domain.RenewalPolicy{
|
|
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
|
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
|
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
|
}
|
|
policyRepo.AddPolicy(policy)
|
|
|
|
err := svc.CheckExpiringCertificates(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
|
}
|
|
|
|
// ARI says renew now, so a renewal job should be created
|
|
hasRenewalJob := false
|
|
for _, job := range jobRepo.Jobs {
|
|
if job.Type == domain.JobTypeRenewal {
|
|
hasRenewalJob = true
|
|
break
|
|
}
|
|
}
|
|
if !hasRenewalJob {
|
|
t.Errorf("expected renewal job when ARI ShouldRenewNow is true")
|
|
}
|
|
}
|
|
|
|
func TestCheckExpiringCertificates_ARI_NotYet(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
// ARI says NOT yet: window starts in the future
|
|
ariConnector := &mockIssuerConnector{
|
|
getRenewalInfoResult: &RenewalInfoResult{
|
|
SuggestedWindowStart: time.Now().Add(72 * time.Hour),
|
|
SuggestedWindowEnd: time.Now().Add(96 * time.Hour),
|
|
},
|
|
}
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-acme", ariConnector)
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
// Cert is within the 30-day threshold window (would normally trigger renewal),
|
|
// but ARI says "not yet"
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-ari-wait",
|
|
Name: "ARI Wait Cert",
|
|
CommonName: "ari-wait.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-acme",
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(0, 0, 10),
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
|
{ID: "cv-2", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
|
}
|
|
|
|
policy := &domain.RenewalPolicy{
|
|
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
|
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
|
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
|
}
|
|
policyRepo.AddPolicy(policy)
|
|
|
|
err := svc.CheckExpiringCertificates(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
|
}
|
|
|
|
// ARI says not yet, so NO renewal job should be created
|
|
for _, job := range jobRepo.Jobs {
|
|
if job.Type == domain.JobTypeRenewal {
|
|
t.Errorf("expected no renewal job when ARI says not yet, but found one")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckExpiringCertificates_ARI_NilResult_FallsThrough(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
// ARI returns nil (issuer doesn't support ARI) — default mock behavior
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-local", &mockIssuerConnector{})
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-ari-nil",
|
|
Name: "No ARI Cert",
|
|
CommonName: "no-ari.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-local",
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(0, 0, 20),
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
|
{ID: "cv-3", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
|
}
|
|
|
|
policy := &domain.RenewalPolicy{
|
|
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
|
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
|
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
|
}
|
|
policyRepo.AddPolicy(policy)
|
|
|
|
err := svc.CheckExpiringCertificates(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
|
}
|
|
|
|
// ARI is nil (not supported), so threshold-based logic applies; cert is within 30-day window
|
|
hasRenewalJob := false
|
|
for _, job := range jobRepo.Jobs {
|
|
if job.Type == domain.JobTypeRenewal {
|
|
hasRenewalJob = true
|
|
break
|
|
}
|
|
}
|
|
if !hasRenewalJob {
|
|
t.Errorf("expected renewal job via threshold fallback when ARI returns nil")
|
|
}
|
|
}
|
|
|
|
func TestCheckExpiringCertificates_ARI_Error_FallsThrough(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
policyRepo := newMockRenewalPolicyRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
// ARI returns an error — should fall through to threshold-based renewal
|
|
ariConnector := &mockIssuerConnector{
|
|
getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"),
|
|
}
|
|
issuerRegistry := NewIssuerRegistry(slog.Default())
|
|
issuerRegistry.Set("iss-acme", ariConnector)
|
|
|
|
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
|
|
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-ari-err",
|
|
Name: "ARI Error Cert",
|
|
CommonName: "ari-err.example.com",
|
|
SANs: []string{},
|
|
OwnerID: "owner-1",
|
|
TeamID: "team-1",
|
|
IssuerID: "iss-acme",
|
|
RenewalPolicyID: "rp-standard",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(0, 0, 15),
|
|
Tags: make(map[string]string),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
|
|
{ID: "cv-4", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
|
|
}
|
|
|
|
policy := &domain.RenewalPolicy{
|
|
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
|
|
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
|
|
AlertThresholdsDays: []int{30, 14, 7, 0},
|
|
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
|
}
|
|
policyRepo.AddPolicy(policy)
|
|
|
|
err := svc.CheckExpiringCertificates(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CheckExpiringCertificates failed: %v", err)
|
|
}
|
|
|
|
// ARI failed but renewal should still happen via threshold fallback
|
|
hasRenewalJob := false
|
|
for _, job := range jobRepo.Jobs {
|
|
if job.Type == domain.JobTypeRenewal {
|
|
hasRenewalJob = true
|
|
break
|
|
}
|
|
}
|
|
if !hasRenewalJob {
|
|
t.Errorf("expected renewal job via threshold fallback when ARI errors")
|
|
}
|
|
}
|
|
|
|
// TestExpireShortLivedCertificates_Tier3 tests that ExpireShortLivedCertificates
|
|
// marks short-lived certificates that have passed their expiry time as Expired.
|
|
func TestExpireShortLivedCertificates_Tier3(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Set up repos
|
|
certRepo := newMockCertificateRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
|
|
// Import the profile repo mock from context_test which already exists
|
|
profileRepo := &mockCertificateProfileRepository{
|
|
Profiles: make(map[string]*domain.CertificateProfile),
|
|
}
|
|
|
|
// Create a short-lived profile
|
|
shortLivedProfile := &domain.CertificateProfile{
|
|
ID: "prof-sl-1",
|
|
Name: "ShortLived",
|
|
MaxTTLSeconds: 3599, // Under 1 hour
|
|
AllowShortLived: true,
|
|
Enabled: true,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
profileRepo.Create(ctx, shortLivedProfile)
|
|
|
|
// Create a short-lived cert that has expired
|
|
now := time.Now()
|
|
expiredTime := now.Add(-5 * time.Minute) // Already expired
|
|
expiredCert := &domain.ManagedCertificate{
|
|
ID: "cert-short-1",
|
|
CommonName: "test.example.com",
|
|
Status: domain.CertificateStatusActive,
|
|
CertificateProfileID: "prof-sl-1",
|
|
ExpiresAt: expiredTime,
|
|
CreatedAt: now.Add(-10 * time.Minute),
|
|
UpdatedAt: now.Add(-10 * time.Minute),
|
|
}
|
|
certRepo.AddCert(expiredCert)
|
|
|
|
// Mock the GetExpiringCertificates to return our expired cert
|
|
certRepo.MockGetExpiring = []*domain.ManagedCertificate{expiredCert}
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
|
|
|
svc := NewRenewalService(
|
|
certRepo, nil, nil, profileRepo,
|
|
auditSvc, notifSvc, NewIssuerRegistry(slog.Default()), "agent",
|
|
)
|
|
|
|
// Call ExpireShortLivedCertificates
|
|
err := svc.ExpireShortLivedCertificates(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ExpireShortLivedCertificates failed: %v", err)
|
|
}
|
|
|
|
// Verify the cert status was updated to Expired
|
|
if len(certRepo.Updated) == 0 {
|
|
t.Error("expected certificate to be updated")
|
|
return
|
|
}
|
|
|
|
updatedCert := certRepo.Updated[0]
|
|
if updatedCert.Status != domain.CertificateStatusExpired {
|
|
t.Errorf("expected status Expired, got %s", updatedCert.Status)
|
|
}
|
|
}
|
|
|
|
// TestFailJob_SetsFailedStatus tests that job status is correctly updated to Failed.
|
|
func TestFailJob_SetsFailedStatus(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Set up repos
|
|
jobRepo := newMockJobRepository()
|
|
|
|
// Create a job
|
|
job := &domain.Job{
|
|
ID: "job-fail-1",
|
|
Type: domain.JobTypeRenewal,
|
|
Status: domain.JobStatusRunning,
|
|
CreatedAt: time.Now(),
|
|
ScheduledAt: time.Now(),
|
|
}
|
|
jobRepo.Jobs[job.ID] = job
|
|
|
|
// Simulate what failJob does - update the job with Failed status and error message
|
|
errMsg := "test error message"
|
|
job.Status = domain.JobStatusFailed
|
|
job.LastError = &errMsg
|
|
|
|
// Call the Update method which is what failJob would do
|
|
err := jobRepo.Update(ctx, job)
|
|
if err != nil {
|
|
t.Fatalf("failed to update job: %v", err)
|
|
}
|
|
|
|
// Verify the job was marked as failed
|
|
if len(jobRepo.Updated) == 0 {
|
|
t.Error("expected job to be updated")
|
|
return
|
|
}
|
|
|
|
updatedJob := jobRepo.Updated[0]
|
|
if updatedJob.Status != domain.JobStatusFailed {
|
|
t.Errorf("expected status Failed, got %s", updatedJob.Status)
|
|
}
|
|
if updatedJob.LastError == nil || *updatedJob.LastError == "" {
|
|
t.Error("expected error message to be set")
|
|
}
|
|
}
|
|
|
|
|
|
// --- CreateDeploymentJobs Tests ---
|
|
|
|
func TestCreateDeploymentJobs_PartialFailure(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
jobRepo := newMockJobRepository()
|
|
targetRepo := newMockTargetRepository()
|
|
agentRepo := newMockAgentRepository()
|
|
certRepo := newMockCertificateRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
|
|
auditSvc := NewAuditService(auditRepo)
|
|
|
|
depSvc := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditSvc, nil)
|
|
|
|
// Create certificate
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-partial",
|
|
CommonName: "test.example.com",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.AddCert(cert)
|
|
|
|
// Create target with agent assignment
|
|
target := &domain.DeploymentTarget{
|
|
ID: "tgt-1",
|
|
Name: "target-1",
|
|
Type: "nginx",
|
|
AgentID: "agent-1",
|
|
Config: json.RawMessage("{}"),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
targetRepo.Targets[target.ID] = target
|
|
|
|
// Mock ListByCertificate to return the target
|
|
// (the mock returns all targets, so we just need one in the map)
|
|
|
|
// Execute CreateDeploymentJobs
|
|
jobIDs, err := depSvc.CreateDeploymentJobs(ctx, cert.ID)
|
|
|
|
// Should succeed
|
|
if err != nil {
|
|
t.Fatalf("CreateDeploymentJobs failed: %v", err)
|
|
}
|
|
|
|
// Verify job was created
|
|
if len(jobIDs) == 0 {
|
|
t.Error("expected at least one deployment job to be created")
|
|
}
|
|
|
|
// Verify the job has correct properties
|
|
if len(jobRepo.Jobs) == 0 {
|
|
t.Fatal("expected job to be created")
|
|
}
|
|
|
|
createdJob := jobRepo.Jobs[jobIDs[0]]
|
|
if createdJob.Type != domain.JobTypeDeployment {
|
|
t.Errorf("expected JobTypeDeployment, got %s", createdJob.Type)
|
|
}
|
|
if createdJob.CertificateID != cert.ID {
|
|
t.Errorf("expected certificate ID %s, got %s", cert.ID, createdJob.CertificateID)
|
|
}
|
|
if createdJob.AgentID == nil || *createdJob.AgentID != "agent-1" {
|
|
t.Error("expected job to be routed to agent-1")
|
|
}
|
|
}
|
|
|
|
|
|
// stringPtr is defined in notification_test.go
|