mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
43836aca7c
Sprint 6 closure of the audit's HIGH-severity COMP-001-HASH finding.
Pre-fix posture: migration 000018 installs a WORM trigger on
audit_events that blocks UPDATE / DELETE for the application role.
But the trigger header itself documents a compliance-superuser
bypass (backup restore, retention purges, breach recovery). Without
a hash chain, that role can rewrite any row's actor / action /
details / timestamp / event_category with no on-disk trace.
HIPAA §164.312(b), FedRAMP AU-9, NIST 800-53 AU-10 want tamper-
EVIDENCE, not just tamper-prevention. This commit ships the
evidence layer.
Wire shape:
migrations/000047_audit_events_hash_chain.up.sql
+ pgcrypto extension (digest function)
+ audit_chain_head: single-row sentinel table holding the most
recent row_hash; FOR UPDATE row-lock serialises chain writes
under concurrent INSERTs so two parallel writers can't read
the same prev_hash and produce a forked chain
+ audit_events: prev_hash + row_hash columns
+ audit_events_canonical_payload(): centralised hash input
builder. UTC + microsecond ISO-8601 keeps the hash session-
timezone-independent. All columns separated by '|' so a
concatenation-ambiguity exploit can't fabricate a collision
+ audit_events_compute_hash_chain(): BEFORE-INSERT trigger
function. Reads sentinel FOR UPDATE → computes
sha256(prev_hash || id || actor || actor_type || action ||
resource_type || resource_id || details::text ||
timestamp_utc_iso || event_category) → writes both columns +
advances the sentinel
+ backfill loop walks every existing row in (timestamp ASC, id
ASC) order; WORM trigger temporarily DISABLEd inside this
migration's transaction so backfill UPDATEs land cleanly,
ENABLEd before COMMIT
+ audit_events_verify_chain(): STABLE plpgsql verifier. Walks
the chain end-to-end and returns the first break:
(first_break_id TEXT, first_break_pos INT, row_count INT)
internal/repository/postgres/audit.go
+ AuditRepository.VerifyHashChain — calls the SQL function and
maps the OUT parameters to Go return values
internal/repository/interfaces.go
+ AuditRepository.VerifyHashChain in the contract; every
in-memory mock + stub picks up the no-op implementation
internal/scheduler/scheduler.go
+ AuditChainVerifier + AuditChainBreakRecorder interfaces
+ auditChainVerifyInterval (default 6h)
+ auditChainVerifyLoop: runs once on start + every tick;
atomic.Bool guard + 5-min per-tick context timeout match every
other GC loop's pattern
internal/service/audit_chain_metric.go
+ AuditChainCounter type with atomic counters. Sticky-first-
detection on (BrokenAtID, BrokenAtPos) so the actionable
alarm doesn't drift across walks. Snapshot() returns the
full state for the metrics handler
internal/api/handler/metrics.go
+ AuditChainCounterSnapshotter interface + Prometheus
exposition for four series:
certctl_audit_chain_break_detected_total counter (the alarm)
certctl_audit_chain_verify_total counter (walks done)
certctl_audit_chain_rows gauge (last walk size)
certctl_audit_chain_last_verified_at gauge (unix seconds)
internal/config/config.go
+ AuditChainConfig{ VerifyInterval } + CERTCTL_AUDIT_CHAIN_VERIFY_INTERVAL
cmd/server/main.go
+ wires AuditChainCounter into both the scheduler (recorder) +
metrics handler (snapshotter) — single instance shared so the
writer + reader are guaranteed to converge
internal/repository/postgres/audit_chain_test.go (NEW)
+ TestAuditEventsHashChain_FreshTable: empty walk → clean
+ TestAuditEventsHashChain_AppendLinksRows: three INSERTs
produce a strictly-linked chain; prev_hash on row 0 is NULL;
verifier walks clean over the 3 rows
+ TestAuditEventsHashChain_VerifierDetectsTampering: simulate
the compliance-superuser threat model (DISABLE WORM, UPDATE
a middle row, ENABLE WORM); verifier returns the tampered
row's id at position 1
docs/operator/audit-chain.md (NEW)
+ Layered-defenses explainer (WORM + hash chain). Verifier
function reference. Recommended Prometheus alert rule.
Performance scaling table (10k to 10M rows). Step-by-step
runbook for what to do when a break is detected. Operator
configuration table.
Test-stub additions for AuditRepository.VerifyHashChain:
internal/service/testutil_test.go — mockAuditRepo
internal/service/acme_test.go — fakeAuditRepo
internal/integration/lifecycle_test.go — mockAuditRepository
internal/api/handler/scep_intune_e2e_test.go — intuneE2EAuditRepo
Verified locally:
go vet ./... (clean)
gofmt -l internal/ cmd/ (clean)
go test -short -count=1 ./internal/scheduler/... ./internal/config/...
./internal/service/... ./internal/api/handler/... ./internal/repository/...
(all green)
Verified with testcontainers + postgres:16-alpine + the migration
runner (not gated under -short — requires docker):
go test -count=1 -run TestAuditEventsHashChain ./internal/repository/postgres/...
Closes COMP-001-HASH leg of Sprint 6. COMP-002-RETENTION lands in
the next commit (separate concern: federated-user PII retention).
1624 lines
51 KiB
Go
1624 lines
51 KiB
Go
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/api/handler"
|
|
"github.com/certctl-io/certctl/internal/api/router"
|
|
"github.com/certctl-io/certctl/internal/connector/issuer/local"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
"github.com/certctl-io/certctl/internal/service"
|
|
)
|
|
|
|
// TestCertificateLifecycle exercises the full certificate lifecycle:
|
|
// create -> renew -> process jobs -> verify versions -> register agent -> heartbeat -> audit trail
|
|
func TestCertificateLifecycle(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Setup: Create in-memory mock repositories
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
agentRepo := newMockAgentRepository()
|
|
targetRepo := newMockTargetRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
policyRepo := newMockPolicyRepository()
|
|
renewalPolicyRepo := newMockRenewalPolicyRepository()
|
|
issuerRepo := newMockIssuerRepository()
|
|
|
|
// Create logger
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
|
|
// Initialize Local CA issuer connector (real implementation, no mock)
|
|
localCA := local.New(nil, logger)
|
|
|
|
// Build issuer registry with adapter
|
|
issuerRegistry := service.NewIssuerRegistry(logger)
|
|
issuerRegistry.Set("iss-local", service.NewIssuerConnectorAdapter(localCA))
|
|
|
|
// Initialize services (following dependency graph)
|
|
auditService := service.NewAuditService(auditRepo)
|
|
policyService := service.NewPolicyService(policyRepo, auditService)
|
|
certificateService := service.NewCertificateService(certRepo, policyService, auditService)
|
|
notificationService := service.NewNotificationService(notifRepo, make(map[string]service.Notifier))
|
|
revocationRepo := newMockRevocationRepository()
|
|
|
|
// Wire decomposed sub-services (TICKET-007)
|
|
revocationSvc := service.NewRevocationSvc(certRepo, revocationRepo, auditService)
|
|
revocationSvc.SetNotificationService(notificationService)
|
|
revocationSvc.SetIssuerRegistry(issuerRegistry)
|
|
caOperationsSvc := service.NewCAOperationsSvc(revocationRepo, certRepo, nil)
|
|
caOperationsSvc.SetIssuerRegistry(issuerRegistry)
|
|
certificateService.SetRevocationSvc(revocationSvc)
|
|
certificateService.SetCAOperationsSvc(caOperationsSvc)
|
|
certificateService.SetTargetRepo(targetRepo)
|
|
renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notificationService, issuerRegistry, "server")
|
|
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
|
ownerRepo := newMockOwnerRepository()
|
|
jobService := service.NewJobService(jobRepo, certRepo, ownerRepo, renewalService, deploymentService, logger)
|
|
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
|
// 32-byte AES-256 test key — C-2 remediation makes IssuerService fail closed
|
|
// without a configured CERTCTL_CONFIG_ENCRYPTION_KEY. Happy-path CRUD tests
|
|
// must supply a real key so the encrypt path runs instead of returning
|
|
// ErrEncryptionKeyRequired.
|
|
testEncryptionKey := "0123456789abcdef0123456789abcdef"
|
|
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, testEncryptionKey, slog.Default())
|
|
|
|
// Initialize handlers
|
|
certificateHandler := handler.NewCertificateHandler(certificateService)
|
|
issuerHandler := handler.NewIssuerHandler(issuerService)
|
|
targetHandler := handler.NewTargetHandler(&mockTargetService{targetRepo: targetRepo, auditService: auditService})
|
|
agentHandler := handler.NewAgentHandler(agentService, "") // Bundle-5 / H-007: integration fixture uses warn-mode pass-through
|
|
jobHandler := handler.NewJobHandler(jobService)
|
|
policyHandler := handler.NewPolicyHandler(policyService)
|
|
profileHandler := handler.NewProfileHandler(&mockProfileService{})
|
|
teamHandler := handler.NewTeamHandler(&mockTeamService{})
|
|
ownerHandler := handler.NewOwnerHandler(&mockOwnerService{})
|
|
agentGroupHandler := handler.NewAgentGroupHandler(&mockAgentGroupService{})
|
|
auditHandler := handler.NewAuditHandler(auditService)
|
|
notificationHandler := handler.NewNotificationHandler(notificationService)
|
|
statsHandler := handler.NewStatsHandler(&mockStatsService{})
|
|
metricsHandler := handler.NewMetricsHandler(&mockStatsService{}, time.Now())
|
|
healthHandler := handler.NewHealthHandler("none", nil) // Bundle-5 / H-006: integration fixture has no DB pool wired
|
|
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
|
|
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
|
|
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
|
|
|
|
// EST handler — uses real Local CA issuer via ESTService
|
|
localCAConnector, _ := issuerRegistry.Get("iss-local")
|
|
estService := service.NewESTService("iss-local", localCAConnector, auditService, logger)
|
|
estHandler := handler.NewESTHandler(estService)
|
|
|
|
// Create router and register handlers
|
|
r := router.New()
|
|
r.RegisterHandlers(router.HandlerRegistry{
|
|
Certificates: certificateHandler,
|
|
Issuers: issuerHandler,
|
|
Targets: targetHandler,
|
|
Agents: agentHandler,
|
|
Jobs: jobHandler,
|
|
Policies: policyHandler,
|
|
Profiles: profileHandler,
|
|
Teams: teamHandler,
|
|
Owners: ownerHandler,
|
|
AgentGroups: agentGroupHandler,
|
|
Audit: auditHandler,
|
|
Notifications: notificationHandler,
|
|
Stats: statsHandler,
|
|
Metrics: metricsHandler,
|
|
Health: healthHandler,
|
|
Discovery: discoveryHandler,
|
|
NetworkScan: networkScanHandler,
|
|
Verification: verificationHandler,
|
|
BulkRevocation: handler.BulkRevocationHandler{},
|
|
})
|
|
// EST RFC 7030 hardening Phase 1: RegisterESTHandlers takes a map
|
|
// keyed by PathID. Empty PathID = legacy /.well-known/est/ root.
|
|
r.RegisterESTHandlers(map[string]handler.ESTHandler{"": estHandler})
|
|
|
|
// Create test server
|
|
server := httptest.NewServer(r)
|
|
defer server.Close()
|
|
|
|
// ======================
|
|
// Step 1: Check health
|
|
// ======================
|
|
t.Run("HealthCheck", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/health")
|
|
if err != nil {
|
|
t.Fatalf("GET /health failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var body map[string]string
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if body["status"] != "healthy" {
|
|
t.Errorf("expected status=healthy, got %s", body["status"])
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Step 2: Create certificate
|
|
// ======================
|
|
var certID string
|
|
t.Run("CreateCertificate", func(t *testing.T) {
|
|
now := time.Now()
|
|
payload := map[string]interface{}{
|
|
"name": "Example Certificate",
|
|
"common_name": "example.com",
|
|
"sans": []string{"www.example.com", "api.example.com"},
|
|
"environment": "production",
|
|
"owner_id": "owner-alice",
|
|
"team_id": "team-platform",
|
|
"issuer_id": "iss-local",
|
|
"target_ids": []string{},
|
|
"renewal_policy_id": "policy-standard",
|
|
"status": "Pending",
|
|
"expires_at": now.AddDate(1, 0, 0),
|
|
"tags": map[string]string{"environment": "prod"},
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal payload: %v", err)
|
|
}
|
|
|
|
resp, err := http.Post(
|
|
server.URL+"/api/v1/certificates",
|
|
"application/json",
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("POST /api/v1/certificates failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
t.Errorf("expected status 201, got %d. Body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var cert domain.ManagedCertificate
|
|
if err := json.NewDecoder(resp.Body).Decode(&cert); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if cert.ID == "" {
|
|
t.Fatalf("response missing id field")
|
|
}
|
|
|
|
certID = cert.ID
|
|
t.Logf("Created certificate with ID: %s", certID)
|
|
})
|
|
|
|
// ======================
|
|
// Step 3: Verify certificate
|
|
// ======================
|
|
t.Run("GetCertificate", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/certificates/" + certID)
|
|
if err != nil {
|
|
t.Fatalf("GET /api/v1/certificates/{id} failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var cert domain.ManagedCertificate
|
|
if err := json.NewDecoder(resp.Body).Decode(&cert); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if cert.ID != certID {
|
|
t.Errorf("expected cert ID %s, got %s", certID, cert.ID)
|
|
}
|
|
if cert.CommonName != "example.com" {
|
|
t.Errorf("expected common_name example.com, got %s", cert.CommonName)
|
|
}
|
|
if len(cert.SANs) != 2 {
|
|
t.Errorf("expected 2 SANs, got %d", len(cert.SANs))
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Step 4: Trigger renewal
|
|
// ======================
|
|
t.Run("TriggerRenewal", func(t *testing.T) {
|
|
resp, err := http.Post(
|
|
server.URL+"/api/v1/certificates/"+certID+"/renew",
|
|
"application/json",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("POST /api/v1/certificates/{id}/renew failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusAccepted {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
t.Errorf("expected status 202, got %d. Body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Step 5: Process jobs (simulate scheduler)
|
|
// ======================
|
|
t.Run("ProcessPendingJobs", func(t *testing.T) {
|
|
// Jobs should have been created by the renewal trigger.
|
|
// Process them using the job service directly.
|
|
if err := jobService.ProcessPendingJobs(ctx); err != nil {
|
|
t.Fatalf("failed to process pending jobs: %v", err)
|
|
}
|
|
|
|
// Verify that jobs were processed
|
|
jobs, err := jobRepo.ListByStatus(ctx, domain.JobStatusCompleted)
|
|
if err != nil {
|
|
t.Fatalf("failed to list completed jobs: %v", err)
|
|
}
|
|
|
|
// We expect at least one renewal job to have been processed
|
|
if len(jobs) == 0 {
|
|
t.Logf("Warning: no completed jobs found. This may indicate the renewal job wasn't processed.")
|
|
// Check pending jobs instead
|
|
pending, _ := jobRepo.ListByStatus(ctx, domain.JobStatusPending)
|
|
t.Logf("Pending jobs: %d", len(pending))
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Step 6: Verify certificate versions
|
|
// ======================
|
|
t.Run("GetCertificateVersions", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/certificates/" + certID + "/versions")
|
|
if err != nil {
|
|
t.Fatalf("GET /api/v1/certificates/{id}/versions failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
t.Errorf("expected status 200, got %d. Body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var respBody map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
// Extract data field which contains the versions array
|
|
dataField := respBody["data"]
|
|
if dataField == nil {
|
|
t.Logf("No versions found yet - this is expected if renewal is still in progress")
|
|
} else {
|
|
versions, ok := dataField.([]interface{})
|
|
if !ok {
|
|
t.Errorf("expected data to be array, got %T", dataField)
|
|
} else if len(versions) > 0 {
|
|
t.Logf("Found %d certificate versions", len(versions))
|
|
// Verify the first version has required fields
|
|
if version, ok := versions[0].(map[string]interface{}); ok {
|
|
if version["pem_chain"] == nil || version["pem_chain"] == "" {
|
|
t.Errorf("certificate version missing pem_chain")
|
|
}
|
|
if version["serial_number"] == nil || version["serial_number"] == "" {
|
|
t.Errorf("certificate version missing serial_number")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Step 7: Register agent
|
|
// ======================
|
|
var agentID string
|
|
t.Run("RegisterAgent", func(t *testing.T) {
|
|
payload := map[string]string{
|
|
"name": "agent-prod-1",
|
|
"hostname": "prod-server-01.example.com",
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal payload: %v", err)
|
|
}
|
|
|
|
resp, err := http.Post(
|
|
server.URL+"/api/v1/agents",
|
|
"application/json",
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("POST /api/v1/agents failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
t.Errorf("expected status 201, got %d. Body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
// The handler returns the agent directly, not wrapped
|
|
var agent domain.Agent
|
|
if err := json.NewDecoder(resp.Body).Decode(&agent); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
agentID = agent.ID
|
|
if agentID == "" {
|
|
t.Fatalf("agent id is empty")
|
|
}
|
|
|
|
t.Logf("Registered agent with ID: %s", agentID)
|
|
})
|
|
|
|
// ======================
|
|
// Step 8: Agent heartbeat
|
|
// ======================
|
|
t.Run("AgentHeartbeat", func(t *testing.T) {
|
|
payload := map[string]string{
|
|
"agent_id": agentID,
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal payload: %v", err)
|
|
}
|
|
|
|
resp, err := http.Post(
|
|
server.URL+"/api/v1/agents/"+agentID+"/heartbeat",
|
|
"application/json",
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("POST /api/v1/agents/{id}/heartbeat failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
t.Errorf("expected status 200, got %d. Body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
// Verify agent heartbeat was updated
|
|
agent, err := agentRepo.Get(ctx, agentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to get agent: %v", err)
|
|
}
|
|
|
|
if agent.LastHeartbeatAt == nil {
|
|
t.Errorf("agent LastHeartbeatAt was not updated")
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Step 9: List audit events
|
|
// ======================
|
|
t.Run("ListAuditEvents", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/audit?page=1&per_page=50")
|
|
if err != nil {
|
|
t.Fatalf("GET /api/v1/audit failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var respBody map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
// Extract data field which contains the events array
|
|
dataField := respBody["data"]
|
|
if dataField == nil {
|
|
t.Logf("No audit events found")
|
|
} else {
|
|
events, ok := dataField.([]interface{})
|
|
if !ok {
|
|
t.Errorf("expected data to be array, got %T", dataField)
|
|
} else {
|
|
t.Logf("Found %d audit events", len(events))
|
|
if len(events) == 0 {
|
|
t.Logf("Warning: no audit events found. Expected events for certificate_created, agent_registered, etc.")
|
|
}
|
|
|
|
// Verify we have expected event types
|
|
eventTypes := make(map[string]int)
|
|
for _, evt := range events {
|
|
if eventMap, ok := evt.(map[string]interface{}); ok {
|
|
if action, ok := eventMap["action"].(string); ok {
|
|
eventTypes[action]++
|
|
}
|
|
}
|
|
}
|
|
t.Logf("Audit event types: %v", eventTypes)
|
|
}
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Step 10: Get agent and verify status
|
|
// ======================
|
|
t.Run("GetAgent", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/agents/" + agentID)
|
|
if err != nil {
|
|
t.Fatalf("GET /api/v1/agents/{id} failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
t.Errorf("expected status 200, got %d. Body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var agent domain.Agent
|
|
if err := json.NewDecoder(resp.Body).Decode(&agent); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if agent.ID != agentID {
|
|
t.Errorf("expected agent ID %s, got %s", agentID, agent.ID)
|
|
}
|
|
if agent.Status != domain.AgentStatusOnline {
|
|
t.Errorf("expected agent status Online, got %s", agent.Status)
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Summary
|
|
// ======================
|
|
t.Run("Summary", func(t *testing.T) {
|
|
totalCerts, _, _ := certRepo.List(ctx, &repository.CertificateFilter{})
|
|
totalJobs, _ := jobRepo.List(ctx)
|
|
totalAgents, _ := agentRepo.List(ctx)
|
|
totalAuditEvents, _ := auditRepo.List(ctx, &repository.AuditFilter{})
|
|
|
|
t.Logf("=== Integration Test Summary ===")
|
|
t.Logf("Certificates: %d", len(totalCerts))
|
|
t.Logf("Jobs: %d", len(totalJobs))
|
|
t.Logf("Agents: %d", len(totalAgents))
|
|
t.Logf("Audit Events: %d", len(totalAuditEvents))
|
|
|
|
if len(totalCerts) == 0 {
|
|
t.Error("Expected at least 1 certificate")
|
|
}
|
|
if len(totalAgents) == 0 {
|
|
t.Error("Expected at least 1 agent")
|
|
}
|
|
if len(totalAuditEvents) == 0 {
|
|
t.Logf("Warning: Expected audit events, but none found")
|
|
}
|
|
})
|
|
}
|
|
|
|
// Mock repository implementations for integration testing
|
|
// These are simple in-memory implementations similar to testutil_test.go patterns
|
|
|
|
type mockCertificateRepository struct {
|
|
certs map[string]*domain.ManagedCertificate
|
|
versions map[string][]*domain.CertificateVersion
|
|
}
|
|
|
|
func newMockCertificateRepository() *mockCertificateRepository {
|
|
return &mockCertificateRepository{
|
|
certs: make(map[string]*domain.ManagedCertificate),
|
|
versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
}
|
|
|
|
func (m *mockCertificateRepository) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
|
|
var certs []*domain.ManagedCertificate
|
|
for _, c := range m.certs {
|
|
certs = append(certs, c)
|
|
}
|
|
return certs, len(certs), nil
|
|
}
|
|
|
|
func (m *mockCertificateRepository) Get(ctx context.Context, id string) (*domain.ManagedCertificate, error) {
|
|
cert, ok := m.certs[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("certificate not found")
|
|
}
|
|
return cert, nil
|
|
}
|
|
|
|
func (m *mockCertificateRepository) Create(ctx context.Context, cert *domain.ManagedCertificate) error {
|
|
m.certs[cert.ID] = cert
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCertificateRepository) CreateWithTx(ctx context.Context, _ repository.Querier, cert *domain.ManagedCertificate) error {
|
|
return m.Create(ctx, cert)
|
|
}
|
|
|
|
func (m *mockCertificateRepository) Update(ctx context.Context, cert *domain.ManagedCertificate) error {
|
|
m.certs[cert.ID] = cert
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCertificateRepository) UpdateWithTx(ctx context.Context, _ repository.Querier, cert *domain.ManagedCertificate) error {
|
|
return m.Update(ctx, cert)
|
|
}
|
|
|
|
func (m *mockCertificateRepository) Archive(ctx context.Context, id string) error {
|
|
cert, ok := m.certs[id]
|
|
if !ok {
|
|
return fmt.Errorf("certificate not found")
|
|
}
|
|
cert.Status = domain.CertificateStatusArchived
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCertificateRepository) ListVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error) {
|
|
return m.versions[certID], nil
|
|
}
|
|
|
|
func (m *mockCertificateRepository) CreateVersion(ctx context.Context, version *domain.CertificateVersion) error {
|
|
m.versions[version.CertificateID] = append(m.versions[version.CertificateID], version)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCertificateRepository) CreateVersionWithTx(ctx context.Context, _ repository.Querier, version *domain.CertificateVersion) error {
|
|
return m.CreateVersion(ctx, version)
|
|
}
|
|
|
|
func (m *mockCertificateRepository) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) {
|
|
var expiring []*domain.ManagedCertificate
|
|
for _, c := range m.certs {
|
|
if c.ExpiresAt.Before(before) {
|
|
expiring = append(expiring, c)
|
|
}
|
|
}
|
|
return expiring, nil
|
|
}
|
|
|
|
func (m *mockCertificateRepository) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
|
|
versions := m.versions[certID]
|
|
if len(versions) == 0 {
|
|
return nil, fmt.Errorf("no versions found")
|
|
}
|
|
return versions[len(versions)-1], nil
|
|
}
|
|
|
|
// GetByIssuerAndSerial emulates the PostgreSQL JOIN that scopes cert lookup to
|
|
// (issuer_id, serial). Returns sql.ErrNoRows when no match exists so callers
|
|
// that branch on errors.Is(err, sql.ErrNoRows) (notably the OCSP handler's
|
|
// M-004 "unknown" fallback) behave the same in-memory as against PostgreSQL.
|
|
func (m *mockCertificateRepository) 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
|
|
}
|
|
|
|
// GetVersionBySerial mirrors GetByIssuerAndSerial but returns the version
|
|
// row (where the PEM lives) — used by the ACME serial-only revoke path.
|
|
func (m *mockCertificateRepository) GetVersionBySerial(ctx context.Context, issuerID, serial string) (*domain.CertificateVersion, error) {
|
|
for _, cert := range m.certs {
|
|
if cert.IssuerID != issuerID {
|
|
continue
|
|
}
|
|
for _, v := range m.versions[cert.ID] {
|
|
if v.SerialNumber == serial {
|
|
return v, nil
|
|
}
|
|
}
|
|
}
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
type mockJobRepository struct {
|
|
jobs map[string]*domain.Job
|
|
}
|
|
|
|
func newMockJobRepository() *mockJobRepository {
|
|
return &mockJobRepository{
|
|
jobs: make(map[string]*domain.Job),
|
|
}
|
|
}
|
|
|
|
func (m *mockJobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
|
var jobs []*domain.Job
|
|
for _, j := range m.jobs {
|
|
jobs = append(jobs, j)
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
func (m *mockJobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
|
|
job, ok := m.jobs[id]
|
|
if !ok {
|
|
// S-2 closure: wrap repository.ErrNotFound so the handler's
|
|
// errors.Is dispatch resolves to 404 (matches the Postgres
|
|
// repo's post-S-2 wrapping).
|
|
return nil, fmt.Errorf("job not found: %w", repository.ErrNotFound)
|
|
}
|
|
return job, nil
|
|
}
|
|
|
|
func (m *mockJobRepository) Create(ctx context.Context, job *domain.Job) error {
|
|
m.jobs[job.ID] = job
|
|
return nil
|
|
}
|
|
|
|
func (m *mockJobRepository) Update(ctx context.Context, job *domain.Job) error {
|
|
m.jobs[job.ID] = job
|
|
return nil
|
|
}
|
|
|
|
func (m *mockJobRepository) Delete(ctx context.Context, id string) error {
|
|
delete(m.jobs, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockJobRepository) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
|
|
var jobs []*domain.Job
|
|
for _, j := range m.jobs {
|
|
if j.Status == status {
|
|
jobs = append(jobs, j)
|
|
}
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
func (m *mockJobRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
|
|
var jobs []*domain.Job
|
|
for _, j := range m.jobs {
|
|
if j.CertificateID == certID {
|
|
jobs = append(jobs, j)
|
|
}
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
func (m *mockJobRepository) UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error {
|
|
job, ok := m.jobs[id]
|
|
if !ok {
|
|
return fmt.Errorf("job not found")
|
|
}
|
|
job.Status = status
|
|
if errMsg != "" {
|
|
job.LastError = &errMsg
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockJobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
|
|
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 *mockJobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
|
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 mirrors the production H-6 semantics: Pending jobs of the given type
|
|
// (or any type when jobType is empty) flip to Running before being returned. limit <= 0
|
|
// means unlimited.
|
|
func (m *mockJobRepository) ClaimPendingJobs(ctx context.Context, jobType domain.JobType, limit int) ([]*domain.Job, error) {
|
|
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 mirrors the production H-6 semantics: Pending deployment rows for
|
|
// the agent flip to Running; AwaitingCSR rows are returned with state preserved.
|
|
func (m *mockJobRepository) ClaimPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
|
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 is the I-003 integration-mock stub. Returns jobs whose
|
|
// created_at predates the relevant cutoff for their status.
|
|
func (m *mockJobRepository) ListTimedOutAwaitingJobs(ctx context.Context, csrCutoff, approvalCutoff time.Time) ([]*domain.Job, error) {
|
|
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
|
|
}
|
|
|
|
// ListJobsWithOfflineAgents is the Bundle C / Audit M-016 integration-mock
|
|
// stub. The lifecycle integration test does not exercise the offline-agent
|
|
// reaper path; the unit-level test in internal/service covers it. Here we
|
|
// just satisfy the JobRepository interface so the package compiles.
|
|
func (m *mockJobRepository) ListJobsWithOfflineAgents(ctx context.Context, agentCutoff time.Time) ([]*domain.Job, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type mockAuditRepository struct {
|
|
events []*domain.AuditEvent
|
|
}
|
|
|
|
func newMockAuditRepository() *mockAuditRepository {
|
|
return &mockAuditRepository{
|
|
events: make([]*domain.AuditEvent, 0),
|
|
}
|
|
}
|
|
|
|
func (m *mockAuditRepository) Create(ctx context.Context, event *domain.AuditEvent) error {
|
|
m.events = append(m.events, event)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAuditRepository) CreateWithTx(ctx context.Context, _ repository.Querier, event *domain.AuditEvent) error {
|
|
return m.Create(ctx, event)
|
|
}
|
|
|
|
func (m *mockAuditRepository) List(ctx context.Context, filter *repository.AuditFilter) ([]*domain.AuditEvent, error) {
|
|
return m.events, nil
|
|
}
|
|
|
|
// VerifyHashChain is the Sprint 6 COMP-001-HASH interface addition.
|
|
// In-memory mock: report "clean walk over N events"; real chain
|
|
// semantics are pinned by internal/repository/postgres/audit_chain_test.go.
|
|
func (m *mockAuditRepository) VerifyHashChain(ctx context.Context) (string, int, int, error) {
|
|
return "", -1, len(m.events), nil
|
|
}
|
|
|
|
type mockAgentRepository struct {
|
|
agents map[string]*domain.Agent
|
|
}
|
|
|
|
func newMockAgentRepository() *mockAgentRepository {
|
|
return &mockAgentRepository{
|
|
agents: make(map[string]*domain.Agent),
|
|
}
|
|
}
|
|
|
|
func (m *mockAgentRepository) List(ctx context.Context) ([]*domain.Agent, error) {
|
|
var agents []*domain.Agent
|
|
for _, a := range m.agents {
|
|
agents = append(agents, a)
|
|
}
|
|
return agents, nil
|
|
}
|
|
|
|
func (m *mockAgentRepository) Get(ctx context.Context, id string) (*domain.Agent, error) {
|
|
agent, ok := m.agents[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("agent not found")
|
|
}
|
|
return agent, nil
|
|
}
|
|
|
|
func (m *mockAgentRepository) Create(ctx context.Context, agent *domain.Agent) error {
|
|
m.agents[agent.ID] = agent
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAgentRepository) CreateIfNotExists(ctx context.Context, agent *domain.Agent) (bool, error) {
|
|
if _, exists := m.agents[agent.ID]; exists {
|
|
return false, nil
|
|
}
|
|
m.agents[agent.ID] = agent
|
|
return true, nil
|
|
}
|
|
|
|
func (m *mockAgentRepository) Update(ctx context.Context, agent *domain.Agent) error {
|
|
m.agents[agent.ID] = agent
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAgentRepository) Delete(ctx context.Context, id string) error {
|
|
delete(m.agents, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAgentRepository) UpdateHeartbeat(ctx context.Context, id string, metadata *domain.AgentMetadata) error {
|
|
agent, ok := m.agents[id]
|
|
if !ok {
|
|
return fmt.Errorf("agent not found")
|
|
}
|
|
now := time.Now()
|
|
agent.LastHeartbeatAt = &now
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAgentRepository) GetByAPIKey(ctx context.Context, keyHash string) (*domain.Agent, error) {
|
|
for _, a := range m.agents {
|
|
if a.APIKeyHash == keyHash {
|
|
return a, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("agent not found")
|
|
}
|
|
|
|
// I-004: the integration-level mockAgentRepository implements the 6 new
|
|
// retirement-surface methods as thin contract-satisfying stubs. The
|
|
// integration suite exercises lifecycle flows (issue → renew → deploy)
|
|
// that don't touch retirement, so these methods never need real behavior
|
|
// here — they exist purely to keep mockAgentRepository a valid
|
|
// AgentRepository implementation after migration 000015 expanded the
|
|
// interface. Dedicated retirement tests live in internal/service/
|
|
// agent_retire_test.go against the richer service-layer mockAgentRepo.
|
|
|
|
func (m *mockAgentRepository) ListRetired(ctx context.Context, page, perPage int) ([]*domain.Agent, int, error) {
|
|
var retired []*domain.Agent
|
|
for _, a := range m.agents {
|
|
if a.RetiredAt != nil {
|
|
retired = append(retired, a)
|
|
}
|
|
}
|
|
return retired, len(retired), nil
|
|
}
|
|
|
|
func (m *mockAgentRepository) SoftRetire(ctx context.Context, id string, retiredAt time.Time, reason string) error {
|
|
agent, ok := m.agents[id]
|
|
if !ok {
|
|
return fmt.Errorf("agent not found")
|
|
}
|
|
if agent.RetiredAt != nil {
|
|
return nil
|
|
}
|
|
stamped := retiredAt
|
|
agent.RetiredAt = &stamped
|
|
stampedReason := reason
|
|
agent.RetiredReason = &stampedReason
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAgentRepository) RetireAgentWithCascade(ctx context.Context, id string, retiredAt time.Time, reason string) error {
|
|
return m.SoftRetire(ctx, id, retiredAt, reason)
|
|
}
|
|
|
|
func (m *mockAgentRepository) CountActiveTargets(ctx context.Context, agentID string) (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (m *mockAgentRepository) CountActiveCertificates(ctx context.Context, agentID string) (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (m *mockAgentRepository) CountPendingJobs(ctx context.Context, agentID string) (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
type mockTargetRepository struct {
|
|
targets map[string]*domain.DeploymentTarget
|
|
}
|
|
|
|
func newMockTargetRepository() *mockTargetRepository {
|
|
return &mockTargetRepository{
|
|
targets: make(map[string]*domain.DeploymentTarget),
|
|
}
|
|
}
|
|
|
|
func (m *mockTargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget, error) {
|
|
var targets []*domain.DeploymentTarget
|
|
for _, t := range m.targets {
|
|
targets = append(targets, t)
|
|
}
|
|
return targets, nil
|
|
}
|
|
|
|
// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2).
|
|
func (m *mockTargetRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.DeploymentTarget, int64, error) {
|
|
all, _ := m.List(ctx)
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
if offset >= len(all) {
|
|
return nil, int64(len(all)), nil
|
|
}
|
|
if limit <= 0 {
|
|
return all[offset:], int64(len(all)), nil
|
|
}
|
|
end := offset + limit
|
|
if end > len(all) {
|
|
end = len(all)
|
|
}
|
|
return all[offset:end], int64(len(all)), nil
|
|
}
|
|
|
|
func (m *mockTargetRepository) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
|
|
target, ok := m.targets[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("target not found")
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
func (m *mockTargetRepository) Create(ctx context.Context, target *domain.DeploymentTarget) error {
|
|
m.targets[target.ID] = target
|
|
return nil
|
|
}
|
|
|
|
func (m *mockTargetRepository) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
|
|
if _, exists := m.targets[target.ID]; exists {
|
|
return false, nil
|
|
}
|
|
m.targets[target.ID] = target
|
|
return true, nil
|
|
}
|
|
|
|
func (m *mockTargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
|
m.targets[target.ID] = target
|
|
return nil
|
|
}
|
|
|
|
func (m *mockTargetRepository) Delete(ctx context.Context, id string) error {
|
|
delete(m.targets, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockTargetRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error) {
|
|
return m.List(ctx)
|
|
}
|
|
|
|
// mockOwnerRepository satisfies repository.OwnerRepository for the M-003
|
|
// not-self approval wiring. Tests that don't care about owner lookup get an
|
|
// empty map (Get returns errNotFound, which checkNotSelf permits).
|
|
type mockOwnerRepository struct {
|
|
owners map[string]*domain.Owner
|
|
}
|
|
|
|
func newMockOwnerRepository() *mockOwnerRepository {
|
|
return &mockOwnerRepository{owners: make(map[string]*domain.Owner)}
|
|
}
|
|
|
|
func (m *mockOwnerRepository) List(ctx context.Context) ([]*domain.Owner, error) {
|
|
var out []*domain.Owner
|
|
for _, o := range m.owners {
|
|
out = append(out, o)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *mockOwnerRepository) Get(ctx context.Context, id string) (*domain.Owner, error) {
|
|
o, ok := m.owners[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("owner not found")
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
func (m *mockOwnerRepository) Create(ctx context.Context, o *domain.Owner) error {
|
|
m.owners[o.ID] = o
|
|
return nil
|
|
}
|
|
|
|
func (m *mockOwnerRepository) Update(ctx context.Context, o *domain.Owner) error {
|
|
m.owners[o.ID] = o
|
|
return nil
|
|
}
|
|
|
|
func (m *mockOwnerRepository) Delete(ctx context.Context, id string) error {
|
|
delete(m.owners, id)
|
|
return nil
|
|
}
|
|
|
|
type mockNotificationRepository struct {
|
|
notifications []*domain.NotificationEvent
|
|
}
|
|
|
|
func newMockNotificationRepository() *mockNotificationRepository {
|
|
return &mockNotificationRepository{
|
|
notifications: make([]*domain.NotificationEvent, 0),
|
|
}
|
|
}
|
|
|
|
func (m *mockNotificationRepository) Create(ctx context.Context, notif *domain.NotificationEvent) error {
|
|
m.notifications = append(m.notifications, notif)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockNotificationRepository) List(ctx context.Context, filter *repository.NotificationFilter) ([]*domain.NotificationEvent, error) {
|
|
return m.notifications, nil
|
|
}
|
|
|
|
func (m *mockNotificationRepository) UpdateStatus(ctx context.Context, id string, status string, sentAt time.Time) error {
|
|
for _, n := range m.notifications {
|
|
if n.ID == id {
|
|
n.Status = status
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("notification not found")
|
|
}
|
|
|
|
// I-005: retry/DLQ interface satisfiers. The integration tests in this package
|
|
// drive the end-to-end lifecycle against a NotificationService which requires
|
|
// the full repository.NotificationRepository interface, but none of the
|
|
// lifecycle scenarios exercise the retry sweep or dead-letter transitions —
|
|
// they're covered by unit tests in internal/service/notification_test.go. So
|
|
// these are deliberate no-op / panic-free stubs whose only job is to satisfy
|
|
// the compile-time interface contract. If a future integration test needs
|
|
// real retry semantics, promote this mock to match internal/service's
|
|
// mockNotifRepo (testutil_test.go:410) one-for-one.
|
|
|
|
func (m *mockNotificationRepository) ListRetryEligible(ctx context.Context, now time.Time, maxAttempts, limit int) ([]*domain.NotificationEvent, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockNotificationRepository) RecordFailedAttempt(ctx context.Context, id string, lastError string, nextRetryAt time.Time) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockNotificationRepository) MarkAsDead(ctx context.Context, id string, lastError string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockNotificationRepository) Requeue(ctx context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
// CountByStatus satisfies the NotificationRepository interface contract added
|
|
// by I-005 Phase 2 Green. Counts in-memory rows so StatsService wiring exercised
|
|
// by the lifecycle integration tests gets a truthful count even though the
|
|
// retry/DLQ surface isn't driven here.
|
|
func (m *mockNotificationRepository) CountByStatus(ctx context.Context, status string) (int64, error) {
|
|
var count int64
|
|
for _, n := range m.notifications {
|
|
if n.Status == status {
|
|
count++
|
|
}
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
type mockPolicyRepository struct {
|
|
rules map[string]*domain.PolicyRule
|
|
violations []*domain.PolicyViolation
|
|
}
|
|
|
|
func newMockPolicyRepository() *mockPolicyRepository {
|
|
return &mockPolicyRepository{
|
|
rules: make(map[string]*domain.PolicyRule),
|
|
violations: make([]*domain.PolicyViolation, 0),
|
|
}
|
|
}
|
|
|
|
func (m *mockPolicyRepository) ListRules(ctx context.Context) ([]*domain.PolicyRule, error) {
|
|
var rules []*domain.PolicyRule
|
|
for _, r := range m.rules {
|
|
rules = append(rules, r)
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
func (m *mockPolicyRepository) GetRule(ctx context.Context, id string) (*domain.PolicyRule, error) {
|
|
rule, ok := m.rules[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("rule not found")
|
|
}
|
|
return rule, nil
|
|
}
|
|
|
|
func (m *mockPolicyRepository) CreateRule(ctx context.Context, rule *domain.PolicyRule) error {
|
|
m.rules[rule.ID] = rule
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPolicyRepository) UpdateRule(ctx context.Context, rule *domain.PolicyRule) error {
|
|
m.rules[rule.ID] = rule
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPolicyRepository) DeleteRule(ctx context.Context, id string) error {
|
|
delete(m.rules, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPolicyRepository) CreateViolation(ctx context.Context, violation *domain.PolicyViolation) error {
|
|
m.violations = append(m.violations, violation)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPolicyRepository) ListViolations(ctx context.Context, filter *repository.AuditFilter) ([]*domain.PolicyViolation, error) {
|
|
return m.violations, nil
|
|
}
|
|
|
|
type mockRenewalPolicyRepository struct {
|
|
policies map[string]*domain.RenewalPolicy
|
|
}
|
|
|
|
func newMockRenewalPolicyRepository() *mockRenewalPolicyRepository {
|
|
return &mockRenewalPolicyRepository{
|
|
policies: make(map[string]*domain.RenewalPolicy),
|
|
}
|
|
}
|
|
|
|
func (m *mockRenewalPolicyRepository) Get(ctx context.Context, id string) (*domain.RenewalPolicy, error) {
|
|
policy, ok := m.policies[id]
|
|
if !ok {
|
|
// Return default policy
|
|
return &domain.RenewalPolicy{
|
|
ID: id,
|
|
Name: "Default Policy",
|
|
RenewalWindowDays: 30,
|
|
AutoRenew: true,
|
|
MaxRetries: 3,
|
|
RetryInterval: 3600,
|
|
AlertThresholdsDays: domain.DefaultAlertThresholds(),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}, nil
|
|
}
|
|
return policy, nil
|
|
}
|
|
|
|
func (m *mockRenewalPolicyRepository) List(ctx context.Context) ([]*domain.RenewalPolicy, error) {
|
|
var policies []*domain.RenewalPolicy
|
|
for _, p := range m.policies {
|
|
policies = append(policies, p)
|
|
}
|
|
return policies, nil
|
|
}
|
|
|
|
// Create/Update/Delete satisfy the G-1 interface extension. The integration
|
|
// harness never drives the CRUD endpoints directly — these methods exist
|
|
// purely for interface compliance so the binary still builds.
|
|
func (m *mockRenewalPolicyRepository) Create(ctx context.Context, policy *domain.RenewalPolicy) error {
|
|
m.policies[policy.ID] = policy
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRenewalPolicyRepository) Update(ctx context.Context, id string, policy *domain.RenewalPolicy) error {
|
|
policy.ID = id
|
|
m.policies[id] = policy
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRenewalPolicyRepository) Delete(ctx context.Context, id string) error {
|
|
delete(m.policies, id)
|
|
return nil
|
|
}
|
|
|
|
type mockIssuerRepository struct {
|
|
issuers map[string]*domain.Issuer
|
|
}
|
|
|
|
func newMockIssuerRepository() *mockIssuerRepository {
|
|
return &mockIssuerRepository{
|
|
issuers: make(map[string]*domain.Issuer),
|
|
}
|
|
}
|
|
|
|
func (m *mockIssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
|
|
var issuers []*domain.Issuer
|
|
for _, i := range m.issuers {
|
|
issuers = append(issuers, i)
|
|
}
|
|
return issuers, nil
|
|
}
|
|
|
|
// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2).
|
|
func (m *mockIssuerRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Issuer, int64, error) {
|
|
all, _ := m.List(ctx)
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
if offset >= len(all) {
|
|
return nil, int64(len(all)), nil
|
|
}
|
|
if limit <= 0 {
|
|
return all[offset:], int64(len(all)), nil
|
|
}
|
|
end := offset + limit
|
|
if end > len(all) {
|
|
end = len(all)
|
|
}
|
|
return all[offset:end], int64(len(all)), nil
|
|
}
|
|
|
|
func (m *mockIssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) {
|
|
issuer, ok := m.issuers[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("issuer not found")
|
|
}
|
|
return issuer, nil
|
|
}
|
|
|
|
func (m *mockIssuerRepository) Create(ctx context.Context, issuer *domain.Issuer) error {
|
|
m.issuers[issuer.ID] = issuer
|
|
return nil
|
|
}
|
|
|
|
func (m *mockIssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) error {
|
|
m.issuers[issuer.ID] = issuer
|
|
return nil
|
|
}
|
|
|
|
func (m *mockIssuerRepository) CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) {
|
|
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 {
|
|
delete(m.issuers, id)
|
|
return nil
|
|
}
|
|
|
|
// Mock service implementations for handlers that need them but aren't tested
|
|
|
|
type mockTargetService struct {
|
|
targetRepo *mockTargetRepository
|
|
auditService *service.AuditService
|
|
}
|
|
|
|
func (m *mockTargetService) ListTargets(ctx context.Context, page, perPage int) ([]domain.DeploymentTarget, int64, error) {
|
|
targets, err := m.targetRepo.List(ctx)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
var result []domain.DeploymentTarget
|
|
for _, t := range targets {
|
|
result = append(result, *t)
|
|
}
|
|
return result, int64(len(result)), nil
|
|
}
|
|
|
|
func (m *mockTargetService) GetTarget(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
|
|
return m.targetRepo.Get(ctx, id)
|
|
}
|
|
|
|
func (m *mockTargetService) CreateTarget(ctx context.Context, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
|
|
if err := m.targetRepo.Create(ctx, &target); err != nil {
|
|
return nil, err
|
|
}
|
|
return &target, nil
|
|
}
|
|
|
|
func (m *mockTargetService) UpdateTarget(ctx context.Context, id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
|
|
target.ID = id
|
|
if err := m.targetRepo.Update(ctx, &target); err != nil {
|
|
return nil, err
|
|
}
|
|
return &target, nil
|
|
}
|
|
|
|
func (m *mockTargetService) DeleteTarget(ctx context.Context, id string) error {
|
|
return m.targetRepo.Delete(ctx, id)
|
|
}
|
|
|
|
func (m *mockTargetService) TestConnection(ctx context.Context, id string) error {
|
|
return nil // No-op for integration tests
|
|
}
|
|
|
|
type mockTeamService struct{}
|
|
|
|
func (m *mockTeamService) ListTeams(_ context.Context, page, perPage int) ([]domain.Team, int64, error) {
|
|
return []domain.Team{}, 0, nil
|
|
}
|
|
|
|
func (m *mockTeamService) GetTeam(_ context.Context, id string) (*domain.Team, error) {
|
|
return nil, fmt.Errorf("team not found")
|
|
}
|
|
|
|
func (m *mockTeamService) CreateTeam(_ context.Context, team domain.Team) (*domain.Team, error) {
|
|
return &team, nil
|
|
}
|
|
|
|
func (m *mockTeamService) UpdateTeam(_ context.Context, id string, team domain.Team) (*domain.Team, error) {
|
|
team.ID = id
|
|
return &team, nil
|
|
}
|
|
|
|
func (m *mockTeamService) DeleteTeam(_ context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
type mockOwnerService struct{}
|
|
|
|
func (m *mockOwnerService) ListOwners(_ context.Context, page, perPage int) ([]domain.Owner, int64, error) {
|
|
return []domain.Owner{}, 0, nil
|
|
}
|
|
|
|
func (m *mockOwnerService) GetOwner(_ context.Context, id string) (*domain.Owner, error) {
|
|
return nil, fmt.Errorf("owner not found")
|
|
}
|
|
|
|
func (m *mockOwnerService) CreateOwner(_ context.Context, owner domain.Owner) (*domain.Owner, error) {
|
|
return &owner, nil
|
|
}
|
|
|
|
func (m *mockOwnerService) UpdateOwner(_ context.Context, id string, owner domain.Owner) (*domain.Owner, error) {
|
|
owner.ID = id
|
|
return &owner, nil
|
|
}
|
|
|
|
func (m *mockOwnerService) DeleteOwner(_ context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
type mockProfileService struct{}
|
|
|
|
func (m *mockProfileService) ListProfiles(_ context.Context, page, perPage int) ([]domain.CertificateProfile, int64, error) {
|
|
return []domain.CertificateProfile{}, 0, nil
|
|
}
|
|
|
|
func (m *mockProfileService) GetProfile(_ context.Context, id string) (*domain.CertificateProfile, error) {
|
|
return nil, fmt.Errorf("profile not found")
|
|
}
|
|
|
|
func (m *mockProfileService) CreateProfile(_ context.Context, profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
|
|
return &profile, nil
|
|
}
|
|
|
|
func (m *mockProfileService) UpdateProfile(_ context.Context, id string, profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
|
|
profile.ID = id
|
|
return &profile, nil
|
|
}
|
|
|
|
func (m *mockProfileService) DeleteProfile(_ context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
type mockAgentGroupService struct{}
|
|
|
|
func (m *mockAgentGroupService) ListAgentGroups(_ context.Context, page, perPage int) ([]domain.AgentGroup, int64, error) {
|
|
return []domain.AgentGroup{}, 0, nil
|
|
}
|
|
|
|
func (m *mockAgentGroupService) GetAgentGroup(_ context.Context, id string) (*domain.AgentGroup, error) {
|
|
return nil, fmt.Errorf("agent group not found")
|
|
}
|
|
|
|
func (m *mockAgentGroupService) CreateAgentGroup(_ context.Context, group domain.AgentGroup) (*domain.AgentGroup, error) {
|
|
return &group, nil
|
|
}
|
|
|
|
func (m *mockAgentGroupService) UpdateAgentGroup(_ context.Context, id string, group domain.AgentGroup) (*domain.AgentGroup, error) {
|
|
group.ID = id
|
|
return &group, nil
|
|
}
|
|
|
|
func (m *mockAgentGroupService) DeleteAgentGroup(_ context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAgentGroupService) ListMembers(_ context.Context, id string) ([]domain.Agent, int64, error) {
|
|
return []domain.Agent{}, 0, nil
|
|
}
|
|
|
|
// mockRevocationRepository is a test implementation of RevocationRepository for integration tests.
|
|
type mockRevocationRepository struct {
|
|
revocations []*domain.CertificateRevocation
|
|
}
|
|
|
|
func newMockRevocationRepository() *mockRevocationRepository {
|
|
return &mockRevocationRepository{
|
|
revocations: make([]*domain.CertificateRevocation, 0),
|
|
}
|
|
}
|
|
|
|
func (m *mockRevocationRepository) Create(ctx context.Context, revocation *domain.CertificateRevocation) error {
|
|
m.revocations = append(m.revocations, revocation)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRevocationRepository) CreateWithTx(ctx context.Context, _ repository.Querier, revocation *domain.CertificateRevocation) error {
|
|
return m.Create(ctx, revocation)
|
|
}
|
|
|
|
func (m *mockRevocationRepository) 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, fmt.Errorf("revocation not found")
|
|
}
|
|
|
|
func (m *mockRevocationRepository) ListAll(ctx context.Context) ([]*domain.CertificateRevocation, error) {
|
|
return m.revocations, nil
|
|
}
|
|
|
|
func (m *mockRevocationRepository) ListByIssuer(ctx context.Context, issuerID string) ([]*domain.CertificateRevocation, error) {
|
|
var result []*domain.CertificateRevocation
|
|
for _, r := range m.revocations {
|
|
if r.IssuerID == issuerID {
|
|
result = append(result, r)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockRevocationRepository) 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 *mockRevocationRepository) MarkIssuerNotified(ctx context.Context, id string) error {
|
|
for _, r := range m.revocations {
|
|
if r.ID == id {
|
|
r.IssuerNotified = true
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("revocation not found")
|
|
}
|
|
|
|
// mockStatsService implements both handler.StatsService and handler.MetricsService for integration tests.
|
|
type mockStatsService struct{}
|
|
|
|
func (m *mockStatsService) GetDashboardSummary(ctx context.Context) (interface{}, error) {
|
|
return &handler.DashboardSummary{}, nil
|
|
}
|
|
|
|
func (m *mockStatsService) GetCertificatesByStatus(ctx context.Context) (interface{}, error) {
|
|
return map[string]int64{}, nil
|
|
}
|
|
|
|
func (m *mockStatsService) GetExpirationTimeline(ctx context.Context, days int) (interface{}, error) {
|
|
return []interface{}{}, nil
|
|
}
|
|
|
|
func (m *mockStatsService) GetJobStats(ctx context.Context, days int) (interface{}, error) {
|
|
return []interface{}{}, nil
|
|
}
|
|
|
|
func (m *mockStatsService) GetIssuanceRate(ctx context.Context, days int) (interface{}, error) {
|
|
return []interface{}{}, nil
|
|
}
|
|
|
|
// mockDiscoveryService implements handler.DiscoveryService for integration tests.
|
|
type mockDiscoveryService struct{}
|
|
|
|
func (m *mockDiscoveryService) ProcessDiscoveryReport(ctx context.Context, report *domain.DiscoveryReport) (*domain.DiscoveryScan, error) {
|
|
return &domain.DiscoveryScan{ID: "dscan-test"}, nil
|
|
}
|
|
|
|
func (m *mockDiscoveryService) ListDiscovered(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *mockDiscoveryService) GetDiscovered(ctx context.Context, id string) (*domain.DiscoveredCertificate, error) {
|
|
return nil, fmt.Errorf("not found")
|
|
}
|
|
|
|
func (m *mockDiscoveryService) ClaimDiscovered(ctx context.Context, id string, managedCertID string, actor string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockDiscoveryService) DismissDiscovered(ctx context.Context, id string, actor string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockDiscoveryService) ListScans(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *mockDiscoveryService) GetScan(ctx context.Context, id string) (*domain.DiscoveryScan, error) {
|
|
return nil, fmt.Errorf("not found")
|
|
}
|
|
|
|
func (m *mockDiscoveryService) GetDiscoverySummary(ctx context.Context) (map[string]int, error) {
|
|
return map[string]int{}, nil
|
|
}
|
|
|
|
// mockNetworkScanService implements handler.NetworkScanService for integration tests.
|
|
type mockNetworkScanService struct{}
|
|
|
|
func (m *mockNetworkScanService) ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockNetworkScanService) GetTarget(ctx context.Context, id string) (*domain.NetworkScanTarget, error) {
|
|
return nil, fmt.Errorf("not found")
|
|
}
|
|
|
|
func (m *mockNetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
|
return target, nil
|
|
}
|
|
|
|
func (m *mockNetworkScanService) UpdateTarget(ctx context.Context, id string, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
|
return target, nil
|
|
}
|
|
|
|
func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — interface
|
|
// satisfaction stubs. The lifecycle integration tests don't exercise
|
|
// the SCEP probe path; targeted coverage lives in
|
|
// internal/service/scep_probe_test.go.
|
|
func (m *mockNetworkScanService) ProbeSCEP(ctx context.Context, url string) (*domain.SCEPProbeResult, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockNetworkScanService) ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// mockVerificationService implements handler.VerificationService for integration tests.
|
|
type mockVerificationService struct{}
|
|
|
|
func (m *mockVerificationService) RecordVerificationResult(ctx context.Context, result *domain.VerificationResult) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockVerificationService) GetVerificationResult(ctx context.Context, jobID string) (*domain.VerificationResult, error) {
|
|
return nil, fmt.Errorf("not found")
|
|
}
|