mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:11:31 +00:00
995b72df05
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>
463 lines
14 KiB
Go
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)
|
|
}
|
|
}
|