Files
certctl/internal/service/context_test.go
T
Shankar 6b2d1375e6 fix(m2-pr-e): collapse AgentService.HeartbeatWithContext into Heartbeat
PR-E of 6 in the M-2 end-to-end remediation sequence. Collapses the
HeartbeatWithContext wrapper into a single ctx-first Heartbeat method,
matching D-1 (ctx-only signatures, no dual forms). The handler-facing
method name is preserved (D-4) — internal/api/handler/agents.go already
declares `Heartbeat(ctx, ...)` on its local service interface, and the
handler mock at internal/api/handler/agent_handler_test.go already
takes `_ context.Context` as its first param, so no handler churn.

Changes
-------
internal/service/agent.go
  - Delete the zero-body Heartbeat wrapper that forwarded to
    HeartbeatWithContext with context.Background().
  - Rename HeartbeatWithContext → Heartbeat (ctx-bearing body
    folded directly into the canonical method).

internal/service/agent_test.go
  - TestHeartbeat (L95) and TestHeartbeat_NotFound (L128):
    agentService.HeartbeatWithContext(ctx, ...) → .Heartbeat(ctx, ...).

internal/service/concurrent_test.go
  - L162: agentSvc.HeartbeatWithContext(ctx, agentID, metadata)
    → .Heartbeat(ctx, agentID, metadata).

internal/service/context_test.go
  - L179 + L232: agentSvc.HeartbeatWithContext(ctx, ...) → .Heartbeat(...)
  - L185 + L238 t.Logf strings: "HeartbeatWithContext with ..." →
    "Heartbeat with ..." to match the collapsed method name.

Verification (Go 1.25.9 linux/arm64, CI-parity caches)
------------------------------------------------------
  go build ./...                 clean
  go vet ./...                   clean
  go test -short ./internal/service/... ./internal/api/handler/... \
    ./internal/integration/...   all ok
  go test -race -short same set  all ok
  go test -short ./...           all packages ok
  golangci-lint run ./...        0 issues

Locked decisions from the M-2 plan:
  D-1 ctx-only signatures (no dual forms)
  D-4 preserve handler method names facing the router
  D-5 domain types stay ctx-free

Audit complete. Commit: 855124a9d9. Sections: 12. Findings: 2/7/10/4/6.
2026-04-18 01:25:20 +00:00

240 lines
7.2 KiB
Go

