Files
certctl/internal/service/target_test.go
T
shankar0123 f549a7aa79 security: fail closed when CERTCTL_CONFIG_ENCRYPTION_KEY is unset (fixes C-2)
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.
2026-04-16 21:10:40 +00:00

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")
}
}