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 { [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); + mockClient.Verify(c => c.SubmitGetCertificateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_New_PollingThrowsOnFirstAttempt_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 = "poll-error.example.com", Status = new Status { Uuid = uuid } } + }); + // GetSingleRecord (called internally by the poll loop) throws - must be caught and retried, + // not propagated, and the loop still falls back to the pending result once time is up. + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("network blip")); + + 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); + } + + [Fact] + public async Task Enroll_New_CnameMethodWithMixedEmailEntry_SkipsEmailPassthroughEntry() + { + 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 = "mixed.example.com", + Status = new Status { Uuid = "uuid-mixed" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } }, + // Key == value: GetEnrollmentResult's email passthrough shape, mixed into the + // same EnrollmentContext even though the product's DCV method is CNAME. + new DcvDetail { Email = "admin@example.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 }); + + 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" + }); + + await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + // Only the CNAME entry should have been resolved/staged; the email passthrough is skipped. + mockFactory.Verify(f => f.ResolveDomainValidator(It.IsAny(), "cname"), Times.Once); + } + + // --------------------------------------------------------------------- + // Enroll - RenewOrReissue + // --------------------------------------------------------------------- + + [Fact] + public async Task Enroll_RenewOrReissue_MissingPriorCertSn_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.RenewOrReissue); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("PriorCertSN", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_NoOrderIdFoundForSerial_ReturnsFailure() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(string.Empty); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_RenewOrReissue_OrderIdTooShort_ReturnsFailure() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync("short"); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_RenewOrReissue_RenewalWithApplicantLastName_Succeeds() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // OrderDate 2 years ago -> well past the 1-year+RenewalWindowDays expiry -> renewal path. + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") + }); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse + { + Result = new Result { CommonName = "renewed.example.com", Status = new Status { Uuid = orderUuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Enroll_RenewOrReissue_RenewalMissingApplicantLastName_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueWithApplicantLastName_Succeeds() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // OrderDate today -> well within the renewal window -> reissue (free) path. + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") + }); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = orderUuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueMissingApplicantLastName_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_RenewOrReissue_NoOrderDate_FallsBackToCertificateDataReaderExpiry() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // No OrderDate at all -> falls back to expiry-based decision. + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse { OrderDate = null }); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse + { + Result = new Result { CommonName = "expired.example.com", Status = new Status { Uuid = orderUuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).Returns(DateTime.Now.AddDays(-1)); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // GetEndEntityCertificate / ExtractCertificates / FindLeaf + // --------------------------------------------------------------------- + + [Fact] + public void GetEndEntityCertificate_EmptyInput_ReturnsEmpty() + { + var plugin = MakePlugin(); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate("")); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(" ")); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(null!)); + } + + [Fact] + public void GetEndEntityCertificate_NoPemBlocks_ReturnsEmpty() + { + var plugin = MakePlugin(); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate("just some plain text, no PEM fences")); + } + + [Fact] + public void GetEndEntityCertificate_EmptyPemBlockContent_SkipsBlock() + { + var (cert, pem) = MakeSelfSignedCert(); + var emptyBlock = "-----BEGIN CERTIFICATE-----\n \n-----END CERTIFICATE-----\n"; + var plugin = MakePlugin(); + + var result = plugin.GetEndEntityCertificate(emptyBlock + pem); + + Assert.Equal(Convert.ToBase64String(cert.RawData), result); + } + + [Fact] + public void GetEndEntityCertificate_CertWithoutBasicConstraints_TreatedAsNonCa() + { + // A cert with no Basic Constraints extension at all exercises FindLeaf's IsCa "unknown -> + // treat as non-CA" fallback, distinct from an explicit CertificateAuthority=false. + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=no-constraints.example.com", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + 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"; + + var plugin = MakePlugin(); + var result = plugin.GetEndEntityCertificate(pem); + + Assert.Equal(Convert.ToBase64String(cert.RawData), result); + } + + [Fact] + public void GetEndEntityCertificate_MalformedBase64InBlock_SkipsAndReturnsEmpty() + { + var pem = "-----BEGIN CERTIFICATE-----\nNOT!!VALID==BASE64%%CHARS\n-----END CERTIFICATE-----\n"; + var plugin = MakePlugin(); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); + } + + [Fact] + public void GetEndEntityCertificate_ValidBase64ButNotACertificate_SkipsAndReturnsEmpty() + { + var notACert = Convert.ToBase64String(Encoding.UTF8.GetBytes("this decodes fine but is not DER-encoded")); + var pem = $"-----BEGIN CERTIFICATE-----\n{notACert}\n-----END CERTIFICATE-----\n"; + var plugin = MakePlugin(); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); + } + + [Fact] + public void GetEndEntityCertificate_TwoIndependentLeafCerts_ReturnsOneOfThem() + { + var (certA, pemA) = MakeSelfSignedCert("a.example.com"); + var (certB, pemB) = MakeSelfSignedCert("b.example.com"); + var plugin = MakePlugin(); + + var result = plugin.GetEndEntityCertificate(pemA + pemB); + + Assert.True(result == Convert.ToBase64String(certA.RawData) || result == Convert.ToBase64String(certB.RawData)); + } + + [Fact] + public void GetEndEntityCertificate_LeafAndCaChain_ReturnsLeafOnly() + { + using var rsaCa = RSA.Create(2048); + var caReq = new CertificateRequest("CN=Test CA", rsaCa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + caReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + var caCert = caReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); + + using var rsaLeaf = RSA.Create(2048); + var leafReq = new CertificateRequest("CN=leaf.example.com", rsaLeaf, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + leafReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + var leafCert = leafReq.Create(caCert, DateTimeOffset.UtcNow.AddDays(-1), caCert.NotAfter.AddDays(-1), + Guid.NewGuid().ToByteArray()); + + string ToPemBlock(X509Certificate2 c) => "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(c.RawData, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"; + + var chainPem = ToPemBlock(caCert) + ToPemBlock(leafCert); + var plugin = MakePlugin(); + + var result = plugin.GetEndEntityCertificate(chainPem); + + Assert.Equal(Convert.ToBase64String(leafCert.RawData), result); + } + + [Fact] + public void GetEndEntityCertificate_NoDeterminableLeaf_ReturnsEmpty() + { + // Two distinct CA certs that (deliberately) share the exact same Subject/Issuer DN + // string: FindLeaf's Issuer/Subject string-matching heuristic treats each as "issuing" + // the other, so neither ends up in nonIssuers nor anyNonCa (both are CA=true) - the + // "give up" path. + string ToPemBlock(X509Certificate2 c) => "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(c.RawData, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"; + + X509Certificate2 MakeCaCert() + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=duplicate.example.com", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + return req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); + } + + var pem = ToPemBlock(MakeCaCert()) + ToPemBlock(MakeCaCert()); + var plugin = MakePlugin(); + + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); + } + + [Fact] + public async Task Enroll_New_CustomFieldsNull_UsesEmptyListInstead() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync((List)null!); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + { + Result = new Result { CommonName = "nullfields.example.com", Status = new Status { Uuid = "uuid-nf" } } + }); + + 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); + } + + [Fact] + public async Task Enroll_RenewOrReissue_FetchLiveCertThrowsAndFallbackAlsoFails_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // Both the primary live-cert fetch AND the fallback's GetSingleRecord call use the same + // client method, and both fail - forcing the innermost catch(fallbackEx) path. + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ThrowsAsync(new InvalidOperationException("network error")); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).Returns((DateTime?)null); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("unable to determine renewal status", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_RenewalUuidLookupFails_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") // renewal path + }); + + var certDataReader = new Mock(); + // First call resolves the top-level order_id; second (inside the renewal branch, for the + // same PriorCertSN) fails to resolve - exercises ValidateRenewalUUID's failure branch. + certDataReader.SetupSequence(r => r.GetRequestIDBySerialNumber("ABC123")) + .ReturnsAsync(orderUuid) + .ReturnsAsync(string.Empty); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("could not resolve prior certificate serial number", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_RenewalNullResponse_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") + }); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync((RenewalResponse)null!); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("CSC API returned a null response", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueRequestIdLookupEmpty_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") // reissue path + }); + + var certDataReader = new Mock(); + certDataReader.SetupSequence(r => r.GetRequestIDBySerialNumber("ABC123")) + .ReturnsAsync(orderUuid) + .ReturnsAsync(string.Empty); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("could not resolve prior certificate serial number", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueRequestIdTooShort_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") + }); + + var certDataReader = new Mock(); + certDataReader.SetupSequence(r => r.GetRequestIDBySerialNumber("ABC123")) + .ReturnsAsync(orderUuid) + .ReturnsAsync("too-short"); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("too short to extract a UUID", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueNullResponse_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") + }); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync((ReissueResponse)null!); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("CSC API returned a null response", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_FetchLiveCertThrows_FallsBackToExpiryCheck() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.SetupSequence(c => c.SubmitGetCertificateAsync(orderUuid)) + .ThrowsAsync(new InvalidOperationException("network error")); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "fallback.example.com", Status = new Status { Uuid = orderUuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).Returns(DateTime.Now.AddDays(30)); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_RenewOrReissue_NoOrderDateAndNoExpirationDateOnReader_FallsThroughToSingleRecordLookup() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // No OrderDate -> falls back to expiry check. GetExpirationDateByRequestId (below) returns + // null, so the fallback's "??" actually has to call GetSingleRecord for a second time to + // get a RevocationDate - which is never set by GetSingleRecord, so it stays null and the + // nullable "<" comparison evaluates to false (not a renewal). + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = null, + Status = "ACTIVE" + }); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "reissue.example.com", Status = new Status { Uuid = orderUuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).Returns((DateTime?)null); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once); + } +} diff --git a/cscglobal-caplugin.Tests/CscGlobalClientTests.cs b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs new file mode 100644 index 0000000..9c7b0c1 --- /dev/null +++ b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs @@ -0,0 +1,439 @@ +// 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.Net; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CSCGlobal; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; +using Moq; +using Xunit; + +namespace CscGlobalCAPluginTests; + +public class CscGlobalClientTests +{ + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly Func _responder; + public HttpRequestMessage? LastRequest { get; private set; } + + public FakeHttpMessageHandler(Func responder) => _responder = responder; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(_responder(request)); + } + } + + private static HttpResponseMessage JsonResponse(HttpStatusCode code, string json) => + new HttpResponseMessage(code) { Content = new StringContent(json) }; + + private static Mock ValidConfig() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/", + [Constants.BearerToken] = "bearer-token" + }); + return mock; + } + + private static CscGlobalClient MakeClient(Func responder, out FakeHttpMessageHandler handler) + { + handler = new FakeHttpMessageHandler(responder); + return new CscGlobalClient(ValidConfig().Object, handler); + } + + // --------------------------------------------------------------------- + // Constructor validation + // --------------------------------------------------------------------- + + [Fact] + public void Constructor_NullConfig_Throws() + { + Assert.Throws(() => new CscGlobalClient(null!)); + } + + [Fact] + public void Constructor_NullCAConnectionData_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns((Dictionary)null!); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_MissingApiKey_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary()); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_MissingUrl_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_EmptyApiKeyValue_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "", + [Constants.CscGlobalUrl] = "https://example.invalid/" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_NullApiKeyValue_Throws() + { + // Key present but value is a null object (distinct from a missing key or an empty string - + // exercises the `?.ToString()` null-conditional rather than the ContainsKey check). + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = null!, + [Constants.CscGlobalUrl] = "https://example.invalid/" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_NullUrlValue_Throws() + { + // Url key present but value is a null object (distinct from a missing key). + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = null! + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_EmptyBearerTokenValue_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/", + [Constants.BearerToken] = "" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_NullBearerTokenValue_Throws() + { + // BearerToken key present but value is a null object (distinct from a missing key or an + // empty string - exercises the `?.ToString()` null-conditional rather than ContainsKey). + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/", + [Constants.BearerToken] = null! + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_MissingBearerToken_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_ValidConfig_DoesNotThrow() + { + var client = new CscGlobalClient(ValidConfig().Object, new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, "{}"))); + Assert.NotNull(client); + } + + // --------------------------------------------------------------------- + // SubmitRegistrationAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitRegistrationAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"result\":{\"commonName\":\"order-1\",\"price\":{\"currency\":\"USD\",\"total\":99.5}}}"), out var handler); + + var response = await client.SubmitRegistrationAsync(new RegistrationRequest()); + + Assert.Equal("order-1", response.Result.CommonName); + Assert.Contains("/dbs/api/v2/tls/registration", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitRegistrationAsync_NullRequest_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + await Assert.ThrowsAsync(() => client.SubmitRegistrationAsync(null!)); + } + + [Fact] + public async Task SubmitRegistrationAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"denied\"}"), out _); + var response = await client.SubmitRegistrationAsync(new RegistrationRequest()); + Assert.Equal("denied", response.RegistrationError.Description); + Assert.Null(response.Result); + } + + [Fact] + public async Task SubmitRegistrationAsync_OtherError_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + await Assert.ThrowsAsync(() => client.SubmitRegistrationAsync(new RegistrationRequest())); + } + + // --------------------------------------------------------------------- + // SubmitRenewalAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitRenewalAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"result\":{\"commonName\":\"renewed-1\"}}"), out var handler); + var response = await client.SubmitRenewalAsync(new RenewalRequest()); + Assert.Equal("renewed-1", response.Result.CommonName); + Assert.Contains("/dbs/api/v2/tls/renewal", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitRenewalAsync_NullRequest_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + await Assert.ThrowsAsync(() => client.SubmitRenewalAsync(null!)); + } + + [Fact] + public async Task SubmitRenewalAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"denied\"}"), out _); + var response = await client.SubmitRenewalAsync(new RenewalRequest()); + Assert.Equal("denied", response.RegistrationError.Description); + } + + [Fact] + public async Task SubmitRenewalAsync_OtherError_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + await Assert.ThrowsAsync(() => client.SubmitRenewalAsync(new RenewalRequest())); + } + + // --------------------------------------------------------------------- + // SubmitReissueAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitReissueAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"result\":{\"commonName\":\"reissue-1\"}}"), out var handler); + var response = await client.SubmitReissueAsync(new ReissueRequest()); + Assert.Equal("reissue-1", response.Result.CommonName); + Assert.Contains("/dbs/api/v2/tls/reissue", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitReissueAsync_NullPriceTotal_DoesNotThrow() + { + // Real CSC Global response observed in production: "price.total" comes back null for a + // reissue where the certificate is not in a reissuable status. + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"result\":{\"commonName\":\"reissue-2\",\"price\":{\"currency\":\"USD\",\"total\":null}}}"), out _); + + var response = await client.SubmitReissueAsync(new ReissueRequest()); + + Assert.Equal("reissue-2", response.Result.CommonName); + Assert.Null(response.Result.Price.Total); + } + + [Fact] + public async Task SubmitReissueAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"denied\"}"), out _); + var response = await client.SubmitReissueAsync(new ReissueRequest()); + Assert.Equal("denied", response.RegistrationError.Description); + } + + [Fact] + public async Task SubmitReissueAsync_OtherError_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + await Assert.ThrowsAsync(() => client.SubmitReissueAsync(new ReissueRequest())); + } + + // --------------------------------------------------------------------- + // SubmitGetCertificateAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitGetCertificateAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"status\":\"ACTIVE\",\"certificate\":\"abc\"}"), out var handler); + var response = await client.SubmitGetCertificateAsync("uuid-1"); + Assert.Equal("ACTIVE", response.Status); + Assert.Contains("/dbs/api/v2/tls/certificate/uuid-1", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitGetCertificateAsync_NullId_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync(null!)); + } + + [Fact] + public async Task SubmitGetCertificateAsync_ErrorStatus_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.NotFound, "not found"), out _); + await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync("uuid-1")); + } + + // --------------------------------------------------------------------- + // SubmitGetCustomFields + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitGetCustomFields_Success_ReturnsList() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"customFields\":[{\"label\":\"Field1\",\"mandatory\":true}]}"), out var handler); + + var fields = await client.SubmitGetCustomFields(); + + Assert.Single(fields); + Assert.Equal("Field1", fields[0].Label); + Assert.Contains("/dbs/api/v2/admin/customfields", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitGetCustomFields_NullCustomFieldsProperty_ReturnsEmptyList() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + var fields = await client.SubmitGetCustomFields(); + Assert.Empty(fields); + } + + [Fact] + public async Task SubmitGetCustomFields_NullResponseBody_ReturnsEmptyList() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _); + var fields = await client.SubmitGetCustomFields(); + Assert.Empty(fields); + } + + [Fact] + public async Task SubmitGetCustomFields_ErrorStatus_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + await Assert.ThrowsAsync(() => client.SubmitGetCustomFields()); + } + + // --------------------------------------------------------------------- + // SubmitRevokeCertificateAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitRevokeCertificateAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"revokeSuccess\":{\"status\":\"REVOKED\"}}"), out var handler); + + var response = await client.SubmitRevokeCertificateAsync("uuid-1"); + + Assert.Equal("REVOKED", response.RevokeSuccess.Status); + Assert.Contains("/dbs/api/v2/tls/revoke/uuid-1", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitRevokeCertificateAsync_NullUuid_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + await Assert.ThrowsAsync(() => client.SubmitRevokeCertificateAsync(null!)); + } + + [Fact] + public async Task SubmitRevokeCertificateAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"already revoked\"}"), out _); + var response = await client.SubmitRevokeCertificateAsync("uuid-1"); + Assert.Equal("already revoked", response.RegistrationError.Description); + } + + [Fact] + public async Task SubmitRevokeCertificateAsync_OtherError_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + await Assert.ThrowsAsync(() => client.SubmitRevokeCertificateAsync("uuid-1")); + } + + // --------------------------------------------------------------------- + // SubmitCertificateListRequestAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitCertificateListRequestAsync_NoDateFilter_ReturnsResults() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"meta\":{\"numResults\":1},\"results\":[{\"uuid\":\"u1\"}]}"), out var handler); + + var response = await client.SubmitCertificateListRequestAsync(); + + Assert.Single(response.Results); + Assert.DoesNotContain("effectiveDate", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitCertificateListRequestAsync_WithDateFilter_AppendsFilterToQuery() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"results\":[]}"), out var handler); + + await client.SubmitCertificateListRequestAsync("2026/01/01"); + + Assert.Contains("effectiveDate=ge=2026/01/01", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitCertificateListRequestAsync_NullBody_ReturnsEmptyResponse() + { + var client = MakeClient(_ => new HttpResponseMessage(HttpStatusCode.OK), out _); + var response = await client.SubmitCertificateListRequestAsync(); + Assert.NotNull(response); + } + + [Fact] + public async Task SubmitCertificateListRequestAsync_ErrorStatus_DoesNotThrow_ReturnsParsedBody() + { + // Unlike the other Submit* methods, this one only logs on non-success and still parses + // whatever body came back rather than throwing. + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "{\"results\":[]}"), out _); + var response = await client.SubmitCertificateListRequestAsync(); + Assert.NotNull(response.Results); + Assert.Empty(response.Results); + } +} diff --git a/cscglobal-caplugin.Tests/FlowLoggerTests.cs b/cscglobal-caplugin.Tests/FlowLoggerTests.cs new file mode 100644 index 0000000..a9834fd --- /dev/null +++ b/cscglobal-caplugin.Tests/FlowLoggerTests.cs @@ -0,0 +1,220 @@ +// 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 Keyfactor.Extensions.CAPlugin.CSCGlobal; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace CscGlobalCAPluginTests; + +public class FlowLoggerTests +{ + private static Mock NewLoggerMock() + { + var mock = new Mock(); + mock.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + return mock; + } + + [Fact] + public void Step_NoDetail_ChainableAndDoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var result = flow.Step("StepOne"); + Assert.Same(flow, result); + } + + [Fact] + public void Step_WithDetail_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("StepOne", "some detail"); + } + + [Fact] + public void Step_Action_Success_RunsAction() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var ran = false; + flow.Step("Action", () => ran = true); + Assert.True(ran); + } + + [Fact] + public void Step_Action_Throws_RecordsFailureAndRethrows() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + Assert.Throws(() => + flow.Step("Action", () => throw new InvalidOperationException("boom"))); + } + + [Fact] + public void Step_ActionWithDetail_Success() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Action", () => { }, "detail"); + } + + [Fact] + public async Task StepAsync_Success_RunsAction() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var ran = false; + await flow.StepAsync("AsyncStep", () => + { + ran = true; + return Task.CompletedTask; + }); + Assert.True(ran); + } + + [Fact] + public async Task StepAsync_Throws_RecordsFailureAndRethrows() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + await Assert.ThrowsAsync(() => + flow.StepAsync("AsyncStep", () => throw new InvalidOperationException("boom"))); + } + + [Fact] + public async Task StepAsync_WithDetail_Success() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + await flow.StepAsync("AsyncStep", () => Task.CompletedTask, "detail"); + } + + [Fact] + public void Fail_RecordsFailure_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Fail("StepOne"); + flow.Fail("StepTwo", "reason"); + } + + [Fact] + public void Skip_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Skip("StepOne"); + flow.Skip("StepTwo", "not applicable"); + } + + [Fact] + public void Branch_EndBranch_ChildStepsNestUnderBranch() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Branch("Inner"); + flow.Step("NestedStep"); + flow.EndBranch(); + flow.Step("TopLevelStep"); + } + + [Fact] + public void EndBranch_WithoutBranch_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.EndBranch(); + } + + [Fact] + public void GetSummaryEntries_OneEntryPerStepPlusHeader() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "MyFlow"); + flow.Step("StepOne"); + flow.Fail("StepTwo", "boom"); + + var entries = flow.GetSummaryEntries(); + + Assert.True(entries.ContainsKey("Flow: MyFlow")); + Assert.Contains("FAILED", entries["Flow: MyFlow"]); + Assert.Equal(3, entries.Count); // header + 2 steps + Assert.Contains(entries, e => e.Key.Contains("StepOne") && e.Value.Contains("OK")); + Assert.Contains(entries, e => e.Key.Contains("StepTwo") && e.Value.Contains("boom")); + } + + [Fact] + public void GetSummaryEntries_AllStepsSucceed_HeaderReportsOk() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "MyFlow"); + flow.Step("StepOne"); + flow.Step("StepTwo"); + + var entries = flow.GetSummaryEntries(); + + Assert.Contains("[OK]", entries["Flow: MyFlow"]); + } + + [Fact] + public void GetSummaryEntries_BranchChildren_IncludedAsSeparateEntries() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "MyFlow"); + flow.Branch("Inner"); + flow.Step("NestedStep"); + flow.Fail("NestedFail", "inner reason"); + flow.EndBranch(); + flow.Step("TopLevelStep"); + + var entries = flow.GetSummaryEntries(); + + Assert.Contains(entries, e => e.Key.Contains("NestedStep")); + Assert.Contains(entries, e => e.Key.Contains("NestedFail") && e.Value.Contains("inner reason")); + Assert.Contains(entries, e => e.Key.Contains("TopLevelStep")); + } + + [Fact] + public void Dispose_NoSteps_DoesNotThrow() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Dispose(); + } + + [Fact] + public void Dispose_AllStepsSuccess_DoesNotThrow() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok1"); + flow.Step("Ok2"); + flow.Dispose(); + } + + [Fact] + public void Dispose_LastStepFailed_DoesNotThrow() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok1"); + flow.Fail("Failed1"); + flow.Dispose(); + } + + [Fact] + public void Dispose_MidStepFailedButLastSucceeded_PartialFailure_DoesNotThrow() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Fail("Failed1"); + flow.Step("Ok1"); + flow.Dispose(); + } + + [Fact] + public void Dispose_WithBranchChildren_RendersChildrenWithoutThrowing() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Branch("Branch1"); + flow.Step("Child1"); + flow.Fail("Child2"); + flow.Skip("Child3"); + flow.EndBranch(); + flow.Step("AfterBranch"); + flow.Dispose(); + } + + [Fact] + public void Dispose_CalledTwice_IsIdempotent() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok"); + flow.Dispose(); + flow.Dispose(); // should not throw or double-log + } +} diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs new file mode 100644 index 0000000..0762605 --- /dev/null +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -0,0 +1,719 @@ +// 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 Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CSCGlobal; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; +using Newtonsoft.Json; +using Xunit; + +namespace CscGlobalCAPluginTests; + +public class RequestManagerTests +{ + private const string SampleCsr = "sample-csr-body"; + + private static EnrollmentProductInfo ProductInfo(string productId, Dictionary? parameters = null) => + new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = parameters ?? new Dictionary() + }; + + private static RequestManager Manager => new RequestManager(); + + // --------------------------------------------------------------------- + // Certificate type routing - canonical (1.2.0+) names, all 10 products. + // --------------------------------------------------------------------- + + [Theory] + [InlineData("CSC TrustedSecure OV", "0", false, false)] + [InlineData("CSC TrustedSecure OV Wildcard", "1", false, false)] + [InlineData("CSC TrustedSecure OV, Multiple Names", "2", true, false)] + [InlineData("CSC TrustedSecure EV", "3", false, true)] + [InlineData("CSC TrustedSecure DV", "4", false, false)] + [InlineData("CSC TrustedSecure DV Wildcard", "5", false, false)] + [InlineData("CSC TrustedSecure DV, Multiple Names", "6", true, false)] + [InlineData("CSC TrustedSecure EV, Multiple Names", "7", true, true)] + [InlineData("CSC TrustedSecure OV Wildcard, Multiple Names", "8", true, false)] + [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names", "9", true, false)] + [InlineData("Some Unrecognized Product", "-1", false, false)] + public void GetRegistrationRequest_CanonicalProductNames_RoutesCertificateTypeAndOptionalSections( + string productId, string expectedType, bool expectSans, bool expectEv) + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo(productId, new Dictionary + { + ["Domain Control Validation Method"] = "CNAME", + ["Organization Country"] = "US" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Equal(expectedType, request.CertificateType); + Assert.Equal(expectSans, request.SubjectAlternativeNames != null); + Assert.Equal(expectEv, request.EvCertificateDetails != null); + } + + // --------------------------------------------------------------------- + // Certificate type routing - pre-1.2.0 legacy names must resolve identically to their + // canonical replacement, so existing Certificate Templates in Command keep working. + // --------------------------------------------------------------------- + + [Theory] + [InlineData("CSC TrustedSecure Premium Certificate", "0", false, false)] + [InlineData("CSC TrustedSecure Premium Wildcard Certificate", "1", false, false)] + [InlineData("CSC TrustedSecure UC Certificate", "2", true, false)] + [InlineData("CSC TrustedSecure EV Certificate", "3", false, true)] + [InlineData("CSC TrustedSecure Domain Validated SSL", "4", false, false)] + [InlineData("CSC Trusted Secure Domain Validated Wildcard SSL", "5", false, false)] + [InlineData("CSC Trusted Secure Domain Validated UC Certificate", "6", true, false)] + public void GetRegistrationRequest_LegacyProductNames_ResolveToSameCertificateType( + string legacyProductId, string expectedType, bool expectSans, bool expectEv) + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo(legacyProductId, new Dictionary + { + ["Domain Control Validation Method"] = "CNAME", + ["Organization Country"] = "US" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Equal(expectedType, request.CertificateType); + Assert.Equal(expectSans, request.SubjectAlternativeNames != null); + Assert.Equal(expectEv, request.EvCertificateDetails != null); + } + + [Fact] + public void GetRegistrationRequest_LegacyAndCanonicalName_ProduceIdenticalCertificateType() + { + var legacy = ProductInfo("CSC TrustedSecure UC Certificate"); + var canonical = ProductInfo("CSC TrustedSecure OV, Multiple Names"); + + var legacyRequest = Manager.GetRegistrationRequest(legacy, SampleCsr, new Dictionary(), new List()); + var canonicalRequest = Manager.GetRegistrationRequest(canonical, SampleCsr, new Dictionary(), new List()); + + Assert.Equal(canonicalRequest.CertificateType, legacyRequest.CertificateType); + } + + // --------------------------------------------------------------------- + // IsKnownProductId - backs ValidateProductInfo. Must recognize both canonical and legacy + // names from the same source of truth GetCertificateType uses, so the two can't drift. + // --------------------------------------------------------------------- + + [Theory] + [InlineData("CSC TrustedSecure DV")] + [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names")] + [InlineData("CSC TrustedSecure Domain Validated SSL")] + [InlineData("csc trustedsecure dv")] + public void IsKnownProductId_RecognizedName_ReturnsTrue(string productId) + { + Assert.True(Manager.IsKnownProductId(productId)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Not A Real Product")] + public void IsKnownProductId_UnrecognizedOrEmpty_ReturnsFalse(string? productId) + { + Assert.False(Manager.IsKnownProductId(productId!)); + } + + // --------------------------------------------------------------------- + // GetSubjectAlternativeNames (exercised via GetRegistrationRequest) - DCV email resolution. + // --------------------------------------------------------------------- + + [Fact] + public void GetRegistrationRequest_MultiNameEmailMethod_MatchesAdditionalSanEmail() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "EMAIL", + [EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails] = "admin@example.com,admin@other.com" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Single(request.SubjectAlternativeNames); + var san = request.SubjectAlternativeNames[0]; + Assert.Equal("www.example.com", san.DomainName); + Assert.NotNull(san.DomainControlValidation); + Assert.Equal("admin@example.com", san.DomainControlValidation.EmailAddress); + } + + [Fact] + public void GetRegistrationRequest_MultiNameEmailMethodNoAddtlSanMatch_FallsBackToCommonNameDcvEmail() + { + // CSC Global rejects the request if a SAN entry has no domainControlValidation, so a SAN + // domain unrelated to any configured "Addtl Sans" email must fall back to the primary + // CN's DCV email rather than being left null. + var sans = new Dictionary { ["dnsname"] = new[] { "www.unrelated-domain.io" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "EMAIL", + [EnrollmentConfigConstants.CnDcvEmail] = "cn@example.com" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Single(request.SubjectAlternativeNames); + var san = request.SubjectAlternativeNames[0]; + Assert.NotNull(san.DomainControlValidation); + Assert.Equal("cn@example.com", san.DomainControlValidation.EmailAddress); + } + + [Fact] + public void GetRegistrationRequest_MultiNameCnameMethod_MirrorsCommonNameDcv() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Single(request.SubjectAlternativeNames); + Assert.NotNull(request.SubjectAlternativeNames[0].DomainControlValidation); + Assert.Equal("CNAME", request.SubjectAlternativeNames[0].DomainControlValidation.MethodType); + } + + [Fact] + public void GetRegistrationRequest_WildcardMultiNameProduct_AcceptsUnrelatedDomainSans() + { + // Types 8/9 are wildcard + multi-name (the underlying Sectigo Multi-Domain Wildcard + // product) - additional SANs are not restricted to the CN's own base domain. + var sans = new Dictionary + { + ["dnsname"] = new[] { "*.example2.com", "*.example3.com" } + }; + var productInfo = ProductInfo("CSC TrustedSecure DV Wildcard, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Equal(2, request.SubjectAlternativeNames.Count); + Assert.Equal("*.example2.com", request.SubjectAlternativeNames[0].DomainName); + Assert.Equal("*.example3.com", request.SubjectAlternativeNames[1].DomainName); + } + + // --------------------------------------------------------------------- + // Price.Total nullability - CSC Global returns "price.total": null for orders that cannot + // be processed. Total must be nullable or Newtonsoft throws mid-deserialization, before the + // caller ever sees the RegistrationError/order status CSC was actually trying to report. + // --------------------------------------------------------------------- + + [Fact] + public void RegistrationResponse_NullPriceTotal_DeserializesWithoutThrowing() + { + const string json = "{\"result\":{\"commonName\":\"order-1\",\"price\":{\"currency\":\"\",\"total\":null}}}"; + + var response = JsonConvert.DeserializeObject(json); + + Assert.NotNull(response?.Result?.Price); + Assert.Null(response!.Result!.Price!.Total); + } + + // --------------------------------------------------------------------- + // GetRenewResponse / GetReIssueResult - CSC never returns an issued certificate on these + // responses (only order/DCV status), so success must report EXTERNALVALIDATION, not + // GENERATED, or the gateway host will try to parse a certificate that doesn't exist. + // --------------------------------------------------------------------- + + [Fact] + public void GetRenewResponse_Success_ReturnsExternalValidation() + { + var response = new RenewalResponse + { + Result = new Result { CommonName = "renewed.example.com", Status = new Status { Uuid = "uuid-1" } } + }; + + var result = Manager.GetRenewResponse(response); + + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal("uuid-1", result.CARequestID); + } + + [Fact] + public void GetReIssueResult_Success_ReturnsExternalValidation() + { + var response = new ReissueResponse + { + Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = "uuid-2" } } + }; + + var result = Manager.GetReIssueResult(response); + + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal("uuid-2", result.CARequestID); + } + + [Fact] + public void GetReIssueResult_NullResponse_ReturnsFailed() + { + var result = Manager.GetReIssueResult(null); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetReIssueResult_RegistrationError_ReturnsFailedWithDescription() + { + var response = new ReissueResponse { RegistrationError = new RegistrationError { Description = "rejected" } }; + var result = Manager.GetReIssueResult(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + Assert.Equal("rejected", result.StatusMessage); + } + + [Fact] + public void GetReIssueResult_NullResult_ReturnsFailed() + { + var response = new ReissueResponse { Result = null }; + var result = Manager.GetReIssueResult(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetRenewResponse_NullResponse_ReturnsFailed() + { + var result = Manager.GetRenewResponse(null); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetRenewResponse_RegistrationError_ReturnsFailedWithDescription() + { + var response = new RenewalResponse + { + RegistrationError = new RegistrationError { Description = "boom" }, + Result = new Result { Status = new Status { Uuid = "abc-123" } } + }; + var result = Manager.GetRenewResponse(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + Assert.Equal("abc-123", result.CARequestID); + Assert.Equal("boom", result.StatusMessage); + } + + [Fact] + public void GetRenewResponse_NullResult_StillReturnsExternalValidation() + { + // Unlike GetEnrollmentResult/GetReIssueResult, GetRenewResponse has no explicit + // Result==null guard - it just null-conditionals through to "(unknown)"/null. + var response = new RenewalResponse { Result = null }; + var result = Manager.GetRenewResponse(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("(unknown)", result.StatusMessage); + } + + [Fact] + public void GetEnrollmentResult_NullResponse_ReturnsFailed() + { + var result = Manager.GetEnrollmentResult(null); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetEnrollmentResult_RegistrationError_ReturnsFailed() + { + var response = new RegistrationResponse { RegistrationError = new RegistrationError { Description = "denied" } }; + var result = Manager.GetEnrollmentResult(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + Assert.Equal("denied", result.StatusMessage); + } + + [Fact] + public void GetEnrollmentResult_NullResult_ReturnsFailed() + { + var response = new RegistrationResponse { Result = null }; + var result = Manager.GetEnrollmentResult(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetEnrollmentResult_SuccessNoDcvDetails_ReturnsExternalValidationWithNullContext() + { + var response = new RegistrationResponse + { + Result = new Result { CommonName = "order-1", Status = new Status { Uuid = "uuid-1" } } + }; + var result = Manager.GetEnrollmentResult(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal("uuid-1", result.CARequestID); + Assert.Null(result.EnrollmentContext); + } + + [Fact] + public void GetEnrollmentResult_WithCNameAndEmailDcvDetails_PopulatesEnrollmentContext() + { + var response = new RegistrationResponse + { + Result = new Result + { + CommonName = "order-2", + Status = new Status { Uuid = "uuid-2" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } }, + new DcvDetail { Email = "admin@example.com" }, + // Duplicate keys should not throw and should not be added twice. + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } }, + new DcvDetail { Email = "admin@example.com" }, + // Entry with neither CName nor Email contributes nothing. Null entries are skipped. + new DcvDetail(), + null! + } + } + }; + + var result = Manager.GetEnrollmentResult(response); + + Assert.NotNull(result.EnrollmentContext); + Assert.Equal(2, result.EnrollmentContext.Count); + Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]); + Assert.Equal("admin@example.com", result.EnrollmentContext["admin@example.com"]); + } + + // --------------------------------------------------------------------- + // GetRevokeResult + // --------------------------------------------------------------------- + + [Fact] + public void GetRevokeResult_NullResponse_ReturnsFailed() + { + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, Manager.GetRevokeResult(null)); + } + + [Fact] + public void GetRevokeResult_RegistrationError_ReturnsFailed() + { + var response = new RevokeResponse { RegistrationError = new RegistrationError { Description = "denied" } }; + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, Manager.GetRevokeResult(response)); + } + + [Fact] + public void GetRevokeResult_Success_ReturnsRevoked() + { + var response = new RevokeResponse { RevokeSuccess = new RevokeSuccessResponse { Status = "REVOKED" } }; + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.REVOKED, Manager.GetRevokeResult(response)); + } + + // --------------------------------------------------------------------- + // MapReturnStatus + // --------------------------------------------------------------------- + + [Theory] + [InlineData("ACTIVE", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.GENERATED)] + [InlineData("Initial", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.INITIALIZED)] + [InlineData("Pending", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.INPROCESS)] + [InlineData("REVOKED", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.REVOKED)] + [InlineData("SomethingUnexpected", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED)] + [InlineData(null, Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED)] + public void MapReturnStatus_MapsExpectedStatus(string? cscStatus, Keyfactor.PKI.Enums.EJBCA.EndEntityStatus expected) + { + Assert.Equal((int)expected, Manager.MapReturnStatus(cscStatus!)); + } + + // --------------------------------------------------------------------- + // GetNotifications + // --------------------------------------------------------------------- + + [Fact] + public void GetNotifications_NoEmailsConfigured_ReturnsEmptyList() + { + var notifications = Manager.GetNotifications(ProductInfo("CSC TrustedSecure DV")); + Assert.True(notifications.Enabled); + Assert.Empty(notifications.AdditionalNotificationEmails); + } + + [Fact] + public void GetNotifications_EmailsConfigured_SplitsOnComma() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", + new Dictionary { ["Notification Email(s) Comma Separated"] = "a@example.com,b@example.com" }); + + var notifications = Manager.GetNotifications(productInfo); + + Assert.Equal(2, notifications.AdditionalNotificationEmails.Count); + Assert.Contains("a@example.com", notifications.AdditionalNotificationEmails); + } + + // --------------------------------------------------------------------- + // GetDomainControlValidation + // --------------------------------------------------------------------- + + [Fact] + public void GetDomainControlValidation_EmptyEmailArray_ReturnsNull() + { + Assert.Null(Manager.GetDomainControlValidation("EMAIL", Array.Empty(), "example.com")); + } + + [Fact] + public void GetDomainControlValidation_NullEmailArray_ReturnsNull() + { + Assert.Null(Manager.GetDomainControlValidation("EMAIL", null!, "example.com")); + } + + [Fact] + public void GetDomainControlValidation_MatchingHostFound_ReturnsValidation() + { + var result = Manager.GetDomainControlValidation("EMAIL", new[] { "not-an-email", "admin@example.com" }, "www.example.com"); + Assert.NotNull(result); + Assert.Equal("EMAIL", result.MethodType); + Assert.Contains("admin@example.com", result.EmailAddress); + } + + [Fact] + public void GetDomainControlValidation_NoMatchingHost_ReturnsNull() + { + Assert.Null(Manager.GetDomainControlValidation("EMAIL", new[] { "admin@other.com" }, "www.example.com")); + } + + [Fact] + public void GetDomainControlValidation_SingleEmailOverload_ReturnsValidationVerbatim() + { + var result = Manager.GetDomainControlValidation("CNAME", "admin@example.com"); + Assert.Equal("CNAME", result.MethodType); + Assert.Equal("admin@example.com", result.EmailAddress); + } + + // --------------------------------------------------------------------- + // GetCustomFields (exercised via GetRegistrationRequest) + // --------------------------------------------------------------------- + + [Fact] + public void GetRegistrationRequest_MandatoryCustomFieldMissing_Throws() + { + var productInfo = ProductInfo("CSC TrustedSecure DV"); + var customFields = new List { new GetCustomField { Label = "Required Field", Mandatory = true } }; + + Assert.Throws(() => + Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), customFields)); + } + + [Fact] + public void GetRegistrationRequest_OptionalCustomFieldMissing_DoesNotThrow() + { + var productInfo = ProductInfo("CSC TrustedSecure DV"); + var customFields = new List { new GetCustomField { Label = "Optional Field", Mandatory = false } }; + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), customFields); + Assert.Empty(request.CustomFields); + } + + [Fact] + public void GetRegistrationRequest_CustomFieldPresent_IsMapped() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary { ["Custom Field"] = "value" }); + var customFields = new List { new GetCustomField { Label = "Custom Field", Mandatory = false } }; + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), customFields); + + Assert.Single(request.CustomFields); + Assert.Equal("value", request.CustomFields[0].Value); + } + + [Fact] + public void GetRegistrationRequest_NullCustomFieldsList_ReturnsEmptyCustomFields() + { + var request = Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), SampleCsr, + new Dictionary(), null!); + Assert.Empty(request.CustomFields); + } + + [Fact] + public void GetRegistrationRequest_CustomFieldsWithNullEntryAndBlankLabel_SkipsBoth() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary { ["Custom Field"] = "value" }); + var customFields = new List + { + null!, + new GetCustomField { Label = "", Mandatory = false }, + new GetCustomField { Label = "Custom Field", Mandatory = false } + }; + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), customFields); + + Assert.Single(request.CustomFields); + Assert.Equal("value", request.CustomFields[0].Value); + } + + // --------------------------------------------------------------------- + // GetRenewalRequest / GetReissueRequest - parity with GetRegistrationRequest + // --------------------------------------------------------------------- + + [Fact] + public void GetRenewalRequest_EvProduct_PopulatesEvDetailsNoSans() + { + var productInfo = ProductInfo("CSC TrustedSecure EV", new Dictionary { ["Organization Country"] = "CA" }); + var request = Manager.GetRenewalRequest(productInfo, "uuid-456", SampleCsr, new Dictionary(), new List()); + + Assert.Equal("3", request.CertificateType); + Assert.Null(request.SubjectAlternativeNames); + Assert.NotNull(request.EvCertificateDetails); + Assert.Equal("CA", request.EvCertificateDetails.Country); + } + + [Fact] + public void GetReissueRequest_EvMultiNameProduct_PopulatesBothSansAndEvDetails() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure EV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME", + ["Organization Country"] = "GB" + }); + + var request = Manager.GetReissueRequest(productInfo, "uuid-000", SampleCsr, sans, new List()); + + Assert.Equal("7", request.CertificateType); + Assert.Single(request.SubjectAlternativeNames); + Assert.NotNull(request.EvCertificateDetails); + Assert.Equal("GB", request.EvCertificateDetails.Country); + } + + [Fact] + public void GetRegistrationRequest_AllOptionalParametersSupplied_MapsEachField() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary + { + ["Term"] = "12", + ["Applicant First Name"] = "Jane", + ["Applicant Last Name"] = "Doe", + ["Applicant Email Address"] = "jane.doe@example.com", + ["Applicant Phone"] = "555-1234", + ["Organization Contact"] = "contact-1", + ["Business Unit"] = "IT" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), new List()); + + Assert.Equal("12", request.Term); + Assert.Equal("Jane", request.ApplicantFirstName); + Assert.Equal("Doe", request.ApplicantLastName); + Assert.Equal("jane.doe@example.com", request.ApplicantEmailAddress); + Assert.Equal("555-1234", request.ApplicantPhoneNumber); + Assert.Equal("contact-1", request.OrganizationContact); + Assert.Equal("IT", request.BusinessUnit); + } + + [Fact] + public void GetRenewalRequest_AllOptionalParametersSupplied_MapsEachField() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary + { + ["Term"] = "24", + ["Applicant First Name"] = "John", + ["Applicant Last Name"] = "Smith", + ["Applicant Email Address"] = "john.smith@example.com", + ["Applicant Phone"] = "555-5678", + ["Organization Contact"] = "contact-2", + ["Business Unit"] = "Legal" + }); + + var request = Manager.GetRenewalRequest(productInfo, "uuid-renewal", SampleCsr, new Dictionary(), new List()); + + Assert.Equal("24", request.Term); + Assert.Equal("John", request.ApplicantFirstName); + Assert.Equal("Smith", request.ApplicantLastName); + Assert.Equal("john.smith@example.com", request.ApplicantEmailAddress); + Assert.Equal("555-5678", request.ApplicantPhoneNumber); + Assert.Equal("contact-2", request.OrganizationContact); + Assert.Equal("Legal", request.BusinessUnit); + } + + [Fact] + public void GetReissueRequest_AllOptionalParametersSupplied_MapsEachField() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary + { + ["Term"] = "36", + ["Applicant First Name"] = "Alex", + ["Applicant Last Name"] = "Nguyen", + ["Applicant Email Address"] = "alex.nguyen@example.com", + ["Applicant Phone"] = "555-9012", + ["Organization Contact"] = "contact-3", + ["Business Unit"] = "Finance" + }); + + var request = Manager.GetReissueRequest(productInfo, "uuid-reissue", SampleCsr, new Dictionary(), new List()); + + Assert.Equal("36", request.Term); + Assert.Equal("Alex", request.ApplicantFirstName); + Assert.Equal("Nguyen", request.ApplicantLastName); + Assert.Equal("alex.nguyen@example.com", request.ApplicantEmailAddress); + Assert.Equal("555-9012", request.ApplicantPhoneNumber); + Assert.Equal("contact-3", request.OrganizationContact); + Assert.Equal("Finance", request.BusinessUnit); + } + + [Fact] + public void GetRegistrationRequest_NullProductParameters_Throws() + { + var productInfo = new EnrollmentProductInfo { ProductID = "CSC TrustedSecure DV", ProductParameters = null! }; + Assert.Throws(() => + Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), new List())); + } + + [Fact] + public void GetRegistrationRequest_NullProductInfo_Throws() + { + Assert.Throws(() => + Manager.GetRegistrationRequest(null!, SampleCsr, new Dictionary(), new List())); + } + + [Fact] + public void GetRegistrationRequest_NullCsr_Throws() + { + Assert.Throws(() => + Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), null!, new Dictionary(), new List())); + } + + [Fact] + public void GetRegistrationRequest_CsrLongerThan64Chars_WrapsWithPemify() + { + var longCsr = new string('X', 130); + var request = Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), longCsr, new Dictionary(), new List()); + var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(request.Csr)); + Assert.Contains("\n", decoded); + } + + [Fact] + public void GetRenewalRequest_NullUuid_Throws() + { + Assert.Throws(() => + Manager.GetRenewalRequest(ProductInfo("CSC TrustedSecure DV"), null!, SampleCsr, new Dictionary(), new List())); + } + + [Fact] + public void EvCertificateDetails_AllPropertiesSettable() + { + var details = new EvCertificateDetails + { + Country = "US", + City = "Independence", + State = "OH", + DateOfIncorporation = "2020-01-01", + DoingBusinessAs = "Keyfactor", + BusinessCategory = "Private Organization" + }; + + Assert.Equal("US", details.Country); + Assert.Equal("Independence", details.City); + Assert.Equal("OH", details.State); + Assert.Equal("2020-01-01", details.DateOfIncorporation); + Assert.Equal("Keyfactor", details.DoingBusinessAs); + Assert.Equal("Private Organization", details.BusinessCategory); + } + + [Fact] + public void GetRegistrationRequest_EncodesCsrAsBase64() + { + var request = Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), "hello", new Dictionary(), new List()); + var decoded = Convert.FromBase64String(request.Csr); + Assert.Contains("hello", System.Text.Encoding.UTF8.GetString(decoded)); + } +} diff --git a/cscglobal-caplugin.sln b/cscglobal-caplugin.sln index 220a2cd..9594f70 100644 --- a/cscglobal-caplugin.sln +++ b/cscglobal-caplugin.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.0.11217.181 d18.0 +VisualStudioVersion = 18.0.11217.181 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin", "cscglobal-caplugin\CSCGlobalCAPlugin.csproj", "{01DDFD6F-275D-46E7-B522-E0C965D1BF9C}" EndProject @@ -12,23 +12,68 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution integration-manifest.json = integration-manifest.json EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cscglobal-caplugin.Tests", "cscglobal-caplugin.Tests", "{BE4C3E19-CFA0-7860-C455-A18FD2267928}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin.Tests", "cscglobal-caplugin.Tests\CSCGlobalCAPlugin.Tests.csproj", "{1FE36805-D1BD-4552-8B19-17358C5F19E3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cscglobal-caplugin", "cscglobal-caplugin", "{9EDFC34F-9707-CEB2-9158-E7368508D81D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 DebugAndPush|Any CPU = DebugAndPush|Any CPU + DebugAndPush|x64 = DebugAndPush|x64 + DebugAndPush|x86 = DebugAndPush|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x64.ActiveCfg = Debug|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x64.Build.0 = Debug|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x86.ActiveCfg = Debug|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x86.Build.0 = Debug|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|Any CPU.ActiveCfg = DebugAndPush|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|Any CPU.Build.0 = DebugAndPush|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x64.ActiveCfg = DebugAndPush|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x64.Build.0 = DebugAndPush|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x86.ActiveCfg = DebugAndPush|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x86.Build.0 = DebugAndPush|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|Any CPU.ActiveCfg = Release|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|Any CPU.Build.0 = Release|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x64.ActiveCfg = Release|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x64.Build.0 = Release|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x86.ActiveCfg = Release|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x86.Build.0 = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x64.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x64.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x86.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x86.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|Any CPU.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|Any CPU.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x64.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x64.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x86.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x86.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|Any CPU.Build.0 = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x64.ActiveCfg = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x64.Build.0 = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x86.ActiveCfg = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {1FE36805-D1BD-4552-8B19-17358C5F19E3} = {BE4C3E19-CFA0-7860-C455-A18FD2267928} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8861D2F4-FEE8-4D70-8172-DF321704F12D} EndGlobalSection diff --git a/cscglobal-caplugin/AssemblyInfo.cs b/cscglobal-caplugin/AssemblyInfo.cs new file mode 100644 index 0000000..bd280c9 --- /dev/null +++ b/cscglobal-caplugin/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CSCGlobalCAPlugin.Tests")] diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index c22aa32..e42fc86 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -63,7 +63,9 @@ public CSCGlobalCAPlugin(IDomainValidatorFactory validatorFactory) _validatorFactory = validatorFactory; } - private ICscGlobalClient CscGlobalClient { get; set; } + // internal (not private) purely so the test project can inject a mock via + // InternalsVisibleTo, instead of hitting the real CSC Global API in unit tests. + internal ICscGlobalClient CscGlobalClient { get; set; } /// /// Whether the CA is enabled. When false, the plugin returns early from Ping, @@ -432,7 +434,11 @@ private async Task SyncCertificates(BlockingCollection b if (certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || certStatus == Convert.ToInt32(EndEntityStatus.REVOKED)) { - var productId = _requestManager.MapCertificateTypeToProductId(currentResponseItem.CertificateType); + // CSC's list/sync API returns the certificate's current product name directly + // (e.g. "CSC TrustedSecure DV"), which already matches the canonical Product ID + // used for enrollment - no reverse lookup needed, same as the CSC-name-is-truth + // approach taken on feature/ev-ov-dv-multiname-certs. + var productId = currentResponseItem.CertificateType ?? "CscGlobal"; Logger.LogTrace("SyncCertificates: UUID={Uuid} qualifies for sync. CertificateType='{CertType}' -> ProductId='{ProductId}'", currentResponseItem.Uuid, currentResponseItem.CertificateType ?? "(null)", productId); @@ -488,6 +494,16 @@ private async Task SyncCertificates(BlockingCollection b Logger.LogTrace("SyncCertificates: fileContent was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); skippedCount++; } + else + { + Logger.LogTrace("SyncCertificates: fileContent was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); + skippedCount++; + } + } + else + { + Logger.LogTrace("SyncCertificates: UUID={Uuid} status {Status} not eligible for sync, skipping.", currentResponseItem.Uuid, certStatus); + skippedCount++; } else { @@ -621,17 +637,13 @@ public async Task Enroll(string csr, string subject, Dictionar flow.Step("CheckPriorCertSN", () => { - if (productInfo.ProductParameters.ContainsKey("priorcertsn")) + // Command sends this key as "PriorCertSN" (proper case) - a prior version of this + // check gated on "priorcertsn" (lowercase) instead, which Command never actually + // sends, so this block silently never ran and PriorCertSN was never populated. + if (productInfo.ProductParameters.ContainsKey("PriorCertSN")) { - if (productInfo.ProductParameters.ContainsKey("PriorCertSN")) - { - priorSn = productInfo.ProductParameters["PriorCertSN"]; - Logger.LogDebug("Enroll: Prior cert SN: '{PriorSn}'", priorSn ?? "(null)"); - } - else - { - Logger.LogWarning("Enroll: 'priorcertsn' key exists but 'PriorCertSN' (case-sensitive) not found."); - } + priorSn = productInfo.ProductParameters["PriorCertSN"]; + Logger.LogDebug("Enroll: Prior cert SN: '{PriorSn}'", priorSn ?? "(null)"); } }, string.IsNullOrEmpty(priorSn) ? "none" : $"SN={priorSn}"); @@ -676,8 +688,8 @@ await flow.StepAsync("SubmitRegistrationToCSC", async () => flow.Fail("ParseResponse", "API returned null"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Enrollment failed: CSC API returned a null response." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed: CSC API returned a null response." }; } flow.Step("ParseResponse", $"error={enrollmentResponse.RegistrationError != null}"); @@ -688,18 +700,15 @@ await flow.StepAsync("SubmitRegistrationToCSC", async () => flow.Fail("RejectExpiredRenew", "PriorCertSN present on New enrollment"); return new EnrollmentResult { - Status = 30, - StatusMessage = "You cannot renew an expired cert please perform an new enrollment." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nYou cannot renew an expired cert please perform an new enrollment." }; } var enrollResult = _requestManager.GetEnrollmentResult(enrollmentResponse); flow.Step("MapResult", $"Status={enrollResult?.Status}, ID={enrollResult?.CARequestID ?? "(null)"}"); - await flow.StepAsync("PublishCnameDcv", async () => - { - await TryPublishCnameDcvAsync(productInfo, enrollResult); - }); + await flow.StepAsync("DcvAutoPublish", () => TryPublishCnameDcvAsync(productInfo, enrollResult)); EnrollmentResult? newPolled = null; await flow.StepAsync("PollForIssuance", async () => @@ -709,10 +718,12 @@ await flow.StepAsync("PollForIssuance", async () => if (newPolled != null) { flow.Step("PollResult", "issued during poll window"); + AttachFlowSummary(newPolled, flow); Logger.MethodExit(LogLevel.Debug); return newPolled; } + AttachFlowSummary(enrollResult, flow); Logger.MethodExit(LogLevel.Debug); return enrollResult; @@ -724,8 +735,8 @@ await flow.StepAsync("PollForIssuance", async () => flow.Fail("ValidatePriorSN", "PriorCertSN is empty"); return new EnrollmentResult { - Status = 30, - StatusMessage = "RenewOrReissue failed: PriorCertSN is required but was not provided." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: PriorCertSN is required but was not provided." }; } @@ -740,8 +751,8 @@ await flow.StepAsync("LookupOrderId", async () => flow.Fail("ValidateOrderId", $"no order found for SN={priorSn}"); return new EnrollmentResult { - Status = 30, - StatusMessage = $"RenewOrReissue failed: could not find order ID for serial number '{priorSn}'." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: could not find order ID for serial number '{priorSn}'." }; } @@ -750,8 +761,8 @@ await flow.StepAsync("LookupOrderId", async () => flow.Fail("ValidateOrderId", $"order_id too short ({order_id.Length} chars)"); return new EnrollmentResult { - Status = 30, - StatusMessage = $"RenewOrReissue failed: order ID '{order_id}' is too short to extract a UUID." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: order ID '{order_id}' is too short to extract a UUID." }; } flow.Step("ValidateOrderId", $"orderId={order_id}"); @@ -799,8 +810,8 @@ await flow.StepAsync("FetchLiveCertForDecision", async () => flow.Fail("FallbackExpiryCheck", fallbackEx.Message); return new EnrollmentResult { - Status = 30, - StatusMessage = $"RenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" }; } } @@ -823,8 +834,8 @@ await flow.StepAsync("LookupRenewalUUID", async () => flow.Fail("ValidateRenewalUUID", "could not resolve PriorCertSN"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Renewal failed: could not resolve prior certificate serial number to a request ID." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewal failed: could not resolve prior certificate serial number to a request ID." }; } flow.Step("ValidateRenewalUUID", $"uuid={uUId}"); @@ -848,8 +859,8 @@ await flow.StepAsync("SubmitRenewalToCSC", async () => flow.Fail("ParseRenewalResponse", "API returned null"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Renewal failed: CSC API returned a null response." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewal failed: CSC API returned a null response." }; } @@ -862,6 +873,7 @@ await flow.StepAsync("PollForIssuance", async () => { renewPolled = await TryPollForIssuedCertAsync(renewResult?.CARequestID); }); + AttachFlowSummary(renewPolled ?? renewResult, flow); Logger.MethodExit(LogLevel.Debug); return renewPolled ?? renewResult; } @@ -869,9 +881,9 @@ await flow.StepAsync("PollForIssuance", async () => flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click renew unavailable"); return new EnrollmentResult { - Status = 30, + Status = (int)EndEntityStatus.FAILED, StatusMessage = - "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." + $"{flow.GetSummary()}\n\nOne click Renew Is Not Available for this Certificate Type. Use the configure button instead." }; } @@ -890,8 +902,8 @@ await flow.StepAsync("LookupReissueRequestId", async () => flow.Fail("ValidateReissueRequestId", "could not resolve PriorCertSN"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Reissue failed: could not resolve prior certificate serial number to a request ID." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nReissue failed: could not resolve prior certificate serial number to a request ID." }; } @@ -900,8 +912,8 @@ await flow.StepAsync("LookupReissueRequestId", async () => flow.Fail("ValidateReissueRequestId", $"requestid too short ({requestid.Length} chars)"); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Reissue failed: request ID '{requestid}' is too short to extract a UUID." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nReissue failed: request ID '{requestid}' is too short to extract a UUID." }; } @@ -927,8 +939,8 @@ await flow.StepAsync("SubmitReissueToCSC", async () => flow.Fail("ParseReissueResponse", "API returned null"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Reissue failed: CSC API returned a null response." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nReissue failed: CSC API returned a null response." }; } @@ -941,6 +953,7 @@ await flow.StepAsync("PollForIssuance", async () => { reissuePolled = await TryPollForIssuedCertAsync(reissueResult?.CARequestID); }); + AttachFlowSummary(reissuePolled ?? reissueResult, flow); Logger.MethodExit(LogLevel.Debug); return reissuePolled ?? reissueResult; } @@ -948,17 +961,17 @@ await flow.StepAsync("PollForIssuance", async () => flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click reissue unavailable"); return new EnrollmentResult { - Status = 30, + Status = (int)EndEntityStatus.FAILED, StatusMessage = - "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." + $"{flow.GetSummary()}\n\nOne click Reissue Is Not Available for this Certificate Type. Use the configure button instead." }; default: flow.Fail("UnhandledType", $"enrollmentType={enrollmentType}"); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Enroll failed: unhandled enrollment type '{enrollmentType}'." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnroll failed: unhandled enrollment type '{enrollmentType}'." }; } } @@ -969,8 +982,8 @@ await flow.StepAsync("PollForIssuance", async () => Logger.LogError(inner, "Enroll: AggregateException during {EnrollmentType}: {Message}", enrollmentType, inner?.Message ?? ae.Message); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Enrollment failed with error: {inner?.Message ?? ae.Message}" + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed with error: {inner?.Message ?? ae.Message}" }; } catch (Exception ex) @@ -979,12 +992,40 @@ await flow.StepAsync("PollForIssuance", async () => Logger.LogError(ex, "Enroll: unhandled exception during {EnrollmentType}: {Message}", enrollmentType, ex.Message); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Enrollment failed with error: {ex.Message}" + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed with error: {ex.Message}" }; } } + // CSC Global business-level failures (e.g. "Open order in progress") come back from + // RequestManager as a terse StatusMessage with no context on what the plugin actually did + // before hitting that error - prepend the flow's step-by-step summary so the message shown + // to the requester in Command explains what ran, not just how it ended. Command's enrollment + // UI does not surface StatusMessage on a successful/pending result at all - only + // EnrollmentContext is - so attach the summary there instead, as its own entry alongside + // whatever DCV instructions came back. Must be called after TryPublishCnameDcvAsync, which + // treats every EnrollmentContext entry as a candidate DNS record to publish - calling this + // first would make it try to publish "Flow Summary" as a CNAME. + private static void AttachFlowSummary(EnrollmentResult? result, FlowLogger flow) + { + if (result == null) + return; + + if (result.Status == (int)EndEntityStatus.FAILED) + { + result.StatusMessage = $"{flow.GetSummary()}\n\n{result.StatusMessage}"; + return; + } + + // One EnrollmentContext entry per step (rather than one entry holding the whole + // multi-line summary) so Command's bulleted rendering shows a readable line per step + // instead of a single run-on blob. + result.EnrollmentContext ??= new Dictionary(); + foreach (var entry in flow.GetSummaryEntries()) + result.EnrollmentContext[entry.Key] = entry.Value; + } + //done public async Task Ping() { @@ -1079,17 +1120,14 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, throw new ArgumentException("ProductID cannot be null or empty.", nameof(productInfo)); } - var certType = ProductIDs.productIds.Find(x => - x.Equals(productInfo.ProductID, StringComparison.InvariantCultureIgnoreCase)); - - if (certType == null) + if (!_requestManager.IsKnownProductId(productInfo.ProductID)) { Logger.LogError("ValidateProductInfo: cannot find product ID '{ProductId}'. Known IDs: [{KnownIds}]", productInfo.ProductID, string.Join(", ", ProductIDs.productIds)); throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); } - Logger.LogInformation("Validated {CertType} configured for AnyGateway", certType); + Logger.LogInformation("Validated {ProductId} configured for AnyGateway", productInfo.ProductID); Logger.MethodExit(LogLevel.Debug); } @@ -1357,19 +1395,21 @@ record = null; /// resolves for its domain. No-op if the factory wasn't injected, the cert isn't using CNAME /// validation, or the response contains no CNAME details. Failures are logged but never thrown — /// manual publishing remains a fallback so the enrollment result is still returned to Keyfactor. + /// Returns a short description of what happened (published/skipped/why), surfaced as the + /// flow step's detail so a no-op for non-CNAME methods doesn't look unexplained. /// - private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) + private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) { if (_validatorFactory == null) { Logger.LogTrace("TryPublishCnameDcvAsync: no IDomainValidatorFactory was injected, skipping auto-publish."); - return; + return "skipped - no DNS validator factory injected"; } if (enrollResult?.EnrollmentContext == null || enrollResult.EnrollmentContext.Count == 0) { Logger.LogTrace("TryPublishCnameDcvAsync: no CNAME entries in EnrollmentContext, skipping."); - return; + return "skipped - no DCV entries returned by CSC"; } var dcvMethod = productInfo?.ProductParameters != null @@ -1381,7 +1421,7 @@ private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, En !string.Equals(dcvMethod, "CNAME", StringComparison.OrdinalIgnoreCase)) { Logger.LogTrace("TryPublishCnameDcvAsync: DCV method '{Method}' is not CNAME, skipping auto-publish.", dcvMethod ?? "(null)"); - return; + return $"skipped - DCV method is '{dcvMethod ?? "(none)"}', not CNAME"; } Logger.LogInformation( @@ -1467,6 +1507,9 @@ private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, En Logger.LogInformation( "TryPublishCnameDcvAsync: complete. Published={Published}, Failed={Failed}, Unresolved={Unresolved}", successCount, failCount, unresolvedCount); + + return $"published {successCount}, failed {failCount}, unresolved {unresolvedCount} " + + $"of {enrollResult.EnrollmentContext.Count} CNAME record(s)"; } //Trying to fix leaf extraction diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index 032f535..7a5b722 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -21,7 +21,13 @@ public sealed class CscGlobalClient : ICscGlobalClient { private readonly ILogger Logger; - public CscGlobalClient(IAnyCAPluginConfigProvider config) + public CscGlobalClient(IAnyCAPluginConfigProvider config) : this(config, null) + { + } + + // internal so the test project can supply a fake HttpMessageHandler via + // InternalsVisibleTo, instead of the client making real HTTP calls in unit tests. + internal CscGlobalClient(IAnyCAPluginConfigProvider config, HttpMessageHandler? handler) { Logger = LogHandler.GetClassLogger(); @@ -68,7 +74,7 @@ public CscGlobalClient(IAnyCAPluginConfigProvider config) } Logger.LogTrace("CscGlobalClient: BearerToken is present (length={Length}).", Authorization.Length); - RestClient = ConfigureRestClient(); + RestClient = ConfigureRestClient(handler); Logger.LogTrace("CscGlobalClient: RestClient configured successfully."); } else @@ -352,10 +358,9 @@ public async Task SubmitCertificateListRequestAsync(str return certificateListResponse; } - private HttpClient ConfigureRestClient() + private HttpClient ConfigureRestClient(HttpMessageHandler? handler = null) { - var clientHandler = new HttpClientHandler(); - var returnClient = new HttpClient(clientHandler, true) + var returnClient = new HttpClient(handler ?? new HttpClientHandler(), true) { BaseAddress = BaseUrl }; diff --git a/cscglobal-caplugin/Client/Models/Price.cs b/cscglobal-caplugin/Client/Models/Price.cs index ad66ea3..6c71b3e 100644 --- a/cscglobal-caplugin/Client/Models/Price.cs +++ b/cscglobal-caplugin/Client/Models/Price.cs @@ -13,5 +13,5 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; public class Price : IPrice { [JsonProperty("currency")] public string Currency { get; set; } - [JsonProperty("total")] public decimal Total { get; set; } + [JsonProperty("total")] public decimal? Total { get; set; } } \ No newline at end of file diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index dc10866..be33065 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -23,13 +23,16 @@ public class ProductIDs { public static List productIds = new List() { - "CSC TrustedSecure Premium Certificate", - "CSC TrustedSecure EV Certificate", - "CSC TrustedSecure UC Certificate", - "CSC TrustedSecure Premium Wildcard Certificate", - "CSC TrustedSecure Domain Validated SSL", - "CSC Trusted Secure Domain Validated Wildcard SSL", - "CSC Trusted Secure Domain Validated UC Certificate" + "CSC TrustedSecure OV", + "CSC TrustedSecure OV Wildcard", + "CSC TrustedSecure OV, Multiple Names", + "CSC TrustedSecure EV", + "CSC TrustedSecure DV", + "CSC TrustedSecure DV Wildcard", + "CSC TrustedSecure DV, Multiple Names", + "CSC TrustedSecure EV, Multiple Names", + "CSC TrustedSecure OV Wildcard, Multiple Names", + "CSC TrustedSecure DV Wildcard, Multiple Names" }; } diff --git a/cscglobal-caplugin/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs index 5696fcd..fb90961 100644 --- a/cscglobal-caplugin/FlowLogger.cs +++ b/cscglobal-caplugin/FlowLogger.cs @@ -124,6 +124,42 @@ public async Task StepAsync(string name, Func action, string d return this; } + /// + /// Record an async step whose own return value becomes the step's detail - unlike the + /// parameter on the other overload (which is evaluated before + /// the action runs and so can't reflect anything the action decided), this reflects what + /// actually happened during execution (e.g. why a conditional step was a no-op). + /// + public async Task StepAsync(string name, Func> action) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + var detail = await action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = detail; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", + _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = FlowStepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = ex.Message; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", + _flowName, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + return this; + } + /// Record a failed step without throwing. public FlowLogger Fail(string name, string reason = null) { @@ -219,6 +255,86 @@ private string RenderFlow() return sb.ToString(); } + /// + /// Concise step-by-step summary suitable for surfacing in a user-facing failure message + /// (unlike 's ASCII-art tree, which is meant for Trace logs only). + /// + public string GetSummary() + { + var hasFailures = _steps.Any(s => s.Status == FlowStepStatus.Failed) || + _steps.SelectMany(s => s.Children).Any(c => c.Status == FlowStepStatus.Failed); + var overallStatus = hasFailures ? "FAILED" : "OK"; + + var sb = new StringBuilder(); + sb.AppendLine($"Flow: {_flowName} [{overallStatus}] Total: {_totalTimer.ElapsedMilliseconds}ms"); + sb.AppendLine("----------------------------------------"); + + foreach (var step in _steps) + { + AppendSummaryLine(sb, step, 0); + foreach (var child in step.Children) + AppendSummaryLine(sb, child, 1); + } + + return sb.ToString(); + } + + private static void AppendSummaryLine(StringBuilder sb, FlowStep step, int indentLevel) + { + var indent = new string(' ', indentLevel * 2); + var icon = GetStatusIcon(step.Status); + var elapsed = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" - {step.Detail}" : ""; + sb.AppendLine($"{indent}{icon} {step.Name}{elapsed}{detail}"); + } + + /// + /// Same information as , but as one entry per step instead of a + /// single multi-line block. Intended for callers (e.g. EnrollmentResult.EnrollmentContext) + /// whose rendering surface displays a dictionary as a bulleted list and doesn't respect + /// embedded newlines - each step becomes its own bullet instead of one run-on line. + /// + public Dictionary GetSummaryEntries() + { + var allSteps = _steps.Concat(_steps.SelectMany(s => s.Children)).ToList(); + var hasFailures = allSteps.Any(s => s.Status == FlowStepStatus.Failed); + var overallStatus = hasFailures ? "FAILED" : "OK"; + var succeeded = allSteps.Count(s => s.Status == FlowStepStatus.Success); + var failed = allSteps.Count(s => s.Status == FlowStepStatus.Failed); + var skipped = allSteps.Count(s => s.Status == FlowStepStatus.Skipped); + + var entries = new Dictionary + { + [$"Flow: {_flowName}"] = + $"[{overallStatus}] {_totalTimer.ElapsedMilliseconds}ms total - " + + $"{allSteps.Count} steps ({succeeded} ok, {failed} failed, {skipped} skipped)" + }; + + var stepNumber = 0; + foreach (var step in _steps) + { + stepNumber++; + AddSummaryEntry(entries, step, stepNumber, false); + + foreach (var child in step.Children) + { + stepNumber++; + AddSummaryEntry(entries, child, stepNumber, true); + } + } + + return entries; + } + + private static void AddSummaryEntry(Dictionary entries, FlowStep step, int stepNumber, bool indent) + { + var icon = GetStatusIcon(step.Status); + var time = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" - {step.Detail}" : ""; + var prefix = indent ? " " : ""; + entries[$"Flow Step {stepNumber:00}: {prefix}{step.Name}"] = $"{icon}{time}{detail}"; + } + private static string GetStatusIcon(FlowStepStatus status) { return status switch diff --git a/cscglobal-caplugin/Interfaces/IPrice.cs b/cscglobal-caplugin/Interfaces/IPrice.cs index d4bab37..47eb1fb 100644 --- a/cscglobal-caplugin/Interfaces/IPrice.cs +++ b/cscglobal-caplugin/Interfaces/IPrice.cs @@ -10,5 +10,5 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces; public interface IPrice { string Currency { get; set; } - decimal Total { get; set; } + decimal? Total { get; set; } } \ No newline at end of file diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index faf02ac..281e317 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -22,6 +22,12 @@ public class RequestManager public static Func Pemify = ss => ss.Length <= 64 ? ss : ss.Substring(0, 64) + "\n" + Pemify(ss.Substring(64)); + // Certificate types that carry a list of additional SAN domains, vs. a single CN only. + private static readonly HashSet MultiNameCertificateTypes = new() { "2", "6", "7", "8", "9" }; + + // Certificate types that require EvCertificateDetails (Organization Country, etc.). + private static readonly HashSet EvCertificateTypes = new() { "3", "7" }; + private List GetCustomFields(EnrollmentProductInfo productInfo, List customFields) { Logger.LogTrace("GetCustomFields: productInfo is {Null}, customFields count={Count}", @@ -377,60 +383,44 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, ShowPrice = true, CustomFields = GetCustomFields(productInfo, customFields), - SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, - EvCertificateDetails = certificateType == "3" ? GetEvCertificateDetails(productInfo) : null + SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, + EvCertificateDetails = EvCertificateTypes.Contains(certificateType) ? GetEvCertificateDetails(productInfo) : null }; } - // Maps Keyfactor product ID -> CSC API certificate type code (used for enrollment requests) + // Maps Keyfactor product ID -> CSC API certificate type code (used for enrollment requests). + // Each product has an entry for its current (1.2.0+) canonical name and its pre-1.2.0 legacy + // name, so existing Certificate Templates in Command using the old names keep working. + // Types 7/8/9 are new in 1.2.0 and have no legacy name. private static readonly Dictionary ProductIdToCodeMap = new(StringComparer.OrdinalIgnoreCase) { + ["CSC TrustedSecure OV"] = "0", ["CSC TrustedSecure Premium Certificate"] = "0", + ["CSC TrustedSecure OV Wildcard"] = "1", ["CSC TrustedSecure Premium Wildcard Certificate"] = "1", + ["CSC TrustedSecure OV, Multiple Names"] = "2", ["CSC TrustedSecure UC Certificate"] = "2", + ["CSC TrustedSecure EV"] = "3", ["CSC TrustedSecure EV Certificate"] = "3", + ["CSC TrustedSecure DV"] = "4", ["CSC TrustedSecure Domain Validated SSL"] = "4", ["CSC Trusted Secure Domain Validated SSL"] = "4", + ["CSC TrustedSecure DV Wildcard"] = "5", ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "5", + ["CSC TrustedSecure DV, Multiple Names"] = "6", ["CSC Trusted Secure Domain Validated UC Certificate"] = "6", + ["CSC TrustedSecure EV, Multiple Names"] = "7", + ["CSC TrustedSecure OV Wildcard, Multiple Names"] = "8", + ["CSC TrustedSecure DV Wildcard, Multiple Names"] = "9", }; - // Reverse map: CSC API certificateType string -> Keyfactor product ID (used during sync) - // Note: CSC naming is inconsistent — first 4 types use "TrustedSecure" (no space), - // DV Wildcard and DV UC use "Trusted Secure" (with space), - // but CSC API returns DV SSL as "CSC Trusted Secure Domain Validated SSL" (with space) - // while the product ID is "CSC TrustedSecure Domain Validated SSL" (no space). - private static readonly Dictionary CodeToProductIdMap = new(StringComparer.OrdinalIgnoreCase) - { - // Premium - ["0"] = "CSC TrustedSecure Premium Certificate", - ["CSC TrustedSecure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", - ["CSC Trusted Secure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", - // Premium Wildcard - ["1"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["CSC TrustedSecure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["CSC Trusted Secure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", - // UC - ["2"] = "CSC TrustedSecure UC Certificate", - ["CSC TrustedSecure UC Certificate"] = "CSC TrustedSecure UC Certificate", - ["CSC Trusted Secure UC Certificate"] = "CSC TrustedSecure UC Certificate", - // EV - ["3"] = "CSC TrustedSecure EV Certificate", - ["CSC TrustedSecure EV Certificate"] = "CSC TrustedSecure EV Certificate", - ["CSC Trusted Secure EV Certificate"] = "CSC TrustedSecure EV Certificate", - // DV SSL — product ID has no space, but CSC API returns with space - ["4"] = "CSC TrustedSecure Domain Validated SSL", - ["CSC TrustedSecure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", - ["CSC Trusted Secure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", - // DV Wildcard — product ID has space (matches CSC API) - ["5"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - // DV UC — product ID has space (matches CSC API) - ["6"] = "CSC Trusted Secure Domain Validated UC Certificate", - ["CSC Trusted Secure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", - ["CSC TrustedSecure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", - }; + /// + /// True if productId resolves to a known CSC certificate type - either its canonical + /// (1.2.0+) name or a pre-1.2.0 legacy name. Used by ValidateProductInfo so the list of + /// accepted names can't drift out of sync with what GetCertificateType actually resolves. + /// + public bool IsKnownProductId(string productId) => + !string.IsNullOrEmpty(productId) && ProductIdToCodeMap.ContainsKey(productId); private string GetCertificateType(string productId) { @@ -525,9 +515,9 @@ public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, strin OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, ShowPrice = true, - SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, + SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), - EvCertificateDetails = certificateType == "3" ? GetEvCertificateDetails(productInfo) : null + EvCertificateDetails = EvCertificateTypes.Contains(certificateType) ? GetEvCertificateDetails(productInfo) : null }; } @@ -555,6 +545,14 @@ private List GetSubjectAlternativeNames(EnrollmentProduc ? productInfo.ProductParameters["Domain Control Validation Method"] : null; + // CSC Global rejects the request if any subjectAlternativeNames entry is missing + // domainControlValidation, so every SAN below must resolve to a non-null value - falling + // back to the primary CN's DCV email when no per-domain override matches. + var commonNameValidationEmail = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey(EnrollmentConfigConstants.CnDcvEmail) + ? productInfo.ProductParameters[EnrollmentConfigConstants.CnDcvEmail] + : null; + Logger.LogTrace("GetSubjectAlternativeNames: processing {Count} DNS names, methodType='{MethodType}'", dnsNames.Length, methodType ?? "(null)"); @@ -573,18 +571,19 @@ private List GetSubjectAlternativeNames(EnrollmentProduc if (!string.IsNullOrEmpty(methodType) && methodType.ToUpper() == "EMAIL") { - var emailsRaw = productInfo.ProductParameters.ContainsKey("Addtl Sans Comma Separated DVC Emails") - ? productInfo.ProductParameters["Addtl Sans Comma Separated DVC Emails"] + var emailsRaw = productInfo.ProductParameters.ContainsKey(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails) + ? productInfo.ProductParameters[EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails] : null; var emailAddresses = !string.IsNullOrEmpty(emailsRaw) ? emailsRaw.Split(',') : Array.Empty(); Logger.LogTrace("GetSubjectAlternativeNames: EMAIL validation, {Count} email addresses for domain='{Domain}'", emailAddresses.Length, domainName); - san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName); + san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName) + ?? GetDomainControlValidation(methodType, commonNameValidationEmail); } else { Logger.LogTrace("GetSubjectAlternativeNames: CNAME/other validation for domain='{Domain}'", domainName); - san.DomainControlValidation = GetDomainControlValidation(methodType, ""); + san.DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail); } subjectNameList.Add(san); @@ -636,9 +635,9 @@ public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, strin OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, ShowPrice = true, - SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, + SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), - EvCertificateDetails = certificateType == "3" ? GetEvCertificateDetails(productInfo) : null + EvCertificateDetails = EvCertificateTypes.Contains(certificateType) ? GetEvCertificateDetails(productInfo) : null }; } diff --git a/docsource/configuration.md b/docsource/configuration.md index 5dee9d3..122477f 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -129,16 +129,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 -----|--------------|----------------- @@ -153,20 +153,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 -----|--------------|----------------- @@ -182,20 +182,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 -----|--------------|----------------- @@ -212,20 +212,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 -----|--------------|----------------- @@ -240,20 +240,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 -----|--------------|----------------- @@ -268,20 +268,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 -----|--------------|----------------- @@ -296,20 +296,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/integration-manifest.json b/integration-manifest.json index e6c5243..978dacc 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -2,8 +2,8 @@ "$schema": "https://keyfactor.github.io/integration-manifest-schema.json", "integration_type": "anyca-plugin", "name": "CSCGlobal CAPlugin REST Gateway Plugin", - "support_level": "kf-supported", "status": "production", + "support_level": "kf-supported", "link_github": true, "update_catalog": true, "description": "CSCGlobal CAPlugin for the AnyCA REST Gateway framework", @@ -93,14 +93,17 @@ } ], "product_ids": [ - "CSC TrustedSecure Premium Certificate", - "CSC TrustedSecure EV Certificate", - "CSC TrustedSecure UC Certificate", - "CSC TrustedSecure Premium Wildcard Certificate", - "CSC TrustedSecure Domain Validated SSL", - "CSC Trusted Secure Domain Validated Wildcard SSL", - "CSC Trusted Secure Domain Validated UC Certificate" + "CSC TrustedSecure OV", + "CSC TrustedSecure OV Wildcard", + "CSC TrustedSecure OV, Multiple Names", + "CSC TrustedSecure EV", + "CSC TrustedSecure DV", + "CSC TrustedSecure DV Wildcard", + "CSC TrustedSecure DV, Multiple Names", + "CSC TrustedSecure EV, Multiple Names", + "CSC TrustedSecure OV Wildcard, Multiple Names", + "CSC TrustedSecure DV Wildcard, Multiple Names" ] } } -} +} \ No newline at end of file