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:
shankar0123
2026-04-04 01:09:53 -04:00
parent e19b8c95fe
commit e6088c79a3
23 changed files with 849 additions and 151 deletions
+79 -5
View File
@@ -13,11 +13,12 @@ import (
// MockTargetService is a mock implementation of TargetService interface.
type MockTargetService struct {
ListTargetsFn func(page, perPage int) ([]domain.DeploymentTarget, int64, error)
GetTargetFn func(id string) (*domain.DeploymentTarget, error)
CreateTargetFn func(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
UpdateTargetFn func(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
DeleteTargetFn func(id string) error
ListTargetsFn func(page, perPage int) ([]domain.DeploymentTarget, int64, error)
GetTargetFn func(id string) (*domain.DeploymentTarget, error)
CreateTargetFn func(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
UpdateTargetFn func(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
DeleteTargetFn func(id string) error
TestTargetConnectionFn func(id string) error
}
func (m *MockTargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) {
@@ -55,6 +56,13 @@ func (m *MockTargetService) DeleteTarget(id string) error {
return nil
}
func (m *MockTargetService) TestTargetConnection(id string) error {
if m.TestTargetConnectionFn != nil {
return m.TestTargetConnectionFn(id)
}
return nil
}
func TestListTargets_Success(t *testing.T) {
now := time.Now()
t1 := domain.DeploymentTarget{
@@ -419,3 +427,69 @@ func TestDeleteTarget_EmptyID(t *testing.T) {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestTestTargetConnection_Success(t *testing.T) {
mock := &MockTargetService{
TestTargetConnectionFn: func(id string) error {
return nil
},
}
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets/t-nginx-01/test", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.TestTargetConnection(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["status"] != "success" {
t.Errorf("expected status 'success', got %v", resp["status"])
}
}
func TestTestTargetConnection_Failed(t *testing.T) {
mock := &MockTargetService{
TestTargetConnectionFn: func(id string) error {
return ErrMockServiceFailed
},
}
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets/t-nginx-01/test", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.TestTargetConnection(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["status"] != "failed" {
t.Errorf("expected status 'failed', got %v", resp["status"])
}
}
func TestTestTargetConnection_MethodNotAllowed(t *testing.T) {
handler := NewTargetHandler(&MockTargetService{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/targets/t-nginx-01/test", nil)
w := httptest.NewRecorder()
handler.TestTargetConnection(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status 405, got %d", w.Code)
}
}
+34
View File
@@ -17,6 +17,7 @@ type TargetService interface {
CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
DeleteTarget(id string) error
TestTargetConnection(id string) error
}
// TargetHandler handles HTTP requests for deployment target operations.
@@ -189,3 +190,36 @@ func (h TargetHandler) DeleteTarget(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// TestTargetConnection tests target connectivity by checking the assigned agent's heartbeat.
// POST /api/v1/targets/{id}/test
func (h TargetHandler) TestTargetConnection(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Extract target ID from path: /api/v1/targets/{id}/test
path := strings.TrimPrefix(r.URL.Path, "/api/v1/targets/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Target ID is required", requestID)
return
}
id := parts[0]
if err := h.svc.TestTargetConnection(id); err != nil {
JSON(w, http.StatusOK, map[string]interface{}{
"status": "failed",
"message": err.Error(),
})
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"status": "success",
"message": "Agent is online and reachable",
})
}
+1
View File
@@ -126,6 +126,7 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
r.Register("GET /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.GetTarget))
r.Register("PUT /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.UpdateTarget))
r.Register("DELETE /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.DeleteTarget))
r.Register("POST /api/v1/targets/{id}/test", http.HandlerFunc(reg.Targets.TestTargetConnection))
// Agents routes: /api/v1/agents
r.Register("GET /api/v1/agents", http.HandlerFunc(reg.Agents.ListAgents))
+12 -8
View File
@@ -22,14 +22,18 @@ type Issuer struct {
// DeploymentTarget represents a target system where certificates are deployed.
type DeploymentTarget struct {
ID string `json:"id"`
Name string `json:"name"`
Type TargetType `json:"type"`
AgentID string `json:"agent_id"`
Config json.RawMessage `json:"config"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Type TargetType `json:"type"`
AgentID string `json:"agent_id"`
Config json.RawMessage `json:"config"`
EncryptedConfig []byte `json:"-"` // AES-GCM encrypted full config (never exposed via API)
Enabled bool `json:"enabled"`
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
TestStatus string `json:"test_status,omitempty"`
Source string `json:"source,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Agent represents an agent running on a target system.
+12
View File
@@ -786,6 +786,14 @@ func (m *mockTargetRepository) Create(ctx context.Context, target *domain.Deploy
return nil
}
func (m *mockTargetRepository) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
if _, exists := m.targets[target.ID]; exists {
return false, nil
}
m.targets[target.ID] = target
return true, nil
}
func (m *mockTargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error {
m.targets[target.ID] = target
return nil
@@ -1009,6 +1017,10 @@ func (m *mockTargetService) DeleteTarget(id string) error {
return m.targetRepo.Delete(context.Background(), id)
}
func (m *mockTargetService) TestTargetConnection(id string) error {
return nil // No-op for integration tests
}
type mockTeamService struct{}
func (m *mockTeamService) ListTeams(page, perPage int) ([]domain.Team, int64, error) {
+3
View File
@@ -68,6 +68,9 @@ type TargetRepository interface {
Get(ctx context.Context, id string) (*domain.DeploymentTarget, error)
// Create stores a new target.
Create(ctx context.Context, target *domain.DeploymentTarget) error
// CreateIfNotExists creates a target only if the ID doesn't already exist (ON CONFLICT DO NOTHING).
// Returns true if created, false if already existed.
CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error)
// Update modifies an existing target.
Update(ctx context.Context, target *domain.DeploymentTarget) error
// Delete removes a target.
+78 -17
View File
@@ -19,10 +19,40 @@ func NewTargetRepository(db *sql.DB) *TargetRepository {
return &TargetRepository{db: db}
}
// scanTarget scans a target row including optional M35 columns (encrypted_config, last_tested_at, test_status, source).
func scanTarget(scanner interface {
Scan(dest ...interface{}) error
}, target *domain.DeploymentTarget) error {
var lastTestedAt sql.NullTime
var testStatus sql.NullString
var source sql.NullString
if err := scanner.Scan(
&target.ID, &target.Name, &target.Type, &target.AgentID,
&target.Config, &target.EncryptedConfig, &target.Enabled,
&lastTestedAt, &testStatus, &source,
&target.CreatedAt, &target.UpdatedAt,
); err != nil {
return err
}
if lastTestedAt.Valid {
target.LastTestedAt = &lastTestedAt.Time
}
if testStatus.Valid {
target.TestStatus = testStatus.String
}
if source.Valid {
target.Source = source.String
}
return nil
}
// targetSelectColumns is the standard column list for target queries.
const targetSelectColumns = `id, name, type, agent_id, config, COALESCE(encrypted_config, ''::bytea), enabled, last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'), created_at, updated_at`
// List returns all targets
func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, type, agent_id, config, enabled, created_at, updated_at
SELECT `+targetSelectColumns+`
FROM deployment_targets
ORDER BY created_at DESC
`)
@@ -35,8 +65,7 @@ func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget
var targets []*domain.DeploymentTarget
for rows.Next() {
var target domain.DeploymentTarget
if err := rows.Scan(&target.ID, &target.Name, &target.Type, &target.AgentID,
&target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt); err != nil {
if err := scanTarget(rows, &target); err != nil {
return nil, fmt.Errorf("failed to scan target: %w", err)
}
targets = append(targets, &target)
@@ -52,12 +81,11 @@ func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget
// Get retrieves a target by ID
func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
var target domain.DeploymentTarget
err := r.db.QueryRowContext(ctx, `
SELECT id, name, type, agent_id, config, enabled, created_at, updated_at
err := scanTarget(r.db.QueryRowContext(ctx, `
SELECT `+targetSelectColumns+`
FROM deployment_targets
WHERE id = $1
`, id).Scan(&target.ID, &target.Name, &target.Type, &target.AgentID,
&target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt)
`, id), &target)
if err != nil {
if err == sql.ErrNoRows {
@@ -76,10 +104,11 @@ func (r *TargetRepository) Create(ctx context.Context, target *domain.Deployment
}
err := r.db.QueryRowContext(ctx, `
INSERT INTO deployment_targets (id, name, type, agent_id, config, enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
INSERT INTO deployment_targets (id, name, type, agent_id, config, encrypted_config, enabled, last_tested_at, test_status, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id
`, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.Enabled,
`, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig,
target.Enabled, target.LastTestedAt, target.TestStatus, target.Source,
target.CreatedAt, target.UpdatedAt).Scan(&target.ID)
if err != nil {
@@ -89,6 +118,33 @@ func (r *TargetRepository) Create(ctx context.Context, target *domain.Deployment
return nil
}
// CreateIfNotExists creates a target only if the ID doesn't already exist (ON CONFLICT DO NOTHING).
// Returns true if created, false if already existed.
func (r *TargetRepository) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
if target.ID == "" {
target.ID = uuid.New().String()
}
result, err := r.db.ExecContext(ctx, `
INSERT INTO deployment_targets (id, name, type, agent_id, config, encrypted_config, enabled, last_tested_at, test_status, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (id) DO NOTHING
`, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig,
target.Enabled, target.LastTestedAt, target.TestStatus, target.Source,
target.CreatedAt, target.UpdatedAt)
if err != nil {
return false, fmt.Errorf("failed to create target: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return false, fmt.Errorf("failed to get rows affected: %w", err)
}
return rows > 0, nil
}
// Update modifies an existing target
func (r *TargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error {
result, err := r.db.ExecContext(ctx, `
@@ -97,10 +153,16 @@ func (r *TargetRepository) Update(ctx context.Context, target *domain.Deployment
type = $2,
agent_id = $3,
config = $4,
enabled = $5,
updated_at = $6
WHERE id = $7
`, target.Name, target.Type, target.AgentID, target.Config, target.Enabled, target.UpdatedAt, target.ID)
encrypted_config = $5,
enabled = $6,
last_tested_at = $7,
test_status = $8,
source = $9,
updated_at = $10
WHERE id = $11
`, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig,
target.Enabled, target.LastTestedAt, target.TestStatus, target.Source,
target.UpdatedAt, target.ID)
if err != nil {
return fmt.Errorf("failed to update target: %w", err)
@@ -141,7 +203,7 @@ func (r *TargetRepository) Delete(ctx context.Context, id string) error {
// ListByCertificate returns all targets for a given certificate
func (r *TargetRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT dt.id, dt.name, dt.type, dt.agent_id, dt.config, dt.enabled, dt.created_at, dt.updated_at
SELECT dt.id, dt.name, dt.type, dt.agent_id, dt.config, COALESCE(dt.encrypted_config, ''::bytea), dt.enabled, dt.last_tested_at, COALESCE(dt.test_status, 'untested'), COALESCE(dt.source, 'database'), dt.created_at, dt.updated_at
FROM deployment_targets dt
INNER JOIN certificate_target_mappings ctm ON dt.id = ctm.target_id
WHERE ctm.certificate_id = $1
@@ -156,8 +218,7 @@ func (r *TargetRepository) ListByCertificate(ctx context.Context, certID string)
var targets []*domain.DeploymentTarget
for rows.Next() {
var target domain.DeploymentTarget
if err := rows.Scan(&target.ID, &target.Name, &target.Type, &target.AgentID,
&target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt); err != nil {
if err := scanTarget(rows, &target); err != nil {
return nil, fmt.Errorf("failed to scan target: %w", err)
}
targets = append(targets, &target)
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"os"
"sync"
"testing"
@@ -193,7 +194,7 @@ func TestConcurrentTargetCRUD(t *testing.T) {
Targets: make(map[string]*domain.DeploymentTarget),
}
targetSvc := NewTargetService(mockTargetRepo, nil)
targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
var mu sync.Mutex
createdTargets := make([]string, 0)
@@ -402,7 +403,7 @@ func TestConcurrentMixedOperations(t *testing.T) {
// Setup services
auditSvc := &AuditService{auditRepo: mockAuditRepo}
certSvc := NewCertificateService(mockCertRepo, nil, auditSvc)
targetSvc := NewTargetService(mockTargetRepo, auditSvc)
targetSvc := NewTargetService(mockTargetRepo, auditSvc, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
var wg sync.WaitGroup
errChan := make(chan error, 30)
+42
View File
@@ -0,0 +1,42 @@
package service
import (
"encoding/json"
"strings"
)
// sensitiveKeys are config key substrings that should be redacted in API responses.
var sensitiveKeys = []string{"password", "secret", "token", "key", "hmac", "private", "credentials"}
// isSensitiveConfigKey checks if a config key contains sensitive substrings.
func isSensitiveConfigKey(key string) bool {
lower := strings.ToLower(key)
for _, s := range sensitiveKeys {
if strings.Contains(lower, s) {
return true
}
}
return false
}
// redactConfigJSON replaces sensitive values in a JSON config with "********".
func redactConfigJSON(configJSON json.RawMessage) json.RawMessage {
var m map[string]interface{}
if err := json.Unmarshal(configJSON, &m); err != nil {
return configJSON // Not a JSON object, return as-is
}
for k, v := range m {
if isSensitiveConfigKey(k) {
if str, ok := v.(string); ok && str != "" {
m[k] = "********"
}
}
}
redacted, err := json.Marshal(m)
if err != nil {
return configJSON
}
return json.RawMessage(redacted)
}
+2 -1
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"log/slog"
"os"
"testing"
"time"
@@ -141,7 +142,7 @@ func TestTargetService_ListWithCancelledContext(t *testing.T) {
mockTargetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
targetSvc := NewTargetService(mockTargetRepo, nil)
targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
_, _, err := targetSvc.List(ctx, 1, 50)
-37
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/shankar0123/certctl/internal/config"
@@ -16,9 +15,6 @@ import (
"github.com/shankar0123/certctl/internal/repository"
)
// sensitiveKeys are config key substrings that should be redacted in API responses.
var sensitiveKeys = []string{"password", "secret", "token", "key", "hmac", "private", "credentials"}
// IssuerService provides business logic for certificate issuer management.
type IssuerService struct {
issuerRepo repository.IssuerRepository
@@ -703,39 +699,6 @@ func (s *IssuerService) updateTestStatus(ctx context.Context, iss *domain.Issuer
}
}
// redactConfigJSON replaces sensitive values in a JSON config with "********".
func redactConfigJSON(configJSON json.RawMessage) json.RawMessage {
var m map[string]interface{}
if err := json.Unmarshal(configJSON, &m); err != nil {
return configJSON // Not a JSON object, return as-is
}
for k, v := range m {
if isSensitiveConfigKey(k) {
if str, ok := v.(string); ok && str != "" {
m[k] = "********"
}
}
}
redacted, err := json.Marshal(m)
if err != nil {
return configJSON
}
return json.RawMessage(redacted)
}
// isSensitiveConfigKey checks if a config key contains sensitive substrings.
func isSensitiveConfigKey(key string) bool {
lower := strings.ToLower(key)
for _, s := range sensitiveKeys {
if strings.Contains(lower, s) {
return true
}
}
return false
}
// getEnvForSeed reads an environment variable for seed data construction.
func getEnvForSeed(key string) string {
return os.Getenv(key)
+248 -9
View File
@@ -2,28 +2,58 @@ package service
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/shankar0123/certctl/internal/crypto"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// validTargetTypes is the set of allowed target types for validation.
var validTargetTypes = map[domain.TargetType]bool{
domain.TargetTypeNGINX: true,
domain.TargetTypeApache: true,
domain.TargetTypeHAProxy: true,
domain.TargetTypeF5: true,
domain.TargetTypeIIS: true,
domain.TargetTypeTraefik: true,
domain.TargetTypeCaddy: true,
domain.TargetTypeEnvoy: true,
domain.TargetTypePostfix: true,
domain.TargetTypeDovecot: true,
}
// isValidTargetType checks if a type string is a known target type.
func isValidTargetType(t domain.TargetType) bool {
return validTargetTypes[t]
}
// TargetService provides business logic for deployment target management.
type TargetService struct {
targetRepo repository.TargetRepository
auditService *AuditService
targetRepo repository.TargetRepository
agentRepo repository.AgentRepository
auditService *AuditService
encryptionKey []byte
logger *slog.Logger
}
// NewTargetService creates a new target service.
func NewTargetService(
targetRepo repository.TargetRepository,
auditService *AuditService,
agentRepo repository.AgentRepository,
encryptionKey []byte,
logger *slog.Logger,
) *TargetService {
return &TargetService{
targetRepo: targetRepo,
auditService: auditService,
targetRepo: targetRepo,
agentRepo: agentRepo,
auditService: auditService,
encryptionKey: encryptionKey,
logger: logger,
}
}
@@ -61,11 +91,14 @@ func (s *TargetService) Get(ctx context.Context, id string) (*domain.DeploymentT
return target, nil
}
// Create validates and stores a new deployment target.
// Create validates and stores a new deployment target, encrypting sensitive config.
func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTarget, actor string) error {
if target.Name == "" {
return fmt.Errorf("target name is required")
}
if !isValidTargetType(target.Type) {
return fmt.Errorf("unsupported target type: %s", target.Type)
}
if target.ID == "" {
target.ID = generateID("target")
@@ -77,33 +110,68 @@ func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTar
if target.UpdatedAt.IsZero() {
target.UpdatedAt = now
}
if target.TestStatus == "" {
target.TestStatus = "untested"
}
if target.Source == "" {
target.Source = "database"
}
// Encrypt the full config and store redacted version in config column
if len(target.Config) > 0 {
encrypted, _, err := crypto.EncryptIfKeySet([]byte(target.Config), s.encryptionKey)
if err != nil {
return fmt.Errorf("failed to encrypt config: %w", err)
}
target.EncryptedConfig = encrypted
target.Config = redactConfigJSON(target.Config)
}
if err := s.targetRepo.Create(ctx, target); err != nil {
return fmt.Errorf("failed to create target: %w", err)
}
if s.auditService != nil {
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_target", "target", target.ID, nil); auditErr != nil {
slog.Error("failed to record audit event", "error", auditErr)
s.logger.Error("failed to record audit event", "error", auditErr)
}
}
return nil
}
// Update modifies an existing deployment target.
// Update modifies an existing deployment target. Handles "********" preservation for sensitive fields.
func (s *TargetService) Update(ctx context.Context, id string, target *domain.DeploymentTarget, actor string) error {
if target.Name == "" {
return fmt.Errorf("target name is required")
}
target.ID = id
target.UpdatedAt = time.Now()
// If config contains "********" values, merge with existing decrypted config
if len(target.Config) > 0 {
mergedConfig, err := s.mergeRedactedConfig(ctx, id, target.Config)
if err != nil {
return fmt.Errorf("failed to merge config: %w", err)
}
// Encrypt the merged config
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
if encErr != nil {
return fmt.Errorf("failed to encrypt config: %w", encErr)
}
target.EncryptedConfig = encrypted
target.Config = redactConfigJSON(json.RawMessage(mergedConfig))
}
if err := s.targetRepo.Update(ctx, target); err != nil {
return fmt.Errorf("failed to update target %s: %w", id, err)
}
if s.auditService != nil {
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_target", "target", id, nil); auditErr != nil {
slog.Error("failed to record audit event", "error", auditErr)
s.logger.Error("failed to record audit event", "error", auditErr)
}
}
@@ -118,13 +186,50 @@ func (s *TargetService) Delete(ctx context.Context, id string, actor string) err
if s.auditService != nil {
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_target", "target", id, nil); auditErr != nil {
slog.Error("failed to record audit event", "error", auditErr)
s.logger.Error("failed to record audit event", "error", auditErr)
}
}
return nil
}
// TestConnection tests a target's connectivity by checking the assigned agent's heartbeat status.
// Target connectors run on agents, not on the server, so we can't instantiate a connector here.
// Instead, we verify the agent is online and reachable.
func (s *TargetService) TestConnection(ctx context.Context, id string) error {
target, err := s.targetRepo.Get(ctx, id)
if err != nil {
return fmt.Errorf("target not found: %w", err)
}
if target.AgentID == "" {
s.updateTestStatus(ctx, target, "failed")
return fmt.Errorf("target has no assigned agent")
}
agent, err := s.agentRepo.Get(ctx, target.AgentID)
if err != nil {
s.updateTestStatus(ctx, target, "failed")
return fmt.Errorf("assigned agent not found: %w", err)
}
if agent.Status != domain.AgentStatusOnline {
s.updateTestStatus(ctx, target, "failed")
return fmt.Errorf("assigned agent %s is %s (expected Online)", agent.ID, agent.Status)
}
// Check heartbeat freshness (agent must have heartbeated within the last 5 minutes)
if agent.LastHeartbeatAt != nil {
if time.Since(*agent.LastHeartbeatAt) > 5*time.Minute {
s.updateTestStatus(ctx, target, "failed")
return fmt.Errorf("assigned agent %s last heartbeat was %s ago (stale)", agent.ID, time.Since(*agent.LastHeartbeatAt).Round(time.Second))
}
}
s.updateTestStatus(ctx, target, "success")
return nil
}
// ListTargets returns paginated targets (handler interface method).
func (s *TargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) {
if page < 1 {
@@ -157,6 +262,9 @@ func (s *TargetService) GetTarget(id string) (*domain.DeploymentTarget, error) {
// CreateTarget creates a new target (handler interface method).
func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
if !isValidTargetType(target.Type) {
return nil, fmt.Errorf("unsupported target type: %s", target.Type)
}
if target.ID == "" {
target.ID = generateID("target")
}
@@ -167,6 +275,23 @@ func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.De
if target.UpdatedAt.IsZero() {
target.UpdatedAt = now
}
if target.TestStatus == "" {
target.TestStatus = "untested"
}
if target.Source == "" {
target.Source = "database"
}
// Encrypt config
if len(target.Config) > 0 {
encrypted, _, err := crypto.EncryptIfKeySet([]byte(target.Config), s.encryptionKey)
if err != nil {
return nil, fmt.Errorf("failed to encrypt config: %w", err)
}
target.EncryptedConfig = encrypted
target.Config = redactConfigJSON(target.Config)
}
if err := s.targetRepo.Create(context.Background(), &target); err != nil {
return nil, fmt.Errorf("failed to create target: %w", err)
}
@@ -176,6 +301,23 @@ func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.De
// UpdateTarget modifies a target (handler interface method).
func (s *TargetService) UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
target.ID = id
target.UpdatedAt = time.Now()
// Merge redacted fields with existing config
if len(target.Config) > 0 {
mergedConfig, err := s.mergeRedactedConfig(context.Background(), id, target.Config)
if err != nil {
return nil, fmt.Errorf("failed to merge config: %w", err)
}
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
if encErr != nil {
return nil, fmt.Errorf("failed to encrypt config: %w", encErr)
}
target.EncryptedConfig = encrypted
target.Config = redactConfigJSON(json.RawMessage(mergedConfig))
}
if err := s.targetRepo.Update(context.Background(), &target); err != nil {
return nil, fmt.Errorf("failed to update target: %w", err)
}
@@ -186,3 +328,100 @@ func (s *TargetService) UpdateTarget(id string, target domain.DeploymentTarget)
func (s *TargetService) DeleteTarget(id string) error {
return s.targetRepo.Delete(context.Background(), id)
}
// TestTargetConnection tests target connectivity (handler interface method).
func (s *TargetService) TestTargetConnection(id string) error {
return s.TestConnection(context.Background(), id)
}
// --- Internal helpers ---
// getDecryptedConfig returns the decrypted config JSON for a target.
func (s *TargetService) getDecryptedConfig(target *domain.DeploymentTarget) (json.RawMessage, error) {
if len(target.EncryptedConfig) > 0 {
decrypted, err := crypto.DecryptIfKeySet(target.EncryptedConfig, s.encryptionKey)
if err != nil {
return nil, err
}
return json.RawMessage(decrypted), nil
}
if len(target.Config) > 0 {
return target.Config, nil
}
return json.RawMessage("{}"), nil
}
// mergeRedactedConfig merges incoming config (which may have "********" values)
// with the existing decrypted config so sensitive fields are preserved.
func (s *TargetService) mergeRedactedConfig(ctx context.Context, id string, incoming json.RawMessage) ([]byte, error) {
// Parse incoming config
var incomingMap map[string]interface{}
if err := json.Unmarshal(incoming, &incomingMap); err != nil {
s.logger.Warn("mergeRedactedConfig: incoming config is not a JSON object, using as-is", "target", id, "error", err)
return incoming, nil
}
// Check if any values are "********"
hasRedacted := false
for _, v := range incomingMap {
if str, ok := v.(string); ok && str == "********" {
hasRedacted = true
break
}
}
if !hasRedacted {
return incoming, nil // No redacted values, use incoming as-is
}
// Load existing target to get real values
existing, err := s.targetRepo.Get(ctx, id)
if err != nil {
s.logger.Warn("mergeRedactedConfig: could not load existing target, redacted values will be lost", "target", id, "error", err)
return incoming, nil
}
existingConfig, err := s.getDecryptedConfig(existing)
if err != nil {
s.logger.Warn("mergeRedactedConfig: could not decrypt existing config, redacted values will be lost", "target", id, "error", err)
return incoming, nil
}
var existingMap map[string]interface{}
if err := json.Unmarshal(existingConfig, &existingMap); err != nil {
s.logger.Warn("mergeRedactedConfig: existing config is not a JSON object, redacted values will be lost", "target", id, "error", err)
return incoming, nil
}
// Merge: for each "********" value in incoming, use existing value
for k, v := range incomingMap {
if str, ok := v.(string); ok && str == "********" {
if existingVal, exists := existingMap[k]; exists {
incomingMap[k] = existingVal
}
}
}
return json.Marshal(incomingMap)
}
// updateTestStatus updates the test_status and last_tested_at fields in the database
// and records an audit event.
func (s *TargetService) updateTestStatus(ctx context.Context, target *domain.DeploymentTarget, status string) {
now := time.Now()
target.TestStatus = status
target.LastTestedAt = &now
target.UpdatedAt = now
if err := s.targetRepo.Update(ctx, target); err != nil {
s.logger.Error("failed to update test status", "target", target.ID, "status", status, "error", err)
}
// Record audit event for connection test
if s.auditService != nil {
action := "target_test_connection_" + status
details := map[string]interface{}{"target_type": string(target.Type), "result": status, "agent_id": target.AgentID}
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem, action, "target", target.ID, details); auditErr != nil {
s.logger.Error("failed to record test connection audit event", "error", auditErr)
}
}
}
+189 -20
View File
@@ -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")
}
}
+13
View File
@@ -637,6 +637,19 @@ func (m *mockTargetRepo) Create(ctx context.Context, target *domain.DeploymentTa
return nil
}
func (m *mockTargetRepo) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.CreateErr != nil {
return false, m.CreateErr
}
if _, exists := m.Targets[target.ID]; exists {
return false, nil
}
m.Targets[target.ID] = target
return true, nil
}
func (m *mockTargetRepo) Update(ctx context.Context, target *domain.DeploymentTarget) error {
m.mu.Lock()
defer m.mu.Unlock()