Files
certctl/internal/api/handler/agent_handler_test.go
T
Shankar Reddy 49002c8cba Close I-004 (agent hard-delete cascades targets) coverage-gap finding
Operator decision answered as full soft-delete with optional forced
cascade — hard-delete is not reachable from any public surface. Prior
to this commit, DELETE /agents/{id} ran a plain `DELETE FROM agents`
whose schema-level `ON DELETE CASCADE` on deployment_targets.agent_id
silently wiped every target, orphaning certs and aborting in-flight
jobs. The finding closure reshapes the agent-removal contract around
soft retirement with explicit preflight counts, an opt-in cascade
gated by a mandatory reason, and unconditional protection for the
four reserved sentinel agents used by discovery sources.

Schema — migration 000015:
  migrations/000015_agent_retire.up.sql flips
  deployment_targets_agent_id_fkey from ON DELETE CASCADE to ON DELETE
  RESTRICT, so a stray `DELETE FROM agents` now errors at the DB
  boundary instead of quietly destroying targets. Both `agents` and
  `deployment_targets` grow a retired_at TIMESTAMPTZ + retired_reason
  TEXT pair (TEXT not VARCHAR so operator comments are never
  truncated), indexed via partial indexes WHERE retired_at IS NOT
  NULL. The migration is self-healing (ADD COLUMN IF NOT EXISTS, DROP
  CONSTRAINT IF EXISTS then ADD CONSTRAINT, CREATE INDEX IF NOT
  EXISTS) so repeated runs against partially-migrated databases
  converge. migrations/000015_agent_retire.down.sql restores CASCADE
  and drops the new columns for clean rollback. A dedicated
  repository-layer testcontainers test
  (internal/repository/postgres/migration_000015_test.go) asserts the
  before/after FK action, column presence, index presence, and
  round-trip idempotency under up→down→up.

Domain — sentinel guard + dependency counts:
  internal/domain/connector.go gains IsRetired() on Agent, the
  exported SentinelAgentIDs slice listing server-scanner,
  cloud-aws-sm, cloud-azure-kv, cloud-gcp-sm verbatim (matching the
  four reserved IDs documented in CLAUDE.md and created at startup in
  cmd/server/main.go), IsSentinelAgent(id string) predicate,
  AgentDependencyCounts{ActiveTargets, ActiveCertificates,
  PendingJobs} with a HasDependencies() method, and ActorTypeAgent /
  ActorTypeSystem enum values used by audit emission downstream.
  Coverage locked down by internal/domain/connector_test.go.

