Files
certctl/internal/service/issuer_test.go
T
shankar0123 eb14236166 M-2 PR-B: Collapse IssuerService + TargetService to ctx-first signatures
- Delete bare TestConnection wrapper in IssuerService; rename
  TestConnectionWithContext → TestConnection
- Delete TestTargetConnection delegate shim in TargetService (canonical
  TestConnection already ctx-first)
- Add ctx first param to 10 handler-interface methods
  (ListIssuers/GetIssuer/CreateIssuer/UpdateIssuer/DeleteIssuer and
  ListTargets/GetTarget/CreateTarget/UpdateTarget/DeleteTarget)
- Replace 16 context.Background() call sites with received ctx
- Thread r.Context() through 12 HTTP handler sites in issuers.go and
  targets.go (outer TargetHandler.TestTargetConnection HTTP method name
  preserved for router compatibility)
- Update MockIssuerService, MockTargetService, and mockTargetService
  (integration) for ctx-first forwarding; update test callsite literals

Audit complete. Commit: 1f6cf0eafa. Sections: 12. Findings: 2/7/10/4/6.
2026-04-18 00:46:58 +00:00

778 lines
21 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"log/slog"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// TestIssuerService_List tests listing issuers with pagination
func TestIssuerService_List(t *testing.T) {
ctx := context.Background()
issuer1 := &domain.Issuer{
ID: "iss-1",
Name: "ACME Provider",
Type: domain.IssuerTypeACME,
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
issuer2 := &domain.Issuer{
ID: "iss-2",
Name: "Step CA",
Type: domain.IssuerTypeStepCA,
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
issuer3 := &domain.Issuer{
ID: "iss-3",
Name: "Internal CA",
Type: domain.IssuerTypeGenericCA,
Enabled: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
repo := newMockIssuerRepository()
repo.AddIssuer(issuer1)
repo.AddIssuer(issuer2)
repo.AddIssuer(issuer3)
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
issuers, total, err := service.List(ctx, 1, 2)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if total != 3 {
t.Errorf("expected total 3, got %d", total)
}
if len(issuers) != 2 {
t.Errorf("expected 2 issuers on page 1, got %d", len(issuers))
}
// Test page 2
issuers2, _, err := service.List(ctx, 2, 2)
if err != nil {
t.Fatalf("List page 2 failed: %v", err)
}
if len(issuers2) != 1 {
t.Errorf("expected 1 issuer on page 2, got %d", len(issuers2))
}
}
// TestIssuerService_List_DefaultPagination tests list with default pagination values
func TestIssuerService_List_DefaultPagination(t *testing.T) {
ctx := context.Background()
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
// Call with invalid page and perPage
issuers, total, err := service.List(ctx, 0, 0)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if total != 0 {
t.Errorf("expected total 0, got %d", total)
}
if len(issuers) != 0 {
t.Errorf("expected 0 issuers, got %d", len(issuers))
}
}
// TestIssuerService_List_RepositoryError tests list when repository returns error
func TestIssuerService_List_RepositoryError(t *testing.T) {
ctx := context.Background()
repo := newMockIssuerRepository()
repo.ListErr = errors.New("database connection failed")
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
_, _, err := service.List(ctx, 1, 50)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, repo.ListErr) {
t.Errorf("expected error %v, got %v", repo.ListErr, err)
}
}
// TestIssuerService_List_EmptyResult tests list returning empty list
func TestIssuerService_List_EmptyResult(t *testing.T) {
ctx := context.Background()
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
issuers, total, err := service.List(ctx, 1, 50)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if total != 0 {
t.Errorf("expected total 0, got %d", total)
}
if len(issuers) != 0 {
t.Errorf("expected 0 issuers, got %d", len(issuers))
}
}
// TestIssuerService_Get tests retrieving an issuer by ID
func TestIssuerService_Get(t *testing.T) {
ctx := context.Background()
issuer := &domain.Issuer{
ID: "iss-acme-prod",
Name: "ACME Production",
Type: domain.IssuerTypeACME,
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
repo := newMockIssuerRepository()
repo.AddIssuer(issuer)
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
retrieved, err := service.Get(ctx, "iss-acme-prod")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if retrieved.Name != "ACME Production" {
t.Errorf("expected name ACME Production, got %s", retrieved.Name)
}
if retrieved.Type != domain.IssuerTypeACME {
t.Errorf("expected type ACME, got %s", retrieved.Type)
}
}
// TestIssuerService_Get_NotFound tests Get when issuer doesn't exist
func TestIssuerService_Get_NotFound(t *testing.T) {
ctx := context.Background()
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
_, err := service.Get(ctx, "nonexistent-issuer")
if err == nil {
t.Fatal("expected error for nonexistent issuer")
}
}
// TestIssuerService_Create tests creating a new issuer
func TestIssuerService_Create(t *testing.T) {
ctx := context.Background()
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, testEncryptionKey, slog.Default())
config := map[string]interface{}{"endpoint": "https://acme.example.com/v2/new-account"}
configJSON, _ := json.Marshal(config)
issuer := &domain.Issuer{
Name: "Test ACME",
Type: domain.IssuerTypeACME,
Config: configJSON,
Enabled: true,
}
err := service.Create(ctx, issuer, "user-alice")
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if issuer.ID == "" {
t.Error("expected ID to be generated")
}
if issuer.CreatedAt.IsZero() {
t.Error("expected CreatedAt to be set")
}
if issuer.UpdatedAt.IsZero() {
t.Error("expected UpdatedAt to be set")
}
// Verify stored in repo
retrieved, err := repo.Get(ctx, issuer.ID)
if err != nil {
t.Fatalf("failed to retrieve created issuer: %v", err)
}
if retrieved.Name != "Test ACME" {
t.Errorf("expected name Test ACME, got %s", retrieved.Name)
}
// Verify audit event recorded
if len(auditRepo.Events) != 1 {
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
}
if auditRepo.Events[0].Action != "create_issuer" {
t.Errorf("expected action create_issuer, got %s", auditRepo.Events[0].Action)
}
if auditRepo.Events[0].Actor != "user-alice" {
t.Errorf("expected actor user-alice, got %s", auditRepo.Events[0].Actor)
}
}
// TestIssuerService_Create_EmptyName tests Create with empty name validation
func TestIssuerService_Create_EmptyName(t *testing.T) {
ctx := context.Background()
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
issuer := &domain.Issuer{
Name: "",
Type: domain.IssuerTypeACME,
Enabled: true,
}
err := service.Create(ctx, issuer, "user-bob")
if err == nil {
t.Fatal("expected error for empty name")
}
if err.Error() != "issuer name is required" {
t.Errorf("expected 'issuer name is required', got '%v'", err)
}
// Verify no audit event recorded on validation error
if len(auditRepo.Events) != 0 {
t.Errorf("expected 0 audit events on validation error, got %d", len(auditRepo.Events))
}
}
// TestIssuerService_Create_RepositoryError tests Create when repository fails
func TestIssuerService_Create_RepositoryError(t *testing.T) {
ctx := context.Background()
repo := newMockIssuerRepository()
repo.CreateErr = errors.New("database error")
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
issuer := &domain.Issuer{
Name: "Test Issuer",
Type: domain.IssuerTypeACME,
Enabled: true,
}
err := service.Create(ctx, issuer, "user-charlie")
if err == nil {
t.Fatal("expected error from repository")
}
if !errors.Is(err, repo.CreateErr) {
t.Errorf("expected error %v, got %v", repo.CreateErr, err)
}
}
// TestIssuerService_Update tests updating an existing issuer
func TestIssuerService_Update(t *testing.T) {
ctx := context.Background()
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, testEncryptionKey, slog.Default())
config := map[string]interface{}{"endpoint": "https://acme.example.com"}
configJSON, _ := json.Marshal(config)
issuer := &domain.Issuer{
Name: "Updated ACME",
Type: domain.IssuerTypeACME,
Config: configJSON,
Enabled: false,
}
err := service.Update(ctx, "iss-acme-001", issuer, "user-dave")
if err != nil {
t.Fatalf("Update failed: %v", err)
}
if issuer.ID != "iss-acme-001" {
t.Errorf("expected ID to be set to iss-acme-001, got %s", issuer.ID)
}
// Verify audit event recorded
if len(auditRepo.Events) != 1 {
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
}
if auditRepo.Events[0].Action != "update_issuer" {
t.Errorf("expected action update_issuer, got %s", auditRepo.Events[0].Action)
}
if auditRepo.Events[0].ResourceID != "iss-acme-001" {
t.Errorf("expected ResourceID iss-acme-001, got %s", auditRepo.Events[0].ResourceID)
}
}
// TestIssuerService_Update_EmptyName tests Update with empty name validation
func TestIssuerService_Update_EmptyName(t *testing.T) {
ctx := context.Background()
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
issuer := &domain.Issuer{
Name: "",
Type: domain.IssuerTypeACME,
Enabled: true,
}
err := service.Update(ctx, "iss-acme-001", issuer, "user-eve")
if err == nil {
t.Fatal("expected error for empty name")
}
if err.Error() != "issuer name is required" {
t.Errorf("expected 'issuer name is required', got '%v'", err)
}
}
// TestIssuerService_Delete tests deleting an issuer
func TestIssuerService_Delete(t *testing.T) {
ctx := context.Background()
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
err := service.Delete(ctx, "iss-to-delete", "user-frank")
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
// Verify audit event recorded
if len(auditRepo.Events) != 1 {
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
}
if auditRepo.Events[0].Action != "delete_issuer" {
t.Errorf("expected action delete_issuer, got %s", auditRepo.Events[0].Action)
}
if auditRepo.Events[0].ResourceID != "iss-to-delete" {
t.Errorf("expected ResourceID iss-to-delete, got %s", auditRepo.Events[0].ResourceID)
}
}
// TestIssuerService_Delete_RepositoryError tests Delete when repository fails
func TestIssuerService_Delete_RepositoryError(t *testing.T) {
ctx := context.Background()
repo := newMockIssuerRepository()
repo.DeleteErr = errors.New("delete failed")
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
err := service.Delete(ctx, "iss-bad-id", "user-grace")
if err == nil {
t.Fatal("expected error from repository")
}
if !errors.Is(err, repo.DeleteErr) {
t.Errorf("expected error %v, got %v", repo.DeleteErr, err)
}
}
// TestIssuerService_TestConnection_Success tests successful connection test
func TestIssuerService_TestConnection_Success(t *testing.T) {
ctx := context.Background()
// Use GenericCA (Local CA) type because it has no required config fields,
// so ValidateConfig succeeds with empty config.
iss := &domain.Issuer{
ID: "iss-test-conn",
Name: "Test Connection",
Type: domain.IssuerTypeGenericCA,
Config: json.RawMessage(`{"validity_days":365}`),
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
repo := newMockIssuerRepository()
repo.AddIssuer(iss)
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
svc := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
err := svc.TestConnection(ctx, "iss-test-conn")
if err != nil {
t.Fatalf("TestConnection failed: %v", err)
}
}
// TestIssuerService_TestConnection_NotFound tests connection test when issuer not found
func TestIssuerService_TestConnection_NotFound(t *testing.T) {
ctx := context.Background()
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
err := service.TestConnection(ctx, "nonexistent-issuer")
if err == nil {
t.Fatal("expected error for nonexistent issuer")
}
if !errors.Is(err, errNotFound) {
t.Errorf("expected not found error, got %v", err)
}
}
// TestIssuerService_ListIssuers_HandlerInterface tests handler interface method
func TestIssuerService_ListIssuers_HandlerInterface(t *testing.T) {
issuer1 := &domain.Issuer{
ID: "iss-handler-1",
Name: "Handler Test 1",
Type: domain.IssuerTypeACME,
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
issuer2 := &domain.Issuer{
ID: "iss-handler-2",
Name: "Handler Test 2",
Type: domain.IssuerTypeStepCA,
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
repo := newMockIssuerRepository()
repo.AddIssuer(issuer1)
repo.AddIssuer(issuer2)
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
service := NewIssuerService(repo, auditService, NewIssuerRegistry(slog.Default()), "", slog.Default())
ctx := context.Background()
issuers, total, err := service.ListIssuers(ctx, 1, 50)
if err != nil {
t.Fatalf("ListIssuers failed: %v", err)
}
if total != 2 {
t.Errorf("expected total 2, got %d", total)
}
if len(issuers) != 2 {
t.Errorf("expected 2 issuers, got %d", len(issuers))
}
if issuers[0].Name != "Handler Test 1" && issuers[1].Name != "Handler Test 1" {
t.Error("expected to find Handler Test 1 in results")
}
}
// TestIssuerService_CreateIssuer_HandlerInterface tests handler interface create method
func TestIssuerService_CreateIssuer_HandlerInterface(t *testing.T) {
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, testEncryptionKey, slog.Default())
config := map[string]interface{}{"url": "https://example.com"}
configJSON, _ := json.Marshal(config)
issuer := domain.Issuer{
Name: "Handler Create Test",
Type: domain.IssuerTypeGenericCA,
Config: configJSON,
Enabled: true,
}
ctx := context.Background()
result, err := service.CreateIssuer(ctx, issuer)
if err != nil {
t.Fatalf("CreateIssuer failed: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.ID == "" {
t.Error("expected ID to be generated")
}
if result.Name != "Handler Create Test" {
t.Errorf("expected name Handler Create Test, got %s", result.Name)
}
}
// TestIssuerService_DeleteIssuer_HandlerInterface tests handler interface delete method
func TestIssuerService_DeleteIssuer_HandlerInterface(t *testing.T) {
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, "", slog.Default())
ctx := context.Background()
err := service.DeleteIssuer(ctx, "iss-handler-delete")
if err != nil {
t.Fatalf("DeleteIssuer failed: %v", err)
}
}
// TestNormalizeIssuerType tests case-insensitive issuer type normalization.
func TestNormalizeIssuerType(t *testing.T) {
tests := []struct {
input domain.IssuerType
expected domain.IssuerType
}{
// Canonical values pass through unchanged
{domain.IssuerTypeACME, domain.IssuerTypeACME},
{domain.IssuerTypeGenericCA, domain.IssuerTypeGenericCA},
{domain.IssuerTypeStepCA, domain.IssuerTypeStepCA},
{domain.IssuerTypeVault, domain.IssuerTypeVault},
{domain.IssuerTypeDigiCert, domain.IssuerTypeDigiCert},
{domain.IssuerTypeSectigo, domain.IssuerTypeSectigo},
{domain.IssuerTypeGoogleCAS, domain.IssuerTypeGoogleCAS},
{domain.IssuerTypeAWSACMPCA, domain.IssuerTypeAWSACMPCA},
{domain.IssuerTypeEntrust, domain.IssuerTypeEntrust},
{domain.IssuerTypeGlobalSign, domain.IssuerTypeGlobalSign},
{domain.IssuerTypeEJBCA, domain.IssuerTypeEJBCA},
// Lowercase aliases (the actual bug: old frontends send these)
{"acme", domain.IssuerTypeACME},
{"local", domain.IssuerTypeGenericCA},
{"local_ca", domain.IssuerTypeGenericCA},
{"stepca", domain.IssuerTypeStepCA},
{"openssl", domain.IssuerTypeOpenSSL},
{"vaultpki", domain.IssuerTypeVault},
{"digicert", domain.IssuerTypeDigiCert},
{"sectigo", domain.IssuerTypeSectigo},
{"googlecas", domain.IssuerTypeGoogleCAS},
{"awsacmpca", domain.IssuerTypeAWSACMPCA},
{"entrust", domain.IssuerTypeEntrust},
{"globalsign", domain.IssuerTypeGlobalSign},
{"ejbca", domain.IssuerTypeEJBCA},
// Mixed case
{"Acme", domain.IssuerTypeACME},
{"STEPCA", domain.IssuerTypeStepCA},
{"vaultPKI", domain.IssuerTypeVault},
{"GenericCA", domain.IssuerTypeGenericCA},
{"genericca", domain.IssuerTypeGenericCA},
// Unknown types pass through for validation to reject
{"FakeCA", "FakeCA"},
{"", ""},
}
for _, tt := range tests {
t.Run(string(tt.input), func(t *testing.T) {
result := normalizeIssuerType(tt.input)
if result != tt.expected {
t.Errorf("normalizeIssuerType(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// TestIssuerService_Create_LowercaseType tests that Create normalizes lowercase type strings.
func TestIssuerService_Create_LowercaseType(t *testing.T) {
ctx := context.Background()
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, testEncryptionKey, slog.Default())
config := map[string]interface{}{"endpoint": "https://acme.example.com"}
configJSON, _ := json.Marshal(config)
issuer := &domain.Issuer{
Name: "Test Lowercase ACME",
Type: "acme", // lowercase — this is the bug from issue #7
Config: configJSON,
Enabled: true,
}
err := service.Create(ctx, issuer, "user-test")
if err != nil {
t.Fatalf("Create with lowercase 'acme' should succeed, got: %v", err)
}
// Verify the type was normalized to canonical form
if issuer.Type != domain.IssuerTypeACME {
t.Errorf("expected type to be normalized to %q, got %q", domain.IssuerTypeACME, issuer.Type)
}
}
// TestIssuerService_CreateIssuer_LowercaseType tests handler interface path with lowercase type.
func TestIssuerService_CreateIssuer_LowercaseType(t *testing.T) {
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, testEncryptionKey, slog.Default())
config := map[string]interface{}{"url": "https://example.com"}
configJSON, _ := json.Marshal(config)
issuer := domain.Issuer{
Name: "Lowercase StepCA Test",
Type: "stepca", // lowercase
Config: configJSON,
Enabled: true,
}
ctx := context.Background()
result, err := service.CreateIssuer(ctx, issuer)
if err != nil {
t.Fatalf("CreateIssuer with lowercase 'stepca' should succeed, got: %v", err)
}
if result.Type != domain.IssuerTypeStepCA {
t.Errorf("expected type to be normalized to %q, got %q", domain.IssuerTypeStepCA, result.Type)
}
}
// TestIssuerService_Create_M49Types tests that M49 issuer types (Entrust, GlobalSign, EJBCA) are accepted.
func TestIssuerService_Create_M49Types(t *testing.T) {
ctx := context.Background()
m49Types := []struct {
name string
issuerType domain.IssuerType
}{
{"Entrust", domain.IssuerTypeEntrust},
{"GlobalSign", domain.IssuerTypeGlobalSign},
{"EJBCA", domain.IssuerTypeEJBCA},
}
for _, tt := range m49Types {
t.Run(tt.name, func(t *testing.T) {
repo := newMockIssuerRepository()
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
registry := NewIssuerRegistry(slog.Default())
service := NewIssuerService(repo, auditService, registry, testEncryptionKey, slog.Default())
config := map[string]interface{}{"api_url": "https://example.com"}
configJSON, _ := json.Marshal(config)
issuer := &domain.Issuer{
Name: "Test " + tt.name,
Type: tt.issuerType,
Config: configJSON,
Enabled: true,
}
err := service.Create(ctx, issuer, "user-test")
if err != nil {
t.Fatalf("Create with type %q should succeed, got: %v", tt.issuerType, err)
}
})
}
}