mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 18:18:52 +00:00
a579a84c7f
Add certificate profiles as named enrollment templates that control allowed key algorithms, max TTL, permitted EKUs, required SAN patterns, and optional SPIFFE URI SANs. CSR submissions are validated against profile rules at signing time (key type + minimum size). Short-lived certs (TTL < 1 hour) auto-expire via a new scheduler loop — expiry acts as revocation, no CRL/OCSP needed. New files: - Migration 000003: certificate_profiles table, FK columns on managed_certificates/renewal_policies, key metadata on certificate_versions - domain/profile.go: CertificateProfile + KeyAlgorithmRule structs - repository/postgres/profile.go: full CRUD with JSONB marshaling - service/profile.go: ProfileService with validation + audit logging - service/crypto_validation.go: CSR-against-profile validation (RSA/ECDSA/Ed25519) - handler/profiles.go: 5 HTTP endpoints under /api/v1/profiles - web/src/pages/ProfilesPage.tsx: profiles management page Modified: - renewal.go: CSR validation in CompleteAgentCSRRenewal, ExpireShortLivedCertificates - scheduler.go: 30s short-lived expiry check loop - certificate.go (repo): nullable profile FK, key metadata on versions - main.go: profile repo/service/handler wiring, 8-param NewRenewalService - router.go: 12-param RegisterHandlers with profile routes - seed_demo.sql: 4 demo profiles (standard, mtls, short-lived, high-security) - Frontend: types, API client, routing, sidebar nav Tests: 40 new tests across handler (15), service (13), crypto validation (12) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
867 lines
25 KiB
Go
867 lines
25 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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 := map[string]IssuerConnector{
|
|
"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 := map[string]IssuerConnector{
|
|
"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 := map[string]IssuerConnector{
|
|
"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_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 := map[string]IssuerConnector{
|
|
"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 := map[string]IssuerConnector{
|
|
"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 := map[string]IssuerConnector{
|
|
"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 := map[string]IssuerConnector{}
|
|
|
|
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 := map[string]IssuerConnector{
|
|
"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 := map[string]IssuerConnector{
|
|
"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 := map[string]IssuerConnector{
|
|
"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 := map[string]IssuerConnector{
|
|
"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 := map[string]IssuerConnector{
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// stringPtr is defined in notification_test.go
|