mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:42:00 +00:00
aebfd8bd7c
This reverts commit 19706e56b3.
1714 lines
48 KiB
Go
1714 lines
48 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
)
|
|
|
|
var errNotFound = errors.New("not found")
|
|
|
|
// testEncryptionKey is a deterministic passphrase for unit tests that
|
|
// exercise IssuerService/TargetService write paths. After the C-2 remediation
|
|
// these services fail closed when no key is configured, so happy-path tests
|
|
// must supply a real passphrase. M-8 reshaped the type from []byte to string
|
|
// because services now hold the raw passphrase and delegate PBKDF2 to
|
|
// crypto.EncryptIfKeySet / crypto.DecryptIfKeySet (which apply a fresh random
|
|
// salt per ciphertext). Using a constant keeps wire-format assertions stable
|
|
// across runs.
|
|
var testEncryptionKey = "0123456789abcdef0123456789abcdef"
|
|
|
|
// mockCertRepo is a test implementation of CertificateRepository
|
|
type mockCertRepo struct {
|
|
Certs map[string]*domain.ManagedCertificate
|
|
Versions map[string][]*domain.CertificateVersion
|
|
CreateErr error
|
|
UpdateErr error
|
|
GetErr error
|
|
ListErr error
|
|
ListVersionsErr error
|
|
ListVersionsResult []*domain.CertificateVersion
|
|
CreateVersionErr error
|
|
ArchiveErr error
|
|
Updated []*domain.ManagedCertificate
|
|
MockGetExpiring []*domain.ManagedCertificate
|
|
}
|
|
|
|
func (m *mockCertRepo) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
|
|
if m.ListErr != nil {
|
|
return nil, 0, m.ListErr
|
|
}
|
|
var certs []*domain.ManagedCertificate
|
|
for _, c := range m.Certs {
|
|
certs = append(certs, c)
|
|
}
|
|
return certs, len(certs), nil
|
|
}
|
|
|
|
func (m *mockCertRepo) Get(ctx context.Context, id string) (*domain.ManagedCertificate, error) {
|
|
if m.GetErr != nil {
|
|
return nil, m.GetErr
|
|
}
|
|
cert, ok := m.Certs[id]
|
|
if !ok {
|
|
return nil, errNotFound
|
|
}
|
|
return cert, nil
|
|
}
|
|
|
|
func (m *mockCertRepo) Create(ctx context.Context, cert *domain.ManagedCertificate) error {
|
|
if m.CreateErr != nil {
|
|
return m.CreateErr
|
|
}
|
|
m.Certs[cert.ID] = cert
|
|
return nil
|
|
}
|
|
|
|
// CreateWithTx mirrors Create — mocks have no DB, so the Querier
|
|
// argument is ignored. Production behavior comes from postgres.WithTx
|
|
// path; mocks just exercise the in-memory state.
|
|
func (m *mockCertRepo) CreateWithTx(ctx context.Context, q repository.Querier, cert *domain.ManagedCertificate) error {
|
|
return m.Create(ctx, cert)
|
|
}
|
|
|
|
func (m *mockCertRepo) Update(ctx context.Context, cert *domain.ManagedCertificate) error {
|
|
if m.UpdateErr != nil {
|
|
return m.UpdateErr
|
|
}
|
|
m.Certs[cert.ID] = cert
|
|
m.Updated = append(m.Updated, cert)
|
|
return nil
|
|
}
|
|
|
|
// UpdateWithTx mirrors Update — see CreateWithTx note.
|
|
func (m *mockCertRepo) UpdateWithTx(ctx context.Context, q repository.Querier, cert *domain.ManagedCertificate) error {
|
|
return m.Update(ctx, cert)
|
|
}
|
|
|
|
func (m *mockCertRepo) Archive(ctx context.Context, id string) error {
|
|
if m.ArchiveErr != nil {
|
|
return m.ArchiveErr
|
|
}
|
|
cert, ok := m.Certs[id]
|
|
if !ok {
|
|
return errNotFound
|
|
}
|
|
cert.Status = domain.CertificateStatusArchived
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCertRepo) ListVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error) {
|
|
if m.ListVersionsErr != nil {
|
|
return nil, m.ListVersionsErr
|
|
}
|
|
if m.ListVersionsResult != nil {
|
|
return m.ListVersionsResult, nil
|
|
}
|
|
return m.Versions[certID], nil
|
|
}
|
|
|
|
func (m *mockCertRepo) CreateVersion(ctx context.Context, version *domain.CertificateVersion) error {
|
|
if m.CreateVersionErr != nil {
|
|
return m.CreateVersionErr
|
|
}
|
|
m.Versions[version.CertificateID] = append(m.Versions[version.CertificateID], version)
|
|
return nil
|
|
}
|
|
|
|
// CreateVersionWithTx mirrors CreateVersion.
|
|
func (m *mockCertRepo) CreateVersionWithTx(ctx context.Context, q repository.Querier, version *domain.CertificateVersion) error {
|
|
return m.CreateVersion(ctx, version)
|
|
}
|
|
|
|
func (m *mockCertRepo) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) {
|
|
// Return MockGetExpiring if set, for test control
|
|
if m.MockGetExpiring != nil {
|
|
return m.MockGetExpiring, nil
|
|
}
|
|
var expiring []*domain.ManagedCertificate
|
|
for _, c := range m.Certs {
|
|
if c.ExpiresAt.Before(before) {
|
|
expiring = append(expiring, c)
|
|
}
|
|
}
|
|
return expiring, nil
|
|
}
|
|
|
|
func (m *mockCertRepo) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
|
|
versions := m.Versions[certID]
|
|
if len(versions) == 0 {
|
|
return nil, errNotFound
|
|
}
|
|
return versions[len(versions)-1], nil
|
|
}
|
|
|
|
// GetByIssuerAndSerial emulates the PostgreSQL JOIN:
|
|
// SELECT mc.* FROM managed_certificates mc JOIN certificate_versions cv
|
|
// ON cv.certificate_id = mc.id WHERE mc.issuer_id = $1 AND cv.serial_number = $2.
|
|
// Returns sql.ErrNoRows (the sentinel the real repo surfaces) when no match
|
|
// exists, so callers that branch on errors.Is(err, sql.ErrNoRows) behave the
|
|
// same in-memory as they do against PostgreSQL.
|
|
func (m *mockCertRepo) GetByIssuerAndSerial(ctx context.Context, issuerID, serial string) (*domain.ManagedCertificate, error) {
|
|
for _, cert := range m.Certs {
|
|
if cert.IssuerID != issuerID {
|
|
continue
|
|
}
|
|
for _, v := range m.Versions[cert.ID] {
|
|
if v.SerialNumber == serial {
|
|
return cert, nil
|
|
}
|
|
}
|
|
}
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
// GetVersionBySerial mirrors GetByIssuerAndSerial but returns the version
|
|
// row — exists to support the ACME serial-only revoke path tests.
|
|
func (m *mockCertRepo) 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
|
|
}
|
|
|
|
func (m *mockCertRepo) AddCert(cert *domain.ManagedCertificate) {
|
|
m.Certs[cert.ID] = cert
|
|
}
|
|
|
|
// mockJobRepo is a test implementation of JobRepository
|
|
type mockJobRepo struct {
|
|
mu sync.Mutex
|
|
Jobs map[string]*domain.Job
|
|
Agents map[string]*domain.Agent
|
|
StatusUpdates map[string]domain.JobStatus
|
|
CreateErr error
|
|
UpdateErr error
|
|
UpdateErrorByID map[string]error
|
|
UpdateErrorByIDMu sync.Mutex
|
|
UpdateStatusErr error
|
|
GetErr error
|
|
ListErr error
|
|
ListByStatusErr error
|
|
DeleteErr error
|
|
ListTimedOutErr error
|
|
ListOfflineAgentJobsErr error
|
|
Updated []*domain.Job
|
|
}
|
|
|
|
func (m *mockJobRepo) List(ctx context.Context) ([]*domain.Job, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
var jobs []*domain.Job
|
|
for _, j := range m.Jobs {
|
|
jobs = append(jobs, j)
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
func (m *mockJobRepo) Get(ctx context.Context, id string) (*domain.Job, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.GetErr != nil {
|
|
return nil, m.GetErr
|
|
}
|
|
job, ok := m.Jobs[id]
|
|
if !ok {
|
|
return nil, errNotFound
|
|
}
|
|
return job, nil
|
|
}
|
|
|
|
func (m *mockJobRepo) Create(ctx context.Context, job *domain.Job) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.CreateErr != nil {
|
|
return m.CreateErr
|
|
}
|
|
m.Jobs[job.ID] = job
|
|
return nil
|
|
}
|
|
|
|
func (m *mockJobRepo) Update(ctx context.Context, job *domain.Job) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.UpdateErr != nil {
|
|
return m.UpdateErr
|
|
}
|
|
// Check per-ID error injection
|
|
m.UpdateErrorByIDMu.Lock()
|
|
idErr, ok := m.UpdateErrorByID[job.ID]
|
|
m.UpdateErrorByIDMu.Unlock()
|
|
if ok && idErr != nil {
|
|
return idErr
|
|
}
|
|
m.Jobs[job.ID] = job
|
|
m.Updated = append(m.Updated, job)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockJobRepo) Delete(ctx context.Context, id string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.DeleteErr != nil {
|
|
return m.DeleteErr
|
|
}
|
|
delete(m.Jobs, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockJobRepo) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListByStatusErr != nil {
|
|
return nil, m.ListByStatusErr
|
|
}
|
|
var jobs []*domain.Job
|
|
for _, j := range m.Jobs {
|
|
if j.Status == status {
|
|
jobs = append(jobs, j)
|
|
}
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
func (m *mockJobRepo) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var jobs []*domain.Job
|
|
for _, j := range m.Jobs {
|
|
if j.CertificateID == certID {
|
|
jobs = append(jobs, j)
|
|
}
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
func (m *mockJobRepo) UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.UpdateStatusErr != nil {
|
|
return m.UpdateStatusErr
|
|
}
|
|
job, ok := m.Jobs[id]
|
|
if !ok {
|
|
return errNotFound
|
|
}
|
|
job.Status = status
|
|
if errMsg != "" {
|
|
job.LastError = &errMsg
|
|
}
|
|
m.StatusUpdates[id] = status
|
|
return nil
|
|
}
|
|
|
|
func (m *mockJobRepo) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var jobs []*domain.Job
|
|
for _, j := range m.Jobs {
|
|
if j.Type == jobType && j.Status == domain.JobStatusPending {
|
|
jobs = append(jobs, j)
|
|
}
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
func (m *mockJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
var result []*domain.Job
|
|
for _, j := range m.Jobs {
|
|
if j.AgentID != nil && *j.AgentID == agentID {
|
|
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
|
|
result = append(result, j)
|
|
} else if j.Status == domain.JobStatusAwaitingCSR {
|
|
result = append(result, j)
|
|
}
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ClaimPendingJobs simulates the H-6 atomic claim semantics: matching rows are transitioned
|
|
// Pending → Running before being returned. The in-memory mock has no concurrency primitives
|
|
// beyond the existing mutex, which is sufficient for single-goroutine service tests.
|
|
func (m *mockJobRepo) ClaimPendingJobs(ctx context.Context, jobType domain.JobType, limit int) ([]*domain.Job, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
var claimed []*domain.Job
|
|
for _, j := range m.Jobs {
|
|
if j.Status != domain.JobStatusPending {
|
|
continue
|
|
}
|
|
if jobType != "" && j.Type != jobType {
|
|
continue
|
|
}
|
|
j.Status = domain.JobStatusRunning
|
|
claimed = append(claimed, j)
|
|
if limit > 0 && len(claimed) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return claimed, nil
|
|
}
|
|
|
|
// ClaimPendingByAgentID simulates the H-6 per-agent claim: Pending deployment rows scoped
|
|
// to the agent flip to Running; AwaitingCSR rows are returned but keep their state.
|
|
func (m *mockJobRepo) ClaimPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
var result []*domain.Job
|
|
for _, j := range m.Jobs {
|
|
if j.AgentID == nil || *j.AgentID != agentID {
|
|
continue
|
|
}
|
|
switch {
|
|
case j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment:
|
|
j.Status = domain.JobStatusRunning
|
|
result = append(result, j)
|
|
case j.Status == domain.JobStatusAwaitingCSR:
|
|
result = append(result, j)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ListTimedOutAwaitingJobs returns jobs stuck in AwaitingCSR/AwaitingApproval past the
|
|
// respective cutoffs. I-003 coverage-gap closure.
|
|
func (m *mockJobRepo) ListTimedOutAwaitingJobs(ctx context.Context, csrCutoff, approvalCutoff time.Time) ([]*domain.Job, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListTimedOutErr != nil {
|
|
return nil, m.ListTimedOutErr
|
|
}
|
|
var jobs []*domain.Job
|
|
for _, j := range m.Jobs {
|
|
switch j.Status {
|
|
case domain.JobStatusAwaitingCSR:
|
|
if j.CreatedAt.Before(csrCutoff) {
|
|
jobs = append(jobs, j)
|
|
}
|
|
case domain.JobStatusAwaitingApproval:
|
|
if j.CreatedAt.Before(approvalCutoff) {
|
|
jobs = append(jobs, j)
|
|
}
|
|
}
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
// ListJobsWithOfflineAgents returns Running jobs whose owning agent's
|
|
// last_heartbeat_at is older than agentCutoff. The mock walks Jobs +
|
|
// Agents the same way the real repo does. Bundle C / Audit M-016.
|
|
func (m *mockJobRepo) ListJobsWithOfflineAgents(ctx context.Context, agentCutoff time.Time) ([]*domain.Job, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListOfflineAgentJobsErr != nil {
|
|
return nil, m.ListOfflineAgentJobsErr
|
|
}
|
|
var jobs []*domain.Job
|
|
for _, j := range m.Jobs {
|
|
if j.Status != domain.JobStatusRunning {
|
|
continue
|
|
}
|
|
if j.AgentID == nil || *j.AgentID == "" {
|
|
continue
|
|
}
|
|
ag, ok := m.Agents[*j.AgentID]
|
|
if !ok || ag.LastHeartbeatAt == nil {
|
|
continue
|
|
}
|
|
if ag.LastHeartbeatAt.Before(agentCutoff) {
|
|
jobs = append(jobs, j)
|
|
}
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
func (m *mockJobRepo) AddJob(job *domain.Job) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.Jobs[job.ID] = job
|
|
}
|
|
|
|
// mockNotifRepo is a test implementation of NotificationRepository.
|
|
//
|
|
// I-005 extensions (ListRetryEligible / RecordFailedAttempt / MarkAsDead /
|
|
// Requeue) mutate the seeded *domain.NotificationEvent pointers in place.
|
|
// The service tests in notification_test.go assert against those same
|
|
// pointers (via notifRepo.Notifications or the local `row` handle), so
|
|
// in-place mutation is the contract — not a copy-and-replace pattern.
|
|
//
|
|
// Error fields are layered:
|
|
// - Per-method errors (ListRetryEligibleErr, RecordFailedAttemptErr, etc.)
|
|
// for fine-grained failure injection when a test targets exactly one
|
|
// method.
|
|
// - Shared legacy errors (ListErr for list-shaped reads, UpdateErr for
|
|
// update-shaped writes) so the pre-I-005 tests that configure ListErr
|
|
// or UpdateErr continue to short-circuit the new methods too. The
|
|
// RequeueNotification_RepoError test deliberately relies on this by
|
|
// setting UpdateErr rather than RequeueErr.
|
|
type mockNotifRepo struct {
|
|
mu sync.Mutex
|
|
Notifications []*domain.NotificationEvent
|
|
CreateErr error
|
|
ListErr error
|
|
UpdateErr error
|
|
|
|
// I-005 per-method failure injection.
|
|
ListRetryEligibleErr error
|
|
RecordFailedAttemptErr error
|
|
MarkAsDeadErr error
|
|
RequeueErr error
|
|
CountByStatusErr error
|
|
}
|
|
|
|
func (m *mockNotifRepo) Create(ctx context.Context, notif *domain.NotificationEvent) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.CreateErr != nil {
|
|
return m.CreateErr
|
|
}
|
|
m.Notifications = append(m.Notifications, notif)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockNotifRepo) List(ctx context.Context, filter *repository.NotificationFilter) ([]*domain.NotificationEvent, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
if filter == nil {
|
|
out := make([]*domain.NotificationEvent, len(m.Notifications))
|
|
copy(out, m.Notifications)
|
|
return out, nil
|
|
}
|
|
// Apply each non-zero filter field. Mirror the postgres notification
|
|
// repo's WHERE-clause shape (CertificateID, Type, Status, Channel,
|
|
// MessageLike) so the multi-channel expiry-alert tests
|
|
// (renewal_expiry_alerts_test.go, Rank 4 of the 2026-05-03 Infisical
|
|
// deep-research deliverable) get the same per-(cert, threshold,
|
|
// channel) dedup behaviour they'd see in production. Pre-Rank 4 the
|
|
// mock returned all rows regardless of filter; legacy callers
|
|
// happened to work because their assertions were "any notification
|
|
// fired" rather than "this specific (cert,threshold,channel) one".
|
|
out := make([]*domain.NotificationEvent, 0, len(m.Notifications))
|
|
msgSubstring := strings.Trim(filter.MessageLike, "%")
|
|
for _, n := range m.Notifications {
|
|
if filter.CertificateID != "" {
|
|
if n.CertificateID == nil || *n.CertificateID != filter.CertificateID {
|
|
continue
|
|
}
|
|
}
|
|
if filter.Type != "" && string(n.Type) != filter.Type {
|
|
continue
|
|
}
|
|
if filter.Status != "" && n.Status != filter.Status {
|
|
continue
|
|
}
|
|
if filter.Channel != "" && string(n.Channel) != filter.Channel {
|
|
continue
|
|
}
|
|
if msgSubstring != "" && !strings.Contains(n.Message, msgSubstring) {
|
|
continue
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *mockNotifRepo) UpdateStatus(ctx context.Context, id string, status string, sentAt time.Time) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.UpdateErr != nil {
|
|
return m.UpdateErr
|
|
}
|
|
for _, n := range m.Notifications {
|
|
if n.ID == id {
|
|
n.Status = status
|
|
return nil
|
|
}
|
|
}
|
|
return errNotFound
|
|
}
|
|
|
|
// ListRetryEligible returns failed rows whose NextRetryAt is non-nil, at or
|
|
// before beforeTime, AND whose RetryCount is strictly less than maxAttempts,
|
|
// ordered oldest-due first, capped at limit. Signature matches the postgres-
|
|
// canonical shape pinned by notification_test.go:118 ("repo.ListRetryEligible
|
|
// (ctx, now, 5, 100)") and the NotificationRepository interface at
|
|
// interfaces.go:308 — a row at retry_count == maxAttempts is NOT returned
|
|
// because the service has already exhausted its attempt budget and the row
|
|
// must be MarkAsDead'd by whichever tick last touched it, not re-swept here.
|
|
// Mirrors the partial-index predicate
|
|
// `WHERE status='failed' AND next_retry_at IS NOT NULL AND next_retry_at <= $1`
|
|
// that migration 000016's retry-sweep index makes cheap to scan; the
|
|
// retry_count filter is an extra Go-side guard so the mock behaves
|
|
// identically to the postgres `AND retry_count < $2` clause.
|
|
func (m *mockNotifRepo) ListRetryEligible(ctx context.Context, beforeTime time.Time, maxAttempts, limit int) ([]*domain.NotificationEvent, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListRetryEligibleErr != nil {
|
|
return nil, m.ListRetryEligibleErr
|
|
}
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
eligible := make([]*domain.NotificationEvent, 0)
|
|
for _, n := range m.Notifications {
|
|
if n.Status != string(domain.NotificationStatusFailed) {
|
|
continue
|
|
}
|
|
if n.NextRetryAt == nil {
|
|
continue
|
|
}
|
|
if n.NextRetryAt.After(beforeTime) {
|
|
continue
|
|
}
|
|
if n.RetryCount >= maxAttempts {
|
|
continue
|
|
}
|
|
eligible = append(eligible, n)
|
|
}
|
|
// Oldest-due first so the service processes the most-overdue row first,
|
|
// matching how an ORDER BY next_retry_at ASC query would behave.
|
|
sort.Slice(eligible, func(i, j int) bool {
|
|
return eligible[i].NextRetryAt.Before(*eligible[j].NextRetryAt)
|
|
})
|
|
if limit > 0 && len(eligible) > limit {
|
|
eligible = eligible[:limit]
|
|
}
|
|
return eligible, nil
|
|
}
|
|
|
|
// RecordFailedAttempt mutates the matched row in place: increments
|
|
// retry_count, pins next_retry_at, stores last_error, and keeps the row in
|
|
// 'failed' state so the next retry-sweep tick picks it up again. Service-
|
|
// level backoff math happens before the call; the repo is a dumb setter.
|
|
// Signature matches the postgres-canonical shape pinned by
|
|
// notification_test.go:184 ("repo.RecordFailedAttempt(ctx, 'notif-attempt-1',
|
|
// 'connection refused', nextTry)") and the NotificationRepository interface
|
|
// at interfaces.go:315 — id, then lastError, then nextRetryAt. The earlier
|
|
// (id, nextRetryAt, lastError) ordering from the Phase 1 Red seed was wrong
|
|
// and is corrected here in Phase 2 Green.
|
|
func (m *mockNotifRepo) RecordFailedAttempt(ctx context.Context, id string, lastError string, nextRetryAt time.Time) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.RecordFailedAttemptErr != nil {
|
|
return m.RecordFailedAttemptErr
|
|
}
|
|
if m.UpdateErr != nil {
|
|
return m.UpdateErr
|
|
}
|
|
for _, n := range m.Notifications {
|
|
if n.ID == id {
|
|
n.RetryCount++
|
|
next := nextRetryAt
|
|
n.NextRetryAt = &next
|
|
le := lastError
|
|
n.LastError = &le
|
|
n.Status = string(domain.NotificationStatusFailed)
|
|
return nil
|
|
}
|
|
}
|
|
return errNotFound
|
|
}
|
|
|
|
// MarkAsDead flips the row into the terminal DLQ state. next_retry_at is
|
|
// cleared so the partial retry-sweep index no longer touches this row —
|
|
// otherwise RetryFailedNotifications would loop over it forever without
|
|
// making any state change.
|
|
func (m *mockNotifRepo) MarkAsDead(ctx context.Context, id string, lastError string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.MarkAsDeadErr != nil {
|
|
return m.MarkAsDeadErr
|
|
}
|
|
if m.UpdateErr != nil {
|
|
return m.UpdateErr
|
|
}
|
|
for _, n := range m.Notifications {
|
|
if n.ID == id {
|
|
n.Status = string(domain.NotificationStatusDead)
|
|
n.NextRetryAt = nil
|
|
le := lastError
|
|
n.LastError = &le
|
|
return nil
|
|
}
|
|
}
|
|
return errNotFound
|
|
}
|
|
|
|
// Requeue is the operator-driven escape hatch from 'dead' back to 'pending'.
|
|
// Clears retry bookkeeping entirely so ProcessPendingNotifications treats
|
|
// the requeued row as a fresh attempt — identical on the wire to a freshly-
|
|
// created notification.
|
|
func (m *mockNotifRepo) Requeue(ctx context.Context, id string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.RequeueErr != nil {
|
|
return m.RequeueErr
|
|
}
|
|
if m.UpdateErr != nil {
|
|
return m.UpdateErr
|
|
}
|
|
for _, n := range m.Notifications {
|
|
if n.ID == id {
|
|
n.Status = string(domain.NotificationStatusPending)
|
|
n.RetryCount = 0
|
|
n.NextRetryAt = nil
|
|
n.LastError = nil
|
|
return nil
|
|
}
|
|
}
|
|
return errNotFound
|
|
}
|
|
|
|
func (m *mockNotifRepo) AddNotification(notif *domain.NotificationEvent) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.Notifications = append(m.Notifications, notif)
|
|
}
|
|
|
|
// CountByStatus counts in-memory rows whose Status field matches exactly.
|
|
// Dedicated error injection via CountByStatusErr so a test can assert the
|
|
// StatsService wrap-path ("failed to count dead notifications: …") without
|
|
// also tripping ListErr or other shared fields. I-005 Phase 2 Green.
|
|
func (m *mockNotifRepo) CountByStatus(ctx context.Context, status string) (int64, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.CountByStatusErr != nil {
|
|
return 0, m.CountByStatusErr
|
|
}
|
|
var count int64
|
|
for _, n := range m.Notifications {
|
|
if n.Status == status {
|
|
count++
|
|
}
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// mockAuditRepo is a test implementation of AuditRepository
|
|
type mockAuditRepo struct {
|
|
mu sync.Mutex
|
|
Events []*domain.AuditEvent
|
|
CreateErr error
|
|
ListErr error
|
|
}
|
|
|
|
func (m *mockAuditRepo) Create(ctx context.Context, event *domain.AuditEvent) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.CreateErr != nil {
|
|
return m.CreateErr
|
|
}
|
|
m.Events = append(m.Events, event)
|
|
return nil
|
|
}
|
|
|
|
// CreateWithTx mirrors Create — mocks have no DB; the Querier is ignored.
|
|
func (m *mockAuditRepo) CreateWithTx(ctx context.Context, q repository.Querier, event *domain.AuditEvent) error {
|
|
return m.Create(ctx, event)
|
|
}
|
|
|
|
func (m *mockAuditRepo) List(ctx context.Context, filter *repository.AuditFilter) ([]*domain.AuditEvent, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
// Apply filtering like the real repo
|
|
var filtered []*domain.AuditEvent
|
|
for _, e := range m.Events {
|
|
if filter != nil {
|
|
if filter.ResourceType != "" && e.ResourceType != filter.ResourceType {
|
|
continue
|
|
}
|
|
if filter.ResourceID != "" && e.ResourceID != filter.ResourceID {
|
|
continue
|
|
}
|
|
if filter.Actor != "" && e.Actor != filter.Actor {
|
|
continue
|
|
}
|
|
if !filter.From.IsZero() && e.Timestamp.Before(filter.From) {
|
|
continue
|
|
}
|
|
if !filter.To.IsZero() && e.Timestamp.After(filter.To) {
|
|
continue
|
|
}
|
|
}
|
|
filtered = append(filtered, e)
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
func (m *mockAuditRepo) AddEvent(event *domain.AuditEvent) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.Events = append(m.Events, event)
|
|
}
|
|
|
|
// mockPolicyRepo is a test implementation of PolicyRepository
|
|
type mockPolicyRepo struct {
|
|
Rules map[string]*domain.PolicyRule
|
|
Violations []*domain.PolicyViolation
|
|
CreateRuleErr error
|
|
UpdateRuleErr error
|
|
DeleteRuleErr error
|
|
GetRuleErr error
|
|
ListRulesErr error
|
|
CreateViolationErr error
|
|
ListViolationsErr error
|
|
}
|
|
|
|
func (m *mockPolicyRepo) ListRules(ctx context.Context) ([]*domain.PolicyRule, error) {
|
|
if m.ListRulesErr != nil {
|
|
return nil, m.ListRulesErr
|
|
}
|
|
var rules []*domain.PolicyRule
|
|
for _, r := range m.Rules {
|
|
rules = append(rules, r)
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
func (m *mockPolicyRepo) GetRule(ctx context.Context, id string) (*domain.PolicyRule, error) {
|
|
if m.GetRuleErr != nil {
|
|
return nil, m.GetRuleErr
|
|
}
|
|
rule, ok := m.Rules[id]
|
|
if !ok {
|
|
return nil, errNotFound
|
|
}
|
|
return rule, nil
|
|
}
|
|
|
|
func (m *mockPolicyRepo) CreateRule(ctx context.Context, rule *domain.PolicyRule) error {
|
|
if m.CreateRuleErr != nil {
|
|
return m.CreateRuleErr
|
|
}
|
|
m.Rules[rule.ID] = rule
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPolicyRepo) UpdateRule(ctx context.Context, rule *domain.PolicyRule) error {
|
|
if m.UpdateRuleErr != nil {
|
|
return m.UpdateRuleErr
|
|
}
|
|
m.Rules[rule.ID] = rule
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPolicyRepo) DeleteRule(ctx context.Context, id string) error {
|
|
if m.DeleteRuleErr != nil {
|
|
return m.DeleteRuleErr
|
|
}
|
|
delete(m.Rules, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPolicyRepo) CreateViolation(ctx context.Context, violation *domain.PolicyViolation) error {
|
|
if m.CreateViolationErr != nil {
|
|
return m.CreateViolationErr
|
|
}
|
|
m.Violations = append(m.Violations, violation)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPolicyRepo) ListViolations(ctx context.Context, filter *repository.AuditFilter) ([]*domain.PolicyViolation, error) {
|
|
if m.ListViolationsErr != nil {
|
|
return nil, m.ListViolationsErr
|
|
}
|
|
return m.Violations, nil
|
|
}
|
|
|
|
func (m *mockPolicyRepo) AddRule(rule *domain.PolicyRule) {
|
|
m.Rules[rule.ID] = rule
|
|
}
|
|
|
|
// mockRenewalPolicyRepo is a test implementation of RenewalPolicyRepository.
|
|
//
|
|
// G-1: repo contract extended with Create/Update/Delete to support the
|
|
// /api/v1/renewal-policies CRUD endpoints. Per-method *Err fields let tests
|
|
// force specific repo failures (duplicate name → 23505, FK RESTRICT on Delete
|
|
// → 23503) without standing up a real Postgres connection. The sentinel
|
|
// errors `ErrRenewalPolicyDuplicateName` and `ErrRenewalPolicyInUse` are the
|
|
// typed envelopes the service / handler layers translate into 409 Conflict.
|
|
type mockRenewalPolicyRepo struct {
|
|
Policies map[string]*domain.RenewalPolicy
|
|
GetErr error
|
|
ListErr error
|
|
CreateErr error
|
|
UpdateErr error
|
|
DeleteErr error
|
|
}
|
|
|
|
func (m *mockRenewalPolicyRepo) Get(ctx context.Context, id string) (*domain.RenewalPolicy, error) {
|
|
if m.GetErr != nil {
|
|
return nil, m.GetErr
|
|
}
|
|
policy, ok := m.Policies[id]
|
|
if !ok {
|
|
return nil, errNotFound
|
|
}
|
|
return policy, nil
|
|
}
|
|
|
|
func (m *mockRenewalPolicyRepo) List(ctx context.Context) ([]*domain.RenewalPolicy, error) {
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
var policies []*domain.RenewalPolicy
|
|
for _, p := range m.Policies {
|
|
policies = append(policies, p)
|
|
}
|
|
// Deterministic ordering mirrors the production repo's ORDER BY name,
|
|
// so pagination-boundary assertions don't become flaky under map
|
|
// iteration randomness.
|
|
sort.Slice(policies, func(i, j int) bool {
|
|
return policies[i].Name < policies[j].Name
|
|
})
|
|
return policies, nil
|
|
}
|
|
|
|
func (m *mockRenewalPolicyRepo) Create(ctx context.Context, policy *domain.RenewalPolicy) error {
|
|
if m.CreateErr != nil {
|
|
return m.CreateErr
|
|
}
|
|
if _, exists := m.Policies[policy.ID]; exists {
|
|
return m.CreateErr
|
|
}
|
|
m.Policies[policy.ID] = policy
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRenewalPolicyRepo) Update(ctx context.Context, id string, policy *domain.RenewalPolicy) error {
|
|
if m.UpdateErr != nil {
|
|
return m.UpdateErr
|
|
}
|
|
if _, exists := m.Policies[id]; !exists {
|
|
return errNotFound
|
|
}
|
|
policy.ID = id
|
|
m.Policies[id] = policy
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRenewalPolicyRepo) Delete(ctx context.Context, id string) error {
|
|
if m.DeleteErr != nil {
|
|
return m.DeleteErr
|
|
}
|
|
if _, exists := m.Policies[id]; !exists {
|
|
return errNotFound
|
|
}
|
|
delete(m.Policies, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRenewalPolicyRepo) AddPolicy(policy *domain.RenewalPolicy) {
|
|
m.Policies[policy.ID] = policy
|
|
}
|
|
|
|
// mockAgentRepo is a test implementation of AgentRepository.
|
|
//
|
|
// I-004: ActiveTargetCounts / ActiveCertCounts / PendingJobCounts are keyed by
|
|
// agent ID and read back verbatim by the Count* methods — the retirement
|
|
// service's preflight pokes these maps to simulate "agent has N active
|
|
// deployments / M deployed certs / K pending jobs" without having to seed
|
|
// real target/cert/job rows across multiple mock repos. An unset key means
|
|
// zero, matching the production repo behavior on an agent with no deps.
|
|
type mockAgentRepo struct {
|
|
mu sync.Mutex
|
|
Agents map[string]*domain.Agent
|
|
HeartbeatUpdates map[string]time.Time
|
|
CreateErr error
|
|
UpdateErr error
|
|
DeleteErr error
|
|
GetErr error
|
|
ListErr error
|
|
UpdateHeartbeatErr error
|
|
GetByAPIKeyErr error
|
|
// I-004 preflight count seeds (read by CountActiveTargets etc.).
|
|
ActiveTargetCounts map[string]int
|
|
ActiveCertCounts map[string]int
|
|
PendingJobCounts map[string]int
|
|
// I-004 retirement write-path error seams. Let tests force a SoftRetire
|
|
// or RetireAgentWithCascade failure after preflight passed, so the
|
|
// service's error surfacing (wrap+return, skip audit, etc.) can be
|
|
// exercised without having to stand up a real PG connection.
|
|
SoftRetireErr error
|
|
RetireCascadeErr error
|
|
CountErr error
|
|
ListRetiredErr error
|
|
}
|
|
|
|
// List mirrors the production repo contract post-I-004: it returns only
|
|
// ACTIVE agents (RetiredAt == nil). Tests that seed a retired agent via
|
|
// AddAgent and then call a List-driven service method (e.g. ListAgents,
|
|
// MarkStaleAgentsOffline, stats dashboards) must not see the retired row
|
|
// here — otherwise the mock would pass while the real planner filters it
|
|
// out at the WHERE clause level. ListRetired is the companion method for
|
|
// explicit retired-only listing.
|
|
func (m *mockAgentRepo) List(ctx context.Context) ([]*domain.Agent, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
var agents []*domain.Agent
|
|
for _, a := range m.Agents {
|
|
if a.RetiredAt != nil {
|
|
continue
|
|
}
|
|
agents = append(agents, a)
|
|
}
|
|
return agents, nil
|
|
}
|
|
|
|
func (m *mockAgentRepo) Get(ctx context.Context, id string) (*domain.Agent, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.GetErr != nil {
|
|
return nil, m.GetErr
|
|
}
|
|
agent, ok := m.Agents[id]
|
|
if !ok {
|
|
return nil, errNotFound
|
|
}
|
|
return agent, nil
|
|
}
|
|
|
|
func (m *mockAgentRepo) Create(ctx context.Context, agent *domain.Agent) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.CreateErr != nil {
|
|
return m.CreateErr
|
|
}
|
|
m.Agents[agent.ID] = agent
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAgentRepo) CreateIfNotExists(ctx context.Context, agent *domain.Agent) (bool, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.CreateErr != nil {
|
|
return false, m.CreateErr
|
|
}
|
|
if _, exists := m.Agents[agent.ID]; exists {
|
|
return false, nil
|
|
}
|
|
m.Agents[agent.ID] = agent
|
|
return true, nil
|
|
}
|
|
|
|
func (m *mockAgentRepo) Update(ctx context.Context, agent *domain.Agent) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.UpdateErr != nil {
|
|
return m.UpdateErr
|
|
}
|
|
m.Agents[agent.ID] = agent
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAgentRepo) Delete(ctx context.Context, id string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.DeleteErr != nil {
|
|
return m.DeleteErr
|
|
}
|
|
delete(m.Agents, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAgentRepo) UpdateHeartbeat(ctx context.Context, id string, metadata *domain.AgentMetadata) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.UpdateHeartbeatErr != nil {
|
|
return m.UpdateHeartbeatErr
|
|
}
|
|
agent, ok := m.Agents[id]
|
|
if !ok {
|
|
return errNotFound
|
|
}
|
|
now := time.Now()
|
|
agent.LastHeartbeatAt = &now
|
|
m.HeartbeatUpdates[id] = now
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAgentRepo) GetByAPIKey(ctx context.Context, keyHash string) (*domain.Agent, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.GetByAPIKeyErr != nil {
|
|
return nil, m.GetByAPIKeyErr
|
|
}
|
|
for _, a := range m.Agents {
|
|
if a.APIKeyHash == keyHash {
|
|
return a, nil
|
|
}
|
|
}
|
|
return nil, errNotFound
|
|
}
|
|
|
|
func (m *mockAgentRepo) AddAgent(agent *domain.Agent) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.Agents[agent.ID] = agent
|
|
}
|
|
|
|
// ListRetired returns the paginated retired-agents slice + total count.
|
|
// Matches the production repo contract: RetiredAt != nil, sorted by
|
|
// RetiredAt DESC, page<1 → 1, perPage<1 → 50. Sort is done in-memory over
|
|
// the keyed map so the mock stays dependency-free. I-004.
|
|
func (m *mockAgentRepo) ListRetired(ctx context.Context, page, perPage int) ([]*domain.Agent, int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListRetiredErr != nil {
|
|
return nil, 0, m.ListRetiredErr
|
|
}
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 {
|
|
perPage = 50
|
|
}
|
|
var retired []*domain.Agent
|
|
for _, a := range m.Agents {
|
|
if a.RetiredAt != nil {
|
|
retired = append(retired, a)
|
|
}
|
|
}
|
|
total := len(retired)
|
|
// Sort by RetiredAt DESC — most recent first. The real query uses the
|
|
// partial idx_agents_retired_at index; here we sort in Go.
|
|
sort.SliceStable(retired, func(i, j int) bool {
|
|
return retired[i].RetiredAt.After(*retired[j].RetiredAt)
|
|
})
|
|
// Apply page/perPage window.
|
|
offset := (page - 1) * perPage
|
|
if offset >= total {
|
|
return nil, total, nil
|
|
}
|
|
end := offset + perPage
|
|
if end > total {
|
|
end = total
|
|
}
|
|
return retired[offset:end], total, nil
|
|
}
|
|
|
|
// SoftRetire stamps RetiredAt + RetiredReason on the agent row. Mirrors
|
|
// the real repo's idempotent semantics: a row already retired is left
|
|
// untouched (zero-rows-affected is not an error). I-004 preserves
|
|
// retirement metadata across re-retire attempts — whoever retired it
|
|
// first owns the audit trail.
|
|
func (m *mockAgentRepo) SoftRetire(ctx context.Context, id string, retiredAt time.Time, reason string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.SoftRetireErr != nil {
|
|
return m.SoftRetireErr
|
|
}
|
|
agent, ok := m.Agents[id]
|
|
if !ok {
|
|
return errNotFound
|
|
}
|
|
if agent.RetiredAt != nil {
|
|
return nil // already retired — no-op
|
|
}
|
|
stamped := retiredAt
|
|
agent.RetiredAt = &stamped
|
|
stampedReason := reason
|
|
agent.RetiredReason = &stampedReason
|
|
return nil
|
|
}
|
|
|
|
// RetireAgentWithCascade stamps the agent row the same way SoftRetire
|
|
// does. The real repo also stamps every active deployment_targets row
|
|
// in the same transaction; the mock can't do that because targets live
|
|
// in mockTargetRepo, which the retirement service doesn't write to
|
|
// through this repo interface. Tests that need to assert cascade
|
|
// semantics on targets should seed mockTargetRepo directly and verify
|
|
// the service-layer audit event captured the cascade count. I-004.
|
|
func (m *mockAgentRepo) RetireAgentWithCascade(ctx context.Context, id string, retiredAt time.Time, reason string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.RetireCascadeErr != nil {
|
|
return m.RetireCascadeErr
|
|
}
|
|
agent, ok := m.Agents[id]
|
|
if !ok {
|
|
return errNotFound
|
|
}
|
|
if agent.RetiredAt != nil {
|
|
return nil // already retired — no-op (same as production transaction)
|
|
}
|
|
stamped := retiredAt
|
|
agent.RetiredAt = &stamped
|
|
stampedReason := reason
|
|
agent.RetiredReason = &stampedReason
|
|
return nil
|
|
}
|
|
|
|
// CountActiveTargets returns the seeded ActiveTargetCounts value (0 if
|
|
// unset). Matches the real repo signature: COUNT of non-retired
|
|
// deployment_targets with agent_id=$1. I-004 preflight.
|
|
func (m *mockAgentRepo) CountActiveTargets(ctx context.Context, agentID string) (int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.CountErr != nil {
|
|
return 0, m.CountErr
|
|
}
|
|
return m.ActiveTargetCounts[agentID], nil
|
|
}
|
|
|
|
// CountActiveCertificates returns the seeded ActiveCertCounts value.
|
|
// Real query: COUNT(DISTINCT certificate_id) across
|
|
// certificate_target_mappings ↔ deployment_targets on agent_id. I-004.
|
|
func (m *mockAgentRepo) CountActiveCertificates(ctx context.Context, agentID string) (int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.CountErr != nil {
|
|
return 0, m.CountErr
|
|
}
|
|
return m.ActiveCertCounts[agentID], nil
|
|
}
|
|
|
|
// CountPendingJobs returns the seeded PendingJobCounts value. Real
|
|
// query: COUNT of jobs with agent_id=$1 AND status IN (Pending,
|
|
// AwaitingCSR, AwaitingApproval, Running). I-004.
|
|
func (m *mockAgentRepo) CountPendingJobs(ctx context.Context, agentID string) (int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.CountErr != nil {
|
|
return 0, m.CountErr
|
|
}
|
|
return m.PendingJobCounts[agentID], nil
|
|
}
|
|
|
|
// mockTargetRepo is a test implementation of TargetRepository
|
|
type mockTargetRepo struct {
|
|
mu sync.Mutex
|
|
Targets map[string]*domain.DeploymentTarget
|
|
CreateErr error
|
|
UpdateErr error
|
|
DeleteErr error
|
|
GetErr error
|
|
ListErr error
|
|
ListByCertErr error
|
|
}
|
|
|
|
func (m *mockTargetRepo) List(ctx context.Context) ([]*domain.DeploymentTarget, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
var targets []*domain.DeploymentTarget
|
|
for _, t := range m.Targets {
|
|
targets = append(targets, t)
|
|
}
|
|
return targets, nil
|
|
}
|
|
|
|
func (m *mockTargetRepo) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.GetErr != nil {
|
|
return nil, m.GetErr
|
|
}
|
|
target, ok := m.Targets[id]
|
|
if !ok {
|
|
return nil, errNotFound
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
func (m *mockTargetRepo) Create(ctx context.Context, target *domain.DeploymentTarget) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.CreateErr != nil {
|
|
return m.CreateErr
|
|
}
|
|
m.Targets[target.ID] = target
|
|
return nil
|
|
}
|
|
|
|
func (m *mockTargetRepo) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.CreateErr != nil {
|
|
return false, m.CreateErr
|
|
}
|
|
if _, exists := m.Targets[target.ID]; exists {
|
|
return false, nil
|
|
}
|
|
m.Targets[target.ID] = target
|
|
return true, nil
|
|
}
|
|
|
|
func (m *mockTargetRepo) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.UpdateErr != nil {
|
|
return m.UpdateErr
|
|
}
|
|
m.Targets[target.ID] = target
|
|
return nil
|
|
}
|
|
|
|
func (m *mockTargetRepo) Delete(ctx context.Context, id string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.DeleteErr != nil {
|
|
return m.DeleteErr
|
|
}
|
|
delete(m.Targets, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockTargetRepo) ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.ListByCertErr != nil {
|
|
return nil, m.ListByCertErr
|
|
}
|
|
// Don't call List again to avoid double-locking
|
|
var targets []*domain.DeploymentTarget
|
|
for _, t := range m.Targets {
|
|
targets = append(targets, t)
|
|
}
|
|
return targets, nil
|
|
}
|
|
|
|
func (m *mockTargetRepo) AddTarget(target *domain.DeploymentTarget) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.Targets[target.ID] = target
|
|
}
|
|
|
|
func newMockTargetRepository() *mockTargetRepo {
|
|
return &mockTargetRepo{
|
|
Targets: make(map[string]*domain.DeploymentTarget),
|
|
}
|
|
}
|
|
|
|
// mockIssuerConnector is a test implementation of IssuerConnector
|
|
type mockIssuerConnector struct {
|
|
Result *IssuanceResult
|
|
Err error
|
|
getRenewalInfoResult *RenewalInfoResult
|
|
getRenewalInfoErr error
|
|
// LastOCSPSignRequest captures the last request passed to SignOCSPResponse.
|
|
// Tests use this to assert CertStatus (0=good, 1=revoked, 2=unknown).
|
|
LastOCSPSignRequest *OCSPSignRequest
|
|
|
|
// LastMustStaple records the must-staple bool from the most recent
|
|
// Issue/Renew call so tests can assert the service-layer wire from
|
|
// CertificateProfile.MustStaple → IssuerConnector reaches the
|
|
// connector. SCEP RFC 8894 + Intune master bundle Phase 5.6 follow-up.
|
|
LastMustStaple bool
|
|
}
|
|
|
|
// LastMustStaple records the must-staple bool from the most recent
|
|
// IssueCertificate / RenewCertificate call. Set by both methods so tests
|
|
// can assert the wire from CertificateProfile.MustStaple → service →
|
|
// IssuerConnector reaches the connector. SCEP RFC 8894 + Intune master
|
|
// bundle Phase 5.6 follow-up.
|
|
//
|
|
// (Field added to mockIssuerConnector struct above; declared via the
|
|
// pointer receiver so existing test fixtures don't need re-zeroing.)
|
|
|
|
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*IssuanceResult, error) {
|
|
m.LastMustStaple = mustStaple
|
|
if m.Err != nil {
|
|
return nil, m.Err
|
|
}
|
|
if m.Result != nil {
|
|
return m.Result, nil
|
|
}
|
|
now := time.Now()
|
|
return &IssuanceResult{
|
|
Serial: "test-serial-123",
|
|
CertPEM: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
|
|
ChainPEM: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----",
|
|
NotBefore: now,
|
|
NotAfter: now.AddDate(1, 0, 0),
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*IssuanceResult, error) {
|
|
m.LastMustStaple = mustStaple
|
|
if m.Err != nil {
|
|
return nil, m.Err
|
|
}
|
|
return m.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds, mustStaple)
|
|
}
|
|
|
|
func (m *mockIssuerConnector) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
|
if m.Err != nil {
|
|
return m.Err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockIssuerConnector) GenerateCRL(ctx context.Context, entries []CRLEntry) ([]byte, error) {
|
|
if m.Err != nil {
|
|
return nil, m.Err
|
|
}
|
|
return []byte("-----BEGIN X509 CRL-----\nmock-crl-data\n-----END X509 CRL-----"), nil
|
|
}
|
|
|
|
func (m *mockIssuerConnector) SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error) {
|
|
// Capture the request for test assertions (e.g., CertStatus verification)
|
|
reqCopy := req
|
|
m.LastOCSPSignRequest = &reqCopy
|
|
if m.Err != nil {
|
|
return nil, m.Err
|
|
}
|
|
return []byte("mock-ocsp-response"), nil
|
|
}
|
|
|
|
func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error) {
|
|
if m.Err != nil {
|
|
return "", m.Err
|
|
}
|
|
return "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----", nil
|
|
}
|
|
|
|
func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
|
|
if m.getRenewalInfoErr != nil {
|
|
return nil, m.getRenewalInfoErr
|
|
}
|
|
if m.getRenewalInfoResult != nil {
|
|
return m.getRenewalInfoResult, nil
|
|
}
|
|
// Default: return nil, nil (issuer does not support ARI)
|
|
return nil, nil
|
|
}
|
|
|
|
// Constructor functions for mocks
|
|
|
|
func newMockCertificateRepository() *mockCertRepo {
|
|
return &mockCertRepo{
|
|
Certs: make(map[string]*domain.ManagedCertificate),
|
|
Versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
}
|
|
|
|
func newMockJobRepository() *mockJobRepo {
|
|
return &mockJobRepo{
|
|
Jobs: make(map[string]*domain.Job),
|
|
StatusUpdates: make(map[string]domain.JobStatus),
|
|
}
|
|
}
|
|
|
|
func newMockNotificationRepository() *mockNotifRepo {
|
|
return &mockNotifRepo{
|
|
Notifications: make([]*domain.NotificationEvent, 0),
|
|
}
|
|
}
|
|
|
|
func newMockAuditRepository() *mockAuditRepo {
|
|
return &mockAuditRepo{
|
|
Events: make([]*domain.AuditEvent, 0),
|
|
}
|
|
}
|
|
|
|
func newMockPolicyRepository() *mockPolicyRepo {
|
|
return &mockPolicyRepo{
|
|
Rules: make(map[string]*domain.PolicyRule),
|
|
Violations: make([]*domain.PolicyViolation, 0),
|
|
}
|
|
}
|
|
|
|
func newMockRenewalPolicyRepository() *mockRenewalPolicyRepo {
|
|
return &mockRenewalPolicyRepo{
|
|
Policies: make(map[string]*domain.RenewalPolicy),
|
|
}
|
|
}
|
|
|
|
// mockTransactor is a no-op repository.Transactor for tests. It runs fn
|
|
// synchronously without any DB; the Querier passed to fn is nil because
|
|
// the mock repo *WithTx methods ignore it. If fn returns an error, the
|
|
// "transaction" is not committed — but since mocks share state, in-memory
|
|
// rollback isn't simulated. Tests that need rollback semantics use
|
|
// mockTransactor with WantRollbackOnErr=true to assert fn's error
|
|
// propagated correctly.
|
|
type mockTransactor struct {
|
|
WantRollbackOnErr bool
|
|
BeginTxErr error
|
|
CommitErr error
|
|
}
|
|
|
|
func (m *mockTransactor) WithinTx(ctx context.Context, fn func(q repository.Querier) error) error {
|
|
if m.BeginTxErr != nil {
|
|
return m.BeginTxErr
|
|
}
|
|
if err := fn(nil); err != nil {
|
|
return err
|
|
}
|
|
return m.CommitErr
|
|
}
|
|
|
|
func newMockTransactor() *mockTransactor { return &mockTransactor{} }
|
|
|
|
func newMockAgentRepository() *mockAgentRepo {
|
|
return &mockAgentRepo{
|
|
Agents: make(map[string]*domain.Agent),
|
|
HeartbeatUpdates: make(map[string]time.Time),
|
|
// I-004 preflight count maps. Tests seed these directly via
|
|
// agentRepo.ActiveTargetCounts["agent-id"] = N — unset keys
|
|
// read back as zero from CountActiveTargets etc., matching
|
|
// the production repo behavior for agents with no deps.
|
|
ActiveTargetCounts: make(map[string]int),
|
|
ActiveCertCounts: make(map[string]int),
|
|
PendingJobCounts: make(map[string]int),
|
|
}
|
|
}
|
|
|
|
var _ = func() *mockTargetRepo {
|
|
return &mockTargetRepo{
|
|
Targets: make(map[string]*domain.DeploymentTarget),
|
|
}
|
|
}
|
|
|
|
func newMockIssuerRepository() *mockIssuerRepository {
|
|
return &mockIssuerRepository{
|
|
issuers: make(map[string]*domain.Issuer),
|
|
}
|
|
}
|
|
|
|
// mockIssuerRepository is a test implementation of IssuerRepository
|
|
type mockIssuerRepository struct {
|
|
issuers map[string]*domain.Issuer
|
|
GetErr error
|
|
ListErr error
|
|
CreateErr error
|
|
UpdateErr error
|
|
DeleteErr error
|
|
}
|
|
|
|
func (m *mockIssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
var issuers []*domain.Issuer
|
|
for _, i := range m.issuers {
|
|
issuers = append(issuers, i)
|
|
}
|
|
return issuers, nil
|
|
}
|
|
|
|
func (m *mockIssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) {
|
|
if m.GetErr != nil {
|
|
return nil, m.GetErr
|
|
}
|
|
issuer, ok := m.issuers[id]
|
|
if !ok {
|
|
return nil, errNotFound
|
|
}
|
|
return issuer, nil
|
|
}
|
|
|
|
func (m *mockIssuerRepository) Create(ctx context.Context, issuer *domain.Issuer) error {
|
|
if m.CreateErr != nil {
|
|
return m.CreateErr
|
|
}
|
|
m.issuers[issuer.ID] = issuer
|
|
return nil
|
|
}
|
|
|
|
func (m *mockIssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) error {
|
|
if m.UpdateErr != nil {
|
|
return m.UpdateErr
|
|
}
|
|
m.issuers[issuer.ID] = issuer
|
|
return nil
|
|
}
|
|
|
|
func (m *mockIssuerRepository) CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) {
|
|
if m.CreateErr != nil {
|
|
return false, m.CreateErr
|
|
}
|
|
if _, exists := m.issuers[issuer.ID]; exists {
|
|
return false, nil
|
|
}
|
|
m.issuers[issuer.ID] = issuer
|
|
return true, nil
|
|
}
|
|
|
|
func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error {
|
|
if m.DeleteErr != nil {
|
|
return m.DeleteErr
|
|
}
|
|
delete(m.issuers, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockIssuerRepository) AddIssuer(issuer *domain.Issuer) {
|
|
m.issuers[issuer.ID] = issuer
|
|
}
|
|
|
|
// mockRevocationRepo is a test implementation of RevocationRepository
|
|
type mockRevocationRepo struct {
|
|
Revocations []*domain.CertificateRevocation
|
|
CreateErr error
|
|
ListErr error
|
|
// F-001 regression instrumentation: track which list method was invoked
|
|
// so tests can assert that the CRL generation hot path uses the scoped
|
|
// ListByIssuer query (migration 000012 composite index) rather than
|
|
// ListAll followed by in-Go filtering.
|
|
ListAllCalls int
|
|
ListByIssuerCalls int
|
|
LastListIssuerID string
|
|
}
|
|
|
|
// CreateWithTx mirrors Create — mocks have no DB; the Querier is ignored.
|
|
func (m *mockRevocationRepo) CreateWithTx(ctx context.Context, q repository.Querier, revocation *domain.CertificateRevocation) error {
|
|
return m.Create(ctx, revocation)
|
|
}
|
|
|
|
func (m *mockRevocationRepo) Create(ctx context.Context, revocation *domain.CertificateRevocation) error {
|
|
if m.CreateErr != nil {
|
|
return m.CreateErr
|
|
}
|
|
m.Revocations = append(m.Revocations, revocation)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRevocationRepo) GetByIssuerAndSerial(ctx context.Context, issuerID, serial string) (*domain.CertificateRevocation, error) {
|
|
for _, r := range m.Revocations {
|
|
if r.IssuerID == issuerID && r.SerialNumber == serial {
|
|
return r, nil
|
|
}
|
|
}
|
|
return nil, errNotFound
|
|
}
|
|
|
|
func (m *mockRevocationRepo) ListAll(ctx context.Context) ([]*domain.CertificateRevocation, error) {
|
|
m.ListAllCalls++
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
return m.Revocations, nil
|
|
}
|
|
|
|
func (m *mockRevocationRepo) ListByIssuer(ctx context.Context, issuerID string) ([]*domain.CertificateRevocation, error) {
|
|
m.ListByIssuerCalls++
|
|
m.LastListIssuerID = issuerID
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
var result []*domain.CertificateRevocation
|
|
for _, r := range m.Revocations {
|
|
if r.IssuerID == issuerID {
|
|
result = append(result, r)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockRevocationRepo) ListByCertificate(ctx context.Context, certID string) ([]*domain.CertificateRevocation, error) {
|
|
var result []*domain.CertificateRevocation
|
|
for _, r := range m.Revocations {
|
|
if r.CertificateID == certID {
|
|
result = append(result, r)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockRevocationRepo) MarkIssuerNotified(ctx context.Context, id string) error {
|
|
for _, r := range m.Revocations {
|
|
if r.ID == id {
|
|
r.IssuerNotified = true
|
|
return nil
|
|
}
|
|
}
|
|
return errNotFound
|
|
}
|
|
|
|
func newMockRevocationRepository() *mockRevocationRepo {
|
|
return &mockRevocationRepo{
|
|
Revocations: make([]*domain.CertificateRevocation, 0),
|
|
}
|
|
}
|
|
|
|
// mockNotifier is a simple notifier for testing
|
|
type mockNotifier struct {
|
|
mu sync.Mutex
|
|
messages []*mockNotifierMessage
|
|
SendErr error
|
|
}
|
|
|
|
type mockNotifierMessage struct {
|
|
Recipient string
|
|
Subject string
|
|
Body string
|
|
}
|
|
|
|
func newMockNotifier() *mockNotifier {
|
|
return &mockNotifier{
|
|
messages: make([]*mockNotifierMessage, 0),
|
|
}
|
|
}
|
|
|
|
func (m *mockNotifier) Send(ctx context.Context, recipient string, subject string, body string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.SendErr != nil {
|
|
return m.SendErr
|
|
}
|
|
m.messages = append(m.messages, &mockNotifierMessage{
|
|
Recipient: recipient,
|
|
Subject: subject,
|
|
Body: body,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (m *mockNotifier) Channel() string {
|
|
return "Email"
|
|
}
|
|
|
|
func (m *mockNotifier) getSentCount() int {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return len(m.messages)
|
|
}
|
|
|
|
var _ = func(m *mockNotifier) *mockNotifierMessage {
|
|
if len(m.messages) == 0 {
|
|
return nil
|
|
}
|
|
return m.messages[len(m.messages)-1]
|
|
}
|