Files
certctl/internal/service/csr_renewal_test.go
T
shankar0123 03472072b8 test + docs: close 12 test gaps (~250 new tests) and expand testing guide to 34 parts
Implements all P0-P2 test gaps from docs/test-gap-prompt.md:
- Deployment service tests (20), target service tests (18), scheduler tests (8)
- Agent binary tests (48), CSR renewal tests (8), short-lived cert tests (7)
- Domain model tests (25), context cancellation tests (9), concurrency tests (7)
- Handler negative-path tests (23 across 5 files)
- Frontend error handling tests (86) and API client tests (7)

Expands testing-guide.md from 28 to 34 parts covering certificate export,
S/MIME/EKU, OCSP/DER CRL, body size limits, Apache/HAProxy connectors,
and sub-CA mode. Fixes stale profile count (4->5) and updates sign-off table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 17:57:25 -04:00

463 lines
14 KiB
Go

package service
import (
"context"
"errors"
"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 := map[string]IssuerConnector{
"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)
}
}