package service import ( "context" "errors" "log/slog" "strings" "testing" "time" "github.com/certctl-io/certctl/internal/domain" ) // helper to create a test BulkRevocationService wired for bulk revocation tests func newBulkRevocationTestService() (*BulkRevocationService, *mockCertRepo, *mockRevocationRepo, *mockAuditRepo) { certRepo := newMockCertificateRepository() auditRepo := newMockAuditRepository() revocationRepo := newMockRevocationRepository() auditService := NewAuditService(auditRepo) // Create RevocationSvc (underlying single-cert revocation) revSvc := NewRevocationSvc(certRepo, revocationRepo, auditService) registry := NewIssuerRegistry(slog.Default()) registry.Set("iss-local", &mockIssuerConnector{}) revSvc.SetIssuerRegistry(registry) bulkSvc := NewBulkRevocationService(revSvc, certRepo, auditService, slog.Default()) return bulkSvc, certRepo, revocationRepo, auditRepo } func addTestCert(repo *mockCertRepo, id, status, issuerID string) { cert := &domain.ManagedCertificate{ ID: id, CommonName: id + ".example.com", Status: domain.CertificateStatus(status), IssuerID: issuerID, ExpiresAt: time.Now().AddDate(0, 6, 0), } repo.AddCert(cert) // Add a version with serial number (needed by RevokeCertificateWithActor) repo.Versions[id] = []*domain.CertificateVersion{ { ID: "ver-" + id, CertificateID: id, SerialNumber: "serial-" + id, NotBefore: time.Now(), NotAfter: time.Now().AddDate(1, 0, 0), CreatedAt: time.Now(), }, } } func addTestCertWithProfile(repo *mockCertRepo, id, status, issuerID, profileID, ownerID string) { cert := &domain.ManagedCertificate{ ID: id, CommonName: id + ".example.com", Status: domain.CertificateStatus(status), IssuerID: issuerID, CertificateProfileID: profileID, OwnerID: ownerID, ExpiresAt: time.Now().AddDate(0, 6, 0), } repo.AddCert(cert) repo.Versions[id] = []*domain.CertificateVersion{ { ID: "ver-" + id, CertificateID: id, SerialNumber: "serial-" + id, NotBefore: time.Now(), NotAfter: time.Now().AddDate(1, 0, 0), CreatedAt: time.Now(), }, } } func TestBulkRevoke_ByExplicitIDs(t *testing.T) { svc, certRepo, _, _ := newBulkRevocationTestService() addTestCert(certRepo, "mc-1", "Active", "iss-local") addTestCert(certRepo, "mc-2", "Active", "iss-local") addTestCert(certRepo, "mc-3", "Active", "iss-local") criteria := domain.BulkRevocationCriteria{ CertificateIDs: []string{"mc-1", "mc-2", "mc-3"}, } result, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin") if err != nil { t.Fatalf("expected no error, got: %v", err) } if result.TotalMatched != 3 { t.Errorf("expected TotalMatched=3, got %d", result.TotalMatched) } if result.TotalRevoked != 3 { t.Errorf("expected TotalRevoked=3, got %d", result.TotalRevoked) } if result.TotalSkipped != 0 { t.Errorf("expected TotalSkipped=0, got %d", result.TotalSkipped) } if result.TotalFailed != 0 { t.Errorf("expected TotalFailed=0, got %d", result.TotalFailed) } // Verify certs are revoked for _, id := range []string{"mc-1", "mc-2", "mc-3"} { cert, _ := certRepo.Get(context.Background(), id) if cert.Status != domain.CertificateStatusRevoked { t.Errorf("expected cert %s to be Revoked, got %s", id, cert.Status) } } } func TestBulkRevoke_ByProfile(t *testing.T) { svc, certRepo, _, _ := newBulkRevocationTestService() // The mock List returns all certs regardless of filter (mock limitation). // We test the code path — real repo would filter by profile. addTestCert(certRepo, "mc-1", "Active", "iss-local") addTestCert(certRepo, "mc-2", "Active", "iss-local") criteria := domain.BulkRevocationCriteria{ ProfileID: "prof-tls", } result, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin") if err != nil { t.Fatalf("expected no error, got: %v", err) } if result.TotalMatched != 2 { t.Errorf("expected TotalMatched=2, got %d", result.TotalMatched) } if result.TotalRevoked != 2 { t.Errorf("expected TotalRevoked=2, got %d", result.TotalRevoked) } } func TestBulkRevoke_ByOwner(t *testing.T) { svc, certRepo, _, _ := newBulkRevocationTestService() addTestCertWithProfile(certRepo, "mc-1", "Active", "iss-local", "", "o-alice") addTestCertWithProfile(certRepo, "mc-2", "Active", "iss-local", "", "o-alice") criteria := domain.BulkRevocationCriteria{ OwnerID: "o-alice", } result, err := svc.BulkRevoke(context.Background(), criteria, "cessationOfOperation", "admin") if err != nil { t.Fatalf("expected no error, got: %v", err) } if result.TotalRevoked != 2 { t.Errorf("expected TotalRevoked=2, got %d", result.TotalRevoked) } } func TestBulkRevoke_MultipleCriteria(t *testing.T) { svc, certRepo, _, _ := newBulkRevocationTestService() addTestCertWithProfile(certRepo, "mc-1", "Active", "iss-local", "prof-tls", "o-alice") addTestCertWithProfile(certRepo, "mc-2", "Active", "iss-local", "prof-tls", "o-bob") criteria := domain.BulkRevocationCriteria{ ProfileID: "prof-tls", CertificateIDs: []string{"mc-1"}, // Intersect: only mc-1 from the filter results } result, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin") if err != nil { t.Fatalf("expected no error, got: %v", err) } // Both certs match the filter, but intersection with IDs gives 1 if result.TotalMatched != 1 { t.Errorf("expected TotalMatched=1, got %d", result.TotalMatched) } if result.TotalRevoked != 1 { t.Errorf("expected TotalRevoked=1, got %d", result.TotalRevoked) } // mc-1 should be revoked, mc-2 should not cert1, _ := certRepo.Get(context.Background(), "mc-1") if cert1.Status != domain.CertificateStatusRevoked { t.Errorf("expected mc-1 to be Revoked, got %s", cert1.Status) } cert2, _ := certRepo.Get(context.Background(), "mc-2") if cert2.Status == domain.CertificateStatusRevoked { t.Error("expected mc-2 to NOT be revoked") } } func TestBulkRevoke_EmptyCriteria_Error(t *testing.T) { svc, _, _, _ := newBulkRevocationTestService() criteria := domain.BulkRevocationCriteria{} _, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin") if err == nil { t.Fatal("expected error for empty criteria") } if !strings.Contains(err.Error(), "at least one filter criterion") { t.Errorf("expected 'at least one filter criterion' error, got: %v", err) } } func TestBulkRevoke_InvalidReason_Error(t *testing.T) { svc, _, _, _ := newBulkRevocationTestService() criteria := domain.BulkRevocationCriteria{ CertificateIDs: []string{"mc-1"}, } _, err := svc.BulkRevoke(context.Background(), criteria, "totallyBogus", "admin") if err == nil { t.Fatal("expected error for invalid reason") } if !strings.Contains(err.Error(), "invalid revocation reason") { t.Errorf("expected 'invalid revocation reason' error, got: %v", err) } } func TestBulkRevoke_EmptyReason_Error(t *testing.T) { svc, _, _, _ := newBulkRevocationTestService() criteria := domain.BulkRevocationCriteria{ CertificateIDs: []string{"mc-1"}, } _, err := svc.BulkRevoke(context.Background(), criteria, "", "admin") if err == nil { t.Fatal("expected error for empty reason") } if !strings.Contains(err.Error(), "revocation reason is required") { t.Errorf("expected 'revocation reason is required' error, got: %v", err) } } func TestBulkRevoke_SkipsRevokedAndArchived(t *testing.T) { svc, certRepo, _, _ := newBulkRevocationTestService() addTestCert(certRepo, "mc-active", "Active", "iss-local") addTestCert(certRepo, "mc-revoked", "Revoked", "iss-local") addTestCert(certRepo, "mc-archived", "Archived", "iss-local") criteria := domain.BulkRevocationCriteria{ CertificateIDs: []string{"mc-active", "mc-revoked", "mc-archived"}, } result, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin") if err != nil { t.Fatalf("expected no error, got: %v", err) } if result.TotalMatched != 3 { t.Errorf("expected TotalMatched=3, got %d", result.TotalMatched) } if result.TotalRevoked != 1 { t.Errorf("expected TotalRevoked=1, got %d", result.TotalRevoked) } if result.TotalSkipped != 2 { t.Errorf("expected TotalSkipped=2, got %d", result.TotalSkipped) } } func TestBulkRevoke_PartialFailure(t *testing.T) { svc, certRepo, _, _ := newBulkRevocationTestService() // mc-1 is active with version — will succeed addTestCert(certRepo, "mc-1", "Active", "iss-local") // mc-2 is active but has NO version — RevokeCertificateWithActor will fail on GetLatestVersion cert2 := &domain.ManagedCertificate{ ID: "mc-2", CommonName: "mc-2.example.com", Status: domain.CertificateStatusActive, IssuerID: "iss-local", ExpiresAt: time.Now().AddDate(0, 6, 0), } certRepo.AddCert(cert2) // Don't add versions for mc-2 so GetLatestVersion returns errNotFound criteria := domain.BulkRevocationCriteria{ CertificateIDs: []string{"mc-1", "mc-2"}, } result, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin") if err != nil { t.Fatalf("expected no error (partial failure is ok), got: %v", err) } if result.TotalMatched != 2 { t.Errorf("expected TotalMatched=2, got %d", result.TotalMatched) } if result.TotalRevoked != 1 { t.Errorf("expected TotalRevoked=1, got %d", result.TotalRevoked) } if result.TotalFailed != 1 { t.Errorf("expected TotalFailed=1, got %d", result.TotalFailed) } if len(result.Errors) != 1 { t.Fatalf("expected 1 error entry, got %d", len(result.Errors)) } if result.Errors[0].CertificateID != "mc-2" { t.Errorf("expected error for mc-2, got %s", result.Errors[0].CertificateID) } } func TestBulkRevoke_AuditEvent(t *testing.T) { svc, certRepo, _, auditRepo := newBulkRevocationTestService() addTestCert(certRepo, "mc-1", "Active", "iss-local") criteria := domain.BulkRevocationCriteria{ CertificateIDs: []string{"mc-1"}, } _, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin") if err != nil { t.Fatalf("expected no error, got: %v", err) } // Find the bulk_revocation_initiated audit event var found bool for _, event := range auditRepo.Events { if event.Action == "bulk_revocation_initiated" { found = true if event.Actor != "admin" { t.Errorf("expected actor 'admin', got '%s'", event.Actor) } if event.ResourceType != "certificate" { t.Errorf("expected resource type 'certificate', got '%s'", event.ResourceType) } break } } if !found { t.Error("expected bulk_revocation_initiated audit event") } } func TestBulkRevoke_NoMatches(t *testing.T) { svc, _, _, _ := newBulkRevocationTestService() // IDs that don't exist in the repo criteria := domain.BulkRevocationCriteria{ CertificateIDs: []string{"mc-nonexistent-1", "mc-nonexistent-2"}, } result, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin") if err != nil { t.Fatalf("expected no error, got: %v", err) } if result.TotalMatched != 0 { t.Errorf("expected TotalMatched=0, got %d", result.TotalMatched) } if result.TotalRevoked != 0 { t.Errorf("expected TotalRevoked=0, got %d", result.TotalRevoked) } } func TestBulkRevoke_ListError(t *testing.T) { svc, certRepo, _, _ := newBulkRevocationTestService() certRepo.ListErr = errors.New("database connection failed") criteria := domain.BulkRevocationCriteria{ ProfileID: "prof-tls", } _, err := svc.BulkRevoke(context.Background(), criteria, "keyCompromise", "admin") if err == nil { t.Fatal("expected error from list failure") } if !strings.Contains(err.Error(), "failed to resolve certificates") { t.Errorf("expected 'failed to resolve certificates' error, got: %v", err) } }