Service — 8-step ordered contract:
  internal/service/agent_retire.go:RetireAgent(ctx, id, actor,
  opts{Force, Reason}) enforces a fixed execution order:
  (1) sentinel guard — IsSentinelAgent(id) returns ErrAgentIsSentinel
      unconditionally; force=true does NOT bypass it.
  (2) fetch — ErrAgentNotFound on miss.
  (3) idempotency — if IsRetired() already, return
      AgentRetirementResult{AlreadyRetired: true} with no new audit
      event and no state change (safe to replay from flaky clients).
  (4) preflight counts — collectAgentDependencyCounts runs
      ActiveTargets, ActiveCertificates, PendingJobs sequentially
      (not in parallel; keeps the per-query timeout predictable and
      matches the repo's existing call-chain shape).
  (5) force-reason guard — opts.Force=true with empty Reason returns
      ErrForceReasonRequired (wired into the 400 status surface).
  (6) dependency guard — HasDependencies() with opts.Force=false
      returns BlockedByDependenciesError{Counts} (wired into the 409
      body with per-bucket counts).
  (7) mutation — single pinned retiredAt := time.Now(); agent
      retirement first, then cascade target retirement if opts.Force,
      all under the repo's single transaction so the two retired_at
      stamps match to the second.
  (8) best-effort audit — agent_retired always; agent_retirement_
      cascaded additionally on the force path. Actor is whatever the
      handler resolves from the request; actor type is mapped by
      resolveActorType (system/agent-prefix→Agent/else→User). Audit
      emission failures are logged via slog.Error but do not abort
      the retirement (matches the house convention used by every
      other scheduler-emitted event).

  BlockedByDependenciesError implements Error() as
  "active_targets=%d, active_certificates=%d, pending_jobs=%d" and
  Unwrap() → ErrBlockedByDependencies. The single struct satisfies
  errors.Is via Unwrap (used by scheduler-level tests) and errors.As
  via the concrete type (used by the handler to fish out Counts for
  the 409 body). ListRetiredAgents(page, perPage) adds a separate
  paginated accessor with page<1→1 and perPage<1→50 normalization so
  retired rows are queryable without polluting the default agent
  listing.

  Sentinel guard coverage is asymmetric by design: all four reserved
  IDs are protected, and force=true cannot override. Regression tests
  in internal/service/agent_retire_test.go assert each of the eight
  steps in order, plus sentinel bypass attempts and idempotency
  replay.

Handler + router — status-code surface:
  internal/api/handler/agents.go:RetireAgent exposes seven status
  codes on DELETE /agents/{id}:
    200 on a fresh retirement (body echoes AgentRetirementResult).
    204 on idempotent replay (AlreadyRetired=true; no new audit).
    400 on ErrForceReasonRequired.
    403 on ErrAgentIsSentinel.
    404 on ErrAgentNotFound.
    409 on BlockedByDependenciesError, with a custom body shape
        {error, counts{active_targets, active_certificates,
        pending_jobs}} that bypasses the default ErrorWithRequestID
        envelope so callers get the per-bucket numbers directly.
    500 on any other error.
  Heartbeat HandleHeartbeat returns 410 Gone when the agent is
  retired (ErrAgentRetired), signalling the agent to shut down.
  Query params `force=true` and `reason=<text>` drive the cascade
  path; both are forwarded as url.Values through the new MCP
  transport.

  internal/api/router/router.go registers GET /api/v1/agents/retired
  literal-path BEFORE /api/v1/agents/{id} — Go 1.22 ServeMux's
  literal-beats-pattern-var precedence routes "retired" to the
  paginated retired-agents listing instead of fetching a hypothetical
  agent named "retired".

Agent binary — clean shutdown on 410:
  cmd/agent/main.go gains the ErrAgentRetired sentinel, a
  retiredOnce sync.Once, and a retiredSignal chan struct{}. A
  markRetired(source, statusCode, body) helper closes the channel
  exactly once; the Run() select loop observes the close and returns
  ErrAgentRetired; main() matches via errors.Is(err, ErrAgentRetired)
  and exits cleanly instead of spinning in the heartbeat retry loop.
  The 410 Gone surface is therefore terminal for the agent process.

MCP transport:
  internal/mcp/client.go adds Client.DeleteWithQuery(path, query),
  a new additive transport method. Client.Delete is path-only; without
  this method the retire tool would silently drop `force` and `reason`,
  turning every cascade retire into a default soft-retire. The new
  method shares do()'s 204 normalization and 4xx/5xx error
  propagation so tool authors get one contract.
  internal/mcp/tools.go + internal/mcp/types.go expose the
  retire_agent tool with Force+Reason inputs wired through
  DeleteWithQuery.

CLI:
  cmd/cli/main.go + internal/cli/client.go add two CLI surfaces:
  `agents list --retired` (client-side strip of --retired then
  delegation to ListRetiredAgents, sharing --page/--per-page parsing
  with the default listing) and `agents retire <id> [--force --reason
  "…"]` (mirrors ErrForceReasonRequired — force without reason is
  rejected client-side before the request is sent). JSON + table
  output modes both honor the new columns.

Frontend:
  web/src/pages/AgentsPage.tsx surfaces retired/retire affordances.
  web/src/api/client.ts + web/src/api/types.ts expose the retire
  endpoint and the retired-listing. 4 new Vitest regression cases.

OpenAPI:
  api/openapi.yaml documents DELETE /agents/{id} with all seven
  status codes, 410 on heartbeat, and the 409 per-bucket body shape.

Regression coverage (six new test files, all green):
  internal/service/agent_retire_test.go           — 8-step contract + sentinel guards
  internal/api/handler/agent_retire_handler_test.go — 7-status-code surface + 410 heartbeat
  internal/mcp/retire_agent_test.go               — DeleteWithQuery wire-through
  internal/cli/agent_retire_test.go               — --retired listing + --force/--reason pairing
  internal/repository/postgres/migration_000015_test.go — FK flip + columns + indexes + up↔down
  internal/domain/connector_test.go               — IsRetired, IsSentinelAgent, SentinelAgentIDs, HasDependencies

Files:
  api/openapi.yaml                                — DELETE + 410 + 409 body shape
  cmd/agent/main.go                               — ErrAgentRetired, markRetired, retiredSignal
  cmd/cli/main.go                                 — handleAgents list/get/retire dispatch
  docs/architecture.md, docs/concepts.md,
    docs/testing-guide.md                         — retirement contract narrative
  internal/api/handler/agents.go                  — RetireAgent, status surface, 410 on heartbeat
  internal/api/handler/agent_handler_test.go      — extended coverage
  internal/api/handler/agent_retire_handler_test.go — new
  internal/api/router/router.go                   — /agents/retired before /agents/{id}
  internal/cli/agent_retire_test.go               — new
  internal/cli/client.go                          — ListRetiredAgents + RetireAgent
  internal/domain/connector.go                    — IsRetired, SentinelAgentIDs,
                                                    IsSentinelAgent, AgentDependencyCounts,
                                                    ActorTypeAgent/System
  internal/domain/connector_test.go               — new
  internal/integration/lifecycle_test.go          — retirement fixture
  internal/mcp/client.go                          — DeleteWithQuery additive transport
  internal/mcp/retire_agent_test.go               — new
  internal/mcp/tools.go, internal/mcp/types.go    — retire_agent tool + Force/Reason inputs
  internal/repository/interfaces.go               — AgentRepository retirement methods
  internal/repository/postgres/agent.go           — retire + cascade target retire + counts
  internal/repository/postgres/migration_000015_test.go — new
  internal/service/agent.go                       — wire into AgentService surface
  internal/service/agent_retire.go                — new 8-step contract
  internal/service/agent_retire_test.go           — new
  internal/service/deployment.go                  — skip retired agents
  internal/service/target.go                      — skip retired agents
  internal/service/testutil_test.go               — shared mocks extended
  migrations/000015_agent_retire.up.sql           — new
  migrations/000015_agent_retire.down.sql         — new
  web/src/api/client.ts, types.ts + tests         — retire endpoint wiring
  web/src/pages/AgentsPage.tsx                    — retire UI
2026-04-19 05:24:00 +00:00

896 lines
26 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
// MockAgentService is a mock implementation of AgentService interface.
type MockAgentService struct {
ListAgentsFn func(page, perPage int) ([]domain.Agent, int64, error)
GetAgentFn func(id string) (*domain.Agent, error)
RegisterAgentFn func(agent domain.Agent) (*domain.Agent, error)
HeartbeatFn func(agentID string, metadata *domain.AgentMetadata) error
CSRSubmitFn func(agentID string, csrPEM string) (string, error)
CSRSubmitForCertFn func(agentID string, certID string, csrPEM string) (string, error)
CertificatePickupFn func(agentID, certID string) (string, error)
GetWorkFn func(agentID string) ([]domain.Job, error)
GetWorkWithTargetsFn func(agentID string) ([]domain.WorkItem, error)
UpdateJobStatusFn func(agentID string, jobID string, status string, errMsg string) error
// I-004: soft-retirement hooks. Tests that don't set these receive nil
// results and nil errors, which mirrors the safest default (no-op) for
// unrelated suites that mock only the legacy surface.
RetireAgentFn func(agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error)
ListRetiredAgentsFn func(page, perPage int) ([]domain.Agent, int64, error)
}
func (m *MockAgentService) ListAgents(_ context.Context, page, perPage int) ([]domain.Agent, int64, error) {
if m.ListAgentsFn != nil {
return m.ListAgentsFn(page, perPage)
}
return nil, 0, nil
}
func (m *MockAgentService) GetAgent(_ context.Context, id string) (*domain.Agent, error) {
if m.GetAgentFn != nil {
return m.GetAgentFn(id)
}
return nil, nil
}
func (m *MockAgentService) RegisterAgent(_ context.Context, agent domain.Agent) (*domain.Agent, error) {
if m.RegisterAgentFn != nil {
return m.RegisterAgentFn(agent)
}
return nil, nil
}
func (m *MockAgentService) Heartbeat(_ context.Context, agentID string, metadata *domain.AgentMetadata) error {
if m.HeartbeatFn != nil {
return m.HeartbeatFn(agentID, metadata)
}
return nil
}
func (m *MockAgentService) CSRSubmit(_ context.Context, agentID string, csrPEM string) (string, error) {
if m.CSRSubmitFn != nil {
return m.CSRSubmitFn(agentID, csrPEM)
}
return "", nil
}
func (m *MockAgentService) CSRSubmitForCert(_ context.Context, agentID string, certID string, csrPEM string) (string, error) {
if m.CSRSubmitForCertFn != nil {
return m.CSRSubmitForCertFn(agentID, certID, csrPEM)
}
return "", nil
}
func (m *MockAgentService) CertificatePickup(_ context.Context, agentID, certID string) (string, error) {
if m.CertificatePickupFn != nil {
return m.CertificatePickupFn(agentID, certID)
}
return "", nil
}
func (m *MockAgentService) GetWork(_ context.Context, agentID string) ([]domain.Job, error) {
if m.GetWorkFn != nil {
return m.GetWorkFn(agentID)
}
return nil, nil
}
func (m *MockAgentService) GetWorkWithTargets(_ context.Context, agentID string) ([]domain.WorkItem, error) {
if m.GetWorkWithTargetsFn != nil {
return m.GetWorkWithTargetsFn(agentID)
}
return nil, nil
}
func (m *MockAgentService) UpdateJobStatus(_ context.Context, agentID string, jobID string, status string, errMsg string) error {
if m.UpdateJobStatusFn != nil {
return m.UpdateJobStatusFn(agentID, jobID, status, errMsg)
}
return nil
}
// RetireAgent is the I-004 soft-retirement entrypoint. Tests that don't set
// RetireAgentFn get a nil result + nil error, which is a no-op response that
// lets unrelated suites compile without caring about the retirement surface.
func (m *MockAgentService) RetireAgent(_ context.Context, agentID, actor string, force bool, reason string) (*service.AgentRetirementResult, error) {
if m.RetireAgentFn != nil {
return m.RetireAgentFn(agentID, actor, force, reason)
}
return nil, nil
}
// ListRetiredAgents returns retired rows for the retired-agents tab / audit
// views. Same zero-value default as RetireAgent for unrelated tests.
func (m *MockAgentService) ListRetiredAgents(_ context.Context, page, perPage int) ([]domain.Agent, int64, error) {
if m.ListRetiredAgentsFn != nil {
return m.ListRetiredAgentsFn(page, perPage)
}
return nil, 0, nil
}
// Test ListAgents - success case
func TestListAgents_Success(t *testing.T) {
now := time.Now()
agent1 := domain.Agent{
ID: "a-prod-001",
Name: "Production Agent",
Hostname: "prod-server-01",
Status: domain.AgentStatusOnline,
LastHeartbeatAt: &now,
RegisteredAt: now,
}
agent2 := domain.Agent{
ID: "a-prod-002",
Name: "API Agent",
Hostname: "api-server-01",
Status: domain.AgentStatusOnline,
LastHeartbeatAt: &now,
RegisteredAt: now,
}
mock := &MockAgentService{
ListAgentsFn: func(page, perPage int) ([]domain.Agent, int64, error) {
if page == 1 && perPage == 50 {
return []domain.Agent{agent1, agent2}, 2, nil
}
return nil, 0, nil
},
}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents?page=1&per_page=50", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListAgents(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var response PagedResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response.Total != 2 {
t.Errorf("expected total 2, got %d", response.Total)
}
}
// Test ListAgents - method not allowed
func TestListAgents_MethodNotAllowed(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListAgents(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
// Test ListAgents - service error
func TestListAgents_ServiceError(t *testing.T) {
mock := &MockAgentService{
ListAgentsFn: func(page, perPage int) ([]domain.Agent, int64, error) {
return nil, 0, ErrMockServiceFailed
},
}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListAgents(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
// Test GetAgent - success case
func TestGetAgent_Success(t *testing.T) {
now := time.Now()
agent := &domain.Agent{
ID: "a-prod-001",
Name: "Production Agent",
Hostname: "prod-server-01",
Status: domain.AgentStatusOnline,
LastHeartbeatAt: &now,
RegisteredAt: now,
}
mock := &MockAgentService{
GetAgentFn: func(id string) (*domain.Agent, error) {
if id == "a-prod-001" {
return agent, nil
}
return nil, ErrMockNotFound
},
}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetAgent(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var response domain.Agent
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response.ID != "a-prod-001" {
t.Errorf("expected ID a-prod-001, got %s", response.ID)
}
}
// Test GetAgent - not found
func TestGetAgent_NotFound(t *testing.T) {
mock := &MockAgentService{
GetAgentFn: func(id string) (*domain.Agent, error) {
return nil, ErrMockNotFound
},
}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nonexistent", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetAgent(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
}
}
// Test RegisterAgent - success case
func TestRegisterAgent_Success(t *testing.T) {
now := time.Now()
registered := &domain.Agent{
ID: "a-prod-001",
Name: "Production Agent",
Hostname: "prod-server-01",
Status: domain.AgentStatusOnline,
RegisteredAt: now,
}
mock := &MockAgentService{
RegisterAgentFn: func(agent domain.Agent) (*domain.Agent, error) {
return registered, nil
},
}
handler := NewAgentHandler(mock)
agentBody := domain.Agent{
Name: "Production Agent",
Hostname: "prod-server-01",
}
body, _ := json.Marshal(agentBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", bytes.NewReader(body))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RegisterAgent(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status %d, got %d", http.StatusCreated, w.Code)
}
var response domain.Agent
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response.ID != "a-prod-001" {
t.Errorf("expected ID a-prod-001, got %s", response.ID)
}
}
// Test RegisterAgent - invalid body
func TestRegisterAgent_InvalidBody(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", bytes.NewReader([]byte("invalid json")))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RegisterAgent(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
// Test Heartbeat - success case
func TestHeartbeat_Success(t *testing.T) {
mock := &MockAgentService{
HeartbeatFn: func(agentID string, metadata *domain.AgentMetadata) error {
if agentID == "a-prod-001" {
return nil
}
return ErrMockNotFound
},
}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/heartbeat", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.Heartbeat(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var response map[string]string
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response["status"] != "heartbeat_recorded" {
t.Errorf("expected status 'heartbeat_recorded', got %s", response["status"])
}
}
// Test Heartbeat - service error
func TestHeartbeat_ServiceError(t *testing.T) {
mock := &MockAgentService{
HeartbeatFn: func(agentID string, metadata *domain.AgentMetadata) error {
return ErrMockServiceFailed
},
}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/heartbeat", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.Heartbeat(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
// Test AgentCSRSubmit - with certificate_id
func TestAgentCSRSubmit_WithCertificateID(t *testing.T) {
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nMIIC...\n-----END CERTIFICATE REQUEST-----"
mock := &MockAgentService{
CSRSubmitForCertFn: func(agentID string, certID string, csrPEM string) (string, error) {
if agentID == "a-prod-001" && certID == "mc-prod-001" {
return "csr_submitted", nil
}
return "", ErrMockNotFound
},
}
handler := NewAgentHandler(mock)
reqBody := map[string]string{
"csr_pem": csrPEM,
"certificate_id": "mc-prod-001",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/csr", bytes.NewReader(body))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.AgentCSRSubmit(w, req)
if w.Code != http.StatusAccepted {
t.Errorf("expected status %d, got %d", http.StatusAccepted, w.Code)
}
var response map[string]string
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response["status"] != "csr_submitted" {
t.Errorf("expected status 'csr_submitted', got %s", response["status"])
}
}
// Test AgentCSRSubmit - without certificate_id
func TestAgentCSRSubmit_WithoutCertificateID(t *testing.T) {
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nMIIC...\n-----END CERTIFICATE REQUEST-----"
mock := &MockAgentService{
CSRSubmitFn: func(agentID string, csrPEM string) (string, error) {
if agentID == "a-prod-001" {
return "csr_submitted", nil
}
return "", ErrMockNotFound
},
}
handler := NewAgentHandler(mock)
reqBody := map[string]string{
"csr_pem": csrPEM,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/csr", bytes.NewReader(body))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.AgentCSRSubmit(w, req)
if w.Code != http.StatusAccepted {
t.Errorf("expected status %d, got %d", http.StatusAccepted, w.Code)
}
}
// Test AgentCSRSubmit - missing CSR PEM
func TestAgentCSRSubmit_MissingCSRPEM(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
reqBody := map[string]string{
"certificate_id": "mc-prod-001",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/csr", bytes.NewReader(body))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.AgentCSRSubmit(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
// Test AgentCSRSubmit - invalid body
func TestAgentCSRSubmit_InvalidBody(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/csr", bytes.NewReader([]byte("invalid")))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.AgentCSRSubmit(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
// Test AgentCertificatePickup - success case
func TestAgentCertificatePickup_Success(t *testing.T) {
certPEM := "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"
mock := &MockAgentService{
CertificatePickupFn: func(agentID, certID string) (string, error) {
if agentID == "a-prod-001" && certID == "mc-prod-001" {
return certPEM, nil
}
return "", ErrMockNotFound
},
}
handler := NewAgentHandler(mock)
// Path structure: /api/v1/agents/{agent_id}/certificates/{cert_id}
// After trim and split: parts[0]="agent_id", parts[1]="certificates", parts[2]="cert_id", parts[3]=""
// Note: handler checks len(parts) < 4, so we need the trailing slash
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001/certificates/mc-prod-001/", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.AgentCertificatePickup(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d (body: %s)", http.StatusOK, w.Code, w.Body.String())
}
var response map[string]string
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response["certificate_pem"] != certPEM {
t.Errorf("expected cert PEM %s, got %s", certPEM, response["certificate_pem"])
}
}
// Test AgentCertificatePickup - not found
func TestAgentCertificatePickup_NotFound(t *testing.T) {
mock := &MockAgentService{
CertificatePickupFn: func(agentID, certID string) (string, error) {
return "", ErrMockNotFound
},
}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001/certificates/nonexistent/", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.AgentCertificatePickup(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d (body: %s)", http.StatusNotFound, w.Code, w.Body.String())
}
}
// Test AgentGetWork - success with items
func TestAgentGetWork_Success(t *testing.T) {
workItem := domain.WorkItem{
ID: "j-deploy-001",
Type: domain.JobTypeDeployment,
CertificateID: "mc-prod-001",
TargetID: stringPtr("t-nginx-001"),
TargetType: "NGINX",
Status: domain.JobStatusPending,
}
mock := &MockAgentService{
GetWorkWithTargetsFn: func(agentID string) ([]domain.WorkItem, error) {
if agentID == "a-prod-001" {
return []domain.WorkItem{workItem}, nil
}
return nil, ErrMockNotFound
},
}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001/work", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.AgentGetWork(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var response map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response["count"] != float64(1) {
t.Errorf("expected count 1, got %v", response["count"])
}
}
// Test AgentGetWork - no work items
func TestAgentGetWork_NoItems(t *testing.T) {
mock := &MockAgentService{
GetWorkWithTargetsFn: func(agentID string) ([]domain.WorkItem, error) {
return nil, nil
},
}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001/work", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.AgentGetWork(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var response map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response["count"] != float64(0) {
t.Errorf("expected count 0, got %v", response["count"])
}
}
// Test AgentGetWork - service error
func TestAgentGetWork_ServiceError(t *testing.T) {
mock := &MockAgentService{
GetWorkWithTargetsFn: func(agentID string) ([]domain.WorkItem, error) {
return nil, ErrMockServiceFailed
},
}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a-prod-001/work", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.AgentGetWork(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
// Test AgentReportJobStatus - success case
func TestAgentReportJobStatus_Success(t *testing.T) {
mock := &MockAgentService{
UpdateJobStatusFn: func(agentID string, jobID string, status string, errMsg string) error {
if agentID == "a-prod-001" && jobID == "j-deploy-001" && status == "Completed" {
return nil
}
return ErrMockNotFound
},
}
handler := NewAgentHandler(mock)
statusReq := map[string]string{
"status": "Completed",
}
body, _ := json.Marshal(statusReq)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/jobs/j-deploy-001/status", bytes.NewReader(body))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.AgentReportJobStatus(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var response map[string]string
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response["status"] != "updated" {
t.Errorf("expected status 'updated', got %s", response["status"])
}
}
// Test AgentReportJobStatus - with error message
func TestAgentReportJobStatus_WithError(t *testing.T) {
mock := &MockAgentService{
UpdateJobStatusFn: func(agentID string, jobID string, status string, errMsg string) error {
if agentID == "a-prod-001" && jobID == "j-deploy-001" && status == "Failed" && errMsg == "timeout" {
return nil
}
return ErrMockNotFound
},
}
handler := NewAgentHandler(mock)
statusReq := map[string]string{
"status": "Failed",
"error": "timeout",
}
body, _ := json.Marshal(statusReq)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/jobs/j-deploy-001/status", bytes.NewReader(body))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.AgentReportJobStatus(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
}
// Test AgentReportJobStatus - missing status
func TestAgentReportJobStatus_MissingStatus(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
statusReq := map[string]string{}
body, _ := json.Marshal(statusReq)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/jobs/j-deploy-001/status", bytes.NewReader(body))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.AgentReportJobStatus(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
// Test AgentReportJobStatus - invalid body
func TestAgentReportJobStatus_InvalidBody(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/jobs/j-deploy-001/status", bytes.NewReader([]byte("invalid")))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.AgentReportJobStatus(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
// Test ListAgents - invalid pagination parameters
func TestListAgents_InvalidPagination(t *testing.T) {
mock := &MockAgentService{
ListAgentsFn: func(page, perPage int) ([]domain.Agent, int64, error) {
// Should default to page=1, perPage=50 if invalid
if page == 1 && perPage == 50 {
return []domain.Agent{}, 0, nil
}
return nil, 0, nil
},
}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents?page=invalid&per_page=invalid", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListAgents(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
}
// Test GetAgent - empty ID
func TestGetAgent_EmptyID(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetAgent(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
// Test RegisterAgent - service error
func TestRegisterAgent_ServiceError(t *testing.T) {
mock := &MockAgentService{
RegisterAgentFn: func(agent domain.Agent) (*domain.Agent, error) {
return nil, ErrMockServiceFailed
},
}
handler := NewAgentHandler(mock)
agentBody := domain.Agent{
Name: "Production Agent",
Hostname: "prod-server-01",
}
body, _ := json.Marshal(agentBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", bytes.NewReader(body))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RegisterAgent(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
// Test Heartbeat - empty agent ID
func TestHeartbeat_EmptyAgentID(t *testing.T) {
mock := &MockAgentService{}
handler := NewAgentHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents//heartbeat", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.Heartbeat(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
// Test AgentCSRSubmit - service error
func TestAgentCSRSubmit_ServiceError(t *testing.T) {
mock := &MockAgentService{
CSRSubmitFn: func(agentID string, csrPEM string) (string, error) {
return "", ErrMockServiceFailed
},
}
handler := NewAgentHandler(mock)
reqBody := map[string]string{
"csr_pem": "-----BEGIN CERTIFICATE REQUEST-----\nMIIC...\n-----END CERTIFICATE REQUEST-----",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/csr", bytes.NewReader(body))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.AgentCSRSubmit(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
// Test AgentReportJobStatus - service error
func TestAgentReportJobStatus_ServiceError(t *testing.T) {
mock := &MockAgentService{
UpdateJobStatusFn: func(agentID string, jobID string, status string, errMsg string) error {
return ErrMockServiceFailed
},
}
handler := NewAgentHandler(mock)
statusReq := map[string]string{
"status": "Completed",
}
body, _ := json.Marshal(statusReq)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a-prod-001/jobs/j-deploy-001/status", bytes.NewReader(body))
req = req.WithContext(contextWithRequestID())
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.AgentReportJobStatus(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
// Helper function to create a string pointer
func stringPtr(s string) *string {
return &s
}