mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:21:30 +00:00
7cb453a336
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 0f205a8) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.
Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.
The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
1386 lines
42 KiB
Go
1386 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
|