package service
import (
"context"
"log/slog"
"os"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// TestCertificateService_ListWithCancelledContext verifies that List respects a cancelled context
func TestCertificateService_ListWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
mockCertRepo := newMockCertificateRepository()
certSvc := NewCertificateService(mockCertRepo, nil, nil)
_, _, err := certSvc.List(ctx, &repository.CertificateFilter{})
// The service should propagate context cancellation errors
// even though our mock may not check context, we verify the call goes through
// and the context error becomes part of the error chain
if err == nil || ctx.Err() == context.Canceled {
// Either the service respects context and returns an error,
// or the context was cancelled. Both are valid findings.
return
}
t.Logf("List with cancelled context returned: %v", err)
}
// TestCertificateService_GetWithCancelledContext verifies that Get respects a cancelled context
func TestCertificateService_GetWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
mockCertRepo := newMockCertificateRepository()
mockCertRepo.AddCert(&domain.ManagedCertificate{ID: "mc-test-1", CommonName: "test.example.com"})
certSvc := NewCertificateService(mockCertRepo, nil, nil)
_, err := certSvc.Get(ctx, "mc-test-1")
// Service should handle cancelled context
if err == nil || ctx.Err() == context.Canceled {
return
}
t.Logf("Get with cancelled context returned: %v", err)
}
// TestRenewalService_ProcessWithCancelledContext verifies that renewal processing respects a cancelled context
func TestRenewalService_ProcessWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
mockCertRepo := newMockCertificateRepository()
mockJobRepo := newMockJobRepository()
mockPolicyRepo := newMockRenewalPolicyRepository()
mockProfileRepo := &mockCertificateProfileRepository{
Profiles: make(map[string]*domain.CertificateProfile),
}
mockAuditSvc := &AuditService{auditRepo: newMockAuditRepository()}
mockNotifSvc := &NotificationService{
notifRepo: newMockNotificationRepository(),
ownerRepo: nil,
notifierRegistry: make(map[string]Notifier),
}
issuerRegistry := NewIssuerRegistry(slog.Default())
renewalSvc := NewRenewalService(
mockCertRepo,
mockJobRepo,
mockPolicyRepo,
mockProfileRepo,
mockAuditSvc,
mockNotifSvc,
issuerRegistry,
"agent",
)
// Attempt to check expiring certificates with cancelled context
err := renewalSvc.CheckExpiringCertificates(ctx)
// Should handle cancelled context gracefully
if err == nil || ctx.Err() == context.Canceled {
return
}
t.Logf("CheckExpiringCertificates with cancelled context returned: %v", err)
}
// mockCertificateProfileRepository is a mock for testing
type mockCertificateProfileRepository struct {
Profiles map[string]*domain.CertificateProfile
GetErr error
ListErr error
}
func (m *mockCertificateProfileRepository) List(ctx context.Context) ([]*domain.CertificateProfile, error) {
if m.ListErr != nil {
return nil, m.ListErr
}
var profiles []*domain.CertificateProfile
for _, p := range m.Profiles {
profiles = append(profiles, p)
}
return profiles, nil
}
func (m *mockCertificateProfileRepository) Get(ctx context.Context, id string) (*domain.CertificateProfile, error) {
if m.GetErr != nil {
return nil, m.GetErr
}
profile, ok := m.Profiles[id]
if !ok {
return nil, errNotFound
}
return profile, nil
}
func (m *mockCertificateProfileRepository) Create(ctx context.Context, profile *domain.CertificateProfile) error {
m.Profiles[profile.ID] = profile
return nil
}
func (m *mockCertificateProfileRepository) Update(ctx context.Context, profile *domain.CertificateProfile) error {
m.Profiles[profile.ID] = profile
return nil
}
func (m *mockCertificateProfileRepository) Delete(ctx context.Context, id string) error {
delete(m.Profiles, id)
return nil
}
// TestTargetService_ListWithCancelledContext verifies that target listing respects a cancelled context
func TestTargetService_ListWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
mockTargetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
targetSvc := NewTargetService(mockTargetRepo, nil, nil, "", slog.New(slog.NewTextHandler(os.Stderr, nil)))
_, _, err := targetSvc.List(ctx, 1, 50)
// Service should handle cancelled context
if err == nil || ctx.Err() == context.Canceled {
return
}
t.Logf("TargetService.List with cancelled context returned: %v", err)
}
// TestAgentService_HeartbeatWithCancelledContext verifies that heartbeat respects a cancelled context
func TestAgentService_HeartbeatWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
mockAgentRepo := newMockAgentRepository()
mockAgentRepo.AddAgent(&domain.Agent{
ID: "agent-1",
Name: "test-agent",
Hostname: "localhost",
})
issuerRegistry := NewIssuerRegistry(slog.Default())
agentSvc := NewAgentService(
mockAgentRepo,
nil, // certRepo
nil, // jobRepo
nil, // targetRepo
nil, // auditService
issuerRegistry,
nil, // renewalService
)
err := agentSvc.Heartbeat(ctx, "agent-1", &domain.AgentMetadata{})
// Service should handle cancelled context
if err == nil || ctx.Err() == context.Canceled {
return
}
t.Logf("Heartbeat with cancelled context returned: %v", err)
}
// Test with timeout context (should trigger deadline exceeded)
func TestCertificateService_ListWithDeadlineExceeded(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 0) // Immediate timeout
defer cancel()
mockCertRepo := newMockCertificateRepository()
certSvc := NewCertificateService(mockCertRepo, nil, nil)
time.Sleep(10 * time.Millisecond) // Ensure deadline is exceeded
_, _, err := certSvc.List(ctx, &repository.CertificateFilter{})
// Should handle deadline exceeded gracefully
if err == nil || ctx.Err() == context.DeadlineExceeded {
return
}
t.Logf("List with deadline exceeded returned: %v", err)
}
// Test with timeout context on agent heartbeat
func TestAgentService_HeartbeatWithDeadlineExceeded(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 0) // Immediate timeout
defer cancel()
mockAgentRepo := newMockAgentRepository()
mockAgentRepo.AddAgent(&domain.Agent{
ID: "agent-1",
Name: "test-agent",
Hostname: "localhost",
})
issuerRegistry := NewIssuerRegistry(slog.Default())
agentSvc := NewAgentService(
mockAgentRepo,
nil, // certRepo
nil, // jobRepo
nil, // targetRepo
nil, // auditService
issuerRegistry,
nil, // renewalService
)
time.Sleep(10 * time.Millisecond) // Ensure deadline is exceeded
err := agentSvc.Heartbeat(ctx, "agent-1", &domain.AgentMetadata{})
// Service should handle deadline exceeded
if err == nil || ctx.Err() == context.DeadlineExceeded {
return
}
t.Logf("Heartbeat with deadline exceeded returned: %v", err)
}