mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:21:30 +00:00
f549a7aa79
EncryptIfKeySet/DecryptIfKeySet in internal/crypto/encryption.go previously
returned plaintext + wasEncrypted=false when the operator had not configured
CERTCTL_CONFIG_ENCRYPTION_KEY. That produced a data-at-rest confidentiality
bypass (CWE-311): sensitive fields on dynamically-configured issuer and
target rows (source='database') were persisted to PostgreSQL without any
encryption, and no caller could distinguish the encrypted from the plaintext
branch at runtime. The only visible signal was a single warning log line
emitted once at startup.
Fail closed instead:
- EncryptIfKeySet / DecryptIfKeySet now return crypto.ErrEncryptionKeyRequired
(a new exported sentinel, errors.Is-unwrappable) when the key is empty or
nil, rather than silently emitting plaintext. The (result, wasEncrypted,
err) tuple signature is preserved for source compatibility; only the
semantics of the no-key branch changed.
- cmd/server/main.go grows a startup pre-flight check: if no encryption key
is configured the server lists issuers and targets, counts rows with
source='database', and refuses to start (os.Exit(1)) if any exist. Operators
must either configure CERTCTL_CONFIG_ENCRYPTION_KEY or remove the exposed
rows before the control plane can boot. The warning-only path is retained
for the clean-slate case (no database rows).
- internal/service/issuer.go's SeedFromEnvVars now guards the encryption call
with len(s.encryptionKey) > 0 so env-seeded rows (source='env', which are
reconstructable on every boot from process env) continue to persist as
plaintext in the 'config' column when no key is configured. Registry load
already falls through to cfg.Config when EncryptedConfig is nil. GUI/API
write paths (source='database') remain fail-closed via propagation of
ErrEncryptionKeyRequired.
- Integration tests that exercise CreateIssuer via the handler layer now
supply a real 32-byte AES-256 test key so the encrypt path runs instead of
returning ErrEncryptionKeyRequired. Same pattern in internal/service/
testutil_test.go for consolidated service-layer tests.
- internal/crypto/encryption_test.go grows regression guards:
TestEncryptIfKeySet_EmptyKeyFailsClosed (nil_key + empty_key subtests),
TestDecryptIfKeySet_EmptyKeyFailsClosed (nil_key + empty_key subtests),
TestEncryptDecryptIfKeySet_RoundTripProducesDifferentCiphertext,
TestDecryptIfKeySet_RejectsTamperedCiphertext, and
TestEncryptIfKeySet_PreservesErrEncryptionKeyRequiredSentinel (verifies
the sentinel unwraps through fmt.Errorf(%w)-style wrapping).
Wire format is unchanged: AES-256-GCM Encrypt/Decrypt/DeriveKey, the
12-byte nonce prefix, the GCM auth tag, the PBKDF2 salt
('certctl-config-encryption-v1'), and the 100,000 iteration count are all
byte-identical. Ciphertexts produced before this change remain decryptable.
Verified:
- go build ./... : clean
- go vet ./... : clean
- go test -race ./internal/crypto/... ./internal/service/... \
./internal/integration/... ./cmd/server/... : pass
- golangci-lint run ./... : 0 issues
- govulncheck ./... : 0 reachable vulnerabilities
- rg 'return plaintext, false, nil' internal/ : no matches
- Coverage: crypto 85.0% (unchanged), service 67.8% (was 67.9%, noise),
cmd/server 0.0% (unchanged baseline). All above CI thresholds.
See certctl-audit-report.md for the full finding record and resolution log.
582 lines
15 KiB
Go
582 lines
15 KiB
Go
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, *mockAgentRepo) {
|
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
|
auditRepo := newMockAuditRepository()
|
|
auditSvc := NewAuditService(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, testEncryptionKey, logger), targetRepo, auditRepo, agentRepo
|
|
}
|
|
|
|
func TestTargetService_List_Success(t *testing.T) {
|
|
svc, targetRepo, _, _ := newTestTargetService()
|
|
ctx := context.Background()
|
|
|
|
// Add 3 targets
|
|
target1 := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
|
target2 := &domain.DeploymentTarget{ID: "t-2", Name: "Target 2", Type: domain.TargetTypeApache}
|
|
target3 := &domain.DeploymentTarget{ID: "t-3", Name: "Target 3", Type: domain.TargetTypeHAProxy}
|
|
targetRepo.AddTarget(target1)
|
|
targetRepo.AddTarget(target2)
|
|
targetRepo.AddTarget(target3)
|
|
|
|
// Request page 1, perPage 2
|
|
targets, total, err := svc.List(ctx, 1, 2)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(targets) != 2 {
|
|
t.Errorf("expected 2 targets, got %d", len(targets))
|
|
}
|
|
|
|
if total != 3 {
|
|
t.Errorf("expected total=3, got %d", total)
|
|
}
|
|
}
|
|
|
|
func TestTargetService_List_DefaultPagination(t *testing.T) {
|
|
svc, _, _, _ := newTestTargetService()
|
|
ctx := context.Background()
|
|
|
|
// Call with invalid pagination (page=0, perPage=0)
|
|
targets, total, err := svc.List(ctx, 0, 0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Should not panic; should use defaults (page=1, perPage=50)
|
|
if targets != nil || total != 0 {
|
|
t.Errorf("expected empty list with defaults, got %d targets", len(targets))
|
|
}
|
|
}
|
|
|
|
func TestTargetService_List_EmptyPage(t *testing.T) {
|
|
svc, targetRepo, _, _ := newTestTargetService()
|
|
ctx := context.Background()
|
|
|
|
// Add 3 targets
|
|
target1 := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
|
target2 := &domain.DeploymentTarget{ID: "t-2", Name: "Target 2", Type: domain.TargetTypeApache}
|
|
target3 := &domain.DeploymentTarget{ID: "t-3", Name: "Target 3", Type: domain.TargetTypeHAProxy}
|
|
targetRepo.AddTarget(target1)
|
|
targetRepo.AddTarget(target2)
|
|
targetRepo.AddTarget(target3)
|
|
|
|
// Request page 2 with perPage 10 (beyond available data)
|
|
targets, total, err := svc.List(ctx, 2, 10)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(targets) != 0 {
|
|
t.Errorf("expected 0 targets, got %d", len(targets))
|
|
}
|
|
|
|
if total != 3 {
|
|
t.Errorf("expected total=3, got %d", total)
|
|
}
|
|
}
|
|
|
|
func TestTargetService_List_RepoError(t *testing.T) {
|
|
svc, targetRepo, _, _ := newTestTargetService()
|
|
ctx := context.Background()
|
|
|
|
// Set repo to return error
|
|
targetRepo.ListErr = errNotFound
|
|
|
|
targets, total, err := svc.List(ctx, 1, 50)
|
|
if err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
|
|
if targets != nil || total != 0 {
|
|
t.Errorf("expected nil targets and zero total, got %d targets and %d total", len(targets), total)
|
|
}
|
|
}
|
|
|
|
func TestTargetService_Get_Success(t *testing.T) {
|
|
svc, targetRepo, _, _ := newTestTargetService()
|
|
ctx := context.Background()
|
|
|
|
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
|
targetRepo.AddTarget(target)
|
|
|
|
result, err := svc.Get(ctx, "t-1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if result.ID != "t-1" || result.Name != "Target 1" {
|
|
t.Errorf("expected target t-1/Target 1, got %s/%s", result.ID, result.Name)
|
|
}
|
|
}
|
|
|
|
func TestTargetService_Get_NotFound(t *testing.T) {
|
|
svc, _, _, _ := newTestTargetService()
|
|
ctx := context.Background()
|
|
|
|
result, err := svc.Get(ctx, "nonexistent")
|
|
if err == nil {
|
|
t.Fatalf("expected error for nonexistent target, got nil")
|
|
}
|
|
|
|
if result != nil {
|
|
t.Errorf("expected nil result, got %v", result)
|
|
}
|
|
}
|
|
|
|
func TestTargetService_Create_Success(t *testing.T) {
|
|
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
|
ctx := context.Background()
|
|
|
|
target := &domain.DeploymentTarget{
|
|
Name: "New Target",
|
|
Type: domain.TargetTypeNGINX,
|
|
Config: json.RawMessage(`{"path": "/etc/nginx/certs"}`),
|
|
}
|
|
|
|
err := svc.Create(ctx, target, "test-actor")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Verify target was stored
|
|
if target.ID == "" || len(target.ID) < 7 || target.ID[:6] != "target" {
|
|
t.Errorf("expected ID to start with 'target', got %s", target.ID)
|
|
}
|
|
|
|
stored, ok := targetRepo.Targets[target.ID]
|
|
if !ok {
|
|
t.Fatalf("target not stored in repo")
|
|
}
|
|
|
|
if stored.Name != "New Target" {
|
|
t.Errorf("expected name 'New Target', got %s", stored.Name)
|
|
}
|
|
|
|
// Verify timestamps are set
|
|
if target.CreatedAt.IsZero() || target.UpdatedAt.IsZero() {
|
|
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")
|
|
}
|
|
|
|
lastEvent := auditRepo.Events[len(auditRepo.Events)-1]
|
|
if lastEvent.Action != "create_target" {
|
|
t.Errorf("expected action 'create_target', got %s", lastEvent.Action)
|
|
}
|
|
|
|
if lastEvent.Actor != "test-actor" {
|
|
t.Errorf("expected actor 'test-actor', got %s", lastEvent.Actor)
|
|
}
|
|
}
|
|
|
|
func TestTargetService_Create_MissingName(t *testing.T) {
|
|
svc, _, _, _ := newTestTargetService()
|
|
ctx := context.Background()
|
|
|
|
target := &domain.DeploymentTarget{
|
|
Type: domain.TargetTypeNGINX,
|
|
}
|
|
|
|
err := svc.Create(ctx, target, "test-actor")
|
|
if err == nil {
|
|
t.Fatalf("expected error for missing name, got nil")
|
|
}
|
|
}
|
|
|
|
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()
|
|
ctx := context.Background()
|
|
|
|
targetRepo.CreateErr = errNotFound
|
|
|
|
target := &domain.DeploymentTarget{
|
|
Name: "New Target",
|
|
Type: domain.TargetTypeNGINX,
|
|
}
|
|
|
|
err := svc.Create(ctx, target, "test-actor")
|
|
if err == nil {
|
|
t.Fatalf("expected error from repo, got nil")
|
|
}
|
|
}
|
|
|
|
func TestTargetService_Update_Success(t *testing.T) {
|
|
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
|
ctx := context.Background()
|
|
|
|
// Create initial target
|
|
existing := &domain.DeploymentTarget{ID: "t-1", Name: "Old Name", Type: domain.TargetTypeNGINX}
|
|
targetRepo.AddTarget(existing)
|
|
|
|
// Update it
|
|
updated := &domain.DeploymentTarget{
|
|
Name: "New Name",
|
|
Type: domain.TargetTypeApache,
|
|
}
|
|
|
|
err := svc.Update(ctx, "t-1", updated, "test-actor")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Verify update
|
|
stored := targetRepo.Targets["t-1"]
|
|
if stored.Name != "New Name" {
|
|
t.Errorf("expected name 'New Name', got %s", stored.Name)
|
|
}
|
|
|
|
// Verify audit event
|
|
if len(auditRepo.Events) == 0 {
|
|
t.Fatalf("expected audit event, got none")
|
|
}
|
|
|
|
lastEvent := auditRepo.Events[len(auditRepo.Events)-1]
|
|
if lastEvent.Action != "update_target" {
|
|
t.Errorf("expected action 'update_target', got %s", lastEvent.Action)
|
|
}
|
|
}
|
|
|
|
func TestTargetService_Update_MissingName(t *testing.T) {
|
|
svc, _, _, _ := newTestTargetService()
|
|
ctx := context.Background()
|
|
|
|
target := &domain.DeploymentTarget{
|
|
Type: domain.TargetTypeNGINX,
|
|
}
|
|
|
|
err := svc.Update(ctx, "t-1", target, "test-actor")
|
|
if err == nil {
|
|
t.Fatalf("expected error for missing name, got nil")
|
|
}
|
|
}
|
|
|
|
func TestTargetService_Delete_Success(t *testing.T) {
|
|
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
|
ctx := context.Background()
|
|
|
|
// Create initial target
|
|
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target To Delete", Type: domain.TargetTypeNGINX}
|
|
targetRepo.AddTarget(target)
|
|
|
|
// Delete it
|
|
err := svc.Delete(ctx, "t-1", "test-actor")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Verify deletion
|
|
if _, ok := targetRepo.Targets["t-1"]; ok {
|
|
t.Errorf("target should be deleted from repo")
|
|
}
|
|
|
|
// Verify audit event
|
|
if len(auditRepo.Events) == 0 {
|
|
t.Fatalf("expected audit event, got none")
|
|
}
|
|
|
|
lastEvent := auditRepo.Events[len(auditRepo.Events)-1]
|
|
if lastEvent.Action != "delete_target" {
|
|
t.Errorf("expected action 'delete_target', got %s", lastEvent.Action)
|
|
}
|
|
}
|
|
|
|
func TestTargetService_Delete_RepoError(t *testing.T) {
|
|
svc, targetRepo, _, _ := newTestTargetService()
|
|
ctx := context.Background()
|
|
|
|
targetRepo.DeleteErr = errNotFound
|
|
|
|
err := svc.Delete(ctx, "t-1", "test-actor")
|
|
if err == nil {
|
|
t.Fatalf("expected error from repo, got nil")
|
|
}
|
|
}
|
|
|
|
func TestTargetService_ListTargets_Success(t *testing.T) {
|
|
svc, targetRepo, _, _ := newTestTargetService()
|
|
|
|
// Add targets
|
|
target1 := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
|
target2 := &domain.DeploymentTarget{ID: "t-2", Name: "Target 2", Type: domain.TargetTypeApache}
|
|
targetRepo.AddTarget(target1)
|
|
targetRepo.AddTarget(target2)
|
|
|
|
// Call handler-interface method
|
|
targets, total, err := svc.ListTargets(1, 50)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(targets) != 2 {
|
|
t.Errorf("expected 2 targets, got %d", len(targets))
|
|
}
|
|
|
|
if total != 2 {
|
|
t.Errorf("expected total=2, got %d", total)
|
|
}
|
|
}
|
|
|
|
func TestTargetService_GetTarget_Success(t *testing.T) {
|
|
svc, targetRepo, _, _ := newTestTargetService()
|
|
|
|
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
|
targetRepo.AddTarget(target)
|
|
|
|
result, err := svc.GetTarget("t-1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if result.ID != "t-1" || result.Name != "Target 1" {
|
|
t.Errorf("expected target t-1/Target 1, got %s/%s", result.ID, result.Name)
|
|
}
|
|
}
|
|
|
|
func TestTargetService_CreateTarget_Success(t *testing.T) {
|
|
svc, targetRepo, _, _ := newTestTargetService()
|
|
|
|
target := domain.DeploymentTarget{
|
|
Name: "New Target",
|
|
Type: domain.TargetTypeNGINX,
|
|
}
|
|
|
|
result, err := svc.CreateTarget(target)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if result.ID == "" || len(result.ID) < 7 || result.ID[:6] != "target" {
|
|
t.Errorf("expected ID to start with 'target', got %s", result.ID)
|
|
}
|
|
|
|
// Verify it was stored
|
|
if _, ok := targetRepo.Targets[result.ID]; !ok {
|
|
t.Fatalf("target not stored in repo")
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
// Create initial target
|
|
target := &domain.DeploymentTarget{ID: "t-1", Name: "Old Name", Type: domain.TargetTypeNGINX}
|
|
targetRepo.AddTarget(target)
|
|
|
|
// Update it
|
|
updated := domain.DeploymentTarget{
|
|
Name: "New Name",
|
|
Type: domain.TargetTypeApache,
|
|
}
|
|
|
|
result, err := svc.UpdateTarget("t-1", updated)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if result.Name != "New Name" {
|
|
t.Errorf("expected name 'New Name', got %s", result.Name)
|
|
}
|
|
}
|
|
|
|
func TestTargetService_DeleteTarget_Success(t *testing.T) {
|
|
svc, targetRepo, _, _ := newTestTargetService()
|
|
|
|
// Create initial target
|
|
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target To Delete", Type: domain.TargetTypeNGINX}
|
|
targetRepo.AddTarget(target)
|
|
|
|
// Delete it
|
|
err := svc.DeleteTarget("t-1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Verify deletion
|
|
if _, ok := targetRepo.Targets["t-1"]; ok {
|
|
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")
|
|
}
|
|
}
|