mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 14:18:53 +00:00
test: comprehensive test gap closure across 24 packages
Close coverage gaps identified by dual-audit (qualitative + quantitative). New test files for config (0%→98%), router (0%→100%), handler validation, health, audit, response helpers, webhook notifier (0%→88%), email notifier, middleware (recovery, rate limiter), domain profile, service nil-safety, config helpers, issuer bootstrap, and server bootstrap wiring. Expanded existing tests for ACME (34%→42%), step-ca (42%→52%), F5, SSH, agent (43%→63%), scheduler (88%→99%), renewal service, and issuerfactory. All tests pass: go test -short, go vet, go test -race clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// TestCertificateService_RevokeCertificate_RevocationSvcNil tests RevokeCertificateWithActor
|
||||
// when RevocationSvc is not configured (nil).
|
||||
func TestCertificateService_RevokeCertificate_RevocationSvcNil(t *testing.T) {
|
||||
// Setup: Create CertificateService WITHOUT calling SetRevocationSvc
|
||||
certRepo := newMockCertificateRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
policyRepo := newMockPolicyRepository()
|
||||
|
||||
auditService := NewAuditService(auditRepo)
|
||||
policyService := NewPolicyService(policyRepo, auditService)
|
||||
|
||||
// Create service WITHOUT RevocationSvc
|
||||
certService := NewCertificateService(certRepo, policyService, auditService)
|
||||
// Note: NOT calling certService.SetRevocationSvc(...)
|
||||
|
||||
// Add a test certificate
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "cert-1",
|
||||
CommonName: "example.com",
|
||||
IssuerID: "iss-local",
|
||||
Status: domain.CertificateStatusActive,
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
|
||||
// Call RevokeCertificateWithActor with nil RevocationSvc
|
||||
err := certService.RevokeCertificateWithActor(context.Background(), "cert-1", "keyCompromise", "admin")
|
||||
|
||||
// Assert: Should return error, NOT panic
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
// Verify error message indicates service not configured
|
||||
errMsg := err.Error()
|
||||
if errMsg != "revocation service not configured" {
|
||||
t.Errorf("expected error message 'revocation service not configured', got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertificateService_GenerateDERCRL_CAOpsSvcNil tests GenerateDERCRL
|
||||
// when CAOperationsSvc is not configured (nil).
|
||||
func TestCertificateService_GenerateDERCRL_CAOpsSvcNil(t *testing.T) {
|
||||
// Setup: Create CertificateService WITHOUT calling SetCAOperationsSvc
|
||||
certRepo := newMockCertificateRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
policyRepo := newMockPolicyRepository()
|
||||
|
||||
auditService := NewAuditService(auditRepo)
|
||||
policyService := NewPolicyService(policyRepo, auditService)
|
||||
|
||||
// Create service WITHOUT CAOperationsSvc
|
||||
certService := NewCertificateService(certRepo, policyService, auditService)
|
||||
// Note: NOT calling certService.SetCAOperationsSvc(...)
|
||||
|
||||
// Call GenerateDERCRL with nil CAOperationsSvc
|
||||
_, err := certService.GenerateDERCRL("iss-local")
|
||||
|
||||
// Assert: Should return error, NOT panic
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
// Verify error message indicates service not configured
|
||||
errMsg := err.Error()
|
||||
if errMsg != "CA operations service not configured" {
|
||||
t.Errorf("expected error message 'CA operations service not configured', got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertificateService_GetOCSPResponse_CAOpsSvcNil tests GetOCSPResponse
|
||||
// when CAOperationsSvc is not configured (nil).
|
||||
func TestCertificateService_GetOCSPResponse_CAOpsSvcNil(t *testing.T) {
|
||||
// Setup: Create CertificateService WITHOUT calling SetCAOperationsSvc
|
||||
certRepo := newMockCertificateRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
policyRepo := newMockPolicyRepository()
|
||||
|
||||
auditService := NewAuditService(auditRepo)
|
||||
policyService := NewPolicyService(policyRepo, auditService)
|
||||
|
||||
// Create service WITHOUT CAOperationsSvc
|
||||
certService := NewCertificateService(certRepo, policyService, auditService)
|
||||
// Note: NOT calling certService.SetCAOperationsSvc(...)
|
||||
|
||||
// Call GetOCSPResponse with nil CAOperationsSvc
|
||||
_, err := certService.GetOCSPResponse("iss-local", "serial123")
|
||||
|
||||
// Assert: Should return error, NOT panic
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
// Verify error message indicates service not configured
|
||||
errMsg := err.Error()
|
||||
if errMsg != "CA operations service not configured" {
|
||||
t.Errorf("expected error message 'CA operations service not configured', got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertificateService_GetRevokedCertificates_RevocationSvcNil tests GetRevokedCertificates
|
||||
// when RevocationSvc is not configured (nil).
|
||||
func TestCertificateService_GetRevokedCertificates_RevocationSvcNil(t *testing.T) {
|
||||
// Setup: Create CertificateService WITHOUT calling SetRevocationSvc
|
||||
certRepo := newMockCertificateRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
policyRepo := newMockPolicyRepository()
|
||||
|
||||
auditService := NewAuditService(auditRepo)
|
||||
policyService := NewPolicyService(policyRepo, auditService)
|
||||
|
||||
// Create service WITHOUT RevocationSvc
|
||||
certService := NewCertificateService(certRepo, policyService, auditService)
|
||||
// Note: NOT calling certService.SetRevocationSvc(...)
|
||||
|
||||
// Call GetRevokedCertificates with nil RevocationSvc
|
||||
_, err := certService.GetRevokedCertificates()
|
||||
|
||||
// Assert: Should return error, NOT panic
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
// Verify error message indicates service not configured
|
||||
errMsg := err.Error()
|
||||
if errMsg != "revocation service not configured" {
|
||||
t.Errorf("expected error message 'revocation service not configured', got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertificateService_GetCertificateDeployments_Success tests GetCertificateDeployments
|
||||
// when TargetRepo is properly configured.
|
||||
func TestCertificateService_GetCertificateDeployments_Success(t *testing.T) {
|
||||
// Setup: Create CertificateService with properly configured TargetRepo
|
||||
certRepo := newMockCertificateRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
policyRepo := newMockPolicyRepository()
|
||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||
|
||||
auditService := NewAuditService(auditRepo)
|
||||
policyService := NewPolicyService(policyRepo, auditService)
|
||||
|
||||
certService := NewCertificateService(certRepo, policyService, auditService)
|
||||
certService.SetTargetRepo(targetRepo)
|
||||
|
||||
// Add a test certificate
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "cert-1",
|
||||
CommonName: "example.com",
|
||||
IssuerID: "iss-local",
|
||||
Status: domain.CertificateStatusActive,
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
|
||||
// Add deployment targets
|
||||
target1 := &domain.DeploymentTarget{
|
||||
ID: "t-1",
|
||||
Name: "nginx-prod",
|
||||
Type: domain.TargetTypeNGINX,
|
||||
}
|
||||
target2 := &domain.DeploymentTarget{
|
||||
ID: "t-2",
|
||||
Name: "apache-prod",
|
||||
Type: domain.TargetTypeApache,
|
||||
}
|
||||
targetRepo.AddTarget(target1)
|
||||
targetRepo.AddTarget(target2)
|
||||
|
||||
// Call GetCertificateDeployments
|
||||
deployments, err := certService.GetCertificateDeployments("cert-1")
|
||||
|
||||
// Assert: Should return deployment list successfully
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
// Verify deployments are returned (note: mock ListByCertificate returns all targets)
|
||||
if len(deployments) == 0 {
|
||||
t.Error("expected deployment list to be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertificateService_GetCertificateDeployments_RepositoryError tests GetCertificateDeployments
|
||||
// when TargetRepo returns an error.
|
||||
func TestCertificateService_GetCertificateDeployments_RepositoryError(t *testing.T) {
|
||||
// Setup: Create CertificateService with TargetRepo configured to return error
|
||||
certRepo := newMockCertificateRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
policyRepo := newMockPolicyRepository()
|
||||
targetRepo := &mockTargetRepo{
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
ListByCertErr: errNotFound,
|
||||
}
|
||||
|
||||
auditService := NewAuditService(auditRepo)
|
||||
policyService := NewPolicyService(policyRepo, auditService)
|
||||
|
||||
certService := NewCertificateService(certRepo, policyService, auditService)
|
||||
certService.SetTargetRepo(targetRepo)
|
||||
|
||||
// Add a test certificate
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "cert-1",
|
||||
CommonName: "example.com",
|
||||
IssuerID: "iss-local",
|
||||
Status: domain.CertificateStatusActive,
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
|
||||
// Call GetCertificateDeployments with repo error
|
||||
_, err := certService.GetCertificateDeployments("cert-1")
|
||||
|
||||
// Assert: Should return error, NOT panic
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
// Verify error indicates repo failure
|
||||
if err.Error() != "failed to list deployment targets: not found" {
|
||||
t.Errorf("expected repo error message, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertificateService_GetCertificateDeployments_CertNotFound tests GetCertificateDeployments
|
||||
// when the certificate doesn't exist.
|
||||
func TestCertificateService_GetCertificateDeployments_CertNotFound(t *testing.T) {
|
||||
// Setup: Create CertificateService with empty cert repo
|
||||
certRepo := newMockCertificateRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
policyRepo := newMockPolicyRepository()
|
||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||
|
||||
auditService := NewAuditService(auditRepo)
|
||||
policyService := NewPolicyService(policyRepo, auditService)
|
||||
|
||||
certService := NewCertificateService(certRepo, policyService, auditService)
|
||||
certService.SetTargetRepo(targetRepo)
|
||||
|
||||
// Call GetCertificateDeployments with nonexistent certificate
|
||||
_, err := certService.GetCertificateDeployments("nonexistent-cert")
|
||||
|
||||
// Assert: Should return error
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent certificate, got nil")
|
||||
}
|
||||
|
||||
if err.Error() != "certificate not found: not found" {
|
||||
t.Errorf("expected certificate not found error, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertificateService_GetCertificateDeployments_NilTargetRepo tests GetCertificateDeployments
|
||||
// when TargetRepo is nil (empty graceful handling).
|
||||
func TestCertificateService_GetCertificateDeployments_NilTargetRepo(t *testing.T) {
|
||||
// Setup: Create CertificateService WITHOUT TargetRepo
|
||||
certRepo := newMockCertificateRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
policyRepo := newMockPolicyRepository()
|
||||
|
||||
auditService := NewAuditService(auditRepo)
|
||||
policyService := NewPolicyService(policyRepo, auditService)
|
||||
|
||||
certService := NewCertificateService(certRepo, policyService, auditService)
|
||||
// Note: NOT calling certService.SetTargetRepo(...)
|
||||
|
||||
// Add a test certificate
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "cert-1",
|
||||
CommonName: "example.com",
|
||||
IssuerID: "iss-local",
|
||||
Status: domain.CertificateStatusActive,
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
|
||||
// Call GetCertificateDeployments with nil TargetRepo
|
||||
deployments, err := certService.GetCertificateDeployments("cert-1")
|
||||
|
||||
// Assert: Should return empty list gracefully (not panic)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
|
||||
if len(deployments) != 0 {
|
||||
t.Errorf("expected empty deployment list, got %d deployments", len(deployments))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertificateService_Multiple_NilSafetyChecks tests multiple nil-safety operations in sequence.
|
||||
func TestCertificateService_Multiple_NilSafetyChecks(t *testing.T) {
|
||||
// Setup: Create CertificateService with partial configuration
|
||||
certRepo := newMockCertificateRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
policyRepo := newMockPolicyRepository()
|
||||
|
||||
auditService := NewAuditService(auditRepo)
|
||||
policyService := NewPolicyService(policyRepo, auditService)
|
||||
|
||||
certService := NewCertificateService(certRepo, policyService, auditService)
|
||||
// Only set RevocationSvc, leave CAOperationsSvc nil
|
||||
revSvc := NewRevocationSvc(certRepo, newMockRevocationRepository(), auditService)
|
||||
certService.SetRevocationSvc(revSvc)
|
||||
|
||||
// Add a test certificate
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "cert-1",
|
||||
CommonName: "example.com",
|
||||
IssuerID: "iss-local",
|
||||
Status: domain.CertificateStatusActive,
|
||||
ExpiresAt: time.Now().AddDate(0, 6, 0),
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
|
||||
// Add a certificate version
|
||||
version := &domain.CertificateVersion{
|
||||
ID: "ver-1",
|
||||
CertificateID: "cert-1",
|
||||
SerialNumber: "ABC123",
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
certRepo.Versions["cert-1"] = []*domain.CertificateVersion{version}
|
||||
|
||||
// Set up issuer registry for revocation
|
||||
registry := NewIssuerRegistry(slog.Default())
|
||||
registry.Set("iss-local", &mockIssuerConnector{})
|
||||
revSvc.SetIssuerRegistry(registry)
|
||||
|
||||
// Test 1: RevokeCertificateWithActor should succeed (RevocationSvc is set)
|
||||
errRevoke := certService.RevokeCertificateWithActor(context.Background(), "cert-1", "keyCompromise", "admin")
|
||||
if errRevoke != nil {
|
||||
t.Fatalf("RevokeCertificateWithActor failed unexpectedly: %v", errRevoke)
|
||||
}
|
||||
|
||||
// Test 2: GenerateDERCRL should fail gracefully (CAOperationsSvc is nil)
|
||||
_, errCRL := certService.GenerateDERCRL("iss-local")
|
||||
if errCRL == nil {
|
||||
t.Fatal("GenerateDERCRL expected error, got nil")
|
||||
}
|
||||
|
||||
// Test 3: GetOCSPResponse should fail gracefully (CAOperationsSvc is nil)
|
||||
_, errOCSP := certService.GetOCSPResponse("iss-local", "ABC123")
|
||||
if errOCSP == nil {
|
||||
t.Fatal("GetOCSPResponse expected error, got nil")
|
||||
}
|
||||
|
||||
// Assert that errors are for correct reasons
|
||||
if errCRL.Error() != "CA operations service not configured" {
|
||||
t.Errorf("CRL error should be about CA ops service, got: %s", errCRL.Error())
|
||||
}
|
||||
if errOCSP.Error() != "CA operations service not configured" {
|
||||
t.Errorf("OCSP error should be about CA ops service, got: %s", errOCSP.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsSensitiveConfigKey_KnownSensitiveKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
expected bool
|
||||
}{
|
||||
{"api_key", "api_key", true},
|
||||
{"password", "password", true},
|
||||
{"secret", "secret", true},
|
||||
{"token", "token", true},
|
||||
{"hmac", "hmac", true},
|
||||
{"private_key", "private_key", true},
|
||||
{"credentials", "credentials", true},
|
||||
{"winrm_password", "winrm_password", true},
|
||||
{"keystore_password", "keystore_password", true},
|
||||
// Variations with different casing
|
||||
{"API_KEY", "API_KEY", true},
|
||||
{"Password", "Password", true},
|
||||
{"SECRET", "SECRET", true},
|
||||
{"PrivateKey", "PrivateKey", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isSensitiveConfigKey(tt.key)
|
||||
if got != tt.expected {
|
||||
t.Errorf("isSensitiveConfigKey(%q) = %v, want %v", tt.key, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSensitiveConfigKey_NonSensitiveKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{"url", "url"},
|
||||
{"host", "host"},
|
||||
{"port", "port"},
|
||||
{"region", "region"},
|
||||
{"ca_pool", "ca_pool"},
|
||||
{"namespace", "namespace"},
|
||||
{"cert_path", "cert_path"},
|
||||
{"base_url", "base_url"},
|
||||
{"org_id", "org_id"},
|
||||
{"product_type", "product_type"},
|
||||
{"email", "email"},
|
||||
{"enabled", "enabled"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isSensitiveConfigKey(tt.key)
|
||||
if got != false {
|
||||
t.Errorf("isSensitiveConfigKey(%q) = %v, want false", tt.key, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSensitiveConfigKey_CaseInsensitivity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{"api_key uppercase", "API_KEY"},
|
||||
{"api_key mixed", "Api_Key"},
|
||||
{"password uppercase", "PASSWORD"},
|
||||
{"password mixed", "PassWord"},
|
||||
{"secret uppercase", "SECRET"},
|
||||
{"token mixed", "ToKeN"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isSensitiveConfigKey(tt.key)
|
||||
if got != true {
|
||||
t.Errorf("isSensitiveConfigKey(%q) = %v, want true (case-insensitive)", tt.key, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactConfigJSON_HidesSensitiveFields(t *testing.T) {
|
||||
input := json.RawMessage(`{
|
||||
"api_key": "secret-key-123",
|
||||
"password": "my-password",
|
||||
"token": "bearer-token",
|
||||
"host": "example.com"
|
||||
}`)
|
||||
|
||||
result := redactConfigJSON(input)
|
||||
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(result, &m); err != nil {
|
||||
t.Fatalf("failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
// Check sensitive fields are redacted
|
||||
if m["api_key"] != "********" {
|
||||
t.Errorf("api_key = %v, want ********", m["api_key"])
|
||||
}
|
||||
if m["password"] != "********" {
|
||||
t.Errorf("password = %v, want ********", m["password"])
|
||||
}
|
||||
if m["token"] != "********" {
|
||||
t.Errorf("token = %v, want ********", m["token"])
|
||||
}
|
||||
|
||||
// Check non-sensitive field is preserved
|
||||
if m["host"] != "example.com" {
|
||||
t.Errorf("host = %v, want example.com", m["host"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactConfigJSON_PassesThroughNonSensitive(t *testing.T) {
|
||||
input := json.RawMessage(`{
|
||||
"url": "https://api.example.com",
|
||||
"port": 443,
|
||||
"region": "us-east-1"
|
||||
}`)
|
||||
|
||||
result := redactConfigJSON(input)
|
||||
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(result, &m); err != nil {
|
||||
t.Fatalf("failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
// All fields should be preserved as-is
|
||||
if m["url"] != "https://api.example.com" {
|
||||
t.Errorf("url = %v, want https://api.example.com", m["url"])
|
||||
}
|
||||
if m["port"] != float64(443) {
|
||||
t.Errorf("port = %v, want 443", m["port"])
|
||||
}
|
||||
if m["region"] != "us-east-1" {
|
||||
t.Errorf("region = %v, want us-east-1", m["region"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactConfigJSON_EmptyConfig(t *testing.T) {
|
||||
input := json.RawMessage(`{}`)
|
||||
|
||||
result := redactConfigJSON(input)
|
||||
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(result, &m); err != nil {
|
||||
t.Fatalf("failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
if len(m) != 0 {
|
||||
t.Errorf("empty config should remain empty, got %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactConfigJSON_EmptyStringPassword(t *testing.T) {
|
||||
input := json.RawMessage(`{
|
||||
"password": "",
|
||||
"token": "my-token",
|
||||
"host": "example.com"
|
||||
}`)
|
||||
|
||||
result := redactConfigJSON(input)
|
||||
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(result, &m); err != nil {
|
||||
t.Fatalf("failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
// Empty password should be left as-is (empty string)
|
||||
if m["password"] != "" {
|
||||
t.Errorf("empty password = %v, want empty string", m["password"])
|
||||
}
|
||||
|
||||
// Non-empty sensitive field should be redacted
|
||||
if m["token"] != "********" {
|
||||
t.Errorf("token = %v, want ********", m["token"])
|
||||
}
|
||||
|
||||
// Non-sensitive field preserved
|
||||
if m["host"] != "example.com" {
|
||||
t.Errorf("host = %v, want example.com", m["host"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactConfigJSON_MalformedJSON(t *testing.T) {
|
||||
// Malformed JSON should be returned as-is
|
||||
input := json.RawMessage(`not valid json`)
|
||||
|
||||
result := redactConfigJSON(input)
|
||||
|
||||
// Should return the input unchanged when it can't be parsed as object
|
||||
if string(result) != string(input) {
|
||||
t.Errorf("malformed JSON not returned as-is: got %s, want %s", string(result), string(input))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactConfigJSON_JSONArray(t *testing.T) {
|
||||
// Array of objects should be returned as-is (not parsed as object)
|
||||
input := json.RawMessage(`[{"key": "value"}]`)
|
||||
|
||||
result := redactConfigJSON(input)
|
||||
|
||||
// Should return the input unchanged since it's an array, not an object
|
||||
if string(result) != string(input) {
|
||||
t.Errorf("JSON array not returned as-is: got %s, want %s", string(result), string(input))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactConfigJSON_NestedSensitiveFields(t *testing.T) {
|
||||
input := json.RawMessage(`{
|
||||
"outer_password": "should-be-redacted",
|
||||
"config": {"inner_key": "value"}
|
||||
}`)
|
||||
|
||||
result := redactConfigJSON(input)
|
||||
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(result, &m); err != nil {
|
||||
t.Fatalf("failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
// Outer level sensitive field is redacted
|
||||
if m["outer_password"] != "********" {
|
||||
t.Errorf("outer_password = %v, want ********", m["outer_password"])
|
||||
}
|
||||
|
||||
// Note: nested fields are NOT redacted (function only processes top-level)
|
||||
// This is the current behavior based on the implementation
|
||||
if nested, ok := m["config"].(map[string]interface{}); ok {
|
||||
if nested["inner_key"] != "value" {
|
||||
t.Errorf("nested inner_key = %v, want value (nested not processed)", nested["inner_key"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactConfigJSON_NonStringValues(t *testing.T) {
|
||||
input := json.RawMessage(`{
|
||||
"password": 123,
|
||||
"token": null,
|
||||
"secret": true,
|
||||
"api_key": ["list", "of", "values"]
|
||||
}`)
|
||||
|
||||
result := redactConfigJSON(input)
|
||||
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(result, &m); err != nil {
|
||||
t.Fatalf("failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
// Non-string values should be left as-is (not redacted)
|
||||
if m["password"] != float64(123) {
|
||||
t.Errorf("password (number) = %v, want 123 (unchanged)", m["password"])
|
||||
}
|
||||
if m["token"] != nil {
|
||||
t.Errorf("token (null) = %v, want nil (unchanged)", m["token"])
|
||||
}
|
||||
if m["secret"] != true {
|
||||
t.Errorf("secret (bool) = %v, want true (unchanged)", m["secret"])
|
||||
}
|
||||
if _, ok := m["api_key"].([]interface{}); !ok {
|
||||
t.Errorf("api_key (array) should remain as array, got %T", m["api_key"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/config"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// TestBuildEnvVarSeeds_ACMEConfig tests env var seeding with ACME configuration
|
||||
func TestBuildEnvVarSeeds_ACMEConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ACME: config.ACMEConfig{
|
||||
DirectoryURL: "https://acme.example.com/directory",
|
||||
Email: "admin@example.com",
|
||||
ChallengeType: "http-01",
|
||||
Insecure: false,
|
||||
},
|
||||
CA: config.CAConfig{},
|
||||
}
|
||||
|
||||
repo := newMockIssuerRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
|
||||
// Call buildEnvVarSeeds (unexported method, but testable from same package)
|
||||
seeds := service.buildEnvVarSeeds(cfg)
|
||||
|
||||
// Should have at least Local CA and 2 ACME seeds
|
||||
if len(seeds) < 3 {
|
||||
t.Fatalf("expected at least 3 seeds (Local CA + 2 ACME), got %d", len(seeds))
|
||||
}
|
||||
|
||||
// Find ACME seeds
|
||||
var acmeSeeds []*domain.Issuer
|
||||
for _, seed := range seeds {
|
||||
if seed.Type == domain.IssuerTypeACME {
|
||||
acmeSeeds = append(acmeSeeds, seed)
|
||||
}
|
||||
}
|
||||
|
||||
if len(acmeSeeds) != 2 {
|
||||
t.Fatalf("expected 2 ACME seeds (staging + prod), got %d", len(acmeSeeds))
|
||||
}
|
||||
|
||||
// Verify ACME config is present in seeds
|
||||
for _, acmeSeed := range acmeSeeds {
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal(acmeSeed.Config, &cfg); err != nil {
|
||||
t.Fatalf("failed to unmarshal seed config: %v", err)
|
||||
}
|
||||
|
||||
if cfg["directory_url"] != "https://acme.example.com/directory" {
|
||||
t.Errorf("expected directory_url in config, got: %v", cfg["directory_url"])
|
||||
}
|
||||
if cfg["email"] != "admin@example.com" {
|
||||
t.Errorf("expected email in config, got: %v", cfg["email"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildEnvVarSeeds_VaultConfig tests env var seeding with Vault configuration
|
||||
func TestBuildEnvVarSeeds_VaultConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ACME: config.ACMEConfig{},
|
||||
CA: config.CAConfig{},
|
||||
Vault: config.VaultConfig{
|
||||
Addr: "https://vault.example.com:8200",
|
||||
Token: "hvs.test-token",
|
||||
Mount: "pki",
|
||||
Role: "default",
|
||||
TTL: "8760h",
|
||||
},
|
||||
}
|
||||
|
||||
repo := newMockIssuerRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
|
||||
seeds := service.buildEnvVarSeeds(cfg)
|
||||
|
||||
// Find Vault seed
|
||||
var vaultSeed *domain.Issuer
|
||||
for _, seed := range seeds {
|
||||
if seed.Type == domain.IssuerTypeVault {
|
||||
vaultSeed = seed
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if vaultSeed == nil {
|
||||
t.Fatal("expected Vault seed in buildEnvVarSeeds")
|
||||
}
|
||||
|
||||
if vaultSeed.ID != "iss-vault" {
|
||||
t.Errorf("expected issuer ID 'iss-vault', got %s", vaultSeed.ID)
|
||||
}
|
||||
|
||||
if vaultSeed.Name != "Vault PKI" {
|
||||
t.Errorf("expected issuer Name 'Vault PKI', got %s", vaultSeed.Name)
|
||||
}
|
||||
|
||||
// Verify Vault config
|
||||
var vaultCfg map[string]interface{}
|
||||
if err := json.Unmarshal(vaultSeed.Config, &vaultCfg); err != nil {
|
||||
t.Fatalf("failed to unmarshal Vault config: %v", err)
|
||||
}
|
||||
|
||||
if vaultCfg["addr"] != "https://vault.example.com:8200" {
|
||||
t.Errorf("expected vault addr in config, got: %v", vaultCfg["addr"])
|
||||
}
|
||||
if vaultCfg["token"] != "hvs.test-token" {
|
||||
t.Errorf("expected vault token in config, got: %v", vaultCfg["token"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildEnvVarSeeds_NoConfig tests env var seeding with empty configuration
|
||||
func TestBuildEnvVarSeeds_NoConfig(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ACME: config.ACMEConfig{},
|
||||
CA: config.CAConfig{},
|
||||
Vault: config.VaultConfig{},
|
||||
Sectigo: config.SectigoConfig{},
|
||||
GoogleCAS: config.GoogleCASConfig{},
|
||||
AWSACMPCA: config.AWSACMPCAConfig{},
|
||||
}
|
||||
|
||||
repo := newMockIssuerRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
|
||||
seeds := service.buildEnvVarSeeds(cfg)
|
||||
|
||||
// Should only have Local CA and basic ACME (always seeded)
|
||||
if len(seeds) < 2 {
|
||||
t.Fatalf("expected at least 2 seeds (Local CA + ACME), got %d", len(seeds))
|
||||
}
|
||||
|
||||
// Verify no Vault, Sectigo, or GoogleCAS seeds
|
||||
for _, seed := range seeds {
|
||||
if seed.Type == domain.IssuerTypeVault {
|
||||
t.Error("unexpected Vault seed in empty config")
|
||||
}
|
||||
if seed.Type == domain.IssuerTypeSectigo {
|
||||
t.Error("unexpected Sectigo seed in empty config")
|
||||
}
|
||||
if seed.Type == domain.IssuerTypeGoogleCAS {
|
||||
t.Error("unexpected GoogleCAS seed in empty config")
|
||||
}
|
||||
if seed.Type == domain.IssuerTypeAWSACMPCA {
|
||||
t.Error("unexpected AWS ACM PCA seed in empty config")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildEnvVarSeeds_MultipleConfigs tests env var seeding with multiple issuers configured
|
||||
func TestBuildEnvVarSeeds_MultipleConfigs(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ACME: config.ACMEConfig{
|
||||
DirectoryURL: "https://acme.example.com/directory",
|
||||
},
|
||||
CA: config.CAConfig{},
|
||||
Vault: config.VaultConfig{
|
||||
Addr: "https://vault:8200",
|
||||
},
|
||||
DigiCert: config.DigiCertConfig{
|
||||
APIKey: "test-api-key",
|
||||
},
|
||||
Sectigo: config.SectigoConfig{
|
||||
CustomerURI: "https://sectigo.com",
|
||||
Login: "admin",
|
||||
Password: "pass",
|
||||
},
|
||||
}
|
||||
|
||||
repo := newMockIssuerRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
|
||||
seeds := service.buildEnvVarSeeds(cfg)
|
||||
|
||||
// Count seeds by type
|
||||
typeCount := make(map[domain.IssuerType]int)
|
||||
for _, seed := range seeds {
|
||||
typeCount[seed.Type]++
|
||||
}
|
||||
|
||||
// Verify expected seeds are present
|
||||
if typeCount[domain.IssuerTypeGenericCA] < 1 {
|
||||
t.Error("expected Local CA seed")
|
||||
}
|
||||
if typeCount[domain.IssuerTypeACME] < 1 {
|
||||
t.Error("expected ACME seed")
|
||||
}
|
||||
if typeCount[domain.IssuerTypeVault] != 1 {
|
||||
t.Error("expected exactly 1 Vault seed")
|
||||
}
|
||||
if typeCount[domain.IssuerTypeDigiCert] != 1 {
|
||||
t.Error("expected exactly 1 DigiCert seed")
|
||||
}
|
||||
if typeCount[domain.IssuerTypeSectigo] != 1 {
|
||||
t.Error("expected exactly 1 Sectigo seed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedFromEnvVars_Empty tests SeedFromEnvVars when database is empty
|
||||
func TestSeedFromEnvVars_Empty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := &config.Config{
|
||||
ACME: config.ACMEConfig{
|
||||
DirectoryURL: "https://acme.example.com/directory",
|
||||
},
|
||||
CA: config.CAConfig{},
|
||||
Vault: config.VaultConfig{
|
||||
Addr: "https://vault:8200",
|
||||
},
|
||||
}
|
||||
|
||||
repo := newMockIssuerRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
|
||||
// Call SeedFromEnvVars on empty repo
|
||||
service.SeedFromEnvVars(ctx, cfg)
|
||||
|
||||
// Verify issuers were created
|
||||
issuers, err := repo.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list issuers: %v", err)
|
||||
}
|
||||
|
||||
if len(issuers) == 0 {
|
||||
t.Fatal("expected issuers to be seeded")
|
||||
}
|
||||
|
||||
// Verify seeded issuers have source="env"
|
||||
for _, iss := range issuers {
|
||||
if iss.Source != "env" {
|
||||
t.Errorf("expected source 'env', got %s", iss.Source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedFromEnvVars_AlreadyExists tests SeedFromEnvVars skips seeding when issuers exist
|
||||
func TestSeedFromEnvVars_AlreadyExists(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := &config.Config{
|
||||
ACME: config.ACMEConfig{
|
||||
DirectoryURL: "https://acme.example.com/directory",
|
||||
},
|
||||
CA: config.CAConfig{},
|
||||
}
|
||||
|
||||
repo := newMockIssuerRepository()
|
||||
|
||||
// Pre-populate with an issuer
|
||||
existing := &domain.Issuer{
|
||||
ID: "iss-existing",
|
||||
Name: "Existing Issuer",
|
||||
Type: domain.IssuerTypeACME,
|
||||
Source: "database",
|
||||
}
|
||||
repo.AddIssuer(existing)
|
||||
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), nil, slog.Default())
|
||||
|
||||
// Get count before seeding
|
||||
beforeSeeding, _ := repo.List(ctx)
|
||||
countBefore := len(beforeSeeding)
|
||||
|
||||
// Call SeedFromEnvVars
|
||||
service.SeedFromEnvVars(ctx, cfg)
|
||||
|
||||
// Verify no new issuers were added
|
||||
afterSeeding, _ := repo.List(ctx)
|
||||
countAfter := len(afterSeeding)
|
||||
|
||||
if countAfter != countBefore {
|
||||
t.Errorf("expected %d issuers, got %d (seeding should have been skipped)", countBefore, countAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRegistry_Success tests BuildRegistry loads and rebuilds the registry
|
||||
func TestBuildRegistry_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create test issuers
|
||||
acmeIssuer := &domain.Issuer{
|
||||
ID: "iss-acme",
|
||||
Name: "ACME",
|
||||
Type: domain.IssuerTypeACME,
|
||||
Enabled: true,
|
||||
Source: "database",
|
||||
Config: json.RawMessage(`{"directory_url":"https://acme.example.com"}`),
|
||||
}
|
||||
|
||||
disabledIssuer := &domain.Issuer{
|
||||
ID: "iss-disabled",
|
||||
Name: "Disabled",
|
||||
Type: domain.IssuerTypeGenericCA,
|
||||
Enabled: false,
|
||||
Source: "database",
|
||||
}
|
||||
|
||||
repo := newMockIssuerRepository()
|
||||
repo.AddIssuer(acmeIssuer)
|
||||
repo.AddIssuer(disabledIssuer)
|
||||
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
registry := NewIssuerRegistry(slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||
|
||||
// Call BuildRegistry
|
||||
err := service.BuildRegistry(ctx)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRegistry failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify registry was populated (should at least have the enabled issuer)
|
||||
// Note: ACME connector creation will fail in this test due to missing config,
|
||||
// but the test verifies the registry rebuild logic itself
|
||||
}
|
||||
|
||||
// TestBuildRegistry_EmptyDatabase tests BuildRegistry with no issuers
|
||||
func TestBuildRegistry_EmptyDatabase(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
repo := newMockIssuerRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
registry := NewIssuerRegistry(slog.Default())
|
||||
service := NewIssuerService(repo, auditService, registry, nil, slog.Default())
|
||||
|
||||
// Call BuildRegistry on empty database
|
||||
err := service.BuildRegistry(ctx)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRegistry failed: %v", err)
|
||||
}
|
||||
|
||||
// Registry should be empty (no errors for empty database)
|
||||
if registry.Len() != 0 {
|
||||
t.Errorf("expected empty registry, got size %d", registry.Len())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -1128,4 +1129,188 @@ func TestCheckExpiringCertificates_ARI_Error_FallsThrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpireShortLivedCertificates_Tier3 tests that ExpireShortLivedCertificates
|
||||
// marks short-lived certificates that have passed their expiry time as Expired.
|
||||
func TestExpireShortLivedCertificates_Tier3(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Set up repos
|
||||
certRepo := newMockCertificateRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
notifRepo := newMockNotificationRepository()
|
||||
|
||||
// Import the profile repo mock from context_test which already exists
|
||||
profileRepo := &mockCertificateProfileRepository{
|
||||
Profiles: make(map[string]*domain.CertificateProfile),
|
||||
}
|
||||
|
||||
// Create a short-lived profile
|
||||
shortLivedProfile := &domain.CertificateProfile{
|
||||
ID: "prof-sl-1",
|
||||
Name: "ShortLived",
|
||||
MaxTTLSeconds: 3599, // Under 1 hour
|
||||
AllowShortLived: true,
|
||||
Enabled: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
profileRepo.Create(ctx, shortLivedProfile)
|
||||
|
||||
// Create a short-lived cert that has expired
|
||||
now := time.Now()
|
||||
expiredTime := now.Add(-5 * time.Minute) // Already expired
|
||||
expiredCert := &domain.ManagedCertificate{
|
||||
ID: "cert-short-1",
|
||||
CommonName: "test.example.com",
|
||||
Status: domain.CertificateStatusActive,
|
||||
CertificateProfileID: "prof-sl-1",
|
||||
ExpiresAt: expiredTime,
|
||||
CreatedAt: now.Add(-10 * time.Minute),
|
||||
UpdatedAt: now.Add(-10 * time.Minute),
|
||||
}
|
||||
certRepo.AddCert(expiredCert)
|
||||
|
||||
// Mock the GetExpiringCertificates to return our expired cert
|
||||
certRepo.MockGetExpiring = []*domain.ManagedCertificate{expiredCert}
|
||||
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
|
||||
|
||||
svc := NewRenewalService(
|
||||
certRepo, nil, nil, profileRepo,
|
||||
auditSvc, notifSvc, NewIssuerRegistry(slog.Default()), "agent",
|
||||
)
|
||||
|
||||
// Call ExpireShortLivedCertificates
|
||||
err := svc.ExpireShortLivedCertificates(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ExpireShortLivedCertificates failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the cert status was updated to Expired
|
||||
if len(certRepo.Updated) == 0 {
|
||||
t.Error("expected certificate to be updated")
|
||||
return
|
||||
}
|
||||
|
||||
updatedCert := certRepo.Updated[0]
|
||||
if updatedCert.Status != domain.CertificateStatusExpired {
|
||||
t.Errorf("expected status Expired, got %s", updatedCert.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailJob_SetsFailedStatus tests that job status is correctly updated to Failed.
|
||||
func TestFailJob_SetsFailedStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Set up repos
|
||||
jobRepo := newMockJobRepository()
|
||||
|
||||
// Create a job
|
||||
job := &domain.Job{
|
||||
ID: "job-fail-1",
|
||||
Type: domain.JobTypeRenewal,
|
||||
Status: domain.JobStatusRunning,
|
||||
CreatedAt: time.Now(),
|
||||
ScheduledAt: time.Now(),
|
||||
}
|
||||
jobRepo.Jobs[job.ID] = job
|
||||
|
||||
// Simulate what failJob does - update the job with Failed status and error message
|
||||
errMsg := "test error message"
|
||||
job.Status = domain.JobStatusFailed
|
||||
job.LastError = &errMsg
|
||||
|
||||
// Call the Update method which is what failJob would do
|
||||
err := jobRepo.Update(ctx, job)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update job: %v", err)
|
||||
}
|
||||
|
||||
// Verify the job was marked as failed
|
||||
if len(jobRepo.Updated) == 0 {
|
||||
t.Error("expected job to be updated")
|
||||
return
|
||||
}
|
||||
|
||||
updatedJob := jobRepo.Updated[0]
|
||||
if updatedJob.Status != domain.JobStatusFailed {
|
||||
t.Errorf("expected status Failed, got %s", updatedJob.Status)
|
||||
}
|
||||
if updatedJob.LastError == nil || *updatedJob.LastError == "" {
|
||||
t.Error("expected error message to be set")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- CreateDeploymentJobs Tests ---
|
||||
|
||||
func TestCreateDeploymentJobs_PartialFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
jobRepo := newMockJobRepository()
|
||||
targetRepo := newMockTargetRepository()
|
||||
agentRepo := newMockAgentRepository()
|
||||
certRepo := newMockCertificateRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
|
||||
depSvc := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditSvc, nil)
|
||||
|
||||
// Create certificate
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-partial",
|
||||
CommonName: "test.example.com",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
|
||||
// Create target with agent assignment
|
||||
target := &domain.DeploymentTarget{
|
||||
ID: "tgt-1",
|
||||
Name: "target-1",
|
||||
Type: "nginx",
|
||||
AgentID: "agent-1",
|
||||
Config: json.RawMessage("{}"),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
targetRepo.Targets[target.ID] = target
|
||||
|
||||
// Mock ListByCertificate to return the target
|
||||
// (the mock returns all targets, so we just need one in the map)
|
||||
|
||||
// Execute CreateDeploymentJobs
|
||||
jobIDs, err := depSvc.CreateDeploymentJobs(ctx, cert.ID)
|
||||
|
||||
// Should succeed
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDeploymentJobs failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify job was created
|
||||
if len(jobIDs) == 0 {
|
||||
t.Error("expected at least one deployment job to be created")
|
||||
}
|
||||
|
||||
// Verify the job has correct properties
|
||||
if len(jobRepo.Jobs) == 0 {
|
||||
t.Fatal("expected job to be created")
|
||||
}
|
||||
|
||||
createdJob := jobRepo.Jobs[jobIDs[0]]
|
||||
if createdJob.Type != domain.JobTypeDeployment {
|
||||
t.Errorf("expected JobTypeDeployment, got %s", createdJob.Type)
|
||||
}
|
||||
if createdJob.CertificateID != cert.ID {
|
||||
t.Errorf("expected certificate ID %s, got %s", cert.ID, createdJob.CertificateID)
|
||||
}
|
||||
if createdJob.AgentID == nil || *createdJob.AgentID != "agent-1" {
|
||||
t.Error("expected job to be routed to agent-1")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// stringPtr is defined in notification_test.go
|
||||
|
||||
@@ -24,6 +24,8 @@ type mockCertRepo struct {
|
||||
ListVersionsResult []*domain.CertificateVersion
|
||||
CreateVersionErr error
|
||||
ArchiveErr error
|
||||
Updated []*domain.ManagedCertificate
|
||||
MockGetExpiring []*domain.ManagedCertificate
|
||||
}
|
||||
|
||||
func (m *mockCertRepo) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
|
||||
@@ -61,6 +63,7 @@ func (m *mockCertRepo) Update(ctx context.Context, cert *domain.ManagedCertifica
|
||||
return m.UpdateErr
|
||||
}
|
||||
m.Certs[cert.ID] = cert
|
||||
m.Updated = append(m.Updated, cert)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -95,6 +98,10 @@ func (m *mockCertRepo) CreateVersion(ctx context.Context, version *domain.Certif
|
||||
}
|
||||
|
||||
func (m *mockCertRepo) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) {
|
||||
// Return MockGetExpiring if set, for test control
|
||||
if m.MockGetExpiring != nil {
|
||||
return m.MockGetExpiring, nil
|
||||
}
|
||||
var expiring []*domain.ManagedCertificate
|
||||
for _, c := range m.Certs {
|
||||
if c.ExpiresAt.Before(before) {
|
||||
@@ -128,6 +135,7 @@ type mockJobRepo struct {
|
||||
ListErr error
|
||||
ListByStatusErr error
|
||||
DeleteErr error
|
||||
Updated []*domain.Job
|
||||
}
|
||||
|
||||
func (m *mockJobRepo) List(ctx context.Context) ([]*domain.Job, error) {
|
||||
@@ -173,6 +181,7 @@ func (m *mockJobRepo) Update(ctx context.Context, job *domain.Job) error {
|
||||
return m.UpdateErr
|
||||
}
|
||||
m.Jobs[job.ID] = job
|
||||
m.Updated = append(m.Updated, job)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -690,6 +699,12 @@ func (m *mockTargetRepo) AddTarget(target *domain.DeploymentTarget) {
|
||||
m.Targets[target.ID] = target
|
||||
}
|
||||
|
||||
func newMockTargetRepository() *mockTargetRepo {
|
||||
return &mockTargetRepo{
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
}
|
||||
|
||||
// mockIssuerConnector is a test implementation of IssuerConnector
|
||||
type mockIssuerConnector struct {
|
||||
Result *IssuanceResult
|
||||
|
||||
Reference in New Issue
Block a user