Files
certctl/internal/service/csr_renewal_test.go
T
shankar0123 8b75e0311b chore: rename Go module path to github.com/certctl-io/certctl
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.

Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.

Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).

Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.

Diff shape:
  361 *.go files  — import path replacement only
    2 go.mod     — module declaration replacement only
    1 binary     — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
                   so embedded build-info reflects the new path (8618965 vs
                   8618933 bytes; 32-byte diff is the build-info change)

  Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
  mechanical substitution.

Verification:
  gofmt: 17 files needed re-alignment after sed (the new path is one char
    shorter than the old, so column-aligned import groups drifted). Applied
    `gofmt -w` to fix.
  go mod tidy: clean exit on both modules.
  go vet ./...: clean exit.
  go build ./...: clean exit.
  go test -short -count=1 on representative packages: all green
    (internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
    cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
    confirming the module path resolves correctly.
  binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
    nothing; `strings | grep certctl-io/certctl` shows the new module path
    embedded in build-info.

Files intentionally NOT touched in this commit:
  README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
    URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
    purely the Go-tooling layer.
  Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
    namespace, not a Go import or GitHub repo URL. Stays.

This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
2026-05-04 00:30:29 +00:00

463 lines
14 KiB
Go

package service
import (
"context"
"errors"
"log/slog"
"testing"
"time"
"github.com/certctl-io/certctl/internal/domain"
)
// NOTE: generateTestCSR(t, keyType, keySize) is defined in crypto_validation_test.go
// Use it as: generateTestCSR(t, "ECDSA", 256)
// newTestRenewalServiceForCSR creates a RenewalService with mocks suitable for CSR renewal testing.
func newTestRenewalServiceForCSR(issuerErr error) *RenewalService {
certRepo := newMockCertificateRepository()
jobRepo := newMockJobRepository()
policyRepo := newMockRenewalPolicyRepository()
profileRepo := newMockProfileRepository()
auditRepo := newMockAuditRepository()
notifRepo := newMockNotificationRepository()
notifier := newMockNotifier()
auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{
"Email": notifier,
})
issuerConnector := &mockIssuerConnector{Err: issuerErr}
issuerRegistry := NewIssuerRegistry(slog.Default())
issuerRegistry.Set("iss-local", issuerConnector)
svc := NewRenewalService(certRepo, jobRepo, policyRepo, profileRepo, auditSvc, notifSvc, issuerRegistry, "agent")
return svc
}
// TestCompleteAgentCSRRenewal_Success tests the happy path: valid CSR, issuer signs, cert stored, deployment jobs created.
func TestCompleteAgentCSRRenewal_Success(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-001",
Name: "Test Certificate",
CommonName: "example.com",
SANs: []string{"www.example.com"},
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
TargetIDs: []string{"t-nginx-1"},
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-csr-001",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err != nil {
t.Fatalf("CompleteAgentCSRRenewal failed: %v", err)
}
// Verify job was completed
updatedJob, err := jobRepo.Get(ctx, job.ID)
if err != nil {
t.Fatalf("failed to get job after renewal: %v", err)
}
if updatedJob.Status != domain.JobStatusCompleted {
t.Errorf("expected job status Completed, got %s", updatedJob.Status)
}
// Verify certificate version was created
versions, err := certRepo.ListVersions(ctx, cert.ID)
if err != nil {
t.Fatalf("failed to list versions: %v", err)
}
if len(versions) != 1 {
t.Errorf("expected 1 version, got %d", len(versions))
}
// Verify version fields
version := versions[0]
if version.SerialNumber != "test-serial-123" {
t.Errorf("expected serial 'test-serial-123', got %s", version.SerialNumber)
}
if version.CSRPEM != csrPEM {
t.Errorf("expected CSR PEM to be stored as-is (agent mode), got mismatch")
}
if version.PEMChain == "" {
t.Errorf("expected PEMChain to be populated")
}
// Verify certificate was updated
updatedCert, err := certRepo.Get(ctx, cert.ID)
if err != nil {
t.Fatalf("failed to get cert after renewal: %v", err)
}
if updatedCert.Status != domain.CertificateStatusActive {
t.Errorf("expected cert status Active, got %s", updatedCert.Status)
}
if updatedCert.LastRenewalAt == nil {
t.Errorf("expected LastRenewalAt to be set")
}
// Verify deployment jobs were created
deploymentJobs := 0
for _, j := range jobRepo.Jobs {
if j.Type == domain.JobTypeDeployment && j.CertificateID == cert.ID {
deploymentJobs++
}
}
if deploymentJobs != 1 {
t.Errorf("expected 1 deployment job, got %d", deploymentJobs)
}
}
// TestCompleteAgentCSRRenewal_JobNotFound tests that the method handles a missing job gracefully.
func TestCompleteAgentCSRRenewal_JobNotFound(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-not-found",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
// Job not added to repo — simulates "not found" on status update
job := &domain.Job{
ID: "job-nonexistent",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
CreatedAt: time.Now(),
}
csrPEM := generateTestCSR(t, "ECDSA", 256)
// Call will pass CSR validation but fail when updating job status to Running
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err == nil {
t.Errorf("expected error for missing job, got nil")
}
}
// TestCompleteAgentCSRRenewal_JobNotAwaitingCSR tests that the method processes regardless of job state
// (the method doesn't check job.Status — it trusts the caller).
func TestCompleteAgentCSRRenewal_JobNotAwaitingCSR(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-wrong-state",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-running",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusRunning, // Wrong state — method doesn't check
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
// The method doesn't validate job state, so it should still process
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
// Depending on mock behavior, this may succeed or fail — the point is no panic
_ = err
}
// TestCompleteAgentCSRRenewal_InvalidCSR tests that invalid CSR PEM causes failure.
func TestCompleteAgentCSRRenewal_InvalidCSR(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-invalid-csr",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-invalid-csr",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
invalidCSR := "not a pem certificate request at all"
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, invalidCSR)
if err == nil {
t.Errorf("expected error for invalid CSR, got nil")
}
// Verify job was marked as failed
updatedJob, _ := jobRepo.Get(ctx, job.ID)
if updatedJob.Status != domain.JobStatusFailed {
t.Errorf("expected job status Failed after CSR validation error, got %s", updatedJob.Status)
}
if updatedJob.LastError == nil || *updatedJob.LastError == "" {
t.Errorf("expected error message stored in job, got none")
}
}
// TestCompleteAgentCSRRenewal_IssuerError tests that issuer connector failure is handled.
func TestCompleteAgentCSRRenewal_IssuerError(t *testing.T) {
ctx := context.Background()
issuerErr := errors.New("issuer signing failed")
svc := newTestRenewalServiceForCSR(issuerErr)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-issuer-error",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-issuer-error",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err == nil {
t.Errorf("expected error from issuer failure, got nil")
}
// Verify job was marked as failed
updatedJob, _ := jobRepo.Get(ctx, job.ID)
if updatedJob.Status != domain.JobStatusFailed {
t.Errorf("expected job status Failed, got %s", updatedJob.Status)
}
// Verify no version was created
versions, _ := certRepo.ListVersions(ctx, cert.ID)
if len(versions) > 0 {
t.Errorf("expected no version created after issuer failure, got %d", len(versions))
}
}
// TestCompleteAgentCSRRenewal_StoreVersionError tests that version storage failure is handled.
func TestCompleteAgentCSRRenewal_StoreVersionError(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
certRepo.CreateVersionErr = errors.New("version storage failed")
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-store-error",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-store-error",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err == nil {
t.Errorf("expected error from version storage failure, got nil")
}
// Verify job was marked as failed
updatedJob, _ := jobRepo.Get(ctx, job.ID)
if updatedJob.Status != domain.JobStatusFailed {
t.Errorf("expected job status Failed, got %s", updatedJob.Status)
}
// Verify no version was actually stored
versions, _ := certRepo.ListVersions(ctx, cert.ID)
if len(versions) > 0 {
t.Errorf("expected no version stored after storage error, got %d", len(versions))
}
}
// TestCompleteAgentCSRRenewal_CertNotFound tests that missing issuer connector is handled.
func TestCompleteAgentCSRRenewal_CertNotFound(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
jobRepo := svc.jobRepo.(*mockJobRepo)
job := &domain.Job{
ID: "job-cert-not-found",
CertificateID: "mc-nonexistent",
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
cert := &domain.ManagedCertificate{
ID: "mc-cert-not-found",
CommonName: "example.com",
IssuerID: "iss-nonexistent", // Not in registry
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err == nil {
t.Errorf("expected error for missing issuer, got nil")
}
if !contains(err.Error(), "issuer connector not found") {
t.Errorf("expected 'issuer connector not found' error, got: %v", err)
}
}
// TestCompleteAgentCSRRenewal_EKUFromProfile tests that EKUs are resolved from profile and passed to issuer.
func TestCompleteAgentCSRRenewal_EKUFromProfile(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
profileRepo := svc.profileRepo.(*mockProfileRepo)
profile := &domain.CertificateProfile{
ID: "prof-smime",
Name: "S/MIME",
MaxTTLSeconds: 31536000, // 365 days
AllowedEKUs: []string{"emailProtection", "clientAuth"},
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
profileRepo.AddProfile(profile)
cert := &domain.ManagedCertificate{
ID: "mc-test-eku",
Name: "S/MIME Certificate",
CommonName: "user@example.com",
SANs: []string{"user@example.com"},
IssuerID: "iss-local",
CertificateProfileID: "prof-smime",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-eku",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err != nil {
t.Fatalf("CompleteAgentCSRRenewal failed: %v", err)
}
// Verify job was completed — profile lookup + EKU resolution worked
updatedJob, _ := jobRepo.Get(ctx, job.ID)
if updatedJob.Status != domain.JobStatusCompleted {
t.Errorf("expected job status Completed, got %s", updatedJob.Status)
}
}