diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c6124b..b8781cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,11 @@
+v1.2.0
+- Added support for CSC TrustedSecure EV, Multiple Names; CSC TrustedSecure OV Wildcard, Multiple Names; and CSC TrustedSecure DV Wildcard, Multiple Names certificate products
+- Renamed all certificate template product IDs to match CSC's current certificate type names (e.g. "CSC TrustedSecure Premium Certificate" is now "CSC TrustedSecure OV", "CSC TrustedSecure Domain Validated SSL" is now "CSC TrustedSecure DV"). Existing Certificate Templates in Command using the old names continue to work; new Templates should use the new names.
+- Fixed the "Addtl Sans Comma Separated DCV Emails" enrollment field never actually being read during enrollment, due to a typo in the code looking up "DVC" instead of "DCV". Per-domain DCV emails for additional SANs on unrelated domains were silently ignored, falling back to the primary CN's DCV email - which does not have authority to validate a different domain.
+- Fixed a case-sensitivity bug ("priorcertsn" vs "PriorCertSN") that prevented PriorCertSN from ever being read during Renew/Reissue enrollment.
+- Fixed a crash when CSC Global returns a null "price.total" (e.g. reissuing a certificate that is not in an active status) - Price.Total is now nullable instead of causing a JSON deserialization exception.
+- Added an xUnit test suite covering certificate type/SAN/EV routing, legacy product name backward compatibility, and the fixes above.
+
v.1.1.1
- Added Incremental Sync that goes back X Number of days
- Fixed issue with parsing certain certificates that were in zip format
diff --git a/README.md b/README.md
index 5effcf2..c185bec 100644
--- a/README.md
+++ b/README.md
@@ -96,16 +96,16 @@ If a field value is specified as both an Enrollment Field in Command and in the
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure Premium Certificate
-Template Display Name | CSC TrustedSecure Premium Certificate
-Friendly Name | CSC TrustedSecure Premium Certificate
+Template Short Name | CSC TrustedSecure OV
+Template Display Name | CSC TrustedSecure OV
+Friendly Name | CSC TrustedSecure OV
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
-**CSC TrustedSecure Premium Certificate - Enrollment Fields**
+**CSC TrustedSecure OV - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
@@ -120,20 +120,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients
Notification Email(s) Comma Separated | String | N/A
CN DCV Email | String | N/A
-**CSC TrustedSecure EV Certificate - Details Tab**
+**CSC TrustedSecure EV - Details Tab**
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure EV Certificate
-Template Display Name | CSC TrustedSecure EV Certificate
-Friendly Name | CSC TrustedSecure EV Certificate
+Template Short Name | CSC TrustedSecure EV
+Template Display Name | CSC TrustedSecure EV
+Friendly Name | CSC TrustedSecure EV
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
-**CSC TrustedSecure EV Certificate - Enrollment Fields**
+**CSC TrustedSecure EV - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
@@ -149,20 +149,20 @@ Notification Email(s) Comma Separated | String | N/A
CN DCV Email | String | N/A
Organization Country | String | N/A
-**CSC TrustedSecure UC Certificate - Details Tab**
+**CSC TrustedSecure OV, Multiple Names - Details Tab**
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure UC Certificate
-Template Display Name | CSC TrustedSecure UC Certificate
-Friendly Name | CSC TrustedSecure UC Certificate
+Template Short Name | CSC TrustedSecure OV, Multiple Names
+Template Display Name | CSC TrustedSecure OV, Multiple Names
+Friendly Name | CSC TrustedSecure OV, Multiple Names
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
-**CSC TrustedSecure UC Certificate - Enrollment Fields**
+**CSC TrustedSecure OV, Multiple Names - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
@@ -179,20 +179,20 @@ CN DCV Email | String | N/A
Addtl Sans Comma Separated DCV Emails | String | N/A
-**CSC TrustedSecure Premium Wildcard Certificate - Details Tab**
+**CSC TrustedSecure OV Wildcard - Details Tab**
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure Premium Wildcard Certificate
-Template Display Name | CSC TrustedSecure Premium Wildcard Certificate
-Friendly Name | CSC TrustedSecure Premium Wildcard Certificate
+Template Short Name | CSC TrustedSecure OV Wildcard
+Template Display Name | CSC TrustedSecure OV Wildcard
+Friendly Name | CSC TrustedSecure OV Wildcard
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
-**CSC TrustedSecure Premium Wildcard Certificate - Enrollment Fields**
+**CSC TrustedSecure OV Wildcard - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
@@ -207,20 +207,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients
Notification Email(s) Comma Separated | String | N/A
CN DCV Email | String | N/A
-**CSC TrustedSecure Domain Validated SSL - Details Tab**
+**CSC TrustedSecure DV - Details Tab**
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure Domain Validated SSL
-Template Display Name | CSC TrustedSecure Domain Validated SSL
-Friendly Name | CSC TrustedSecure Domain Validated SSL
+Template Short Name | CSC TrustedSecure DV
+Template Display Name | CSC TrustedSecure DV
+Friendly Name | CSC TrustedSecure DV
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
-**CSC TrustedSecure Domain Validated SSL - Enrollment Fields**
+**CSC TrustedSecure DV - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
@@ -235,20 +235,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients
Notification Email(s) Comma Separated | String | N/A
CN DCV Email | String | N/A
-**CSC TrustedSecure Domain Validated Wildcard SSL - Details Tab**
+**CSC TrustedSecure DV Wildcard - Details Tab**
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure Domain Validated Wildcard SSL
-Template Display Name | CSC TrustedSecure Domain Validated Wildcard SSL
-Friendly Name | CSC TrustedSecure Domain Validated Wildcard SSL
+Template Short Name | CSC TrustedSecure DV Wildcard
+Template Display Name | CSC TrustedSecure DV Wildcard
+Friendly Name | CSC TrustedSecure DV Wildcard
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
-**CSC TrustedSecure Domain Validated Wildcard SSL - Enrollment Fields**
+**CSC TrustedSecure DV Wildcard - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
@@ -263,20 +263,108 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients
Notification Email(s) Comma Separated | String | N/A
CN DCV Email | String | N/A
-**CSC TrustedSecure Domain Validated UC Certificate - Details Tab**
+**CSC TrustedSecure DV, Multiple Names - Details Tab**
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure Domain Validated UC Certificate
-Template Display Name | CSC TrustedSecure Domain Validated UC Certificate
-Friendly Name | CSC TrustedSecure Domain Validated UC Certificate
+Template Short Name | CSC TrustedSecure DV, Multiple Names
+Template Display Name | CSC TrustedSecure DV, Multiple Names
+Friendly Name | CSC TrustedSecure DV, Multiple Names
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
-**CSC TrustedSecure Domain Validated UC Certificate - Enrollment Fields**
+**CSC TrustedSecure DV, Multiple Names - Enrollment Fields**
+
+NAME | DATA TYPE | VALUES
+-----|--------------|-----------------
+Term | Multiple Choice | 12,24
+Applicant First Name | String | N/A
+Applicant Last Name | String | N/A
+Applicant Email Address | String | N/A
+Applicant Phone | String | N/A
+Domain Control Validation Method | Multiple Choice | EMAIL
+Organization Contact | Multiple Choice | Get From CSC Differs For Clients
+Business Unit | Multiple Choice | Get From CSC Differs For Clients
+Notification Email(s) Comma Separated | String | N/A
+CN DCV Email | String | N/A
+Addtl Sans Comma Separated DCV Emails | String | N/A
+
+**CSC TrustedSecure EV, Multiple Names - Details Tab**
+
+CONFIG ELEMENT | DESCRIPTION
+----------------------------|------------------
+Template Short Name | CSC TrustedSecure EV, Multiple Names
+Template Display Name | CSC TrustedSecure EV, Multiple Names
+Friendly Name | CSC TrustedSecure EV, Multiple Names
+Keys Size | 2048
+Enforce RFC 2818 Compliance | True
+CSR Enrollment | True
+Pfx Enrollment | True
+
+
+**CSC TrustedSecure EV, Multiple Names - Enrollment Fields**
+
+NAME | DATA TYPE | VALUES
+-----|--------------|-----------------
+Term | Multiple Choice | 12,24
+Applicant First Name | String | N/A
+Applicant Last Name | String | N/A
+Applicant Email Address | String | N/A
+Applicant Phone | String | N/A
+Domain Control Validation Method | Multiple Choice | EMAIL
+Organization Contact | Multiple Choice | Get From CSC Differs For Clients
+Business Unit | Multiple Choice | Get From CSC Differs For Clients
+Notification Email(s) Comma Separated | String | N/A
+CN DCV Email | String | N/A
+Addtl Sans Comma Separated DCV Emails | String | N/A
+Organization Country | String | N/A
+
+**CSC TrustedSecure OV Wildcard, Multiple Names - Details Tab**
+
+CONFIG ELEMENT | DESCRIPTION
+----------------------------|------------------
+Template Short Name | CSC TrustedSecure OV Wildcard, Multiple Names
+Template Display Name | CSC TrustedSecure OV Wildcard, Multiple Names
+Friendly Name | CSC TrustedSecure OV Wildcard, Multiple Names
+Keys Size | 2048
+Enforce RFC 2818 Compliance | True
+CSR Enrollment | True
+Pfx Enrollment | True
+
+
+**CSC TrustedSecure OV Wildcard, Multiple Names - Enrollment Fields**
+
+NAME | DATA TYPE | VALUES
+-----|--------------|-----------------
+Term | Multiple Choice | 12,24
+Applicant First Name | String | N/A
+Applicant Last Name | String | N/A
+Applicant Email Address | String | N/A
+Applicant Phone | String | N/A
+Domain Control Validation Method | Multiple Choice | EMAIL
+Organization Contact | Multiple Choice | Get From CSC Differs For Clients
+Business Unit | Multiple Choice | Get From CSC Differs For Clients
+Notification Email(s) Comma Separated | String | N/A
+CN DCV Email | String | N/A
+Addtl Sans Comma Separated DCV Emails | String | N/A
+
+**CSC TrustedSecure DV Wildcard, Multiple Names - Details Tab**
+
+CONFIG ELEMENT | DESCRIPTION
+----------------------------|------------------
+Template Short Name | CSC TrustedSecure DV Wildcard, Multiple Names
+Template Display Name | CSC TrustedSecure DV Wildcard, Multiple Names
+Friendly Name | CSC TrustedSecure DV Wildcard, Multiple Names
+Keys Size | 2048
+Enforce RFC 2818 Compliance | True
+CSR Enrollment | True
+Pfx Enrollment | True
+
+
+**CSC TrustedSecure DV Wildcard, Multiple Names - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj
new file mode 100644
index 0000000..823e49f
--- /dev/null
+++ b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ CSCGlobalCAPlugin.Tests
+ CscGlobalCAPluginTests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs
new file mode 100644
index 0000000..a763f2e
--- /dev/null
+++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs
@@ -0,0 +1,1901 @@
+// Copyright 2021 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces;
+using Keyfactor.PKI.Enums.EJBCA;
+using Moq;
+using Xunit;
+
+namespace CscGlobalCAPluginTests;
+
+public class CSCGlobalCAPluginTests
+{
+ private static EnrollmentProductInfo ProductInfo(string productId = "CSC TrustedSecure DV",
+ Dictionary? parameters = null) => new EnrollmentProductInfo
+ {
+ ProductID = productId,
+ ProductParameters = parameters ?? new Dictionary()
+ };
+
+ private static Mock ConfigProviderMock(Dictionary? overrides = null)
+ {
+ var data = new Dictionary
+ {
+ [Constants.CscGlobalApiKey] = "api-key",
+ [Constants.CscGlobalUrl] = "https://example.invalid/",
+ [Constants.BearerToken] = "bearer-token"
+ };
+ if (overrides != null)
+ foreach (var kv in overrides)
+ data[kv.Key] = kv.Value;
+
+ var mock = new Mock();
+ mock.Setup(c => c.CAConnectionData).Returns(data);
+ return mock;
+ }
+
+ private static CSCGlobalCAPlugin MakePlugin(Mock? client = null,
+ Mock? certDataReader = null, Dictionary? configOverrides = null,
+ IDomainValidatorFactory? validatorFactory = null)
+ {
+ var plugin = validatorFactory != null ? new CSCGlobalCAPlugin(validatorFactory) : new CSCGlobalCAPlugin();
+ plugin.Initialize(ConfigProviderMock(configOverrides).Object,
+ (certDataReader ?? new Mock()).Object);
+ plugin.CscGlobalClient = (client ?? new Mock()).Object;
+ return plugin;
+ }
+
+ private static (X509Certificate2 Cert, string Pem) MakeSelfSignedCert(string cn = "test.example.com", bool isCa = false)
+ {
+ using var rsa = RSA.Create(2048);
+ var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ req.CertificateExtensions.Add(new X509BasicConstraintsExtension(isCa, false, 0, true));
+ var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365));
+ var pem = "-----BEGIN CERTIFICATE-----\n" +
+ Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks) +
+ "\n-----END CERTIFICATE-----\n";
+ return (cert, pem);
+ }
+
+ // ---------------------------------------------------------------------
+ // Initialize
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void Initialize_NullConfigProvider_Throws()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ Assert.Throws(() => plugin.Initialize(null!, Mock.Of()));
+ }
+
+ [Fact]
+ public void Initialize_NullCertificateDataReader_Throws()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ Assert.Throws(() => plugin.Initialize(ConfigProviderMock().Object, null!));
+ }
+
+ [Fact]
+ public void Initialize_NullCAConnectionData_Throws()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ var mock = new Mock();
+ mock.Setup(c => c.CAConnectionData).Returns((Dictionary)null!);
+ Assert.Throws(() => plugin.Initialize(mock.Object, Mock.Of()));
+ }
+
+ [Fact]
+ public void Initialize_EnabledDefault_ConstructsRealClient()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(ConfigProviderMock().Object, Mock.Of());
+ Assert.True(plugin.Enabled);
+ Assert.NotNull(plugin.CscGlobalClient);
+ }
+
+ [Fact]
+ public void Initialize_ExplicitlyDisabled_SkipsClientCreation()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = "false" }).Object,
+ Mock.Of());
+ Assert.False(plugin.Enabled);
+ Assert.Null(plugin.CscGlobalClient);
+ }
+
+ [Fact]
+ public void Initialize_UnparsableEnabled_DefaultsToTrue()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = "not-a-bool" }).Object,
+ Mock.Of());
+ Assert.True(plugin.Enabled);
+ }
+
+ [Fact]
+ public void Initialize_EnabledButMissingApiKey_Throws()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ var mock = new Mock();
+ mock.Setup(c => c.CAConnectionData).Returns(new Dictionary());
+ Assert.Throws(() => plugin.Initialize(mock.Object, Mock.Of()));
+ }
+
+ [Theory]
+ [InlineData("10", 10)]
+ [InlineData("not-a-number", 0)]
+ public void Initialize_SyncFilterDays_ParsesOrDefaults(string raw, int expected)
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.SyncFilterDays] = raw }).Object,
+ Mock.Of());
+ Assert.Equal(expected, plugin.SyncFilterDays);
+ }
+
+ [Theory]
+ [InlineData("45", 45)]
+ [InlineData("not-a-number", 30)]
+ [InlineData("-5", 30)]
+ public void Initialize_RenewalWindowDays_ParsesOrDefaults(string raw, int expected)
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.RenewalWindowDays] = raw }).Object,
+ Mock.Of());
+ Assert.Equal(expected, plugin.RenewalWindowDays);
+ }
+
+ [Fact]
+ public void Initialize_RenewalWindowDaysNotConfigured_DefaultsTo30()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(ConfigProviderMock().Object, Mock.Of());
+ Assert.Equal(30, plugin.RenewalWindowDays);
+ }
+
+ [Theory]
+ [InlineData("5", 5)]
+ [InlineData("not-a-number", 0)]
+ [InlineData("-1", 0)]
+ public void Initialize_DcvPollTimeoutSeconds_ParsesOrDefaults(string raw, int expected)
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.DcvPollTimeoutSeconds] = raw }).Object,
+ Mock.Of());
+ Assert.Equal(expected, plugin.DcvPollTimeoutSeconds);
+ }
+
+ [Fact]
+ public void Initialize_EnabledKeyPresentButNullValue_DefaultsToTrue()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = null! }).Object,
+ Mock.Of());
+ Assert.True(plugin.Enabled);
+ }
+
+ [Fact]
+ public void Initialize_SyncFilterDaysKeyPresentButNullValue_DefaultsToZero()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.SyncFilterDays] = null! }).Object,
+ Mock.Of());
+ Assert.Equal(0, plugin.SyncFilterDays);
+ }
+
+ [Fact]
+ public void Initialize_RenewalWindowDaysKeyPresentButNullValue_DefaultsTo30()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.RenewalWindowDays] = null! }).Object,
+ Mock.Of());
+ Assert.Equal(30, plugin.RenewalWindowDays);
+ }
+
+ [Fact]
+ public void Initialize_DcvPollTimeoutSecondsKeyPresentButNullValue_DefaultsToZero()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.DcvPollTimeoutSeconds] = null! }).Object,
+ Mock.Of());
+ Assert.Equal(0, plugin.DcvPollTimeoutSeconds);
+ }
+
+ [Fact]
+ public void Initialize_WithValidatorFactory_DoesNotThrow()
+ {
+ var plugin = new CSCGlobalCAPlugin(Mock.Of());
+ plugin.Initialize(ConfigProviderMock().Object, Mock.Of());
+ Assert.True(plugin.Enabled);
+ }
+
+ // ---------------------------------------------------------------------
+ // GetSingleRecord
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task GetSingleRecord_NullId_Throws()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() => plugin.GetSingleRecord(null!));
+ }
+
+ [Fact]
+ public async Task GetSingleRecord_TooShortId_Throws()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() => plugin.GetSingleRecord("short-id"));
+ }
+
+ [Fact]
+ public async Task GetSingleRecord_NullClientResponse_ReturnsFailedMappedStatus()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync((CertificateResponse)null!);
+
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.GetSingleRecord(uuid);
+
+ Assert.Equal(uuid, result.CARequestID);
+ Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
+ }
+
+ [Fact]
+ public async Task GetSingleRecord_ValidId_ReturnsMappedCertificate()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var (cert, pem) = MakeSelfSignedCert();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse
+ {
+ Certificate = Convert.ToBase64String(Encoding.ASCII.GetBytes(pem)),
+ Status = "ACTIVE"
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.GetSingleRecord(uuid);
+
+ Assert.Equal(uuid, result.CARequestID);
+ Assert.Equal((int)EndEntityStatus.GENERATED, result.Status);
+ Assert.Equal(Convert.ToBase64String(cert.RawData), result.Certificate);
+ }
+
+ [Fact]
+ public async Task GetSingleRecord_InvalidBase64Certificate_ReturnsEmptyCertificate()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse
+ {
+ Certificate = Convert.ToBase64String(Encoding.ASCII.GetBytes("not valid pem at all")),
+ Status = "ACTIVE"
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.GetSingleRecord(uuid);
+
+ Assert.Equal(string.Empty, result.Certificate);
+ }
+
+ [Fact]
+ public async Task GetSingleRecord_ClientThrows_WrapsException()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("boom"));
+
+ var plugin = MakePlugin(mockClient);
+ await Assert.ThrowsAsync(() => plugin.GetSingleRecord(uuid));
+ }
+
+ // ---------------------------------------------------------------------
+ // Synchronize / SyncCertificates
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task Synchronize_NullBuffer_Throws()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() => plugin.Synchronize(null!, null, true, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Synchronize_Disabled_CompletesImmediatelyWithoutCallingClient()
+ {
+ var mockClient = new Mock();
+ var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.Enabled] = "false" });
+ var buffer = new System.Collections.Concurrent.BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ Assert.True(buffer.IsAddingCompleted);
+ mockClient.Verify(c => c.SubmitCertificateListRequestAsync(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Synchronize_FullSync_QueuesGeneratedAndRevokedOnly()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(null)).ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List
+ {
+ new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = null, CertificateType = "4" },
+ new CertificateResponse { Uuid = "u2", Status = "Pending", Certificate = null, CertificateType = "4" },
+ null!
+ }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new System.Collections.Concurrent.BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ Assert.True(buffer.IsAddingCompleted);
+ // Neither item has actual certificate bytes, so both get skipped after status-eligibility
+ // check; this exercises the eligible-but-empty-content and null-item paths.
+ Assert.Empty(buffer);
+ }
+
+ [Fact]
+ public async Task Synchronize_IncrementalSync_UsesFilterDate()
+ {
+ var mockClient = new Mock();
+ string? capturedFilter = "not-called";
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .Callback(f => capturedFilter = f)
+ .ReturnsAsync(new CertificateListResponse { Results = new List() });
+
+ var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.SyncFilterDays] = "10" });
+ var buffer = new System.Collections.Concurrent.BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, false, CancellationToken.None);
+
+ Assert.NotNull(capturedFilter);
+ Assert.NotEqual("not-called", capturedFilter);
+ }
+
+ [Fact]
+ public async Task Synchronize_IncrementalSync_SyncFilterDaysNotConfigured_DefaultsToFiveDays()
+ {
+ var mockClient = new Mock();
+ string? capturedFilter = "not-called";
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .Callback(f => capturedFilter = f)
+ .ReturnsAsync(new CertificateListResponse { Results = new List() });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new System.Collections.Concurrent.BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, false, CancellationToken.None);
+
+ var expected = DateTime.Today.Subtract(TimeSpan.FromDays(5)).ToString("yyyy/MM/dd");
+ Assert.Equal(expected, capturedFilter);
+ }
+
+ [Fact]
+ public async Task Synchronize_NullResultsFromClient_CompletesWithoutError()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .ReturnsAsync((CertificateListResponse)null!);
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new System.Collections.Concurrent.BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ Assert.True(buffer.IsAddingCompleted);
+ }
+
+ [Fact]
+ public async Task Synchronize_NullResultsCollection_CompletesWithoutError()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .ReturnsAsync(new CertificateListResponse { Results = null });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new System.Collections.Concurrent.BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ Assert.True(buffer.IsAddingCompleted);
+ }
+
+ [Fact]
+ public async Task Synchronize_ValidCertificateContent_AddsToBufferWithMappedProductId()
+ {
+ var (_, pem) = MakeSelfSignedCert();
+ var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(pem));
+
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List
+ {
+ new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "CSC TrustedSecure DV" }
+ }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new System.Collections.Concurrent.BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ var items = buffer.ToArray();
+ Assert.Single(items);
+ Assert.Equal("u1", items[0].CARequestID);
+ // CSC's list/sync API returns the certificate's current product name directly, so the
+ // synced ProductID must match it verbatim (and therefore match the canonical Certificate
+ // Profile name configured in Command) rather than going through a name-remapping table.
+ Assert.Equal("CSC TrustedSecure DV", items[0].ProductID);
+ }
+
+ [Fact]
+ public async Task Synchronize_MalformedBase64Certificate_SkipsItem()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List
+ {
+ new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = "not valid base64 at all!!", CertificateType = "4" }
+ }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new System.Collections.Concurrent.BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ Assert.Empty(buffer);
+ }
+
+ [Fact]
+ public async Task Synchronize_ValidBase64ButNoPemCertificates_SkipsItem()
+ {
+ var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("this is not a PEM certificate"));
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List
+ {
+ new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "4" }
+ }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new System.Collections.Concurrent.BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ Assert.Empty(buffer);
+ }
+
+ [Fact]
+ public async Task Synchronize_RevokedStatus_AlsoQualifiesForSync()
+ {
+ var (_, pem) = MakeSelfSignedCert();
+ var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(pem));
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List
+ {
+ new CertificateResponse { Uuid = "u1", Status = "REVOKED", Certificate = apiBase64, CertificateType = "4" }
+ }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new System.Collections.Concurrent.BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ Assert.Single(buffer);
+ }
+
+ [Fact]
+ public async Task Synchronize_ClientThrows_PropagatesAndCompletesBuffer()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("boom"));
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new System.Collections.Concurrent.BlockingCollection();
+
+ await Assert.ThrowsAsync(() => plugin.Synchronize(buffer, null, true, CancellationToken.None));
+ Assert.True(buffer.IsAddingCompleted);
+ }
+
+ [Fact]
+ public async Task Synchronize_Cancelled_ThrowsOperationCanceledAndCompletesBuffer()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List { new CertificateResponse { Uuid = "u1", Status = "ACTIVE" } }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new System.Collections.Concurrent.BlockingCollection();
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Assert.ThrowsAsync(() => plugin.Synchronize(buffer, null, true, cts.Token));
+ Assert.True(buffer.IsAddingCompleted);
+ }
+
+ // ---------------------------------------------------------------------
+ // Revoke
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task Revoke_Disabled_Throws()
+ {
+ var plugin = MakePlugin(configOverrides: new Dictionary { [Constants.Enabled] = "false" });
+ await Assert.ThrowsAsync(() =>
+ plugin.Revoke(new string('a', 36), "serial", 0));
+ }
+
+ [Fact]
+ public async Task Revoke_TooShortId_Throws()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() => plugin.Revoke("short", "serial", 0));
+ }
+
+ [Fact]
+ public async Task Revoke_NullId_Throws()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() => plugin.Revoke(null!, "serial", 0));
+ }
+
+ [Fact]
+ public async Task Revoke_NullResponse_Throws()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync((RevokeResponse)null!);
+
+ var plugin = MakePlugin(mockClient);
+ // Wrapped by the generic catch (Exception e) at the bottom of Revoke, since
+ // InvalidOperationException isn't AggregateException or HttpRequestException.
+ var ex = await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "serial", 0));
+ Assert.IsType(ex.InnerException);
+ }
+
+ [Fact]
+ public async Task Revoke_Success_ReturnsRevoked()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse
+ {
+ RevokeSuccess = new RevokeSuccessResponse { Status = "REVOKED" }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.Revoke(uuid, "serial", 0);
+
+ Assert.Equal((int)EndEntityStatus.REVOKED, result);
+ }
+
+ [Fact]
+ public async Task Revoke_ErrorWithDescription_ThrowsHttpRequestException()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse
+ {
+ RegistrationError = new RegistrationError { Description = "already revoked" }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "serial", 0));
+ }
+
+ [Fact]
+ public async Task Revoke_ClientThrows_WrapsException()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("boom"));
+
+ var plugin = MakePlugin(mockClient);
+ await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "serial", 0));
+ }
+
+ // ---------------------------------------------------------------------
+ // Ping / ValidateCAConnectionInfo
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task Ping_Enabled_DoesNotThrow()
+ {
+ var plugin = MakePlugin();
+ await plugin.Ping();
+ }
+
+ [Fact]
+ public async Task Ping_Disabled_DoesNotThrow()
+ {
+ var plugin = MakePlugin(configOverrides: new Dictionary { [Constants.Enabled] = "false" });
+ await plugin.Ping();
+ }
+
+ [Fact]
+ public async Task ValidateCAConnectionInfo_NullConnectionInfo_Throws()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() => plugin.ValidateCAConnectionInfo(null!));
+ }
+
+ [Fact]
+ public async Task ValidateCAConnectionInfo_Enabled_DoesNotThrow()
+ {
+ var plugin = MakePlugin();
+ await plugin.ValidateCAConnectionInfo(new Dictionary());
+ }
+
+ [Fact]
+ public async Task ValidateCAConnectionInfo_ExplicitlyDisabled_DoesNotThrow()
+ {
+ var plugin = MakePlugin();
+ await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "false" });
+ }
+
+ [Fact]
+ public async Task ValidateCAConnectionInfo_UnparsableEnabledValue_TreatsAsEnabled()
+ {
+ var plugin = MakePlugin();
+ await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "not-a-bool" });
+ }
+
+ // ---------------------------------------------------------------------
+ // ValidateProductInfo
+ // ---------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("CSC TrustedSecure DV")]
+ [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names")]
+ public async Task ValidateProductInfo_CanonicalProductName_DoesNotThrow(string productId)
+ {
+ var plugin = MakePlugin();
+ await plugin.ValidateProductInfo(ProductInfo(productId), new Dictionary());
+ }
+
+ [Theory]
+ [InlineData("CSC TrustedSecure UC Certificate")]
+ [InlineData("CSC TrustedSecure Domain Validated SSL")]
+ [InlineData("CSC Trusted Secure Domain Validated Wildcard SSL")]
+ public async Task ValidateProductInfo_LegacyProductName_DoesNotThrow(string legacyProductId)
+ {
+ var plugin = MakePlugin();
+ await plugin.ValidateProductInfo(ProductInfo(legacyProductId), new Dictionary());
+ }
+
+ [Fact]
+ public async Task ValidateProductInfo_NullProductInfo_Throws()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() =>
+ plugin.ValidateProductInfo(null!, new Dictionary()));
+ }
+
+ [Fact]
+ public async Task ValidateProductInfo_EmptyProductId_Throws()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() =>
+ plugin.ValidateProductInfo(ProductInfo(""), new Dictionary()));
+ }
+
+ [Fact]
+ public async Task ValidateProductInfo_UnknownProduct_Throws()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() =>
+ plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), new Dictionary()));
+ }
+
+ [Fact]
+ public async Task ValidateProductInfo_NullConnectionInfo_TreatsAsEnabled()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() =>
+ plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), null!));
+ }
+
+ [Fact]
+ public async Task ValidateProductInfo_DisabledConnector_SkipsValidationEvenForUnknownProduct()
+ {
+ var plugin = MakePlugin();
+ var connectionInfo = new Dictionary { [Constants.Enabled] = "false" };
+
+ // Should not throw even though the product is unknown - Enabled=false short-circuits
+ // validation entirely (pre-configuration workflow).
+ await plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), connectionInfo);
+ }
+
+ [Fact]
+ public async Task ValidateProductInfo_UnparsableEnabledValue_TreatsAsEnabled()
+ {
+ var plugin = MakePlugin();
+ var connectionInfo = new Dictionary { [Constants.Enabled] = "not-a-bool" };
+ await Assert.ThrowsAsync(() =>
+ plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), connectionInfo));
+ }
+
+ // ---------------------------------------------------------------------
+ // Annotations / product IDs
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void GetCAConnectorAnnotations_ReturnsExpectedKeys()
+ {
+ var plugin = MakePlugin();
+ var annotations = plugin.GetCAConnectorAnnotations();
+ Assert.Contains(Constants.Enabled, annotations.Keys);
+ Assert.Contains(Constants.CscGlobalUrl, annotations.Keys);
+ Assert.Contains(Constants.DcvPollTimeoutSeconds, annotations.Keys);
+ }
+
+ [Fact]
+ public void GetTemplateParameterAnnotations_ReturnsExpectedKeys()
+ {
+ var plugin = MakePlugin();
+ var annotations = plugin.GetTemplateParameterAnnotations();
+ Assert.Contains(EnrollmentConfigConstants.CnDcvEmail, annotations.Keys);
+ Assert.Contains(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails, annotations.Keys);
+ }
+
+ [Fact]
+ public void GetProductIds_ReturnsCanonicalTenProducts()
+ {
+ var plugin = MakePlugin();
+ Assert.Equal(10, plugin.GetProductIds().Count);
+ }
+
+ // ---------------------------------------------------------------------
+ // Enroll - validation and New enrollment
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task Enroll_Disabled_ReturnsFailedWithoutCallingClient()
+ {
+ var mockClient = new Mock();
+ var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.Enabled] = "false" });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
+ mockClient.Verify(c => c.SubmitGetCustomFields(), Times.Never);
+ }
+
+ [Fact]
+ public async Task Enroll_NullProductInfo_Throws()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() =>
+ plugin.Enroll("csr", "CN=test", new Dictionary(), null!, RequestFormat.PKCS10, EnrollmentType.New));
+ }
+
+ [Fact]
+ public async Task Enroll_NullProductParameters_Throws()
+ {
+ var plugin = MakePlugin();
+ var productInfo = new EnrollmentProductInfo { ProductID = "CSC TrustedSecure DV", ProductParameters = null! };
+ await Assert.ThrowsAsync(() =>
+ plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.New));
+ }
+
+ [Fact]
+ public async Task Enroll_EmptyCsr_Throws()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() =>
+ plugin.Enroll("", "CN=test", new Dictionary(), ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New));
+ }
+
+ [Fact]
+ public async Task Enroll_New_PriorCertSnPresent_ReturnsFailure()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ var plugin = MakePlugin(mockClient);
+ var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ mockClient.Verify(c => c.SubmitRegistrationAsync(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Enroll_New_Success_ReturnsExternalValidation()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ Result = new Result { CommonName = "new.example.com", Status = new Status { Uuid = "uuid-new" } }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ Assert.Equal("uuid-new", result.CARequestID);
+ // Command's enrollment UI doesn't surface StatusMessage on a successful/pending result -
+ // only EnrollmentContext is - so the flow summary must be attached there instead, one
+ // bullet per step so it renders readably rather than as a single run-on blob.
+ Assert.NotNull(result.EnrollmentContext);
+ Assert.True(result.EnrollmentContext.ContainsKey("Flow: Enroll-New"));
+ Assert.True(result.EnrollmentContext.Keys.Count(k => k.StartsWith("Flow Step ")) > 1);
+ }
+
+ [Fact]
+ public async Task Enroll_New_SuccessWithDcvDetails_KeepsDcvEntriesAlongsideFlowSummary()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ Result = new Result
+ {
+ CommonName = "dcv.example.com",
+ Status = new Status { Uuid = "uuid-dcv" },
+ DcvDetails = new List
+ {
+ new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } }
+ }
+ }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]);
+ Assert.True(result.EnrollmentContext.ContainsKey("Flow: Enroll-New"));
+ Assert.True(result.EnrollmentContext.Keys.Count(k => k.StartsWith("Flow Step ")) > 1);
+ }
+
+ [Fact]
+ public async Task Enroll_New_RegistrationErrorFromCsc_PrependsFlowSummaryToStatusMessage()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ RegistrationError = new RegistrationError { Description = "Open order in progress" }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
+ Assert.Contains("Enroll-New", result.StatusMessage);
+ Assert.Contains("Open order in progress", result.StatusMessage);
+ }
+
+ [Fact]
+ public async Task Enroll_New_NullClientResponse_ReturnsFailure()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync((RegistrationResponse)null!);
+
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ }
+
+ [Fact]
+ public async Task Enroll_New_ClientThrows_ReturnsFailureInsteadOfThrowing()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("boom"));
+
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ Assert.Contains("boom", result.StatusMessage);
+ }
+
+ [Fact]
+ public async Task Enroll_UnhandledEnrollmentType_ReturnsFailure()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ var plugin = MakePlugin(mockClient);
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.Renew);
+
+ Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ }
+
+ [Fact]
+ public async Task Enroll_New_WithPollingEnabledAndFastIssuance_ReturnsGeneratedCertDirectly()
+ {
+ var (_, pem) = MakeSelfSignedCert();
+ var apiBase64 = Convert.ToBase64String(Encoding.ASCII.GetBytes(pem));
+ var uuid = Guid.NewGuid().ToString(); // must be >= 36 chars - GetSingleRecord validates length
+
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ Result = new Result { CommonName = "fast.example.com", Status = new Status { Uuid = uuid } }
+ });
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse
+ {
+ Status = "ACTIVE",
+ Certificate = apiBase64
+ });
+
+ // DcvPollTimeoutSeconds < the 10s poll interval means exactly one poll attempt happens
+ // and the loop then breaks without ever calling Task.Delay - fast and deterministic.
+ var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.GENERATED, result.Status);
+ Assert.Equal(uuid, result.CARequestID);
+ Assert.NotNull(result.Certificate);
+ }
+
+ [Fact]
+ public async Task Enroll_New_PollingEnabledButNotIssued_FallsBackToPendingResult()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ Result = new Result { CommonName = "pending.example.com", Status = new Status { Uuid = uuid } }
+ });
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse { Status = "Pending" });
+
+ var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ Assert.Equal(uuid, result.CARequestID);
+ }
+
+ [Fact]
+ public async Task Enroll_New_WithDnsValidatorFactory_PublishesCnameRecord()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ Result = new Result
+ {
+ CommonName = "cname.example.com",
+ Status = new Status { Uuid = "uuid-cname" },
+ DcvDetails = new List
+ {
+ new DcvDetail { CName = new CName { Name = "_dnsauth.example.com.", Value = "target.sectigo.com." } }
+ }
+ }
+ });
+
+ var mockValidator = new Mock();
+ mockValidator.Setup(v => v.GetValidationType()).Returns("cname");
+ mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new DomainValidationResult { Success = true, Status = "staged" });
+
+ var mockFactory = new Mock();
+ mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object);
+
+ var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ // Trailing dots must be stripped before resolution or no provider would match.
+ mockFactory.Verify(f => f.ResolveDomainValidator("_dnsauth.example.com", "cname"), Times.Once);
+ mockValidator.Verify(v => v.StageValidation("_dnsauth.example.com", "target.sectigo.com", It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Enroll_New_WithDnsValidatorFactoryButEmailMethod_DoesNotAttemptPublish()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ Result = new Result
+ {
+ CommonName = "email.example.com",
+ Status = new Status { Uuid = "uuid-email" },
+ DcvDetails = new List { new DcvDetail { Email = "admin@example.com" } }
+ }
+ });
+
+ var mockFactory = new Mock();
+ var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ [EnrollmentConfigConstants.DomainControlValidationMethod] = "EMAIL"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ mockFactory.Verify(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny()), Times.Never);
+ // The DcvAutoPublish step must explain *why* it was a no-op for a non-CNAME method,
+ // rather than showing a bare [OK] under a CNAME-sounding step name.
+ var publishStep = result.EnrollmentContext.Single(e => e.Key.Contains("DcvAutoPublish"));
+ Assert.Contains("not CNAME", publishStep.Value);
+ }
+
+ [Fact]
+ public async Task Enroll_New_DnsValidatorUnresolved_DoesNotThrow()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ Result = new Result
+ {
+ CommonName = "unresolved.example.com",
+ Status = new Status { Uuid = "uuid-unresolved" },
+ DcvDetails = new List
+ {
+ new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } }
+ }
+ }
+ });
+
+ var mockFactory = new Mock();
+ mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns((IDomainValidator)null!);
+
+ var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ }
+
+ [Fact]
+ public async Task Enroll_New_DnsValidatorStageValidationThrows_DoesNotThrow()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ Result = new Result
+ {
+ CommonName = "err.example.com",
+ Status = new Status { Uuid = "uuid-err" },
+ DcvDetails = new List
+ {
+ new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } }
+ }
+ }
+ });
+
+ var mockValidator = new Mock();
+ mockValidator.Setup(v => v.GetValidationType()).Returns("cname");
+ mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("dns failure"));
+
+ var mockFactory = new Mock();
+ mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object);
+
+ var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ }
+
+ [Fact]
+ public async Task Enroll_New_DnsValidatorReturnsFailure_DoesNotThrow()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ Result = new Result
+ {
+ CommonName = "fail.example.com",
+ Status = new Status { Uuid = "uuid-fail" },
+ DcvDetails = new List
+ {
+ new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } }
+ }
+ }
+ });
+
+ var mockValidator = new Mock();
+ mockValidator.Setup(v => v.GetValidationType()).Returns("cname");
+ mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new DomainValidationResult { Success = false, Status = "error", ErrorMessage = "nope" });
+
+ var mockFactory = new Mock();
+ mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object);
+
+ var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ }
+
+ [Fact]
+ public async Task Enroll_New_DnsValidatorReturnsNullResult_DoesNotThrow()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ Result = new Result
+ {
+ CommonName = "fail-null.example.com",
+ Status = new Status { Uuid = "uuid-fail-null" },
+ DcvDetails = new List
+ {
+ new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } }
+ }
+ }
+ });
+
+ var mockValidator = new Mock();
+ mockValidator.Setup(v => v.GetValidationType()).Returns("cname");
+ mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((DomainValidationResult)null!);
+
+ var mockFactory = new Mock();
+ mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object);
+
+ var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ }
+
+ [Fact]
+ public async Task Enroll_New_DnsFactoryButNoEnrollmentContext_SkipsPublish()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ Result = new Result { CommonName = "none.example.com", Status = new Status { Uuid = "uuid-none" } }
+ });
+
+ var mockFactory = new Mock();
+ var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object);
+
+ await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ mockFactory.Verify(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Enroll_New_PollingEnabledButNoCARequestId_SkipsPollingWithoutError()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ // No Status/Uuid at all -> enrollResult.CARequestID is null -> TryPollForIssuedCertAsync
+ // must skip cleanly rather than throw.
+ Result = new Result { CommonName = "no-uuid.example.com" }
+ });
+
+ var plugin = MakePlugin(mockClient, configOverrides: new Dictionary