mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 16:38:55 +00:00
feat(M35): dynamic target configuration with encrypted config, test connection, and GUI updates
Mirror M34's dynamic issuer config pattern for deployment targets: AES-256-GCM encrypted config storage, sensitive field redaction in API responses, agent heartbeat-based test connection endpoint, and full frontend updates including test status indicators, source badges, and removal of stale hostname/status fields from the Target interface. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+189
-20
@@ -3,21 +3,26 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// newTestTargetService creates a TargetService with mock repositories for testing.
|
||||
func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo) {
|
||||
func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo, *mockAgentRepo) {
|
||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
return NewTargetService(targetRepo, auditSvc), targetRepo, auditRepo
|
||||
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent), HeartbeatUpdates: make(map[string]time.Time)}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
return NewTargetService(targetRepo, auditSvc, agentRepo, nil, logger), targetRepo, auditRepo, agentRepo
|
||||
}
|
||||
|
||||
func TestTargetService_List_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Add 3 targets
|
||||
@@ -44,7 +49,7 @@ func TestTargetService_List_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_List_DefaultPagination(t *testing.T) {
|
||||
svc, _, _ := newTestTargetService()
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Call with invalid pagination (page=0, perPage=0)
|
||||
@@ -60,7 +65,7 @@ func TestTargetService_List_DefaultPagination(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_List_EmptyPage(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Add 3 targets
|
||||
@@ -87,7 +92,7 @@ func TestTargetService_List_EmptyPage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_List_RepoError(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Set repo to return error
|
||||
@@ -104,7 +109,7 @@ func TestTargetService_List_RepoError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Get_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
||||
@@ -121,7 +126,7 @@ func TestTargetService_Get_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Get_NotFound(t *testing.T) {
|
||||
svc, _, _ := newTestTargetService()
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := svc.Get(ctx, "nonexistent")
|
||||
@@ -135,7 +140,7 @@ func TestTargetService_Get_NotFound(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Create_Success(t *testing.T) {
|
||||
svc, targetRepo, auditRepo := newTestTargetService()
|
||||
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
@@ -168,6 +173,14 @@ func TestTargetService_Create_Success(t *testing.T) {
|
||||
t.Errorf("expected timestamps to be set, CreatedAt=%v, UpdatedAt=%v", target.CreatedAt, target.UpdatedAt)
|
||||
}
|
||||
|
||||
// Verify test status and source defaults
|
||||
if target.TestStatus != "untested" {
|
||||
t.Errorf("expected test_status 'untested', got %s", target.TestStatus)
|
||||
}
|
||||
if target.Source != "database" {
|
||||
t.Errorf("expected source 'database', got %s", target.Source)
|
||||
}
|
||||
|
||||
// Verify audit event
|
||||
if len(auditRepo.Events) == 0 {
|
||||
t.Fatalf("expected audit event, got none")
|
||||
@@ -184,7 +197,7 @@ func TestTargetService_Create_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Create_MissingName(t *testing.T) {
|
||||
svc, _, _ := newTestTargetService()
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
@@ -197,8 +210,23 @@ func TestTargetService_Create_MissingName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_Create_InvalidType(t *testing.T) {
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
Name: "Bad Target",
|
||||
Type: domain.TargetType("InvalidType"),
|
||||
}
|
||||
|
||||
err := svc.Create(ctx, target, "test-actor")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_Create_RepoError(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
targetRepo.CreateErr = errNotFound
|
||||
@@ -215,7 +243,7 @@ func TestTargetService_Create_RepoError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Update_Success(t *testing.T) {
|
||||
svc, targetRepo, auditRepo := newTestTargetService()
|
||||
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create initial target
|
||||
@@ -251,7 +279,7 @@ func TestTargetService_Update_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Update_MissingName(t *testing.T) {
|
||||
svc, _, _ := newTestTargetService()
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
@@ -265,7 +293,7 @@ func TestTargetService_Update_MissingName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Delete_Success(t *testing.T) {
|
||||
svc, targetRepo, auditRepo := newTestTargetService()
|
||||
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create initial target
|
||||
@@ -295,7 +323,7 @@ func TestTargetService_Delete_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Delete_RepoError(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
targetRepo.DeleteErr = errNotFound
|
||||
@@ -307,7 +335,7 @@ func TestTargetService_Delete_RepoError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_ListTargets_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
|
||||
// Add targets
|
||||
target1 := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
||||
@@ -331,7 +359,7 @@ func TestTargetService_ListTargets_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_GetTarget_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
|
||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
||||
targetRepo.AddTarget(target)
|
||||
@@ -347,7 +375,7 @@ func TestTargetService_GetTarget_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_CreateTarget_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
|
||||
target := domain.DeploymentTarget{
|
||||
Name: "New Target",
|
||||
@@ -369,8 +397,22 @@ func TestTargetService_CreateTarget_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_CreateTarget_InvalidType(t *testing.T) {
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
|
||||
target := domain.DeploymentTarget{
|
||||
Name: "Bad Target",
|
||||
Type: domain.TargetType("Unknown"),
|
||||
}
|
||||
|
||||
_, err := svc.CreateTarget(target)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_UpdateTarget_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
|
||||
// Create initial target
|
||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Old Name", Type: domain.TargetTypeNGINX}
|
||||
@@ -393,7 +435,7 @@ func TestTargetService_UpdateTarget_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_DeleteTarget_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
|
||||
// Create initial target
|
||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target To Delete", Type: domain.TargetTypeNGINX}
|
||||
@@ -410,3 +452,130 @@ func TestTargetService_DeleteTarget_Success(t *testing.T) {
|
||||
t.Errorf("target should be deleted from repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_TestConnection_AgentOnline(t *testing.T) {
|
||||
svc, targetRepo, _, agentRepo := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Set up agent
|
||||
heartbeat := time.Now()
|
||||
agent := &domain.Agent{
|
||||
ID: "agent-1",
|
||||
Name: "Test Agent",
|
||||
Status: domain.AgentStatusOnline,
|
||||
LastHeartbeatAt: &heartbeat,
|
||||
}
|
||||
agentRepo.Create(ctx, agent)
|
||||
|
||||
// Set up target assigned to agent
|
||||
target := &domain.DeploymentTarget{
|
||||
ID: "t-1",
|
||||
Name: "Test Target",
|
||||
Type: domain.TargetTypeNGINX,
|
||||
AgentID: "agent-1",
|
||||
}
|
||||
targetRepo.AddTarget(target)
|
||||
|
||||
// Test connection should succeed
|
||||
err := svc.TestConnection(ctx, "t-1")
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify test status was updated
|
||||
stored := targetRepo.Targets["t-1"]
|
||||
if stored.TestStatus != "success" {
|
||||
t.Errorf("expected test_status 'success', got %s", stored.TestStatus)
|
||||
}
|
||||
if stored.LastTestedAt == nil {
|
||||
t.Error("expected last_tested_at to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_TestConnection_AgentOffline(t *testing.T) {
|
||||
svc, targetRepo, _, agentRepo := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Set up offline agent
|
||||
agent := &domain.Agent{
|
||||
ID: "agent-1",
|
||||
Name: "Offline Agent",
|
||||
Status: domain.AgentStatusOffline,
|
||||
}
|
||||
agentRepo.Create(ctx, agent)
|
||||
|
||||
// Set up target
|
||||
target := &domain.DeploymentTarget{
|
||||
ID: "t-1",
|
||||
Name: "Test Target",
|
||||
Type: domain.TargetTypeNGINX,
|
||||
AgentID: "agent-1",
|
||||
}
|
||||
targetRepo.AddTarget(target)
|
||||
|
||||
err := svc.TestConnection(ctx, "t-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for offline agent, got nil")
|
||||
}
|
||||
|
||||
stored := targetRepo.Targets["t-1"]
|
||||
if stored.TestStatus != "failed" {
|
||||
t.Errorf("expected test_status 'failed', got %s", stored.TestStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_TestConnection_NoAgent(t *testing.T) {
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
ID: "t-1",
|
||||
Name: "Test Target",
|
||||
Type: domain.TargetTypeNGINX,
|
||||
AgentID: "",
|
||||
}
|
||||
targetRepo.AddTarget(target)
|
||||
|
||||
err := svc.TestConnection(ctx, "t-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing agent, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_TestConnection_TargetNotFound(t *testing.T) {
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
err := svc.TestConnection(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent target, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_TestConnection_StaleHeartbeat(t *testing.T) {
|
||||
svc, targetRepo, _, agentRepo := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Set up agent with stale heartbeat (10 minutes ago)
|
||||
staleTime := time.Now().Add(-10 * time.Minute)
|
||||
agent := &domain.Agent{
|
||||
ID: "agent-1",
|
||||
Name: "Stale Agent",
|
||||
Status: domain.AgentStatusOnline,
|
||||
LastHeartbeatAt: &staleTime,
|
||||
}
|
||||
agentRepo.Create(ctx, agent)
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
ID: "t-1",
|
||||
Name: "Test Target",
|
||||
Type: domain.TargetTypeNGINX,
|
||||
AgentID: "agent-1",
|
||||
}
|
||||
targetRepo.AddTarget(target)
|
||||
|
||||
err := svc.TestConnection(ctx, "t-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stale heartbeat, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user