mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:51:32 +00:00
07275bf92f
Agents now report OS, architecture, IP address, hostname, and version via heartbeat using runtime.GOOS, runtime.GOARCH, and net.Dial. New migration adds columns to agents table. Heartbeat handler, service, and repository updated to accept and persist metadata. GUI shows OS/Arch in agent list and full system info in agent detail page. Apache httpd connector: separate cert/chain/key files, apachectl configtest validation, graceful reload. HAProxy connector: combined PEM file (cert+chain+key), optional config validation, reload. Both wired into agent binary's target connector switch. 14 tests for new connectors. All existing tests updated for new Heartbeat/UpdateHeartbeat signatures. Docs updated across README, architecture, concepts, and connectors guides. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
468 lines
14 KiB
Go
468 lines
14 KiB
Go
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, nil)
|
|
|
|
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, nil)
|
|
|
|
err := agentService.HeartbeatWithContext(ctx, "agent-001", nil)
|
|
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, nil)
|
|
|
|
err := agentService.HeartbeatWithContext(ctx, "nonexistent", nil)
|
|
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, nil)
|
|
|
|
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, nil)
|
|
|
|
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, nil)
|
|
|
|
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, nil)
|
|
|
|
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, nil)
|
|
|
|
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, nil)
|
|
|
|
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)
|
|
}
|
|
}
|