Files
certctl/internal/service/ca_operations_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

180 lines
4.8 KiB
Go

// Tests for CAOperationsSvc, the focused sub-service that handles CRL generation
// and OCSP response signing extracted from CertificateService (TICKET-007).
package service
import (
"log/slog"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// helper to create a CAOperationsSvc for testing
func newCAOperationsSvcTest() (*CAOperationsSvc, *mockRevocationRepo, *mockCertRepo) {
revocationRepo := newMockRevocationRepository()
certRepo := newMockCertificateRepository()
profileRepo := newMockProfileRepository()
caSvc := NewCAOperationsSvc(revocationRepo, certRepo, profileRepo)
registry := NewIssuerRegistry(slog.Default())
registry.Set("iss-local", &mockIssuerConnector{})
caSvc.SetIssuerRegistry(registry)
return caSvc, revocationRepo, certRepo
}
func TestCAOperationsSvc_GenerateDERCRL_Success(t *testing.T) {
caSvc, revocationRepo, _ := newCAOperationsSvcTest()
// Add some revoked certificates to the repo
now := time.Now()
revocationRepo.Revocations = []*domain.CertificateRevocation{
{
SerialNumber: "SERIAL-001",
CertificateID: "cert-1",
IssuerID: "iss-local",
Reason: "keyCompromise",
RevokedAt: now.Add(-24 * time.Hour),
RevokedBy: "admin",
},
{
SerialNumber: "SERIAL-002",
CertificateID: "cert-2",
IssuerID: "iss-local",
Reason: "superseded",
RevokedAt: now.Add(-12 * time.Hour),
RevokedBy: "admin",
},
}
crl, err := caSvc.GenerateDERCRL("iss-local")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if crl == nil {
t.Fatal("expected non-nil CRL")
}
if len(crl) == 0 {
t.Fatal("expected non-empty CRL")
}
t.Logf("DER CRL generated successfully: %d bytes", len(crl))
}
func TestCAOperationsSvc_GenerateDERCRL_EmptyCRL(t *testing.T) {
caSvc, revocationRepo, _ := newCAOperationsSvcTest()
// No revoked certs for this issuer
revocationRepo.Revocations = []*domain.CertificateRevocation{}
crl, err := caSvc.GenerateDERCRL("iss-local")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if crl == nil {
t.Fatal("expected non-nil CRL even when empty")
}
if len(crl) == 0 {
t.Fatal("expected non-empty CRL bytes (at least the CRL structure)")
}
t.Logf("Empty DER CRL generated successfully: %d bytes", len(crl))
}
func TestCAOperationsSvc_GetOCSPResponse_Good(t *testing.T) {
caSvc, _, certRepo := newCAOperationsSvcTest()
// Add a non-revoked certificate
cert := &domain.ManagedCertificate{
ID: "cert-ocsp-good",
CommonName: "good.example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(1, 0, 0),
}
certRepo.AddCert(cert)
version := &domain.CertificateVersion{
ID: "ver-ocsp-good",
CertificateID: "cert-ocsp-good",
SerialNumber: "OCSP-GOOD-001",
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
CreatedAt: time.Now(),
}
certRepo.Versions["cert-ocsp-good"] = []*domain.CertificateVersion{version}
// Request OCSP response for good cert
resp, err := caSvc.GetOCSPResponse("iss-local", "OCSP-GOOD-001")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if resp == nil || len(resp) == 0 {
t.Fatal("expected non-empty OCSP response for good cert")
}
t.Logf("OCSP response for good cert generated: %d bytes", len(resp))
}
func TestCAOperationsSvc_GetOCSPResponse_Revoked(t *testing.T) {
caSvc, revocationRepo, certRepo := newCAOperationsSvcTest()
now := time.Now()
// Add a revoked certificate
cert := &domain.ManagedCertificate{
ID: "cert-ocsp-revoked",
CommonName: "revoked.example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRevoked,
RevokedAt: &now,
RevocationReason: "keyCompromise",
ExpiresAt: time.Now().AddDate(1, 0, 0),
}
certRepo.AddCert(cert)
version := &domain.CertificateVersion{
ID: "ver-ocsp-revoked",
CertificateID: "cert-ocsp-revoked",
SerialNumber: "OCSP-REVOKED-001",
NotBefore: time.Now().Add(-24 * time.Hour),
NotAfter: time.Now().AddDate(1, 0, 0),
CreatedAt: time.Now(),
}
certRepo.Versions["cert-ocsp-revoked"] = []*domain.CertificateVersion{version}
// Add revocation record
revocationRepo.Revocations = []*domain.CertificateRevocation{
{
SerialNumber: "OCSP-REVOKED-001",
CertificateID: "cert-ocsp-revoked",
IssuerID: "iss-local",
Reason: "keyCompromise",
RevokedAt: now.Add(-24 * time.Hour),
RevokedBy: "admin",
},
}
// Request OCSP response for revoked cert
resp, err := caSvc.GetOCSPResponse("iss-local", "OCSP-REVOKED-001")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if resp == nil || len(resp) == 0 {
t.Fatal("expected non-empty OCSP response for revoked cert")
}
t.Logf("OCSP response for revoked cert generated: %d bytes", len(resp))
}