Files
certctl/internal/service/renewal_test.go
T
shankar0123 a579a84c7f feat: M11a — certificate profiles, crypto policy enforcement, short-lived cert expiry
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>
2026-03-20 20:39:49 -04:00

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