mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
Implement M4: comprehensive test coverage with 120 tests
Service layer (63 tests): certificate, agent, audit, job, notification, policy, and renewal services with mock repositories covering threshold alerting, deduplication, status transitions, and job processing. Handler layer (46 tests): certificate and agent HTTP handlers using httptest with mock service interfaces, covering success/error paths, pagination, JSON marshaling, and path parameter extraction. Integration (11 subtests): end-to-end certificate lifecycle test exercising real services and Local CA issuer through HTTP API — create cert, trigger renewal, process jobs, register agent, heartbeat, verify audit trail. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
func TestRegisterAgent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: make(map[string]*domain.Agent),
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: make(map[string]*domain.Job),
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
targetRepo := &mockTargetRepo{
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
issuerRegistry := make(map[string]IssuerConnector)
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry)
|
||||
|
||||
agent, apiKey, err := agentService.Register(ctx, "prod-agent-1", "server-01.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Register failed: %v", err)
|
||||
}
|
||||
|
||||
if agent.Name != "prod-agent-1" {
|
||||
t.Errorf("expected name prod-agent-1, got %s", agent.Name)
|
||||
}
|
||||
if agent.Hostname != "server-01.example.com" {
|
||||
t.Errorf("expected hostname server-01.example.com, got %s", agent.Hostname)
|
||||
}
|
||||
if agent.Status != domain.AgentStatusOnline {
|
||||
t.Errorf("expected status Online, got %s", agent.Status)
|
||||
}
|
||||
if apiKey == "" {
|
||||
t.Fatal("expected non-empty API key")
|
||||
}
|
||||
|
||||
if len(agentRepo.Agents) != 1 {
|
||||
t.Errorf("expected 1 agent in repo, got %d", len(agentRepo.Agents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeat(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
agent := &domain.Agent{
|
||||
ID: "agent-001",
|
||||
Name: "prod-agent",
|
||||
Hostname: "server-01",
|
||||
Status: domain.AgentStatusOnline,
|
||||
RegisteredAt: now,
|
||||
LastHeartbeatAt: &now,
|
||||
APIKeyHash: "hash123",
|
||||
}
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{"agent-001": agent},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: make(map[string]*domain.Job),
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
targetRepo := &mockTargetRepo{
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
auditRepo := &mockAuditRepo{}
|
||||
auditService := NewAuditService(auditRepo)
|
||||
issuerRegistry := make(map[string]IssuerConnector)
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry)
|
||||
|
||||
err := agentService.HeartbeatWithContext(ctx, "agent-001")
|
||||
if err != nil {
|
||||
t.Fatalf("Heartbeat failed: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := agentRepo.HeartbeatUpdates["agent-001"]; !ok {
|
||||
t.Fatal("heartbeat not recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeat_NotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: make(map[string]*domain.Agent),
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: make(map[string]*domain.Job),
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
targetRepo := &mockTargetRepo{
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
auditRepo := &mockAuditRepo{}
|
||||
auditService := NewAuditService(auditRepo)
|
||||
issuerRegistry := make(map[string]IssuerConnector)
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry)
|
||||
|
||||
err := agentService.HeartbeatWithContext(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPendingWork(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
agent := &domain.Agent{
|
||||
ID: "agent-001",
|
||||
Name: "prod-agent",
|
||||
Hostname: "server-01",
|
||||
Status: domain.AgentStatusOnline,
|
||||
RegisteredAt: now,
|
||||
LastHeartbeatAt: &now,
|
||||
APIKeyHash: "hash123",
|
||||
}
|
||||
|
||||
job1 := &domain.Job{
|
||||
ID: "job-001",
|
||||
Type: domain.JobTypeDeployment,
|
||||
CertificateID: "cert-001",
|
||||
Status: domain.JobStatusPending,
|
||||
CreatedAt: now,
|
||||
}
|
||||
job2 := &domain.Job{
|
||||
ID: "job-002",
|
||||
Type: domain.JobTypeRenewal,
|
||||
CertificateID: "cert-002",
|
||||
Status: domain.JobStatusPending,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{"agent-001": agent},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: map[string]*domain.Job{"job-001": job1, "job-002": job2},
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
targetRepo := &mockTargetRepo{
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
auditRepo := &mockAuditRepo{}
|
||||
auditService := NewAuditService(auditRepo)
|
||||
issuerRegistry := make(map[string]IssuerConnector)
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry)
|
||||
|
||||
jobs, err := agentService.GetPendingWork(ctx, "agent-001")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingWork failed: %v", err)
|
||||
}
|
||||
|
||||
if len(jobs) != 1 {
|
||||
t.Errorf("expected 1 deployment job, got %d", len(jobs))
|
||||
}
|
||||
if jobs[0].Type != domain.JobTypeDeployment {
|
||||
t.Errorf("expected JobTypeDeployment, got %s", jobs[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportJobStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
agent := &domain.Agent{
|
||||
ID: "agent-001",
|
||||
Name: "prod-agent",
|
||||
Hostname: "server-01",
|
||||
Status: domain.AgentStatusOnline,
|
||||
RegisteredAt: now,
|
||||
LastHeartbeatAt: &now,
|
||||
APIKeyHash: "hash123",
|
||||
}
|
||||
job := &domain.Job{
|
||||
ID: "job-001",
|
||||
Type: domain.JobTypeDeployment,
|
||||
CertificateID: "cert-001",
|
||||
Status: domain.JobStatusRunning,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{"agent-001": agent},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: map[string]*domain.Job{"job-001": job},
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
targetRepo := &mockTargetRepo{
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
||||
auditService := NewAuditService(auditRepo)
|
||||
issuerRegistry := make(map[string]IssuerConnector)
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry)
|
||||
|
||||
err := agentService.ReportJobStatus(ctx, "agent-001", "job-001", domain.JobStatusCompleted, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ReportJobStatus failed: %v", err)
|
||||
}
|
||||
|
||||
if jobRepo.StatusUpdates["job-001"] != domain.JobStatusCompleted {
|
||||
t.Errorf("expected status Completed, got %s", jobRepo.StatusUpdates["job-001"])
|
||||
}
|
||||
|
||||
if len(auditRepo.Events) != 1 {
|
||||
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkStaleAgentsOffline(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
staleTime := now.Add(-3 * time.Hour)
|
||||
|
||||
agent1 := &domain.Agent{
|
||||
ID: "agent-001",
|
||||
Name: "online-agent",
|
||||
Hostname: "server-01",
|
||||
Status: domain.AgentStatusOnline,
|
||||
RegisteredAt: now,
|
||||
LastHeartbeatAt: &now,
|
||||
APIKeyHash: "hash1",
|
||||
}
|
||||
agent2 := &domain.Agent{
|
||||
ID: "agent-002",
|
||||
Name: "stale-agent",
|
||||
Hostname: "server-02",
|
||||
Status: domain.AgentStatusOnline,
|
||||
RegisteredAt: now.Add(-24 * time.Hour),
|
||||
LastHeartbeatAt: &staleTime,
|
||||
APIKeyHash: "hash2",
|
||||
}
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{"agent-001": agent1, "agent-002": agent2},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: make(map[string]*domain.Job),
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
targetRepo := &mockTargetRepo{
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
auditRepo := &mockAuditRepo{}
|
||||
auditService := NewAuditService(auditRepo)
|
||||
issuerRegistry := make(map[string]IssuerConnector)
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry)
|
||||
|
||||
err := agentService.MarkStaleAgentsOffline(ctx, 1*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("MarkStaleAgentsOffline failed: %v", err)
|
||||
}
|
||||
|
||||
if agentRepo.Agents["agent-001"].Status != domain.AgentStatusOnline {
|
||||
t.Errorf("expected agent-001 to be Online, got %s", agentRepo.Agents["agent-001"].Status)
|
||||
}
|
||||
if agentRepo.Agents["agent-002"].Status != domain.AgentStatusOffline {
|
||||
t.Errorf("expected agent-002 to be Offline, got %s", agentRepo.Agents["agent-002"].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitCSR(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
agent := &domain.Agent{
|
||||
ID: "agent-001",
|
||||
Name: "prod-agent",
|
||||
Hostname: "server-01",
|
||||
Status: domain.AgentStatusOnline,
|
||||
RegisteredAt: now,
|
||||
LastHeartbeatAt: &now,
|
||||
APIKeyHash: "hash123",
|
||||
}
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "cert-001",
|
||||
CommonName: "example.com",
|
||||
IssuerID: "iss-local",
|
||||
Status: domain.CertificateStatusPending,
|
||||
ExpiresAt: now.AddDate(1, 0, 0),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{"agent-001": agent},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: map[string]*domain.ManagedCertificate{"cert-001": cert},
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: make(map[string]*domain.Job),
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
targetRepo := &mockTargetRepo{
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
||||
auditService := NewAuditService(auditRepo)
|
||||
|
||||
issuerConnector := &mockIssuerConnector{
|
||||
Result: &IssuanceResult{
|
||||
Serial: "serial-123",
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----",
|
||||
NotBefore: now,
|
||||
NotAfter: now.AddDate(1, 0, 0),
|
||||
},
|
||||
}
|
||||
issuerRegistry := map[string]IssuerConnector{"iss-local": issuerConnector}
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry)
|
||||
|
||||
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\ntest-csr\n-----END CERTIFICATE REQUEST-----"
|
||||
err := agentService.SubmitCSR(ctx, "agent-001", "cert-001", []byte(csrPEM))
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitCSR failed: %v", err)
|
||||
}
|
||||
|
||||
if len(certRepo.Versions["cert-001"]) != 1 {
|
||||
t.Errorf("expected 1 certificate version, got %d", len(certRepo.Versions["cert-001"]))
|
||||
}
|
||||
|
||||
if cert.Status != domain.CertificateStatusActive {
|
||||
t.Errorf("expected certificate status Active, got %s", cert.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitCSR_EmptyCSR(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
agent := &domain.Agent{
|
||||
ID: "agent-001",
|
||||
Name: "prod-agent",
|
||||
Hostname: "server-01",
|
||||
Status: domain.AgentStatusOnline,
|
||||
RegisteredAt: now,
|
||||
LastHeartbeatAt: &now,
|
||||
APIKeyHash: "hash123",
|
||||
}
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{"agent-001": agent},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: make(map[string]*domain.Job),
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
targetRepo := &mockTargetRepo{
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
auditRepo := &mockAuditRepo{}
|
||||
auditService := NewAuditService(auditRepo)
|
||||
issuerRegistry := make(map[string]IssuerConnector)
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry)
|
||||
|
||||
err := agentService.SubmitCSR(ctx, "agent-001", "", []byte{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty CSR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAgents(t *testing.T) {
|
||||
now := time.Now()
|
||||
agent1 := &domain.Agent{
|
||||
ID: "agent-001",
|
||||
Name: "agent1",
|
||||
Hostname: "server-01",
|
||||
Status: domain.AgentStatusOnline,
|
||||
RegisteredAt: now,
|
||||
LastHeartbeatAt: &now,
|
||||
APIKeyHash: "hash1",
|
||||
}
|
||||
agent2 := &domain.Agent{
|
||||
ID: "agent-002",
|
||||
Name: "agent2",
|
||||
Hostname: "server-02",
|
||||
Status: domain.AgentStatusOnline,
|
||||
RegisteredAt: now,
|
||||
LastHeartbeatAt: &now,
|
||||
APIKeyHash: "hash2",
|
||||
}
|
||||
|
||||
agentRepo := &mockAgentRepo{
|
||||
Agents: map[string]*domain.Agent{"agent-001": agent1, "agent-002": agent2},
|
||||
HeartbeatUpdates: make(map[string]time.Time),
|
||||
}
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{
|
||||
Jobs: make(map[string]*domain.Job),
|
||||
StatusUpdates: make(map[string]domain.JobStatus),
|
||||
}
|
||||
targetRepo := &mockTargetRepo{
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
auditRepo := &mockAuditRepo{}
|
||||
auditService := NewAuditService(auditRepo)
|
||||
issuerRegistry := make(map[string]IssuerConnector)
|
||||
|
||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry)
|
||||
|
||||
agents, total, err := agentService.ListAgents(1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAgents failed: %v", err)
|
||||
}
|
||||
|
||||
if len(agents) != 2 {
|
||||
t.Errorf("expected 2 agents, got %d", len(agents))
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("expected total 2, got %d", total)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user