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