Files
certctl/internal/service/testutil_test.go
T
shankar0123 0725713e19 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

1315 lines
34 KiB
Go

package service
import (
"context"
"database/sql"
"errors"
"sort"
"sync"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
var errNotFound = errors.New("not found")
// testEncryptionKey is a deterministic passphrase for unit tests that
// exercise IssuerService/TargetService write paths. After the C-2 remediation
// these services fail closed when no key is configured, so happy-path tests
// must supply a real passphrase. M-8 reshaped the type from []byte to string
// because services now hold the raw passphrase and delegate PBKDF2 to
// crypto.EncryptIfKeySet / crypto.DecryptIfKeySet (which apply a fresh random
// salt per ciphertext). Using a constant keeps wire-format assertions stable
// across runs.
var testEncryptionKey = "0123456789abcdef0123456789abcdef"
// mockCertRepo is a test implementation of CertificateRepository
type mockCertRepo struct {
Certs map[string]*domain.ManagedCertificate
Versions map[string][]*domain.CertificateVersion
CreateErr error
UpdateErr error
GetErr error
ListErr error
ListVersionsErr error
ListVersionsResult []*domain.CertificateVersion
CreateVersionErr error
ArchiveErr error
Updated []*domain.ManagedCertificate
MockGetExpiring []*domain.ManagedCertificate
}
func (m *mockCertRepo) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
if m.ListErr != nil {
return nil, 0, m.ListErr
}
var certs []*domain.ManagedCertificate
for _, c := range m.Certs {
certs = append(certs, c)
}
return certs, len(certs), nil
}
func (m *mockCertRepo) Get(ctx context.Context, id string) (*domain.ManagedCertificate, error) {
if m.GetErr != nil {
return nil, m.GetErr
}
cert, ok := m.Certs[id]
if !ok {
return nil, errNotFound
}
return cert, nil
}
func (m *mockCertRepo) Create(ctx context.Context, cert *domain.ManagedCertificate) error {
if m.CreateErr != nil {
return m.CreateErr
}
m.Certs[cert.ID] = cert
return nil
}
func (m *mockCertRepo) Update(ctx context.Context, cert *domain.ManagedCertificate) error {
if m.UpdateErr != nil {
return m.UpdateErr
}
m.Certs[cert.ID] = cert
m.Updated = append(m.Updated, cert)
return nil
}
func (m *mockCertRepo) Archive(ctx context.Context, id string) error {
if m.ArchiveErr != nil {
return m.ArchiveErr
}
cert, ok := m.Certs[id]
if !ok {
return errNotFound
}
cert.Status = domain.CertificateStatusArchived
return nil
}
func (m *mockCertRepo) ListVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error) {
if m.ListVersionsErr != nil {
return nil, m.ListVersionsErr
}
if m.ListVersionsResult != nil {
return m.ListVersionsResult, nil
}
return m.Versions[certID], nil
}
func (m *mockCertRepo) CreateVersion(ctx context.Context, version *domain.CertificateVersion) error {
if m.CreateVersionErr != nil {
return m.CreateVersionErr
}
m.Versions[version.CertificateID] = append(m.Versions[version.CertificateID], version)
return nil
}
func (m *mockCertRepo) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) {
// Return MockGetExpiring if set, for test control
if m.MockGetExpiring != nil {
return m.MockGetExpiring, nil
}
var expiring []*domain.ManagedCertificate
for _, c := range m.Certs {
if c.ExpiresAt.Before(before) {
expiring = append(expiring, c)
}
}
return expiring, nil
}
func (m *mockCertRepo) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
versions := m.Versions[certID]
if len(versions) == 0 {
return nil, errNotFound
}
return versions[len(versions)-1], nil
}
// GetByIssuerAndSerial emulates the PostgreSQL JOIN:
// SELECT mc.* FROM managed_certificates mc JOIN certificate_versions cv
// ON cv.certificate_id = mc.id WHERE mc.issuer_id = $1 AND cv.serial_number = $2.
// Returns sql.ErrNoRows (the sentinel the real repo surfaces) when no match
// exists, so callers that branch on errors.Is(err, sql.ErrNoRows) behave the
// same in-memory as they do against PostgreSQL.
func (m *mockCertRepo) GetByIssuerAndSerial(ctx context.Context, issuerID, serial string) (*domain.ManagedCertificate, error) {
for _, cert := range m.Certs {
if cert.IssuerID != issuerID {
continue
}
for _, v := range m.Versions[cert.ID] {
if v.SerialNumber == serial {
return cert, nil
}
}
}
return nil, sql.ErrNoRows
}
func (m *mockCertRepo) AddCert(cert *domain.ManagedCertificate) {
m.Certs[cert.ID] = cert
}
// mockJobRepo is a test implementation of JobRepository
type mockJobRepo struct {
mu sync.Mutex
Jobs map[string]*domain.Job
StatusUpdates map[string]domain.JobStatus
CreateErr error
UpdateErr error
UpdateErrorByID map[string]error
UpdateErrorByIDMu sync.Mutex
UpdateStatusErr error
GetErr error
ListErr error
ListByStatusErr error
DeleteErr error
ListTimedOutErr error
Updated []*domain.Job
}
func (m *mockJobRepo) List(ctx context.Context) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
var jobs []*domain.Job
for _, j := range m.Jobs {
jobs = append(jobs, j)
}
return jobs, nil
}
func (m *mockJobRepo) Get(ctx context.Context, id string) (*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.GetErr != nil {
return nil, m.GetErr
}
job, ok := m.Jobs[id]
if !ok {
return nil, errNotFound
}
return job, nil
}
func (m *mockJobRepo) Create(ctx context.Context, job *domain.Job) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.CreateErr != nil {
return m.CreateErr
}
m.Jobs[job.ID] = job
return nil
}
func (m *mockJobRepo) Update(ctx context.Context, job *domain.Job) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.UpdateErr != nil {
return m.UpdateErr
}
// Check per-ID error injection
m.UpdateErrorByIDMu.Lock()
idErr, ok := m.UpdateErrorByID[job.ID]
m.UpdateErrorByIDMu.Unlock()
if ok && idErr != nil {
return idErr
}
m.Jobs[job.ID] = job
m.Updated = append(m.Updated, job)
return nil
}
func (m *mockJobRepo) Delete(ctx context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.DeleteErr != nil {
return m.DeleteErr
}
delete(m.Jobs, id)
return nil
}
func (m *mockJobRepo) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListByStatusErr != nil {
return nil, m.ListByStatusErr
}
var jobs []*domain.Job
for _, j := range m.Jobs {
if j.Status == status {
jobs = append(jobs, j)
}
}
return jobs, nil
}
func (m *mockJobRepo) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
var jobs []*domain.Job
for _, j := range m.Jobs {
if j.CertificateID == certID {
jobs = append(jobs, j)
}
}
return jobs, nil
}
func (m *mockJobRepo) UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.UpdateStatusErr != nil {
return m.UpdateStatusErr
}
job, ok := m.Jobs[id]
if !ok {
return errNotFound
}
job.Status = status
if errMsg != "" {
job.LastError = &errMsg
}
m.StatusUpdates[id] = status
return nil
}
func (m *mockJobRepo) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
var jobs []*domain.Job
for _, j := range m.Jobs {
if j.Type == jobType && j.Status == domain.JobStatusPending {
jobs = append(jobs, j)
}
}
return jobs, nil
}
func (m *mockJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
var result []*domain.Job
for _, j := range m.Jobs {
if j.AgentID != nil && *j.AgentID == agentID {
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
result = append(result, j)
} else if j.Status == domain.JobStatusAwaitingCSR {
result = append(result, j)
}
}
}
return result, nil
}
// ClaimPendingJobs simulates the H-6 atomic claim semantics: matching rows are transitioned
// Pending → Running before being returned. The in-memory mock has no concurrency primitives
// beyond the existing mutex, which is sufficient for single-goroutine service tests.
func (m *mockJobRepo) ClaimPendingJobs(ctx context.Context, jobType domain.JobType, limit int) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
var claimed []*domain.Job
for _, j := range m.Jobs {
if j.Status != domain.JobStatusPending {
continue
}
if jobType != "" && j.Type != jobType {
continue
}
j.Status = domain.JobStatusRunning
claimed = append(claimed, j)
if limit > 0 && len(claimed) >= limit {
break
}
}
return claimed, nil
}
// ClaimPendingByAgentID simulates the H-6 per-agent claim: Pending deployment rows scoped
// to the agent flip to Running; AwaitingCSR rows are returned but keep their state.
func (m *mockJobRepo) ClaimPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
var result []*domain.Job
for _, j := range m.Jobs {
if j.AgentID == nil || *j.AgentID != agentID {
continue
}
switch {
case j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment:
j.Status = domain.JobStatusRunning
result = append(result, j)
case j.Status == domain.JobStatusAwaitingCSR:
result = append(result, j)
}
}
return result, nil
}
// ListTimedOutAwaitingJobs returns jobs stuck in AwaitingCSR/AwaitingApproval past the
// respective cutoffs. I-003 coverage-gap closure.
func (m *mockJobRepo) ListTimedOutAwaitingJobs(ctx context.Context, csrCutoff, approvalCutoff time.Time) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListTimedOutErr != nil {
return nil, m.ListTimedOutErr
}
var jobs []*domain.Job
for _, j := range m.Jobs {
switch j.Status {
case domain.JobStatusAwaitingCSR:
if j.CreatedAt.Before(csrCutoff) {
jobs = append(jobs, j)
}
case domain.JobStatusAwaitingApproval:
if j.CreatedAt.Before(approvalCutoff) {
jobs = append(jobs, j)
}
}
}
return jobs, nil
}
func (m *mockJobRepo) AddJob(job *domain.Job) {
m.mu.Lock()
defer m.mu.Unlock()
m.Jobs[job.ID] = job
}
// mockNotifRepo is a test implementation of NotificationRepository
type mockNotifRepo struct {
mu sync.Mutex
Notifications []*domain.NotificationEvent
CreateErr error
ListErr error
UpdateErr error
}
func (m *mockNotifRepo) Create(ctx context.Context, notif *domain.NotificationEvent) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.CreateErr != nil {
return m.CreateErr
}
m.Notifications = append(m.Notifications, notif)
return nil
}
func (m *mockNotifRepo) List(ctx context.Context, filter *repository.NotificationFilter) ([]*domain.NotificationEvent, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
return m.Notifications, nil
}
func (m *mockNotifRepo) UpdateStatus(ctx context.Context, id string, status string, sentAt time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.UpdateErr != nil {
return m.UpdateErr
}
for _, n := range m.Notifications {
if n.ID == id {
n.Status = status
return nil
}
}
return errNotFound
}
func (m *mockNotifRepo) AddNotification(notif *domain.NotificationEvent) {
m.mu.Lock()
defer m.mu.Unlock()
m.Notifications = append(m.Notifications, notif)
}
// mockAuditRepo is a test implementation of AuditRepository
type mockAuditRepo struct {
mu sync.Mutex
Events []*domain.AuditEvent
CreateErr error
ListErr error
}
func (m *mockAuditRepo) Create(ctx context.Context, event *domain.AuditEvent) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.CreateErr != nil {
return m.CreateErr
}
m.Events = append(m.Events, event)
return nil
}
func (m *mockAuditRepo) List(ctx context.Context, filter *repository.AuditFilter) ([]*domain.AuditEvent, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
// Apply filtering like the real repo
var filtered []*domain.AuditEvent
for _, e := range m.Events {
if filter != nil {
if filter.ResourceType != "" && e.ResourceType != filter.ResourceType {
continue
}
if filter.ResourceID != "" && e.ResourceID != filter.ResourceID {
continue
}
if filter.Actor != "" && e.Actor != filter.Actor {
continue
}
if !filter.From.IsZero() && e.Timestamp.Before(filter.From) {
continue
}
if !filter.To.IsZero() && e.Timestamp.After(filter.To) {
continue
}
}
filtered = append(filtered, e)
}
return filtered, nil
}
func (m *mockAuditRepo) AddEvent(event *domain.AuditEvent) {
m.mu.Lock()
defer m.mu.Unlock()
m.Events = append(m.Events, event)
}
// mockPolicyRepo is a test implementation of PolicyRepository
type mockPolicyRepo struct {
Rules map[string]*domain.PolicyRule
Violations []*domain.PolicyViolation
CreateRuleErr error
UpdateRuleErr error
DeleteRuleErr error
GetRuleErr error
ListRulesErr error
CreateViolationErr error
ListViolationsErr error
}
func (m *mockPolicyRepo) ListRules(ctx context.Context) ([]*domain.PolicyRule, error) {
if m.ListRulesErr != nil {
return nil, m.ListRulesErr
}
var rules []*domain.PolicyRule
for _, r := range m.Rules {
rules = append(rules, r)
}
return rules, nil
}
func (m *mockPolicyRepo) GetRule(ctx context.Context, id string) (*domain.PolicyRule, error) {
if m.GetRuleErr != nil {
return nil, m.GetRuleErr
}
rule, ok := m.Rules[id]
if !ok {
return nil, errNotFound
}
return rule, nil
}
func (m *mockPolicyRepo) CreateRule(ctx context.Context, rule *domain.PolicyRule) error {
if m.CreateRuleErr != nil {
return m.CreateRuleErr
}
m.Rules[rule.ID] = rule
return nil
}
func (m *mockPolicyRepo) UpdateRule(ctx context.Context, rule *domain.PolicyRule) error {
if m.UpdateRuleErr != nil {
return m.UpdateRuleErr
}
m.Rules[rule.ID] = rule
return nil
}
func (m *mockPolicyRepo) DeleteRule(ctx context.Context, id string) error {
if m.DeleteRuleErr != nil {
return m.DeleteRuleErr
}
delete(m.Rules, id)
return nil
}
func (m *mockPolicyRepo) CreateViolation(ctx context.Context, violation *domain.PolicyViolation) error {
if m.CreateViolationErr != nil {
return m.CreateViolationErr
}
m.Violations = append(m.Violations, violation)
return nil
}
func (m *mockPolicyRepo) ListViolations(ctx context.Context, filter *repository.AuditFilter) ([]*domain.PolicyViolation, error) {
if m.ListViolationsErr != nil {
return nil, m.ListViolationsErr
}
return m.Violations, nil
}
func (m *mockPolicyRepo) AddRule(rule *domain.PolicyRule) {
m.Rules[rule.ID] = rule
}
// mockRenewalPolicyRepo is a test implementation of RenewalPolicyRepository
type mockRenewalPolicyRepo struct {
Policies map[string]*domain.RenewalPolicy
GetErr error
ListErr error
}
func (m *mockRenewalPolicyRepo) Get(ctx context.Context, id string) (*domain.RenewalPolicy, error) {
if m.GetErr != nil {
return nil, m.GetErr
}
policy, ok := m.Policies[id]
if !ok {
return nil, errNotFound
}
return policy, nil
}
func (m *mockRenewalPolicyRepo) List(ctx context.Context) ([]*domain.RenewalPolicy, error) {
if m.ListErr != nil {
return nil, m.ListErr
}
var policies []*domain.RenewalPolicy
for _, p := range m.Policies {
policies = append(policies, p)
}
return policies, nil
}
func (m *mockRenewalPolicyRepo) AddPolicy(policy *domain.RenewalPolicy) {
m.Policies[policy.ID] = policy
}
// mockAgentRepo is a test implementation of AgentRepository.
//
// I-004: ActiveTargetCounts / ActiveCertCounts / PendingJobCounts are keyed by
// agent ID and read back verbatim by the Count* methods — the retirement
// service's preflight pokes these maps to simulate "agent has N active
// deployments / M deployed certs / K pending jobs" without having to seed
// real target/cert/job rows across multiple mock repos. An unset key means
// zero, matching the production repo behavior on an agent with no deps.
type mockAgentRepo struct {
mu sync.Mutex
Agents map[string]*domain.Agent
HeartbeatUpdates map[string]time.Time
CreateErr error
UpdateErr error
DeleteErr error
GetErr error
ListErr error
UpdateHeartbeatErr error
GetByAPIKeyErr error
// I-004 preflight count seeds (read by CountActiveTargets etc.).
ActiveTargetCounts map[string]int
ActiveCertCounts map[string]int
PendingJobCounts map[string]int
// I-004 retirement write-path error seams. Let tests force a SoftRetire
// or RetireAgentWithCascade failure after preflight passed, so the
// service's error surfacing (wrap+return, skip audit, etc.) can be
// exercised without having to stand up a real PG connection.
SoftRetireErr error
RetireCascadeErr error
CountErr error
ListRetiredErr error
}
// List mirrors the production repo contract post-I-004: it returns only
// ACTIVE agents (RetiredAt == nil). Tests that seed a retired agent via
// AddAgent and then call a List-driven service method (e.g. ListAgents,
// MarkStaleAgentsOffline, stats dashboards) must not see the retired row
// here — otherwise the mock would pass while the real planner filters it
// out at the WHERE clause level. ListRetired is the companion method for
// explicit retired-only listing.
func (m *mockAgentRepo) List(ctx context.Context) ([]*domain.Agent, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
var agents []*domain.Agent
for _, a := range m.Agents {
if a.RetiredAt != nil {
continue
}
agents = append(agents, a)
}
return agents, nil
}
func (m *mockAgentRepo) Get(ctx context.Context, id string) (*domain.Agent, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.GetErr != nil {
return nil, m.GetErr
}
agent, ok := m.Agents[id]
if !ok {
return nil, errNotFound
}
return agent, nil
}
func (m *mockAgentRepo) Create(ctx context.Context, agent *domain.Agent) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.CreateErr != nil {
return m.CreateErr
}
m.Agents[agent.ID] = agent
return nil
}
func (m *mockAgentRepo) CreateIfNotExists(ctx context.Context, agent *domain.Agent) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.CreateErr != nil {
return false, m.CreateErr
}
if _, exists := m.Agents[agent.ID]; exists {
return false, nil
}
m.Agents[agent.ID] = agent
return true, nil
}
func (m *mockAgentRepo) Update(ctx context.Context, agent *domain.Agent) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.UpdateErr != nil {
return m.UpdateErr
}
m.Agents[agent.ID] = agent
return nil
}
func (m *mockAgentRepo) Delete(ctx context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.DeleteErr != nil {
return m.DeleteErr
}
delete(m.Agents, id)
return nil
}
func (m *mockAgentRepo) UpdateHeartbeat(ctx context.Context, id string, metadata *domain.AgentMetadata) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.UpdateHeartbeatErr != nil {
return m.UpdateHeartbeatErr
}
agent, ok := m.Agents[id]
if !ok {
return errNotFound
}
now := time.Now()
agent.LastHeartbeatAt = &now
m.HeartbeatUpdates[id] = now
return nil
}
func (m *mockAgentRepo) GetByAPIKey(ctx context.Context, keyHash string) (*domain.Agent, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.GetByAPIKeyErr != nil {
return nil, m.GetByAPIKeyErr
}
for _, a := range m.Agents {
if a.APIKeyHash == keyHash {
return a, nil
}
}
return nil, errNotFound
}
func (m *mockAgentRepo) AddAgent(agent *domain.Agent) {
m.mu.Lock()
defer m.mu.Unlock()
m.Agents[agent.ID] = agent
}
// ListRetired returns the paginated retired-agents slice + total count.
// Matches the production repo contract: RetiredAt != nil, sorted by
// RetiredAt DESC, page<1 → 1, perPage<1 → 50. Sort is done in-memory over
// the keyed map so the mock stays dependency-free. I-004.
func (m *mockAgentRepo) ListRetired(ctx context.Context, page, perPage int) ([]*domain.Agent, int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListRetiredErr != nil {
return nil, 0, m.ListRetiredErr
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
var retired []*domain.Agent
for _, a := range m.Agents {
if a.RetiredAt != nil {
retired = append(retired, a)
}
}
total := len(retired)
// Sort by RetiredAt DESC — most recent first. The real query uses the
// partial idx_agents_retired_at index; here we sort in Go.
sort.SliceStable(retired, func(i, j int) bool {
return retired[i].RetiredAt.After(*retired[j].RetiredAt)
})
// Apply page/perPage window.
offset := (page - 1) * perPage
if offset >= total {
return nil, total, nil
}
end := offset + perPage
if end > total {
end = total
}
return retired[offset:end], total, nil
}
// SoftRetire stamps RetiredAt + RetiredReason on the agent row. Mirrors
// the real repo's idempotent semantics: a row already retired is left
// untouched (zero-rows-affected is not an error). I-004 preserves
// retirement metadata across re-retire attempts — whoever retired it
// first owns the audit trail.
func (m *mockAgentRepo) SoftRetire(ctx context.Context, id string, retiredAt time.Time, reason string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.SoftRetireErr != nil {
return m.SoftRetireErr
}
agent, ok := m.Agents[id]
if !ok {
return errNotFound
}
if agent.RetiredAt != nil {
return nil // already retired — no-op
}
stamped := retiredAt
agent.RetiredAt = &stamped
stampedReason := reason
agent.RetiredReason = &stampedReason
return nil
}
// RetireAgentWithCascade stamps the agent row the same way SoftRetire
// does. The real repo also stamps every active deployment_targets row
// in the same transaction; the mock can't do that because targets live
// in mockTargetRepo, which the retirement service doesn't write to
// through this repo interface. Tests that need to assert cascade
// semantics on targets should seed mockTargetRepo directly and verify
// the service-layer audit event captured the cascade count. I-004.
func (m *mockAgentRepo) RetireAgentWithCascade(ctx context.Context, id string, retiredAt time.Time, reason string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.RetireCascadeErr != nil {
return m.RetireCascadeErr
}
agent, ok := m.Agents[id]
if !ok {
return errNotFound
}
if agent.RetiredAt != nil {
return nil // already retired — no-op (same as production transaction)
}
stamped := retiredAt
agent.RetiredAt = &stamped
stampedReason := reason
agent.RetiredReason = &stampedReason
return nil
}
// CountActiveTargets returns the seeded ActiveTargetCounts value (0 if
// unset). Matches the real repo signature: COUNT of non-retired
// deployment_targets with agent_id=$1. I-004 preflight.
func (m *mockAgentRepo) CountActiveTargets(ctx context.Context, agentID string) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.CountErr != nil {
return 0, m.CountErr
}
return m.ActiveTargetCounts[agentID], nil
}
// CountActiveCertificates returns the seeded ActiveCertCounts value.
// Real query: COUNT(DISTINCT certificate_id) across
// certificate_target_mappings ↔ deployment_targets on agent_id. I-004.
func (m *mockAgentRepo) CountActiveCertificates(ctx context.Context, agentID string) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.CountErr != nil {
return 0, m.CountErr
}
return m.ActiveCertCounts[agentID], nil
}
// CountPendingJobs returns the seeded PendingJobCounts value. Real
// query: COUNT of jobs with agent_id=$1 AND status IN (Pending,
// AwaitingCSR, AwaitingApproval, Running). I-004.
func (m *mockAgentRepo) CountPendingJobs(ctx context.Context, agentID string) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.CountErr != nil {
return 0, m.CountErr
}
return m.PendingJobCounts[agentID], nil
}
// mockTargetRepo is a test implementation of TargetRepository
type mockTargetRepo struct {
mu sync.Mutex
Targets map[string]*domain.DeploymentTarget
CreateErr error
UpdateErr error
DeleteErr error
GetErr error
ListErr error
ListByCertErr error
}
func (m *mockTargetRepo) List(ctx context.Context) ([]*domain.DeploymentTarget, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
var targets []*domain.DeploymentTarget
for _, t := range m.Targets {
targets = append(targets, t)
}
return targets, nil
}
func (m *mockTargetRepo) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.GetErr != nil {
return nil, m.GetErr
}
target, ok := m.Targets[id]
if !ok {
return nil, errNotFound
}
return target, nil
}
func (m *mockTargetRepo) Create(ctx context.Context, target *domain.DeploymentTarget) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.CreateErr != nil {
return m.CreateErr
}
m.Targets[target.ID] = target
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()
if m.UpdateErr != nil {
return m.UpdateErr
}
m.Targets[target.ID] = target
return nil
}
func (m *mockTargetRepo) Delete(ctx context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.DeleteErr != nil {
return m.DeleteErr
}
delete(m.Targets, id)
return nil
}
func (m *mockTargetRepo) ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListByCertErr != nil {
return nil, m.ListByCertErr
}
// Don't call List again to avoid double-locking
var targets []*domain.DeploymentTarget
for _, t := range m.Targets {
targets = append(targets, t)
}
return targets, nil
}
func (m *mockTargetRepo) AddTarget(target *domain.DeploymentTarget) {
m.mu.Lock()
defer m.mu.Unlock()
m.Targets[target.ID] = target
}
func newMockTargetRepository() *mockTargetRepo {
return &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
}
// mockIssuerConnector is a test implementation of IssuerConnector
type mockIssuerConnector struct {
Result *IssuanceResult
Err error
getRenewalInfoResult *RenewalInfoResult
getRenewalInfoErr error
// LastOCSPSignRequest captures the last request passed to SignOCSPResponse.
// Tests use this to assert CertStatus (0=good, 1=revoked, 2=unknown).
LastOCSPSignRequest *OCSPSignRequest
}
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error) {
if m.Err != nil {
return nil, m.Err
}
if m.Result != nil {
return m.Result, nil
}
now := time.Now()
return &IssuanceResult{
Serial: "test-serial-123",
CertPEM: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----",
NotBefore: now,
NotAfter: now.AddDate(1, 0, 0),
}, nil
}
func (m *mockIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error) {
if m.Err != nil {
return nil, m.Err
}
return m.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds)
}
func (m *mockIssuerConnector) RevokeCertificate(ctx context.Context, serial string, reason string) error {
if m.Err != nil {
return m.Err
}
return nil
}
func (m *mockIssuerConnector) GenerateCRL(ctx context.Context, entries []CRLEntry) ([]byte, error) {
if m.Err != nil {
return nil, m.Err
}
return []byte("-----BEGIN X509 CRL-----\nmock-crl-data\n-----END X509 CRL-----"), nil
}
func (m *mockIssuerConnector) SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error) {
// Capture the request for test assertions (e.g., CertStatus verification)
reqCopy := req
m.LastOCSPSignRequest = &reqCopy
if m.Err != nil {
return nil, m.Err
}
return []byte("mock-ocsp-response"), nil
}
func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error) {
if m.Err != nil {
return "", m.Err
}
return "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----", nil
}
func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
if m.getRenewalInfoErr != nil {
return nil, m.getRenewalInfoErr
}
if m.getRenewalInfoResult != nil {
return m.getRenewalInfoResult, nil
}
// Default: return nil, nil (issuer does not support ARI)
return nil, nil
}
// Constructor functions for mocks
func newMockCertificateRepository() *mockCertRepo {
return &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
}
func newMockJobRepository() *mockJobRepo {
return &mockJobRepo{
Jobs: make(map[string]*domain.Job),
StatusUpdates: make(map[string]domain.JobStatus),
}
}
func newMockNotificationRepository() *mockNotifRepo {
return &mockNotifRepo{
Notifications: make([]*domain.NotificationEvent, 0),
}
}
func newMockAuditRepository() *mockAuditRepo {
return &mockAuditRepo{
Events: make([]*domain.AuditEvent, 0),
}
}
func newMockPolicyRepository() *mockPolicyRepo {
return &mockPolicyRepo{
Rules: make(map[string]*domain.PolicyRule),
Violations: make([]*domain.PolicyViolation, 0),
}
}
func newMockRenewalPolicyRepository() *mockRenewalPolicyRepo {
return &mockRenewalPolicyRepo{
Policies: make(map[string]*domain.RenewalPolicy),
}
}
func newMockAgentRepository() *mockAgentRepo {
return &mockAgentRepo{
Agents: make(map[string]*domain.Agent),
HeartbeatUpdates: make(map[string]time.Time),
// I-004 preflight count maps. Tests seed these directly via
// agentRepo.ActiveTargetCounts["agent-id"] = N — unset keys
// read back as zero from CountActiveTargets etc., matching
// the production repo behavior for agents with no deps.
ActiveTargetCounts: make(map[string]int),
ActiveCertCounts: make(map[string]int),
PendingJobCounts: make(map[string]int),
}
}
var _ = func() *mockTargetRepo {
return &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
}
func newMockIssuerRepository() *mockIssuerRepository {
return &mockIssuerRepository{
issuers: make(map[string]*domain.Issuer),
}
}
// mockIssuerRepository is a test implementation of IssuerRepository
type mockIssuerRepository struct {
issuers map[string]*domain.Issuer
GetErr error
ListErr error
CreateErr error
UpdateErr error
DeleteErr error
}
func (m *mockIssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
if m.ListErr != nil {
return nil, m.ListErr
}
var issuers []*domain.Issuer
for _, i := range m.issuers {
issuers = append(issuers, i)
}
return issuers, nil
}
func (m *mockIssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) {
if m.GetErr != nil {
return nil, m.GetErr
}
issuer, ok := m.issuers[id]
if !ok {
return nil, errNotFound
}
return issuer, nil
}
func (m *mockIssuerRepository) Create(ctx context.Context, issuer *domain.Issuer) error {
if m.CreateErr != nil {
return m.CreateErr
}
m.issuers[issuer.ID] = issuer
return nil
}
func (m *mockIssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) error {
if m.UpdateErr != nil {
return m.UpdateErr
}
m.issuers[issuer.ID] = issuer
return nil
}
func (m *mockIssuerRepository) CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) {
if m.CreateErr != nil {
return false, m.CreateErr
}
if _, exists := m.issuers[issuer.ID]; exists {
return false, nil
}
m.issuers[issuer.ID] = issuer
return true, nil
}
func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error {
if m.DeleteErr != nil {
return m.DeleteErr
}
delete(m.issuers, id)
return nil
}
func (m *mockIssuerRepository) AddIssuer(issuer *domain.Issuer) {
m.issuers[issuer.ID] = issuer
}
// mockRevocationRepo is a test implementation of RevocationRepository
type mockRevocationRepo struct {
Revocations []*domain.CertificateRevocation
CreateErr error
ListErr error
}
func (m *mockRevocationRepo) Create(ctx context.Context, revocation *domain.CertificateRevocation) error {
if m.CreateErr != nil {
return m.CreateErr
}
m.Revocations = append(m.Revocations, revocation)
return nil
}
func (m *mockRevocationRepo) GetByIssuerAndSerial(ctx context.Context, issuerID, serial string) (*domain.CertificateRevocation, error) {
for _, r := range m.Revocations {
if r.IssuerID == issuerID && r.SerialNumber == serial {
return r, nil
}
}
return nil, errNotFound
}
func (m *mockRevocationRepo) ListAll(ctx context.Context) ([]*domain.CertificateRevocation, error) {
if m.ListErr != nil {
return nil, m.ListErr
}
return m.Revocations, nil
}
func (m *mockRevocationRepo) ListByCertificate(ctx context.Context, certID string) ([]*domain.CertificateRevocation, error) {
var result []*domain.CertificateRevocation
for _, r := range m.Revocations {
if r.CertificateID == certID {
result = append(result, r)
}
}
return result, nil
}
func (m *mockRevocationRepo) MarkIssuerNotified(ctx context.Context, id string) error {
for _, r := range m.Revocations {
if r.ID == id {
r.IssuerNotified = true
return nil
}
}
return errNotFound
}
func newMockRevocationRepository() *mockRevocationRepo {
return &mockRevocationRepo{
Revocations: make([]*domain.CertificateRevocation, 0),
}
}
// mockNotifier is a simple notifier for testing
type mockNotifier struct {
mu sync.Mutex
messages []*mockNotifierMessage
SendErr error
}
type mockNotifierMessage struct {
Recipient string
Subject string
Body string
}
func newMockNotifier() *mockNotifier {
return &mockNotifier{
messages: make([]*mockNotifierMessage, 0),
}
}
func (m *mockNotifier) Send(ctx context.Context, recipient string, subject string, body string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.SendErr != nil {
return m.SendErr
}
m.messages = append(m.messages, &mockNotifierMessage{
Recipient: recipient,
Subject: subject,
Body: body,
})
return nil
}
func (m *mockNotifier) Channel() string {
return "Email"
}
func (m *mockNotifier) getSentCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.messages)
}
var _ = func(m *mockNotifier) *mockNotifierMessage {
if len(m.messages) == 0 {
return nil
}
return m.messages[len(m.messages)-1]
}