Files
certctl/internal/service/csr_renewal_test.go
T
shankar0123 995b72df05 feat(M34): dynamic issuer configuration with encrypted config storage
Replace static env-var-based issuer wiring with GUI-driven dynamic
configuration stored encrypted in PostgreSQL. Operators can now
configure, test, enable/disable, and manage issuers from the dashboard
without restarting the server.

Key changes:
- AES-256-GCM encryption for sensitive issuer config at rest (PBKDF2
  key derivation with 100k iterations)
- Dynamic IssuerRegistry with sync.RWMutex replacing static map
- Connector factory pattern (issuerfactory.NewFromConfig) replacing
  140 lines of static wiring in main.go
- Migration 000009: encrypted_config, last_tested_at, test_status,
  source columns on issuers table
- Env var seeding on first boot with ON CONFLICT DO NOTHING
- Registry Rebuild() for atomic map swap after CRUD operations
- Issuer type validation against domain constants on Create
- Audit trail for test connection results
- Conditional seeding for step-ca/OpenSSL (only when env vars set)
- GUI: source badge, connection test status on issuer detail page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 00:20:13 -04:00

463 lines
14 KiB
Go

package service
import (
"context"
"errors"
"log/slog"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// NOTE: generateTestCSR(t, keyType, keySize) is defined in crypto_validation_test.go
// Use it as: generateTestCSR(t, "ECDSA", 256)
// newTestRenewalServiceForCSR creates a RenewalService with mocks suitable for CSR renewal testing.
func newTestRenewalServiceForCSR(issuerErr error) *RenewalService {
certRepo := newMockCertificateRepository()
jobRepo := newMockJobRepository()
policyRepo := newMockRenewalPolicyRepository()
profileRepo := newMockProfileRepository()
auditRepo := newMockAuditRepository()
notifRepo := newMockNotificationRepository()
notifier := newMockNotifier()
auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{
"Email": notifier,
})
issuerConnector := &mockIssuerConnector{Err: issuerErr}
issuerRegistry := NewIssuerRegistry(slog.Default())
issuerRegistry.Set("iss-local", issuerConnector)
svc := NewRenewalService(certRepo, jobRepo, policyRepo, profileRepo, auditSvc, notifSvc, issuerRegistry, "agent")
return svc
}
// TestCompleteAgentCSRRenewal_Success tests the happy path: valid CSR, issuer signs, cert stored, deployment jobs created.
func TestCompleteAgentCSRRenewal_Success(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-001",
Name: "Test Certificate",
CommonName: "example.com",
SANs: []string{"www.example.com"},
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
TargetIDs: []string{"t-nginx-1"},
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-csr-001",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err != nil {
t.Fatalf("CompleteAgentCSRRenewal failed: %v", err)
}
// Verify job was completed
updatedJob, err := jobRepo.Get(ctx, job.ID)
if err != nil {
t.Fatalf("failed to get job after renewal: %v", err)
}
if updatedJob.Status != domain.JobStatusCompleted {
t.Errorf("expected job status Completed, got %s", updatedJob.Status)
}
// Verify certificate version was created
versions, err := certRepo.ListVersions(ctx, cert.ID)
if err != nil {
t.Fatalf("failed to list versions: %v", err)
}
if len(versions) != 1 {
t.Errorf("expected 1 version, got %d", len(versions))
}
// Verify version fields
version := versions[0]
if version.SerialNumber != "test-serial-123" {
t.Errorf("expected serial 'test-serial-123', got %s", version.SerialNumber)
}
if version.CSRPEM != csrPEM {
t.Errorf("expected CSR PEM to be stored as-is (agent mode), got mismatch")
}
if version.PEMChain == "" {
t.Errorf("expected PEMChain to be populated")
}
// Verify certificate was updated
updatedCert, err := certRepo.Get(ctx, cert.ID)
if err != nil {
t.Fatalf("failed to get cert after renewal: %v", err)
}
if updatedCert.Status != domain.CertificateStatusActive {
t.Errorf("expected cert status Active, got %s", updatedCert.Status)
}
if updatedCert.LastRenewalAt == nil {
t.Errorf("expected LastRenewalAt to be set")
}
// Verify deployment jobs were created
deploymentJobs := 0
for _, j := range jobRepo.Jobs {
if j.Type == domain.JobTypeDeployment && j.CertificateID == cert.ID {
deploymentJobs++
}
}
if deploymentJobs != 1 {
t.Errorf("expected 1 deployment job, got %d", deploymentJobs)
}
}
// TestCompleteAgentCSRRenewal_JobNotFound tests that the method handles a missing job gracefully.
func TestCompleteAgentCSRRenewal_JobNotFound(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-not-found",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
// Job not added to repo — simulates "not found" on status update
job := &domain.Job{
ID: "job-nonexistent",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
CreatedAt: time.Now(),
}
csrPEM := generateTestCSR(t, "ECDSA", 256)
// Call will pass CSR validation but fail when updating job status to Running
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err == nil {
t.Errorf("expected error for missing job, got nil")
}
}
// TestCompleteAgentCSRRenewal_JobNotAwaitingCSR tests that the method processes regardless of job state
// (the method doesn't check job.Status — it trusts the caller).
func TestCompleteAgentCSRRenewal_JobNotAwaitingCSR(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-wrong-state",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-running",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusRunning, // Wrong state — method doesn't check
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
// The method doesn't validate job state, so it should still process
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
// Depending on mock behavior, this may succeed or fail — the point is no panic
_ = err
}
// TestCompleteAgentCSRRenewal_InvalidCSR tests that invalid CSR PEM causes failure.
func TestCompleteAgentCSRRenewal_InvalidCSR(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-invalid-csr",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-invalid-csr",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
invalidCSR := "not a pem certificate request at all"
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, invalidCSR)
if err == nil {
t.Errorf("expected error for invalid CSR, got nil")
}
// Verify job was marked as failed
updatedJob, _ := jobRepo.Get(ctx, job.ID)
if updatedJob.Status != domain.JobStatusFailed {
t.Errorf("expected job status Failed after CSR validation error, got %s", updatedJob.Status)
}
if updatedJob.LastError == nil || *updatedJob.LastError == "" {
t.Errorf("expected error message stored in job, got none")
}
}
// TestCompleteAgentCSRRenewal_IssuerError tests that issuer connector failure is handled.
func TestCompleteAgentCSRRenewal_IssuerError(t *testing.T) {
ctx := context.Background()
issuerErr := errors.New("issuer signing failed")
svc := newTestRenewalServiceForCSR(issuerErr)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-issuer-error",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-issuer-error",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err == nil {
t.Errorf("expected error from issuer failure, got nil")
}
// Verify job was marked as failed
updatedJob, _ := jobRepo.Get(ctx, job.ID)
if updatedJob.Status != domain.JobStatusFailed {
t.Errorf("expected job status Failed, got %s", updatedJob.Status)
}
// Verify no version was created
versions, _ := certRepo.ListVersions(ctx, cert.ID)
if len(versions) > 0 {
t.Errorf("expected no version created after issuer failure, got %d", len(versions))
}
}
// TestCompleteAgentCSRRenewal_StoreVersionError tests that version storage failure is handled.
func TestCompleteAgentCSRRenewal_StoreVersionError(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
certRepo.CreateVersionErr = errors.New("version storage failed")
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-store-error",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-store-error",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err == nil {
t.Errorf("expected error from version storage failure, got nil")
}
// Verify job was marked as failed
updatedJob, _ := jobRepo.Get(ctx, job.ID)
if updatedJob.Status != domain.JobStatusFailed {
t.Errorf("expected job status Failed, got %s", updatedJob.Status)
}
// Verify no version was actually stored
versions, _ := certRepo.ListVersions(ctx, cert.ID)
if len(versions) > 0 {
t.Errorf("expected no version stored after storage error, got %d", len(versions))
}
}
// TestCompleteAgentCSRRenewal_CertNotFound tests that missing issuer connector is handled.
func TestCompleteAgentCSRRenewal_CertNotFound(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
jobRepo := svc.jobRepo.(*mockJobRepo)
job := &domain.Job{
ID: "job-cert-not-found",
CertificateID: "mc-nonexistent",
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
cert := &domain.ManagedCertificate{
ID: "mc-cert-not-found",
CommonName: "example.com",
IssuerID: "iss-nonexistent", // Not in registry
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err == nil {
t.Errorf("expected error for missing issuer, got nil")
}
if !contains(err.Error(), "issuer connector not found") {
t.Errorf("expected 'issuer connector not found' error, got: %v", err)
}
}
// TestCompleteAgentCSRRenewal_EKUFromProfile tests that EKUs are resolved from profile and passed to issuer.
func TestCompleteAgentCSRRenewal_EKUFromProfile(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
profileRepo := svc.profileRepo.(*mockProfileRepo)
profile := &domain.CertificateProfile{
ID: "prof-smime",
Name: "S/MIME",
MaxTTLSeconds: 31536000, // 365 days
AllowedEKUs: []string{"emailProtection", "clientAuth"},
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
profileRepo.AddProfile(profile)
cert := &domain.ManagedCertificate{
ID: "mc-test-eku",
Name: "S/MIME Certificate",
CommonName: "user@example.com",
SANs: []string{"user@example.com"},
IssuerID: "iss-local",
CertificateProfileID: "prof-smime",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-eku",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err != nil {
t.Fatalf("CompleteAgentCSRRenewal failed: %v", err)
}
// Verify job was completed — profile lookup + EKU resolution worked
updatedJob, _ := jobRepo.Get(ctx, job.ID)
if updatedJob.Status != domain.JobStatusCompleted {
t.Errorf("expected job status Completed, got %s", updatedJob.Status)
}
}