From 3b830b01701346b48deccd51cdf3e8aaff26a4b3 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:19:45 -0700 Subject: [PATCH 01/17] fix(build): exclude CnameResolverLiveDnsTests from the no-DCV IntegrationTests build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The -p:DcvSupport=false flavor failed to compile: CnameResolverLiveDnsTests.cs references the IntegrationTestData helper defined in DcvLifecycleTests.cs, which is already excluded on that flavor, and the test itself exercises DcvFollowCnameDelegation — a DCV feature. Exclude it alongside the other DCV test files. --- CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj index 6cfb4a5..f70cd75 100644 --- a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj +++ b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj @@ -24,6 +24,10 @@ + + From 5b5e3fe6249896b1196138564c3b0fbb67d9db5d Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:19:45 -0700 Subject: [PATCH 02/17] feat(enroll): synchronous certificate pickup on all enrollment paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enroll() now polls GetCertificate after submitting an order so fast-issuing products return the issued certificate in the same call instead of deferring to the next synchronization — restoring the behavior expiration-renewal workflows relied on with the legacy Sectigo connector (PickUpEnrolledCertificate). - Generalize the post-DCV WaitForIssuanceAfterDcvAsync into a parameterized WaitForIssuanceAsync (budget, interval, ct) shared by the post-DCV wait and the new pickup; a budget of retries × delay yields exactly 'retries' polls. - TryPickupIssuedCertificateAsync runs at the end of EnrollNewAsync and the renew-API path on BOTH build flavors (default DCV/3.3.0 and -p:DcvSupport=false/3.2.0). DV orders poll; OV/EV orders return pending immediately with a message explaining that CERTInext issues them asynchronously by design (organization verification — confirmed by CERTInext support, ticket #162763). Unknown products poll optimistically. Timeout and any failure soft-fall back to the pending result — never throws. - Product validation level (DV/OV/EV) resolves from the account catalog (GetProductDetails already returns productTypeID/productName; classified by name token since numeric codes differ per environment), cached 60 min with a 5-min failure back-off, falling back to the template product name. The renew path classifies the connector DefaultProductCode — the code the renewal order is actually placed with. - New connector settings PickupRetries (default 5) and PickupDelaySeconds (default 10), env-var overridable (CERTINEXT_PICKUP_RETRIES / CERTINEXT_PICKUP_DELAY_SECONDS); retries × delay ≈ max Command worker-thread hold; 0 disables. One CTS (budget + grace) bounds the entire pickup including the catalog fetch (SOX CC7.3). - 17 new unit tests: DV pending-then-issued → GENERATED+PEM, OV/EV defer without polling, catalog caching and failure back-off, soft fallbacks, renew-path pickup and product-code classification, PEM recovery for issued-without-body orders. - Docs: configuration/overview/architecture docsource updates, README regenerated via doctool, integration-manifest entries, CHANGELOG 1.2.0. --- CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 2 +- .../CERTInextCAPluginPickupTests.cs | 556 ++++++++++++++++++ CERTInext/API/CertificateResponse.cs | 10 + CERTInext/CERTInextCAPlugin.cs | 398 +++++++++++-- CERTInext/CERTInextCAPluginConfig.cs | 102 +++- CERTInext/Constants.cs | 27 + CERTInext/Models/ProductValidationType.cs | 65 ++ CHANGELOG.md | 9 + README.md | 16 +- docsource/architecture.md | 12 +- docsource/configuration.md | 9 + docsource/overview.md | 9 +- integration-manifest.json | 8 + 13 files changed, 1160 insertions(+), 63 deletions(-) create mode 100644 CERTInext.Tests/CERTInextCAPluginPickupTests.cs create mode 100644 CERTInext/Models/ProductValidationType.cs diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index f1de8f8..4d639b8 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -463,7 +463,7 @@ public async Task Dcv_Skipped_WhenOrderStatusIdIsTerminal_EvenIfDcvValidated(str mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never, - "Enroll must not enter WaitForIssuanceAfterDcvAsync when the order is " + + "Enroll must not enter the post-DCV issuance wait (WaitForIssuanceAsync) when the order is " + "cancelled/rejected, even if DCV happens to be in a 'validated' state"); validator.StagedRecords.Should().BeEmpty( "DCV staging must not run for a cancelled/rejected order"); diff --git a/CERTInext.Tests/CERTInextCAPluginPickupTests.cs b/CERTInext.Tests/CERTInextCAPluginPickupTests.cs new file mode 100644 index 0000000..9935572 --- /dev/null +++ b/CERTInext.Tests/CERTInextCAPluginPickupTests.cs @@ -0,0 +1,556 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CERTInext.API; +using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Unit tests for the synchronous pickup poll (TryPickupIssuedCertificateAsync) + /// that runs at the end of every enrollment path on both build flavors: + /// DV products poll GetCertificate and return GENERATED + PEM when CERTInext + /// issues within the budget; OV/EV products defer immediately (async by CA design, + /// support ticket #162763); exhaustion or any failure soft-falls back to the pending + /// result without throwing. Compiles on both the DCV (3.3.0) and no-DCV (3.2.0) flavors. + /// + public class CERTInextCAPluginPickupTests + { + private const string DvCode = "842"; + private const string OvCode = "846"; + private const string EvCode = "850"; + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static Mock NewMock() => new Mock(MockBehavior.Strict); + + /// Config with a fast pickup budget so tests don't sit in real delays. + private static CERTInextConfig PickupConfig(int retries = 3, int delaySeconds = 1) => + new CERTInextConfig { PickupRetries = retries, PickupDelaySeconds = delaySeconds }; + + private static List SslCatalog() => new List + { + new ProductDetail { ProductCode = DvCode, ProductName = "DV SSL Certificate 1 Year", ProductTypeId = "13" }, + new ProductDetail { ProductCode = OvCode, ProductName = "OV SSL Certificate 1 Year", ProductTypeId = "15" }, + new ProductDetail { ProductCode = EvCode, ProductName = "EV SSL Certificate 1 Year", ProductTypeId = "17" } + }; + + private static EnrollmentProductInfo ProductInfo(string productName, string productCode) => + new EnrollmentProductInfo + { + ProductID = productName, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = productCode + } + }; + + private static Task Enroll( + CERTInextCAPlugin plugin, EnrollmentProductInfo productInfo, + EnrollmentType type = EnrollmentType.New) => + plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=test.example.com", + san: new Dictionary { ["dns"] = new[] { "test.example.com" } }, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: type); + + private static void SetupPendingEnroll(Mock mock) => + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); + + private static void SetupCatalog(Mock mock) => + mock.Setup(c => c.GetProductDetailsAsync(It.IsAny())) + .ReturnsAsync(SslCatalog()); + + // --------------------------------------------------------------------------- + // DV: pending-N-then-issued → GENERATED + PEM + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_DvProduct_PendingThenIssued_ReturnsGeneratedWithPem() + { + var mock = NewMock(); + SetupPendingEnroll(mock); + SetupCatalog(mock); + mock.SetupSequence(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.CertId2)) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.CertId2)); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "a DV order that issues within the pickup budget must return synchronously"); + result.Certificate.Should().Contain("BEGIN CERTIFICATE"); + result.CARequestID.Should().Be(MockCertificateData.CertId2); + + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2), "the poll must stop as soon as the certificate is issued"); + } + + [Fact] + public async Task Pickup_DvProduct_IssuedOnFirstPoll_ReturnsGenerated() + { + var mock = NewMock(); + SetupPendingEnroll(mock); + SetupCatalog(mock); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.CertId2)); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + // --------------------------------------------------------------------------- + // OV/EV: pending immediately, no poll + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_OvProduct_ReturnsPendingImmediately_WithoutPolling() + { + var mock = NewMock(); + SetupPendingEnroll(mock); + SetupCatalog(mock); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.OvSsl, OvCode)); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + result.StatusMessage.Should().Contain("asynchronously", + "the operator must be told OV issuance is async by CA design, not a failure"); + result.StatusMessage.Should().Contain("synchronization", + "the operator must be told the cert completes on a later sync"); + + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Never, + "OV orders take minutes to issue (org verification) — polling holds a Command " + + "worker thread with no chance of success"); + } + + [Fact] + public async Task Pickup_EvProduct_ReturnsPendingImmediately_WithoutPolling() + { + var mock = NewMock(); + SetupPendingEnroll(mock); + SetupCatalog(mock); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.EvSsl, EvCode)); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Pickup_OvByTemplateName_Defers_WhenCatalogUnavailable() + { + var mock = NewMock(); + SetupPendingEnroll(mock); + mock.Setup(c => c.GetProductDetailsAsync(It.IsAny())) + .ThrowsAsync(new Exception("catalog endpoint down")); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + + // Template product name carries the OV token — the fallback classifier + // must still prevent a futile poll when the catalog can't be fetched. + var result = await Enroll(plugin, ProductInfo(Constants.Products.OvSslWildcard, OvCode)); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + // --------------------------------------------------------------------------- + // Product-type catalog caching + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_ProductCatalog_IsCachedAcrossEnrollments() + { + var mock = NewMock(); + SetupPendingEnroll(mock); + SetupCatalog(mock); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var ov = ProductInfo(Constants.Products.OvSsl, OvCode); + + await Enroll(plugin, ov); + await Enroll(plugin, ov); + await Enroll(plugin, ov); + + mock.Verify(c => c.GetProductDetailsAsync(It.IsAny()), Times.Once, + "the catalog must be cached — never fetched per-enrollment"); + } + + // --------------------------------------------------------------------------- + // Unknown type: poll optimistically + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_UnknownProduct_PollsOptimistically() + { + var mock = NewMock(); + SetupPendingEnroll(mock); + mock.Setup(c => c.GetProductDetailsAsync(It.IsAny())) + .ThrowsAsync(new Exception("catalog endpoint down")); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.CertId2)); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + + // Neither the catalog nor the product name identify DV/OV/EV → the bounded poll + // runs anyway (a wasted wait beats silently breaking a fast product's sync return). + var result = await Enroll(plugin, ProductInfo(MockCertificateData.ProfileIdTls, MockCertificateData.ProfileIdTls)); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + // --------------------------------------------------------------------------- + // Soft fallback — never throw + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_SoftFallsBackToPending_WhenBudgetExhausted() + { + var mock = NewMock(); + SetupPendingEnroll(mock); + SetupCatalog(mock); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.CertId2)); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: 2, delaySeconds: 1)); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "exhausting the pickup budget must degrade to the pending result, never throw"); + result.StatusMessage.Should().Contain("later synchronization"); + // Upper bound 2 is the documented PickupRetries semantics (the old off-by-one + // yielded retries+1 = 3). Lower bound 1 rather than exactly 2 because the poll + // loop runs against the real clock — a stalled test runner can legitimately + // exhaust the 2 s budget after a single poll. + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Between(1, 2, Moq.Range.Inclusive), + "PickupRetries=2 must never yield more than two polls"); + } + + [Fact] + public async Task Pickup_SoftFallsBackToPending_WhenGetCertificateThrows() + { + var mock = NewMock(); + SetupPendingEnroll(mock); + SetupCatalog(mock); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("CERTInext API 500")); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "a failing pickup poll must not fail the enrollment — the order was accepted"); + result.CARequestID.Should().Be(MockCertificateData.CertId2); + } + + [Fact] + public async Task Pickup_ReturnsFailed_WhenOrderReachesTerminalFailure() + { + var mock = NewMock(); + SetupPendingEnroll(mock); + SetupCatalog(mock); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new LegacyGetCertificateResponse + { + Id = MockCertificateData.CertId2, + Status = "failed" + }); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.FAILED, + "a terminal failure discovered during pickup must be surfaced, not left pending"); + result.StatusMessage.Should().NotContain("Issued", + "the operator-visible message for a rejected order must not claim the certificate was issued"); + } + + // --------------------------------------------------------------------------- + // Opt-out and no-op paths + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_Disabled_WhenRetriesZero() + { + var mock = NewMock(); + SetupPendingEnroll(mock); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: 0)); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + mock.Verify(c => c.GetProductDetailsAsync(It.IsAny()), Times.Never, + "with pickup disabled the catalog must not be fetched either"); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Pickup_Skipped_WhenEnrollReturnsIssuedWithPem() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedEnrollResponse()); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Never, "an already-complete result needs no pickup"); + } + + [Fact] + public async Task Pickup_FetchesPem_WhenEnrollReturnsIssuedWithoutPem() + { + var mock = NewMock(); + var issuedNoPem = MockCertificateData.IssuedEnrollResponse(); + issuedNoPem.Certificate = null; // fulfilled order whose post-submit download failed + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(issuedNoPem); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord()); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().Contain("BEGIN CERTIFICATE", + "the pickup must recover the PEM for an issued order whose download failed"); + } + + // --------------------------------------------------------------------------- + // Catalog failure back-off + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_CatalogFailure_IsBackedOff_NotRetriedPerEnrollment() + { + var mock = NewMock(); + SetupPendingEnroll(mock); + mock.Setup(c => c.GetProductDetailsAsync(It.IsAny())) + .ThrowsAsync(new Exception("catalog endpoint down")); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.CertId2)); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var dv = ProductInfo(Constants.Products.DvSsl, DvCode); + + await Enroll(plugin, dv); + await Enroll(plugin, dv); + await Enroll(plugin, dv); + + mock.Verify(c => c.GetProductDetailsAsync(It.IsAny()), Times.Once, + "a failing catalog fetch must be backed off — even while the cache is still " + + "empty — not retried on every enrollment"); + } + + // --------------------------------------------------------------------------- + // Renew path + // --------------------------------------------------------------------------- + + [Fact] + public async Task Pickup_RenewPath_PendingThenIssued_ReturnsGenerated() + { + var clientMock = NewMock(); + var readerMock = new Mock(MockBehavior.Strict); + + readerMock.Setup(r => r.GetRequestIDBySerialNumber(It.IsAny())) + .ReturnsAsync(MockCertificateData.CertId1); + readerMock.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1)) + .Returns(DateTime.UtcNow.AddDays(30)); + + clientMock.Setup(c => c.RenewCertificateAsync( + MockCertificateData.CertId1, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse("renewed-01")); + SetupCatalog(clientMock); + clientMock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord("renewed-01")); + + var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object, PickupConfig()); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = DvCode, + ["PriorCertSN"] = "AABB", + ["RenewalWindowDays"] = "90" + } + }; + + var result = await Enroll(plugin, productInfo, EnrollmentType.RenewOrReissue); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "the renew API path must run the same synchronous pickup as new enrollment — " + + "this is the expiration-renewal workflow scenario"); + result.Certificate.Should().Contain("BEGIN CERTIFICATE"); + clientMock.Verify(c => c.GetCertificateAsync("renewed-01", It.IsAny()), + Times.Once, "the pickup must poll the NEW order number returned by the renewal"); + } + + [Fact] + public async Task Pickup_RenewPath_RunsEvenWhenDcvEnabled() + { + // In-call DCV only exists on the New/Reissue path, so DcvEnabled must NOT + // suppress the pickup for renewals — that is the expiration-renewal scenario + // this feature exists for. + var clientMock = NewMock(); + var readerMock = new Mock(MockBehavior.Strict); + + readerMock.Setup(r => r.GetRequestIDBySerialNumber(It.IsAny())) + .ReturnsAsync(MockCertificateData.CertId1); + readerMock.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1)) + .Returns(DateTime.UtcNow.AddDays(30)); + + clientMock.Setup(c => c.RenewCertificateAsync( + MockCertificateData.CertId1, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse("renewed-02")); + SetupCatalog(clientMock); + clientMock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord("renewed-02")); + + var config = PickupConfig(); + config.DcvEnabled = true; + var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object, config); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = DvCode, + ["PriorCertSN"] = "AABB", + ["RenewalWindowDays"] = "90" + } + }; + + var result = await Enroll(plugin, productInfo, EnrollmentType.RenewOrReissue); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "DcvEnabled must not disable the renew-path pickup — no in-call DCV runs there"); + } + + [Fact] + public async Task Pickup_FetchesPem_ForIssuedOrder_EvenWhenDcvEnabled() + { + // An issued-but-PEM-missing order is past validation entirely, so the recovery + // fetch must run regardless of DCV configuration. + var mock = NewMock(); + var issuedNoPem = MockCertificateData.IssuedEnrollResponse(); + issuedNoPem.Certificate = null; + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(issuedNoPem); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord()); +#if SUPPORTS_DCV + // On the DCV build the enroll path consults TrackOrder for manual-DCV guidance + // when DcvEnabled is set without a validator factory; let it fail soft. + mock.Setup(c => c.TrackOrderAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("not relevant to this test")); +#endif + + var config = PickupConfig(); + config.DcvEnabled = true; + var plugin = new CERTInextCAPlugin(mock.Object, config); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().Contain("BEGIN CERTIFICATE", + "the PEM-recovery fetch must run even when DCV owns pending-order waits"); + } + + [Fact] + public async Task Pickup_RenewPath_ClassifiesTheProductCodeActuallyOrdered() + { + // CERTInextClient.RenewCertificateAsync places the renewal order with the + // connector's DefaultProductCode, not the template's code — the OV/EV gate + // must classify what was ordered, or it polls futilely / defers wrongly. + var clientMock = NewMock(); + var readerMock = new Mock(MockBehavior.Strict); + + readerMock.Setup(r => r.GetRequestIDBySerialNumber(It.IsAny())) + .ReturnsAsync(MockCertificateData.CertId1); + readerMock.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1)) + .Returns(DateTime.UtcNow.AddDays(30)); + + clientMock.Setup(c => c.RenewCertificateAsync( + MockCertificateData.CertId1, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(MockCertificateData.PendingEnrollResponse("renewed-03")); + SetupCatalog(clientMock); + + var config = PickupConfig(); + config.DefaultProductCode = OvCode; // what the renewal order is actually placed with + var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object, config); + var productInfo = new EnrollmentProductInfo + { + ProductID = Constants.Products.DvSsl, // template says DV… + ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductCode"] = DvCode, // …and so does its code + ["PriorCertSN"] = "AABB", + ["RenewalWindowDays"] = "90" + } + }; + + var result = await Enroll(plugin, productInfo, EnrollmentType.RenewOrReissue); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + clientMock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Never, + "the order was placed as OV (connector DefaultProductCode) — polling cannot win, " + + "regardless of what the template's own code says"); + } + } +} diff --git a/CERTInext/API/CertificateResponse.cs b/CERTInext/API/CertificateResponse.cs index dbaea80..b3b1441 100644 --- a/CERTInext/API/CertificateResponse.cs +++ b/CERTInext/API/CertificateResponse.cs @@ -587,6 +587,7 @@ public List FlattenProducts() ProductCode = p.ProductCode, ProductName = p.ProductName, ProductType = cat.CategoryName, + ProductTypeId = p.ProductTypeId, Active = true // API does not return an active flag at this level }); } @@ -658,6 +659,15 @@ public class ProductDetail [JsonPropertyName("productType")] public string ProductType { get; set; } + /// + /// Raw numeric product type ID from the API (e.g. "13" for DV SSL). The full + /// value space is not documented by CERTInext, so validation-level (DV/OV/EV) + /// classification is derived from instead; this is + /// retained for logging and diagnostics. + /// + [JsonPropertyName("productTypeID")] + public string ProductTypeId { get; set; } + /// /// Always true for products returned by the API — the API only /// returns products that are available on the account. diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index d6b384c..e64675f 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -84,6 +84,15 @@ public class CERTInextCAPlugin : IAnyCAPlugin, IDisposable // to stage TXT records for the same order. The value byte is unused; this is a set. private readonly ConcurrentDictionary _dcvInFlight = new(); + // Cached productCode → DV/OV/EV classification built from GetProductDetails, used by + // the synchronous pickup gate (TryPickupIssuedCertificateAsync). Refreshed at most + // once per Constants.Pickup.ProductTypeCacheMinutes so the catalog is never fetched + // per-enrollment; a fetch failure falls back to the stale map (or template-name + // classification) rather than failing the enrollment. + private readonly SemaphoreSlim _productTypeCacheLock = new(1, 1); + private volatile Dictionary _productTypeByCode; + private DateTime _productTypeCacheExpiresUtc = DateTime.MinValue; + #if SUPPORTS_DCV // Issue 0006: resolves CNAME delegation for the DCV challenge hostname when // DcvFollowCnameDelegation is enabled. Only ever referenced from PerformDcvIfNeededAsync, @@ -129,13 +138,16 @@ internal CERTInextCAPlugin(ICERTInextClient client) /// Internal test-injection constructor — pass a mock /// and a mock for tests that exercise /// RenewOrReissue logic that reads prior certificate data from Command's database. + /// An optional lets those tests also override + /// configuration (e.g. shrink the pickup-poll delays). /// - internal CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certDataReader) + internal CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certDataReader, + CERTInextConfig config = null) { _client = client; _clientWasInjected = true; _certificateDataReader = certDataReader; - _config = new CERTInextConfig(); + _config = config ?? new CERTInextConfig(); } /// @@ -224,6 +236,7 @@ public void Dispose() { if (!_clientWasInjected) (_client as IDisposable)?.Dispose(); + _productTypeCacheLock.Dispose(); } // --------------------------------------------------------------------------- @@ -1164,17 +1177,17 @@ private async Task EnrollNewAsync( // but the cert PEM isn't immediately available. Without this poll, Enroll // returns a pending result and the cert is picked up on the next sync cycle, // which is undesirable when the whole thing completes in under a minute. - var postDcv = await WaitForIssuanceAfterDcvAsync(orderNumber, dcvCts.Token); + // Fixed 3-second poll interval: the post-DCV issuance step typically + // completes within 5–15s, so a slower cadence would push typical-case + // latency toward the budget ceiling. Decoupled from + // DcvPropagationDelaySeconds (a DNS concern) so admins tuning DNS + // settings don't accidentally make this polling chunky. + var postDcv = await WaitForIssuanceAsync( + orderNumber, _config.GetEffectiveDcvWaitForIssuanceSeconds(), 3, dcvCts.Token); if (postDcv != null) { - return BuildEnrollmentResult(new EnrollCertificateResponse - { - Id = postDcv.Id, - Status = postDcv.Status, - Certificate = postDcv.Certificate, - SerialNumber = postDcv.SerialNumber, - Message = $"Post-DCV status: {postDcv.Status}." - }, ep.AutoApprove); + return BuildEnrollmentResultFromCertificate(postDcv, orderNumber, + $"Post-DCV status: {postDcv.Status}.", ep.AutoApprove); } } } @@ -1206,8 +1219,22 @@ private async Task EnrollNewAsync( } #endif + // Synchronous pickup (both build flavors): poll for the issued certificate so + // fast-issuing (DV) orders return GENERATED + PEM in this same call instead of + // deferring to the next sync cycle. No-ops for OV/EV, when the in-call DCV flow + // owns the wait, or when the result is already terminal. + bool dcvOwnsIssuanceWait = false; +#if SUPPORTS_DCV + // When DCV is enabled, the DCV branch above already performed (or deliberately + // deferred to the sync-driven DCV path) the issuance wait for this new order. + dcvOwnsIssuanceWait = _config.DcvEnabled; +#endif + var newResult = BuildEnrollmentResult(enrollResp, ep.AutoApprove); + newResult = await TryPickupIssuedCertificateAsync( + newResult, enrollResp.Id, ep, ep.ProductCode, dcvOwnsIssuanceWait); + _logger.MethodExit(LogLevel.Debug); - return BuildEnrollmentResult(enrollResp, ep.AutoApprove); + return newResult; } #if SUPPORTS_DCV @@ -1417,6 +1444,30 @@ private async Task RenewOrReissueAsync( "PriorCARequestID={PriorId}, NewCARequestID={NewId}, Status={Status}", priorCaRequestId, renewResult.CARequestID, renewResult.Status); + // Synchronous pickup (both build flavors) — expiration-renewal workflows get + // the issued cert back in this call when the CA issues fast enough (the + // original Sectigo-parity scenario). In-call DCV never runs on this path, so + // the pickup is always eligible (on DCV-enabled gateways a renewal that does + // need fresh domain validation simply exhausts the bounded budget and falls + // back to pending). The renewal order is actually placed with the connector's + // DefaultProductCode (see CERTInextClient.RenewCertificateAsync), which can + // differ from the template's code — classify the code that reached the API. + // When DefaultProductCode is blank the order went out with an empty code and + // the template's code is only a best-effort guess for the gate. + string renewedProductCode = string.IsNullOrWhiteSpace(_config.DefaultProductCode) + ? ep.ProductCode + : _config.DefaultProductCode; + if (!string.Equals(renewedProductCode, ep.ProductCode, StringComparison.Ordinal)) + { + _logger.LogWarning( + "Renewal order {OrderNumber} was placed with the connector DefaultProductCode " + + "({OrderedCode}), which differs from this template's product code ({TemplateCode}). " + + "The synchronous-pickup gate classifies the ordered code.", + renewResp.Id, renewedProductCode, ep.ProductCode); + } + renewResult = await TryPickupIssuedCertificateAsync( + renewResult, renewResp.Id, ep, renewedProductCode, dcvOwnsIssuanceWait: false); + return renewResult; } else @@ -1428,6 +1479,266 @@ private async Task RenewOrReissueAsync( } } + // --------------------------------------------------------------------------- + // Synchronous pickup — DCV-independent, both build flavors + // --------------------------------------------------------------------------- + + /// + /// Attempts to complete an enrollment synchronously by polling for the issued + /// certificate after the order was submitted, mirroring the legacy Sectigo + /// connector's PickUpEnrolledCertificate loop. Called at the end of every + /// enrollment path (New/Reissue and the Renew API path) on both build flavors. + /// + /// Only DV products are polled: CERTInext issues OV/EV asynchronously by design — + /// the mandatory organization-verification step takes minutes and may be human-gated + /// (support ticket #162763), so holding a Command worker thread for them cannot + /// succeed; those orders return pending immediately with an explanatory message and + /// are completed by the next synchronization. Products whose validation level cannot + /// be determined are polled optimistically — the poll is bounded and a wasted wait is + /// preferable to silently breaking a fast-issuing product's synchronous return. + /// + /// Never throws: any failure (catalog lookup, poll, cancellation) degrades to + /// returning unchanged so the order is picked up by + /// the next sync cycle, exactly as before this feature existed. + /// + /// + /// The numeric product code the order was actually placed with. Callers must pass + /// the code that reached the API — for renewals that is the connector's + /// DefaultProductCode (see ), which can + /// differ from the template's code. + /// + /// + /// True only on the New/Reissue path of a DCV-enabled gateway, where the in-call DCV + /// flow already performed (or deliberately deferred) the issuance wait — a pending + /// order there is waiting on domain validation that only the sync-driven DCV path can + /// advance, so a second poll cannot win. The renew path never runs in-call DCV and + /// must always be eligible for pickup. + /// + private async Task TryPickupIssuedCertificateAsync( + EnrollmentResult pendingResult, string orderNumber, EnrollmentParams ep, + string productCode, bool dcvOwnsIssuanceWait) + { + if (pendingResult == null || string.IsNullOrWhiteSpace(orderNumber)) + return pendingResult; + + // Two states can still benefit from a poll: pending approval (the normal case), + // and issued-but-PEM-missing (order fulfilled but the post-submit certificate + // download failed — one successful GetCertificate fetch completes the result). + bool pendingApproval = pendingResult.Status == (int)EndEntityStatus.EXTERNALVALIDATION; + bool issuedWithoutPem = pendingResult.Status == (int)EndEntityStatus.GENERATED + && string.IsNullOrWhiteSpace(pendingResult.Certificate); + if (!pendingApproval && !issuedWithoutPem) + return pendingResult; + + // Only the pending-approval state defers to the DCV flow — an issued-but-PEM-missing + // order is past validation entirely, so the fetch below is useful regardless of DCV. + if (dcvOwnsIssuanceWait && pendingApproval) + { + _logger.LogDebug( + "Skipping synchronous pickup for order {OrderNumber} — the in-call DCV flow owns this order's issuance wait.", + orderNumber); + return pendingResult; + } + + int retries = _config.GetEffectivePickupRetries(); + int delaySeconds = _config.GetEffectivePickupDelaySeconds(); + if (retries <= 0 || delaySeconds <= 0) + { + _logger.LogDebug( + "Synchronous pickup disabled (PickupRetries={Retries}, PickupDelaySeconds={Delay}). " + + "Order {OrderNumber} will be picked up on the next sync cycle.", + retries, delaySeconds, orderNumber); + return pendingResult; + } + + // Compute the budget in long first: both knobs accept arbitrary non-negative ints + // from env vars, and an int overflow here would go negative and make the CTS + // constructor throw (silently disabling pickup via the catch below). Clamp to a + // ceiling far above any sane configuration — the docs tell operators to stay + // under ~90 s. + const int maxBudgetSeconds = 3600; + int budgetSeconds = (int)Math.Min((long)retries * delaySeconds, maxBudgetSeconds); + + try + { + // One ceiling bounds the ENTIRE pickup — catalog classification included — so + // a hung catalog endpoint cannot hold a Command worker thread beyond the + // configured budget (+ grace for one in-flight request). This is what keeps + // the documented "retries × delay = max Command-occupied time" honest. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(budgetSeconds + 30)); + + var validationType = ProductValidationType.Unknown; + if (pendingApproval) + { + validationType = await ResolveProductValidationTypeAsync(productCode, ep.ProductId, cts.Token); + if (validationType is ProductValidationType.Ov or ProductValidationType.Ev) + { + string typeLabel = validationType == ProductValidationType.Ov ? "OV" : "EV"; + + // SOC2 CC7.2: the decision to defer is policy-relevant — log at + // Information so it survives production log filters. + _logger.LogInformation( + "Synchronous pickup skipped — {Type} products are issued asynchronously by " + + "CERTInext (organization verification). OrderNumber={OrderNumber}, " + + "ProductCode={ProductCode}. The certificate will be imported by a later synchronization.", + typeLabel, orderNumber, productCode); + + pendingResult.StatusMessage = + $"Certificate request accepted by CERTInext. ID: {orderNumber}. " + + $"{typeLabel} certificates are issued asynchronously by the CA — organization " + + "verification is performed on the CA side and can take minutes to hours, so the " + + "certificate cannot be returned within this enrollment call. It will be imported " + + "automatically by the next CA synchronization once CERTInext completes issuance."; + if (pendingResult.EnrollmentContext != null) + { + pendingResult.EnrollmentContext["certinextOrderNumber"] = orderNumber; + pendingResult.EnrollmentContext["certinextValidationType"] = typeLabel; + pendingResult.EnrollmentContext["certinextAsyncIssuanceByDesign"] = "true"; + } + return pendingResult; + } + } + + _logger.LogInformation( + "Synchronous pickup poll started. OrderNumber={OrderNumber}, ProductCode={ProductCode}, " + + "ValidationType={ValidationType}, Retries={Retries}, DelaySeconds={Delay}, BudgetSeconds={Budget}", + orderNumber, productCode, validationType, retries, delaySeconds, budgetSeconds); + + var final = await WaitForIssuanceAsync(orderNumber, budgetSeconds, delaySeconds, cts.Token); + + if (final != null + && StatusMapper.ToRequestDisposition(final.Status) != (int)EndEntityStatus.EXTERNALVALIDATION) + { + _logger.LogInformation( + "Synchronous pickup complete. OrderNumber={OrderNumber}, Status={Status}", + orderNumber, final.Status); + // Neutral wording: this message is surfaced verbatim in the operator-visible + // StatusMessage by BuildEnrollmentResult's FAILED branch, so it must not + // claim "issued" for an order that was rejected during the poll. + return BuildEnrollmentResultFromCertificate(final, orderNumber, + $"Order reached status '{final.Status}' during synchronous pickup.", ep.AutoApprove); + } + + // Soft fallback: still pending after the budget. Keep the pending result — + // the next sync cycle completes the order — but say what happened so the + // operator understands why the cert didn't come back in-call. + _logger.LogInformation( + "Synchronous pickup did not complete within {Budget}s for order {OrderNumber}. " + + "Returning pending result; sync will pick up the certificate later.", + budgetSeconds, orderNumber); + // No duration claim: the poll may have aborted on its first API failure + // rather than waiting the full budget, and for an issued-but-PEM-missing + // order the base message already reports successful issuance. + pendingResult.StatusMessage = + $"{pendingResult.StatusMessage} The certificate was not retrievable within the " + + "synchronous-pickup budget; it will be imported by a later synchronization."; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Synchronous pickup failed for order {OrderNumber}. Returning pending result; " + + "sync will pick up the certificate later.", orderNumber); + } + + return pendingResult; + } + + /// + /// Resolves the validation level (DV/OV/EV) that gates the synchronous pickup poll. + /// The account's product catalog (via GetProductDetails, cached — see + /// ) is authoritative because numeric + /// product codes differ between CERTInext environments; the Command template's + /// product name (e.g. "OV SSL Wildcard") is the fallback when the catalog is + /// unavailable or doesn't list the code. Never throws. + /// + private async Task ResolveProductValidationTypeAsync( + string productCode, string templateProductName, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(productCode)) + { + // The expiry timestamp — not map nullness — decides whether to hit the API, + // so the failure back-off below also protects the never-succeeded case + // (map still null): without this, a down catalog endpoint would add one + // failing API round-trip to every enrollment. + var map = DateTime.UtcNow >= _productTypeCacheExpiresUtc + ? await RefreshProductTypeCacheAsync(ct) + : _productTypeByCode; + + if (map != null && map.TryGetValue(productCode.Trim(), out var fromCatalog) + && fromCatalog != ProductValidationType.Unknown) + { + return fromCatalog; + } + } + + return ProductClassifier.ClassifyName(templateProductName); + } + + /// + /// Refreshes the cached productCode → validation-type map from the account's + /// catalog. Serialized so concurrent enrollments trigger at most one + /// GetProductDetails call; on failure the retry is backed off (and any + /// previous stale map is kept), so a down catalog endpoint costs at most one + /// failing API call per back-off window rather than one per enrollment. + /// Bounded by — the caller's pickup budget — so a hanging + /// catalog endpoint cannot hold a Command worker thread past the documented ceiling. + /// + private async Task> RefreshProductTypeCacheAsync(CancellationToken ct) + { + await _productTypeCacheLock.WaitAsync(ct); + try + { + // Another caller may have refreshed (or failed and armed the back-off) + // while this one waited on the lock. The timestamp alone gates the API + // call — a null map inside the back-off window must NOT retry. + if (DateTime.UtcNow < _productTypeCacheExpiresUtc) + return _productTypeByCode; + + try + { + var products = await _client.GetProductDetailsAsync(ct); + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in products ?? new List()) + { + if (string.IsNullOrWhiteSpace(p?.ProductCode)) + continue; + var classified = ProductClassifier.ClassifyName(p.ProductName); + map[p.ProductCode.Trim()] = classified; + _logger.LogDebug( + "Catalog product classified for pickup gating. ProductCode={Code}, " + + "ProductTypeId={TypeId}, ProductName={Name}, ValidationType={Type}", + p.ProductCode, p.ProductTypeId, p.ProductName, classified); + } + + _productTypeByCode = map; + _productTypeCacheExpiresUtc = DateTime.UtcNow.AddMinutes(Constants.Pickup.ProductTypeCacheMinutes); + _logger.LogInformation( + "Product-type catalog cached for synchronous-pickup gating. Products={Count}, " + + "CacheMinutes={Minutes}", map.Count, Constants.Pickup.ProductTypeCacheMinutes); + return map; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // The caller's pickup budget expired mid-fetch — not a catalog outage. + // Propagate so the caller's soft-fallback handles it, without arming the + // failure back-off or logging a misleading catalog warning. + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not refresh the product catalog for synchronous-pickup gating; falling back to {Fallback}.", + _productTypeByCode != null ? "the stale cached catalog" : "template-name classification"); + _productTypeCacheExpiresUtc = DateTime.UtcNow.AddMinutes(5); + return _productTypeByCode; + } + } + finally + { + _productTypeCacheLock.Release(); + } + } + // --------------------------------------------------------------------------- // DCV helpers // --------------------------------------------------------------------------- @@ -1861,28 +2172,22 @@ private async Task PerformDcvIfNeededAsync( /// /// Polls GetCertificateAsync until either (a) the certificate reaches a terminal - /// state (issued or rejected) or (b) the configured DcvWaitForIssuanceSeconds - /// budget expires. Returns the final response on success, or null if all polls + /// state (issued or rejected) or (b) the budget + /// expires. Returns the final response on success, or null if all polls /// failed (so callers fall back to the pending result they already have). /// - /// CERTInext's issuance pipeline is asynchronous on their side: after the plugin's - /// VerifyDcv triggers and the per-domain DCV is confirmed, the cert generation step - /// finishes a few seconds later. Without this poll the plugin would catch the cert - /// in pending state and return it that way, forcing the gateway to wait for the next - /// sync cycle. + /// CERTInext's issuance pipeline is asynchronous on their side, so a just-submitted + /// (or just-DCV-verified) order's certificate typically becomes downloadable a short + /// time after the triggering call returns. Without this poll the plugin would catch + /// the cert in pending state and return it that way, forcing the gateway to wait for + /// the next sync cycle. Used by both the post-DCV wait (budget = + /// DcvWaitForIssuanceSeconds, 3 s interval) and the general synchronous pickup + /// in (budget = PickupRetries × + /// PickupDelaySeconds, PickupDelaySeconds interval). /// - private async Task WaitForIssuanceAfterDcvAsync( - string orderNumber, CancellationToken ct) + private async Task WaitForIssuanceAsync( + string orderNumber, int waitBudgetSeconds, int pollIntervalSeconds, CancellationToken ct) { - int waitBudgetSeconds = _config.GetEffectiveDcvWaitForIssuanceSeconds(); - - // Fixed 3-second poll interval. CERTInext's post-DCV issuance step typically - // completes within 5–15s; polling more aggressively would just add API load, - // and polling more slowly would push the typical-case latency closer to the - // budget ceiling. Decoupled from DcvPropagationDelaySeconds (which is for DNS - // propagation, a different concern) so admins tuning DNS settings don't - // accidentally make post-DCV polling chunky. - int pollIntervalSeconds = 3; DateTime deadline = DateTime.UtcNow.AddSeconds(Math.Max(0, waitBudgetSeconds)); LegacyGetCertificateResponse last = null; @@ -1893,11 +2198,12 @@ private async Task WaitForIssuanceAfterDcvAsync( if (waitBudgetSeconds <= 0) { _logger.LogDebug( - "Post-DCV issuance wait disabled (DcvWaitForIssuanceSeconds<=0). " + + "Issuance wait disabled (budget<=0). " + "Order {OrderNumber} will be picked up on the next sync cycle.", orderNumber); return null; } + pollIntervalSeconds = Math.Max(1, pollIntervalSeconds); int attempt = 0; while (true) @@ -1915,7 +2221,7 @@ private async Task WaitForIssuanceAfterDcvAsync( // can use as a fallback). Without this distinction a repeated first-call // failure would look identical to a working-but-always-pending enroll. _logger.LogWarning(ex, - "Post-DCV GetCertificate failed for order {OrderNumber} (attempt {Attempt}). " + + "GetCertificate failed during issuance wait for order {OrderNumber} (attempt {Attempt}). " + "Returning {Outcome}; sync will pick up the cert later.", orderNumber, attempt, last == null ? "pending fallback (no prior result)" : "prior pending result"); return last; @@ -1929,10 +2235,14 @@ private async Task WaitForIssuanceAfterDcvAsync( return last; } - if (waitBudgetSeconds <= 0 || DateTime.UtcNow >= deadline) + // Stop when the NEXT poll would land at or past the deadline. This makes a + // budget of retries × delay yield exactly `retries` polls (t = 0, delay, + // 2×delay, …), matching the documented PickupRetries semantics — checking + // the deadline alone after the sleep would sneak in an extra boundary poll. + if (DateTime.UtcNow.AddSeconds(pollIntervalSeconds) > deadline) { _logger.LogInformation( - "Post-DCV issuance not complete within {Budget}s for order {OrderNumber}. " + + "Issuance not complete within {Budget}s for order {OrderNumber}. " + "Returning pending result; sync will pick up the cert later.", waitBudgetSeconds, orderNumber); return last; @@ -2024,6 +2334,26 @@ private async Task WaitForDcvVerificationAsync(string orderNumber, IReadOnlyList } } + /// + /// Maps a live (returned by an issuance + /// wait) through . Shared by the post-DCV wait + /// and the synchronous pickup so the field mapping cannot drift between the two + /// paths; falls back to when the API returned an + /// empty Id so the result always carries a usable CARequestID. + /// + private EnrollmentResult BuildEnrollmentResultFromCertificate( + LegacyGetCertificateResponse cert, string orderNumber, string message, bool autoApprove) + { + return BuildEnrollmentResult(new EnrollCertificateResponse + { + Id = string.IsNullOrWhiteSpace(cert.Id) ? orderNumber : cert.Id, + Status = cert.Status, + Certificate = cert.Certificate, + SerialNumber = cert.SerialNumber, + Message = message + }, autoApprove); + } + /// /// Converts a CERTInext API enrollment/renewal response into the /// expected by the AnyCA gateway. diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index 33fa362..14334c1 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -272,6 +272,34 @@ public static Dictionary GetCAConnectorAnnotations() DefaultValue = true, Type = "Boolean" }, + [Constants.Config.PickupRetries] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate " + + "after submitting an order for a DV product, so fast-issuing orders return the " + + "certificate synchronously in the same enrollment call. " + + "PickupRetries × PickupDelaySeconds ≈ the maximum time an enrollment call can " + + "occupy a Keyfactor Command worker thread (a small internal grace margin applies) " + + "— keep the product under ~90 seconds. " + + "OV/EV products never poll: CERTInext issues them asynchronously by design " + + "(organization verification takes minutes and may be human-gated), so those " + + "orders return pending and are completed by the next synchronization. " + + "Set to 0 to disable the poll entirely. " + + $"Can also be set via the {Constants.Config.PickupRetriesEnvVar} environment " + + "variable; the env var takes precedence when both are set. Default: 5.", + Hidden = false, + DefaultValue = Constants.Pickup.DefaultRetries, + Type = "Number" + }, + [Constants.Config.PickupDelaySeconds] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Seconds between synchronous pickup polls inside Enroll() (see " + + "PickupRetries). " + + $"Can also be set via the {Constants.Config.PickupDelaySecondsEnvVar} environment " + + "variable; the env var takes precedence when both are set. Default: 10.", + Hidden = false, + DefaultValue = Constants.Pickup.DefaultDelaySeconds, + Type = "Number" + }, [Constants.Config.DcvEnabled] = new PropertyConfigInfo { Comments = "OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) " + @@ -679,6 +707,26 @@ public class CERTInextConfig [JsonPropertyName("IgnoreExpired")] public bool IgnoreExpired { get; set; } = false; + /// + /// Number of times Enroll() polls GetCertificate after submitting an + /// order for a DV product, waiting for CERTInext to issue so the certificate can be + /// returned synchronously (mirrors the legacy Sectigo connector's pickup loop). + /// PickupRetries × PickupDelaySeconds is the maximum time an enrollment call + /// can occupy a Command worker thread. Set to 0 to disable the poll entirely (the + /// certificate is then picked up on the next synchronization). Overridden by + /// CERTINEXT_PICKUP_RETRIES when set. Default: 5. + /// + [JsonPropertyName("PickupRetries")] + public int PickupRetries { get; set; } = Constants.Pickup.DefaultRetries; + + /// + /// Seconds between synchronous pickup polls inside Enroll(). See + /// . Overridden by CERTINEXT_PICKUP_DELAY_SECONDS + /// when set. Default: 10. + /// + [JsonPropertyName("PickupDelaySeconds")] + public int PickupDelaySeconds { get; set; } = Constants.Pickup.DefaultDelaySeconds; + [JsonPropertyName("PageSize")] public int PageSize { get; set; } = Constants.Api.DefaultPageSize; @@ -770,40 +818,54 @@ public class CERTInextConfig public bool DcvFollowCnameDelegation { get; set; } = false; /// - /// Returns the effective DCV timeout, preferring the environment variable over the - /// config field so operators can adjust the ceiling without a connector reconfiguration. + /// Shared resolution for the numeric "GetEffective*" knobs: the environment variable + /// wins when set and parseable, then the configured field, then the compiled default. + /// distinguishes knobs where 0 is a meaningful + /// "disabled" value from knobs that require a positive value. /// - public int GetEffectiveDcvTimeoutMinutes() + private static int GetEffectiveInt(string envVarName, int configured, int fallback, bool zeroAllowed) { - var env = System.Environment.GetEnvironmentVariable(Constants.Config.DcvTimeoutMinutesEnvVar); - if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && envVal > 0) + bool Valid(int v) => zeroAllowed ? v >= 0 : v > 0; + var env = System.Environment.GetEnvironmentVariable(envVarName); + if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && Valid(envVal)) return envVal; - return DcvTimeoutMinutes > 0 ? DcvTimeoutMinutes : 10; + return Valid(configured) ? configured : fallback; } + /// + /// Returns the effective DCV timeout, preferring the environment variable over the + /// config field so operators can adjust the ceiling without a connector reconfiguration. + /// + public int GetEffectiveDcvTimeoutMinutes() => + GetEffectiveInt(Constants.Config.DcvTimeoutMinutesEnvVar, DcvTimeoutMinutes, 10, zeroAllowed: false); + /// /// Returns the effective wait for the DCV challenge to appear in TrackOrder, preferring /// the env var so operators can tune without re-saving the connector. A value of 0 /// (either field or env var) disables the wait entirely. /// - public int GetEffectiveDcvWaitForChallengeSeconds() - { - var env = System.Environment.GetEnvironmentVariable(Constants.Config.DcvWaitForChallengeSecondsEnvVar); - if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && envVal >= 0) - return envVal; - return DcvWaitForChallengeSeconds >= 0 ? DcvWaitForChallengeSeconds : 60; - } + public int GetEffectiveDcvWaitForChallengeSeconds() => + GetEffectiveInt(Constants.Config.DcvWaitForChallengeSecondsEnvVar, DcvWaitForChallengeSeconds, 60, zeroAllowed: true); /// /// Returns the effective post-DCV wait for cert issuance, preferring the env var. /// A value of 0 disables the wait. /// - public int GetEffectiveDcvWaitForIssuanceSeconds() - { - var env = System.Environment.GetEnvironmentVariable(Constants.Config.DcvWaitForIssuanceSecondsEnvVar); - if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && envVal >= 0) - return envVal; - return DcvWaitForIssuanceSeconds >= 0 ? DcvWaitForIssuanceSeconds : 60; - } + public int GetEffectiveDcvWaitForIssuanceSeconds() => + GetEffectiveInt(Constants.Config.DcvWaitForIssuanceSecondsEnvVar, DcvWaitForIssuanceSeconds, 60, zeroAllowed: true); + + /// + /// Returns the effective synchronous-pickup retry count, preferring the env var so + /// operators can tune without re-saving the connector. 0 disables the pickup poll. + /// + public int GetEffectivePickupRetries() => + GetEffectiveInt(Constants.Config.PickupRetriesEnvVar, PickupRetries, Constants.Pickup.DefaultRetries, zeroAllowed: true); + + /// + /// Returns the effective delay between synchronous-pickup polls, preferring the env + /// var. 0 disables the pickup poll. + /// + public int GetEffectivePickupDelaySeconds() => + GetEffectiveInt(Constants.Config.PickupDelaySecondsEnvVar, PickupDelaySeconds, Constants.Pickup.DefaultDelaySeconds, zeroAllowed: true); } } diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index 061aff8..81b9636 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -74,10 +74,24 @@ public static class Config // the TXT record / resolving the DNS provider plugin (issue 0006). Off by default. public const string DcvFollowCnameDelegation = "DcvFollowCnameDelegation"; + // Synchronous pickup poll inside Enroll() — DCV-independent, both build flavors. + // After submitting an order for a DV product, Enroll() polls GetCertificate up to + // PickupRetries times, PickupDelaySeconds apart, so fast-issuing orders return the + // issued certificate in the same enrollment call (matching the legacy Sectigo + // connector's PickUpEnrolledCertificate behavior). retries × delay = the maximum + // time an enrollment call can occupy a Command worker thread. OV/EV products skip + // the poll entirely — CERTInext issues them asynchronously by design (org + // verification, minutes to hours; support ticket #162763) and no in-call poll can + // absorb that within Command's enrollment timeout. + public const string PickupRetries = "PickupRetries"; + public const string PickupDelaySeconds = "PickupDelaySeconds"; + // Environment variable that overrides DcvTimeoutMinutes when set. public const string DcvTimeoutMinutesEnvVar = "CERTINEXT_DCV_TIMEOUT_MINUTES"; public const string DcvWaitForChallengeSecondsEnvVar = "CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS"; public const string DcvWaitForIssuanceSecondsEnvVar = "CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS"; + public const string PickupRetriesEnvVar = "CERTINEXT_PICKUP_RETRIES"; + public const string PickupDelaySecondsEnvVar = "CERTINEXT_PICKUP_DELAY_SECONDS"; // Auth mode values public const string AuthModeAccessKey = "AccessKey"; // default; authKey = SHA256(accessKey+ts+txn) @@ -272,6 +286,19 @@ public static class RevocationReasonId public const int Default = KeyCompromise; } + public static class Pickup + { + // Defaults mirror the legacy Sectigo connector (5 retries × 10 s ≈ 50 s ceiling), + // which is the behavior customers migrating from Sectigo expect from Enroll(). + public const int DefaultRetries = 5; + public const int DefaultDelaySeconds = 10; + + // How long a fetched product catalog (productCode → DV/OV/EV classification) is + // reused before being refreshed via GetProductDetails. The catalog is effectively + // static for an account, so this only bounds staleness after a CA-side change. + public const int ProductTypeCacheMinutes = 60; + } + public static class Dcv { // CERTInext dcvMethod values (dcvDetails.dcvMethod in GetDcv / VerifyDcv) diff --git a/CERTInext/Models/ProductValidationType.cs b/CERTInext/Models/ProductValidationType.cs new file mode 100644 index 0000000..705f049 --- /dev/null +++ b/CERTInext/Models/ProductValidationType.cs @@ -0,0 +1,65 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System.Text.RegularExpressions; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Models +{ + /// + /// Validation level of a CERTInext SSL product. Drives whether Enroll() performs a + /// synchronous pickup poll: DV products issue in seconds once accepted, while OV/EV products + /// go through a mandatory organization-verification step and issue asynchronously — minutes + /// to hours, sometimes human-gated (CERTInext support ticket #162763: "there is no setting + /// on our end that makes this certificate type return instantly in a single call"). + /// + internal enum ProductValidationType + { + /// Could not be determined (catalog unavailable and the product name carries no DV/OV/EV token). + Unknown = 0, + Dv = 1, + Ov = 2, + Ev = 3 + } + + /// + /// Classifies CERTInext products into DV/OV/EV by name. Name-based classification is + /// deliberate: the numeric product codes differ between CERTInext environments (e.g. + /// production 842 is OV while sandbox 842 is DV) and the raw productTypeID value + /// space is undocumented, but product names consistently carry a "DV"/"OV"/"EV" token in + /// both the account catalog ("OV SSL Certificate 1 Year") and the plugin's template + /// product list ("OV SSL Wildcard"). + /// + internal static class ProductClassifier + { + private static readonly Regex DvToken = new Regex(@"\bDV\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex OvToken = new Regex(@"\bOV\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex EvToken = new Regex(@"\bEV\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// + /// Returns the validation type encoded in a product name, or + /// when the name carries no recognizable + /// token (or carries more than one, which would make any single answer a guess). + /// + internal static ProductValidationType ClassifyName(string productName) + { + if (string.IsNullOrWhiteSpace(productName)) + return ProductValidationType.Unknown; + + bool dv = DvToken.IsMatch(productName); + bool ov = OvToken.IsMatch(productName); + bool ev = EvToken.IsMatch(productName); + + int matches = (dv ? 1 : 0) + (ov ? 1 : 0) + (ev ? 1 : 0); + if (matches != 1) + return ProductValidationType.Unknown; + + return dv ? ProductValidationType.Dv + : ov ? ProductValidationType.Ov + : ProductValidationType.Ev; + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index bce090e..4f15cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# 1.2.0 + +## Features +- feat(enroll): `Enroll()` now runs a synchronous pickup poll on every enrollment path (new, reissue, and renewal) on both build flavors — DV orders that issue within the poll budget return the issued certificate in the same call instead of waiting for the next synchronization, restoring the behavior expiration-renewal workflows relied on with the legacy Sectigo connector. Configurable via the new `PickupRetries` (default 5) and `PickupDelaySeconds` (default 10) connector settings (`retries × delay` = maximum time an enrollment call occupies a Command worker thread); set `PickupRetries` to `0` to disable. +- feat(enroll): OV/EV orders skip the pickup poll and return pending immediately with a status message explaining that CERTInext issues these products asynchronously by design (organization verification; confirmed by CERTInext support) — the certificate is imported by the next synchronization. The product's validation level is resolved from the account's product catalog (`GetProductDetails`, cached for 60 minutes), with the template product name as fallback. + +## Bug Fixes +- fix(build): The `-p:DcvSupport=false` (no-DCV, IAnyCAPlugin 3.2.0) flavor of `CERTInext.IntegrationTests` failed to compile — `CnameResolverLiveDnsTests.cs` references a helper defined in the DCV-only `DcvLifecycleTests.cs` and is itself a DCV feature test, so it is now excluded from the no-DCV build alongside the other DCV test files. + # 1.1.0 ## Features diff --git a/README.md b/README.md index 4732d47..c8c87f5 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,8 @@ CERTInext operates three separate environments. Use the sandbox environment for * **IgnoreExpired** - If true, expired certificates will be skipped during synchronization. Default: false. * **PageSize** - Number of orders to fetch per page during synchronization. Default: 100, max: 500. * **Enabled** - Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true. + * **PickupRetries** - OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product, so fast-issuing orders return the certificate synchronously in the same enrollment call. PickupRetries × PickupDelaySeconds ≈ the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 to disable. Can also be set via the CERTINEXT_PICKUP_RETRIES environment variable; the env var takes precedence. Default: 5. + * **PickupDelaySeconds** - OPTIONAL: Seconds between synchronous pickup polls inside Enroll() (see PickupRetries). Can also be set via the CERTINEXT_PICKUP_DELAY_SECONDS environment variable; the env var takes precedence. Default: 10. * **DcvEnabled** - OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) during enrollment for orders that require it, using the configured DNS provider plugin. Requires a DNS provider plugin (e.g. azure-azuredns-dnsplugin) to be deployed on the gateway. Default: false. * **DcvTxtRecordTemplate** - OPTIONAL: Format string for the DNS TXT record hostname used during DCV. {0} is replaced with the domain name being validated. Default: _emsign-validation.{0} * **DcvPropagationDelaySeconds** - OPTIONAL: Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: 30. @@ -260,6 +262,8 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | +| `PickupRetries` | Optional | Number of times `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product, so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). `PickupRetries × PickupDelaySeconds` is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` to disable the poll. Can also be set via the `CERTINEXT_PICKUP_RETRIES` environment variable; the environment variable takes precedence. Default: `5`. | N/A | `5` | +| `PickupDelaySeconds` | Optional | Seconds between synchronous pickup polls inside `Enroll()` (see `PickupRetries`). Can also be set via the `CERTINEXT_PICKUP_DELAY_SECONDS` environment variable; the environment variable takes precedence. Default: `10`. | N/A | `10` | | `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | | `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | | `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: `30`. | N/A | `30` | @@ -479,8 +483,14 @@ sequenceDiagram alt Certificate issued immediately Plugin-->>CMD: Certificate ready — PEM returned - else Certificate pending approval - Plugin-->>CMD: Pending — Command will pick it up
during the next synchronization + else Pending and product is DV + loop Synchronous pickup
(up to PickupRetries × PickupDelaySeconds) + Plugin->>API: Fetch certificate + API-->>Plugin: Issued, or still pending + end + Plugin-->>CMD: Certificate ready if issued within the budget —
otherwise pending, completed by the next synchronization + else Pending and product is OV or EV + Plugin-->>CMD: Pending — CERTInext issues OV/EV asynchronously by design
(organization verification); completed by the next synchronization else Order rejected by CERTInext Plugin-->>CMD: Enrollment failed — see gateway logs end @@ -488,6 +498,8 @@ sequenceDiagram Plugin->>Plugin: Record enrollment outcome in audit log
(order number, serial number, status) ``` +The synchronous pickup step mirrors the legacy Sectigo connector's behavior: DV orders that CERTInext issues within the poll budget are returned in the same enrollment call, so automated workflows (e.g. expiration renewal) receive the certificate without waiting for a sync cycle. The product's validation level is resolved from the account's product catalog (cached), falling back to the template product name. OV/EV orders are never polled — their organization-verification step takes minutes and may be human-gated, so the plugin returns pending immediately with a message explaining the deferral. + ### Renewal When Command initiates a renewal, the plugin checks whether the existing certificate is within the configured renewal window. If it is, the prior order record is used as context for the new request. If it is outside the window (or the prior certificate cannot be located), the plugin falls back to issuing a new certificate. diff --git a/docsource/architecture.md b/docsource/architecture.md index f051475..5fa2fc2 100644 --- a/docsource/architecture.md +++ b/docsource/architecture.md @@ -139,8 +139,14 @@ sequenceDiagram alt Certificate issued immediately Plugin-->>CMD: Certificate ready — PEM returned - else Certificate pending approval - Plugin-->>CMD: Pending — Command will pick it up
during the next synchronization + else Pending and product is DV + loop Synchronous pickup
(up to PickupRetries × PickupDelaySeconds) + Plugin->>API: Fetch certificate + API-->>Plugin: Issued, or still pending + end + Plugin-->>CMD: Certificate ready if issued within the budget —
otherwise pending, completed by the next synchronization + else Pending and product is OV or EV + Plugin-->>CMD: Pending — CERTInext issues OV/EV asynchronously by design
(organization verification); completed by the next synchronization else Order rejected by CERTInext Plugin-->>CMD: Enrollment failed — see gateway logs end @@ -148,6 +154,8 @@ sequenceDiagram Plugin->>Plugin: Record enrollment outcome in audit log
(order number, serial number, status) ``` +The synchronous pickup step mirrors the legacy Sectigo connector's behavior: DV orders that CERTInext issues within the poll budget are returned in the same enrollment call, so automated workflows (e.g. expiration renewal) receive the certificate without waiting for a sync cycle. The product's validation level is resolved from the account's product catalog (cached), falling back to the template product name. OV/EV orders are never polled — their organization-verification step takes minutes and may be human-gated, so the plugin returns pending immediately with a message explaining the deferral. + ### Renewal When Command initiates a renewal, the plugin checks whether the existing certificate is within the configured renewal window. If it is, the prior order record is used as context for the new request. If it is outside the window (or the prior certificate cannot be located), the plugin falls back to issuing a new certificate. diff --git a/docsource/configuration.md b/docsource/configuration.md index 0f90459..04da715 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -113,6 +113,8 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | +| `PickupRetries` | Optional | Number of times `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product, so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). `PickupRetries × PickupDelaySeconds` is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` to disable the poll. Can also be set via the `CERTINEXT_PICKUP_RETRIES` environment variable; the environment variable takes precedence. Default: `5`. | N/A | `5` | +| `PickupDelaySeconds` | Optional | Seconds between synchronous pickup polls inside `Enroll()` (see `PickupRetries`). Can also be set via the `CERTINEXT_PICKUP_DELAY_SECONDS` environment variable; the environment variable takes precedence. Default: `10`. | N/A | `10` | | `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | | `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | | `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: `30`. | N/A | `30` | @@ -248,6 +250,13 @@ CERTInext orders pass through several internal status stages before a certificat - **Pending approval** (status 2, 8, 15, 24) → enrollment returns a pending status to Command. If `AutoApprove` is enabled on the template, the plugin attempts automatic approval before returning. - **Rejected / cancelled** (status 4, 5, 13, 14) → enrollment fails with an error. +Before returning a pending result, `Enroll()` runs a **synchronous pickup poll** gated by the product's validation level: + +- **DV products** — the plugin polls `GetCertificate` up to `PickupRetries` times, `PickupDelaySeconds` apart (default 5 × 10 s ≈ 50 s). If CERTInext issues within that budget, the enrollment call returns the issued certificate directly — no waiting for the next sync. If the budget elapses, the pending result is returned unchanged and sync completes the order later. +- **OV/EV products** — the poll is skipped entirely. CERTInext issues OV/EV asynchronously by design: the mandatory organization-verification step takes minutes and may require human review, so no in-call wait can succeed within Command's enrollment timeout (confirmed by CERTInext support, ticket #162763). The pending result carries a status message explaining this; the certificate is imported automatically by the next synchronization. + +The validation level (DV/OV/EV) is resolved from the account's product catalog (`GetProductDetails`, cached for 60 minutes — never fetched per-enrollment), falling back to the DV/OV/EV token in the template's product name when the catalog is unavailable. Products whose level cannot be determined are polled optimistically. When `DcvEnabled` is `true`, pending **new/reissue** orders skip the pickup poll — the in-call DCV flow owns those waits — but renewals (which never run in-call DCV) and issued-orders-awaiting-PEM-download remain eligible. + The gateway polls the `TrackOrder` endpoint during sync to pick up certificates that were approved after the initial enrollment call. ### Synchronization diff --git a/docsource/overview.md b/docsource/overview.md index f11d3d3..ec042ad 100644 --- a/docsource/overview.md +++ b/docsource/overview.md @@ -54,14 +54,15 @@ Enrollment completes successfully but the cert is not yet issued — Command sho **Root cause** -This is the expected return shape on two paths: +This is the expected return shape on three paths: -1. The plugin was loaded on an older gateway host (pre-IAnyCAPlugin v3.3) that does not inject `IDomainValidatorFactory`. DCV cannot run, so any product that requires DNS validation completes only after CERTInext-side validation finishes. -2. The plugin's bounded `Enroll()` budget (`DcvWaitForChallengeSeconds` + `DcvWaitForIssuanceSeconds`, defaults 60s each) elapsed before CERTInext finished asynchronous issuance. +1. **The product is OV or EV.** CERTInext issues OV/EV certificates asynchronously by design — the mandatory organization-verification step takes minutes and may require human review, and there is no CA-side setting that makes these products return in a single call (confirmed by CERTInext support). The plugin deliberately skips its synchronous pickup poll for OV/EV and returns pending immediately with a status message explaining this. +2. **The product is DV but issuance outran the pickup budget.** `Enroll()` polls for the issued certificate up to `PickupRetries × PickupDelaySeconds` (default ≈ 50 s) before returning pending. +3. **DCV builds only:** the DCV-specific `Enroll()` budget (`DcvWaitForChallengeSeconds` + `DcvWaitForIssuanceSeconds`, defaults 60s each) elapsed before CERTInext finished asynchronous issuance, or the plugin was loaded on an older gateway host (pre-IAnyCAPlugin v3.3) that does not inject `IDomainValidatorFactory`, so DCV could not run in-call. **Mitigation** -The next gateway sync cycle will pick the cert up and transition it to `GENERATED`. The plugin's sync-driven DCV retry is single-shot per record, so even with hundreds of pending orders the sync completes in seconds, not minutes — see [configuration.md](configuration.md) for the `DcvWaitForChallengeSeconds`/`DcvWaitForIssuanceSeconds` knobs if you want to tune the Enroll-time budget. +The next gateway sync cycle will pick the cert up and transition it to `GENERATED`. For OV/EV this is the designed flow — no tuning changes it. For DV, raise `PickupRetries`/`PickupDelaySeconds` if your orders reliably issue just past the default budget (keep the product under ~90 s — it holds a Command worker thread). The plugin's sync-driven DCV retry is single-shot per record, so even with hundreds of pending orders the sync completes in seconds, not minutes — see [configuration.md](configuration.md) for the `PickupRetries`/`PickupDelaySeconds` and `DcvWaitForChallengeSeconds`/`DcvWaitForIssuanceSeconds` knobs. ### `EMS-956 "Invalid Request for this API"` from `GetDcv` diff --git a/integration-manifest.json b/integration-manifest.json index 2276f2d..29ff1c5 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -141,6 +141,14 @@ "name": "Enabled", "description": "Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true." }, + { + "name": "PickupRetries", + "description": "OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product, so fast-issuing orders return the certificate synchronously in the same enrollment call. PickupRetries \u00d7 PickupDelaySeconds \u2248 the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) \u2014 keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 to disable. Can also be set via the CERTINEXT_PICKUP_RETRIES environment variable; the env var takes precedence. Default: 5." + }, + { + "name": "PickupDelaySeconds", + "description": "OPTIONAL: Seconds between synchronous pickup polls inside Enroll() (see PickupRetries). Can also be set via the CERTINEXT_PICKUP_DELAY_SECONDS environment variable; the env var takes precedence. Default: 10." + }, { "name": "DcvEnabled", "description": "OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) during enrollment for orders that require it, using the configured DNS provider plugin. Requires a DNS provider plugin (e.g. azure-azuredns-dnsplugin) to be deployed on the gateway. Default: false." From 396d7267bc519b347f4132558e3765af0ccad27e Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:20:50 -0700 Subject: [PATCH 03/17] fix(enroll): harden the synchronous pickup per full review cycle (code + compliance + security) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review findings (round 3, adversarially verified): - WaitForIssuanceAsync retries through transient GetCertificate failures until the budget expires instead of aborting on the first blip (Sectigo parity — retries=N no longer degrades to a single attempt), and the next-poll deadline check uses >= so a poll landing exactly at the deadline no longer sneaks in a retries+1th attempt. - Negative PickupRetries/PickupDelaySeconds now disable the pickup (a common operator convention) instead of silently re-enabling the compiled defaults; PickupDelaySeconds=0 disabling the poll is now documented on both knobs. - RenewCertificateAsync reports the product code it actually ordered with on the response (ProfileId); the pickup gate classifies that reported code instead of re-deriving the client's selection logic at a distance. - Dispose no longer disposes the product-catalog semaphore (not safe concurrently with WaitAsync/Release during connector recycle). - architecture.md enrollment diagram now states the pickup loop applies only when the in-call DCV flow does not own the wait. Compliance audit (SOX/SOC2 — PASS, evidencing gaps closed): - Pickup-disabled and DCV-owns-wait decisions log at Information with the effective values, so env-var-driven behavior is reconstructable from production logs; set-but-invalid CERTINEXT_* env vars log a Warning (deduped per distinct value). - Pickup budget hard-capped at Constants.Pickup.MaxBudgetSeconds (300 s) with a Warning when a configured product is clamped; catalog failure back-off window hoisted to Constants.Pickup.FailureBackoffMinutes and stated in the Warning. - Shared issuance-wait log lines carry Phase=PostDcv|Pickup for single-line forensic reconstruction. Security review: no findings. Tests: regression tests for transient-failure retry, negative-value disable, and client-reported renewal product code; pickup tests use generous budgets so real-clock polling cannot flake under CI load; DCV suite and the pending-status mapping test explicitly disable pickup (it has its own suite), keeping suite runtime at baseline. --- CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 7 +- .../CERTInextCAPluginPickupTests.cs | 64 +++++++++- CERTInext.Tests/CERTInextCAPluginTests.cs | 5 +- CERTInext/CERTInextCAPlugin.cs | 120 +++++++++++------- CERTInext/CERTInextCAPluginConfig.cs | 62 +++++++-- CERTInext/Client/CERTInextClient.cs | 4 + CERTInext/Constants.cs | 11 ++ CHANGELOG.md | 2 +- README.md | 12 +- docsource/architecture.md | 4 +- docsource/configuration.md | 4 +- integration-manifest.json | 4 +- 12 files changed, 225 insertions(+), 74 deletions(-) diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index 4d639b8..1f81fc0 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -46,7 +46,12 @@ private static CERTInextConfig DcvConfig( // behaviour and run fast. Tests that exercise the new wait paths can opt // in with a positive value (see WaitsForChallenge_ToAppear / WaitsForIssuance). DcvWaitForChallengeSeconds = dcvWaitForChallengeSeconds, - DcvWaitForIssuanceSeconds = dcvWaitForIssuanceSeconds + DcvWaitForIssuanceSeconds = dcvWaitForIssuanceSeconds, + // This suite tests DCV behavior, not the synchronous pickup (which has its + // own suite, including the DCV interaction cases). Disable pickup so tests + // with DcvEnabled=false and pending orders don't spend the default 5×10 s + // poll budget retrying strict mocks. + PickupRetries = 0 }; private static Mock NewMock() => diff --git a/CERTInext.Tests/CERTInextCAPluginPickupTests.cs b/CERTInext.Tests/CERTInextCAPluginPickupTests.cs index 9935572..6766f22 100644 --- a/CERTInext.Tests/CERTInextCAPluginPickupTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginPickupTests.cs @@ -39,8 +39,15 @@ public class CERTInextCAPluginPickupTests private static Mock NewMock() => new Mock(MockBehavior.Strict); - /// Config with a fast pickup budget so tests don't sit in real delays. - private static CERTInextConfig PickupConfig(int retries = 3, int delaySeconds = 1) => + /// + /// Config with a 1-second poll interval so tests that complete the poll run fast. + /// The default retry count is deliberately generous: the poll loop runs against the + /// real clock, so a small budget makes tests that expect the poll to *complete* + /// flaky under CI load (a slow first poll can exhaust the budget before the second, + /// issuing, poll). Tests that specifically exercise budget exhaustion pass a small + /// explicit retry count instead. + /// + private static CERTInextConfig PickupConfig(int retries = 10, int delaySeconds = 1) => new CERTInextConfig { PickupRetries = retries, PickupDelaySeconds = delaySeconds }; private static List SslCatalog() => new List @@ -263,6 +270,45 @@ public async Task Pickup_SoftFallsBackToPending_WhenBudgetExhausted() "PickupRetries=2 must never yield more than two polls"); } + [Fact] + public async Task Pickup_SurvivesTransientFailure_AndReturnsIssuedOnRetry() + { + var mock = NewMock(); + SetupPendingEnroll(mock); + SetupCatalog(mock); + mock.SetupSequence(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("momentary CERTInext 500")) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.CertId2)); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "a transient API failure must consume one attempt, not the whole budget — " + + "the legacy Sectigo pickup loop retried through failures"); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task Pickup_Disabled_WhenRetriesNegative() + { + // "-1 to disable" is a common operator convention — it must not silently + // fall back to the enabled default of 5. + var mock = NewMock(); + SetupPendingEnroll(mock); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: -1)); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + mock.Verify(c => c.GetProductDetailsAsync(It.IsAny()), Times.Never); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + [Fact] public async Task Pickup_SoftFallsBackToPending_WhenGetCertificateThrows() { @@ -272,7 +318,8 @@ public async Task Pickup_SoftFallsBackToPending_WhenGetCertificateThrows() mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new Exception("CERTInext API 500")); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + // Small explicit budget: every poll throws, so this test runs to exhaustion. + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: 2)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); @@ -513,8 +560,9 @@ public async Task Pickup_FetchesPem_ForIssuedOrder_EvenWhenDcvEnabled() public async Task Pickup_RenewPath_ClassifiesTheProductCodeActuallyOrdered() { // CERTInextClient.RenewCertificateAsync places the renewal order with the - // connector's DefaultProductCode, not the template's code — the OV/EV gate - // must classify what was ordered, or it polls futilely / defers wrongly. + // connector's DefaultProductCode (not the template's code) and reports the + // ordered code back on the response's ProfileId — the OV/EV gate must classify + // that reported code, or it polls futilely / defers wrongly. var clientMock = NewMock(); var readerMock = new Mock(MockBehavior.Strict); @@ -523,15 +571,17 @@ public async Task Pickup_RenewPath_ClassifiesTheProductCodeActuallyOrdered() readerMock.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1)) .Returns(DateTime.UtcNow.AddDays(30)); + var renewPending = MockCertificateData.PendingEnrollResponse("renewed-03"); + renewPending.ProfileId = OvCode; // the code the client actually ordered with clientMock.Setup(c => c.RenewCertificateAsync( MockCertificateData.CertId1, It.IsAny(), It.IsAny())) - .ReturnsAsync(MockCertificateData.PendingEnrollResponse("renewed-03")); + .ReturnsAsync(renewPending); SetupCatalog(clientMock); var config = PickupConfig(); - config.DefaultProductCode = OvCode; // what the renewal order is actually placed with + config.DefaultProductCode = OvCode; // what RenewCertificateAsync orders with var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object, config); var productInfo = new EnrollmentProductInfo { diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs index 7524624..f9b9084 100644 --- a/CERTInext.Tests/CERTInextCAPluginTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginTests.cs @@ -332,7 +332,10 @@ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval() It.IsAny())) .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); - var plugin = BuildPlugin(mock.Object); + // Pickup disabled: this test verifies the pending-status mapping, not the + // synchronous pickup (which has its own suite) — with the default 5×10 s + // budget the poll would otherwise spend ~50 s retrying the strict mock. + var plugin = new CERTInextCAPlugin(mock.Object, new CERTInextConfig { PickupRetries = 0 }); var result = await plugin.Enroll( csr: MockCertificateData.FakeCsrPem, diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index e64675f..03e34cd 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -236,7 +236,12 @@ public void Dispose() { if (!_clientWasInjected) (_client as IDisposable)?.Dispose(); - _productTypeCacheLock.Dispose(); + // _productTypeCacheLock is deliberately NOT disposed: SemaphoreSlim.Dispose is + // not safe concurrently with WaitAsync/Release, and the gateway can recycle the + // plugin (config re-save, service stop) while an enrollment's pickup is mid + // catalog refresh — disposing here would fault that in-flight enrollment for no + // benefit (a SemaphoreSlim whose AvailableWaitHandle is never touched holds no + // unmanaged resources). } // --------------------------------------------------------------------------- @@ -1183,7 +1188,7 @@ private async Task EnrollNewAsync( // DcvPropagationDelaySeconds (a DNS concern) so admins tuning DNS // settings don't accidentally make this polling chunky. var postDcv = await WaitForIssuanceAsync( - orderNumber, _config.GetEffectiveDcvWaitForIssuanceSeconds(), 3, dcvCts.Token); + orderNumber, _config.GetEffectiveDcvWaitForIssuanceSeconds(), 3, "PostDcv", dcvCts.Token); if (postDcv != null) { return BuildEnrollmentResultFromCertificate(postDcv, orderNumber, @@ -1449,14 +1454,15 @@ private async Task RenewOrReissueAsync( // original Sectigo-parity scenario). In-call DCV never runs on this path, so // the pickup is always eligible (on DCV-enabled gateways a renewal that does // need fresh domain validation simply exhausts the bounded budget and falls - // back to pending). The renewal order is actually placed with the connector's - // DefaultProductCode (see CERTInextClient.RenewCertificateAsync), which can - // differ from the template's code — classify the code that reached the API. - // When DefaultProductCode is blank the order went out with an empty code and - // the template's code is only a best-effort guess for the gate. - string renewedProductCode = string.IsNullOrWhiteSpace(_config.DefaultProductCode) - ? ep.ProductCode - : _config.DefaultProductCode; + // back to pending). Classify the product code the renewal order was actually + // placed with — the client reports it on the response (renewResp.ProfileId), + // which can differ from the template's code because RenewCertificateAsync + // orders with the connector's DefaultProductCode. When the response omits it + // (order went out with an empty code), the template's code is only a + // best-effort guess for the gate. + string renewedProductCode = !string.IsNullOrWhiteSpace(renewResp.ProfileId) + ? renewResp.ProfileId + : ep.ProductCode; if (!string.Equals(renewedProductCode, ep.ProductCode, StringComparison.Ordinal)) { _logger.LogWarning( @@ -1534,7 +1540,9 @@ private async Task TryPickupIssuedCertificateAsync( // order is past validation entirely, so the fetch below is useful regardless of DCV. if (dcvOwnsIssuanceWait && pendingApproval) { - _logger.LogDebug( + // SOC2 CC7.2: Information so the enrollment timeline shows which mechanism + // owned the in-call wait (pairs with the "Starting DCV for order" line). + _logger.LogInformation( "Skipping synchronous pickup for order {OrderNumber} — the in-call DCV flow owns this order's issuance wait.", orderNumber); return pendingResult; @@ -1544,20 +1552,32 @@ private async Task TryPickupIssuedCertificateAsync( int delaySeconds = _config.GetEffectivePickupDelaySeconds(); if (retries <= 0 || delaySeconds <= 0) { - _logger.LogDebug( - "Synchronous pickup disabled (PickupRetries={Retries}, PickupDelaySeconds={Delay}). " + - "Order {OrderNumber} will be picked up on the next sync cycle.", + // SOC2 CC7.2 / SOX change management: the effective values may come from env + // vars rather than the connector record, so this Information line is the only + // production-log evidence distinguishing "pickup disabled by operator" from + // "pickup never attempted". + _logger.LogInformation( + "Synchronous pickup disabled by configuration (effective PickupRetries={Retries}, " + + "PickupDelaySeconds={Delay}). Order {OrderNumber} will be picked up on the next sync cycle.", retries, delaySeconds, orderNumber); return pendingResult; } // Compute the budget in long first: both knobs accept arbitrary non-negative ints // from env vars, and an int overflow here would go negative and make the CTS - // constructor throw (silently disabling pickup via the catch below). Clamp to a - // ceiling far above any sane configuration — the docs tell operators to stay - // under ~90 s. - const int maxBudgetSeconds = 3600; - int budgetSeconds = (int)Math.Min((long)retries * delaySeconds, maxBudgetSeconds); + // constructor throw (silently disabling pickup via the catch below). Clamp to the + // hard ceiling — Command abandons enrollment calls long before it, so a larger + // budget would only orphan a worker thread (docs: keep retries × delay under ~90 s). + long configuredBudgetSeconds = (long)retries * delaySeconds; + int budgetSeconds = (int)Math.Min(configuredBudgetSeconds, Constants.Pickup.MaxBudgetSeconds); + if (configuredBudgetSeconds > Constants.Pickup.MaxBudgetSeconds) + { + // SOX CC7.3: the clamp is a policy decision — evidence it. + _logger.LogWarning( + "Configured pickup budget ({Configured}s = PickupRetries {Retries} × PickupDelaySeconds {Delay}) " + + "exceeds the hard ceiling; clamped to {Max}s for order {OrderNumber}.", + configuredBudgetSeconds, retries, delaySeconds, Constants.Pickup.MaxBudgetSeconds, orderNumber); + } try { @@ -1604,7 +1624,7 @@ private async Task TryPickupIssuedCertificateAsync( "ValidationType={ValidationType}, Retries={Retries}, DelaySeconds={Delay}, BudgetSeconds={Budget}", orderNumber, productCode, validationType, retries, delaySeconds, budgetSeconds); - var final = await WaitForIssuanceAsync(orderNumber, budgetSeconds, delaySeconds, cts.Token); + var final = await WaitForIssuanceAsync(orderNumber, budgetSeconds, delaySeconds, "Pickup", cts.Token); if (final != null && StatusMapper.ToRequestDisposition(final.Status) != (int)EndEntityStatus.EXTERNALVALIDATION) @@ -1727,9 +1747,11 @@ private async Task> RefreshProductType catch (Exception ex) { _logger.LogWarning(ex, - "Could not refresh the product catalog for synchronous-pickup gating; falling back to {Fallback}.", - _productTypeByCode != null ? "the stale cached catalog" : "template-name classification"); - _productTypeCacheExpiresUtc = DateTime.UtcNow.AddMinutes(5); + "Could not refresh the product catalog for synchronous-pickup gating; falling back to " + + "{Fallback}. Retry backed off for {BackoffMinutes} minutes.", + _productTypeByCode != null ? "the stale cached catalog" : "template-name classification", + Constants.Pickup.FailureBackoffMinutes); + _productTypeCacheExpiresUtc = DateTime.UtcNow.AddMinutes(Constants.Pickup.FailureBackoffMinutes); return _productTypeByCode; } } @@ -2186,7 +2208,7 @@ private async Task PerformDcvIfNeededAsync( /// PickupDelaySeconds, PickupDelaySeconds interval). ///
private async Task WaitForIssuanceAsync( - string orderNumber, int waitBudgetSeconds, int pollIntervalSeconds, CancellationToken ct) + string orderNumber, int waitBudgetSeconds, int pollIntervalSeconds, string phase, CancellationToken ct) { DateTime deadline = DateTime.UtcNow.AddSeconds(Math.Max(0, waitBudgetSeconds)); LegacyGetCertificateResponse last = null; @@ -2198,9 +2220,9 @@ private async Task WaitForIssuanceAsync( if (waitBudgetSeconds <= 0) { _logger.LogDebug( - "Issuance wait disabled (budget<=0). " + + "Issuance wait disabled (budget<=0). Phase={Phase}. " + "Order {OrderNumber} will be picked up on the next sync cycle.", - orderNumber); + phase, orderNumber); return null; } pollIntervalSeconds = Math.Max(1, pollIntervalSeconds); @@ -2210,41 +2232,51 @@ private async Task WaitForIssuanceAsync( { attempt++; ct.ThrowIfCancellationRequested(); + LegacyGetCertificateResponse current = null; try { - last = await _client.GetCertificateAsync(orderNumber, ct); + current = await _client.GetCertificateAsync(orderNumber, ct); + } + catch (OperationCanceledException) + { + // The budget's token fired mid-call — hand back whatever we have. + return last; } catch (Exception ex) { - // Distinguish first-call failure (no result to return, sync must pick up) - // from later-poll failure (we have a prior pending result that the caller - // can use as a fallback). Without this distinction a repeated first-call - // failure would look identical to a working-but-always-pending enroll. + // A transient API failure consumes this attempt, not the whole budget: + // keep polling until the deadline, mirroring the legacy Sectigo pickup + // loop. Aborting here would silently degrade a retries=N configuration + // to a single attempt on the first blip. _logger.LogWarning(ex, - "GetCertificate failed during issuance wait for order {OrderNumber} (attempt {Attempt}). " + - "Returning {Outcome}; sync will pick up the cert later.", - orderNumber, attempt, last == null ? "pending fallback (no prior result)" : "prior pending result"); - return last; + "GetCertificate failed during issuance wait for order {OrderNumber} " + + "(attempt {Attempt}, Phase={Phase}). Continuing until the budget expires.", + orderNumber, attempt, phase); } - int disposition = StatusMapper.ToRequestDisposition(last.Status); - if (disposition == (int)EndEntityStatus.GENERATED - || disposition == (int)EndEntityStatus.REVOKED - || disposition == (int)EndEntityStatus.FAILED) + if (current != null) { - return last; + last = current; + int disposition = StatusMapper.ToRequestDisposition(last.Status); + if (disposition == (int)EndEntityStatus.GENERATED + || disposition == (int)EndEntityStatus.REVOKED + || disposition == (int)EndEntityStatus.FAILED) + { + return last; + } } // Stop when the NEXT poll would land at or past the deadline. This makes a // budget of retries × delay yield exactly `retries` polls (t = 0, delay, // 2×delay, …), matching the documented PickupRetries semantics — checking // the deadline alone after the sleep would sneak in an extra boundary poll. - if (DateTime.UtcNow.AddSeconds(pollIntervalSeconds) > deadline) + if (DateTime.UtcNow.AddSeconds(pollIntervalSeconds) >= deadline) { _logger.LogInformation( - "Issuance not complete within {Budget}s for order {OrderNumber}. " + - "Returning pending result; sync will pick up the cert later.", - waitBudgetSeconds, orderNumber); + "Issuance not complete within {Budget}s for order {OrderNumber} (Phase={Phase}). " + + "Returning {Outcome}; sync will pick up the cert later.", + waitBudgetSeconds, orderNumber, phase, + last == null ? "pending fallback (no successful poll)" : "last pending result"); return last; } diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index 14334c1..464e130 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -5,9 +5,12 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. +using System.Collections.Concurrent; using System.Collections.Generic; using System.Text.Json.Serialization; using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; namespace Keyfactor.Extensions.CAPlugin.CERTInext { @@ -283,7 +286,7 @@ public static Dictionary GetCAConnectorAnnotations() "OV/EV products never poll: CERTInext issues them asynchronously by design " + "(organization verification takes minutes and may be human-gated), so those " + "orders return pending and are completed by the next synchronization. " + - "Set to 0 to disable the poll entirely. " + + "Set to 0 (or any negative value) to disable the poll entirely. " + $"Can also be set via the {Constants.Config.PickupRetriesEnvVar} environment " + "variable; the env var takes precedence when both are set. Default: 5.", Hidden = false, @@ -293,7 +296,8 @@ public static Dictionary GetCAConnectorAnnotations() [Constants.Config.PickupDelaySeconds] = new PropertyConfigInfo { Comments = "OPTIONAL: Seconds between synchronous pickup polls inside Enroll() (see " + - "PickupRetries). " + + "PickupRetries). Setting this to 0 (or any negative value) disables the " + + "pickup poll entirely — it does NOT mean back-to-back polling. " + $"Can also be set via the {Constants.Config.PickupDelaySecondsEnvVar} environment " + "variable; the env var takes precedence when both are set. Default: 10.", Hidden = false, @@ -817,19 +821,54 @@ public class CERTInextConfig [JsonPropertyName("DcvFollowCnameDelegation")] public bool DcvFollowCnameDelegation { get; set; } = false; + private static readonly ILogger EffectiveConfigLogger = LogHandler.GetClassLogger(); + + // Tracks (envVar, rejected value) pairs already warned about so a misconfigured + // env var produces one audit-trail warning per distinct value per process, not one + // per enrollment/sync pass. + private static readonly ConcurrentDictionary WarnedInvalidEnvValues = new(); + /// /// Shared resolution for the numeric "GetEffective*" knobs: the environment variable /// wins when set and parseable, then the configured field, then the compiled default. /// distinguishes knobs where 0 is a meaningful /// "disabled" value from knobs that require a positive value. + /// makes negative values coerce to 0 rather + /// than being rejected — for the pickup knobs, where "-1 to disable" is a common + /// operator convention and silently re-enabling the compiled default would be the + /// opposite of the operator's intent. + /// A set-but-invalid env var is rejected with a Warning (SOX change management / + /// SOC2 CC7.2: the override changes runtime control behavior, so silently ignoring + /// it would leave the deployed value unexplained in the audit trail). /// - private static int GetEffectiveInt(string envVarName, int configured, int fallback, bool zeroAllowed) + private static int GetEffectiveInt(string envVarName, int configured, int fallback, + bool zeroAllowed, bool negativeMeansZero = false) { bool Valid(int v) => zeroAllowed ? v >= 0 : v > 0; + int Normalize(int v) => negativeMeansZero && v < 0 ? 0 : v; + + configured = Normalize(configured); + int effective = Valid(configured) ? configured : fallback; + var env = System.Environment.GetEnvironmentVariable(envVarName); - if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && Valid(envVal)) - return envVal; - return Valid(configured) ? configured : fallback; + if (string.IsNullOrEmpty(env)) + return effective; + + if (int.TryParse(env, out int envVal)) + { + envVal = Normalize(envVal); + if (Valid(envVal)) + return envVal; + } + + if (WarnedInvalidEnvValues.TryAdd($"{envVarName}={env}", 0)) + { + EffectiveConfigLogger.LogWarning( + "Environment variable {EnvVar} is set to '{Value}', which is not a valid value for this " + + "setting; falling back to the configured/default value {Effective}.", + envVarName, env, effective); + } + return effective; } /// @@ -856,16 +895,19 @@ public int GetEffectiveDcvWaitForIssuanceSeconds() => /// /// Returns the effective synchronous-pickup retry count, preferring the env var so - /// operators can tune without re-saving the connector. 0 disables the pickup poll. + /// operators can tune without re-saving the connector. 0 (or any negative value) + /// disables the pickup poll. /// public int GetEffectivePickupRetries() => - GetEffectiveInt(Constants.Config.PickupRetriesEnvVar, PickupRetries, Constants.Pickup.DefaultRetries, zeroAllowed: true); + GetEffectiveInt(Constants.Config.PickupRetriesEnvVar, PickupRetries, Constants.Pickup.DefaultRetries, + zeroAllowed: true, negativeMeansZero: true); /// /// Returns the effective delay between synchronous-pickup polls, preferring the env - /// var. 0 disables the pickup poll. + /// var. 0 (or any negative value) disables the pickup poll. /// public int GetEffectivePickupDelaySeconds() => - GetEffectiveInt(Constants.Config.PickupDelaySecondsEnvVar, PickupDelaySeconds, Constants.Pickup.DefaultDelaySeconds, zeroAllowed: true); + GetEffectiveInt(Constants.Config.PickupDelaySecondsEnvVar, PickupDelaySeconds, Constants.Pickup.DefaultDelaySeconds, + zeroAllowed: true, negativeMeansZero: true); } } diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 2c604aa..5c4c058 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -767,6 +767,10 @@ public async Task RenewCertificateAsync( Status = MapCertStatusIdToLegacyString(certStatusId), Certificate = pemCert, SerialNumber = serialNumber, + // Report the product code this renewal order was actually placed with so + // callers (e.g. the synchronous-pickup gate) classify what reached the API + // instead of re-deriving this method's selection logic at a distance. + ProfileId = orderReq.OrderDetails.ProductCode, Message = trackResp.OrderDetails?.CertificateStatus }; diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index 81b9636..d68efb7 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -293,10 +293,21 @@ public static class Pickup public const int DefaultRetries = 5; public const int DefaultDelaySeconds = 10; + // Hard ceiling on the pickup budget (retries × delay), applied regardless of + // configuration. Command abandons enrollment calls long before this; anything + // larger would only orphan a worker thread generating pointless API traffic. + // The documented guidance is to keep retries × delay under ~90 s. + public const int MaxBudgetSeconds = 300; + // How long a fetched product catalog (productCode → DV/OV/EV classification) is // reused before being refreshed via GetProductDetails. The catalog is effectively // static for an account, so this only bounds staleness after a CA-side change. public const int ProductTypeCacheMinutes = 60; + + // How long to wait before retrying GetProductDetails after a failed catalog + // refresh, so a down catalog endpoint costs at most one failing API call per + // window instead of one per enrollment. + public const int FailureBackoffMinutes = 5; } public static class Dcv diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f15cbf..cd698c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # 1.2.0 ## Features -- feat(enroll): `Enroll()` now runs a synchronous pickup poll on every enrollment path (new, reissue, and renewal) on both build flavors — DV orders that issue within the poll budget return the issued certificate in the same call instead of waiting for the next synchronization, restoring the behavior expiration-renewal workflows relied on with the legacy Sectigo connector. Configurable via the new `PickupRetries` (default 5) and `PickupDelaySeconds` (default 10) connector settings (`retries × delay` = maximum time an enrollment call occupies a Command worker thread); set `PickupRetries` to `0` to disable. +- feat(enroll): `Enroll()` now runs a synchronous pickup poll on every enrollment path (new, reissue, and renewal) on both build flavors — DV orders that issue within the poll budget return the issued certificate in the same call instead of waiting for the next synchronization, restoring the behavior expiration-renewal workflows relied on with the legacy Sectigo connector. Configurable via the new `PickupRetries` (default 5) and `PickupDelaySeconds` (default 10) connector settings (`retries × delay` ≈ maximum time an enrollment call occupies a Command worker thread, hard-capped at 300 s); set either to `0` (or a negative value) to disable. Transient API failures consume an attempt rather than aborting the poll. - feat(enroll): OV/EV orders skip the pickup poll and return pending immediately with a status message explaining that CERTInext issues these products asynchronously by design (organization verification; confirmed by CERTInext support) — the certificate is imported by the next synchronization. The product's validation level is resolved from the account's product catalog (`GetProductDetails`, cached for 60 minutes), with the template product name as fallback. ## Bug Fixes diff --git a/README.md b/README.md index c8c87f5..6c72cdd 100644 --- a/README.md +++ b/README.md @@ -143,8 +143,8 @@ CERTInext operates three separate environments. Use the sandbox environment for * **IgnoreExpired** - If true, expired certificates will be skipped during synchronization. Default: false. * **PageSize** - Number of orders to fetch per page during synchronization. Default: 100, max: 500. * **Enabled** - Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true. - * **PickupRetries** - OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product, so fast-issuing orders return the certificate synchronously in the same enrollment call. PickupRetries × PickupDelaySeconds ≈ the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 to disable. Can also be set via the CERTINEXT_PICKUP_RETRIES environment variable; the env var takes precedence. Default: 5. - * **PickupDelaySeconds** - OPTIONAL: Seconds between synchronous pickup polls inside Enroll() (see PickupRetries). Can also be set via the CERTINEXT_PICKUP_DELAY_SECONDS environment variable; the env var takes precedence. Default: 10. + * **PickupRetries** - OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product, so fast-issuing orders return the certificate synchronously in the same enrollment call. PickupRetries × PickupDelaySeconds ≈ the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 (or any negative value) to disable. Can also be set via the CERTINEXT_PICKUP_RETRIES environment variable; the env var takes precedence. Default: 5. + * **PickupDelaySeconds** - OPTIONAL: Seconds between synchronous pickup polls inside Enroll() (see PickupRetries). Setting this to 0 (or any negative value) disables the pickup poll entirely — it does NOT mean back-to-back polling. Can also be set via the CERTINEXT_PICKUP_DELAY_SECONDS environment variable; the env var takes precedence. Default: 10. * **DcvEnabled** - OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) during enrollment for orders that require it, using the configured DNS provider plugin. Requires a DNS provider plugin (e.g. azure-azuredns-dnsplugin) to be deployed on the gateway. Default: false. * **DcvTxtRecordTemplate** - OPTIONAL: Format string for the DNS TXT record hostname used during DCV. {0} is replaced with the domain name being validated. Default: _emsign-validation.{0} * **DcvPropagationDelaySeconds** - OPTIONAL: Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: 30. @@ -262,8 +262,8 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | -| `PickupRetries` | Optional | Number of times `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product, so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). `PickupRetries × PickupDelaySeconds` is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` to disable the poll. Can also be set via the `CERTINEXT_PICKUP_RETRIES` environment variable; the environment variable takes precedence. Default: `5`. | N/A | `5` | -| `PickupDelaySeconds` | Optional | Seconds between synchronous pickup polls inside `Enroll()` (see `PickupRetries`). Can also be set via the `CERTINEXT_PICKUP_DELAY_SECONDS` environment variable; the environment variable takes precedence. Default: `10`. | N/A | `10` | +| `PickupRetries` | Optional | Number of times `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product, so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). `PickupRetries × PickupDelaySeconds` is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` (or any negative value) to disable the poll. Can also be set via the `CERTINEXT_PICKUP_RETRIES` environment variable; the environment variable takes precedence. Default: `5`. | N/A | `5` | +| `PickupDelaySeconds` | Optional | Seconds between synchronous pickup polls inside `Enroll()` (see `PickupRetries`). Setting this to `0` (or any negative value) disables the pickup poll entirely — it does **not** mean back-to-back polling. Can also be set via the `CERTINEXT_PICKUP_DELAY_SECONDS` environment variable; the environment variable takes precedence. Default: `10`. | N/A | `10` | | `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | | `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | | `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: `30`. | N/A | `30` | @@ -483,7 +483,7 @@ sequenceDiagram alt Certificate issued immediately Plugin-->>CMD: Certificate ready — PEM returned - else Pending and product is DV + else Pending, product is DV, and DCV does not own the wait loop Synchronous pickup
(up to PickupRetries × PickupDelaySeconds) Plugin->>API: Fetch certificate API-->>Plugin: Issued, or still pending @@ -500,6 +500,8 @@ sequenceDiagram The synchronous pickup step mirrors the legacy Sectigo connector's behavior: DV orders that CERTInext issues within the poll budget are returned in the same enrollment call, so automated workflows (e.g. expiration renewal) receive the certificate without waiting for a sync cycle. The product's validation level is resolved from the account's product catalog (cached), falling back to the template product name. OV/EV orders are never polled — their organization-verification step takes minutes and may be human-gated, so the plugin returns pending immediately with a message explaining the deferral. +On gateways with `DcvEnabled` (DCV build flavor), pending **new/reissue** orders skip this pickup loop entirely — the in-call DCV flow owns those waits, and its post-validation issuance poll is budgeted by `DcvWaitForIssuanceSeconds` (3-second interval), not by `PickupRetries × PickupDelaySeconds`. Renewals never run in-call DCV, so the pickup loop above applies to them on every flavor, as does the recovery fetch for orders that issued but whose certificate download initially failed. + ### Renewal When Command initiates a renewal, the plugin checks whether the existing certificate is within the configured renewal window. If it is, the prior order record is used as context for the new request. If it is outside the window (or the prior certificate cannot be located), the plugin falls back to issuing a new certificate. diff --git a/docsource/architecture.md b/docsource/architecture.md index 5fa2fc2..c7032f7 100644 --- a/docsource/architecture.md +++ b/docsource/architecture.md @@ -139,7 +139,7 @@ sequenceDiagram alt Certificate issued immediately Plugin-->>CMD: Certificate ready — PEM returned - else Pending and product is DV + else Pending, product is DV, and DCV does not own the wait loop Synchronous pickup
(up to PickupRetries × PickupDelaySeconds) Plugin->>API: Fetch certificate API-->>Plugin: Issued, or still pending @@ -156,6 +156,8 @@ sequenceDiagram The synchronous pickup step mirrors the legacy Sectigo connector's behavior: DV orders that CERTInext issues within the poll budget are returned in the same enrollment call, so automated workflows (e.g. expiration renewal) receive the certificate without waiting for a sync cycle. The product's validation level is resolved from the account's product catalog (cached), falling back to the template product name. OV/EV orders are never polled — their organization-verification step takes minutes and may be human-gated, so the plugin returns pending immediately with a message explaining the deferral. +On gateways with `DcvEnabled` (DCV build flavor), pending **new/reissue** orders skip this pickup loop entirely — the in-call DCV flow owns those waits, and its post-validation issuance poll is budgeted by `DcvWaitForIssuanceSeconds` (3-second interval), not by `PickupRetries × PickupDelaySeconds`. Renewals never run in-call DCV, so the pickup loop above applies to them on every flavor, as does the recovery fetch for orders that issued but whose certificate download initially failed. + ### Renewal When Command initiates a renewal, the plugin checks whether the existing certificate is within the configured renewal window. If it is, the prior order record is used as context for the new request. If it is outside the window (or the prior certificate cannot be located), the plugin falls back to issuing a new certificate. diff --git a/docsource/configuration.md b/docsource/configuration.md index 04da715..36bb81f 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -113,8 +113,8 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | -| `PickupRetries` | Optional | Number of times `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product, so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). `PickupRetries × PickupDelaySeconds` is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` to disable the poll. Can also be set via the `CERTINEXT_PICKUP_RETRIES` environment variable; the environment variable takes precedence. Default: `5`. | N/A | `5` | -| `PickupDelaySeconds` | Optional | Seconds between synchronous pickup polls inside `Enroll()` (see `PickupRetries`). Can also be set via the `CERTINEXT_PICKUP_DELAY_SECONDS` environment variable; the environment variable takes precedence. Default: `10`. | N/A | `10` | +| `PickupRetries` | Optional | Number of times `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product, so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). `PickupRetries × PickupDelaySeconds` is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` (or any negative value) to disable the poll. Can also be set via the `CERTINEXT_PICKUP_RETRIES` environment variable; the environment variable takes precedence. Default: `5`. | N/A | `5` | +| `PickupDelaySeconds` | Optional | Seconds between synchronous pickup polls inside `Enroll()` (see `PickupRetries`). Setting this to `0` (or any negative value) disables the pickup poll entirely — it does **not** mean back-to-back polling. Can also be set via the `CERTINEXT_PICKUP_DELAY_SECONDS` environment variable; the environment variable takes precedence. Default: `10`. | N/A | `10` | | `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | | `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | | `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: `30`. | N/A | `30` | diff --git a/integration-manifest.json b/integration-manifest.json index 29ff1c5..8d63e91 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -143,11 +143,11 @@ }, { "name": "PickupRetries", - "description": "OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product, so fast-issuing orders return the certificate synchronously in the same enrollment call. PickupRetries \u00d7 PickupDelaySeconds \u2248 the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) \u2014 keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 to disable. Can also be set via the CERTINEXT_PICKUP_RETRIES environment variable; the env var takes precedence. Default: 5." + "description": "OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product, so fast-issuing orders return the certificate synchronously in the same enrollment call. PickupRetries \u00d7 PickupDelaySeconds \u2248 the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) \u2014 keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 (or any negative value) to disable. Can also be set via the CERTINEXT_PICKUP_RETRIES environment variable; the env var takes precedence. Default: 5." }, { "name": "PickupDelaySeconds", - "description": "OPTIONAL: Seconds between synchronous pickup polls inside Enroll() (see PickupRetries). Can also be set via the CERTINEXT_PICKUP_DELAY_SECONDS environment variable; the env var takes precedence. Default: 10." + "description": "OPTIONAL: Seconds between synchronous pickup polls inside Enroll() (see PickupRetries). Setting this to 0 (or any negative value) disables the pickup poll entirely \u2014 it does NOT mean back-to-back polling. Can also be set via the CERTINEXT_PICKUP_DELAY_SECONDS environment variable; the env var takes precedence. Default: 10." }, { "name": "DcvEnabled", From dc04e95d03d41ed955ca6e7ec3a62cc7767e4d7f Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:31:10 -0700 Subject: [PATCH 04/17] fix(enroll): close bodyless-GENERATED escapes and make pickup poll count deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens the synchronous certificate pickup per a four-round full review cycle (code + compliance + security in parallel each round, looped to zero findings). Correctness — never return GENERATED without a certificate body: - WaitForIssuanceAsync no longer treats a GENERATED status as terminal unless the PEM is actually present, so a swallowed DownloadCertificate failure mid-poll no longer surfaces a false "issued, no PEM" result; the loop keeps polling within budget to recover the body. - The Pickup and PostDcv callers only complete on GENERATED-with-body (or REVOKED/FAILED); otherwise they fall through to the pending soft-fallback. - TryPickupIssuedCertificateAsync now enforces the invariant on EVERY return via DegradeBodylessIssuedToPending: an issued-but-PEM-missing entry state that the poll cannot recover — download kept failing, pickup disabled, or no order number to poll with — degrades to EXTERNALVALIDATION so a later sync refetches the body, instead of persisting a bodyless "issued" record. Determinism / bounds: - Poll count is now capped explicitly (maxPolls = budget / interval) so the documented "retries x delay => retries polls" is an exact upper bound rather than an emergent property of wall-clock arithmetic that timer jitter could push to retries + 1. The wall-clock deadline is retained as the early-stop for slow polls. - PickupDelaySeconds is clamped into [1, budget] as the poll interval, so a pathological env override can't make Task.Delay outlast the budget. - A structurally slow product catalog now arms the failure back-off on budget cancellation, so subsequent enrollments don't each pay the full budget. Compliance: - BuildEnrollmentResult now logs the GENERATED (issuance) outcome at Information with CERTInext id, serial, and status — the one privileged act was previously unlogged. Covers both immediate and pickup-completed paths; no PEM/secret logged. Tests: 7 new/updated pickup tests covering bodyless-GENERATED mid-poll recovery, soft-fallback on issued-without-PEM (poll-never-recovers, disabled-pickup, and empty-order-number entry states), and the now-deterministic exact poll count. Both flavors green: 238/238 (default DCV, 3.3.0) and 209/209 (no-DCV, 3.2.0), 0 warnings. --- .../CERTInextCAPluginPickupTests.cs | 152 +++++++++++++++++- CERTInext/CERTInextCAPlugin.cs | 141 +++++++++++++--- 2 files changed, 267 insertions(+), 26 deletions(-) diff --git a/CERTInext.Tests/CERTInextCAPluginPickupTests.cs b/CERTInext.Tests/CERTInextCAPluginPickupTests.cs index 6766f22..49d3639 100644 --- a/CERTInext.Tests/CERTInextCAPluginPickupTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginPickupTests.cs @@ -261,13 +261,13 @@ public async Task Pickup_SoftFallsBackToPending_WhenBudgetExhausted() result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, "exhausting the pickup budget must degrade to the pending result, never throw"); result.StatusMessage.Should().Contain("later synchronization"); - // Upper bound 2 is the documented PickupRetries semantics (the old off-by-one - // yielded retries+1 = 3). Lower bound 1 rather than exactly 2 because the poll - // loop runs against the real clock — a stalled test runner can legitimately - // exhaust the 2 s budget after a single poll. + // PickupRetries=2 yields exactly 2 polls. The poll count is now capped deterministically + // (maxPolls = budget / interval) rather than emerging from wall-clock arithmetic, so this + // is an exact assertion — no real-clock tolerance needed. This is the off-by-one guard: + // the old bug yielded retries + 1 = 3. mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), - Times.Between(1, 2, Moq.Range.Inclusive), - "PickupRetries=2 must never yield more than two polls"); + Times.Exactly(2), + "PickupRetries=2 must yield exactly two polls"); } [Fact] @@ -410,6 +410,146 @@ public async Task Pickup_FetchesPem_WhenEnrollReturnsIssuedWithoutPem() "the pickup must recover the PEM for an issued order whose download failed"); } + [Fact] + public async Task Pickup_KeepsPolling_WhenGeneratedWithoutBody_ThenRecoversPem() + { + // GetCertificateAsync maps status from TrackOrder but swallows a transient + // DownloadCertificate failure, returning Status=issued with Certificate=null. + // A body-less GENERATED must NOT be treated as terminal mid-poll — the loop must + // keep going (each attempt re-downloads) and recover the PEM within the budget. + var mock = NewMock(); + SetupPendingEnroll(mock); + SetupCatalog(mock); + mock.SetupSequence(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new LegacyGetCertificateResponse + { + Id = MockCertificateData.CertId2, Status = "issued", Certificate = null + }) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.CertId2)); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().Contain("BEGIN CERTIFICATE", + "a body-less 'issued' response must not end the poll — the next attempt recovers the PEM"); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2), "the poll must continue past a GENERATED-without-body response"); + } + + [Fact] + public async Task Pickup_SoftFallsBackToPending_WhenGeneratedBodyNeverArrives() + { + // Every poll reports issued but the PEM download keeps failing (Certificate=null), + // and the budget expires with only a body-less GENERATED in hand. The pickup must + // NOT surface that as a successful "issued, no certificate" result — Command would + // store a body-less record — but degrade to pending so a later sync refetches the PEM. + var mock = NewMock(); + SetupPendingEnroll(mock); + SetupCatalog(mock); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new LegacyGetCertificateResponse + { + Id = MockCertificateData.CertId2, Status = "issued", Certificate = null + }); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: 3, delaySeconds: 1)); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "an issued order whose PEM never downloads within the budget must degrade to " + + "pending, never a GENERATED result with no certificate body"); + result.Certificate.Should().BeNullOrEmpty( + "a bodyless GENERATED must not be returned as a successful pickup"); + result.StatusMessage.Should().Contain("later synchronization"); + } + + [Fact] + public async Task Pickup_SoftFallsBackToPending_WhenEnrollIssuedWithoutPem_AndBodyNeverArrives() + { + // Entry state (not just a mid-poll read) is issued-without-PEM: EnrollCertificateAsync + // reported issued but swallowed the post-submit download failure (Certificate=null). + // The pickup polls to recover the body; if every poll also comes back body-less and the + // budget expires, the RESULT returned to Command must degrade to pending — it must NOT + // return the original GENERATED entry state with a null certificate. + var mock = NewMock(); + var issuedNoPem = MockCertificateData.IssuedEnrollResponse(); + issuedNoPem.Certificate = null; + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(issuedNoPem); + SetupCatalog(mock); + mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new LegacyGetCertificateResponse + { + Id = MockCertificateData.CertId2, Status = "issued", Certificate = null + }); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: 3, delaySeconds: 1)); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "an issued-without-PEM enroll that the poll cannot recover must be returned pending, " + + "never as GENERATED with no certificate body"); + result.Certificate.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task Pickup_Disabled_DowngradesIssuedWithoutPem_ToPending() + { + // Pickup disabled (PickupRetries=0) short-circuits before any poll. If the enroll + // response is issued-without-PEM, returning it verbatim would hand Command a bodyless + // GENERATED. The disabled path must still enforce the no-bodyless-GENERATED invariant + // and degrade to pending so a later sync imports the certificate. + var mock = NewMock(); + var issuedNoPem = MockCertificateData.IssuedEnrollResponse(); + issuedNoPem.Certificate = null; + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(issuedNoPem); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: 0)); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "with pickup disabled a bodyless issued result must still degrade to pending"); + result.Certificate.Should().BeNullOrEmpty(); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Never, "disabled pickup must not poll"); + } + + [Fact] + public async Task Pickup_DegradesIssuedWithoutPem_ToPending_WhenOrderNumberEmpty() + { + // The no-order-number guard is the first return in the pickup and cannot poll or + // refetch. If the enroll response is issued-without-PEM but carries no order number, + // that guard must STILL enforce the no-bodyless-GENERATED invariant rather than return + // the broken result verbatim. (Defense-in-depth: the shipped client throws before + // returning an empty Id, but the pickup must not depend on that upstream guarantee.) + var mock = NewMock(); + var issuedNoPemNoId = MockCertificateData.IssuedEnrollResponse(); + issuedNoPemNoId.Certificate = null; + issuedNoPemNoId.Id = ""; + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(issuedNoPemNoId); + + var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + + var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); + + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "a bodyless issued result must degrade to pending even when there is no order " + + "number to poll with"); + result.Certificate.Should().BeNullOrEmpty(); + mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), + Times.Never, "an empty order number cannot be polled"); + } + // --------------------------------------------------------------------------- // Catalog failure back-off // --------------------------------------------------------------------------- diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 03e34cd..c2176eb 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1189,10 +1189,22 @@ private async Task EnrollNewAsync( // settings don't accidentally make this polling chunky. var postDcv = await WaitForIssuanceAsync( orderNumber, _config.GetEffectiveDcvWaitForIssuanceSeconds(), 3, "PostDcv", dcvCts.Token); + // Only a genuine terminal outcome ends the enroll call here. A GENERATED + // result without a PEM (a transient download failure during the wait) + // must fall through to the pending path so a later sync refetches the + // body — never surface a bodyless "issued" result. REVOKED/FAILED carry + // no body and are surfaced as-is. if (postDcv != null) { - return BuildEnrollmentResultFromCertificate(postDcv, orderNumber, - $"Post-DCV status: {postDcv.Status}.", ep.AutoApprove); + int postDcvDisposition = StatusMapper.ToRequestDisposition(postDcv.Status); + if (postDcvDisposition == (int)EndEntityStatus.REVOKED + || postDcvDisposition == (int)EndEntityStatus.FAILED + || (postDcvDisposition == (int)EndEntityStatus.GENERATED + && !string.IsNullOrWhiteSpace(postDcv.Certificate))) + { + return BuildEnrollmentResultFromCertificate(postDcv, orderNumber, + $"Post-DCV status: {postDcv.Status}.", ep.AutoApprove); + } } } } @@ -1524,8 +1536,36 @@ private async Task TryPickupIssuedCertificateAsync( EnrollmentResult pendingResult, string orderNumber, EnrollmentParams ep, string productCode, bool dcvOwnsIssuanceWait) { + // Invariant enforced on EVERY return that hands back the caller's result (including the + // guards below): never hand Command a GENERATED result with no certificate body. An + // issued-but-PEM-missing state the poll could not recover — the download kept failing, + // pickup is disabled, or there is no order number to poll/refetch with — must degrade to + // EXTERNALVALIDATION so a later synchronization refetches the body; otherwise Command + // persists a bodyless "issued" record, the exact outcome the pickup exists to prevent. + // Poll-success and REVOKED/FAILED returns already carry a body (or legitimately have + // none), so they no-op through this. Declared first so no early return can bypass it. + EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) + { + if (r != null + && r.Status == (int)EndEntityStatus.GENERATED + && string.IsNullOrWhiteSpace(r.Certificate)) + { + _logger.LogInformation( + "Order {OrderNumber} is issued but its certificate body was not retrievable " + + "in-call; returning pending so a later synchronization imports it.", orderNumber); + r.Status = (int)EndEntityStatus.EXTERNALVALIDATION; + r.StatusMessage = + $"Certificate for order {orderNumber} was issued by CERTInext but its body was " + + "not retrievable within this enrollment call; it will be imported by a later " + + "synchronization."; + } + return r; + } + + // No order number means we cannot poll or refetch — but still enforce the invariant so a + // bodyless issued result never escapes (the null case no-ops inside the helper). if (pendingResult == null || string.IsNullOrWhiteSpace(orderNumber)) - return pendingResult; + return DegradeBodylessIssuedToPending(pendingResult); // Two states can still benefit from a poll: pending approval (the normal case), // and issued-but-PEM-missing (order fulfilled but the post-submit certificate @@ -1560,7 +1600,7 @@ private async Task TryPickupIssuedCertificateAsync( "Synchronous pickup disabled by configuration (effective PickupRetries={Retries}, " + "PickupDelaySeconds={Delay}). Order {OrderNumber} will be picked up on the next sync cycle.", retries, delaySeconds, orderNumber); - return pendingResult; + return DegradeBodylessIssuedToPending(pendingResult); } // Compute the budget in long first: both knobs accept arbitrary non-negative ints @@ -1626,12 +1666,25 @@ private async Task TryPickupIssuedCertificateAsync( var final = await WaitForIssuanceAsync(orderNumber, budgetSeconds, delaySeconds, "Pickup", cts.Token); + // A GENERATED result is only a completed pickup once the PEM is present. + // WaitForIssuanceAsync keeps polling a body-less GENERATED, but the budget can + // still expire while the download keeps failing transiently — in that case fall + // through to the pending soft-fallback (sync refetches the body later) rather + // than surface a bogus "issued, no certificate" result. REVOKED/FAILED are real + // terminal outcomes with no body and must still be surfaced. + int finalDisposition = final == null + ? (int)EndEntityStatus.EXTERNALVALIDATION + : StatusMapper.ToRequestDisposition(final.Status); if (final != null - && StatusMapper.ToRequestDisposition(final.Status) != (int)EndEntityStatus.EXTERNALVALIDATION) + && (finalDisposition == (int)EndEntityStatus.REVOKED + || finalDisposition == (int)EndEntityStatus.FAILED + || (finalDisposition == (int)EndEntityStatus.GENERATED + && !string.IsNullOrWhiteSpace(final.Certificate)))) { _logger.LogInformation( - "Synchronous pickup complete. OrderNumber={OrderNumber}, Status={Status}", - orderNumber, final.Status); + "Synchronous pickup complete. OrderNumber={OrderNumber}, Status={Status}, SerialNumber={Serial}", + orderNumber, final.Status, + string.IsNullOrWhiteSpace(final.SerialNumber) ? "(none)" : final.SerialNumber); // Neutral wording: this message is surfaced verbatim in the operator-visible // StatusMessage by BuildEnrollmentResult's FAILED branch, so it must not // claim "issued" for an order that was rejected during the poll. @@ -1660,7 +1713,7 @@ private async Task TryPickupIssuedCertificateAsync( "sync will pick up the certificate later.", orderNumber); } - return pendingResult; + return DegradeBodylessIssuedToPending(pendingResult); } /// @@ -1739,9 +1792,21 @@ private async Task> RefreshProductType } catch (OperationCanceledException) when (ct.IsCancellationRequested) { - // The caller's pickup budget expired mid-fetch — not a catalog outage. - // Propagate so the caller's soft-fallback handles it, without arming the - // failure back-off or logging a misleading catalog warning. + // The caller's pickup budget expired mid-fetch. The fetch had the full + // budget PLUS the ~30 s grace on the pickup CTS, so a cancellation here means + // the catalog endpoint is structurally slower than any enrollment can wait — + // not a transient blip. Arm the back-off so the NEXT enrollment doesn't spend + // its whole budget on the same doomed fetch; it will classify from the stale + // catalog (or the template product name) until the endpoint recovers. Still + // propagate, because THIS enrollment's budget is already spent — its + // soft-fallback returns the pending result. + _productTypeCacheExpiresUtc = DateTime.UtcNow.AddMinutes(Constants.Pickup.FailureBackoffMinutes); + _logger.LogWarning( + "Product catalog fetch for synchronous-pickup gating exceeded the enrollment budget; " + + "catalog refresh backed off for {BackoffMinutes} minutes. Subsequent enrollments will " + + "classify from {Fallback} until it recovers.", + Constants.Pickup.FailureBackoffMinutes, + _productTypeByCode != null ? "the stale cached catalog" : "the template product name"); throw; } catch (Exception ex) @@ -2225,7 +2290,20 @@ private async Task WaitForIssuanceAsync( phase, orderNumber); return null; } - pollIntervalSeconds = Math.Max(1, pollIntervalSeconds); + // Clamp the interval into [1, budget] so a pathological PickupDelaySeconds override + // (e.g. from CERTINEXT_PICKUP_DELAY_SECONDS) can never make Task.Delay outlast the + // budget. Correctness here no longer depends on the deadline check happening to run + // before the sleep — the wait is bounded by construction. + pollIntervalSeconds = Math.Min(Math.Max(1, pollIntervalSeconds), Math.Max(1, waitBudgetSeconds)); + + // Deterministic upper bound on the poll count. The documented "retries × delay ⇒ + // retries polls" contract must hold exactly, not merely emerge from wall-clock + // arithmetic — Task.Delay can fire a hair early at the exact budget boundary and the + // deadline check below would then admit one extra poll (a real, if rare, off-by-one). + // Capping the attempt count removes that race. The wall-clock deadline is retained as + // the early-stop when individual polls run long, so a slow endpoint still cannot blow + // the time budget (and the CTS remains the hard backstop). + int maxPolls = Math.Max(1, waitBudgetSeconds / pollIntervalSeconds); int attempt = 0; while (true) @@ -2258,19 +2336,33 @@ private async Task WaitForIssuanceAsync( { last = current; int disposition = StatusMapper.ToRequestDisposition(last.Status); - if (disposition == (int)EndEntityStatus.GENERATED - || disposition == (int)EndEntityStatus.REVOKED - || disposition == (int)EndEntityStatus.FAILED) + + // GENERATED is only terminal once the PEM is actually in hand. + // GetCertificateAsync maps status from TrackOrder but swallows a + // transient DownloadCertificate failure (logs a warning, returns + // Certificate == null). Treating that as terminal would hand Command a + // "successfully issued" result with no cert body and burn the remaining + // budget that could have recovered the PEM. Keep polling instead — each + // GetCertificateAsync re-attempts the download — mirroring the same + // refetch defense Synchronize() already applies. REVOKED/FAILED are + // genuinely terminal and carry no body, so they short-circuit as before. + bool terminal = + disposition == (int)EndEntityStatus.REVOKED + || disposition == (int)EndEntityStatus.FAILED + || (disposition == (int)EndEntityStatus.GENERATED + && !string.IsNullOrWhiteSpace(last.Certificate)); + if (terminal) { return last; } } - // Stop when the NEXT poll would land at or past the deadline. This makes a - // budget of retries × delay yield exactly `retries` polls (t = 0, delay, - // 2×delay, …), matching the documented PickupRetries semantics — checking - // the deadline alone after the sleep would sneak in an extra boundary poll. - if (DateTime.UtcNow.AddSeconds(pollIntervalSeconds) >= deadline) + // Stop when we have used the deterministic poll budget, OR when the NEXT poll + // would land at or past the wall-clock deadline. The attempt cap makes a budget + // of retries × delay yield at most `retries` polls regardless of timer jitter; + // the deadline check stops early when polls themselves run long. + if (attempt >= maxPolls + || DateTime.UtcNow.AddSeconds(pollIntervalSeconds) >= deadline) { _logger.LogInformation( "Issuance not complete within {Budget}s for order {OrderNumber} (Phase={Phase}). " + @@ -2402,6 +2494,15 @@ private EnrollmentResult BuildEnrollmentResult(EnrollCertificateResponse resp, b { case (int)EndEntityStatus.GENERATED: message = $"Certificate issued successfully. CERTInext ID: {resp.Id}."; + // SOC2 CC7.2 / SOX completeness: certificate issuance is the privileged + // act this plugin performs; record it so an auditor can reconstruct which + // orders received a credential and its serial. Both the immediate-issuance + // and pickup-completed paths funnel through here, so this one line covers + // both. The PEM itself is never logged. + _logger.LogInformation( + "Certificate issued. CERTInextId={Id}, SerialNumber={Serial}, Status={Status}.", + resp.Id, string.IsNullOrWhiteSpace(resp.SerialNumber) ? "(pending download)" : resp.SerialNumber, + resp.Status); break; case (int)EndEntityStatus.EXTERNALVALIDATION: From 32a94576f7ee42107bac1d523ab590482b68151a Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:09:43 -0700 Subject: [PATCH 05/17] refactor(enroll): rename Pickup* config fields to EnrollmentWait* for clarity PickupRetries/PickupDelaySeconds read as ambiguous to end users; renamed to EnrollmentWaitAttempts/EnrollmentWaitIntervalSeconds throughout config, constants, plugin code, tests, manifest, and docs (README regenerated via doctool). Env vars renamed to CERTINEXT_ENROLLMENT_WAIT_ATTEMPTS/ CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS. No functional change. --- CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 10 +- ...> CERTInextCAPluginEnrollmentWaitTests.cs} | 152 +++++++++--------- CERTInext.Tests/CERTInextCAPluginTests.cs | 8 +- CERTInext/CERTInextCAPlugin.cs | 137 ++++++++-------- CERTInext/CERTInextCAPluginConfig.cs | 70 ++++---- CERTInext/Constants.cs | 42 ++--- CHANGELOG.md | 4 +- README.md | 14 +- docsource/architecture.md | 6 +- docsource/configuration.md | 10 +- docsource/overview.md | 6 +- integration-manifest.json | 8 +- 12 files changed, 235 insertions(+), 232 deletions(-) rename CERTInext.Tests/{CERTInextCAPluginPickupTests.cs => CERTInextCAPluginEnrollmentWaitTests.cs} (83%) diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index 1f81fc0..2a5fe01 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -47,11 +47,11 @@ private static CERTInextConfig DcvConfig( // in with a positive value (see WaitsForChallenge_ToAppear / WaitsForIssuance). DcvWaitForChallengeSeconds = dcvWaitForChallengeSeconds, DcvWaitForIssuanceSeconds = dcvWaitForIssuanceSeconds, - // This suite tests DCV behavior, not the synchronous pickup (which has its - // own suite, including the DCV interaction cases). Disable pickup so tests - // with DcvEnabled=false and pending orders don't spend the default 5×10 s - // poll budget retrying strict mocks. - PickupRetries = 0 + // This suite tests DCV behavior, not the synchronous enrollment wait (which + // has its own suite, including the DCV interaction cases). Disable it so + // tests with DcvEnabled=false and pending orders don't spend the default + // 5×10 s poll budget retrying strict mocks. + EnrollmentWaitAttempts = 0 }; private static Mock NewMock() => diff --git a/CERTInext.Tests/CERTInextCAPluginPickupTests.cs b/CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs similarity index 83% rename from CERTInext.Tests/CERTInextCAPluginPickupTests.cs rename to CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs index 49d3639..024a82d 100644 --- a/CERTInext.Tests/CERTInextCAPluginPickupTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs @@ -20,14 +20,14 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests { /// - /// Unit tests for the synchronous pickup poll (TryPickupIssuedCertificateAsync) + /// Unit tests for the synchronous enrollment-wait poll (TryEnrollmentWaitForCertificateAsync) /// that runs at the end of every enrollment path on both build flavors: /// DV products poll GetCertificate and return GENERATED + PEM when CERTInext /// issues within the budget; OV/EV products defer immediately (async by CA design, /// support ticket #162763); exhaustion or any failure soft-falls back to the pending /// result without throwing. Compiles on both the DCV (3.3.0) and no-DCV (3.2.0) flavors. /// - public class CERTInextCAPluginPickupTests + public class CERTInextCAPluginEnrollmentWaitTests { private const string DvCode = "842"; private const string OvCode = "846"; @@ -47,8 +47,8 @@ public class CERTInextCAPluginPickupTests /// issuing, poll). Tests that specifically exercise budget exhaustion pass a small /// explicit retry count instead. /// - private static CERTInextConfig PickupConfig(int retries = 10, int delaySeconds = 1) => - new CERTInextConfig { PickupRetries = retries, PickupDelaySeconds = delaySeconds }; + private static CERTInextConfig EnrollmentWaitConfig(int attempts = 10, int delaySeconds = 1) => + new CERTInextConfig { EnrollmentWaitAttempts = attempts, EnrollmentWaitIntervalSeconds = delaySeconds }; private static List SslCatalog() => new List { @@ -92,7 +92,7 @@ private static void SetupCatalog(Mock mock) => // --------------------------------------------------------------------------- [Fact] - public async Task Pickup_DvProduct_PendingThenIssued_ReturnsGeneratedWithPem() + public async Task EnrollmentWait_DvProduct_PendingThenIssued_ReturnsGeneratedWithPem() { var mock = NewMock(); SetupPendingEnroll(mock); @@ -101,12 +101,12 @@ public async Task Pickup_DvProduct_PendingThenIssued_ReturnsGeneratedWithPem() .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.CertId2)) .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.CertId2)); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); result.Status.Should().Be((int)EndEntityStatus.GENERATED, - "a DV order that issues within the pickup budget must return synchronously"); + "a DV order that issues within the enrollment-wait budget must return synchronously"); result.Certificate.Should().Contain("BEGIN CERTIFICATE"); result.CARequestID.Should().Be(MockCertificateData.CertId2); @@ -115,7 +115,7 @@ public async Task Pickup_DvProduct_PendingThenIssued_ReturnsGeneratedWithPem() } [Fact] - public async Task Pickup_DvProduct_IssuedOnFirstPoll_ReturnsGenerated() + public async Task EnrollmentWait_DvProduct_IssuedOnFirstPoll_ReturnsGenerated() { var mock = NewMock(); SetupPendingEnroll(mock); @@ -123,7 +123,7 @@ public async Task Pickup_DvProduct_IssuedOnFirstPoll_ReturnsGenerated() mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.CertId2)); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); @@ -137,13 +137,13 @@ public async Task Pickup_DvProduct_IssuedOnFirstPoll_ReturnsGenerated() // --------------------------------------------------------------------------- [Fact] - public async Task Pickup_OvProduct_ReturnsPendingImmediately_WithoutPolling() + public async Task EnrollmentWait_OvProduct_ReturnsPendingImmediately_WithoutPolling() { var mock = NewMock(); SetupPendingEnroll(mock); SetupCatalog(mock); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); var result = await Enroll(plugin, ProductInfo(Constants.Products.OvSsl, OvCode)); @@ -160,13 +160,13 @@ public async Task Pickup_OvProduct_ReturnsPendingImmediately_WithoutPolling() } [Fact] - public async Task Pickup_EvProduct_ReturnsPendingImmediately_WithoutPolling() + public async Task EnrollmentWait_EvProduct_ReturnsPendingImmediately_WithoutPolling() { var mock = NewMock(); SetupPendingEnroll(mock); SetupCatalog(mock); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); var result = await Enroll(plugin, ProductInfo(Constants.Products.EvSsl, EvCode)); @@ -176,14 +176,14 @@ public async Task Pickup_EvProduct_ReturnsPendingImmediately_WithoutPolling() } [Fact] - public async Task Pickup_OvByTemplateName_Defers_WhenCatalogUnavailable() + public async Task EnrollmentWait_OvByTemplateName_Defers_WhenCatalogUnavailable() { var mock = NewMock(); SetupPendingEnroll(mock); mock.Setup(c => c.GetProductDetailsAsync(It.IsAny())) .ThrowsAsync(new Exception("catalog endpoint down")); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); // Template product name carries the OV token — the fallback classifier // must still prevent a futile poll when the catalog can't be fetched. @@ -199,13 +199,13 @@ public async Task Pickup_OvByTemplateName_Defers_WhenCatalogUnavailable() // --------------------------------------------------------------------------- [Fact] - public async Task Pickup_ProductCatalog_IsCachedAcrossEnrollments() + public async Task EnrollmentWait_ProductCatalog_IsCachedAcrossEnrollments() { var mock = NewMock(); SetupPendingEnroll(mock); SetupCatalog(mock); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); var ov = ProductInfo(Constants.Products.OvSsl, OvCode); await Enroll(plugin, ov); @@ -221,7 +221,7 @@ public async Task Pickup_ProductCatalog_IsCachedAcrossEnrollments() // --------------------------------------------------------------------------- [Fact] - public async Task Pickup_UnknownProduct_PollsOptimistically() + public async Task EnrollmentWait_UnknownProduct_PollsOptimistically() { var mock = NewMock(); SetupPendingEnroll(mock); @@ -230,7 +230,7 @@ public async Task Pickup_UnknownProduct_PollsOptimistically() mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.CertId2)); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); // Neither the catalog nor the product name identify DV/OV/EV → the bounded poll // runs anyway (a wasted wait beats silently breaking a fast product's sync return). @@ -246,7 +246,7 @@ public async Task Pickup_UnknownProduct_PollsOptimistically() // --------------------------------------------------------------------------- [Fact] - public async Task Pickup_SoftFallsBackToPending_WhenBudgetExhausted() + public async Task EnrollmentWait_SoftFallsBackToPending_WhenBudgetExhausted() { var mock = NewMock(); SetupPendingEnroll(mock); @@ -254,24 +254,24 @@ public async Task Pickup_SoftFallsBackToPending_WhenBudgetExhausted() mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.CertId2)); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: 2, delaySeconds: 1)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: 2, delaySeconds: 1)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, - "exhausting the pickup budget must degrade to the pending result, never throw"); + "exhausting the enrollment-wait budget must degrade to the pending result, never throw"); result.StatusMessage.Should().Contain("later synchronization"); - // PickupRetries=2 yields exactly 2 polls. The poll count is now capped deterministically + // EnrollmentWaitAttempts=2 yields exactly 2 polls. The poll count is now capped deterministically // (maxPolls = budget / interval) rather than emerging from wall-clock arithmetic, so this // is an exact assertion — no real-clock tolerance needed. This is the off-by-one guard: - // the old bug yielded retries + 1 = 3. + // the old bug yielded attempts + 1 = 3. mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Exactly(2), - "PickupRetries=2 must yield exactly two polls"); + "EnrollmentWaitAttempts=2 must yield exactly two polls"); } [Fact] - public async Task Pickup_SurvivesTransientFailure_AndReturnsIssuedOnRetry() + public async Task EnrollmentWait_SurvivesTransientFailure_AndReturnsIssuedOnRetry() { var mock = NewMock(); SetupPendingEnroll(mock); @@ -280,7 +280,7 @@ public async Task Pickup_SurvivesTransientFailure_AndReturnsIssuedOnRetry() .ThrowsAsync(new Exception("momentary CERTInext 500")) .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.CertId2)); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); @@ -292,14 +292,14 @@ public async Task Pickup_SurvivesTransientFailure_AndReturnsIssuedOnRetry() } [Fact] - public async Task Pickup_Disabled_WhenRetriesNegative() + public async Task EnrollmentWait_Disabled_WhenRetriesNegative() { // "-1 to disable" is a common operator convention — it must not silently // fall back to the enabled default of 5. var mock = NewMock(); SetupPendingEnroll(mock); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: -1)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: -1)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); @@ -310,7 +310,7 @@ public async Task Pickup_Disabled_WhenRetriesNegative() } [Fact] - public async Task Pickup_SoftFallsBackToPending_WhenGetCertificateThrows() + public async Task EnrollmentWait_SoftFallsBackToPending_WhenGetCertificateThrows() { var mock = NewMock(); SetupPendingEnroll(mock); @@ -319,17 +319,17 @@ public async Task Pickup_SoftFallsBackToPending_WhenGetCertificateThrows() .ThrowsAsync(new Exception("CERTInext API 500")); // Small explicit budget: every poll throws, so this test runs to exhaustion. - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: 2)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: 2)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, - "a failing pickup poll must not fail the enrollment — the order was accepted"); + "a failing enrollment-wait poll must not fail the enrollment — the order was accepted"); result.CARequestID.Should().Be(MockCertificateData.CertId2); } [Fact] - public async Task Pickup_ReturnsFailed_WhenOrderReachesTerminalFailure() + public async Task EnrollmentWait_ReturnsFailed_WhenOrderReachesTerminalFailure() { var mock = NewMock(); SetupPendingEnroll(mock); @@ -341,12 +341,12 @@ public async Task Pickup_ReturnsFailed_WhenOrderReachesTerminalFailure() Status = "failed" }); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); result.Status.Should().Be((int)EndEntityStatus.FAILED, - "a terminal failure discovered during pickup must be surfaced, not left pending"); + "a terminal failure discovered during the enrollment wait must be surfaced, not left pending"); result.StatusMessage.Should().NotContain("Issued", "the operator-visible message for a rejected order must not claim the certificate was issued"); } @@ -356,41 +356,41 @@ public async Task Pickup_ReturnsFailed_WhenOrderReachesTerminalFailure() // --------------------------------------------------------------------------- [Fact] - public async Task Pickup_Disabled_WhenRetriesZero() + public async Task EnrollmentWait_Disabled_WhenRetriesZero() { var mock = NewMock(); SetupPendingEnroll(mock); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: 0)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: 0)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); mock.Verify(c => c.GetProductDetailsAsync(It.IsAny()), Times.Never, - "with pickup disabled the catalog must not be fetched either"); + "with the enrollment wait disabled the catalog must not be fetched either"); mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] - public async Task Pickup_Skipped_WhenEnrollReturnsIssuedWithPem() + public async Task EnrollmentWait_Skipped_WhenEnrollReturnsIssuedWithPem() { var mock = NewMock(); mock.Setup(c => c.EnrollCertificateAsync( It.IsAny(), It.IsAny())) .ReturnsAsync(MockCertificateData.IssuedEnrollResponse()); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); result.Status.Should().Be((int)EndEntityStatus.GENERATED); mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), - Times.Never, "an already-complete result needs no pickup"); + Times.Never, "an already-complete result needs no enrollment wait"); } [Fact] - public async Task Pickup_FetchesPem_WhenEnrollReturnsIssuedWithoutPem() + public async Task EnrollmentWait_FetchesPem_WhenEnrollReturnsIssuedWithoutPem() { var mock = NewMock(); var issuedNoPem = MockCertificateData.IssuedEnrollResponse(); @@ -401,17 +401,17 @@ public async Task Pickup_FetchesPem_WhenEnrollReturnsIssuedWithoutPem() mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(MockCertificateData.IssuedCertRecord()); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); result.Status.Should().Be((int)EndEntityStatus.GENERATED); result.Certificate.Should().Contain("BEGIN CERTIFICATE", - "the pickup must recover the PEM for an issued order whose download failed"); + "the enrollment wait must recover the PEM for an issued order whose download failed"); } [Fact] - public async Task Pickup_KeepsPolling_WhenGeneratedWithoutBody_ThenRecoversPem() + public async Task EnrollmentWait_KeepsPolling_WhenGeneratedWithoutBody_ThenRecoversPem() { // GetCertificateAsync maps status from TrackOrder but swallows a transient // DownloadCertificate failure, returning Status=issued with Certificate=null. @@ -427,7 +427,7 @@ public async Task Pickup_KeepsPolling_WhenGeneratedWithoutBody_ThenRecoversPem() }) .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.CertId2)); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); @@ -439,10 +439,10 @@ public async Task Pickup_KeepsPolling_WhenGeneratedWithoutBody_ThenRecoversPem() } [Fact] - public async Task Pickup_SoftFallsBackToPending_WhenGeneratedBodyNeverArrives() + public async Task EnrollmentWait_SoftFallsBackToPending_WhenGeneratedBodyNeverArrives() { // Every poll reports issued but the PEM download keeps failing (Certificate=null), - // and the budget expires with only a body-less GENERATED in hand. The pickup must + // and the budget expires with only a body-less GENERATED in hand. The enrollment wait must // NOT surface that as a successful "issued, no certificate" result — Command would // store a body-less record — but degrade to pending so a later sync refetches the PEM. var mock = NewMock(); @@ -454,7 +454,7 @@ public async Task Pickup_SoftFallsBackToPending_WhenGeneratedBodyNeverArrives() Id = MockCertificateData.CertId2, Status = "issued", Certificate = null }); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: 3, delaySeconds: 1)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: 3, delaySeconds: 1)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); @@ -462,16 +462,16 @@ public async Task Pickup_SoftFallsBackToPending_WhenGeneratedBodyNeverArrives() "an issued order whose PEM never downloads within the budget must degrade to " + "pending, never a GENERATED result with no certificate body"); result.Certificate.Should().BeNullOrEmpty( - "a bodyless GENERATED must not be returned as a successful pickup"); + "a bodyless GENERATED must not be returned as a successful enrollment wait"); result.StatusMessage.Should().Contain("later synchronization"); } [Fact] - public async Task Pickup_SoftFallsBackToPending_WhenEnrollIssuedWithoutPem_AndBodyNeverArrives() + public async Task EnrollmentWait_SoftFallsBackToPending_WhenEnrollIssuedWithoutPem_AndBodyNeverArrives() { // Entry state (not just a mid-poll read) is issued-without-PEM: EnrollCertificateAsync // reported issued but swallowed the post-submit download failure (Certificate=null). - // The pickup polls to recover the body; if every poll also comes back body-less and the + // The enrollment wait polls to recover the body; if every poll also comes back body-less and the // budget expires, the RESULT returned to Command must degrade to pending — it must NOT // return the original GENERATED entry state with a null certificate. var mock = NewMock(); @@ -487,7 +487,7 @@ public async Task Pickup_SoftFallsBackToPending_WhenEnrollIssuedWithoutPem_AndBo Id = MockCertificateData.CertId2, Status = "issued", Certificate = null }); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: 3, delaySeconds: 1)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: 3, delaySeconds: 1)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); @@ -498,9 +498,9 @@ public async Task Pickup_SoftFallsBackToPending_WhenEnrollIssuedWithoutPem_AndBo } [Fact] - public async Task Pickup_Disabled_DowngradesIssuedWithoutPem_ToPending() + public async Task EnrollmentWait_Disabled_DowngradesIssuedWithoutPem_ToPending() { - // Pickup disabled (PickupRetries=0) short-circuits before any poll. If the enroll + // Enrollment wait disabled (EnrollmentWaitAttempts=0) short-circuits before any poll. If the enroll // response is issued-without-PEM, returning it verbatim would hand Command a bodyless // GENERATED. The disabled path must still enforce the no-bodyless-GENERATED invariant // and degrade to pending so a later sync imports the certificate. @@ -511,25 +511,25 @@ public async Task Pickup_Disabled_DowngradesIssuedWithoutPem_ToPending() It.IsAny(), It.IsAny())) .ReturnsAsync(issuedNoPem); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig(retries: 0)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: 0)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, - "with pickup disabled a bodyless issued result must still degrade to pending"); + "with the enrollment wait disabled a bodyless issued result must still degrade to pending"); result.Certificate.Should().BeNullOrEmpty(); mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), - Times.Never, "disabled pickup must not poll"); + Times.Never, "the disabled enrollment wait must not poll"); } [Fact] - public async Task Pickup_DegradesIssuedWithoutPem_ToPending_WhenOrderNumberEmpty() + public async Task EnrollmentWait_DegradesIssuedWithoutPem_ToPending_WhenOrderNumberEmpty() { - // The no-order-number guard is the first return in the pickup and cannot poll or + // The no-order-number guard is the first return in the enrollment wait and cannot poll or // refetch. If the enroll response is issued-without-PEM but carries no order number, // that guard must STILL enforce the no-bodyless-GENERATED invariant rather than return // the broken result verbatim. (Defense-in-depth: the shipped client throws before - // returning an empty Id, but the pickup must not depend on that upstream guarantee.) + // returning an empty Id, but the enrollment wait must not depend on that upstream guarantee.) var mock = NewMock(); var issuedNoPemNoId = MockCertificateData.IssuedEnrollResponse(); issuedNoPemNoId.Certificate = null; @@ -538,7 +538,7 @@ public async Task Pickup_DegradesIssuedWithoutPem_ToPending_WhenOrderNumberEmpty It.IsAny(), It.IsAny())) .ReturnsAsync(issuedNoPemNoId); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); @@ -555,7 +555,7 @@ public async Task Pickup_DegradesIssuedWithoutPem_ToPending_WhenOrderNumberEmpty // --------------------------------------------------------------------------- [Fact] - public async Task Pickup_CatalogFailure_IsBackedOff_NotRetriedPerEnrollment() + public async Task EnrollmentWait_CatalogFailure_IsBackedOff_NotRetriedPerEnrollment() { var mock = NewMock(); SetupPendingEnroll(mock); @@ -564,7 +564,7 @@ public async Task Pickup_CatalogFailure_IsBackedOff_NotRetriedPerEnrollment() mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.CertId2)); - var plugin = new CERTInextCAPlugin(mock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig()); var dv = ProductInfo(Constants.Products.DvSsl, DvCode); await Enroll(plugin, dv); @@ -581,7 +581,7 @@ public async Task Pickup_CatalogFailure_IsBackedOff_NotRetriedPerEnrollment() // --------------------------------------------------------------------------- [Fact] - public async Task Pickup_RenewPath_PendingThenIssued_ReturnsGenerated() + public async Task EnrollmentWait_RenewPath_PendingThenIssued_ReturnsGenerated() { var clientMock = NewMock(); var readerMock = new Mock(MockBehavior.Strict); @@ -600,7 +600,7 @@ public async Task Pickup_RenewPath_PendingThenIssued_ReturnsGenerated() clientMock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(MockCertificateData.IssuedCertRecord("renewed-01")); - var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object, PickupConfig()); + var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object, EnrollmentWaitConfig()); var productInfo = new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl, @@ -615,18 +615,18 @@ public async Task Pickup_RenewPath_PendingThenIssued_ReturnsGenerated() var result = await Enroll(plugin, productInfo, EnrollmentType.RenewOrReissue); result.Status.Should().Be((int)EndEntityStatus.GENERATED, - "the renew API path must run the same synchronous pickup as new enrollment — " + + "the renew API path must run the same synchronous enrollment wait as new enrollment — " + "this is the expiration-renewal workflow scenario"); result.Certificate.Should().Contain("BEGIN CERTIFICATE"); clientMock.Verify(c => c.GetCertificateAsync("renewed-01", It.IsAny()), - Times.Once, "the pickup must poll the NEW order number returned by the renewal"); + Times.Once, "the enrollment wait must poll the NEW order number returned by the renewal"); } [Fact] - public async Task Pickup_RenewPath_RunsEvenWhenDcvEnabled() + public async Task EnrollmentWait_RenewPath_RunsEvenWhenDcvEnabled() { // In-call DCV only exists on the New/Reissue path, so DcvEnabled must NOT - // suppress the pickup for renewals — that is the expiration-renewal scenario + // suppress the enrollment wait for renewals — that is the expiration-renewal scenario // this feature exists for. var clientMock = NewMock(); var readerMock = new Mock(MockBehavior.Strict); @@ -645,7 +645,7 @@ public async Task Pickup_RenewPath_RunsEvenWhenDcvEnabled() clientMock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(MockCertificateData.IssuedCertRecord("renewed-02")); - var config = PickupConfig(); + var config = EnrollmentWaitConfig(); config.DcvEnabled = true; var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object, config); var productInfo = new EnrollmentProductInfo @@ -662,11 +662,11 @@ public async Task Pickup_RenewPath_RunsEvenWhenDcvEnabled() var result = await Enroll(plugin, productInfo, EnrollmentType.RenewOrReissue); result.Status.Should().Be((int)EndEntityStatus.GENERATED, - "DcvEnabled must not disable the renew-path pickup — no in-call DCV runs there"); + "DcvEnabled must not disable the renew-path enrollment wait — no in-call DCV runs there"); } [Fact] - public async Task Pickup_FetchesPem_ForIssuedOrder_EvenWhenDcvEnabled() + public async Task EnrollmentWait_FetchesPem_ForIssuedOrder_EvenWhenDcvEnabled() { // An issued-but-PEM-missing order is past validation entirely, so the recovery // fetch must run regardless of DCV configuration. @@ -685,7 +685,7 @@ public async Task Pickup_FetchesPem_ForIssuedOrder_EvenWhenDcvEnabled() .ThrowsAsync(new Exception("not relevant to this test")); #endif - var config = PickupConfig(); + var config = EnrollmentWaitConfig(); config.DcvEnabled = true; var plugin = new CERTInextCAPlugin(mock.Object, config); @@ -697,7 +697,7 @@ public async Task Pickup_FetchesPem_ForIssuedOrder_EvenWhenDcvEnabled() } [Fact] - public async Task Pickup_RenewPath_ClassifiesTheProductCodeActuallyOrdered() + public async Task EnrollmentWait_RenewPath_ClassifiesTheProductCodeActuallyOrdered() { // CERTInextClient.RenewCertificateAsync places the renewal order with the // connector's DefaultProductCode (not the template's code) and reports the @@ -720,7 +720,7 @@ public async Task Pickup_RenewPath_ClassifiesTheProductCodeActuallyOrdered() .ReturnsAsync(renewPending); SetupCatalog(clientMock); - var config = PickupConfig(); + var config = EnrollmentWaitConfig(); config.DefaultProductCode = OvCode; // what RenewCertificateAsync orders with var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object, config); var productInfo = new EnrollmentProductInfo diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs index f9b9084..97d1eeb 100644 --- a/CERTInext.Tests/CERTInextCAPluginTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginTests.cs @@ -332,10 +332,10 @@ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval() It.IsAny())) .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); - // Pickup disabled: this test verifies the pending-status mapping, not the - // synchronous pickup (which has its own suite) — with the default 5×10 s - // budget the poll would otherwise spend ~50 s retrying the strict mock. - var plugin = new CERTInextCAPlugin(mock.Object, new CERTInextConfig { PickupRetries = 0 }); + // Enrollment wait disabled: this test verifies the pending-status mapping, not + // the synchronous enrollment wait (which has its own suite) — with the default + // 5×10 s budget the poll would otherwise spend ~50 s retrying the strict mock. + var plugin = new CERTInextCAPlugin(mock.Object, new CERTInextConfig { EnrollmentWaitAttempts = 0 }); var result = await plugin.Enroll( csr: MockCertificateData.FakeCsrPem, diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index c2176eb..cfa7985 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -85,10 +85,10 @@ public class CERTInextCAPlugin : IAnyCAPlugin, IDisposable private readonly ConcurrentDictionary _dcvInFlight = new(); // Cached productCode → DV/OV/EV classification built from GetProductDetails, used by - // the synchronous pickup gate (TryPickupIssuedCertificateAsync). Refreshed at most - // once per Constants.Pickup.ProductTypeCacheMinutes so the catalog is never fetched - // per-enrollment; a fetch failure falls back to the stale map (or template-name - // classification) rather than failing the enrollment. + // the synchronous enrollment-wait gate (TryEnrollmentWaitForCertificateAsync). Refreshed + // at most once per Constants.EnrollmentWait.ProductTypeCacheMinutes so the catalog is + // never fetched per-enrollment; a fetch failure falls back to the stale map (or + // template-name classification) rather than failing the enrollment. private readonly SemaphoreSlim _productTypeCacheLock = new(1, 1); private volatile Dictionary _productTypeByCode; private DateTime _productTypeCacheExpiresUtc = DateTime.MinValue; @@ -1247,7 +1247,7 @@ private async Task EnrollNewAsync( dcvOwnsIssuanceWait = _config.DcvEnabled; #endif var newResult = BuildEnrollmentResult(enrollResp, ep.AutoApprove); - newResult = await TryPickupIssuedCertificateAsync( + newResult = await TryEnrollmentWaitForCertificateAsync( newResult, enrollResp.Id, ep, ep.ProductCode, dcvOwnsIssuanceWait); _logger.MethodExit(LogLevel.Debug); @@ -1480,10 +1480,10 @@ private async Task RenewOrReissueAsync( _logger.LogWarning( "Renewal order {OrderNumber} was placed with the connector DefaultProductCode " + "({OrderedCode}), which differs from this template's product code ({TemplateCode}). " + - "The synchronous-pickup gate classifies the ordered code.", + "The synchronous enrollment-wait gate classifies the ordered code.", renewResp.Id, renewedProductCode, ep.ProductCode); } - renewResult = await TryPickupIssuedCertificateAsync( + renewResult = await TryEnrollmentWaitForCertificateAsync( renewResult, renewResp.Id, ep, renewedProductCode, dcvOwnsIssuanceWait: false); return renewResult; @@ -1498,7 +1498,7 @@ private async Task RenewOrReissueAsync( } // --------------------------------------------------------------------------- - // Synchronous pickup — DCV-independent, both build flavors + // Synchronous enrollment wait — DCV-independent, both build flavors // --------------------------------------------------------------------------- /// @@ -1530,20 +1530,21 @@ private async Task RenewOrReissueAsync( /// flow already performed (or deliberately deferred) the issuance wait — a pending /// order there is waiting on domain validation that only the sync-driven DCV path can /// advance, so a second poll cannot win. The renew path never runs in-call DCV and - /// must always be eligible for pickup. + /// must always be eligible for the enrollment-wait poll. /// - private async Task TryPickupIssuedCertificateAsync( + private async Task TryEnrollmentWaitForCertificateAsync( EnrollmentResult pendingResult, string orderNumber, EnrollmentParams ep, string productCode, bool dcvOwnsIssuanceWait) { // Invariant enforced on EVERY return that hands back the caller's result (including the // guards below): never hand Command a GENERATED result with no certificate body. An // issued-but-PEM-missing state the poll could not recover — the download kept failing, - // pickup is disabled, or there is no order number to poll/refetch with — must degrade to - // EXTERNALVALIDATION so a later synchronization refetches the body; otherwise Command - // persists a bodyless "issued" record, the exact outcome the pickup exists to prevent. - // Poll-success and REVOKED/FAILED returns already carry a body (or legitimately have - // none), so they no-op through this. Declared first so no early return can bypass it. + // the enrollment wait is disabled, or there is no order number to poll/refetch with — + // must degrade to EXTERNALVALIDATION so a later synchronization refetches the body; + // otherwise Command persists a bodyless "issued" record, the exact outcome the + // enrollment wait exists to prevent. Poll-success and REVOKED/FAILED returns already + // carry a body (or legitimately have none), so they no-op through this. Declared first + // so no early return can bypass it. EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) { if (r != null @@ -1583,48 +1584,49 @@ EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) // SOC2 CC7.2: Information so the enrollment timeline shows which mechanism // owned the in-call wait (pairs with the "Starting DCV for order" line). _logger.LogInformation( - "Skipping synchronous pickup for order {OrderNumber} — the in-call DCV flow owns this order's issuance wait.", + "Skipping synchronous enrollment wait for order {OrderNumber} — the in-call DCV flow owns this order's issuance wait.", orderNumber); return pendingResult; } - int retries = _config.GetEffectivePickupRetries(); - int delaySeconds = _config.GetEffectivePickupDelaySeconds(); - if (retries <= 0 || delaySeconds <= 0) + int attempts = _config.GetEffectiveEnrollmentWaitAttempts(); + int delaySeconds = _config.GetEffectiveEnrollmentWaitIntervalSeconds(); + if (attempts <= 0 || delaySeconds <= 0) { // SOC2 CC7.2 / SOX change management: the effective values may come from env // vars rather than the connector record, so this Information line is the only - // production-log evidence distinguishing "pickup disabled by operator" from - // "pickup never attempted". + // production-log evidence distinguishing "enrollment wait disabled by operator" + // from "enrollment wait never attempted". _logger.LogInformation( - "Synchronous pickup disabled by configuration (effective PickupRetries={Retries}, " + - "PickupDelaySeconds={Delay}). Order {OrderNumber} will be picked up on the next sync cycle.", - retries, delaySeconds, orderNumber); + "Synchronous enrollment wait disabled by configuration (effective EnrollmentWaitAttempts={Attempts}, " + + "EnrollmentWaitIntervalSeconds={Delay}). Order {OrderNumber} will be picked up on the next sync cycle.", + attempts, delaySeconds, orderNumber); return DegradeBodylessIssuedToPending(pendingResult); } // Compute the budget in long first: both knobs accept arbitrary non-negative ints // from env vars, and an int overflow here would go negative and make the CTS - // constructor throw (silently disabling pickup via the catch below). Clamp to the - // hard ceiling — Command abandons enrollment calls long before it, so a larger - // budget would only orphan a worker thread (docs: keep retries × delay under ~90 s). - long configuredBudgetSeconds = (long)retries * delaySeconds; - int budgetSeconds = (int)Math.Min(configuredBudgetSeconds, Constants.Pickup.MaxBudgetSeconds); - if (configuredBudgetSeconds > Constants.Pickup.MaxBudgetSeconds) + // constructor throw (silently disabling the enrollment wait via the catch below). + // Clamp to the hard ceiling — Command abandons enrollment calls long before it, so a + // larger budget would only orphan a worker thread (docs: keep attempts × interval + // under ~90 s). + long configuredBudgetSeconds = (long)attempts * delaySeconds; + int budgetSeconds = (int)Math.Min(configuredBudgetSeconds, Constants.EnrollmentWait.MaxBudgetSeconds); + if (configuredBudgetSeconds > Constants.EnrollmentWait.MaxBudgetSeconds) { // SOX CC7.3: the clamp is a policy decision — evidence it. _logger.LogWarning( - "Configured pickup budget ({Configured}s = PickupRetries {Retries} × PickupDelaySeconds {Delay}) " + + "Configured enrollment-wait budget ({Configured}s = EnrollmentWaitAttempts {Attempts} × EnrollmentWaitIntervalSeconds {Delay}) " + "exceeds the hard ceiling; clamped to {Max}s for order {OrderNumber}.", - configuredBudgetSeconds, retries, delaySeconds, Constants.Pickup.MaxBudgetSeconds, orderNumber); + configuredBudgetSeconds, attempts, delaySeconds, Constants.EnrollmentWait.MaxBudgetSeconds, orderNumber); } try { - // One ceiling bounds the ENTIRE pickup — catalog classification included — so - // a hung catalog endpoint cannot hold a Command worker thread beyond the - // configured budget (+ grace for one in-flight request). This is what keeps - // the documented "retries × delay = max Command-occupied time" honest. + // One ceiling bounds the ENTIRE enrollment wait — catalog classification + // included — so a hung catalog endpoint cannot hold a Command worker thread + // beyond the configured budget (+ grace for one in-flight request). This is what + // keeps the documented "attempts × interval = max Command-occupied time" honest. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(budgetSeconds + 30)); var validationType = ProductValidationType.Unknown; @@ -1638,7 +1640,7 @@ EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) // SOC2 CC7.2: the decision to defer is policy-relevant — log at // Information so it survives production log filters. _logger.LogInformation( - "Synchronous pickup skipped — {Type} products are issued asynchronously by " + + "Synchronous enrollment wait skipped — {Type} products are issued asynchronously by " + "CERTInext (organization verification). OrderNumber={OrderNumber}, " + "ProductCode={ProductCode}. The certificate will be imported by a later synchronization.", typeLabel, orderNumber, productCode); @@ -1660,13 +1662,13 @@ EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) } _logger.LogInformation( - "Synchronous pickup poll started. OrderNumber={OrderNumber}, ProductCode={ProductCode}, " + - "ValidationType={ValidationType}, Retries={Retries}, DelaySeconds={Delay}, BudgetSeconds={Budget}", - orderNumber, productCode, validationType, retries, delaySeconds, budgetSeconds); + "Synchronous enrollment-wait poll started. OrderNumber={OrderNumber}, ProductCode={ProductCode}, " + + "ValidationType={ValidationType}, Attempts={Attempts}, DelaySeconds={Delay}, BudgetSeconds={Budget}", + orderNumber, productCode, validationType, attempts, delaySeconds, budgetSeconds); - var final = await WaitForIssuanceAsync(orderNumber, budgetSeconds, delaySeconds, "Pickup", cts.Token); + var final = await WaitForIssuanceAsync(orderNumber, budgetSeconds, delaySeconds, "EnrollmentWait", cts.Token); - // A GENERATED result is only a completed pickup once the PEM is present. + // A GENERATED result is only a completed enrollment wait once the PEM is present. // WaitForIssuanceAsync keeps polling a body-less GENERATED, but the budget can // still expire while the download keeps failing transiently — in that case fall // through to the pending soft-fallback (sync refetches the body later) rather @@ -1778,45 +1780,45 @@ private async Task> RefreshProductType var classified = ProductClassifier.ClassifyName(p.ProductName); map[p.ProductCode.Trim()] = classified; _logger.LogDebug( - "Catalog product classified for pickup gating. ProductCode={Code}, " + + "Catalog product classified for enrollment-wait gating. ProductCode={Code}, " + "ProductTypeId={TypeId}, ProductName={Name}, ValidationType={Type}", p.ProductCode, p.ProductTypeId, p.ProductName, classified); } _productTypeByCode = map; - _productTypeCacheExpiresUtc = DateTime.UtcNow.AddMinutes(Constants.Pickup.ProductTypeCacheMinutes); + _productTypeCacheExpiresUtc = DateTime.UtcNow.AddMinutes(Constants.EnrollmentWait.ProductTypeCacheMinutes); _logger.LogInformation( - "Product-type catalog cached for synchronous-pickup gating. Products={Count}, " + - "CacheMinutes={Minutes}", map.Count, Constants.Pickup.ProductTypeCacheMinutes); + "Product-type catalog cached for synchronous-enrollment-wait gating. Products={Count}, " + + "CacheMinutes={Minutes}", map.Count, Constants.EnrollmentWait.ProductTypeCacheMinutes); return map; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { - // The caller's pickup budget expired mid-fetch. The fetch had the full - // budget PLUS the ~30 s grace on the pickup CTS, so a cancellation here means - // the catalog endpoint is structurally slower than any enrollment can wait — - // not a transient blip. Arm the back-off so the NEXT enrollment doesn't spend - // its whole budget on the same doomed fetch; it will classify from the stale - // catalog (or the template product name) until the endpoint recovers. Still - // propagate, because THIS enrollment's budget is already spent — its - // soft-fallback returns the pending result. - _productTypeCacheExpiresUtc = DateTime.UtcNow.AddMinutes(Constants.Pickup.FailureBackoffMinutes); + // The caller's enrollment-wait budget expired mid-fetch. The fetch had the + // full budget PLUS the ~30 s grace on the enrollment-wait CTS, so a + // cancellation here means the catalog endpoint is structurally slower than + // any enrollment can wait — not a transient blip. Arm the back-off so the + // NEXT enrollment doesn't spend its whole budget on the same doomed fetch; it + // will classify from the stale catalog (or the template product name) until + // the endpoint recovers. Still propagate, because THIS enrollment's budget is + // already spent — its soft-fallback returns the pending result. + _productTypeCacheExpiresUtc = DateTime.UtcNow.AddMinutes(Constants.EnrollmentWait.FailureBackoffMinutes); _logger.LogWarning( - "Product catalog fetch for synchronous-pickup gating exceeded the enrollment budget; " + + "Product catalog fetch for synchronous-enrollment-wait gating exceeded the enrollment budget; " + "catalog refresh backed off for {BackoffMinutes} minutes. Subsequent enrollments will " + "classify from {Fallback} until it recovers.", - Constants.Pickup.FailureBackoffMinutes, + Constants.EnrollmentWait.FailureBackoffMinutes, _productTypeByCode != null ? "the stale cached catalog" : "the template product name"); throw; } catch (Exception ex) { _logger.LogWarning(ex, - "Could not refresh the product catalog for synchronous-pickup gating; falling back to " + + "Could not refresh the product catalog for synchronous-enrollment-wait gating; falling back to " + "{Fallback}. Retry backed off for {BackoffMinutes} minutes.", _productTypeByCode != null ? "the stale cached catalog" : "template-name classification", - Constants.Pickup.FailureBackoffMinutes); - _productTypeCacheExpiresUtc = DateTime.UtcNow.AddMinutes(Constants.Pickup.FailureBackoffMinutes); + Constants.EnrollmentWait.FailureBackoffMinutes); + _productTypeCacheExpiresUtc = DateTime.UtcNow.AddMinutes(Constants.EnrollmentWait.FailureBackoffMinutes); return _productTypeByCode; } } @@ -2268,9 +2270,10 @@ private async Task PerformDcvIfNeededAsync( /// time after the triggering call returns. Without this poll the plugin would catch /// the cert in pending state and return it that way, forcing the gateway to wait for /// the next sync cycle. Used by both the post-DCV wait (budget = - /// DcvWaitForIssuanceSeconds, 3 s interval) and the general synchronous pickup - /// in (budget = PickupRetries × - /// PickupDelaySeconds, PickupDelaySeconds interval). + /// DcvWaitForIssuanceSeconds, 3 s interval) and the general synchronous + /// enrollment wait in (budget = + /// EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds, + /// EnrollmentWaitIntervalSeconds interval). /// private async Task WaitForIssuanceAsync( string orderNumber, int waitBudgetSeconds, int pollIntervalSeconds, string phase, CancellationToken ct) @@ -2290,10 +2293,10 @@ private async Task WaitForIssuanceAsync( phase, orderNumber); return null; } - // Clamp the interval into [1, budget] so a pathological PickupDelaySeconds override - // (e.g. from CERTINEXT_PICKUP_DELAY_SECONDS) can never make Task.Delay outlast the - // budget. Correctness here no longer depends on the deadline check happening to run - // before the sleep — the wait is bounded by construction. + // Clamp the interval into [1, budget] so a pathological EnrollmentWaitIntervalSeconds + // override (e.g. from CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS) can never make + // Task.Delay outlast the budget. Correctness here no longer depends on the deadline + // check happening to run before the sleep — the wait is bounded by construction. pollIntervalSeconds = Math.Min(Math.Max(1, pollIntervalSeconds), Math.Max(1, waitBudgetSeconds)); // Deterministic upper bound on the poll count. The documented "retries × delay ⇒ diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index 464e130..07a924d 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -275,33 +275,33 @@ public static Dictionary GetCAConnectorAnnotations() DefaultValue = true, Type = "Boolean" }, - [Constants.Config.PickupRetries] = new PropertyConfigInfo + [Constants.Config.EnrollmentWaitAttempts] = new PropertyConfigInfo { Comments = "OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate " + "after submitting an order for a DV product, so fast-issuing orders return the " + "certificate synchronously in the same enrollment call. " + - "PickupRetries × PickupDelaySeconds ≈ the maximum time an enrollment call can " + - "occupy a Keyfactor Command worker thread (a small internal grace margin applies) " + - "— keep the product under ~90 seconds. " + + "EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds ≈ the maximum time an " + + "enrollment call can occupy a Keyfactor Command worker thread (a small internal " + + "grace margin applies) — keep the product under ~90 seconds. " + "OV/EV products never poll: CERTInext issues them asynchronously by design " + "(organization verification takes minutes and may be human-gated), so those " + "orders return pending and are completed by the next synchronization. " + "Set to 0 (or any negative value) to disable the poll entirely. " + - $"Can also be set via the {Constants.Config.PickupRetriesEnvVar} environment " + + $"Can also be set via the {Constants.Config.EnrollmentWaitAttemptsEnvVar} environment " + "variable; the env var takes precedence when both are set. Default: 5.", Hidden = false, - DefaultValue = Constants.Pickup.DefaultRetries, + DefaultValue = Constants.EnrollmentWait.DefaultAttempts, Type = "Number" }, - [Constants.Config.PickupDelaySeconds] = new PropertyConfigInfo + [Constants.Config.EnrollmentWaitIntervalSeconds] = new PropertyConfigInfo { - Comments = "OPTIONAL: Seconds between synchronous pickup polls inside Enroll() (see " + - "PickupRetries). Setting this to 0 (or any negative value) disables the " + - "pickup poll entirely — it does NOT mean back-to-back polling. " + - $"Can also be set via the {Constants.Config.PickupDelaySecondsEnvVar} environment " + + Comments = "OPTIONAL: Seconds between synchronous enrollment-wait polls inside Enroll() (see " + + "EnrollmentWaitAttempts). Setting this to 0 (or any negative value) disables the " + + "poll entirely — it does NOT mean back-to-back polling. " + + $"Can also be set via the {Constants.Config.EnrollmentWaitIntervalSecondsEnvVar} environment " + "variable; the env var takes precedence when both are set. Default: 10.", Hidden = false, - DefaultValue = Constants.Pickup.DefaultDelaySeconds, + DefaultValue = Constants.EnrollmentWait.DefaultIntervalSeconds, Type = "Number" }, [Constants.Config.DcvEnabled] = new PropertyConfigInfo @@ -715,21 +715,21 @@ public class CERTInextConfig /// Number of times Enroll() polls GetCertificate after submitting an /// order for a DV product, waiting for CERTInext to issue so the certificate can be /// returned synchronously (mirrors the legacy Sectigo connector's pickup loop). - /// PickupRetries × PickupDelaySeconds is the maximum time an enrollment call - /// can occupy a Command worker thread. Set to 0 to disable the poll entirely (the - /// certificate is then picked up on the next synchronization). Overridden by - /// CERTINEXT_PICKUP_RETRIES when set. Default: 5. + /// EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds is the maximum time an + /// enrollment call can occupy a Command worker thread. Set to 0 to disable the poll + /// entirely (the certificate is then picked up on the next synchronization). Overridden + /// by CERTINEXT_ENROLLMENT_WAIT_ATTEMPTS when set. Default: 5. ///
- [JsonPropertyName("PickupRetries")] - public int PickupRetries { get; set; } = Constants.Pickup.DefaultRetries; + [JsonPropertyName("EnrollmentWaitAttempts")] + public int EnrollmentWaitAttempts { get; set; } = Constants.EnrollmentWait.DefaultAttempts; /// - /// Seconds between synchronous pickup polls inside Enroll(). See - /// . Overridden by CERTINEXT_PICKUP_DELAY_SECONDS - /// when set. Default: 10. + /// Seconds between synchronous enrollment-wait polls inside Enroll(). See + /// . Overridden by + /// CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS when set. Default: 10. /// - [JsonPropertyName("PickupDelaySeconds")] - public int PickupDelaySeconds { get; set; } = Constants.Pickup.DefaultDelaySeconds; + [JsonPropertyName("EnrollmentWaitIntervalSeconds")] + public int EnrollmentWaitIntervalSeconds { get; set; } = Constants.EnrollmentWait.DefaultIntervalSeconds; [JsonPropertyName("PageSize")] public int PageSize { get; set; } = Constants.Api.DefaultPageSize; @@ -834,9 +834,9 @@ public class CERTInextConfig /// distinguishes knobs where 0 is a meaningful /// "disabled" value from knobs that require a positive value. /// makes negative values coerce to 0 rather - /// than being rejected — for the pickup knobs, where "-1 to disable" is a common - /// operator convention and silently re-enabling the compiled default would be the - /// opposite of the operator's intent. + /// than being rejected — for the enrollment-wait knobs, where "-1 to disable" is a + /// common operator convention and silently re-enabling the compiled default would be + /// the opposite of the operator's intent. /// A set-but-invalid env var is rejected with a Warning (SOX change management / /// SOC2 CC7.2: the override changes runtime control behavior, so silently ignoring /// it would leave the deployed value unexplained in the audit trail). @@ -894,20 +894,20 @@ public int GetEffectiveDcvWaitForIssuanceSeconds() => GetEffectiveInt(Constants.Config.DcvWaitForIssuanceSecondsEnvVar, DcvWaitForIssuanceSeconds, 60, zeroAllowed: true); /// - /// Returns the effective synchronous-pickup retry count, preferring the env var so - /// operators can tune without re-saving the connector. 0 (or any negative value) - /// disables the pickup poll. + /// Returns the effective synchronous-enrollment-wait attempt count, preferring the env + /// var so operators can tune without re-saving the connector. 0 (or any negative value) + /// disables the poll. /// - public int GetEffectivePickupRetries() => - GetEffectiveInt(Constants.Config.PickupRetriesEnvVar, PickupRetries, Constants.Pickup.DefaultRetries, + public int GetEffectiveEnrollmentWaitAttempts() => + GetEffectiveInt(Constants.Config.EnrollmentWaitAttemptsEnvVar, EnrollmentWaitAttempts, Constants.EnrollmentWait.DefaultAttempts, zeroAllowed: true, negativeMeansZero: true); /// - /// Returns the effective delay between synchronous-pickup polls, preferring the env - /// var. 0 (or any negative value) disables the pickup poll. + /// Returns the effective interval between synchronous enrollment-wait polls, preferring + /// the env var. 0 (or any negative value) disables the poll. /// - public int GetEffectivePickupDelaySeconds() => - GetEffectiveInt(Constants.Config.PickupDelaySecondsEnvVar, PickupDelaySeconds, Constants.Pickup.DefaultDelaySeconds, + public int GetEffectiveEnrollmentWaitIntervalSeconds() => + GetEffectiveInt(Constants.Config.EnrollmentWaitIntervalSecondsEnvVar, EnrollmentWaitIntervalSeconds, Constants.EnrollmentWait.DefaultIntervalSeconds, zeroAllowed: true, negativeMeansZero: true); } } diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index d68efb7..3fabf18 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -74,24 +74,24 @@ public static class Config // the TXT record / resolving the DNS provider plugin (issue 0006). Off by default. public const string DcvFollowCnameDelegation = "DcvFollowCnameDelegation"; - // Synchronous pickup poll inside Enroll() — DCV-independent, both build flavors. - // After submitting an order for a DV product, Enroll() polls GetCertificate up to - // PickupRetries times, PickupDelaySeconds apart, so fast-issuing orders return the - // issued certificate in the same enrollment call (matching the legacy Sectigo - // connector's PickUpEnrolledCertificate behavior). retries × delay = the maximum - // time an enrollment call can occupy a Command worker thread. OV/EV products skip - // the poll entirely — CERTInext issues them asynchronously by design (org - // verification, minutes to hours; support ticket #162763) and no in-call poll can - // absorb that within Command's enrollment timeout. - public const string PickupRetries = "PickupRetries"; - public const string PickupDelaySeconds = "PickupDelaySeconds"; + // Synchronous enrollment-wait poll inside Enroll() — DCV-independent, both build + // flavors. After submitting an order for a DV product, Enroll() polls GetCertificate + // up to EnrollmentWaitAttempts times, EnrollmentWaitIntervalSeconds apart, so + // fast-issuing orders return the issued certificate in the same enrollment call + // (matching the legacy Sectigo connector's PickUpEnrolledCertificate behavior). + // attempts × interval = the maximum time an enrollment call can occupy a Command + // worker thread. OV/EV products skip the poll entirely — CERTInext issues them + // asynchronously by design (org verification, minutes to hours; support ticket + // #162763) and no in-call poll can absorb that within Command's enrollment timeout. + public const string EnrollmentWaitAttempts = "EnrollmentWaitAttempts"; + public const string EnrollmentWaitIntervalSeconds = "EnrollmentWaitIntervalSeconds"; // Environment variable that overrides DcvTimeoutMinutes when set. public const string DcvTimeoutMinutesEnvVar = "CERTINEXT_DCV_TIMEOUT_MINUTES"; public const string DcvWaitForChallengeSecondsEnvVar = "CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS"; public const string DcvWaitForIssuanceSecondsEnvVar = "CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS"; - public const string PickupRetriesEnvVar = "CERTINEXT_PICKUP_RETRIES"; - public const string PickupDelaySecondsEnvVar = "CERTINEXT_PICKUP_DELAY_SECONDS"; + public const string EnrollmentWaitAttemptsEnvVar = "CERTINEXT_ENROLLMENT_WAIT_ATTEMPTS"; + public const string EnrollmentWaitIntervalSecondsEnvVar = "CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS"; // Auth mode values public const string AuthModeAccessKey = "AccessKey"; // default; authKey = SHA256(accessKey+ts+txn) @@ -286,17 +286,17 @@ public static class RevocationReasonId public const int Default = KeyCompromise; } - public static class Pickup + public static class EnrollmentWait { - // Defaults mirror the legacy Sectigo connector (5 retries × 10 s ≈ 50 s ceiling), + // Defaults mirror the legacy Sectigo connector (5 attempts × 10 s ≈ 50 s ceiling), // which is the behavior customers migrating from Sectigo expect from Enroll(). - public const int DefaultRetries = 5; - public const int DefaultDelaySeconds = 10; + public const int DefaultAttempts = 5; + public const int DefaultIntervalSeconds = 10; - // Hard ceiling on the pickup budget (retries × delay), applied regardless of - // configuration. Command abandons enrollment calls long before this; anything - // larger would only orphan a worker thread generating pointless API traffic. - // The documented guidance is to keep retries × delay under ~90 s. + // Hard ceiling on the enrollment-wait budget (attempts × interval), applied + // regardless of configuration. Command abandons enrollment calls long before this; + // anything larger would only orphan a worker thread generating pointless API traffic. + // The documented guidance is to keep attempts × interval under ~90 s. public const int MaxBudgetSeconds = 300; // How long a fetched product catalog (productCode → DV/OV/EV classification) is diff --git a/CHANGELOG.md b/CHANGELOG.md index cd698c2..4b3a098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # 1.2.0 ## Features -- feat(enroll): `Enroll()` now runs a synchronous pickup poll on every enrollment path (new, reissue, and renewal) on both build flavors — DV orders that issue within the poll budget return the issued certificate in the same call instead of waiting for the next synchronization, restoring the behavior expiration-renewal workflows relied on with the legacy Sectigo connector. Configurable via the new `PickupRetries` (default 5) and `PickupDelaySeconds` (default 10) connector settings (`retries × delay` ≈ maximum time an enrollment call occupies a Command worker thread, hard-capped at 300 s); set either to `0` (or a negative value) to disable. Transient API failures consume an attempt rather than aborting the poll. -- feat(enroll): OV/EV orders skip the pickup poll and return pending immediately with a status message explaining that CERTInext issues these products asynchronously by design (organization verification; confirmed by CERTInext support) — the certificate is imported by the next synchronization. The product's validation level is resolved from the account's product catalog (`GetProductDetails`, cached for 60 minutes), with the template product name as fallback. +- feat(enroll): `Enroll()` now runs a synchronous enrollment-wait poll on every enrollment path (new, reissue, and renewal) on both build flavors — DV orders that issue within the poll budget return the issued certificate in the same call instead of waiting for the next synchronization, restoring the behavior expiration-renewal workflows relied on with the legacy Sectigo connector. Configurable via the new `EnrollmentWaitAttempts` (default 5) and `EnrollmentWaitIntervalSeconds` (default 10) connector settings (`attempts × interval` ≈ maximum time an enrollment call occupies a Command worker thread, hard-capped at 300 s); set either to `0` (or a negative value) to disable. Transient API failures consume an attempt rather than aborting the poll. +- feat(enroll): OV/EV orders skip the enrollment-wait poll and return pending immediately with a status message explaining that CERTInext issues these products asynchronously by design (organization verification; confirmed by CERTInext support) — the certificate is imported by the next synchronization. The product's validation level is resolved from the account's product catalog (`GetProductDetails`, cached for 60 minutes), with the template product name as fallback. ## Bug Fixes - fix(build): The `-p:DcvSupport=false` (no-DCV, IAnyCAPlugin 3.2.0) flavor of `CERTInext.IntegrationTests` failed to compile — `CnameResolverLiveDnsTests.cs` references a helper defined in the DCV-only `DcvLifecycleTests.cs` and is itself a DCV feature test, so it is now excluded from the no-DCV build alongside the other DCV test files. diff --git a/README.md b/README.md index 6c72cdd..0b38759 100644 --- a/README.md +++ b/README.md @@ -143,8 +143,8 @@ CERTInext operates three separate environments. Use the sandbox environment for * **IgnoreExpired** - If true, expired certificates will be skipped during synchronization. Default: false. * **PageSize** - Number of orders to fetch per page during synchronization. Default: 100, max: 500. * **Enabled** - Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true. - * **PickupRetries** - OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product, so fast-issuing orders return the certificate synchronously in the same enrollment call. PickupRetries × PickupDelaySeconds ≈ the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 (or any negative value) to disable. Can also be set via the CERTINEXT_PICKUP_RETRIES environment variable; the env var takes precedence. Default: 5. - * **PickupDelaySeconds** - OPTIONAL: Seconds between synchronous pickup polls inside Enroll() (see PickupRetries). Setting this to 0 (or any negative value) disables the pickup poll entirely — it does NOT mean back-to-back polling. Can also be set via the CERTINEXT_PICKUP_DELAY_SECONDS environment variable; the env var takes precedence. Default: 10. + * **EnrollmentWaitAttempts** - OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product, so fast-issuing orders return the certificate synchronously in the same enrollment call. EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds ≈ the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 (or any negative value) to disable. Can also be set via the CERTINEXT_ENROLLMENT_WAIT_ATTEMPTS environment variable; the env var takes precedence. Default: 5. + * **EnrollmentWaitIntervalSeconds** - OPTIONAL: Seconds between synchronous enrollment-wait polls inside Enroll() (see EnrollmentWaitAttempts). Setting this to 0 (or any negative value) disables the poll entirely — it does NOT mean back-to-back polling. Can also be set via the CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS environment variable; the env var takes precedence. Default: 10. * **DcvEnabled** - OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) during enrollment for orders that require it, using the configured DNS provider plugin. Requires a DNS provider plugin (e.g. azure-azuredns-dnsplugin) to be deployed on the gateway. Default: false. * **DcvTxtRecordTemplate** - OPTIONAL: Format string for the DNS TXT record hostname used during DCV. {0} is replaced with the domain name being validated. Default: _emsign-validation.{0} * **DcvPropagationDelaySeconds** - OPTIONAL: Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: 30. @@ -262,8 +262,8 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | -| `PickupRetries` | Optional | Number of times `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product, so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). `PickupRetries × PickupDelaySeconds` is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` (or any negative value) to disable the poll. Can also be set via the `CERTINEXT_PICKUP_RETRIES` environment variable; the environment variable takes precedence. Default: `5`. | N/A | `5` | -| `PickupDelaySeconds` | Optional | Seconds between synchronous pickup polls inside `Enroll()` (see `PickupRetries`). Setting this to `0` (or any negative value) disables the pickup poll entirely — it does **not** mean back-to-back polling. Can also be set via the `CERTINEXT_PICKUP_DELAY_SECONDS` environment variable; the environment variable takes precedence. Default: `10`. | N/A | `10` | +| `EnrollmentWaitAttempts` | Optional | Number of times `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product, so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). `EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds` is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` (or any negative value) to disable the poll. Can also be set via the `CERTINEXT_ENROLLMENT_WAIT_ATTEMPTS` environment variable; the environment variable takes precedence. Default: `5`. | N/A | `5` | +| `EnrollmentWaitIntervalSeconds` | Optional | Seconds between synchronous enrollment-wait polls inside `Enroll()` (see `EnrollmentWaitAttempts`). Setting this to `0` (or any negative value) disables the poll entirely — it does **not** mean back-to-back polling. Can also be set via the `CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS` environment variable; the environment variable takes precedence. Default: `10`. | N/A | `10` | | `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | | `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | | `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: `30`. | N/A | `30` | @@ -484,7 +484,7 @@ sequenceDiagram alt Certificate issued immediately Plugin-->>CMD: Certificate ready — PEM returned else Pending, product is DV, and DCV does not own the wait - loop Synchronous pickup
(up to PickupRetries × PickupDelaySeconds) + loop Synchronous enrollment wait
(up to EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds) Plugin->>API: Fetch certificate API-->>Plugin: Issued, or still pending end @@ -498,9 +498,9 @@ sequenceDiagram Plugin->>Plugin: Record enrollment outcome in audit log
(order number, serial number, status) ``` -The synchronous pickup step mirrors the legacy Sectigo connector's behavior: DV orders that CERTInext issues within the poll budget are returned in the same enrollment call, so automated workflows (e.g. expiration renewal) receive the certificate without waiting for a sync cycle. The product's validation level is resolved from the account's product catalog (cached), falling back to the template product name. OV/EV orders are never polled — their organization-verification step takes minutes and may be human-gated, so the plugin returns pending immediately with a message explaining the deferral. +The synchronous enrollment-wait step mirrors the legacy Sectigo connector's behavior: DV orders that CERTInext issues within the poll budget are returned in the same enrollment call, so automated workflows (e.g. expiration renewal) receive the certificate without waiting for a sync cycle. The product's validation level is resolved from the account's product catalog (cached), falling back to the template product name. OV/EV orders are never polled — their organization-verification step takes minutes and may be human-gated, so the plugin returns pending immediately with a message explaining the deferral. -On gateways with `DcvEnabled` (DCV build flavor), pending **new/reissue** orders skip this pickup loop entirely — the in-call DCV flow owns those waits, and its post-validation issuance poll is budgeted by `DcvWaitForIssuanceSeconds` (3-second interval), not by `PickupRetries × PickupDelaySeconds`. Renewals never run in-call DCV, so the pickup loop above applies to them on every flavor, as does the recovery fetch for orders that issued but whose certificate download initially failed. +On gateways with `DcvEnabled` (DCV build flavor), pending **new/reissue** orders skip this enrollment-wait loop entirely — the in-call DCV flow owns those waits, and its post-validation issuance poll is budgeted by `DcvWaitForIssuanceSeconds` (3-second interval), not by `EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds`. Renewals never run in-call DCV, so the enrollment-wait loop above applies to them on every flavor, as does the recovery fetch for orders that issued but whose certificate download initially failed. ### Renewal diff --git a/docsource/architecture.md b/docsource/architecture.md index c7032f7..a897276 100644 --- a/docsource/architecture.md +++ b/docsource/architecture.md @@ -140,7 +140,7 @@ sequenceDiagram alt Certificate issued immediately Plugin-->>CMD: Certificate ready — PEM returned else Pending, product is DV, and DCV does not own the wait - loop Synchronous pickup
(up to PickupRetries × PickupDelaySeconds) + loop Synchronous enrollment wait
(up to EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds) Plugin->>API: Fetch certificate API-->>Plugin: Issued, or still pending end @@ -154,9 +154,9 @@ sequenceDiagram Plugin->>Plugin: Record enrollment outcome in audit log
(order number, serial number, status) ``` -The synchronous pickup step mirrors the legacy Sectigo connector's behavior: DV orders that CERTInext issues within the poll budget are returned in the same enrollment call, so automated workflows (e.g. expiration renewal) receive the certificate without waiting for a sync cycle. The product's validation level is resolved from the account's product catalog (cached), falling back to the template product name. OV/EV orders are never polled — their organization-verification step takes minutes and may be human-gated, so the plugin returns pending immediately with a message explaining the deferral. +The synchronous enrollment-wait step mirrors the legacy Sectigo connector's behavior: DV orders that CERTInext issues within the poll budget are returned in the same enrollment call, so automated workflows (e.g. expiration renewal) receive the certificate without waiting for a sync cycle. The product's validation level is resolved from the account's product catalog (cached), falling back to the template product name. OV/EV orders are never polled — their organization-verification step takes minutes and may be human-gated, so the plugin returns pending immediately with a message explaining the deferral. -On gateways with `DcvEnabled` (DCV build flavor), pending **new/reissue** orders skip this pickup loop entirely — the in-call DCV flow owns those waits, and its post-validation issuance poll is budgeted by `DcvWaitForIssuanceSeconds` (3-second interval), not by `PickupRetries × PickupDelaySeconds`. Renewals never run in-call DCV, so the pickup loop above applies to them on every flavor, as does the recovery fetch for orders that issued but whose certificate download initially failed. +On gateways with `DcvEnabled` (DCV build flavor), pending **new/reissue** orders skip this enrollment-wait loop entirely — the in-call DCV flow owns those waits, and its post-validation issuance poll is budgeted by `DcvWaitForIssuanceSeconds` (3-second interval), not by `EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds`. Renewals never run in-call DCV, so the enrollment-wait loop above applies to them on every flavor, as does the recovery fetch for orders that issued but whose certificate download initially failed. ### Renewal diff --git a/docsource/configuration.md b/docsource/configuration.md index 36bb81f..9b13f2c 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -113,8 +113,8 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | -| `PickupRetries` | Optional | Number of times `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product, so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). `PickupRetries × PickupDelaySeconds` is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` (or any negative value) to disable the poll. Can also be set via the `CERTINEXT_PICKUP_RETRIES` environment variable; the environment variable takes precedence. Default: `5`. | N/A | `5` | -| `PickupDelaySeconds` | Optional | Seconds between synchronous pickup polls inside `Enroll()` (see `PickupRetries`). Setting this to `0` (or any negative value) disables the pickup poll entirely — it does **not** mean back-to-back polling. Can also be set via the `CERTINEXT_PICKUP_DELAY_SECONDS` environment variable; the environment variable takes precedence. Default: `10`. | N/A | `10` | +| `EnrollmentWaitAttempts` | Optional | Number of times `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product, so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). `EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds` is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` (or any negative value) to disable the poll. Can also be set via the `CERTINEXT_ENROLLMENT_WAIT_ATTEMPTS` environment variable; the environment variable takes precedence. Default: `5`. | N/A | `5` | +| `EnrollmentWaitIntervalSeconds` | Optional | Seconds between synchronous enrollment-wait polls inside `Enroll()` (see `EnrollmentWaitAttempts`). Setting this to `0` (or any negative value) disables the poll entirely — it does **not** mean back-to-back polling. Can also be set via the `CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS` environment variable; the environment variable takes precedence. Default: `10`. | N/A | `10` | | `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | | `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | | `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: `30`. | N/A | `30` | @@ -250,12 +250,12 @@ CERTInext orders pass through several internal status stages before a certificat - **Pending approval** (status 2, 8, 15, 24) → enrollment returns a pending status to Command. If `AutoApprove` is enabled on the template, the plugin attempts automatic approval before returning. - **Rejected / cancelled** (status 4, 5, 13, 14) → enrollment fails with an error. -Before returning a pending result, `Enroll()` runs a **synchronous pickup poll** gated by the product's validation level: +Before returning a pending result, `Enroll()` runs a **synchronous enrollment-wait poll** gated by the product's validation level: -- **DV products** — the plugin polls `GetCertificate` up to `PickupRetries` times, `PickupDelaySeconds` apart (default 5 × 10 s ≈ 50 s). If CERTInext issues within that budget, the enrollment call returns the issued certificate directly — no waiting for the next sync. If the budget elapses, the pending result is returned unchanged and sync completes the order later. +- **DV products** — the plugin polls `GetCertificate` up to `EnrollmentWaitAttempts` times, `EnrollmentWaitIntervalSeconds` apart (default 5 × 10 s ≈ 50 s). If CERTInext issues within that budget, the enrollment call returns the issued certificate directly — no waiting for the next sync. If the budget elapses, the pending result is returned unchanged and sync completes the order later. - **OV/EV products** — the poll is skipped entirely. CERTInext issues OV/EV asynchronously by design: the mandatory organization-verification step takes minutes and may require human review, so no in-call wait can succeed within Command's enrollment timeout (confirmed by CERTInext support, ticket #162763). The pending result carries a status message explaining this; the certificate is imported automatically by the next synchronization. -The validation level (DV/OV/EV) is resolved from the account's product catalog (`GetProductDetails`, cached for 60 minutes — never fetched per-enrollment), falling back to the DV/OV/EV token in the template's product name when the catalog is unavailable. Products whose level cannot be determined are polled optimistically. When `DcvEnabled` is `true`, pending **new/reissue** orders skip the pickup poll — the in-call DCV flow owns those waits — but renewals (which never run in-call DCV) and issued-orders-awaiting-PEM-download remain eligible. +The validation level (DV/OV/EV) is resolved from the account's product catalog (`GetProductDetails`, cached for 60 minutes — never fetched per-enrollment), falling back to the DV/OV/EV token in the template's product name when the catalog is unavailable. Products whose level cannot be determined are polled optimistically. When `DcvEnabled` is `true`, pending **new/reissue** orders skip the enrollment-wait poll — the in-call DCV flow owns those waits — but renewals (which never run in-call DCV) and issued-orders-awaiting-PEM-download remain eligible. The gateway polls the `TrackOrder` endpoint during sync to pick up certificates that were approved after the initial enrollment call. diff --git a/docsource/overview.md b/docsource/overview.md index ec042ad..b5269b2 100644 --- a/docsource/overview.md +++ b/docsource/overview.md @@ -56,13 +56,13 @@ Enrollment completes successfully but the cert is not yet issued — Command sho This is the expected return shape on three paths: -1. **The product is OV or EV.** CERTInext issues OV/EV certificates asynchronously by design — the mandatory organization-verification step takes minutes and may require human review, and there is no CA-side setting that makes these products return in a single call (confirmed by CERTInext support). The plugin deliberately skips its synchronous pickup poll for OV/EV and returns pending immediately with a status message explaining this. -2. **The product is DV but issuance outran the pickup budget.** `Enroll()` polls for the issued certificate up to `PickupRetries × PickupDelaySeconds` (default ≈ 50 s) before returning pending. +1. **The product is OV or EV.** CERTInext issues OV/EV certificates asynchronously by design — the mandatory organization-verification step takes minutes and may require human review, and there is no CA-side setting that makes these products return in a single call (confirmed by CERTInext support). The plugin deliberately skips its synchronous enrollment-wait poll for OV/EV and returns pending immediately with a status message explaining this. +2. **The product is DV but issuance outran the enrollment-wait budget.** `Enroll()` polls for the issued certificate up to `EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds` (default ≈ 50 s) before returning pending. 3. **DCV builds only:** the DCV-specific `Enroll()` budget (`DcvWaitForChallengeSeconds` + `DcvWaitForIssuanceSeconds`, defaults 60s each) elapsed before CERTInext finished asynchronous issuance, or the plugin was loaded on an older gateway host (pre-IAnyCAPlugin v3.3) that does not inject `IDomainValidatorFactory`, so DCV could not run in-call. **Mitigation** -The next gateway sync cycle will pick the cert up and transition it to `GENERATED`. For OV/EV this is the designed flow — no tuning changes it. For DV, raise `PickupRetries`/`PickupDelaySeconds` if your orders reliably issue just past the default budget (keep the product under ~90 s — it holds a Command worker thread). The plugin's sync-driven DCV retry is single-shot per record, so even with hundreds of pending orders the sync completes in seconds, not minutes — see [configuration.md](configuration.md) for the `PickupRetries`/`PickupDelaySeconds` and `DcvWaitForChallengeSeconds`/`DcvWaitForIssuanceSeconds` knobs. +The next gateway sync cycle will pick the cert up and transition it to `GENERATED`. For OV/EV this is the designed flow — no tuning changes it. For DV, raise `EnrollmentWaitAttempts`/`EnrollmentWaitIntervalSeconds` if your orders reliably issue just past the default budget (keep the product under ~90 s — it holds a Command worker thread). The plugin's sync-driven DCV retry is single-shot per record, so even with hundreds of pending orders the sync completes in seconds, not minutes — see [configuration.md](configuration.md) for the `EnrollmentWaitAttempts`/`EnrollmentWaitIntervalSeconds` and `DcvWaitForChallengeSeconds`/`DcvWaitForIssuanceSeconds` knobs. ### `EMS-956 "Invalid Request for this API"` from `GetDcv` diff --git a/integration-manifest.json b/integration-manifest.json index 8d63e91..b579261 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -142,12 +142,12 @@ "description": "Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true." }, { - "name": "PickupRetries", - "description": "OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product, so fast-issuing orders return the certificate synchronously in the same enrollment call. PickupRetries \u00d7 PickupDelaySeconds \u2248 the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) \u2014 keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 (or any negative value) to disable. Can also be set via the CERTINEXT_PICKUP_RETRIES environment variable; the env var takes precedence. Default: 5." + "name": "EnrollmentWaitAttempts", + "description": "OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product, so fast-issuing orders return the certificate synchronously in the same enrollment call. EnrollmentWaitAttempts \u00d7 EnrollmentWaitIntervalSeconds \u2248 the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) \u2014 keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 (or any negative value) to disable. Can also be set via the CERTINEXT_ENROLLMENT_WAIT_ATTEMPTS environment variable; the env var takes precedence. Default: 5." }, { - "name": "PickupDelaySeconds", - "description": "OPTIONAL: Seconds between synchronous pickup polls inside Enroll() (see PickupRetries). Setting this to 0 (or any negative value) disables the pickup poll entirely \u2014 it does NOT mean back-to-back polling. Can also be set via the CERTINEXT_PICKUP_DELAY_SECONDS environment variable; the env var takes precedence. Default: 10." + "name": "EnrollmentWaitIntervalSeconds", + "description": "OPTIONAL: Seconds between synchronous enrollment-wait polls inside Enroll() (see EnrollmentWaitAttempts). Setting this to 0 (or any negative value) disables the poll entirely \u2014 it does NOT mean back-to-back polling. Can also be set via the CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS environment variable; the env var takes precedence. Default: 10." }, { "name": "DcvEnabled", From 4ad4b5a805a10963f43c7fca14c7158162ef4ab6 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:40:13 -0700 Subject: [PATCH 06/17] refactor(enroll): collapse enrollment-wait to a single EnrollmentWaitSeconds knob Replace EnrollmentWaitAttempts/EnrollmentWaitIntervalSeconds with one EnrollmentWaitSeconds total-budget setting; the poll interval is now a fixed, shared 5s constant (Constants.Polling.CertificatePollIntervalSeconds) used by both this feature and the post-DCV issuance wait, which previously hardcoded a different 3s interval. Removes the attempts*interval overflow-guard math entirely since there's no multiplication left. Default (50s) preserves the prior default max-poll-count exactly. --- CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 4 +- .../CERTInextCAPluginEnrollmentWaitTests.cs | 43 +++++----- CERTInext.Tests/CERTInextCAPluginTests.cs | 4 +- CERTInext/CERTInextCAPlugin.cs | 66 ++++++++------- CERTInext/CERTInextCAPluginConfig.cs | 80 +++++++------------ CERTInext/Constants.cs | 50 +++++++----- CHANGELOG.md | 2 +- README.md | 10 +-- docsource/architecture.md | 4 +- docsource/configuration.md | 5 +- docsource/overview.md | 4 +- integration-manifest.json | 8 +- 12 files changed, 127 insertions(+), 153 deletions(-) diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index 2a5fe01..7fde012 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -50,8 +50,8 @@ private static CERTInextConfig DcvConfig( // This suite tests DCV behavior, not the synchronous enrollment wait (which // has its own suite, including the DCV interaction cases). Disable it so // tests with DcvEnabled=false and pending orders don't spend the default - // 5×10 s poll budget retrying strict mocks. - EnrollmentWaitAttempts = 0 + // 50s poll budget retrying strict mocks. + EnrollmentWaitSeconds = 0 }; private static Mock NewMock() => diff --git a/CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs b/CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs index 024a82d..87597f5 100644 --- a/CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs @@ -40,15 +40,14 @@ public class CERTInextCAPluginEnrollmentWaitTests private static Mock NewMock() => new Mock(MockBehavior.Strict); /// - /// Config with a 1-second poll interval so tests that complete the poll run fast. - /// The default retry count is deliberately generous: the poll loop runs against the - /// real clock, so a small budget makes tests that expect the poll to *complete* - /// flaky under CI load (a slow first poll can exhaust the budget before the second, - /// issuing, poll). Tests that specifically exercise budget exhaustion pass a small - /// explicit retry count instead. + /// Config with a fixed 5-second poll interval (Constants.Polling.CertificatePollIntervalSeconds, + /// no longer configurable). The default budget mirrors the plugin's production default + /// (50s ⇒ 10 max polls) so tests that need a few polls to resolve have headroom without + /// hitting exhaustion. Tests that specifically exercise budget exhaustion pass a small + /// explicit totalSeconds instead, sized to the fixed 5s interval — e.g. 10s ⇒ exactly 2 polls. /// - private static CERTInextConfig EnrollmentWaitConfig(int attempts = 10, int delaySeconds = 1) => - new CERTInextConfig { EnrollmentWaitAttempts = attempts, EnrollmentWaitIntervalSeconds = delaySeconds }; + private static CERTInextConfig EnrollmentWaitConfig(int totalSeconds = 50) => + new CERTInextConfig { EnrollmentWaitSeconds = totalSeconds }; private static List SslCatalog() => new List { @@ -254,20 +253,20 @@ public async Task EnrollmentWait_SoftFallsBackToPending_WhenBudgetExhausted() mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.CertId2)); - var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: 2, delaySeconds: 1)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(10)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, "exhausting the enrollment-wait budget must degrade to the pending result, never throw"); result.StatusMessage.Should().Contain("later synchronization"); - // EnrollmentWaitAttempts=2 yields exactly 2 polls. The poll count is now capped deterministically - // (maxPolls = budget / interval) rather than emerging from wall-clock arithmetic, so this - // is an exact assertion — no real-clock tolerance needed. This is the off-by-one guard: - // the old bug yielded attempts + 1 = 3. + // A 10s budget over the fixed 5s interval yields exactly 2 polls. The poll count is + // capped deterministically (maxPolls = budget / interval) rather than emerging from + // wall-clock arithmetic, so this is an exact assertion — no real-clock tolerance + // needed. This is the off-by-one guard: the old bug yielded one extra poll. mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Exactly(2), - "EnrollmentWaitAttempts=2 must yield exactly two polls"); + "a 10s budget over the fixed 5s interval must yield exactly two polls"); } [Fact] @@ -295,11 +294,11 @@ public async Task EnrollmentWait_SurvivesTransientFailure_AndReturnsIssuedOnRetr public async Task EnrollmentWait_Disabled_WhenRetriesNegative() { // "-1 to disable" is a common operator convention — it must not silently - // fall back to the enabled default of 5. + // fall back to the enabled default of 50s. var mock = NewMock(); SetupPendingEnroll(mock); - var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: -1)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(-1)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); @@ -319,7 +318,7 @@ public async Task EnrollmentWait_SoftFallsBackToPending_WhenGetCertificateThrows .ThrowsAsync(new Exception("CERTInext API 500")); // Small explicit budget: every poll throws, so this test runs to exhaustion. - var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: 2)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(10)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); @@ -361,7 +360,7 @@ public async Task EnrollmentWait_Disabled_WhenRetriesZero() var mock = NewMock(); SetupPendingEnroll(mock); - var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: 0)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(0)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); @@ -454,7 +453,7 @@ public async Task EnrollmentWait_SoftFallsBackToPending_WhenGeneratedBodyNeverAr Id = MockCertificateData.CertId2, Status = "issued", Certificate = null }); - var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: 3, delaySeconds: 1)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(15)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); @@ -487,7 +486,7 @@ public async Task EnrollmentWait_SoftFallsBackToPending_WhenEnrollIssuedWithoutP Id = MockCertificateData.CertId2, Status = "issued", Certificate = null }); - var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: 3, delaySeconds: 1)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(15)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); @@ -500,7 +499,7 @@ public async Task EnrollmentWait_SoftFallsBackToPending_WhenEnrollIssuedWithoutP [Fact] public async Task EnrollmentWait_Disabled_DowngradesIssuedWithoutPem_ToPending() { - // Enrollment wait disabled (EnrollmentWaitAttempts=0) short-circuits before any poll. If the enroll + // Enrollment wait disabled (EnrollmentWaitSeconds=0) short-circuits before any poll. If the enroll // response is issued-without-PEM, returning it verbatim would hand Command a bodyless // GENERATED. The disabled path must still enforce the no-bodyless-GENERATED invariant // and degrade to pending so a later sync imports the certificate. @@ -511,7 +510,7 @@ public async Task EnrollmentWait_Disabled_DowngradesIssuedWithoutPem_ToPending() It.IsAny(), It.IsAny())) .ReturnsAsync(issuedNoPem); - var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(attempts: 0)); + var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(0)); var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode)); diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs index 97d1eeb..9d295f6 100644 --- a/CERTInext.Tests/CERTInextCAPluginTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginTests.cs @@ -334,8 +334,8 @@ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval() // Enrollment wait disabled: this test verifies the pending-status mapping, not // the synchronous enrollment wait (which has its own suite) — with the default - // 5×10 s budget the poll would otherwise spend ~50 s retrying the strict mock. - var plugin = new CERTInextCAPlugin(mock.Object, new CERTInextConfig { EnrollmentWaitAttempts = 0 }); + // 50s budget the poll would otherwise spend that long retrying the strict mock. + var plugin = new CERTInextCAPlugin(mock.Object, new CERTInextConfig { EnrollmentWaitSeconds = 0 }); var result = await plugin.Enroll( csr: MockCertificateData.FakeCsrPem, diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index cfa7985..081f30b 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1182,13 +1182,15 @@ private async Task EnrollNewAsync( // but the cert PEM isn't immediately available. Without this poll, Enroll // returns a pending result and the cert is picked up on the next sync cycle, // which is undesirable when the whole thing completes in under a minute. - // Fixed 3-second poll interval: the post-DCV issuance step typically - // completes within 5–15s, so a slower cadence would push typical-case - // latency toward the budget ceiling. Decoupled from - // DcvPropagationDelaySeconds (a DNS concern) so admins tuning DNS + // Fixed poll interval (Constants.Polling.CertificatePollIntervalSeconds, + // shared with the synchronous enrollment-wait poll): the post-DCV + // issuance step typically completes within 5–15s, so a slower cadence + // would push typical-case latency toward the budget ceiling. Decoupled + // from DcvPropagationDelaySeconds (a DNS concern) so admins tuning DNS // settings don't accidentally make this polling chunky. var postDcv = await WaitForIssuanceAsync( - orderNumber, _config.GetEffectiveDcvWaitForIssuanceSeconds(), 3, "PostDcv", dcvCts.Token); + orderNumber, _config.GetEffectiveDcvWaitForIssuanceSeconds(), + Constants.Polling.CertificatePollIntervalSeconds, "PostDcv", dcvCts.Token); // Only a genuine terminal outcome ends the enroll call here. A GENERATED // result without a PEM (a transient download failure during the wait) // must fall through to the pending path so a later sync refetches the @@ -1589,36 +1591,31 @@ EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) return pendingResult; } - int attempts = _config.GetEffectiveEnrollmentWaitAttempts(); - int delaySeconds = _config.GetEffectiveEnrollmentWaitIntervalSeconds(); - if (attempts <= 0 || delaySeconds <= 0) + int configuredBudgetSeconds = _config.GetEffectiveEnrollmentWaitSeconds(); + if (configuredBudgetSeconds <= 0) { - // SOC2 CC7.2 / SOX change management: the effective values may come from env - // vars rather than the connector record, so this Information line is the only + // SOC2 CC7.2 / SOX change management: the effective value may come from an env + // var rather than the connector record, so this Information line is the only // production-log evidence distinguishing "enrollment wait disabled by operator" // from "enrollment wait never attempted". _logger.LogInformation( - "Synchronous enrollment wait disabled by configuration (effective EnrollmentWaitAttempts={Attempts}, " + - "EnrollmentWaitIntervalSeconds={Delay}). Order {OrderNumber} will be picked up on the next sync cycle.", - attempts, delaySeconds, orderNumber); + "Synchronous enrollment wait disabled by configuration (effective EnrollmentWaitSeconds={Budget}). " + + "Order {OrderNumber} will be picked up on the next sync cycle.", + configuredBudgetSeconds, orderNumber); return DegradeBodylessIssuedToPending(pendingResult); } - // Compute the budget in long first: both knobs accept arbitrary non-negative ints - // from env vars, and an int overflow here would go negative and make the CTS - // constructor throw (silently disabling the enrollment wait via the catch below). // Clamp to the hard ceiling — Command abandons enrollment calls long before it, so a - // larger budget would only orphan a worker thread (docs: keep attempts × interval + // larger budget would only orphan a worker thread (docs: keep EnrollmentWaitSeconds // under ~90 s). - long configuredBudgetSeconds = (long)attempts * delaySeconds; - int budgetSeconds = (int)Math.Min(configuredBudgetSeconds, Constants.EnrollmentWait.MaxBudgetSeconds); + int budgetSeconds = Math.Min(configuredBudgetSeconds, Constants.EnrollmentWait.MaxBudgetSeconds); if (configuredBudgetSeconds > Constants.EnrollmentWait.MaxBudgetSeconds) { // SOX CC7.3: the clamp is a policy decision — evidence it. _logger.LogWarning( - "Configured enrollment-wait budget ({Configured}s = EnrollmentWaitAttempts {Attempts} × EnrollmentWaitIntervalSeconds {Delay}) " + - "exceeds the hard ceiling; clamped to {Max}s for order {OrderNumber}.", - configuredBudgetSeconds, attempts, delaySeconds, Constants.EnrollmentWait.MaxBudgetSeconds, orderNumber); + "Configured enrollment-wait budget ({Configured}s = EnrollmentWaitSeconds) exceeds the " + + "hard ceiling; clamped to {Max}s for order {OrderNumber}.", + configuredBudgetSeconds, Constants.EnrollmentWait.MaxBudgetSeconds, orderNumber); } try @@ -1626,7 +1623,7 @@ EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) // One ceiling bounds the ENTIRE enrollment wait — catalog classification // included — so a hung catalog endpoint cannot hold a Command worker thread // beyond the configured budget (+ grace for one in-flight request). This is what - // keeps the documented "attempts × interval = max Command-occupied time" honest. + // keeps the documented "EnrollmentWaitSeconds = max Command-occupied time" honest. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(budgetSeconds + 30)); var validationType = ProductValidationType.Unknown; @@ -1663,10 +1660,10 @@ EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) _logger.LogInformation( "Synchronous enrollment-wait poll started. OrderNumber={OrderNumber}, ProductCode={ProductCode}, " + - "ValidationType={ValidationType}, Attempts={Attempts}, DelaySeconds={Delay}, BudgetSeconds={Budget}", - orderNumber, productCode, validationType, attempts, delaySeconds, budgetSeconds); + "ValidationType={ValidationType}, BudgetSeconds={Budget}", + orderNumber, productCode, validationType, budgetSeconds); - var final = await WaitForIssuanceAsync(orderNumber, budgetSeconds, delaySeconds, "EnrollmentWait", cts.Token); + var final = await WaitForIssuanceAsync(orderNumber, budgetSeconds, Constants.Polling.CertificatePollIntervalSeconds, "EnrollmentWait", cts.Token); // A GENERATED result is only a completed enrollment wait once the PEM is present. // WaitForIssuanceAsync keeps polling a body-less GENERATED, but the budget can @@ -2270,10 +2267,10 @@ private async Task PerformDcvIfNeededAsync( /// time after the triggering call returns. Without this poll the plugin would catch /// the cert in pending state and return it that way, forcing the gateway to wait for /// the next sync cycle. Used by both the post-DCV wait (budget = - /// DcvWaitForIssuanceSeconds, 3 s interval) and the general synchronous - /// enrollment wait in (budget = - /// EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds, - /// EnrollmentWaitIntervalSeconds interval). + /// DcvWaitForIssuanceSeconds) and the general synchronous enrollment wait in + /// (budget = + /// EnrollmentWaitSeconds). Both poll every + /// seconds. ///
private async Task WaitForIssuanceAsync( string orderNumber, int waitBudgetSeconds, int pollIntervalSeconds, string phase, CancellationToken ct) @@ -2293,10 +2290,11 @@ private async Task WaitForIssuanceAsync( phase, orderNumber); return null; } - // Clamp the interval into [1, budget] so a pathological EnrollmentWaitIntervalSeconds - // override (e.g. from CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS) can never make - // Task.Delay outlast the budget. Correctness here no longer depends on the deadline - // check happening to run before the sleep — the wait is bounded by construction. + // Clamp the interval into [1, budget] so a budget smaller than the fixed poll + // interval (e.g. EnrollmentWaitSeconds configured under + // Constants.Polling.CertificatePollIntervalSeconds) can never make Task.Delay + // outlast the budget. Correctness here no longer depends on the deadline check + // happening to run before the sleep — the wait is bounded by construction. pollIntervalSeconds = Math.Min(Math.Max(1, pollIntervalSeconds), Math.Max(1, waitBudgetSeconds)); // Deterministic upper bound on the poll count. The documented "retries × delay ⇒ diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index 07a924d..680a8a8 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -275,33 +275,23 @@ public static Dictionary GetCAConnectorAnnotations() DefaultValue = true, Type = "Boolean" }, - [Constants.Config.EnrollmentWaitAttempts] = new PropertyConfigInfo - { - Comments = "OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate " + - "after submitting an order for a DV product, so fast-issuing orders return the " + - "certificate synchronously in the same enrollment call. " + - "EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds ≈ the maximum time an " + - "enrollment call can occupy a Keyfactor Command worker thread (a small internal " + - "grace margin applies) — keep the product under ~90 seconds. " + + [Constants.Config.EnrollmentWaitSeconds] = new PropertyConfigInfo + { + Comments = "OPTIONAL: Total seconds Enroll() polls CERTInext for the issued certificate " + + "after submitting an order for a DV product (polled every " + + $"{Constants.Polling.CertificatePollIntervalSeconds} seconds), so fast-issuing " + + "orders return the certificate synchronously in the same enrollment call. " + + "This is approximately the maximum time an enrollment call can occupy a " + + "Keyfactor Command worker thread (a small internal grace margin applies) — " + + "keep it under ~90 seconds. " + "OV/EV products never poll: CERTInext issues them asynchronously by design " + "(organization verification takes minutes and may be human-gated), so those " + "orders return pending and are completed by the next synchronization. " + "Set to 0 (or any negative value) to disable the poll entirely. " + - $"Can also be set via the {Constants.Config.EnrollmentWaitAttemptsEnvVar} environment " + - "variable; the env var takes precedence when both are set. Default: 5.", + $"Can also be set via the {Constants.Config.EnrollmentWaitSecondsEnvVar} environment " + + "variable; the env var takes precedence when both are set. Default: 50.", Hidden = false, - DefaultValue = Constants.EnrollmentWait.DefaultAttempts, - Type = "Number" - }, - [Constants.Config.EnrollmentWaitIntervalSeconds] = new PropertyConfigInfo - { - Comments = "OPTIONAL: Seconds between synchronous enrollment-wait polls inside Enroll() (see " + - "EnrollmentWaitAttempts). Setting this to 0 (or any negative value) disables the " + - "poll entirely — it does NOT mean back-to-back polling. " + - $"Can also be set via the {Constants.Config.EnrollmentWaitIntervalSecondsEnvVar} environment " + - "variable; the env var takes precedence when both are set. Default: 10.", - Hidden = false, - DefaultValue = Constants.EnrollmentWait.DefaultIntervalSeconds, + DefaultValue = Constants.EnrollmentWait.DefaultSeconds, Type = "Number" }, [Constants.Config.DcvEnabled] = new PropertyConfigInfo @@ -712,24 +702,16 @@ public class CERTInextConfig public bool IgnoreExpired { get; set; } = false; /// - /// Number of times Enroll() polls GetCertificate after submitting an - /// order for a DV product, waiting for CERTInext to issue so the certificate can be - /// returned synchronously (mirrors the legacy Sectigo connector's pickup loop). - /// EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds is the maximum time an - /// enrollment call can occupy a Command worker thread. Set to 0 to disable the poll - /// entirely (the certificate is then picked up on the next synchronization). Overridden - /// by CERTINEXT_ENROLLMENT_WAIT_ATTEMPTS when set. Default: 5. - /// - [JsonPropertyName("EnrollmentWaitAttempts")] - public int EnrollmentWaitAttempts { get; set; } = Constants.EnrollmentWait.DefaultAttempts; - - /// - /// Seconds between synchronous enrollment-wait polls inside Enroll(). See - /// . Overridden by - /// CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS when set. Default: 10. + /// Total seconds Enroll() polls GetCertificate after submitting an order + /// for a DV product, waiting for CERTInext to issue so the certificate can be returned + /// synchronously (mirrors the legacy Sectigo connector's pickup loop). Polled every + /// seconds. Set to 0 to + /// disable the poll entirely (the certificate is then picked up on the next + /// synchronization). Overridden by CERTINEXT_ENROLLMENT_WAIT_SECONDS when set. + /// Default: 50. /// - [JsonPropertyName("EnrollmentWaitIntervalSeconds")] - public int EnrollmentWaitIntervalSeconds { get; set; } = Constants.EnrollmentWait.DefaultIntervalSeconds; + [JsonPropertyName("EnrollmentWaitSeconds")] + public int EnrollmentWaitSeconds { get; set; } = Constants.EnrollmentWait.DefaultSeconds; [JsonPropertyName("PageSize")] public int PageSize { get; set; } = Constants.Api.DefaultPageSize; @@ -834,7 +816,7 @@ public class CERTInextConfig /// distinguishes knobs where 0 is a meaningful /// "disabled" value from knobs that require a positive value. /// makes negative values coerce to 0 rather - /// than being rejected — for the enrollment-wait knobs, where "-1 to disable" is a + /// than being rejected — for the enrollment-wait knob, where "-1 to disable" is a /// common operator convention and silently re-enabling the compiled default would be /// the opposite of the operator's intent. /// A set-but-invalid env var is rejected with a Warning (SOX change management / @@ -894,20 +876,12 @@ public int GetEffectiveDcvWaitForIssuanceSeconds() => GetEffectiveInt(Constants.Config.DcvWaitForIssuanceSecondsEnvVar, DcvWaitForIssuanceSeconds, 60, zeroAllowed: true); /// - /// Returns the effective synchronous-enrollment-wait attempt count, preferring the env - /// var so operators can tune without re-saving the connector. 0 (or any negative value) - /// disables the poll. - /// - public int GetEffectiveEnrollmentWaitAttempts() => - GetEffectiveInt(Constants.Config.EnrollmentWaitAttemptsEnvVar, EnrollmentWaitAttempts, Constants.EnrollmentWait.DefaultAttempts, - zeroAllowed: true, negativeMeansZero: true); - - /// - /// Returns the effective interval between synchronous enrollment-wait polls, preferring - /// the env var. 0 (or any negative value) disables the poll. + /// Returns the effective synchronous-enrollment-wait total budget in seconds, + /// preferring the env var so operators can tune without re-saving the connector. + /// 0 (or any negative value) disables the poll. /// - public int GetEffectiveEnrollmentWaitIntervalSeconds() => - GetEffectiveInt(Constants.Config.EnrollmentWaitIntervalSecondsEnvVar, EnrollmentWaitIntervalSeconds, Constants.EnrollmentWait.DefaultIntervalSeconds, + public int GetEffectiveEnrollmentWaitSeconds() => + GetEffectiveInt(Constants.Config.EnrollmentWaitSecondsEnvVar, EnrollmentWaitSeconds, Constants.EnrollmentWait.DefaultSeconds, zeroAllowed: true, negativeMeansZero: true); } } diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index 3fabf18..c3a2613 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -76,22 +76,21 @@ public static class Config // Synchronous enrollment-wait poll inside Enroll() — DCV-independent, both build // flavors. After submitting an order for a DV product, Enroll() polls GetCertificate - // up to EnrollmentWaitAttempts times, EnrollmentWaitIntervalSeconds apart, so - // fast-issuing orders return the issued certificate in the same enrollment call - // (matching the legacy Sectigo connector's PickUpEnrolledCertificate behavior). - // attempts × interval = the maximum time an enrollment call can occupy a Command - // worker thread. OV/EV products skip the poll entirely — CERTInext issues them - // asynchronously by design (org verification, minutes to hours; support ticket - // #162763) and no in-call poll can absorb that within Command's enrollment timeout. - public const string EnrollmentWaitAttempts = "EnrollmentWaitAttempts"; - public const string EnrollmentWaitIntervalSeconds = "EnrollmentWaitIntervalSeconds"; + // every Constants.Polling.CertificatePollIntervalSeconds, for up to + // EnrollmentWaitSeconds total, so fast-issuing orders return the issued certificate + // in the same enrollment call (matching the legacy Sectigo connector's + // PickUpEnrolledCertificate behavior). EnrollmentWaitSeconds is the maximum time an + // enrollment call can occupy a Command worker thread. OV/EV products skip the poll + // entirely — CERTInext issues them asynchronously by design (org verification, + // minutes to hours; support ticket #162763) and no in-call poll can absorb that + // within Command's enrollment timeout. + public const string EnrollmentWaitSeconds = "EnrollmentWaitSeconds"; // Environment variable that overrides DcvTimeoutMinutes when set. public const string DcvTimeoutMinutesEnvVar = "CERTINEXT_DCV_TIMEOUT_MINUTES"; public const string DcvWaitForChallengeSecondsEnvVar = "CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS"; public const string DcvWaitForIssuanceSecondsEnvVar = "CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS"; - public const string EnrollmentWaitAttemptsEnvVar = "CERTINEXT_ENROLLMENT_WAIT_ATTEMPTS"; - public const string EnrollmentWaitIntervalSecondsEnvVar = "CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS"; + public const string EnrollmentWaitSecondsEnvVar = "CERTINEXT_ENROLLMENT_WAIT_SECONDS"; // Auth mode values public const string AuthModeAccessKey = "AccessKey"; // default; authKey = SHA256(accessKey+ts+txn) @@ -288,15 +287,17 @@ public static class RevocationReasonId public static class EnrollmentWait { - // Defaults mirror the legacy Sectigo connector (5 attempts × 10 s ≈ 50 s ceiling), - // which is the behavior customers migrating from Sectigo expect from Enroll(). - public const int DefaultAttempts = 5; - public const int DefaultIntervalSeconds = 10; - - // Hard ceiling on the enrollment-wait budget (attempts × interval), applied - // regardless of configuration. Command abandons enrollment calls long before this; - // anything larger would only orphan a worker thread generating pointless API traffic. - // The documented guidance is to keep attempts × interval under ~90 s. + // Default mirrors the legacy Sectigo connector's ~50 s pickup ceiling, which is the + // behavior customers migrating from Sectigo expect from Enroll(). At the fixed + // Polling.CertificatePollIntervalSeconds (5 s), this yields the same 10-poll ceiling + // as before this knob collapsed from attempts × interval to a single total — do not + // change one without checking the other if preserving that parity still matters. + public const int DefaultSeconds = 50; + + // Hard ceiling on the enrollment-wait budget, applied regardless of configuration. + // Command abandons enrollment calls long before this; anything larger would only + // orphan a worker thread generating pointless API traffic. The documented guidance + // is to keep EnrollmentWaitSeconds under ~90 s. public const int MaxBudgetSeconds = 300; // How long a fetched product catalog (productCode → DV/OV/EV classification) is @@ -310,6 +311,15 @@ public static class EnrollmentWait public const int FailureBackoffMinutes = 5; } + public static class Polling + { + // Shared poll interval for both in-call issuance waits: the synchronous + // enrollment-wait poll (TryEnrollmentWaitForCertificateAsync) and the post-DCV + // issuance poll (WaitForIssuanceAsync "PostDcv" phase). Not customer-configurable — + // only the total wait budget is (EnrollmentWaitSeconds / DcvWaitForIssuanceSeconds). + public const int CertificatePollIntervalSeconds = 5; + } + public static class Dcv { // CERTInext dcvMethod values (dcvDetails.dcvMethod in GetDcv / VerifyDcv) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b3a098..a009d96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # 1.2.0 ## Features -- feat(enroll): `Enroll()` now runs a synchronous enrollment-wait poll on every enrollment path (new, reissue, and renewal) on both build flavors — DV orders that issue within the poll budget return the issued certificate in the same call instead of waiting for the next synchronization, restoring the behavior expiration-renewal workflows relied on with the legacy Sectigo connector. Configurable via the new `EnrollmentWaitAttempts` (default 5) and `EnrollmentWaitIntervalSeconds` (default 10) connector settings (`attempts × interval` ≈ maximum time an enrollment call occupies a Command worker thread, hard-capped at 300 s); set either to `0` (or a negative value) to disable. Transient API failures consume an attempt rather than aborting the poll. +- feat(enroll): `Enroll()` now runs a synchronous enrollment-wait poll on every enrollment path (new, reissue, and renewal) on both build flavors — DV orders that issue within the poll budget return the issued certificate in the same call instead of waiting for the next synchronization, restoring the behavior expiration-renewal workflows relied on with the legacy Sectigo connector. Configurable via the new `EnrollmentWaitSeconds` connector setting (default 50, polled every 5 seconds, ≈ maximum time an enrollment call occupies a Command worker thread, hard-capped at 300 s); set to `0` (or a negative value) to disable. Transient API failures consume a poll rather than aborting the wait. The post-DCV issuance poll's interval was also aligned to the same 5-second cadence. - feat(enroll): OV/EV orders skip the enrollment-wait poll and return pending immediately with a status message explaining that CERTInext issues these products asynchronously by design (organization verification; confirmed by CERTInext support) — the certificate is imported by the next synchronization. The product's validation level is resolved from the account's product catalog (`GetProductDetails`, cached for 60 minutes), with the template product name as fallback. ## Bug Fixes diff --git a/README.md b/README.md index 0b38759..711faec 100644 --- a/README.md +++ b/README.md @@ -143,8 +143,7 @@ CERTInext operates three separate environments. Use the sandbox environment for * **IgnoreExpired** - If true, expired certificates will be skipped during synchronization. Default: false. * **PageSize** - Number of orders to fetch per page during synchronization. Default: 100, max: 500. * **Enabled** - Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true. - * **EnrollmentWaitAttempts** - OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product, so fast-issuing orders return the certificate synchronously in the same enrollment call. EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds ≈ the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 (or any negative value) to disable. Can also be set via the CERTINEXT_ENROLLMENT_WAIT_ATTEMPTS environment variable; the env var takes precedence. Default: 5. - * **EnrollmentWaitIntervalSeconds** - OPTIONAL: Seconds between synchronous enrollment-wait polls inside Enroll() (see EnrollmentWaitAttempts). Setting this to 0 (or any negative value) disables the poll entirely — it does NOT mean back-to-back polling. Can also be set via the CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS environment variable; the env var takes precedence. Default: 10. + * **EnrollmentWaitSeconds** - OPTIONAL: Total seconds Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product (polled every 5 seconds), so fast-issuing orders return the certificate synchronously in the same enrollment call. This is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep it under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 (or any negative value) to disable. Can also be set via the CERTINEXT_ENROLLMENT_WAIT_SECONDS environment variable; the env var takes precedence. Default: 50. * **DcvEnabled** - OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) during enrollment for orders that require it, using the configured DNS provider plugin. Requires a DNS provider plugin (e.g. azure-azuredns-dnsplugin) to be deployed on the gateway. Default: false. * **DcvTxtRecordTemplate** - OPTIONAL: Format string for the DNS TXT record hostname used during DCV. {0} is replaced with the domain name being validated. Default: _emsign-validation.{0} * **DcvPropagationDelaySeconds** - OPTIONAL: Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: 30. @@ -262,8 +261,7 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | -| `EnrollmentWaitAttempts` | Optional | Number of times `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product, so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). `EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds` is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` (or any negative value) to disable the poll. Can also be set via the `CERTINEXT_ENROLLMENT_WAIT_ATTEMPTS` environment variable; the environment variable takes precedence. Default: `5`. | N/A | `5` | -| `EnrollmentWaitIntervalSeconds` | Optional | Seconds between synchronous enrollment-wait polls inside `Enroll()` (see `EnrollmentWaitAttempts`). Setting this to `0` (or any negative value) disables the poll entirely — it does **not** mean back-to-back polling. Can also be set via the `CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS` environment variable; the environment variable takes precedence. Default: `10`. | N/A | `10` | +| `EnrollmentWaitSeconds` | Optional | Total seconds `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product (polled every 5 seconds), so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). This is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep it under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` (or any negative value) to disable the poll. Can also be set via the `CERTINEXT_ENROLLMENT_WAIT_SECONDS` environment variable; the environment variable takes precedence. Default: `50`. | N/A | `50` | | `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | | `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | | `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: `30`. | N/A | `30` | @@ -484,7 +482,7 @@ sequenceDiagram alt Certificate issued immediately Plugin-->>CMD: Certificate ready — PEM returned else Pending, product is DV, and DCV does not own the wait - loop Synchronous enrollment wait
(up to EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds) + loop Synchronous enrollment wait
(up to EnrollmentWaitSeconds) Plugin->>API: Fetch certificate API-->>Plugin: Issued, or still pending end @@ -500,7 +498,7 @@ sequenceDiagram The synchronous enrollment-wait step mirrors the legacy Sectigo connector's behavior: DV orders that CERTInext issues within the poll budget are returned in the same enrollment call, so automated workflows (e.g. expiration renewal) receive the certificate without waiting for a sync cycle. The product's validation level is resolved from the account's product catalog (cached), falling back to the template product name. OV/EV orders are never polled — their organization-verification step takes minutes and may be human-gated, so the plugin returns pending immediately with a message explaining the deferral. -On gateways with `DcvEnabled` (DCV build flavor), pending **new/reissue** orders skip this enrollment-wait loop entirely — the in-call DCV flow owns those waits, and its post-validation issuance poll is budgeted by `DcvWaitForIssuanceSeconds` (3-second interval), not by `EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds`. Renewals never run in-call DCV, so the enrollment-wait loop above applies to them on every flavor, as does the recovery fetch for orders that issued but whose certificate download initially failed. +On gateways with `DcvEnabled` (DCV build flavor), pending **new/reissue** orders skip this enrollment-wait loop entirely — the in-call DCV flow owns those waits, and its post-validation issuance poll is budgeted by `DcvWaitForIssuanceSeconds` instead of `EnrollmentWaitSeconds` (both poll every 5 seconds, the same fixed interval). Renewals never run in-call DCV, so the enrollment-wait loop above applies to them on every flavor, as does the recovery fetch for orders that issued but whose certificate download initially failed. ### Renewal diff --git a/docsource/architecture.md b/docsource/architecture.md index a897276..2a268ca 100644 --- a/docsource/architecture.md +++ b/docsource/architecture.md @@ -140,7 +140,7 @@ sequenceDiagram alt Certificate issued immediately Plugin-->>CMD: Certificate ready — PEM returned else Pending, product is DV, and DCV does not own the wait - loop Synchronous enrollment wait
(up to EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds) + loop Synchronous enrollment wait
(up to EnrollmentWaitSeconds) Plugin->>API: Fetch certificate API-->>Plugin: Issued, or still pending end @@ -156,7 +156,7 @@ sequenceDiagram The synchronous enrollment-wait step mirrors the legacy Sectigo connector's behavior: DV orders that CERTInext issues within the poll budget are returned in the same enrollment call, so automated workflows (e.g. expiration renewal) receive the certificate without waiting for a sync cycle. The product's validation level is resolved from the account's product catalog (cached), falling back to the template product name. OV/EV orders are never polled — their organization-verification step takes minutes and may be human-gated, so the plugin returns pending immediately with a message explaining the deferral. -On gateways with `DcvEnabled` (DCV build flavor), pending **new/reissue** orders skip this enrollment-wait loop entirely — the in-call DCV flow owns those waits, and its post-validation issuance poll is budgeted by `DcvWaitForIssuanceSeconds` (3-second interval), not by `EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds`. Renewals never run in-call DCV, so the enrollment-wait loop above applies to them on every flavor, as does the recovery fetch for orders that issued but whose certificate download initially failed. +On gateways with `DcvEnabled` (DCV build flavor), pending **new/reissue** orders skip this enrollment-wait loop entirely — the in-call DCV flow owns those waits, and its post-validation issuance poll is budgeted by `DcvWaitForIssuanceSeconds` instead of `EnrollmentWaitSeconds` (both poll every 5 seconds, the same fixed interval). Renewals never run in-call DCV, so the enrollment-wait loop above applies to them on every flavor, as does the recovery fetch for orders that issued but whose certificate download initially failed. ### Renewal diff --git a/docsource/configuration.md b/docsource/configuration.md index 9b13f2c..36b1b7b 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -113,8 +113,7 @@ The following fields are presented in the Keyfactor Command Management Portal wh | `IgnoreExpired` | Optional | If `true`, expired certificates are skipped during synchronization and are not imported into Keyfactor Command. Default: `false`. | N/A | `false` | | `PageSize` | Optional | Number of orders to retrieve per page during synchronization. Default: `100`. Maximum: `500`. Reduce this value if synchronization requests time out. | N/A | `100` | | `Enabled` | Optional | Enables or disables the CA connector. Setting this to `false` allows the connector record to be created before all credentials are available, without triggering a live connectivity test. Default: `true`. | N/A | `true` | -| `EnrollmentWaitAttempts` | Optional | Number of times `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product, so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). `EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds` is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` (or any negative value) to disable the poll. Can also be set via the `CERTINEXT_ENROLLMENT_WAIT_ATTEMPTS` environment variable; the environment variable takes precedence. Default: `5`. | N/A | `5` | -| `EnrollmentWaitIntervalSeconds` | Optional | Seconds between synchronous enrollment-wait polls inside `Enroll()` (see `EnrollmentWaitAttempts`). Setting this to `0` (or any negative value) disables the poll entirely — it does **not** mean back-to-back polling. Can also be set via the `CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS` environment variable; the environment variable takes precedence. Default: `10`. | N/A | `10` | +| `EnrollmentWaitSeconds` | Optional | Total seconds `Enroll()` polls CERTInext for the issued certificate after submitting an order for a **DV** product (polled every 5 seconds), so fast-issuing orders return the certificate synchronously in the same enrollment call (matching the legacy Sectigo connector's pickup behavior). This is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) — keep it under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification takes minutes and may be human-gated), so those orders return pending and are completed by the next synchronization. Set to `0` (or any negative value) to disable the poll. Can also be set via the `CERTINEXT_ENROLLMENT_WAIT_SECONDS` environment variable; the environment variable takes precedence. Default: `50`. | N/A | `50` | | `DcvEnabled` | Optional | When `true`, the gateway performs DNS-based Domain Control Validation (DCV) during enrollment for orders that require it. Requires a DNS provider plugin (e.g. `azure-azuredns-dnsplugin`) to be deployed on the gateway. Default: `false`. | N/A | `false` | | `DcvTxtRecordTemplate` | Optional | Format string for the DNS TXT record hostname published during DCV. `{0}` is replaced with the domain being validated. Default: `_emsign-validation.{0}`. | N/A | `_emsign-validation.{0}` | | `DcvPropagationDelaySeconds` | Optional | Seconds to wait after publishing the DNS TXT record before asking CERTInext to verify it. Increase for zones with slow propagation. Default: `30`. | N/A | `30` | @@ -252,7 +251,7 @@ CERTInext orders pass through several internal status stages before a certificat Before returning a pending result, `Enroll()` runs a **synchronous enrollment-wait poll** gated by the product's validation level: -- **DV products** — the plugin polls `GetCertificate` up to `EnrollmentWaitAttempts` times, `EnrollmentWaitIntervalSeconds` apart (default 5 × 10 s ≈ 50 s). If CERTInext issues within that budget, the enrollment call returns the issued certificate directly — no waiting for the next sync. If the budget elapses, the pending result is returned unchanged and sync completes the order later. +- **DV products** — the plugin polls `GetCertificate` for up to `EnrollmentWaitSeconds` (default 50 s), every 5 seconds. If CERTInext issues within that budget, the enrollment call returns the issued certificate directly — no waiting for the next sync. If the budget elapses, the pending result is returned unchanged and sync completes the order later. - **OV/EV products** — the poll is skipped entirely. CERTInext issues OV/EV asynchronously by design: the mandatory organization-verification step takes minutes and may require human review, so no in-call wait can succeed within Command's enrollment timeout (confirmed by CERTInext support, ticket #162763). The pending result carries a status message explaining this; the certificate is imported automatically by the next synchronization. The validation level (DV/OV/EV) is resolved from the account's product catalog (`GetProductDetails`, cached for 60 minutes — never fetched per-enrollment), falling back to the DV/OV/EV token in the template's product name when the catalog is unavailable. Products whose level cannot be determined are polled optimistically. When `DcvEnabled` is `true`, pending **new/reissue** orders skip the enrollment-wait poll — the in-call DCV flow owns those waits — but renewals (which never run in-call DCV) and issued-orders-awaiting-PEM-download remain eligible. diff --git a/docsource/overview.md b/docsource/overview.md index b5269b2..565b90e 100644 --- a/docsource/overview.md +++ b/docsource/overview.md @@ -57,12 +57,12 @@ Enrollment completes successfully but the cert is not yet issued — Command sho This is the expected return shape on three paths: 1. **The product is OV or EV.** CERTInext issues OV/EV certificates asynchronously by design — the mandatory organization-verification step takes minutes and may require human review, and there is no CA-side setting that makes these products return in a single call (confirmed by CERTInext support). The plugin deliberately skips its synchronous enrollment-wait poll for OV/EV and returns pending immediately with a status message explaining this. -2. **The product is DV but issuance outran the enrollment-wait budget.** `Enroll()` polls for the issued certificate up to `EnrollmentWaitAttempts × EnrollmentWaitIntervalSeconds` (default ≈ 50 s) before returning pending. +2. **The product is DV but issuance outran the enrollment-wait budget.** `Enroll()` polls for the issued certificate for up to `EnrollmentWaitSeconds` (default 50 s) before returning pending. 3. **DCV builds only:** the DCV-specific `Enroll()` budget (`DcvWaitForChallengeSeconds` + `DcvWaitForIssuanceSeconds`, defaults 60s each) elapsed before CERTInext finished asynchronous issuance, or the plugin was loaded on an older gateway host (pre-IAnyCAPlugin v3.3) that does not inject `IDomainValidatorFactory`, so DCV could not run in-call. **Mitigation** -The next gateway sync cycle will pick the cert up and transition it to `GENERATED`. For OV/EV this is the designed flow — no tuning changes it. For DV, raise `EnrollmentWaitAttempts`/`EnrollmentWaitIntervalSeconds` if your orders reliably issue just past the default budget (keep the product under ~90 s — it holds a Command worker thread). The plugin's sync-driven DCV retry is single-shot per record, so even with hundreds of pending orders the sync completes in seconds, not minutes — see [configuration.md](configuration.md) for the `EnrollmentWaitAttempts`/`EnrollmentWaitIntervalSeconds` and `DcvWaitForChallengeSeconds`/`DcvWaitForIssuanceSeconds` knobs. +The next gateway sync cycle will pick the cert up and transition it to `GENERATED`. For OV/EV this is the designed flow — no tuning changes it. For DV, raise `EnrollmentWaitSeconds` if your orders reliably issue just past the default budget (keep it under ~90 s — it holds a Command worker thread). The plugin's sync-driven DCV retry is single-shot per record, so even with hundreds of pending orders the sync completes in seconds, not minutes — see [configuration.md](configuration.md) for the `EnrollmentWaitSeconds` and `DcvWaitForChallengeSeconds`/`DcvWaitForIssuanceSeconds` knobs. ### `EMS-956 "Invalid Request for this API"` from `GetDcv` diff --git a/integration-manifest.json b/integration-manifest.json index b579261..3fb9938 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -142,12 +142,8 @@ "description": "Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true." }, { - "name": "EnrollmentWaitAttempts", - "description": "OPTIONAL: Number of times Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product, so fast-issuing orders return the certificate synchronously in the same enrollment call. EnrollmentWaitAttempts \u00d7 EnrollmentWaitIntervalSeconds \u2248 the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) \u2014 keep the product under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 (or any negative value) to disable. Can also be set via the CERTINEXT_ENROLLMENT_WAIT_ATTEMPTS environment variable; the env var takes precedence. Default: 5." - }, - { - "name": "EnrollmentWaitIntervalSeconds", - "description": "OPTIONAL: Seconds between synchronous enrollment-wait polls inside Enroll() (see EnrollmentWaitAttempts). Setting this to 0 (or any negative value) disables the poll entirely \u2014 it does NOT mean back-to-back polling. Can also be set via the CERTINEXT_ENROLLMENT_WAIT_INTERVAL_SECONDS environment variable; the env var takes precedence. Default: 10." + "name": "EnrollmentWaitSeconds", + "description": "OPTIONAL: Total seconds Enroll() polls CERTInext for the issued certificate after submitting an order for a DV product (polled every 5 seconds), so fast-issuing orders return the certificate synchronously in the same enrollment call. This is approximately the maximum time an enrollment call can occupy a Keyfactor Command worker thread (a small internal grace margin applies) \u2014 keep it under ~90 seconds. OV/EV products never poll: CERTInext issues them asynchronously by design (organization verification), so those orders return pending and are completed by the next synchronization. Set to 0 (or any negative value) to disable. Can also be set via the CERTINEXT_ENROLLMENT_WAIT_SECONDS environment variable; the env var takes precedence. Default: 50." }, { "name": "DcvEnabled", From 11d0b5d98b72925646d507a4b703ee5e418753f0 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:50:00 -0700 Subject: [PATCH 07/17] fix(enroll): stop dcvOwnsIssuanceWait from silently disabling the enrollment wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full review cycle round 1 (code + compliance + security in parallel). Code review (workflow, high effort, adversarially verified): - dcvOwnsIssuanceWait was derived from the static _config.DcvEnabled flag instead of whether an in-call DCV issuance wait actually ran for THIS order. Any time PerformDcvIfNeededAsync short-circuited (no pending domains, challenge timeout, already-in-flight duplicate, or no factory injected) the flag stayed true purely because DCV was enabled, so TryEnrollmentWaitForCertificateAsync's "DCV owns this wait" guard silently skipped the whole feature for that order on every DCV-enabled gateway. Replaced with dcvIssuanceWaitRan, set only when a post-DCV WaitForIssuanceAsync call actually executed. - A successful post-DCV run whose issuance poll ended non-terminal (GENERATED with no PEM yet) had its result discarded — the code rebuilt the fallback from the stale pre-DCV pending response instead of the post-DCV outcome, so the issued-without-PEM recovery path (meant to run "regardless of DCV") never got a chance to fire. The fallback is now built from the post-DCV result when one ran. - WaitForIssuanceAsync's loop-top ct.ThrowIfCancellationRequested() could escape Enroll() unhandled if the outer DcvTimeoutMinutes budget expired mid post-DCV poll — every other exit from this feature soft-falls back to pending instead of throwing. Added a catch that degrades gracefully and logs the timeout. - Extracted StatusMapper.IsTerminalIssuance to replace the same three-way REVOKED/FAILED/GENERATED-with-body predicate that was copy-pasted at three call sites (and had already caused one bodyless-GENERATED bug fixed by hand in each copy separately in an earlier commit on this branch). Compliance audit (SOX/SOC2): - The two OperationCanceledException catches inside WaitForIssuanceAsync returned silently with no log line — a regression from the pre-PR code, which routed cancellation through a broad catch(Exception) that did log. Added LogWarning to both so a hard-ceiling cancellation is distinguishable in the audit trail from routine budget exhaustion. - Added ProductCode to the enrollment-wait disabled/clamped log lines and PollIntervalSeconds to the poll-started/exhaustion lines, and a per-attempt LogDebug inside the poll loop, so the full sequence is reconstructable from logs without needing to know compile-time constants. Security review: no findings. Not changed: the code-review workflow also flagged that WaitForIssuanceAsync no longer aborts on the first GetCertificate exception (it now retries through transient failures until the budget expires). That's intentional, pre-existing behavior from an earlier commit on this same branch (396d726) — a prior full review round explicitly fixed the opposite bug (aborting on the first blip broke Sectigo-parity retry semantics) — so it is not reverted here. Tests: 2 new DCV regression tests covering the dcvIssuanceWaitRan fix and the postDcv-discard fix. Both flavors green (240/209), 0 warnings. --- CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 84 +++++++++++++ CERTInext/CERTInextCAPlugin.cs | 125 ++++++++++++------- CERTInext/Models/StatusMapper.cs | 14 +++ 3 files changed, 179 insertions(+), 44 deletions(-) diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index 7fde012..d094387 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -220,6 +220,90 @@ public async Task Dcv_Skipped_WhenNoDomainVerificationBlock() mock.Verify(c => c.GetDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task Dcv_EnrollmentWaitStillRuns_WhenDcvShortCircuitsWithoutAnIssuanceWait() + { + // Regression: dcvOwnsIssuanceWait/dcvIssuanceWaitRan must reflect whether an + // in-call DCV issuance wait actually ran for THIS order, not merely whether + // DcvEnabled is set. When PerformDcvIfNeededAsync short-circuits (here: the DCV + // challenge slot never appears) without ever calling WaitForIssuanceAsync, the + // general synchronous enrollment-wait poll must still get a chance to run — + // previously it was unconditionally skipped whenever DcvEnabled=true, silently + // defeating the whole feature on every DCV-enabled gateway. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = null + } + }); + + // The product isn't in any catalog → falls back to Unknown/optimistic polling, + // mirroring EnrollmentWait_UnknownProduct_PollsOptimistically. + mock.Setup(c => c.GetProductDetailsAsync(It.IsAny())) + .ThrowsAsync(new Exception("catalog endpoint down")); + mock.Setup(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId)); + + var validator = new FakeDomainValidator(); + var config = DcvConfig(); // dcvWaitForChallengeSeconds/dcvWaitForIssuanceSeconds default to 0 + config.EnrollmentWaitSeconds = 10; // re-enable the general enrollment-wait poll + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "the enrollment-wait poll must still run and pick up the issued cert even though " + + "DCV short-circuited without ever performing its own issuance wait"); + mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()), + Times.AtLeastOnce, + "the general enrollment-wait poll must actually attempt GetCertificate for this order"); + } + + [Fact] + public async Task Dcv_RecoversPem_WhenPostDcvIssuanceWaitEndsWithGeneratedButNoBody() + { + // Regression: when a real DCV run's post-DCV issuance wait (WaitForIssuanceAsync, + // "PostDcv") ends non-terminal with a GENERATED-but-no-PEM result, that outcome + // must feed the fallback EnrollmentResult passed into + // TryEnrollmentWaitForCertificateAsync instead of being discarded in favor of the + // stale pre-DCV pending response — otherwise the issued-without-PEM recovery poll + // (which is supposed to run "regardless of DCV") never gets a chance to fire, + // because the stale response looks like plain pending-approval and gets skipped by + // the dcvOwnsIssuanceWait guard. + var (mock, validator) = HappyPathMocks(); + + // Post-DCV poll (budget=5s over the fixed 5s interval ⇒ exactly 1 poll) returns + // issued but without a body; the general enrollment-wait poll's next attempt + // finally recovers it. + mock.SetupSequence(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new LegacyGetCertificateResponse + { + Id = MockCertificateData.DcvOrderId, Status = "issued", Certificate = null + }) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId)); + + var config = DcvConfig(dcvWaitForIssuanceSeconds: 5); + config.EnrollmentWaitSeconds = 10; + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.Certificate.Should().Contain("BEGIN CERTIFICATE", + "the general enrollment-wait poll must recover the PEM for an order whose post-DCV " + + "issuance wait ended issued-but-bodyless, instead of being skipped as 'DCV owns this wait'"); + mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()), + Times.Exactly(2), "one post-DCV poll (bodyless) plus one enrollment-wait recovery poll (with body)"); + } + [Fact] public async Task Dcv_SkipsStaging_AndDoesNotIssuancePoll_WhenAllDomainsAlreadyValidated_AndIssuanceBudgetZero() { diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 081f30b..3d2e4e5 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1139,6 +1139,12 @@ private async Task EnrollNewAsync( var enrollResp = await _client.EnrollCertificateAsync(enrollReq); + // Tracks whether an in-call DCV issuance wait actually ran (and its outcome) for + // THIS order — as opposed to merely "DcvEnabled is set" — so the enrollment-wait + // gate below only defers when something genuinely already waited. Declared outside + // the #if so both build flavors see the same fallback-construction logic. + LegacyGetCertificateResponse postDcv = null; + bool dcvIssuanceWaitRan = false; #if SUPPORTS_DCV // DCV: run domain validation if enabled, the factory was injected, and the // order was accepted (not immediately failed). @@ -1188,28 +1194,42 @@ private async Task EnrollNewAsync( // would push typical-case latency toward the budget ceiling. Decoupled // from DcvPropagationDelaySeconds (a DNS concern) so admins tuning DNS // settings don't accidentally make this polling chunky. - var postDcv = await WaitForIssuanceAsync( + postDcv = await WaitForIssuanceAsync( orderNumber, _config.GetEffectiveDcvWaitForIssuanceSeconds(), Constants.Polling.CertificatePollIntervalSeconds, "PostDcv", dcvCts.Token); + // A genuine issuance wait ran for this order — the fallback-result + // construction and the enrollment-wait gate below must reflect that, + // whether or not the outcome turned out to be terminal. + dcvIssuanceWaitRan = true; + // Only a genuine terminal outcome ends the enroll call here. A GENERATED // result without a PEM (a transient download failure during the wait) // must fall through to the pending path so a later sync refetches the // body — never surface a bodyless "issued" result. REVOKED/FAILED carry // no body and are surfaced as-is. - if (postDcv != null) + if (postDcv != null + && StatusMapper.IsTerminalIssuance( + StatusMapper.ToRequestDisposition(postDcv.Status), postDcv.Certificate)) { - int postDcvDisposition = StatusMapper.ToRequestDisposition(postDcv.Status); - if (postDcvDisposition == (int)EndEntityStatus.REVOKED - || postDcvDisposition == (int)EndEntityStatus.FAILED - || (postDcvDisposition == (int)EndEntityStatus.GENERATED - && !string.IsNullOrWhiteSpace(postDcv.Certificate))) - { - return BuildEnrollmentResultFromCertificate(postDcv, orderNumber, - $"Post-DCV status: {postDcv.Status}.", ep.AutoApprove); - } + return BuildEnrollmentResultFromCertificate(postDcv, orderNumber, + $"Post-DCV status: {postDcv.Status}.", ep.AutoApprove); } } } + catch (OperationCanceledException) when (dcvCts.IsCancellationRequested) + { + // DcvTimeoutMinutes expired mid-DCV or mid-post-DCV-poll — neither + // PerformDcvIfNeededAsync's TrackOrder loop nor WaitForIssuanceAsync's + // GetCertificate loop catches this themselves (their own budgets are + // meant to be shorter than this outer ceiling, but a hung endpoint can + // still exhaust it first). Degrade to the pending fallback below instead + // of letting the cancellation escape Enroll() unhandled — every other + // exit from this feature does the same "never throws" soft-fallback. + _logger.LogWarning( + "DCV timed out (DcvTimeoutMinutes={Timeout}) for order {OrderNumber}; " + + "returning the pending result so a later synchronization completes it.", + dcvTimeoutMinutes, orderNumber); + } finally { _dcvInFlight.TryRemove(orderNumber, out _); @@ -1238,19 +1258,25 @@ private async Task EnrollNewAsync( } #endif - // Synchronous pickup (both build flavors): poll for the issued certificate so - // fast-issuing (DV) orders return GENERATED + PEM in this same call instead of - // deferring to the next sync cycle. No-ops for OV/EV, when the in-call DCV flow - // owns the wait, or when the result is already terminal. - bool dcvOwnsIssuanceWait = false; -#if SUPPORTS_DCV - // When DCV is enabled, the DCV branch above already performed (or deliberately - // deferred to the sync-driven DCV path) the issuance wait for this new order. - dcvOwnsIssuanceWait = _config.DcvEnabled; -#endif - var newResult = BuildEnrollmentResult(enrollResp, ep.AutoApprove); + // Synchronous enrollment wait (both build flavors): poll for the issued certificate + // so fast-issuing (DV) orders return GENERATED + PEM in this same call instead of + // deferring to the next sync cycle. No-ops for OV/EV, when an in-call DCV issuance + // wait already ran for this order (dcvIssuanceWaitRan — NOT merely "DcvEnabled is + // set": DCV can short-circuit without ever waiting, e.g. no pending domains, the + // challenge timeout, an already-in-flight duplicate, or a DCV-timeout cancellation + // above), or when the result is already terminal. + // + // If a post-DCV wait DID run, build the fallback from ITS outcome (postDcv) rather + // than the stale pre-DCV enrollResp — an issued-but-PEM-missing postDcv result must + // be visible to TryEnrollmentWaitForCertificateAsync as such so its recovery poll + // (which runs "regardless of DCV" for that specific state) gets a chance to fire, + // instead of looking like a plain still-pending order and being skipped outright. + var newResult = postDcv != null + ? BuildEnrollmentResultFromCertificate(postDcv, enrollResp.Id, + $"Post-DCV status: {postDcv.Status}.", ep.AutoApprove) + : BuildEnrollmentResult(enrollResp, ep.AutoApprove); newResult = await TryEnrollmentWaitForCertificateAsync( - newResult, enrollResp.Id, ep, ep.ProductCode, dcvOwnsIssuanceWait); + newResult, enrollResp.Id, ep, ep.ProductCode, dcvIssuanceWaitRan); _logger.MethodExit(LogLevel.Debug); return newResult; @@ -1600,8 +1626,8 @@ EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) // from "enrollment wait never attempted". _logger.LogInformation( "Synchronous enrollment wait disabled by configuration (effective EnrollmentWaitSeconds={Budget}). " + - "Order {OrderNumber} will be picked up on the next sync cycle.", - configuredBudgetSeconds, orderNumber); + "Order {OrderNumber} (ProductCode={ProductCode}) will be picked up on the next sync cycle.", + configuredBudgetSeconds, orderNumber, productCode); return DegradeBodylessIssuedToPending(pendingResult); } @@ -1614,8 +1640,8 @@ EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) // SOX CC7.3: the clamp is a policy decision — evidence it. _logger.LogWarning( "Configured enrollment-wait budget ({Configured}s = EnrollmentWaitSeconds) exceeds the " + - "hard ceiling; clamped to {Max}s for order {OrderNumber}.", - configuredBudgetSeconds, Constants.EnrollmentWait.MaxBudgetSeconds, orderNumber); + "hard ceiling; clamped to {Max}s for order {OrderNumber} (ProductCode={ProductCode}).", + configuredBudgetSeconds, Constants.EnrollmentWait.MaxBudgetSeconds, orderNumber, productCode); } try @@ -1660,8 +1686,8 @@ EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) _logger.LogInformation( "Synchronous enrollment-wait poll started. OrderNumber={OrderNumber}, ProductCode={ProductCode}, " + - "ValidationType={ValidationType}, BudgetSeconds={Budget}", - orderNumber, productCode, validationType, budgetSeconds); + "ValidationType={ValidationType}, BudgetSeconds={Budget}, PollIntervalSeconds={Interval}", + orderNumber, productCode, validationType, budgetSeconds, Constants.Polling.CertificatePollIntervalSeconds); var final = await WaitForIssuanceAsync(orderNumber, budgetSeconds, Constants.Polling.CertificatePollIntervalSeconds, "EnrollmentWait", cts.Token); @@ -1674,11 +1700,7 @@ EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) int finalDisposition = final == null ? (int)EndEntityStatus.EXTERNALVALIDATION : StatusMapper.ToRequestDisposition(final.Status); - if (final != null - && (finalDisposition == (int)EndEntityStatus.REVOKED - || finalDisposition == (int)EndEntityStatus.FAILED - || (finalDisposition == (int)EndEntityStatus.GENERATED - && !string.IsNullOrWhiteSpace(final.Certificate)))) + if (final != null && StatusMapper.IsTerminalIssuance(finalDisposition, final.Certificate)) { _logger.LogInformation( "Synchronous pickup complete. OrderNumber={OrderNumber}, Status={Status}, SerialNumber={Serial}", @@ -2318,7 +2340,14 @@ private async Task WaitForIssuanceAsync( } catch (OperationCanceledException) { - // The budget's token fired mid-call — hand back whatever we have. + // SOC2 CC7.2: the budget's token fired mid-call — log so this is + // distinguishable in the audit trail from routine budget exhaustion + // (the "not complete within {Budget}s" line below never fires here). + _logger.LogWarning( + "GetCertificate poll cancelled by the wait budget for order {OrderNumber} " + + "(attempt {Attempt}, Phase={Phase}). Returning {Outcome}.", + orderNumber, attempt, phase, + last == null ? "pending fallback (no successful poll)" : "last pending result"); return last; } catch (Exception ex) @@ -2338,6 +2367,12 @@ private async Task WaitForIssuanceAsync( last = current; int disposition = StatusMapper.ToRequestDisposition(last.Status); + // SOC2 CC9.2: record every third-party poll response, not just the + // terminal/exhaustion outcome, so the full poll sequence is reconstructable. + _logger.LogDebug( + "GetCertificate poll attempt {Attempt} for order {OrderNumber}: Status={Status} (Phase={Phase}).", + attempt, orderNumber, last.Status, phase); + // GENERATED is only terminal once the PEM is actually in hand. // GetCertificateAsync maps status from TrackOrder but swallows a // transient DownloadCertificate failure (logs a warning, returns @@ -2347,12 +2382,7 @@ private async Task WaitForIssuanceAsync( // GetCertificateAsync re-attempts the download — mirroring the same // refetch defense Synchronize() already applies. REVOKED/FAILED are // genuinely terminal and carry no body, so they short-circuit as before. - bool terminal = - disposition == (int)EndEntityStatus.REVOKED - || disposition == (int)EndEntityStatus.FAILED - || (disposition == (int)EndEntityStatus.GENERATED - && !string.IsNullOrWhiteSpace(last.Certificate)); - if (terminal) + if (StatusMapper.IsTerminalIssuance(disposition, last.Certificate)) { return last; } @@ -2366,9 +2396,9 @@ private async Task WaitForIssuanceAsync( || DateTime.UtcNow.AddSeconds(pollIntervalSeconds) >= deadline) { _logger.LogInformation( - "Issuance not complete within {Budget}s for order {OrderNumber} (Phase={Phase}). " + - "Returning {Outcome}; sync will pick up the cert later.", - waitBudgetSeconds, orderNumber, phase, + "Issuance not complete within {Budget}s (polled every {Interval}s) for order " + + "{OrderNumber} (Phase={Phase}). Returning {Outcome}; sync will pick up the cert later.", + waitBudgetSeconds, pollIntervalSeconds, orderNumber, phase, last == null ? "pending fallback (no successful poll)" : "last pending result"); return last; } @@ -2379,6 +2409,13 @@ private async Task WaitForIssuanceAsync( } catch (OperationCanceledException) { + // SOC2 CC7.2: same audit-trail rationale as the GetCertificate cancellation + // above — this is the hard-ceiling cancellation firing between polls. + _logger.LogWarning( + "Issuance wait cancelled by the wait budget for order {OrderNumber} " + + "(attempt {Attempt}, Phase={Phase}). Returning {Outcome}.", + orderNumber, attempt, phase, + last == null ? "pending fallback (no successful poll)" : "last pending result"); return last; } } diff --git a/CERTInext/Models/StatusMapper.cs b/CERTInext/Models/StatusMapper.cs index e596717..8c542db 100644 --- a/CERTInext/Models/StatusMapper.cs +++ b/CERTInext/Models/StatusMapper.cs @@ -73,6 +73,20 @@ public static int CertificateStatusIdToRequestDisposition(int certificateStatusI } } + /// + /// True when a disposition/certificate pair represents a genuinely finished + /// issuance outcome — REVOKED, FAILED, or GENERATED with a certificate body + /// actually present. A GENERATED disposition with no body (e.g. a transient + /// download failure mid-poll) is NOT terminal: treating it as such would hand + /// Command a bodyless "issued" record instead of letting the caller keep polling + /// or degrade to pending. Shared by every issuance-wait/pickup call site so this + /// three-way check can't drift between copies or be fixed in only one of them. + /// + public static bool IsTerminalIssuance(int disposition, string certificate) => + disposition == (int)EndEntityStatus.REVOKED + || disposition == (int)EndEntityStatus.FAILED + || (disposition == (int)EndEntityStatus.GENERATED && !string.IsNullOrWhiteSpace(certificate)); + /// /// Converts a CERTInext certificateStatusId string (as returned by the /// API response) to the closest matching code. From 9ab69b9cbec62cd010a8e4040359db806162a66f Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:03:40 -0700 Subject: [PATCH 08/17] fix(enroll): dcvIssuanceWaitRan must require a genuine positive wait budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full review cycle round 2 (fresh code + compliance + security sweep after round 1's fixes). Two issues found independently by both the compliance audit and the code-review workflow (good convergence): - dcvIssuanceWaitRan was set true right after calling WaitForIssuanceAsync regardless of its outcome, but that call is a no-op (returns null, zero API calls) whenever DcvWaitForIssuanceSeconds<=0. An operator who disables only the DCV-specific issuance wait while leaving the general EnrollmentWaitSeconds knob enabled got no poll at all once DCV validation completed in-call — the exact "feature silently does nothing" failure round 1 fixed for the DcvEnabled case, reappearing via a different trigger. Now keyed on whether the effective DcvWaitForIssuanceSeconds budget was actually positive, not on whether the method merely executed (a wait that ran but got no usable response, e.g. every poll failed, still correctly counts as having run). - BuildEnrollmentResult's GENERATED case logged "Certificate issued" at Information purely from the mapped disposition, with no check for whether the certificate body was actually present. Round 1's postDcv-based fallback construction made this reachable with a genuinely bodyless GENERATED result (the exact scenario the new PEM-recovery test exercises), so an audit-trail spot check could see "issued" logged for an order that, moments later, degrades back to pending because the body never arrived. The log (and message) now only claim "issued" once the body is actually in hand; the bodyless case gets its own accurate log line. Compliance audit and security review: no other findings. Not changed (same triage as round 1, re-confirmed): WaitForIssuanceAsync retrying through transient GetCertificate failures until the budget expires is intentional, pre-existing behavior from commit 396d726, not a new regression — reverting it would reintroduce the Sectigo-parity bug a prior review round fixed. Also not changed: the fixed poll interval has no test-injection seam (flagged as a cleanup item) — an already-discussed, accepted tradeoff for keeping the feature's public config to a single knob without adding a test-only abstraction. Tests: 1 new DCV regression test for the dcvIssuanceWaitRan fix. Both flavors green (241/209), 0 warnings. --- CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 52 ++++++++++++++++++++ CERTInext/CERTInextCAPlugin.cs | 51 +++++++++++++------ 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index d094387..0a3355a 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -304,6 +304,58 @@ public async Task Dcv_RecoversPem_WhenPostDcvIssuanceWaitEndsWithGeneratedButNoB Times.Exactly(2), "one post-DCV poll (bodyless) plus one enrollment-wait recovery poll (with body)"); } + [Fact] + public async Task Dcv_EnrollmentWaitStillRuns_WhenDcvCompletesButIssuanceWaitBudgetIsZero() + { + // Regression (round 2 of the full review cycle): dcvIssuanceWaitRan must reflect + // whether the post-DCV issuance wait genuinely had a positive budget, not merely + // whether WaitForIssuanceAsync was invoked. When dcvDone=true but + // DcvWaitForIssuanceSeconds<=0, WaitForIssuanceAsync short-circuits to a no-op (no + // API call at all) — an operator who disabled the DCV-specific wait while leaving + // the general EnrollmentWaitSeconds knob enabled must still get a poll from the + // general enrollment-wait gate. Before this fix, dcvIssuanceWaitRan was set true + // purely because the method was called, silently skipping both waits. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + // domainVerification.status = "1" (already validated) → PerformDcvIfNeededAsync + // returns dcvDone=true with no per-domain polling needed. + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = new TrackOrderDomainVerification + { + Status = Constants.Dcv.StatusValidated + } + } + }); + + mock.Setup(c => c.GetProductDetailsAsync(It.IsAny())) + .ThrowsAsync(new Exception("catalog endpoint down")); + mock.Setup(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId)); + + var validator = new FakeDomainValidator(); + // dcvWaitForIssuanceSeconds stays at the DcvConfig default (0, DCV-specific wait + // disabled) — but the general enrollment-wait knob is enabled. + var config = DcvConfig(); + config.EnrollmentWaitSeconds = 10; + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED, + "the general enrollment-wait poll must still run when DCV completed in-call but its " + + "own issuance-wait budget was disabled"); + mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()), + Times.AtLeastOnce); + } + [Fact] public async Task Dcv_SkipsStaging_AndDoesNotIssuancePoll_WhenAllDomainsAlreadyValidated_AndIssuanceBudgetZero() { diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 3d2e4e5..7295aff 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1194,13 +1194,19 @@ private async Task EnrollNewAsync( // would push typical-case latency toward the budget ceiling. Decoupled // from DcvPropagationDelaySeconds (a DNS concern) so admins tuning DNS // settings don't accidentally make this polling chunky. + int dcvIssuanceBudgetSeconds = _config.GetEffectiveDcvWaitForIssuanceSeconds(); postDcv = await WaitForIssuanceAsync( - orderNumber, _config.GetEffectiveDcvWaitForIssuanceSeconds(), + orderNumber, dcvIssuanceBudgetSeconds, Constants.Polling.CertificatePollIntervalSeconds, "PostDcv", dcvCts.Token); - // A genuine issuance wait ran for this order — the fallback-result - // construction and the enrollment-wait gate below must reflect that, + // A genuine issuance wait ran for this order only if the budget was + // positive — WaitForIssuanceAsync short-circuits to a no-op (returns + // null, no API call) when DcvWaitForIssuanceSeconds<=0, so checking + // "postDcv != null" here would be wrong: a wait that genuinely ran but + // never got a usable response (every poll failed) also returns null, + // and that case DOES count as having run. The fallback-result + // construction and the enrollment-wait gate below must reflect this, // whether or not the outcome turned out to be terminal. - dcvIssuanceWaitRan = true; + dcvIssuanceWaitRan = dcvIssuanceBudgetSeconds > 0; // Only a genuine terminal outcome ends the enroll call here. A GENERATED // result without a PEM (a transient download failure during the wait) @@ -2531,16 +2537,33 @@ private EnrollmentResult BuildEnrollmentResult(EnrollCertificateResponse resp, b switch (status) { case (int)EndEntityStatus.GENERATED: - message = $"Certificate issued successfully. CERTInext ID: {resp.Id}."; - // SOC2 CC7.2 / SOX completeness: certificate issuance is the privileged - // act this plugin performs; record it so an auditor can reconstruct which - // orders received a credential and its serial. Both the immediate-issuance - // and pickup-completed paths funnel through here, so this one line covers - // both. The PEM itself is never logged. - _logger.LogInformation( - "Certificate issued. CERTInextId={Id}, SerialNumber={Serial}, Status={Status}.", - resp.Id, string.IsNullOrWhiteSpace(resp.SerialNumber) ? "(pending download)" : resp.SerialNumber, - resp.Status); + // A GENERATED disposition with no certificate body yet (a download that + // hasn't completed, or an in-progress recovery poll) is not a completed + // issuance from Command's perspective — see DegradeBodylessIssuedToPending. + // Logging it as "issued" here would put a misleading timestamp in the audit + // trail ahead of the actual completion (or a walk-back to pending if the + // body never arrives). Only the body-in-hand case gets the SOC2 CC7.2 / SOX + // completeness "issued" audit line; both the immediate-issuance and + // pickup-completed paths funnel through here, so this one line covers both. + // The PEM itself is never logged. + bool hasCertificateBody = !string.IsNullOrWhiteSpace(resp.Certificate); + message = hasCertificateBody + ? $"Certificate issued successfully. CERTInext ID: {resp.Id}." + : $"Order {resp.Id} reached issued status in CERTInext; the certificate body " + + "is not yet available and will be imported by a later synchronization."; + if (hasCertificateBody) + { + _logger.LogInformation( + "Certificate issued. CERTInextId={Id}, SerialNumber={Serial}, Status={Status}.", + resp.Id, string.IsNullOrWhiteSpace(resp.SerialNumber) ? "(pending download)" : resp.SerialNumber, + resp.Status); + } + else + { + _logger.LogInformation( + "Order {Id} reached GENERATED status in CERTInext but the certificate body is " + + "not yet available (Status={Status}).", resp.Id, resp.Status); + } break; case (int)EndEntityStatus.EXTERNALVALIDATION: From 4cf86d70f46dd5e1c2ec664a0f42ad24da6342b9 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:13:32 -0700 Subject: [PATCH 09/17] fix(enroll): add provenance and phase context to round-3 audit-trail gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full review cycle round 3 (fresh code + compliance + security sweep). Code review and security review found no new findings this round — both round-2 fixes verified correct with no new gaps. Compliance audit found three non-blocking forensic-quality gaps, all addressed here: - The renewal product-code fallback (connector DefaultProductCode guess when RenewCertificateAsync's response omits ProfileId) only logged when the guess happened to differ from the template's code, leaving no evidence in the common case that it *was* a guess rather than an API-confirmed value. Now logs the provenance either way. - The DCV-timeout warning didn't distinguish whether DcvTimeoutMinutes expired during domain validation itself or the post-DCV issuance poll. Now names the phase that was in flight. Not changed: adding poll-attempt counts to the Information-level pickup-complete/exhaustion log lines (the third gap) would require either changing WaitForIssuanceAsync's return shape (shared by two call sites) or promoting its existing per-attempt Debug log to Information — the attempt count is already captured at Debug level per poll, and elevating verbosity codebase-wide for a marginal audit improvement isn't justified. Accepted as a non-blocking gap per the compliance auditor's own assessment. Both flavors green (241/209), 0 warnings. --- CERTInext/CERTInextCAPlugin.cs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 7295aff..f21b6b0 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1178,11 +1178,16 @@ private async Task EnrollNewAsync( } else { + // SOC2 CC7.2: if the outer DcvTimeoutMinutes ceiling fires, this tells the + // catch below which phase was in flight — domain validation itself, or the + // post-DCV issuance poll — instead of leaving that ambiguous in the log. + string dcvPhaseInFlight = "domain validation"; try { bool dcvDone = await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token); if (dcvDone) { + dcvPhaseInFlight = "the post-DCV issuance poll"; // Poll GetCertificate until CERTInext finishes generating the cert OR the // issuance budget expires. CERTInext issuance is async — DCV may verify // but the cert PEM isn't immediately available. Without this poll, Enroll @@ -1232,9 +1237,10 @@ private async Task EnrollNewAsync( // of letting the cancellation escape Enroll() unhandled — every other // exit from this feature does the same "never throws" soft-fallback. _logger.LogWarning( - "DCV timed out (DcvTimeoutMinutes={Timeout}) for order {OrderNumber}; " + - "returning the pending result so a later synchronization completes it.", - dcvTimeoutMinutes, orderNumber); + "DCV timed out (DcvTimeoutMinutes={Timeout}) during {Phase} for order " + + "{OrderNumber}; returning the pending result so a later synchronization " + + "completes it.", + dcvTimeoutMinutes, dcvPhaseInFlight, orderNumber); } finally { @@ -1506,7 +1512,8 @@ private async Task RenewOrReissueAsync( // orders with the connector's DefaultProductCode. When the response omits it // (order went out with an empty code), the template's code is only a // best-effort guess for the gate. - string renewedProductCode = !string.IsNullOrWhiteSpace(renewResp.ProfileId) + bool renewedProductCodeIsApiReported = !string.IsNullOrWhiteSpace(renewResp.ProfileId); + string renewedProductCode = renewedProductCodeIsApiReported ? renewResp.ProfileId : ep.ProductCode; if (!string.Equals(renewedProductCode, ep.ProductCode, StringComparison.Ordinal)) @@ -1517,6 +1524,17 @@ private async Task RenewOrReissueAsync( "The synchronous enrollment-wait gate classifies the ordered code.", renewResp.Id, renewedProductCode, ep.ProductCode); } + else if (!renewedProductCodeIsApiReported) + { + // SOC2 CC9.2: the response omitted ProfileId, so this classification is a + // best-effort guess (the template's code), not an API-confirmed value — + // record that distinction even when the guess happens to match, so a log + // reviewer doesn't mistake it for a confirmed classification. + _logger.LogDebug( + "Renewal order {OrderNumber} response omitted ProfileId; classifying with the " + + "template's product code ({TemplateCode}) as a best-effort guess.", + renewResp.Id, ep.ProductCode); + } renewResult = await TryEnrollmentWaitForCertificateAsync( renewResult, renewResp.Id, ep, renewedProductCode, dcvOwnsIssuanceWait: false); From 724e54693dc1b702fa74f74de07094edd97e2b5d Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:24:57 -0700 Subject: [PATCH 10/17] fix(enroll): don't stack a redundant enrollment-wait poll after DCV already deferred MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full review cycle round 3, code-review workflow findings (compliance and security both clean this round). - dcvIssuanceWaitRan stayed false when the outer DcvTimeoutMinutes ceiling (default 10 minutes) fired mid-DCV or mid-post-DCV-poll, so the general enrollment-wait poll ran again immediately afterward against the same likely-still-unhealthy CERTInext endpoint — pushing total Enroll() call duration well past 10 minutes and risking Command's own enrollment timeout aborting the call instead of getting back a clean pending result. Now set true in that catch, since a 10-minute timeout is reason enough not to pile on another bounded-but-still-costly attempt. - Same gap in the _dcvInFlight "already in flight" duplicate-Enroll guard: the log line promises "Enroll will skip its own DCV attempt and return the pending enroll response," but the general enrollment-wait poll ran anyway for the deferring call, doubling API traffic against CERTInext for the same order — exactly the duplicate work the guard exists to prevent. Now honors what it logs. - The renew path's "Renewal via CERTInext renew API complete" audit log fired with the pre-wait status (comment claimed it "ensures the renew path is auditable if the result is further transformed," which it didn't — the synchronous enrollment-wait poll runs after this line and can still flip pending to GENERATED). Relabeled as "(pre-wait)" and pointed to the wait's own completion log line for the final outcome, rather than fix the misleading claim by moving the log (the raw CA response has independent audit value at its own point in time). Not changed (same triage as prior rounds): the removed fail-fast on GetCertificate errors is intentional, pre-existing behavior from commit 396d726, re-flagged again this round only because the workflow diffs against main; two cleanup findings (a second hand-rolled TTL-cache pattern, an unbounded env-var-warning dedup dictionary) are accepted as non-blocking per their own "no correctness impact" classification. Tests: 1 new regression test using a TaskCompletionSource-gated mock to force genuine concurrent-duplicate overlap, verified non-flaky across 5 runs. Both flavors green (242/209), 0 warnings. --- CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 58 ++++++++++++++++++++ CERTInext/CERTInextCAPlugin.cs | 26 +++++++-- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index 0a3355a..f7ff1fe 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -356,6 +356,64 @@ public async Task Dcv_EnrollmentWaitStillRuns_WhenDcvCompletesButIssuanceWaitBud Times.AtLeastOnce); } + [Fact] + public async Task Dcv_AlreadyInFlight_DuplicateCallDefersWithoutPolling() + { + // Regression (round 3): when the _dcvInFlight duplicate-guard fires for a + // concurrent duplicate Enroll() call, dcvIssuanceWaitRan must also be set so the + // deferring call's general enrollment-wait poll doesn't run — otherwise it + // contradicts the log line's promise of an immediate pending return and doubles + // API traffic against CERTInext for the same order. + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + + var firstCallStarted = new TaskCompletionSource(); + var releaseFirstCall = new TaskCompletionSource(); + + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .Returns(async (string _, CancellationToken ct) => + { + firstCallStarted.TrySetResult(true); + await releaseFirstCall.Task; // hold the _dcvInFlight reservation open + return new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1", + DomainVerification = null + } + }; + }); + + mock.Setup(c => c.GetProductDetailsAsync(It.IsAny())) + .ThrowsAsync(new Exception("catalog endpoint down")); + mock.Setup(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId)); + + var validator = new FakeDomainValidator(); + var config = DcvConfig(); + config.EnrollmentWaitSeconds = 10; + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config); + + var firstEnroll = Enroll(plugin); + await firstCallStarted.Task; // first call now holds the _dcvInFlight reservation + + var secondResult = await Enroll(plugin); // duplicate — must see reserved=false and defer + + releaseFirstCall.TrySetResult(true); + var firstResult = await firstEnroll; + + secondResult.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "the duplicate call must return the pending result immediately, deferring entirely " + + "to the first in-flight caller instead of also polling"); + firstResult.Status.Should().Be((int)EndEntityStatus.GENERATED, + "the original in-flight caller is the one that should actually drive issuance"); + mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()), + Times.Exactly(1), "only the original in-flight caller may poll — the duplicate must not"); + } + [Fact] public async Task Dcv_SkipsStaging_AndDoesNotIssuancePoll_WhenAllDomainsAlreadyValidated_AndIssuanceBudgetZero() { diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index f21b6b0..ee78eaf 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1175,6 +1175,11 @@ private async Task EnrollNewAsync( "DCV is already in flight for order {OrderNumber}; Enroll will skip its own DCV attempt " + "and return the pending enroll response. The other caller will drive issuance.", orderNumber); + // Honor what was just logged: this call defers entirely to the other + // in-flight caller rather than also polling GetCertificate itself, which + // would double API traffic for the same order and contradict the message + // above promising an immediate pending return. + dcvIssuanceWaitRan = true; } else { @@ -1241,6 +1246,14 @@ private async Task EnrollNewAsync( "{OrderNumber}; returning the pending result so a later synchronization " + "completes it.", dcvTimeoutMinutes, dcvPhaseInFlight, orderNumber); + // A DcvTimeoutMinutes ceiling (default 10 minutes) firing means the CA + // endpoint was already unresponsive/unhealthy for that entire window — + // stacking a further up-to-(EnrollmentWaitSeconds+30s) GetCertificate poll + // against the same likely-still-unhealthy backend risks pushing the total + // Enroll() call past Command's own enrollment timeout, trading a clean + // pending result for a hung/aborted call. Treat the wait as having "run" + // so the general enrollment-wait gate doesn't pile on. + dcvIssuanceWaitRan = true; } finally { @@ -1493,11 +1506,16 @@ private async Task RenewOrReissueAsync( var renewResp = await _client.RenewCertificateAsync(priorCaRequestId, renewReq); var renewResult = BuildEnrollmentResult(renewResp, ep.AutoApprove); - // SOX: log the renewal outcome so the new certificate ID and status are - // independently recorded (the outer Enroll method also logs, but this - // ensures the renew path is auditable if the result is further transformed). + // SOX: log the CA's immediate response so the new certificate ID and its + // as-returned status are independently recorded. This is deliberately the + // PRE-WAIT status — the synchronous enrollment-wait poll below can still + // transform renewResult (e.g. pending → GENERATED); that outcome gets its own + // "Synchronous pickup complete" / exhaustion log line from + // TryEnrollmentWaitForCertificateAsync, so the two lines together (correlated + // by CARequestID) give the full before/after picture rather than this one line + // misrepresenting itself as the final outcome. _logger.LogInformation( - "Renewal via CERTInext renew API complete. " + + "Renewal via CERTInext renew API complete (pre-wait). " + "PriorCARequestID={PriorId}, NewCARequestID={NewId}, Status={Status}", priorCaRequestId, renewResult.CARequestID, renewResult.Status); From adeba85ec405035616b86b26d495513d2a6ef8f1 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:39:49 -0700 Subject: [PATCH 11/17] fix(enroll): close the last dcvIssuanceWaitRan fallthrough (no-guidance case) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full review cycle round 4, compliance audit finding (code review and security both clean this round). The issue-0007 branch (DCV enabled, no IDomainValidatorFactory injected) fell through to the general enrollment-wait poll without setting dcvIssuanceWaitRan when TryBuildManualDcvGuidanceAsync found no usable guidance to surface — the same gap already closed for the DCV-timeout and duplicate-in-flight cases in the previous round, just in the last remaining fallthrough path. An order stuck waiting on manual DNS/TXT publication is not waiting on CERTInext to complete issuance, so polling GetCertificate here is essentially guaranteed wasted work. Auditor's own assessment: bounded and logged either way, not a compliance gap — pure efficiency nit. Closed anyway for consistency with the pattern already applied everywhere else in this method. No new tests: existing coverage of this branch (Dcv_NoFactoryInjected_..., Dcv_NoFactoryWired_...) already runs with EnrollmentWaitSeconds at its disabled default, so this fallthrough was already unreachable in those tests either way. Both flavors green (242/209), 0 warnings. --- CERTInext/CERTInextCAPlugin.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index ee78eaf..8817218 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1280,6 +1280,12 @@ private async Task EnrollNewAsync( _logger.MethodExit(LogLevel.Debug); return pendingResult; } + // No usable guidance either (e.g. domain verification data hasn't appeared yet, + // or the TrackOrder probe itself failed) — this order is stuck waiting on manual + // DNS/TXT action that only an operator can take, not on CERTInext completing + // issuance, so a general enrollment-wait poll here is essentially guaranteed + // wasted work. Skip it rather than burn a bounded-but-still-costly attempt. + dcvIssuanceWaitRan = true; } #endif From 5152e1918a15f35bf69396236e6de1ac0ad5dd7d Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:22:00 -0700 Subject: [PATCH 12/17] fix(enroll): don't retry non-idempotent order/CSR submits on transient failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from the v1.0.1 hotfix (PR20). A network-level timeout can land after CERTInext has already created the order or received the CSR, so the previous blind retry re-sent the same requestTxn and was rejected as EMS-947 "Duplicate requestTxn" — failing the enrollment and orphaning the order the first attempt actually created. - ExecuteWithRetryAsync gains an idempotent flag; when false it sends exactly once and does not retry transient (5xx / no-status) failures. - PlaceOrderAsync and SubmitCsrAsync submit with idempotent:false and, on a transient failure, fail closed with actionable "if it was created it will be imported by the next sync — do not resubmit" guidance instead of deserializing an empty body. The rate-limit retry loop (fresh txn per attempt) is unaffected. - PlaceOrderAsync additionally classifies a surfaced EMS-947 as a benign duplicate rather than a generic hard failure. Both branches log the classification decision before throwing so the disposition is auditable. Three WireMock tests cover no-retry-on-transient for both submit paths (asserting exactly one call) and the EMS-947 guidance. Both flavors green (245/212), 0 warnings. --- CERTInext.Tests/CERTInextClientTests.cs | 83 ++++++++++++++++++++++++ CERTInext/Client/CERTInextClient.cs | 86 +++++++++++++++++++++++-- 2 files changed, 163 insertions(+), 6 deletions(-) diff --git a/CERTInext.Tests/CERTInextClientTests.cs b/CERTInext.Tests/CERTInextClientTests.cs index bdade13..f0f66bf 100644 --- a/CERTInext.Tests/CERTInextClientTests.cs +++ b/CERTInext.Tests/CERTInextClientTests.cs @@ -321,6 +321,89 @@ public async Task EnrollCertificateAsync_Throws_When5xxReturned() await act.Should().ThrowAsync(); } + // --------------------------------------------------------------------------- + // Non-idempotent submit safety — order/CSR submissions are NOT retried on a + // transient failure (a timeout may land after the CA already created the order, + // so a retry would be rejected as a duplicate and orphan the created order). + // --------------------------------------------------------------------------- + + [Fact] + public async Task EnrollCertificateAsync_DoesNotRetryOrderSubmit_OnTransient500() + { + // Persistent 5xx on the order submit. Unlike the idempotent Ping path (3 attempts), + // GenerateOrderSSL is non-idempotent — it must be attempted exactly once. + _server + .Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(500) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.ServerErrorJson())); + + var client = BuildClient(); + var req = new EnrollCertificateRequest { ProfileId = MockCertificateData.ProfileIdTls, Csr = MockCertificateData.FakeCsrPem }; + + Func act = () => client.EnrollCertificateAsync(req); + + await act.Should().ThrowAsync() + .WithMessage("*did not return a usable response*"); + + int orderCallCount = _server.LogEntries.Count(e => e.RequestMessage.Path == "/GenerateOrderSSL"); + orderCallCount.Should().Be(1, + "a non-idempotent order submit must not be retried on a transient failure (avoids EMS-947 duplicate/orphan)"); + } + + [Fact] + public async Task EnrollCertificateAsync_SurfacesDuplicateGuidance_OnEms947() + { + // 200 OK but meta failure EMS-947 "Duplicate requestTxn" — classified as a benign + // duplicate (order exists CA-side, next sync imports it), not a generic hard failure. + _server + .Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.ApiFailureJson("EMS-947", "Duplicate requestTxn."))); + + var client = BuildClient(); + var req = new EnrollCertificateRequest { ProfileId = MockCertificateData.ProfileIdTls, Csr = MockCertificateData.FakeCsrPem }; + + Func act = () => client.EnrollCertificateAsync(req); + + await act.Should().ThrowAsync() + .WithMessage("*duplicate order transaction*"); + } + + [Fact] + public async Task SubmitCsrAsync_DoesNotRetry_OnTransient500() + { + _server + .Given(Request.Create().WithPath("/SubmitCSR").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(500) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.ServerErrorJson())); + + var client = BuildClient(); + var req = new SubmitCsrRequest + { + OrderDetails = new SubmitCsrOrderDetails + { + OrderNumber = MockCertificateData.OrderNumber1, + RequestorEmail = "test@example.com", + Csr = MockCertificateData.FakeCsrPem + } + }; + + Func act = () => client.SubmitCsrAsync(req); + + await act.Should().ThrowAsync() + .WithMessage("*did not return a usable response*"); + + int csrCallCount = _server.LogEntries.Count(e => e.RequestMessage.Path == "/SubmitCSR"); + csrCallCount.Should().Be(1, + "a non-idempotent CSR submit must not be retried on a transient failure"); + } + // --------------------------------------------------------------------------- // GetCertificateAsync (legacy) — calls POST /TrackOrder then POST /GetCertificate // --------------------------------------------------------------------------- diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 5c4c058..96e3be8 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -216,7 +216,11 @@ public async Task PlaceOrderAsync( req.AddJsonBody(JsonSerializer.Serialize(request, GetJsonOptions())); var sw = System.Diagnostics.Stopwatch.StartNew(); - resp = await ExecuteWithRetryAsync(req, ct); + // idempotent:false — order submission is non-idempotent. A network-level + // timeout may occur after CERTInext already created the order, so re-sending the + // same requestTxn would be rejected as EMS-947 and orphan the created order. + // Rate-limit retries are still handled below (with a fresh txn). + resp = await ExecuteWithRetryAsync(req, ct, idempotent: false); sw.Stop(); Logger.LogInformation( @@ -232,6 +236,25 @@ public async Task PlaceOrderAsync( $"Authentication failure during certificate order. HTTP {(int)resp.StatusCode}. See gateway logs for details."); } + // Transient/network failure (5xx or no HTTP status) on a non-idempotent submit: + // CERTInext may have already created the order (the response just didn't reach us). + // We deliberately did not retry (see idempotent:false above). Fail clearly instead + // of deserializing an empty body; if the order was created, the next sync imports it. + bool transientFailure = !resp.IsSuccessful + && !((int)resp.StatusCode >= 400 && (int)resp.StatusCode < 500); + if (transientFailure) + { + Logger.LogWarning( + "PlaceOrder received no usable response (DomainName={Domain}, HttpStatus={Status}, LatencyMs={Latency}). " + + "Not retrying to avoid a duplicate order (EMS-947). If CERTInext created the order it " + + "will be imported by the next synchronization.", + request.OrderDetails?.CertificateInformation?.DomainName, (int)resp.StatusCode, sw.ElapsedMilliseconds); + throw new Exception( + "CERTInext did not return a usable response to the order submission. If the order was " + + "created it will be imported by the next synchronization — do not resubmit immediately. " + + "See gateway logs for details."); + } + result = DeserializeOrThrow(resp, "place order"); if (result.Meta != null && !result.Meta.IsSuccess) @@ -259,6 +282,29 @@ public async Task PlaceOrderAsync( continue; // retry } + // EMS-947 "Duplicate requestTxn": CERTInext already received an order for this + // transaction. With the non-idempotent-retry fix above this should no longer be + // caused by our own retry, but if it still surfaces the order exists on the CA + // side and will be imported by the next sync — say so, not a generic failure. + bool isDuplicateTxn = + string.Equals(result.Meta.ErrorCode, "EMS-947", StringComparison.OrdinalIgnoreCase) + || (result.Meta.ErrorMessage?.IndexOf("Duplicate requestTxn", StringComparison.OrdinalIgnoreCase) >= 0); + if (isDuplicateTxn) + { + // Log the classification decision itself (parity with the transient-failure + // branch above) so an auditor sees the plugin deliberately treated this as a + // benign duplicate rather than a hard failure. + Logger.LogWarning( + "PlaceOrder classified {ErrorCode} as a duplicate transaction (not a hard failure). " + + "DomainName={Domain}, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}. If an order exists " + + "for this transaction it will be imported by the next synchronization.", + result.Meta.ErrorCode, request.OrderDetails?.CertificateInformation?.DomainName, Constants.Api.GenerateOrderSslPath, (int)resp.StatusCode, sw.ElapsedMilliseconds); + throw new Exception( + "CERTInext reported a duplicate order transaction (EMS-947). If an order was created " + + "for this transaction it will be imported by the next synchronization — do not resubmit " + + "immediately. See gateway logs for details."); + } + throw new Exception( $"CERTInext order failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " + "See gateway logs for details."); @@ -300,7 +346,9 @@ public async Task SubmitCsrAsync(SubmitCsrRequest request, CancellationToken ct req.AddJsonBody(JsonSerializer.Serialize(request, GetJsonOptions())); var sw = System.Diagnostics.Stopwatch.StartNew(); - var resp = await ExecuteWithRetryAsync(req, ct); + // idempotent:false — submitting a CSR is non-idempotent; do not resend on a network + // timeout (the first attempt may have been received). See PlaceOrderAsync. + var resp = await ExecuteWithRetryAsync(req, ct, idempotent: false); sw.Stop(); Logger.LogInformation( @@ -310,6 +358,23 @@ public async Task SubmitCsrAsync(SubmitCsrRequest request, CancellationToken ct if (!resp.IsSuccessful) { LogApiFailure(Constants.Api.SubmitCsrPath, resp); + // Parity with PlaceOrderAsync: a transient/network failure on this non-idempotent + // submit was NOT retried, so record that decision (the CSR may already have been + // received). 4xx client errors fall through to the generic failure below. + bool transientFailure = !((int)resp.StatusCode >= 400 && (int)resp.StatusCode < 500); + if (transientFailure) + { + Logger.LogWarning( + "SubmitCSR received no usable response (OrderNumber={OrderNumber}, HttpStatus={Status}, " + + "LatencyMs={Latency}); not retrying (non-idempotent). If CERTInext already received the CSR, " + + "do not resubmit immediately.", + request.OrderDetails?.OrderNumber, (int)resp.StatusCode, sw.ElapsedMilliseconds); + // Parity with PlaceOrderAsync: carry the actionable guidance into the surfaced + // exception, not only the log line. + throw new Exception( + "CERTInext did not return a usable response to the CSR submission. If the CSR was received " + + "it will take effect — do not resubmit immediately. See gateway logs for details."); + } throw new Exception($"CERTInext SubmitCSR failed. HTTP {(int)resp.StatusCode}. See gateway logs for details."); } @@ -1217,14 +1282,23 @@ private async Task GetOrRefreshTokenAsync(CancellationToken ct) /// attempts, retrying on HTTP 5xx and network-level failures (no status code). /// 4xx responses are returned immediately — client errors will not be resolved /// by retrying. + /// + /// When is false the request is sent exactly + /// once and transient failures are NOT retried. This is required for non-idempotent + /// order-submission calls: a network-level timeout can occur *after* CERTInext has + /// already received and created the order, so re-sending the same body (same + /// requestTxn) is rejected as "Duplicate requestTxn" (EMS-947) and orphans the + /// order the first attempt actually created. /// private async Task ExecuteWithRetryAsync( RestRequest req, CancellationToken ct, - int maxAttempts = 3) + int maxAttempts = 3, + bool idempotent = true) { + int attempts = idempotent ? maxAttempts : 1; RestResponse resp = null; - for (int attempt = 1; attempt <= maxAttempts; attempt++) + for (int attempt = 1; attempt <= attempts; attempt++) { resp = await _http.ExecuteAsync(req, ct); @@ -1233,11 +1307,11 @@ private async Task ExecuteWithRetryAsync( if (resp.IsSuccessful || isClientError) return resp; - if (attempt < maxAttempts) + if (attempt < attempts) { Logger.LogWarning( "CERTInext API returned {Status} on attempt {Attempt}/{Max} — retrying...", - (int)resp.StatusCode, attempt, maxAttempts); + (int)resp.StatusCode, attempt, attempts); } } From a365f069f8254f2b113ab2b5c1fbfa34cffc828f Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:28:41 -0700 Subject: [PATCH 13/17] chore(enroll): audit provenance on validation-type gate; concise 1.2.0 changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-triage follow-ups: - Compliance (Low/optional): ResolveProductValidationTypeAsync now returns whether the OV/EV validation level came from the authoritative account catalog or the best-effort template-name fallback, and the enrollment-wait skip / poll-started log lines record it as ValidationTypeSource — so an auditor can tell an authoritative deferral from a name-based one (SOC2 CC7.2), matching the distinction the renewal path already logs. - Correctness (accepted design, was re-flagged): documented why the dcvDone==false path intentionally lets the general enrollment-wait poll run (it catches fast DV / cached-validation issuance — regression test Dcv_EnrollmentWaitStillRuns_WhenDcvShortCircuitsWithoutAnIssuanceWait). A blanket else setting dcvIssuanceWaitRan=true would silently defeat DV pickup on every DCV gateway; worst-case stacking stays within the DcvTimeoutMinutes envelope. No behavior change. - Trimmed the 1.2.0 changelog to be short and concise. Both flavors green (245/212), 0 warnings. --- CERTInext/CERTInextCAPlugin.cs | 43 +++++++++++++++++++++++++++------- CHANGELOG.md | 7 +++--- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 8817218..9c7aab0 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1231,6 +1231,21 @@ private async Task EnrollNewAsync( $"Post-DCV status: {postDcv.Status}.", ep.AutoApprove); } } + // Intentional: when PerformDcvIfNeededAsync returns false (no challenge + // slot within budget, no pending DNS-TXT domains, GetDcv not yet ready, + // cancelled/rejected order) dcvIssuanceWaitRan stays false so the general + // enrollment-wait poll below STILL runs. That path legitimately catches a + // fast DV / cached-validation issuance that CERTInext completes without ever + // exposing a challenge — see the regression test + // Dcv_EnrollmentWaitStillRuns_WhenDcvShortCircuitsWithoutAnIssuanceWait. + // The only short-circuits that set the flag + // true (skipping the poll) are the ones where the order definitively will + // NOT fast-issue in this call: the DcvTimeoutMinutes cancellation below, the + // _dcvInFlight duplicate guard, and the no-guidance branch. Worst case here + // (full challenge-wait budget + full enrollment-wait budget) stays within the + // DcvTimeoutMinutes envelope a DCV-enabled gateway already accepts; do NOT + // add a blanket `else` that sets the flag true — it silently defeats DV + // pickup on every DCV gateway. } catch (OperationCanceledException) when (dcvCts.IsCancellationRequested) { @@ -1701,20 +1716,24 @@ EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(budgetSeconds + 30)); var validationType = ProductValidationType.Unknown; + var validationTypeSource = "n/a"; if (pendingApproval) { - validationType = await ResolveProductValidationTypeAsync(productCode, ep.ProductId, cts.Token); + (validationType, validationTypeSource) = await ResolveProductValidationTypeAsync(productCode, ep.ProductId, cts.Token); if (validationType is ProductValidationType.Ov or ProductValidationType.Ev) { string typeLabel = validationType == ProductValidationType.Ov ? "OV" : "EV"; // SOC2 CC7.2: the decision to defer is policy-relevant — log at - // Information so it survives production log filters. + // Information so it survives production log filters. ValidationTypeSource + // records whether this was an authoritative (catalog) classification or a + // best-effort (template-name) one, so the deferral is reconstructable. _logger.LogInformation( "Synchronous enrollment wait skipped — {Type} products are issued asynchronously by " + "CERTInext (organization verification). OrderNumber={OrderNumber}, " + - "ProductCode={ProductCode}. The certificate will be imported by a later synchronization.", - typeLabel, orderNumber, productCode); + "ProductCode={ProductCode}, ValidationTypeSource={Source}. The certificate will be " + + "imported by a later synchronization.", + typeLabel, orderNumber, productCode, validationTypeSource); pendingResult.StatusMessage = $"Certificate request accepted by CERTInext. ID: {orderNumber}. " + @@ -1734,8 +1753,8 @@ EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) _logger.LogInformation( "Synchronous enrollment-wait poll started. OrderNumber={OrderNumber}, ProductCode={ProductCode}, " + - "ValidationType={ValidationType}, BudgetSeconds={Budget}, PollIntervalSeconds={Interval}", - orderNumber, productCode, validationType, budgetSeconds, Constants.Polling.CertificatePollIntervalSeconds); + "ValidationType={ValidationType}, ValidationTypeSource={Source}, BudgetSeconds={Budget}, PollIntervalSeconds={Interval}", + orderNumber, productCode, validationType, validationTypeSource, budgetSeconds, Constants.Polling.CertificatePollIntervalSeconds); var final = await WaitForIssuanceAsync(orderNumber, budgetSeconds, Constants.Polling.CertificatePollIntervalSeconds, "EnrollmentWait", cts.Token); @@ -1793,7 +1812,7 @@ EnrollmentResult DegradeBodylessIssuedToPending(EnrollmentResult r) /// product name (e.g. "OV SSL Wildcard") is the fallback when the catalog is /// unavailable or doesn't list the code. Never throws. /// - private async Task ResolveProductValidationTypeAsync( + private async Task<(ProductValidationType type, string source)> ResolveProductValidationTypeAsync( string productCode, string templateProductName, CancellationToken ct) { if (!string.IsNullOrWhiteSpace(productCode)) @@ -1809,11 +1828,17 @@ private async Task ResolveProductValidationTypeAsync( if (map != null && map.TryGetValue(productCode.Trim(), out var fromCatalog) && fromCatalog != ProductValidationType.Unknown) { - return fromCatalog; + // "catalog" — the authoritative account catalog classified this code. + return (fromCatalog, "catalog"); } } - return ProductClassifier.ClassifyName(templateProductName); + // "template-name" — best-effort classification from the Command template's + // product name because the catalog was unavailable or didn't list the code. + // Surfaced in the caller's audit log so an auditor can tell an authoritative + // OV/EV-skip decision from a name-based one (SOC2 CC7.2), mirroring the + // API-confirmed-vs-best-effort distinction the renewal path already records. + return (ProductClassifier.ClassifyName(templateProductName), "template-name"); } /// diff --git a/CHANGELOG.md b/CHANGELOG.md index a009d96..08f7f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ # 1.2.0 ## Features -- feat(enroll): `Enroll()` now runs a synchronous enrollment-wait poll on every enrollment path (new, reissue, and renewal) on both build flavors — DV orders that issue within the poll budget return the issued certificate in the same call instead of waiting for the next synchronization, restoring the behavior expiration-renewal workflows relied on with the legacy Sectigo connector. Configurable via the new `EnrollmentWaitSeconds` connector setting (default 50, polled every 5 seconds, ≈ maximum time an enrollment call occupies a Command worker thread, hard-capped at 300 s); set to `0` (or a negative value) to disable. Transient API failures consume a poll rather than aborting the wait. The post-DCV issuance poll's interval was also aligned to the same 5-second cadence. -- feat(enroll): OV/EV orders skip the enrollment-wait poll and return pending immediately with a status message explaining that CERTInext issues these products asynchronously by design (organization verification; confirmed by CERTInext support) — the certificate is imported by the next synchronization. The product's validation level is resolved from the account's product catalog (`GetProductDetails`, cached for 60 minutes), with the template product name as fallback. +- feat(enroll): `Enroll()` now briefly polls for the issued certificate on every enrollment path (new, reissue, renewal; both build flavors), so fast-issuing DV orders return the certificate in the same call instead of waiting for the next sync — restoring the legacy Sectigo connector's pickup behavior. New `EnrollmentWaitSeconds` setting (default 50, 5-second poll interval, hard-capped at 300 s); set to `0` to disable. +- feat(enroll): OV/EV orders skip the poll and return pending immediately — CERTInext issues them asynchronously by design (organization verification). Validation level is resolved from the account product catalog (cached 60 minutes), falling back to the template product name. ## Bug Fixes -- fix(build): The `-p:DcvSupport=false` (no-DCV, IAnyCAPlugin 3.2.0) flavor of `CERTInext.IntegrationTests` failed to compile — `CnameResolverLiveDnsTests.cs` references a helper defined in the DCV-only `DcvLifecycleTests.cs` and is itself a DCV feature test, so it is now excluded from the no-DCV build alongside the other DCV test files. +- fix(enroll): Order and CSR submissions are no longer retried after a transient/network failure. A timeout can land *after* CERTInext already created the order, so the retry was rejected as a duplicate (EMS-947) and orphaned the order; submits now fail closed and reconcile on the next sync. +- fix(build): The `-p:DcvSupport=false` flavor of `CERTInext.IntegrationTests` now compiles — DCV-only test files are excluded from the no-DCV build. # 1.1.0 From 02692898ea8e4e95677fdb62127ea96378d92a0b Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:20:57 -0700 Subject: [PATCH 14/17] test(integration): disable the general enrollment-wait poll by default Every live enroll in the integration suite was paying the full default EnrollmentWaitSeconds (~50s) budget: on the sandbox a freshly-submitted DV order stays pending (needs DCV), so the synchronous pickup poll ran to budget exhaustion before returning pending on every enroll test. Set EnrollmentWaitSeconds=0 in the fixture config (covers LifecycleTests, which uses it directly) and inherit it into DcvLifecycleTests.BuildPlugin. The wait's logic is fully covered by the unit suite; live DCV issuance is still exercised via the DCV post-issuance poll. Mirrors the unit suite's BuildPlugin, which already zeroes the pickup budget. Live suite: 41 passed, 26 skipped; the one failure (CnameResolverLiveDnsTests two-hop CNAME) is a DNS-propagation race in the test's own freshly-published records, unrelated to this change. --- CERTInext.IntegrationTests/DcvLifecycleTests.cs | 6 +++++- CERTInext.IntegrationTests/IntegrationTestFixture.cs | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CERTInext.IntegrationTests/DcvLifecycleTests.cs b/CERTInext.IntegrationTests/DcvLifecycleTests.cs index bf89812..4a1b42d 100644 --- a/CERTInext.IntegrationTests/DcvLifecycleTests.cs +++ b/CERTInext.IntegrationTests/DcvLifecycleTests.cs @@ -119,7 +119,11 @@ private CERTInextCAPlugin BuildPlugin(bool dcvEnabled, int propagationDelaySecon PageSize = pageSize ?? _fixture.Config.PageSize, DcvEnabled = dcvEnabled, DcvPropagationDelaySeconds = propagationDelaySeconds, - DcvTimeoutMinutes = 3 + DcvTimeoutMinutes = 3, + // Inherit the fixture's disabled general enrollment-wait (0). The DCV path has its + // own post-DCV issuance poll (DcvWaitForIssuanceSeconds); stacking the general + // 50s enrollment-wait poll on top would only slow the suite without new coverage. + EnrollmentWaitSeconds = _fixture.Config.EnrollmentWaitSeconds }; return new CERTInextCAPlugin(_fixture.Client, BuildDnsFactory(), config); diff --git a/CERTInext.IntegrationTests/IntegrationTestFixture.cs b/CERTInext.IntegrationTests/IntegrationTestFixture.cs index e96df3a..6a1dd7e 100644 --- a/CERTInext.IntegrationTests/IntegrationTestFixture.cs +++ b/CERTInext.IntegrationTests/IntegrationTestFixture.cs @@ -125,7 +125,14 @@ public IntegrationTestFixture() SignerPlace = "Gateway", SignerIp = "127.0.0.1", DefaultProductCode = ProductCode, - PageSize = 100 + PageSize = 100, + // Disable the synchronous enrollment-wait poll by default: on the sandbox a + // freshly-submitted DV order stays pending (it needs DCV), so the poll would + // burn its full EnrollmentWaitSeconds budget (~50s) on every enroll test + // before returning pending. The wait's own logic is covered by the unit suite; + // a test that specifically exercises live pickup can re-enable it on its own + // config. Mirrors the unit suite's BuildPlugin (PickupRetries/EnrollmentWaitSeconds=0). + EnrollmentWaitSeconds = 0 }; Client = new CERTInextClient(Config); From b01490ec0626780280bd7d67b608f0ea2cc9363a Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:28:46 -0700 Subject: [PATCH 15/17] docs(tests): document every test class and rename docs to README.md CERTInext.Tests and CERTInext.IntegrationTests each had several test classes (and individual tests within documented classes) that were never described in their project's test doc. Fill in the gaps and consolidate each project down to a single README.md so there's one place to look. --- .../INTEGRATION_TESTING.md | 163 ----- .../{TESTING.md => README.md} | 96 +++ CERTInext.Tests/README.md | 685 ++++++++++++++++++ CERTInext.Tests/TESTING.md | 412 ----------- docsource/architecture.md | 2 +- docsource/development.md | 2 +- 6 files changed, 783 insertions(+), 577 deletions(-) delete mode 100644 CERTInext.IntegrationTests/INTEGRATION_TESTING.md rename CERTInext.IntegrationTests/{TESTING.md => README.md} (55%) create mode 100644 CERTInext.Tests/README.md delete mode 100644 CERTInext.Tests/TESTING.md diff --git a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md deleted file mode 100644 index 3850303..0000000 --- a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md +++ /dev/null @@ -1,163 +0,0 @@ -# CERTInext Integration Tests - -This project contains xUnit integration tests that exercise the CERTInext plugin against -the live CERTInext REST API. All tests skip automatically when credentials are absent, -so the project is safe to include in CI pipelines that do not have API access. - ---- - -## Prerequisites - -- .NET 8 or .NET 10 SDK -- Access to a CERTInext account (sandbox or production) -- An API Access Key generated in the CERTInext portal under **Integrations → APIs** - ---- - -## Credential Setup - -Create the file `~/.env_certinext` with the following content: - -```sh -# CERTInext API credentials -CERTINEXT_API_URL=https://api.certinext.io/emSignHub-API/ -CERTINEXT_ACCESS_KEY=your-access-key-here -CERTINEXT_ACCOUNT_NUMBER=your-account-number -CERTINEXT_GROUP_NUMBER=your-group-number -CERTINEXT_ORG_NUMBER=your-org-number -CERTINEXT_PRODUCT_CODE=838 -CERTINEXT_REQUESTOR_EMAIL=you@example.com -CERTINEXT_REQUESTOR_NAME=Your Name -``` - -### Field reference - -| Variable | Required | Description | -|----------|----------|-------------| -| `CERTINEXT_API_URL` | Yes | Base URL of the CERTInext API, e.g. `https://api.certinext.io/emSignHub-API/` | -| `CERTINEXT_ACCESS_KEY` | Yes | REST API Access Key from the CERTInext portal (Integrations → APIs) | -| `CERTINEXT_ACCOUNT_NUMBER` | Yes | Your CERTInext account number (numeric string) | -| `CERTINEXT_GROUP_NUMBER` | No | Group number for order filtering | -| `CERTINEXT_ORG_NUMBER` | No | Organization number for order placement | -| `CERTINEXT_PRODUCT_CODE` | No | Default product code (e.g. `838` for DV SSL) | -| `CERTINEXT_REQUESTOR_EMAIL` | No | Email submitted with test orders | -| `CERTINEXT_REQUESTOR_NAME` | No | Name submitted with test orders | - -### API URL reference - -| Environment | URL | -|-------------|-----| -| Sandbox (US) | `https://sandbox-us-api.certinext.io/emSignHub-API/` | -| Production (US) | `https://us-api.certinext.io/emSignHub-API/` | -| Production (Global/India) | `https://api.certinext.io/emSignHub-API/` | - -### Credential file format - -The file is parsed line by line: -- Lines starting with `#` are treated as comments and ignored. -- Blank lines are ignored. -- Each line must be in `KEY=VALUE` format. -- Values are not quoted — do not surround values with `"` or `'`. -- Real environment variables override file values (useful for CI injection). - ---- - -## Running the Tests - -### Using dotnet CLI - -```sh -dotnet test CERTInext.IntegrationTests/ --verbosity normal -``` - -### Using the justfile - -```sh -just integration-test -``` - -### From the solution root (all tests including unit tests) - -```sh -dotnet test certinext-caplugin.sln --verbosity normal -``` - ---- - -## Skip Behaviour - -Each test calls `IntegrationSkip.IfNotConfigured(fixture)` at the top of the test method. -When `~/.env_certinext` is absent or either `CERTINEXT_API_URL` or `CERTINEXT_ACCESS_KEY` -is empty, every test is reported as **Skipped** rather than Failed. - -This makes the test project safe to include in CI pipelines where live credentials are -not available — the tests show up in the results as skipped rather than causing a -pipeline failure. - ---- - -## Test Classes - -### `ConnectivityTests` - -| Test | What it checks | -|------|---------------| -| `Ping_ReturnsSuccess` | Calls `ValidateCredentials` endpoint; asserts no exception is thrown | - -### `ProductTests` - -| Test | What it checks | -|------|---------------| -| `GetProductDetails_ReturnsProducts` | Calls `GetProductDetails`; asserts the call succeeds; when products are returned, asserts product code `838` is present | - -> Note: some CERTInext accounts return an empty list from `GetProductDetails` even though -> orders using those product codes are visible in `GetOrderReport`. An empty list is -> treated as acceptable in this test — only the absence of an exception is mandatory. - -### `OrderReportTests` - -| Test | What it checks | -|------|---------------| -| `GetOrderReport_ReturnsOrders` | Fetches page 1; asserts at least one order is returned | -| `GetOrderReport_AllOrders_HaveRequiredFields` | For each order on page 1: `requestNumber`, `productCode`, and `orderDate` are non-empty | - -### `PluginSmokeTests` - -End-to-end tests exercising `CERTInextCAPlugin` via the `IAnyCAPlugin` interface with -a live `CERTInextClient` injected through the `(ICERTInextClient, CERTInextConfig)` -test constructor. - -| Test | What it checks | -|------|---------------| -| `Ping_ThroughPlugin_Succeeds` | Calls `IAnyCAPlugin.Ping()`; asserts no exception | -| `GetProductIds_ReturnsAtLeastOneProduct` | Calls `IAnyCAPlugin.GetProductIds()`; asserts a non-null list is returned without throwing | -| `Synchronize_ReturnsAtLeastOneRecord` | Runs a full sync; asserts at least one `AnyCAPluginCertificate` record is produced | - ---- - -## Authentication - -The CERTInext API uses HMAC-SHA256 authentication computed for every request: - -``` -authKey = SHA256(accessKey + ts + txn) (lowercase hex) -``` - -Where: -- `accessKey` is the raw API Access Key from `CERTINEXT_ACCESS_KEY` -- `ts` is the current timestamp in ISO 8601 format -- `txn` is a random numeric transaction ID - -The `CERTInextClient` handles this computation automatically. The raw access key is -never transmitted over the wire — only the derived `authKey` hash is sent. - ---- - -## Troubleshooting - -| Symptom | Likely cause | Fix | -|---------|-------------|-----| -| All tests skipped | Missing or empty `~/.env_certinext` | Create the file with required variables | -| `Ping` fails with 401 | Wrong `CERTINEXT_ACCESS_KEY` | Regenerate the key in the CERTInext portal | -| `Ping` fails with timeout | Wrong `CERTINEXT_API_URL` | Verify the URL matches your account region | -| `GetOrderReport` returns 0 orders | Account has no orders | Place a test order first (see `just generate-order` in the project justfile) | diff --git a/CERTInext.IntegrationTests/TESTING.md b/CERTInext.IntegrationTests/README.md similarity index 55% rename from CERTInext.IntegrationTests/TESTING.md rename to CERTInext.IntegrationTests/README.md index 1453658..c78bca7 100644 --- a/CERTInext.IntegrationTests/TESTING.md +++ b/CERTInext.IntegrationTests/README.md @@ -190,6 +190,98 @@ account. These tests do not require any pre-existing account state. |------|---------------| | `Enroll_Synchronize_Revoke_FullLifecycle` | (1) Generates a fresh RSA-2048 CSR; (2) calls `Enroll` and asserts a non-empty `CARequestID` is returned; (3) runs a full sync and asserts the new order appears by `CARequestID`; (4) attempts revocation — skips gracefully if the order is not yet in an issued/approved state | +### `SmokeTests` + +An older, broader smoke-test class that predates the more focused classes above. Its +`Ping_Succeeds`, `GetProductDetails_ReturnsProducts`, and `ListOrders_ReturnsFirstPage` +tests cover the same ground as `ConnectivityTests`, `ProductTests`, and `OrderReportTests` +respectively (calling `ICERTInextClient` directly rather than going through the plugin), +and `Synchronize_DumpsAllRecords` overlaps with `PluginSmokeTests.Synchronize_ReturnsAtLeastOneRecord`. +It has not been removed because it still carries two scenarios the newer classes don't +cover: `TrackOrder_ReturnsDetails` and the per-order sweep in `GetSingleRecord_ForAllOrders_AllSucceed`. +All tests here are gated by `IntegrationSkip.IfNotConfigured`. + +| Test | What it checks | +|------|---------------| +| `Ping_Succeeds` | Calls `ICERTInextClient.PingAsync`; asserts no exception (overlaps `ConnectivityTests.Ping_ReturnsSuccess`) | +| `GetProductDetails_ReturnsProducts` | Calls `ICERTInextClient.GetProductDetailsAsync`; asserts a non-empty product list (overlaps `ProductTests.GetProductDetails_ReturnsProducts`) | +| `ListOrders_ReturnsFirstPage` | Iterates `ICERTInextClient.ListOrdersAsync(pageSize: 10)`, capped at 10 entries; asserts at least one order is returned (overlaps `OrderReportTests.GetOrderReport_ReturnsOrders`) | +| `TrackOrder_ReturnsDetails` | Requires `CERTINEXT_ORDER_ID` env var (skips if unset); calls `ICERTInextClient.TrackOrderAsync`; asserts a non-null `OrderDetails` and logs status/DCV fields | +| `GetSingleRecord_ReturnsRecord` | Requires `CERTINEXT_ORDER_ID` env var (skips if unset); builds a plugin via the `(client, config)` test constructor and calls `GetSingleRecord`; asserts a non-null record | +| `GetSingleRecord_ForAllOrders_AllSucceed` | Lists every order on the account, then calls `GetSingleRecord` for each; asserts every call succeeds (no per-order failures) regardless of certificate status | +| `Synchronize_DumpsAllRecords` | Runs a full `plugin.Synchronize`; asserts the account returns at least one record and logs up to 20 of them (overlaps `PluginSmokeTests.Synchronize_ReturnsAtLeastOneRecord`) | + +### `DcvLifecycleTests` + +End-to-end tests for the DNS DCV enrollment path, run through `CERTInextCAPlugin` +directly (not the `IAnyCAPlugin` interface). DNS validator selection: when +`CERTINEXT_CF_API_TOKEN` and `CERTINEXT_CF_ZONE_ID` are set, a real `CloudflareDomainValidator` +publishes and cleans up an actual TXT record around the enrollment; otherwise a +`StubDomainValidator` is used and the plugin still runs the full DCV orchestration +path (Stage → propagation wait → VerifyDcv → Cleanup), but CERTInext's own DCV +verification is not guaranteed to succeed. `CERTINEXT_DCV_DOMAIN` overrides the +domain used (default `dcv-test.example.com`). All tests are gated by +`IntegrationSkip.IfNotConfigured`; several are additionally opt-in or require extra +environment variables, noted below. + +| Test | What it checks | +|------|---------------| +| `DcvEnroll_CompletesWithoutThrowing` | Enrolls a DV cert with `DcvEnabled=true` against `CERTINEXT_DCV_DOMAIN`; with real Cloudflare DNS asserts the result is `GENERATED` or `EXTERNALVALIDATION`; with the stub validator only asserts a non-null result (VerifyDcv may legitimately fail) | +| `EnrollWithoutDcv_DoesNotInvokeDnsProvider` | Enrolls with `DcvEnabled=false`; asserts the plugin still returns a non-null result via the normal (non-DCV) enrollment flow | +| `EnrollWithDcvOff_OrderAppearsInSync_PluginDidNotInvokeDcv` | Enrolls a fresh random subdomain with `DcvEnabled=false`, then runs `Synchronize`; asserts the order surfaces with `EXTERNALVALIDATION` or `GENERATED` (never `FAILED`) — live verification for GitHub issue #7 that DCV-off does not invoke the DNS provider | +| `EnrollWithDcvOn_OrderIssuedEndToEnd_AndAppearsInSync` | Enrolls a fresh random subdomain with `DcvEnabled=true`, drives DCV via Cloudflare TXT publish/verify, then syncs; asserts the enrolled order reaches `GENERATED` with a parseable cert PEM, and that `GetSingleRecord` returns the same PEM (regression for issue 0001's cert-body-on-sync fix) | +| `EnrollWithDcvOn_IssuesPerKeyAlgorithm` (theory, 10 rows — see `KeyAlgorithms`: RSA-2048/3072/4096/6144/8192, ECDSA-P256/P384/P521, Ed25519, Ed448) | Opt-in via `CERTINEXT_ALGO_MATRIX_DCV=1`, requires Cloudflare DCV credentials. For each algorithm, enrolls a fresh scrup.org DV order, drives DCV to issuance, and asserts the issued cert's public key matches the requested algorithm/size. A CA-side rejection at submission, a `FAILED` order, or an order that doesn't reach `GENERATED` within the polling window is reported as an explicit `Skip` carrying the observed reason rather than a hard failure | +| `GetSingleRecord_DrivesDcvForPendingOrder` | Requires `CERTINEXT_PENDING_ORDER_ID` env var (skips if unset) and Cloudflare DCV credentials (skips if absent). Calls `GetSingleRecord` against a real pending order parked at "Pending System RA"/`dcvStatus=0`; asserts the deferred-DCV retry runs (TXT publish → VerifyDcv → wait → cleanup) and returns `GENERATED` or `EXTERNALVALIDATION` rather than silently no-op'ing | +| `BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks` | Opt-in via `CERTINEXT_RUN_BULK_TEST=1` (default count 101, overridable via `CERTINEXT_BULK_TEST_COUNT`/`CERTINEXT_BULK_TEST_PARALLEL`), requires Cloudflare DCV credentials. Enrolls the configured count of DV orders concurrently, then repeatedly runs `Synchronize` (PageSize=100) until every order reaches `GENERATED` or the pass budget is exhausted; asserts every enrollment succeeds, every order appears in sync, and sync returns >100 records (proves the `ListCertificatesAsync` paginator crosses the page boundary) | +| `CompleteAllPendingDvOrders` | Opt-in via `CERTINEXT_COMPLETE_PENDING=1`, requires Cloudflare DCV credentials. Operational cleanup task — enrolls nothing; repeatedly runs `Synchronize` to drive every existing `EXTERNALVALIDATION` order to `GENERATED`, asserting no order remains pending after the pass budget | +| `FullSync_AllIssuedCerts_CarryParseableCertificateBody` | Runs a full `Synchronize` with `DcvEnabled=false`; asserts the account has at least one `GENERATED` record and every `GENERATED` record carries a parseable certificate PEM body (regression for issue 0001 — the order-report listing carries no body, so the plugin must refetch it) | + +### `AlgorithmMatrixTests` + +Coverage matrix for the CSR key algorithm/size the plugin submits, since every other +test in the suite hardcodes an RSA-2048 CSR. Covers 10 algorithm tags (see +`KeyAlgorithms.All`): `RSA-2048`, `RSA-3072`, `RSA-4096`, `RSA-6144`, `RSA-8192`, +`ECDSA-P256`, `ECDSA-P384`, `ECDSA-P521`, `Ed25519`, `Ed448`. This class only covers +CSR validity and CA submission acceptance — the end-to-end "does CERTInext actually +*issue* this algorithm" matrix (DCV on, real issuance) lives in +`DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm`. + +| Test | What it checks | +|------|---------------| +| `Csr_RoundTripsKeyAlgorithm` (theory, all 10 algorithm tags) | Fully offline, no API, always runs (not gated by `IntegrationSkip`). Generates a CSR for each algorithm via BouncyCastle, re-parses it, and asserts the request signature verifies and the public key type/size (RSA modulus bits, EC field size, or Ed25519/Ed448 key type) round-trips correctly | +| `Enroll_AcceptsKeyAlgorithm` (theory, all 10 algorithm tags) | Gated by `IntegrationSkip.IfNotConfigured` and opt-in via `CERTINEXT_ALGO_MATRIX=1` (each run creates a real, non-issued DV order on the sandbox — no DCV is performed, so orders park at `EXTERNALVALIDATION` and are not cleaned up). Submits a real order per algorithm and asserts CERTInext accepts it (returns a `CARequestID`); a CA-side rejection is reported as an explicit `Skip` carrying the classified reason (unsupported key size vs. insufficient credits) rather than a failure | + +### `CnameResolverLiveDnsTests` + +Live-DNS validation for the production `Dcv.CnameResolver` (issue 0006), exercised +against real public DNS via `DnsClient.NET` rather than a fake single-hop delegate. +Deliberately does **not** go through CERTInext order placement — it only stages +CNAME records in the Cloudflare zone used for DCV tests and resolves them. Neither +test calls `IntegrationSkip.IfNotConfigured` and neither hits the CERTInext API at +all; both only require Cloudflare DNS credentials (`Skip.If(!_fixture.IsCloudflareConfigured, ...)` +— i.e. `CERTINEXT_CF_API_TOKEN`, `CERTINEXT_CF_ZONE_ID`, and `CERTINEXT_DCV_DOMAIN`). +Because this class depends only on live public DNS, its behavior does not vary with +CERTInext account state (fresh sandbox vs. account with history). + +| Test | What it checks | +|------|---------------| +| `ResolveTerminalNameAsync_FollowsRealTwoHopCnameChain` | Creates a two-hop CNAME chain (hopA → hopB → hopC, where hopC is never created and is therefore terminal) in the Cloudflare zone, then asserts `CnameResolver.ResolveTerminalNameAsync` walks the real chain to hopC, retrying up to 8 times (3s apart) to absorb DNS propagation delay | +| `ResolveTerminalNameAsync_NoCname_ReturnsInputUnchanged` | Resolves the DCV domain apex (which carries ordinary A/AAAA/TXT records, no CNAME); asserts the resolver returns the input name unchanged (terminal-on-first-hop path against real DNS) | + +### `IntegrationTestFixtureTests` + +Pure unit tests for the `~/.env_certinext` line parser (`IntegrationTestFixture.ParseEnvValue`), +riding inside the integration test project rather than exercising the CERTInext API. +None of these tests call `IntegrationSkip.IfNotConfigured` and none use `[SkippableFact]` +— they are plain xUnit `[Fact]`/`[Theory]` tests that always run, with no credentials +or account state required. + +| Test | What it checks | +|------|---------------| +| `ParseEnvValue_HandlesQuotingAndWhitespace` (theory, 11 rows) | Asserts whitespace trimming and single-pair quote stripping (double or single quotes) for plain, padded, quoted, empty-quoted, mismatched-quote, and blank inputs — regression for GitHub issue #8, where a shell-style quoted value was parsed with the quote characters still included | +| `ParseEnvValue_NullInput_ReturnsEmptyString` | Asserts a `null` input returns `string.Empty` rather than throwing | +| `ParseEnvValue_DoesNotStripEmbeddedQuotes` | Asserts quotes embedded in the middle of a value (not matching outer wrappers) are left untouched | + --- ## Expected Outcomes by Account State @@ -203,6 +295,8 @@ account. These tests do not require any pre-existing account state. | `OrderReportTests` | Skip — "account has no orders yet" | | `PluginSmokeTests.Synchronize_ReturnsAtLeastOneRecord` | Skip — "account has no certificate records yet" | | `LifecycleTests.Enroll_Synchronize_Revoke_FullLifecycle` | Skip with "Invalid Product Code" if `CERTINEXT_PRODUCT_CODE` is not provisioned for this account; otherwise the enroll and sync steps pass, and the revoke step skips because the DV SSL sandbox order requires domain control verification and RA approval before it reaches an issued/revocable state | +| `SmokeTests` | `TrackOrder_ReturnsDetails` and `GetSingleRecord_ReturnsRecord` skip unless `CERTINEXT_ORDER_ID` is set; `GetSingleRecord_ForAllOrders_AllSucceed` and `Synchronize_DumpsAllRecords` pass trivially against zero orders | +| `DcvLifecycleTests` | Core tests (`DcvEnroll_CompletesWithoutThrowing`, `EnrollWithoutDcv_DoesNotInvokeDnsProvider`, the two `EnrollWithDcvO*_...AppearsInSync` tests) run regardless of account history; the opt-in tests (`EnrollWithDcvOn_IssuesPerKeyAlgorithm`, `BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks`, `CompleteAllPendingDvOrders`) are skipped unless explicitly enabled via their env-var flags; `GetSingleRecord_DrivesDcvForPendingOrder` skips unless `CERTINEXT_PENDING_ORDER_ID` is set | ### Account with history (orders previously placed) @@ -213,6 +307,8 @@ account. These tests do not require any pre-existing account state. | `OrderReportTests` | Pass | | `PluginSmokeTests` | Pass | | `LifecycleTests` | Pass (all three steps) | +| `SmokeTests` | Pass (all seven tests, given `CERTINEXT_ORDER_ID` is set for the two order-specific tests) | +| `DcvLifecycleTests` | Core tests pass; opt-in tests pass when their env-var flags and Cloudflare DCV credentials are set | --- diff --git a/CERTInext.Tests/README.md b/CERTInext.Tests/README.md new file mode 100644 index 0000000..00b5f3a --- /dev/null +++ b/CERTInext.Tests/README.md @@ -0,0 +1,685 @@ +# CERTInext CA Plugin — Unit Test Suite Reference + +## Overview + +The `CERTInext.Tests` project contains unit and contract tests for the CERTInext AnyCA Gateway +REST plugin. No external services are required — all HTTP I/O is handled in-process by WireMock.Net +or replaced by Moq strict mocks. + +The project is split into several focused test classes: + +| Class | Layer under test | Isolation technique | +|---|---|---| +| `CERTInextClientTests` | `CERTInextClient` HTTP transport | WireMock.Net (real loopback HTTP) | +| `CERTInextClientRequestShapeTests` | `CERTInextClient` request body construction | WireMock.Net | +| `CERTInextClientCoverageTests` | `CERTInextClient` auth-failure branches & OAuth2 edge cases | WireMock.Net | +| `CERTInextCAPluginTests` | `CERTInextCAPlugin` IAnyCAPlugin logic | Moq strict mock of `ICERTInextClient` | +| `CERTInextCAPluginCoverageTests` | Additional plugin logic paths | Moq strict mock | +| `CERTInextCAPluginDcvTests` | DCV staging/verification/cleanup orchestration in `CERTInextCAPlugin` | Moq strict mock + `FakeDomainValidator` | +| `CERTInextCAPluginEnrollmentWaitTests` | Post-enroll synchronous polling/wait logic in `CERTInextCAPlugin` | Moq strict mock of `ICERTInextClient` | +| `CERTInextCAPluginPublicSurfaceTests` | Binary-compat / no-DCV surface contract | Reflection only | +| `BoundedDcvSyncTests` | DCV sync age/cap filter logic | Pure unit (no I/O) | +| `RateLimitRetryTests` | Rate-limit back-off helpers | Pure unit (no I/O) | +| `CnameResolverTests` | `CnameResolver` CNAME chain resolution (DCV delegation) | Pure unit (no I/O) | +| `ExtractSerialFromPemTests` | PEM serial-number extraction | Pure unit (no I/O) | +| `RedactCredentialsTests` | Log credential-redaction helper | Pure unit (no I/O) | + +If a test fails in `CERTInextClientTests` or `CERTInextClientRequestShapeTests`, the bug is in +HTTP transport or request serialisation. If it fails in `CERTInextCAPluginTests` or +`CERTInextCAPluginCoverageTests`, the bug is in plugin logic. + +--- + +## Running the Tests + +**Prerequisites:** +- .NET 8 or .NET 10 SDK +- NuGet packages restored (`dotnet restore`) +- No external services required + +**Run all tests:** +```bash +dotnet test CERTInext.Tests/ +``` + +**Run a single test class:** +```bash +dotnet test --filter "FullyQualifiedName~CERTInextClientTests" +dotnet test --filter "FullyQualifiedName~CERTInextCAPluginTests" +``` + +**Run a specific test by name:** +```bash +dotnet test --filter "DisplayName~OAuth2_TokenIsCached" +``` + +Each `CERTInextClientTests` instance starts a fresh `WireMockServer` in its constructor and +stops it in `Dispose()`, so tests are isolated and can run in parallel without port conflicts. + +--- + +## Authentication model + +The real CERTInext API uses HTTP POST for **all** endpoints. There is no Authorization header +for AccessKey mode. Instead, every request body includes a `meta` block containing: + +- `authKey` — `SHA256(accessKey + requestTs + requestTxnId)` (lowercase hex) +- `ts` — ISO 8601 timestamp +- `txn` — unique transaction UUID + +The raw access key is never transmitted — only the derived hash is sent. + +`AuthMode` accepted values: +- `AccessKey` (primary) — HMAC signed body +- `OAuth` (alternative) — bearer token via client credentials flow +- `ApiKey`, `AccessKeyLegacy`, `OAuthLegacy` — legacy aliases accepted for backward compatibility + +--- + +## CERTInextClientTests + +The test class implements `IDisposable`. A `WireMockServer` is started on a random available port +in the constructor. All tests build a `CERTInextClient` pointed at `_server.Urls[0]`. + +Two helper methods build clients: +- `BuildClient(authMode, apiKey)` — builds an AccessKey-authenticated client + (defaults: `authMode="AccessKey"`, `apiKey="test-key"`, `accountNumber="12345"`) +- `BuildOAuthClient(tokenUrl)` — builds an OAuth client with `client_id="my-client"`, + `client_secret="my-secret"` + +### PingAsync — POST /ValidateCredentials + +| Test | Stub | Assertion | +|------|------|-----------| +| `PingAsync_ReturnsHealthy_WhenServerRespondsOk` | `POST /ValidateCredentials` → 200, success meta | Does not throw; WireMock log contains a request to `/ValidateCredentials` | +| `PingAsync_Throws_When500Returned` | `POST /ValidateCredentials` → 500, server error body | Throws `Exception` with message containing `"health check failed"` | +| `PingAsync_Throws_WhenMetaStatusIsFailure` | `POST /ValidateCredentials` → 200, failure meta (`EMS-001`, `"Invalid credentials"`) | Throws `Exception` with message containing `"credential validation failed"` | + +### OAuth2 Token Fetch, Caching, and Injection + +| Test | Stub | Assertion | +|------|------|-----------| +| `OAuth2_FetchesToken_BeforeFirstApiCall` | `POST /oauth/token` → token JSON; `POST /ValidateCredentials` → 200 | Log contains both `/oauth/token` and `/ValidateCredentials` | +| `OAuth2_TokenIsCached_SecondCallDoesNotRefetch` | Same stubs | `PingAsync` called twice; `/oauth/token` appears exactly once; `/ValidateCredentials` appears twice | +| `OAuth_InjectsBearerToken_InAuthorizationHeader` | Token endpoint → `fake-bearer-token-abc123`; `/ValidateCredentials` → 200 | WireMock log entry for `/ValidateCredentials` carries `Authorization: Bearer fake-bearer-token-abc123` | +| `OAuth_DoesNotInjectBearerToken_InAccessKeyMode` | `/ValidateCredentials` → 200 | WireMock log entry has no `Authorization` header | + +### Retry logic + +| Test | Stub | Assertion | +|------|------|-----------| +| `ExecuteWithRetry_MakesThreeAttempts_WhenServerAlwaysReturns500` | `/ValidateCredentials` always → 500 | `PingAsync` throws; WireMock log has exactly 3 requests (3 total attempts, 4xx are not retried) | + +### EnrollCertificateAsync — POST /GenerateOrderSSL + +| Test | Stub | Assertion | +|------|------|-----------| +| `EnrollCertificateAsync_ReturnsCertificate_WhenServerIssues` | `POST /GenerateOrderSSL` → 200, success meta + `orderDetails.orderNumber="ORD-AAA-111"` | Result not null; `OrderNumber == "ORD-AAA-111"` | +| `EnrollCertificateAsync_ReturnsPending_WhenServerReturnsPendingApproval` | `POST /GenerateOrderSSL` → 200, pending response | Status maps to pending | +| `EnrollCertificateAsync_Throws_WhenGenerateOrderFails` | `POST /GenerateOrderSSL` → 200, failure meta (EMS-918) | Throws `Exception` containing the API error message | +| `EnrollCertificateAsync_Throws_When5xxReturned` | `POST /GenerateOrderSSL` → 500 | Throws `Exception` | +| `EnrollCertificateAsync_Throws_When401Returned` | `POST /GenerateOrderSSL` → 401 | Throws `Exception` | +| `EnrollCertificateAsync_DoesNotRetryOrderSubmit_OnTransient500` | `POST /GenerateOrderSSL` → persistent 500 | Throws `Exception` containing `"did not return a usable response"`; exactly 1 request logged — a non-idempotent order submit must not be retried (avoids EMS-947 duplicate/orphan) | +| `EnrollCertificateAsync_SurfacesDuplicateGuidance_OnEms947` | `POST /GenerateOrderSSL` → 200, failure meta `EMS-947` ("Duplicate requestTxn") | Throws `Exception` containing `"duplicate order transaction"` — classified as a benign duplicate, not a generic hard failure | + +### SubmitCsrAsync — POST /SubmitCSR + +| Test | Stub | Assertion | +|------|------|-----------| +| `SubmitCsrAsync_DoesNotRetry_OnTransient500` | `POST /SubmitCSR` → persistent 500 | Throws `Exception` containing `"did not return a usable response"`; exactly 1 request logged — a non-idempotent CSR submit must not be retried | + +### GetCertificateAsync — POST /GetCertificate + +| Test | Stub | Assertion | +|------|------|-----------| +| `GetCertificateAsync_ReturnsCertificate_WhenFound` | `POST /GetCertificate` → 200, PEM in `certificateDetails.endEntityCertificate` | PEM contains `"BEGIN CERTIFICATE"`; serial `"0A1B2C3D4E5F"` | +| `GetCertificateAsync_ThrowsKeyNotFound_WhenOrderNotFound` | `POST /GetCertificate` → 200, failure meta (EMS-not-found) | Throws `KeyNotFoundException` | + +### RevokeCertificateAsync — POST /RevokeOrder + +| Test | Stub | Assertion | +|------|------|-----------| +| `RevokeCertificateAsync_Succeeds_When200Returned` | `POST /RevokeOrder` → 200, success meta | Does not throw | +| `RevokeCertificateAsync_Throws_WhenServerReturnsFailure` | `POST /RevokeOrder` → 200, failure meta | Throws `Exception` | + +### RenewCertificateAsync — POST /GenerateOrderSSL + +CERTInext has no dedicated renewal endpoint. `RenewCertificateAsync` submits a new +`GenerateOrderSSL` order. The test verifies that the correct endpoint and body are used. + +| Test | Stub | Assertion | +|------|------|-----------| +| `RenewCertificateAsync_ReturnsNewCertificate_OnSuccess` | `POST /GenerateOrderSSL` → 200, success with new order number | New order number returned | + +### ListCertificatesAsync — POST /GetOrderReport (paginated) + +`ListCertificatesAsync` is an `IAsyncEnumerable` that paginates +`GetOrderReport`. Pagination stops when the returned page is empty or all pages are fetched. + +| Test | Stub | Assertion | +|------|------|-----------| +| `ListCertificatesAsync_ReturnsSinglePage_WhenOnlyOnePage` | `POST /GetOrderReport` → single-page with `ORD-AAA-111` | Enumeration yields exactly 1 item | +| `ListCertificatesAsync_IteratesMultiplePages` | Two pages: page 1 (`ORD-AAA-111`), page 2 (`ORD-BBB-222`) | Enumeration yields 2 items; both order numbers present | +| `ListCertificatesAsync_StopsWhenEmptyPageReturned` | `POST /GetOrderReport` → empty `ordersArray` | Enumeration yields 0 items | +| `ListCertificatesAsync_RespectsIssuedAfterFilter` | Any request with `issuedAfter` parameter → single-page | Enumeration yields 1 item; `issuedAfter` key present in the request log | + +### GetProfilesAsync — POST /GetProductDetails + +| Test | Stub | Assertion | +|------|------|-----------| +| `GetProfilesAsync_ReturnsProfiles_WhenServerResponds` | `POST /GetProductDetails` → two products in nested category envelope | Result has 2 items; `ProfileIdTls` and `ProfileIdClient` present; all `Active == true` | +| `GetProfilesAsync_ReturnsEmptyList_WhenNoProductsReturned` | `POST /GetProductDetails` → empty `productDetails` array | Result is empty | + +### DCV endpoints + +| Test | Stub | Assertion | +|------|------|-----------| +| `GetDcvAsync_ReturnsToken_WhenServerRespondsOk` | `POST /GetDcv` → 200, `dcvDetails.token="abc123token"` | Returns token string | +| `GetDcvAsync_Throws_WhenMetaStatusIsFailure` | `POST /GetDcv` → 200, failure meta | Throws `Exception` | +| `GetDcvAsync_Throws_WhenServerReturns401` | `POST /GetDcv` → 401 | Throws `Exception` | +| `VerifyDcvAsync_Succeeds_WhenServerRespondsOk` | `POST /VerifyDcv` → 200, success meta | Does not throw | +| `VerifyDcvAsync_Throws_WhenMetaStatusIsFailure` | `POST /VerifyDcv` → 200, failure meta | Throws `Exception` | +| `VerifyDcvAsync_Throws_WhenServerReturns401` | `POST /VerifyDcv` → 401 | Throws `Exception` | +| `VerifyDcvAsync_Throws_WhenServerReturns500` | `POST /VerifyDcv` → 500 | Throws `Exception` | + +--- + +## CERTInextClientRequestShapeTests + +Uses WireMock to verify that the `GenerateOrderSSL` request body includes or omits optional +blocks depending on connector configuration. + +| Test | Assertion | +|------|-----------| +| `OrganizationNumber_Set_EmitsPreVettedOrganizationDetails` | Body includes `organizationDetails.preVetting="1"` and the configured `organizationNumber` | +| `OrganizationNumber_Blank_OmitsOrganizationDetailsBlock` | Body omits `organizationDetails` entirely | +| `GroupNumber_Set_EmitsDelegationInformation` | Body includes `delegationInformation.groupNumber` | +| `GroupNumber_Blank_OmitsDelegationInformation` | Body omits `delegationInformation` | +| `TechnicalContact_AllSet_EmitsExplicitValues` | Body includes `technicalPointOfContact` with the configured values | +| `TechnicalContact_AllBlank_FallsBackToRequestorDefaults` | Body includes `technicalPointOfContact` fields derived from `RequestorName`/`RequestorEmail` | +| `SslBodyDefaults_AreEmitted_FromCustomConnectorValues` | Custom connector-level defaults appear in the order body | +| `SslBodyDefaults_AreSafeFallbacks_WhenConfigUntouched` | Default values are emitted without throwing when optional config fields are omitted | +| `ValidityDays_OnRequest_OverridesConnectorDefault` | `ValidityDays` template parameter overrides the connector `SubscriptionValidityYears` | +| `ValidityYears_OnRequest_OverridesConnectorDefaultAndValidityDays` | `ValidityYears=3` on the request wins over both `ValidityDays=730` and the connector's `SubscriptionValidityYears="1"` default — body's `subscriptionDetails.validity == "3"` | +| `ValidityYears_Unset_FallsBackToValidityDaysThenConnectorDefault` | With `ValidityYears` unset on the request, the connector's `SubscriptionValidityYears="2"` default is used — body's `subscriptionDetails.validity == "2"` | + +--- + +## CERTInextClientCoverageTests + +WireMock tests for auth-failure branches and OAuth2 error conditions in `CERTInextClient` that +aren't exercised by `CERTInextClientTests`. Uses the same `BuildClient`/`BuildOAuthClient` helper +pattern against a fresh per-test `WireMockServer`. + +| Test | Stub | Assertion | +|------|------|-----------| +| `PingAsync_Throws_On401` | `POST /ValidateCredentials` → 401, generic unauthorized body | Throws `Exception` containing `"health check failed"` | +| `PingAsync_Throws_On403` | `POST /ValidateCredentials` → 403, generic forbidden body | Throws `Exception` containing `"health check failed"` | +| `GetCertificateAsync_Throws_On401` | `POST /TrackOrder` → 401 | Throws `Exception` containing `"Authentication failure"` | +| `RevokeCertificateAsync_Throws_On401` | `POST /RevokeOrder` → 401 | Throws `Exception` containing `"authentication failure"` | +| `RenewCertificateAsync_Throws_On401` | `POST /TrackOrder` → 401 (prior-order lookup during renewal) | Throws `Exception` containing `"Authentication failure"` | +| `ListCertificatesAsync_Throws_On401` | `POST /GetOrderReport` → 401 | Enumerating the async stream throws `Exception` containing `"Authentication failure"` | +| `GetProfilesAsync_Throws_On401` | `POST /GetProductDetails` → 401 | Throws `Exception` containing `"Authentication failure"` | +| `EnrollCertificateAsync_Throws_OnEmptyResponseBody` | `POST /GenerateOrderSSL` → 200 with an empty body | Throws `Exception` containing `"empty body"` | +| `RevokeCertificateAsync_ThrowsWithSafeMessage_WhenBodyIsPlainText` | `POST /RevokeOrder` → 500, plain-text body | Throws `Exception` whose message names the `"revoke"` operation but never echoes the raw response body | +| `OAuth2_Throws_WhenTokenEndpointReturns500` | OAuth token endpoint → 500 | `PingAsync` throws `Exception` containing `"OAuth2 token"` | +| `OAuth2_Throws_WhenTokenResponseLacksAccessToken` | OAuth token endpoint → 200 with a body lacking `access_token` | `PingAsync` throws `Exception` containing `"access_token"` | + +--- + +## CERTInextCAPluginTests + +The plugin is constructed with `new CERTInextCAPlugin(client)` where `client` is a Moq strict +mock of `ICERTInextClient`. Any call to an unset-up method throws immediately, making unexpected +client calls visible. + +Two helpers are used across tests: +- `MakeProductInfo(profileId, extras)` — builds an `EnrollmentProductInfo` with `ProfileId` in + `ProductParameters` +- `AsyncEnum(items)` — wraps a list as `IAsyncEnumerable` + +### Ping + +| Test | Mock setup | Assertion | +|------|-----------|-----------| +| `Ping_Succeeds_WhenClientPingAsyncDoesNotThrow` | `PingAsync` returns `Task.CompletedTask` | Does not throw; `PingAsync` called exactly once | +| `Ping_Rethrows_WhenClientPingThrows` | `PingAsync` throws `Exception("Connection refused")` | Throws `Exception` with message matching `"*CERTInext*Connection refused*"` | +| `Ping_SkipsConnectivityTest_WhenConnectorIsDisabled` | Strict mock, no setups; `CERTInextConfig.Enabled = false` | Does not throw; no client method called (verified via `VerifyNoOtherCalls()`) | + +### GetProductIds + +| Test | Mock setup | Assertion | +|------|-----------|-----------| +| `GetProductIds_ReturnsStaticProductList` | No mock calls expected | Returns 10 items including `DV SSL`, `OV SSL`, `EV SSL`; no client method called | + +`GetProductIds()` returns a hardcoded static list — no API call is made. The strict mock's +`VerifyNoOtherCalls()` confirms this. + +### ValidateCAConnectionInfo + +| Test | Mock setup | Assertion | +|------|-----------|-----------| +| `ValidateCAConnectionInfo_Throws_WhenApiUrlMissing` | Connection info dict omits `ApiUrl` | Throws `AnyCAValidationException` containing `"ApiUrl"` and `"required"` | +| `ValidateCAConnectionInfo_Throws_WhenApiUrlIsNotUri` | `ApiUrl = "not-a-url"` | Throws `AnyCAValidationException` containing `"valid absolute URI"` | +| `ValidateCAConnectionInfo_Throws_WhenApiKeyMissingForApiKeyMode` | `AuthMode = "ApiKey"`, no `ApiKey` | Throws `AnyCAValidationException` containing `"ApiKey"` and `"required"` | +| `ValidateCAConnectionInfo_Throws_WhenAuthModeIsBasicOrOtherUnsupported` | `AuthMode = "Basic"` (unsupported by the real API) | Throws `AnyCAValidationException` containing `"AuthMode"` and `"must be one of"` | +| `ValidateCAConnectionInfo_Throws_WhenOAuthFieldsMissing` | `AuthMode = "OAuth"`, missing `OAuthTokenUrl`/`OAuthClientId`/`OAuthClientSecret` | Throws `AnyCAValidationException` containing `"OAuthTokenUrl"` and `"required"` | +| `ValidateCAConnectionInfo_Throws_WhenAuthModeIsInvalid` | `AuthMode = "CertificateBased"` (unrecognized value) | Throws `AnyCAValidationException` containing `"AuthMode"` and `"must be one of"` | +| `ValidateCAConnectionInfo_SkipsValidation_WhenDisabled` | `Enabled = false`, everything else missing | Does not throw; strict mock's `VerifyNoOtherCalls()` confirms no client calls | + +### ValidateProductInfo + +| Test | Mock setup | Assertion | +|------|-----------|-----------| +| `ValidateProductInfo_Throws_WhenProfileIdMissing` | `ProductID = ""`, empty `ProductParameters` | Throws `AnyCAValidationException` containing `"ProfileId"` and `"required"` | + +### Enroll + +The `Enroll` method selects a path based on `EnrollmentType`. Both `New` and `Reissue` submit a +new `GenerateOrderSSL` order. `RenewOrReissue` also submits `GenerateOrderSSL` (CERTInext has +no dedicated renewal endpoint) but applies the renewal-window check to determine how Command +tracks the old→new certificate relationship. + +| Test | EnrollmentType | Mock setup | Assertion | +|------|---------------|-----------|-----------| +| `Enroll_New_CallsEnrollAsync_AndReturnsIssuedResult` | `New` | `PlaceOrderAsync` returns `ORD-AAA-111` | `CARequestID == "ORD-AAA-111"`; `Status == GENERATED` | +| `Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval` | `New` | `PlaceOrderAsync` → pending status | `Status == EXTERNALVALIDATION` | +| `Enroll_New_Throws_WhenProfileIdNotSet` | `New` | Strict mock — no setups | Throws before calling the client | +| `Enroll_Reissue_AlsoCallsEnrollAsync` | `Reissue` | `PlaceOrderAsync` returns issued | `Status == GENERATED`; called once | +| `Enroll_Renew_FallsBackToNewEnroll_WhenNoPriorCertSn` | `RenewOrReissue` | `PlaceOrderAsync` returns issued | `CARequestID == "ORD-AAA-111"`; no dedicated renew call | + +### GetSingleRecord + +| Test | Mock setup | Assertion | +|------|-----------|-----------| +| `GetSingleRecord_ReturnsMappedCertificate_ForIssuedCert` | `TrackOrderAsync("ORD-AAA-111")` returns issued track response; `GetCertificateAsync` returns PEM | `Status == GENERATED`; PEM present; `ProductID == ProfileIdTls` | +| `GetSingleRecord_ReturnsMappedCertificate_ForRevokedCert` | `TrackOrderAsync("ORD-CCC-333")` returns revoked response | `Status == REVOKED`; `RevocationDate` non-null; `RevocationReason == 1` | +| `GetSingleRecord_Rethrows_WhenCertNotFound` | Client throws `KeyNotFoundException` | Rethrows `KeyNotFoundException` | + +### Revoke + +The plugin checks the current certificate status before calling `RevokeOrder`. CRL reason codes +(integers) are mapped to CERTInext string values. + +| Test | Mock setup | Assertion | +|------|-----------|-----------| +| `Revoke_CallsRevokeCertificateAsync_AndReturnsRevokedStatus` | `TrackOrderAsync` returns issued cert; `RevokeOrderAsync` returns `Task.CompletedTask` | Returns `REVOKED`; `RevokeOrderAsync` called once with correct reason string | +| `Revoke_ReturnsAlreadyRevoked_WhenCertAlreadyRevoked` | `TrackOrderAsync` returns revoked cert | Returns `REVOKED`; `RevokeOrderAsync` never called | +| `Revoke_MapsAllCrlReasonCodes` | Per reason code 0–5 and beyond | Verifies mapping: `0→"unspecified"`, `1→"keyCompromise"`, `2→"caCompromise"`, `3→"affiliationChanged"`, `4→"superseded"`, `5→"cessationOfOperation"`, extended codes also covered by `CERTInextCAPluginCoverageTests` | + +### Synchronize + +`Synchronize` iterates `ListOrdersAsync` and posts mapped `AnyCAPluginCertificate` objects to a +`BlockingCollection`. Full sync passes `null` as `issuedAfter`; delta sync passes `lastSync`. + +| Test | Mock setup | Assertion | +|------|-----------|-----------| +| `Synchronize_FullSync_AddsAllCertsToBuffer` | `ListOrdersAsync(null, ...)` returns two issued orders | Buffer contains 2 items; both order numbers present | +| `Synchronize_DeltaSync_PassesLastSyncFilter` | `ListOrdersAsync` captures `issuedAfter` | Captured value equals `lastSync` | +| `Synchronize_FullSync_PassesNullIssuedAfter` | `ListOrdersAsync` captures `issuedAfter` | Even when `lastSync` is non-null, `fullSync:true` forces `issuedAfter=null` | +| `Synchronize_SkipsFailedCertificates` | Returns one issued + one with unknown/failed status | Buffer contains exactly 1 item | +| `Synchronize_HonoursCancellation` | Async enumerable that cancels mid-iteration | Throws `OperationCanceledException` | +| `Synchronize_MapsRevokedCertificates_Correctly` | Returns one revoked record | Buffer item `Status == REVOKED`; `RevocationDate` non-null | +| `Synchronize_IssuedCertMissingBody_RefetchesFullCertificate` | Listing entry is issued but carries no PEM (`Certificate == null`); `GetCertificateAsync` returns the full record | Buffer item carries the refetched PEM body; `GetCertificateAsync` called once (regression for issue 0001) | +| `Synchronize_IssuedCertWithBody_DoesNotRefetch` | Listing entry already carries a PEM body | Buffer item keeps that PEM; `GetCertificateAsync` never called (strict mock has no setup for it) | +| `Synchronize_RevokedCertMissingBody_RefetchesWithRevocationMetadata` | Listing entry is revoked with no body/`RevokedAt`; `GetCertificateAsync` returns body + revocation detail | Buffer item is REVOKED with the PEM body and a non-null `RevocationDate` after the refetch | +| `Synchronize_CallsCompleteAdding_OnNormalExit` | Returns empty | `buffer.IsAddingCompleted == true` | +| `Synchronize_CallsCompleteAdding_OnCancellation` | Cancels mid-iteration | `buffer.IsAddingCompleted == true` even after `OperationCanceledException` | + +**Note on `CompleteAdding`:** `Synchronize` calls `blockingBuffer.CompleteAdding()` in a `finally` +block. Tests must not call `buffer.CompleteAdding()` themselves — doing so after the plugin has +already called it throws `InvalidOperationException`. + +### RenewOrReissue + +Three semantic cases for the `RenewOrReissue` renewal-window check (complementing the +`CERTInextCAPluginCoverageTests` Group A edge cases): whether a prior certificate's expiry falls +inside, outside, or already past the configured `RenewalWindowDays`. + +| Test | Mock setup | Assertion | +|------|-----------|-----------| +| `RenewOrReissue_UsesRenewApi_WhenCertExpiresWithinWindow` | Prior cert expires in 30 days, `RenewalWindowDays = 90` | Calls `RenewCertificateAsync` once for the prior order; GENERATED | +| `RenewOrReissue_UsesNewEnroll_WhenCertExpiresOutsideWindow` | Prior cert expires in 120 days, `RenewalWindowDays = 90` | Calls `EnrollCertificateAsync` (new order) once; `RenewCertificateAsync` never called | +| `RenewOrReissue_UsesNewEnroll_WhenCertAlreadyExpired` | Prior cert expired 5 days ago, `RenewalWindowDays = 90` | Falls back to new enroll (graceful degradation for an already-expired cert); `RenewCertificateAsync` never called | + +--- + +## CERTInextCAPluginCoverageTests + +Additional Moq-based coverage for `CERTInextCAPlugin` logic not exercised by +`CERTInextCAPluginTests` — organized (per the source file's own comments) into Group A +(`RenewOrReissueAsync`/`BuildEnrollmentResult` edge cases), Group B (status-mapping variants via +`Synchronize`/`Revoke`), and Group C (annotations, `Initialize`, SAN builder, revocation-reason +codes). WireMock auth-failure branch tests live in `CERTInextClientCoverageTests` instead. + +### Group A — RenewOrReissueAsync + BuildEnrollmentResult edge cases + +| Test | Mock setup | Assertion | +|------|-----------|-----------| +| `RenewOrReissue_FallsBackToNew_WhenGetRequestIDThrows` | `GetRequestIDBySerialNumber` throws; `EnrollCertificateAsync` returns issued | Falls back to new enroll (GENERATED); `EnrollCertificateAsync` called once, `RenewCertificateAsync` never | +| `RenewOrReissue_FallsBackToNew_WhenGetRequestIDReturnsEmpty` | `GetRequestIDBySerialNumber` returns `""` | Falls back to new enroll (GENERATED) | +| `RenewOrReissue_FallsBackToNew_WhenExpiryIsNull` | `GetExpirationDateByRequestId` returns `null` | Falls back to new enroll; `RenewCertificateAsync` never called | +| `RenewOrReissue_CallsRenewApi_WhenCertWithinRenewalWindow` | Expiry 30 days out, window 90 days | Calls `RenewCertificateAsync` once; `EnrollCertificateAsync` never called | +| `RenewOrReissue_FallsBackToNew_WhenCertOutsideRenewalWindow` | Expiry already 200 days in the past, window 90 days | Falls back to new enroll — an already-expired cert doesn't satisfy `expiry > now` | +| `Enroll_Renew_FallsBackToNew_WhenNoPriorCertSnInParams` | `EnrollmentType.Renew`, no `PriorCertSN` parameter | Falls back to new enroll (GENERATED) | +| `BuildEnrollmentResult_ReturnsFailed_WhenCaReturnsFailedStatus` | `EnrollCertificateAsync` returns `Status = "failed"` | Result `Status == FAILED`; `CARequestID` preserved | +| `BuildEnrollmentResult_ReturnsFailed_WhenCaReturnsUnknownStatus` | `EnrollCertificateAsync` returns `Status = "queued"` (unmapped) | Result `Status == FAILED` via the `StatusMapper` default | +| `Revoke_Throws_WhenCertIsInNonRevocableState` | `GetCertificateAsync` returns `Status = "pending_approval"` | Throws `Exception` containing `"cannot be revoked"` | +| `GetSingleRecord_Rethrows_WhenGenericExceptionOccurs` | `GetCertificateAsync` throws `Exception("Timeout")` | Rethrows `Exception` containing `"Timeout"` | +| `Synchronize_SkipsExpiredCerts_WhenIgnoreExpiredIsTrue` | `IgnoreExpired = true`; one expired + one valid cert | Buffer contains only the valid cert | + +### Group B — status-mapping variants via Synchronize + Revoke + +| Test | Mock setup | Assertion | +|------|-----------|-----------| +| `Synchronize_MapsActiveCert_AsGenerated` | One "active" + one "expired" cert (`IgnoreExpired = false`) | Both map to GENERATED | +| `Synchronize_SkipsCancelledAndRejectedCerts` | "cancelled" + "rejected" + one valid cert | Buffer contains only the valid cert (cancelled/rejected → FAILED → skipped) | +| `Revoke_MapsExtendedCrlReasonCodes` (`Theory`: codes 6, 8, 9, 10) | Reason codes 6/8/9/10 | Map to `certificateHold`/`removeFromCRL`/`privilegeWithdrawn`/`aACompromise` respectively | +| `Synchronize_SkipsCertWithTotallyUnknownStatus` | Cert with `Status = "totally-unknown-status"` | Buffer is empty (unknown status → FAILED → skipped) | + +### Group C — annotations, Initialize, SAN builder, revocation-reason codes + +| Test | Mock setup | Assertion | +|------|-----------|-----------| +| `GetCAConnectorAnnotations_ContainsAllExpectedKeys` | Plugin built directly, no setups | All expected connector annotation keys are present (`ApiUrl`, `AuthMode`, `OAuthTokenUrl`, etc.) | +| `GetTemplateParameterAnnotations_ContainsAllExpectedKeys` | No setups | All expected template parameter keys are present, including the P2-B additions `DomainName`/`SignerName`/`SignerPlace`/`SignerIp` | +| `Initialize_Succeeds_WithValidApiKeyConfig` | `IAnyCAPluginConfigProvider` mock returns a valid ApiKey config | `Initialize` does not throw | +| `Enroll_PassesAllEnrollmentParamsToRequest` | Product params include `ValidityDays`, `AutoApprove`, `RequesterName`, `RequesterEmail`, `KeyType` | Captured `EnrollCertificateRequest` carries all of them through | +| `Enroll_WithInvalidValidityDays_FallsBackToNull` | `ValidityDays = "not-a-number"` | Captured request's `ValidityDays` is `null` (falls back to profile default) | +| `Enroll_PassesValidityYearsToRequest` | `ValidityYears = "3"` | Captured request's `ValidityYears == 3` | +| `Enroll_WithInvalidValidityYears_FallsBackToNull` | `ValidityYears = "not-a-number"` | Captured request's `ValidityYears` is `null` | +| `Enroll_WithNullSanValueArray_StillCallsEnroll` | SAN dict has a key (`ip`) with a `null` value array | Does not throw; `EnrollCertificateAsync` still called once, GENERATED | +| `Enroll_WithUnknownSanType_PassesThroughRawType` | SAN dict has an unrecognized key `oid` | Captured request's `Sans` contains an entry with `Type == "oid"` passed through as-is | +| `GetSingleRecord_MapsRevocationReasonStringToCorrectCode` (`Theory` ×10) | `RevocationReason` string values (`unspecified`…`aACompromise`) | Each string maps to its correct CRL numeric code (0, 1, 2, 3, 4, 5, 6, 8, 9, 10) | + +--- + +## CERTInextCAPluginDcvTests + +Unit tests for the DCV orchestration path inside `CERTInextCAPlugin.Enroll` / +`PerformDcvIfNeededAsync` / `WaitForDcvVerificationAsync` / `WaitForIssuanceAsync`. All external +dependencies (CERTInext client, DNS validator) are stubbed, so no network calls are made, and +propagation delay is set to 0 so tests run fast. + +Helpers: `DcvConfig(enabled, propagationDelaySeconds, timeoutMinutes, dcvWaitForChallengeSeconds, +dcvWaitForIssuanceSeconds)` builds a `CERTInextConfig` with the general `EnrollmentWaitSeconds` +poll defaulted to 0 (so it doesn't interfere with these DCV-focused tests unless a test opts back +in); `BuildPlugin(client, factory, config)`; `HappyPathMocks(...)` wires the full +Enroll → TrackOrder(pending) → GetDcv → VerifyDcv → GetCertificate happy path; `Enroll(plugin)` +drives a single enrollment for a fixed CSR/subject/SAN. + +| Test | Mock setup | Assertion | +|------|-----------|-----------| +| `Dcv_HappyPath_StagesVerifiesAndCleansUp` | Full happy-path mocks; `dcvWaitForIssuanceSeconds = 10` | GENERATED with PEM; TXT record staged at the expected hostname and cleaned up; `VerifyDcvAsync`/`GetCertificateAsync` each called once | +| `Dcv_HappyPath_UsesCustomTxtTemplate` | Happy-path mocks with a custom `DcvTxtRecordTemplate` | TXT record staged and cleaned up at the hostname built from the custom template | +| `Dcv_Skipped_WhenOrderAlreadyIssued` | `EnrollCertificateAsync` returns issued; `TrackOrderAsync` returns an already-issued track response | GENERATED straight from the enroll response; no staging; `GetDcvAsync` never called | +| `Dcv_Skipped_WhenNoDomainVerificationBlock` | `TrackOrderAsync` returns a track response with `DomainVerification = null` | No TXT staged; `GetDcvAsync` never called | +| `Dcv_EnrollmentWaitStillRuns_WhenDcvShortCircuitsWithoutAnIssuanceWait` | DCV challenge slot never appears (`DomainVerification = null`); catalog lookup fails; `EnrollmentWaitSeconds = 10` | The general enrollment-wait poll still runs and returns GENERATED — DCV short-circuiting must not suppress it | +| `Dcv_RecoversPem_WhenPostDcvIssuanceWaitEndsWithGeneratedButNoBody` | Post-DCV `GetCertificateAsync` first returns issued-without-body, then issued-with-body; `EnrollmentWaitSeconds = 10` | GENERATED with PEM after 2 `GetCertificateAsync` calls — the general enrollment-wait poll recovers the PEM the post-DCV wait left bodyless | +| `Dcv_EnrollmentWaitStillRuns_WhenDcvCompletesButIssuanceWaitBudgetIsZero` | DCV already validated (`dcvDone = true`) but `DcvWaitForIssuanceSeconds = 0`; `EnrollmentWaitSeconds = 10` | GENERATED — the general enrollment-wait poll still fires even though the DCV-specific issuance wait short-circuited to a no-op | +| `Dcv_AlreadyInFlight_DuplicateCallDefersWithoutPolling` | Two concurrent `Enroll()` calls for the same order; first holds the `_dcvInFlight` reservation via a gated `TrackOrderAsync` | The duplicate call returns EXTERNALVALIDATION immediately without polling; only the original caller polls `GetCertificateAsync` (once) | +| `Dcv_SkipsStaging_AndDoesNotIssuancePoll_WhenAllDomainsAlreadyValidated_AndIssuanceBudgetZero` | `DomainVerification.Status = "1"` (validated); default `DcvWaitForIssuanceSeconds = 0` | No TXT staged; `GetDcvAsync`/`GetCertificateAsync` never called — order is left for sync to pick up | +| `Dcv_RunsIssuanceWait_WhenDcvAlreadyValidated_AndIssuanceBudgetPositive` | DCV already validated; `dcvWaitForIssuanceSeconds = 10`; `GetCertificateAsync` sequence pending→issued | GENERATED after polling at least twice; no TXT staging or `GetDcvAsync` call needed | +| `Dcv_Skipped_WhenDcvEnabledFalse` | `DcvEnabled = false` | No TXT staged; `TrackOrderAsync` never called | +| `Dcv_NoFactoryInjected_StillReturnsCAsPendingResult_WhenNoGuidanceAvailable` | Plugin built with `domainValidatorFactory: null`, `DcvEnabled = true`; `TrackOrderAsync` returns no `DomainVerification` data | Does not throw; returns the CA's pending status unchanged with empty `EnrollmentContext` | +| `SetDomainValidatorFactory_AfterConstruction_WiresFactoryForSubsequentEnroll` | Plugin constructed with a `null` factory, then `SetDomainValidatorFactory(...)` called before `Enroll` | Subsequent `Enroll()` drives DCV end-to-end (GENERATED, TXT staged) via the injected factory | +| `SetDomainValidatorFactory_SecondCall_OverridesFirst` | `SetDomainValidatorFactory` called twice with different factories | Only the second factory's validator receives TXT staging traffic; the first is never called | +| `Dcv_Skipped_WhenOrderStatusIdIsTerminal_EvenIfDcvValidated` (`Theory`: `OrderStatusId` 4/5) | `DomainVerification.Status = "1"` (validated) but `OrderStatusId` is Cancelled(4)/Rejected(5); `dcvWaitForIssuanceSeconds = 10` | `GetCertificateAsync` never called and no TXT staged — cancelled/rejected orders don't enter the issuance wait even with cached-validated DCV state | +| `SyncDcvRetry_DoesSingleShotTrackOrder_WhenChallengeNotReady` | `dcvWaitForChallengeSeconds = 60` exercised via `GetSingleRecord` (the sync path); `DomainVerification = null` | Completes in well under 10s and makes exactly one `TrackOrderAsync` call — sync's DCV retry is single-shot, not a full poll of the configured challenge budget | +| `Dcv_Throws_WhenNoProviderForDomain` | Factory returns a `null` validator | Throws `InvalidOperationException` containing `"No DNS provider plugin is configured"` | +| `Dcv_Throws_WhenStageValidationFails` | `FakeDomainValidator.StageSucceeds = false` | Throws `InvalidOperationException` containing `"Failed to stage DNS validation"` and the validator's error; `VerifyDcvAsync` never called | +| `Dcv_CleanupAlwaysCalled_EvenWhenVerifyDcvThrows` | `VerifyDcvAsync` throws | Exception propagates but `Cleanup` is still invoked for the staged hostname | +| `Dcv_Throws_WhenGetDcvReturnsNoToken` | `GetDcvAsync` returns a response with `Token = null` | Throws `InvalidOperationException` containing `"GetDcv returned no token"` | +| `Dcv_Defers_When_GetDcv_ReturnsEms956` | `GetDcvAsync` throws an exception whose message contains `"EMS-956"` | Does not throw; returns a non-null pending result; nothing staged/cleaned up; `VerifyDcvAsync` never called | +| `Dcv_Defers_When_GetDcv_ReturnsInvalidRequestMessage_WithoutEms956Code` | `GetDcvAsync` throws `"Invalid Request for this API"` (no EMS-956 code) | Does not throw; nothing staged — tolerance matches the human-readable phrase too, not only the code | +| `Dcv_Rethrows_When_GetDcv_FailsWithUnrelatedError` | `GetDcvAsync` throws `"HTTP 500: Internal Server Error"` | Rethrows — the EMS-956 tolerance is narrow and doesn't swallow unrelated failures | +| `Dcv_WaitsForChallenge_WhenDomainVerificationAppearsLate` | `TrackOrderAsync` sequence: null→pending→verified; `dcvWaitForChallengeSeconds = 10`, `dcvWaitForIssuanceSeconds = 10` | GENERATED and TXT staged — the plugin polled until the challenge slot appeared instead of skipping | +| `Dcv_GivesUpWaitingForChallenge_AfterBudgetExpires` | `DomainVerification` stays `null` forever; `dcvWaitForChallengeSeconds = 5` | Does not throw; polls `TrackOrderAsync` at least twice within the budget then gives up (deferred to sync) | +| `Dcv_WaitsForIssuance_AfterDcvVerifies` | Post-DCV `GetCertificateAsync` sequence: pending→issued; `dcvWaitForIssuanceSeconds = 10` | GENERATED (the polled issued status), with at least 2 `GetCertificateAsync` calls | +| `Dcv_NoFactoryWired_SurfacesManualTxtGuidanceInEnrollmentResult` | No factory at all (`(IDomainValidatorFactory)null`); `GetDcvAsync` returns a token | EXTERNALVALIDATION; `EnrollmentContext` contains the expected TXT hostname → token, and `StatusMessage` mentions both; `VerifyDcvAsync` never called | +| `Dcv_NoFactoryWired_WhenGuidanceLookupFails_FallsBackToPlainPendingMessage` | No factory; `TrackOrderAsync` throws | `EnrollmentContext` is empty; `StatusMessage` falls back to the plain `"pending approval"` message | +| `Dcv_NoFactoryWired_ButDcvDisabled_DoesNotAttemptGuidanceLookup` | No factory; `DcvEnabled = false` | `EnrollmentContext` is empty; `TrackOrderAsync` never called | +| `Dcv_CnameDelegationEnabled_RoutesToTerminalNameValidator` | `DcvFollowCnameDelegation = true`; a `CnameResolver` chain routes the challenge hostname to a terminal name keyed in a `KeyedDomainValidatorFactory` | GENERATED; the factory is queried with the terminal name (not the raw domain) and TXT is staged/cleaned up there | +| `Dcv_CnameDelegationDisabled_UsesRawDomainUnchanged` | `DcvFollowCnameDelegation` left at its default (`false`) | Behavior identical to the pre-CNAME-delegation happy path — TXT staged at the raw domain's hostname | +| `Dcv_CnameDelegationEnabled_LoopDetected_ThrowsCleanly` | `DcvFollowCnameDelegation = true`; CNAME chain cycles back to the challenge hostname | Throws `InvalidOperationException` containing `"loop"`; nothing staged/verified before the loop is detected | + +--- + +## CERTInextCAPluginEnrollmentWaitTests + +Unit tests for the synchronous enrollment-wait poll (`TryEnrollmentWaitForCertificateAsync`) that +runs at the end of every enrollment path on both build flavors: DV products poll `GetCertificate` +and return GENERATED + PEM when CERTInext issues within the budget; OV/EV products defer +immediately (async by CA design, support ticket #162763); exhaustion or any failure soft-falls +back to the pending result without throwing. Compiles on both the DCV (3.3.0) and no-DCV (3.2.0) +flavors. + +Helpers: `EnrollmentWaitConfig(totalSeconds = 50)` builds a `CERTInextConfig` with the fixed +5-second poll interval in mind (default budget ⇒ 10 max polls); `SslCatalog()` returns DV/OV/EV +`ProductDetail`s keyed by product code (`842`/`846`/`850`); `ProductInfo(productName, +productCode)`; `Enroll(plugin, productInfo, type)`. + +| Test | Mock setup | Assertion | +|------|-----------|-----------| +| `EnrollmentWait_DvProduct_PendingThenIssued_ReturnsGeneratedWithPem` | DV product; `GetCertificateAsync` sequence pending→issued | GENERATED with PEM; polled exactly twice, stopping as soon as issued | +| `EnrollmentWait_DvProduct_IssuedOnFirstPoll_ReturnsGenerated` | DV product; `GetCertificateAsync` returns issued immediately | GENERATED; polled exactly once | +| `EnrollmentWait_OvProduct_ReturnsPendingImmediately_WithoutPolling` | OV product | EXTERNALVALIDATION immediately with a status message mentioning "asynchronously"/"synchronization"; `GetCertificateAsync` never called | +| `EnrollmentWait_EvProduct_ReturnsPendingImmediately_WithoutPolling` | EV product | Same as OV — EXTERNALVALIDATION, no poll | +| `EnrollmentWait_OvByTemplateName_Defers_WhenCatalogUnavailable` | Product catalog fetch throws; template name carries the OV wildcard token | EXTERNALVALIDATION; no poll — the name-based classifier fallback still prevents a futile poll | +| `EnrollmentWait_ProductCatalog_IsCachedAcrossEnrollments` | 3 successive OV enrollments | `GetProductDetailsAsync` called exactly once — catalog is cached, not refetched per enrollment | +| `EnrollmentWait_UnknownProduct_PollsOptimistically` | Catalog fetch fails; product name/code unrecognized | GENERATED; polls once — unknown products are polled optimistically rather than silently deferred | +| `EnrollmentWait_SoftFallsBackToPending_WhenBudgetExhausted` | DV product; `GetCertificateAsync` always returns pending; 10s budget (5s interval ⇒ 2 polls) | EXTERNALVALIDATION with a "later synchronization" message; polled exactly twice (off-by-one guard) | +| `EnrollmentWait_SurvivesTransientFailure_AndReturnsIssuedOnRetry` | `GetCertificateAsync` throws once then returns issued | GENERATED; a transient failure consumes one attempt, not the whole budget | +| `EnrollmentWait_Disabled_WhenRetriesNegative` | `EnrollmentWaitSeconds = -1` | EXTERNALVALIDATION; neither the catalog nor `GetCertificateAsync` are called — "-1 to disable" convention honored | +| `EnrollmentWait_SoftFallsBackToPending_WhenGetCertificateThrows` | `GetCertificateAsync` always throws; 10s budget | EXTERNALVALIDATION with the order's `CARequestID` preserved — a failing poll never fails the enrollment | +| `EnrollmentWait_ReturnsFailed_WhenOrderReachesTerminalFailure` | `GetCertificateAsync` returns `Status = "failed"` | Returns FAILED (not left pending); message doesn't claim the cert was issued | +| `EnrollmentWait_Disabled_WhenRetriesZero` | `EnrollmentWaitSeconds = 0` | EXTERNALVALIDATION; catalog and `GetCertificateAsync` never called | +| `EnrollmentWait_Skipped_WhenEnrollReturnsIssuedWithPem` | `EnrollCertificateAsync` returns issued+PEM directly | GENERATED; `GetCertificateAsync` never called — an already-complete result needs no wait | +| `EnrollmentWait_FetchesPem_WhenEnrollReturnsIssuedWithoutPem` | Enroll response issued but `Certificate = null`; `GetCertificateAsync` returns the PEM | GENERATED with PEM recovered via the wait poll | +| `EnrollmentWait_KeepsPolling_WhenGeneratedWithoutBody_ThenRecoversPem` | `GetCertificateAsync` sequence: issued-no-body → issued-with-body | GENERATED with PEM after 2 polls — a bodyless GENERATED mid-poll is not treated as terminal | +| `EnrollmentWait_SoftFallsBackToPending_WhenGeneratedBodyNeverArrives` | `GetCertificateAsync` always returns issued-without-body; 15s budget | EXTERNALVALIDATION with no certificate — never surfaces a bodyless GENERATED as success | +| `EnrollmentWait_SoftFallsBackToPending_WhenEnrollIssuedWithoutPem_AndBodyNeverArrives` | Enroll response issued-without-PEM; every poll also bodyless; 15s budget | EXTERNALVALIDATION with no certificate — same invariant enforced from an issued-without-PEM entry state | +| `EnrollmentWait_Disabled_DowngradesIssuedWithoutPem_ToPending` | `EnrollmentWaitSeconds = 0`; enroll response issued-without-PEM | EXTERNALVALIDATION with no certificate even though the wait never polls — the no-bodyless-GENERATED rule still applies | +| `EnrollmentWait_DegradesIssuedWithoutPem_ToPending_WhenOrderNumberEmpty` | Enroll response issued-without-PEM and empty order `Id` | EXTERNALVALIDATION with no certificate; `GetCertificateAsync` never called (nothing to poll with) | +| `EnrollmentWait_CatalogFailure_IsBackedOff_NotRetriedPerEnrollment` | Catalog fetch always throws; 3 successive DV enrollments | `GetProductDetailsAsync` called exactly once — a failing catalog fetch is backed off, not retried every enrollment | +| `EnrollmentWait_RenewPath_PendingThenIssued_ReturnsGenerated` | Renewal via `RenewCertificateAsync`; `GetCertificateAsync` sequence pending→issued for the new order number | GENERATED with PEM; the wait polls the NEW order number returned by the renewal | +| `EnrollmentWait_RenewPath_RunsEvenWhenDcvEnabled` | Renewal path with `DcvEnabled = true` | GENERATED — DcvEnabled must not suppress the renew-path enrollment wait (no in-call DCV runs on renewals) | +| `EnrollmentWait_FetchesPem_ForIssuedOrder_EvenWhenDcvEnabled` | Enroll response issued-without-PEM; `DcvEnabled = true` | GENERATED with PEM recovered — the PEM-recovery fetch runs regardless of DCV configuration | +| `EnrollmentWait_RenewPath_ClassifiesTheProductCodeActuallyOrdered` | Renewal response's `ProfileId` reports the connector's `DefaultProductCode` (OV) even though the template says DV | EXTERNALVALIDATION; `GetCertificateAsync` never called — the OV/EV gate classifies the code actually ordered, not the template's | + +--- + +## CERTInextCAPluginPublicSurfaceTests + +Reflection-based contract tests that verify the no-DCV build does not expose any public types, +fields, methods, or constructors that reference `IDomainValidatorFactory` or other IAnyCAPlugin +3.3-only types. These tests ensure the default build loads cleanly on AnyCA Gateway 25.5.x hosts. + +| Test | What it checks | +|------|---------------| +| `NoPublicConstructor_ReferencesV3Point3OnlyTypes` | No public constructor has a parameter typed as a 3.3-only interface | +| `NoInstanceField_DeclaredTypeReferencesV3Point3OnlyTypes` | No public or private instance field is typed as a 3.3-only type | +| `NoNestedType_ImplementsV3Point3OnlyInterface` | No nested type implements a 3.3-only interface | +| `NoPublicMethod_SignatureReferencesV3Point3OnlyTypes` | No public method has a parameter or return type referencing 3.3-only types | +| `ParameterlessConstructor_IsPublic` | The plugin has a public parameterless constructor (required by the gateway host for reflection-based instantiation) | +| `SetDomainValidatorFactory_AcceptsObject_NotIDomainValidatorFactory` | The DCV injection method accepts `object`, not the 3.3-only `IDomainValidatorFactory`, so the method signature loads on 3.2 hosts | +| `SetDomainValidatorFactory_NullArgument_LeavesDcvDisabled` | Passing `null` does not enable DCV | +| `SetDomainValidatorFactory_NonFactoryArgument_IsIgnored` | Passing a non-factory object does not enable DCV | + +--- + +## BoundedDcvSyncTests + +Pure unit tests for the age-window and per-pass cap logic in `TryRunDcvDuringSyncAsync`. No +network I/O. Verifies that: +- Orders within the configured age window are attempted +- Orders older than the window are skipped (to avoid retrying abandoned orders indefinitely) +- Orders at the exact age boundary are attempted +- Orders with unknown dates are attempted (not starved) +- Age window of 0 disables the filter +- The per-pass cap skips orders once the cap is reached +- Cap of 0 disables the cap +- Age skip takes precedence over the cap check + +--- + +## RateLimitRetryTests + +Pure unit tests for the `IsRateLimitSurface` and `ComputeRateLimitBackoffSeconds` helpers: +- `IsRateLimitSurface` recognises the documented CERTInext rate-limit error phrase and rejects + unrelated strings +- `ComputeRateLimitBackoffSeconds` produces a result within the expected jittered range for each + attempt number +- Attempt values below 1 are clamped to 1 + +--- + +## CnameResolverTests + +Pure unit tests for `CnameResolver`'s hop-walking algorithm (depth cap + loop detection), used by +the DCV CNAME-delegation feature (issue 0006). Exercised via the internal delegate-injection +constructor against a fake in-memory CNAME chain map, so no real DNS queries are made. + +| Test | What it checks | +|------|---------------| +| `ResolveTerminalNameAsync_NoCname_ReturnsSameName` | A name with no CNAME entry resolves to itself | +| `ResolveTerminalNameAsync_SingleHop_ReturnsTarget` | A single CNAME hop resolves to its target | +| `ResolveTerminalNameAsync_MultiHopChain_FollowsToTerminalName` | A 3-hop chain is followed to its terminal (non-CNAME) name | +| `ResolveTerminalNameAsync_TrailingDotAndCase_AreNormalized` | A resolved target with a trailing root dot and mixed case has the dot stripped (case preserved) for use as a lookup key | +| `ResolveTerminalNameAsync_DirectLoop_ThrowsCleanly` | A 2-node cycle (`a→b→a`) throws `InvalidOperationException` containing `"loop detected"` | +| `ResolveTerminalNameAsync_SelfLoop_ThrowsCleanly` | A name that points to itself throws `InvalidOperationException` containing `"loop detected"` | +| `ResolveTerminalNameAsync_ChainWithinDepthCap_Succeeds` | A 9-hop chain (under the `MaxCnameDepth = 10` cap) resolves successfully to the terminal name | +| `ResolveTerminalNameAsync_ChainExceedingDepthCap_ThrowsCleanly` | An 11-hop non-looping chain (over the depth cap) throws `InvalidOperationException` containing `"maximum depth"` rather than hanging | + +--- + +## ExtractSerialFromPemTests + +Regression tests for the private `CERTInextCAPlugin.ExtractSerialFromPem` helper (invoked via +reflection), which feeds the audit-log `SerialNumber` field. These pin the serial-formatting +invariants established after the BouncyCastle crypto migration (replacing +`X509Certificate2.SerialNumber`) — particularly the leading-zero-byte case where the old BCL +behavior and a naive `BigInteger.ToString(16)` diverge. Certificates are generated in-test with +BouncyCastle only, per the project's crypto policy. + +| Test | What it checks | +|------|---------------| +| `ExtractSerialFromPem_PreservesLeadingZeroByte` | A serial with a leading-zero nibble in its first byte (`0x0A123456`) round-trips as `"0A123456"` (8 nibbles), not `"A123456"` (a dropped leading zero that would mis-correlate against Command's stored serial) | +| `ExtractSerialFromPem_NormalSerial_UppercaseHexNoLeadingZero` | A mid-range serial renders as plain uppercase hex with no separators | +| `ExtractSerialFromPem_LongSerial_AllBytesPreservedUppercase` | A 20-byte serial (the CA/B Forum maximum) preserves every byte as uppercase hex with no loss | +| `ExtractSerialFromPem_GarbageInput_ReturnsParseError` | Non-PEM input returns `"(parse-error)"` instead of throwing — the audit-log path must never throw | +| `ExtractSerialFromPem_EmptyBody_ReturnsEmptyPem` | A PEM header/footer with no body between them returns `"(empty-pem)"` | + +--- + +## RedactCredentialsTests + +Pins the credential-scrubbing pass that `CERTInextClient.RedactCredentials` runs on every +response/request body before it's logged or truncated. The CERTInext request `meta` block +includes an `authKey` SHA-256 digest that is itself a replayable credential under SOX (anyone with +one valid `(ts, txn, authKey)` triple can replay until the timestamp window expires); these tests +pin that the scrubber catches both the documented-as-sent field (`authKey`) and adjacent +credential field names that could end up on the wire via a future code path (`client_secret`, +`accessKey`, `password`). + +| Test | What it checks | +|------|---------------| +| `RedactCredentials_ScrubsJsonCredentialFields` (`Theory` ×4) | JSON bodies with `authKey`, `client_secret`, `apiKey`, and `accessKey`/`password` fields each have the credential value replaced with `***REDACTED***` while sibling fields are left untouched | +| `RedactCredentials_ScrubsFormUrlEncodedCredentialFields` (`Theory` ×2) | Form-urlencoded bodies (`client_secret=...`, `authKey=...`) have the credential value redacted; other key/value pairs pass through untouched | +| `RedactCredentials_ScrubsAuthorizationHeaderLines` | An `Authorization: Bearer ...` header line is replaced with `Authorization: ***REDACTED***`; other header lines (`Host`, `Content-Type`) pass through unchanged | +| `RedactCredentials_PreservesNonCredentialFields` | A body containing only non-credential fields (`ts`, `txn`, `errorMessage`) is returned unchanged | +| `RedactCredentials_HandlesNullAndEmpty` (`Theory`: `null`, `""`) | `null`/empty input is returned as-is without throwing | +| `RedactCredentials_CaseInsensitiveFieldNameMatch` | Mixed-case field names (`AuthKey`, `APIKEY`) are still redacted; documents the known gap that CamelCase `ClientSecret` is NOT currently matched — only the snake_case `client_secret` form CERTInext's OAuth endpoint actually uses | + +--- + +## MockCertificateData + +`MockCertificateData` is a static internal class shared across test suites. It provides realistic +fake CERTInext API response objects and JSON payloads. + +The real CERTInext API uses HTTP POST for all endpoints and wraps every response in a `meta` +block with `status: "1"` (success) or `status: "0"` (failure). + +### Constants + +| Constant | Value | Used for | +|----------|-------|---------| +| `FakePemCertificate` | PEM block starting with `-----BEGIN CERTIFICATE-----` | Certificate body in all responses | +| `FakeCsrPem` | PEM block starting with `-----BEGIN CERTIFICATE REQUEST-----` | CSR body in enroll requests | +| `OrderNumber1` | `"ORD-AAA-111"` | Primary order number (also aliased as `CertId1`) | +| `OrderNumber2` | `"ORD-BBB-222"` | Second order number (also aliased as `CertId2`) | +| `OrderNumber3` | `"ORD-CCC-333"` | Revoked order number (also aliased as `CertId3`) | +| `ProfileIdTls` | `"tls-server"` | TLS server product code placeholder | +| `ProfileIdClient` | `"client-auth"` | Client auth product code placeholder | + +`CertId1/2/3` are backward-compatibility aliases for `OrderNumber1/2/3`. + +### JSON helpers (WireMock stubs) + +| Method | Endpoint | Notes | +|--------|----------|-------| +| `ValidateCredentialsSuccessJson()` | `POST /ValidateCredentials` | Success meta only | +| `ValidateCredentialsFailureJson(code, msg)` | `POST /ValidateCredentials` | Failure meta | +| `GenerateOrderSuccessJson(orderNumber)` | `POST /GenerateOrderSSL` | Includes `orderDetails.orderNumber` | +| `TrackOrderIssuedJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="9"` (GENERATED) | +| `TrackOrderPendingJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="1"` (SetupPending) | +| `TrackOrderRevokedJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="22"`, revocation details present | +| `GetCertificateSuccessJson()` | `POST /GetCertificate` | PEM in `certificateDetails.endEntityCertificate`; serial `"0A1B2C3D4E5F"` | +| `RevokeSuccessJson()` | `POST /RevokeOrder` | Success meta only | +| `OrderReportSinglePageJson()` | `POST /GetOrderReport` | One entry, `ORD-AAA-111` | +| `OrderReportPageJson(orderNumbers, total, pages, current)` | `POST /GetOrderReport` | Multi-entry paginated response | +| `OrderReportEmptyJson()` | `POST /GetOrderReport` | Empty `ordersArray`, `noOfPages=0` | +| `GetProductDetailsJson()` | `POST /GetProductDetails` | Nested category envelope with two products | +| `GetProductDetailsEmptyJson()` | `POST /GetProductDetails` | Empty `productDetails` array | +| `ApiFailureJson(code, msg)` | Any endpoint | Generic `meta.status="0"` failure | +| `GetDcvSuccessJson(token)` | `POST /GetDcv` | `dcvDetails.token` | +| `GetDcvFailureJson(code, msg)` | `POST /GetDcv` | Failure meta | +| `VerifyDcvSuccessJson()` | `POST /VerifyDcv` | Success meta only | +| `VerifyDcvFailureJson(code, msg)` | `POST /VerifyDcv` | Failure meta | +| `OAuth2TokenJson(expiresIn)` | OAuth token endpoint | `access_token="fake-bearer-token-abc123"` | +| `ServerErrorJson()` | Any | Generic 500 error body (not meta-wrapped) | +| `UnauthorizedJson()` | Any | Generic 401 error body (not meta-wrapped) | + +### Object helpers (Moq setups) + +| Method | Returns | +|--------|---------| +| `ActiveProfiles()` | Two `ProfileInfo` objects, both `Active=true`: `ProfileIdTls` and `ProfileIdClient` | +| `MixedProfiles()` | Three `ProfileInfo` objects: `ProfileIdTls` (active), `"legacy-profile"` (inactive), `ProfileIdClient` (active) | +| `IssuedEnrollResponse(id)` | `EnrollCertificateResponse` with `Status="issued"`, PEM, `SerialNumber="0A1B2C3D4E5F"` | +| `PendingEnrollResponse(id)` | `EnrollCertificateResponse` with `Status="pending_approval"`, `Certificate=null` | +| `IssuedCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="issued"`, PEM, `ProfileId=ProfileIdTls` | +| `PendingCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="pending_approval"`, no certificate — maps to `EXTERNALVALIDATION` | +| `RevokedCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="revoked"`, `RevokedAt`, `RevocationReason="keyCompromise"` | +| `DcvPendingTrackResponse(orderNumber, domain)` | `TrackOrderResponse` with one DNS-TXT entry at `dcvStatus="0"` (pending) | +| `DcvVerifiedTrackResponse(orderNumber, domain)` | `TrackOrderResponse` with DNS-TXT entry at `dcvStatus="1"` (validated) | +| `AlreadyIssuedTrackResponse(orderNumber)` | `TrackOrderResponse` with `certificateStatusId="9"` (GENERATED) — DCV should be skipped | +| `DcvTokenResponse(token)` | `GetDcvResponse` with `DcvDetails.Token` set | + +--- + +## Adding New Tests + +### Which suite to add to + +- **`CERTInextClientTests`** — when testing HTTP-level behaviour: a new endpoint, error status + code, authentication header detail, body serialisation, or query parameter. +- **`CERTInextClientRequestShapeTests`** — when verifying that the request body includes or omits + specific JSON blocks based on connector configuration. +- **`CERTInextCAPluginTests` / `CERTInextCAPluginCoverageTests`** — when testing plugin logic: a + new enrollment type, validation rule, status mapping, or response to specific client return values. + +### Adding a new WireMock stub + +1. Register a stub in the test body: + ```csharp + _server + .Given(Request.Create().WithPath("/YourEndpoint").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(MockCertificateData.YourResponseJson())); + ``` +2. Add a `YourResponseJson(...)` JSON helper to `MockCertificateData` if the shape is reused. +3. Verify request details by inspecting `_server.LogEntries` after the call. diff --git a/CERTInext.Tests/TESTING.md b/CERTInext.Tests/TESTING.md deleted file mode 100644 index e56c35a..0000000 --- a/CERTInext.Tests/TESTING.md +++ /dev/null @@ -1,412 +0,0 @@ -# CERTInext CA Plugin — Unit Test Suite Reference - -## Overview - -The `CERTInext.Tests` project contains unit and contract tests for the CERTInext AnyCA Gateway -REST plugin. No external services are required — all HTTP I/O is handled in-process by WireMock.Net -or replaced by Moq strict mocks. - -The project is split into several focused test classes: - -| Class | Layer under test | Isolation technique | -|---|---|---| -| `CERTInextClientTests` | `CERTInextClient` HTTP transport | WireMock.Net (real loopback HTTP) | -| `CERTInextClientRequestShapeTests` | `CERTInextClient` request body construction | WireMock.Net | -| `CERTInextCAPluginTests` | `CERTInextCAPlugin` IAnyCAPlugin logic | Moq strict mock of `ICERTInextClient` | -| `CERTInextCAPluginCoverageTests` | Additional plugin logic paths | Moq strict mock | -| `CERTInextCAPluginPublicSurfaceTests` | Binary-compat / no-DCV surface contract | Reflection only | -| `BoundedDcvSyncTests` | DCV sync age/cap filter logic | Pure unit (no I/O) | -| `RateLimitRetryTests` | Rate-limit back-off helpers | Pure unit (no I/O) | -| `ExtractSerialFromPemTests` | PEM serial-number extraction | Pure unit (no I/O) | -| `RedactCredentialsTests` | Log credential-redaction helper | Pure unit (no I/O) | - -If a test fails in `CERTInextClientTests` or `CERTInextClientRequestShapeTests`, the bug is in -HTTP transport or request serialisation. If it fails in `CERTInextCAPluginTests` or -`CERTInextCAPluginCoverageTests`, the bug is in plugin logic. - ---- - -## Running the Tests - -**Prerequisites:** -- .NET 8 or .NET 10 SDK -- NuGet packages restored (`dotnet restore`) -- No external services required - -**Run all tests:** -```bash -dotnet test CERTInext.Tests/ -``` - -**Run a single test class:** -```bash -dotnet test --filter "FullyQualifiedName~CERTInextClientTests" -dotnet test --filter "FullyQualifiedName~CERTInextCAPluginTests" -``` - -**Run a specific test by name:** -```bash -dotnet test --filter "DisplayName~OAuth2_TokenIsCached" -``` - -Each `CERTInextClientTests` instance starts a fresh `WireMockServer` in its constructor and -stops it in `Dispose()`, so tests are isolated and can run in parallel without port conflicts. - ---- - -## Authentication model - -The real CERTInext API uses HTTP POST for **all** endpoints. There is no Authorization header -for AccessKey mode. Instead, every request body includes a `meta` block containing: - -- `authKey` — `SHA256(accessKey + requestTs + requestTxnId)` (lowercase hex) -- `ts` — ISO 8601 timestamp -- `txn` — unique transaction UUID - -The raw access key is never transmitted — only the derived hash is sent. - -`AuthMode` accepted values: -- `AccessKey` (primary) — HMAC signed body -- `OAuth` (alternative) — bearer token via client credentials flow -- `ApiKey`, `AccessKeyLegacy`, `OAuthLegacy` — legacy aliases accepted for backward compatibility - ---- - -## CERTInextClientTests - -The test class implements `IDisposable`. A `WireMockServer` is started on a random available port -in the constructor. All tests build a `CERTInextClient` pointed at `_server.Urls[0]`. - -Two helper methods build clients: -- `BuildClient(authMode, apiKey)` — builds an AccessKey-authenticated client - (defaults: `authMode="AccessKey"`, `apiKey="test-key"`, `accountNumber="12345"`) -- `BuildOAuthClient(tokenUrl)` — builds an OAuth client with `client_id="my-client"`, - `client_secret="my-secret"` - -### PingAsync — POST /ValidateCredentials - -| Test | Stub | Assertion | -|------|------|-----------| -| `PingAsync_ReturnsHealthy_WhenServerRespondsOk` | `POST /ValidateCredentials` → 200, success meta | Does not throw; WireMock log contains a request to `/ValidateCredentials` | -| `PingAsync_Throws_When500Returned` | `POST /ValidateCredentials` → 500, server error body | Throws `Exception` with message containing `"health check failed"` | -| `PingAsync_Throws_WhenMetaStatusIsFailure` | `POST /ValidateCredentials` → 200, failure meta (`EMS-001`, `"Invalid credentials"`) | Throws `Exception` with message containing `"credential validation failed"` | - -### OAuth2 Token Fetch, Caching, and Injection - -| Test | Stub | Assertion | -|------|------|-----------| -| `OAuth2_FetchesToken_BeforeFirstApiCall` | `POST /oauth/token` → token JSON; `POST /ValidateCredentials` → 200 | Log contains both `/oauth/token` and `/ValidateCredentials` | -| `OAuth2_TokenIsCached_SecondCallDoesNotRefetch` | Same stubs | `PingAsync` called twice; `/oauth/token` appears exactly once; `/ValidateCredentials` appears twice | -| `OAuth_InjectsBearerToken_InAuthorizationHeader` | Token endpoint → `fake-bearer-token-abc123`; `/ValidateCredentials` → 200 | WireMock log entry for `/ValidateCredentials` carries `Authorization: Bearer fake-bearer-token-abc123` | -| `OAuth_DoesNotInjectBearerToken_InAccessKeyMode` | `/ValidateCredentials` → 200 | WireMock log entry has no `Authorization` header | - -### Retry logic - -| Test | Stub | Assertion | -|------|------|-----------| -| `ExecuteWithRetry_MakesThreeAttempts_WhenServerAlwaysReturns500` | `/ValidateCredentials` always → 500 | `PingAsync` throws; WireMock log has exactly 3 requests (3 total attempts, 4xx are not retried) | - -### EnrollCertificateAsync — POST /GenerateOrderSSL - -| Test | Stub | Assertion | -|------|------|-----------| -| `EnrollCertificateAsync_ReturnsCertificate_WhenServerIssues` | `POST /GenerateOrderSSL` → 200, success meta + `orderDetails.orderNumber="ORD-AAA-111"` | Result not null; `OrderNumber == "ORD-AAA-111"` | -| `EnrollCertificateAsync_ReturnsPending_WhenServerReturnsPendingApproval` | `POST /GenerateOrderSSL` → 200, pending response | Status maps to pending | -| `EnrollCertificateAsync_Throws_WhenGenerateOrderFails` | `POST /GenerateOrderSSL` → 200, failure meta (EMS-918) | Throws `Exception` containing the API error message | -| `EnrollCertificateAsync_Throws_When5xxReturned` | `POST /GenerateOrderSSL` → 500 | Throws `Exception` | -| `EnrollCertificateAsync_Throws_When401Returned` | `POST /GenerateOrderSSL` → 401 | Throws `Exception` | - -### GetCertificateAsync — POST /GetCertificate - -| Test | Stub | Assertion | -|------|------|-----------| -| `GetCertificateAsync_ReturnsCertificate_WhenFound` | `POST /GetCertificate` → 200, PEM in `certificateDetails.endEntityCertificate` | PEM contains `"BEGIN CERTIFICATE"`; serial `"0A1B2C3D4E5F"` | -| `GetCertificateAsync_ThrowsKeyNotFound_WhenOrderNotFound` | `POST /GetCertificate` → 200, failure meta (EMS-not-found) | Throws `KeyNotFoundException` | - -### RevokeCertificateAsync — POST /RevokeOrder - -| Test | Stub | Assertion | -|------|------|-----------| -| `RevokeCertificateAsync_Succeeds_When200Returned` | `POST /RevokeOrder` → 200, success meta | Does not throw | -| `RevokeCertificateAsync_Throws_WhenServerReturnsFailure` | `POST /RevokeOrder` → 200, failure meta | Throws `Exception` | - -### RenewCertificateAsync — POST /GenerateOrderSSL - -CERTInext has no dedicated renewal endpoint. `RenewCertificateAsync` submits a new -`GenerateOrderSSL` order. The test verifies that the correct endpoint and body are used. - -| Test | Stub | Assertion | -|------|------|-----------| -| `RenewCertificateAsync_ReturnsNewCertificate_OnSuccess` | `POST /GenerateOrderSSL` → 200, success with new order number | New order number returned | - -### ListCertificatesAsync — POST /GetOrderReport (paginated) - -`ListCertificatesAsync` is an `IAsyncEnumerable` that paginates -`GetOrderReport`. Pagination stops when the returned page is empty or all pages are fetched. - -| Test | Stub | Assertion | -|------|------|-----------| -| `ListCertificatesAsync_ReturnsSinglePage_WhenOnlyOnePage` | `POST /GetOrderReport` → single-page with `ORD-AAA-111` | Enumeration yields exactly 1 item | -| `ListCertificatesAsync_IteratesMultiplePages` | Two pages: page 1 (`ORD-AAA-111`), page 2 (`ORD-BBB-222`) | Enumeration yields 2 items; both order numbers present | -| `ListCertificatesAsync_StopsWhenEmptyPageReturned` | `POST /GetOrderReport` → empty `ordersArray` | Enumeration yields 0 items | -| `ListCertificatesAsync_RespectsIssuedAfterFilter` | Any request with `issuedAfter` parameter → single-page | Enumeration yields 1 item; `issuedAfter` key present in the request log | - -### GetProfilesAsync — POST /GetProductDetails - -| Test | Stub | Assertion | -|------|------|-----------| -| `GetProfilesAsync_ReturnsProfiles_WhenServerResponds` | `POST /GetProductDetails` → two products in nested category envelope | Result has 2 items; `ProfileIdTls` and `ProfileIdClient` present; all `Active == true` | -| `GetProfilesAsync_ReturnsEmptyList_WhenNoProductsReturned` | `POST /GetProductDetails` → empty `productDetails` array | Result is empty | - -### DCV endpoints - -| Test | Stub | Assertion | -|------|------|-----------| -| `GetDcvAsync_ReturnsToken_WhenServerRespondsOk` | `POST /GetDcv` → 200, `dcvDetails.token="abc123token"` | Returns token string | -| `GetDcvAsync_Throws_WhenMetaStatusIsFailure` | `POST /GetDcv` → 200, failure meta | Throws `Exception` | -| `GetDcvAsync_Throws_WhenServerReturns401` | `POST /GetDcv` → 401 | Throws `Exception` | -| `VerifyDcvAsync_Succeeds_WhenServerRespondsOk` | `POST /VerifyDcv` → 200, success meta | Does not throw | -| `VerifyDcvAsync_Throws_WhenMetaStatusIsFailure` | `POST /VerifyDcv` → 200, failure meta | Throws `Exception` | -| `VerifyDcvAsync_Throws_WhenServerReturns401` | `POST /VerifyDcv` → 401 | Throws `Exception` | -| `VerifyDcvAsync_Throws_WhenServerReturns500` | `POST /VerifyDcv` → 500 | Throws `Exception` | - ---- - -## CERTInextClientRequestShapeTests - -Uses WireMock to verify that the `GenerateOrderSSL` request body includes or omits optional -blocks depending on connector configuration. - -| Test | Assertion | -|------|-----------| -| `OrganizationNumber_Set_EmitsPreVettedOrganizationDetails` | Body includes `organizationDetails.preVetting="1"` and the configured `organizationNumber` | -| `OrganizationNumber_Blank_OmitsOrganizationDetailsBlock` | Body omits `organizationDetails` entirely | -| `GroupNumber_Set_EmitsDelegationInformation` | Body includes `delegationInformation.groupNumber` | -| `GroupNumber_Blank_OmitsDelegationInformation` | Body omits `delegationInformation` | -| `TechnicalContact_AllSet_EmitsExplicitValues` | Body includes `technicalPointOfContact` with the configured values | -| `TechnicalContact_AllBlank_FallsBackToRequestorDefaults` | Body includes `technicalPointOfContact` fields derived from `RequestorName`/`RequestorEmail` | -| `SslBodyDefaults_AreEmitted_FromCustomConnectorValues` | Custom connector-level defaults appear in the order body | -| `SslBodyDefaults_AreSafeFallbacks_WhenConfigUntouched` | Default values are emitted without throwing when optional config fields are omitted | -| `ValidityDays_OnRequest_OverridesConnectorDefault` | `ValidityDays` template parameter overrides the connector `SubscriptionValidityYears` | - ---- - -## CERTInextCAPluginTests - -The plugin is constructed with `new CERTInextCAPlugin(client)` where `client` is a Moq strict -mock of `ICERTInextClient`. Any call to an unset-up method throws immediately, making unexpected -client calls visible. - -Two helpers are used across tests: -- `MakeProductInfo(profileId, extras)` — builds an `EnrollmentProductInfo` with `ProfileId` in - `ProductParameters` -- `AsyncEnum(items)` — wraps a list as `IAsyncEnumerable` - -### Ping - -| Test | Mock setup | Assertion | -|------|-----------|-----------| -| `Ping_Succeeds_WhenClientPingAsyncDoesNotThrow` | `PingAsync` returns `Task.CompletedTask` | Does not throw; `PingAsync` called exactly once | -| `Ping_Rethrows_WhenClientPingThrows` | `PingAsync` throws `Exception("Connection refused")` | Throws `Exception` with message matching `"*CERTInext*Connection refused*"` | -| `Ping_SkipsConnectivityTest_WhenConnectorIsDisabled` | Strict mock, no setups; `CERTInextConfig.Enabled = false` | Does not throw; no client method called (verified via `VerifyNoOtherCalls()`) | - -### GetProductIds - -| Test | Mock setup | Assertion | -|------|-----------|-----------| -| `GetProductIds_ReturnsStaticProductList` | No mock calls expected | Returns 10 items including `DV SSL`, `OV SSL`, `EV SSL`; no client method called | - -`GetProductIds()` returns a hardcoded static list — no API call is made. The strict mock's -`VerifyNoOtherCalls()` confirms this. - -### Enroll - -The `Enroll` method selects a path based on `EnrollmentType`. Both `New` and `Reissue` submit a -new `GenerateOrderSSL` order. `RenewOrReissue` also submits `GenerateOrderSSL` (CERTInext has -no dedicated renewal endpoint) but applies the renewal-window check to determine how Command -tracks the old→new certificate relationship. - -| Test | EnrollmentType | Mock setup | Assertion | -|------|---------------|-----------|-----------| -| `Enroll_New_CallsEnrollAsync_AndReturnsIssuedResult` | `New` | `PlaceOrderAsync` returns `ORD-AAA-111` | `CARequestID == "ORD-AAA-111"`; `Status == GENERATED` | -| `Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval` | `New` | `PlaceOrderAsync` → pending status | `Status == EXTERNALVALIDATION` | -| `Enroll_New_Throws_WhenProfileIdNotSet` | `New` | Strict mock — no setups | Throws before calling the client | -| `Enroll_Reissue_AlsoCallsEnrollAsync` | `Reissue` | `PlaceOrderAsync` returns issued | `Status == GENERATED`; called once | -| `Enroll_Renew_FallsBackToNewEnroll_WhenNoPriorCertSn` | `RenewOrReissue` | `PlaceOrderAsync` returns issued | `CARequestID == "ORD-AAA-111"`; no dedicated renew call | - -### GetSingleRecord - -| Test | Mock setup | Assertion | -|------|-----------|-----------| -| `GetSingleRecord_ReturnsMappedCertificate_ForIssuedCert` | `TrackOrderAsync("ORD-AAA-111")` returns issued track response; `GetCertificateAsync` returns PEM | `Status == GENERATED`; PEM present; `ProductID == ProfileIdTls` | -| `GetSingleRecord_ReturnsMappedCertificate_ForRevokedCert` | `TrackOrderAsync("ORD-CCC-333")` returns revoked response | `Status == REVOKED`; `RevocationDate` non-null; `RevocationReason == 1` | -| `GetSingleRecord_Rethrows_WhenCertNotFound` | Client throws `KeyNotFoundException` | Rethrows `KeyNotFoundException` | - -### Revoke - -The plugin checks the current certificate status before calling `RevokeOrder`. CRL reason codes -(integers) are mapped to CERTInext string values. - -| Test | Mock setup | Assertion | -|------|-----------|-----------| -| `Revoke_CallsRevokeCertificateAsync_AndReturnsRevokedStatus` | `TrackOrderAsync` returns issued cert; `RevokeOrderAsync` returns `Task.CompletedTask` | Returns `REVOKED`; `RevokeOrderAsync` called once with correct reason string | -| `Revoke_ReturnsAlreadyRevoked_WhenCertAlreadyRevoked` | `TrackOrderAsync` returns revoked cert | Returns `REVOKED`; `RevokeOrderAsync` never called | -| `Revoke_MapsAllCrlReasonCodes` | Per reason code 0–5 and beyond | Verifies mapping: `0→"unspecified"`, `1→"keyCompromise"`, `2→"caCompromise"`, `3→"affiliationChanged"`, `4→"superseded"`, `5→"cessationOfOperation"`, extended codes also covered by `CERTInextCAPluginCoverageTests` | - -### Synchronize - -`Synchronize` iterates `ListOrdersAsync` and posts mapped `AnyCAPluginCertificate` objects to a -`BlockingCollection`. Full sync passes `null` as `issuedAfter`; delta sync passes `lastSync`. - -| Test | Mock setup | Assertion | -|------|-----------|-----------| -| `Synchronize_FullSync_AddsAllCertsToBuffer` | `ListOrdersAsync(null, ...)` returns two issued orders | Buffer contains 2 items; both order numbers present | -| `Synchronize_DeltaSync_PassesLastSyncFilter` | `ListOrdersAsync` captures `issuedAfter` | Captured value equals `lastSync` | -| `Synchronize_FullSync_PassesNullIssuedAfter` | `ListOrdersAsync` captures `issuedAfter` | Even when `lastSync` is non-null, `fullSync:true` forces `issuedAfter=null` | -| `Synchronize_SkipsFailedCertificates` | Returns one issued + one with unknown/failed status | Buffer contains exactly 1 item | -| `Synchronize_HonoursCancellation` | Async enumerable that cancels mid-iteration | Throws `OperationCanceledException` | -| `Synchronize_MapsRevokedCertificates_Correctly` | Returns one revoked record | Buffer item `Status == REVOKED`; `RevocationDate` non-null | -| `Synchronize_CallsCompleteAdding_OnNormalExit` | Returns empty | `buffer.IsAddingCompleted == true` | -| `Synchronize_CallsCompleteAdding_OnCancellation` | Cancels mid-iteration | `buffer.IsAddingCompleted == true` even after `OperationCanceledException` | - -**Note on `CompleteAdding`:** `Synchronize` calls `blockingBuffer.CompleteAdding()` in a `finally` -block. Tests must not call `buffer.CompleteAdding()` themselves — doing so after the plugin has -already called it throws `InvalidOperationException`. - ---- - -## CERTInextCAPluginPublicSurfaceTests - -Reflection-based contract tests that verify the no-DCV build does not expose any public types, -fields, methods, or constructors that reference `IDomainValidatorFactory` or other IAnyCAPlugin -3.3-only types. These tests ensure the default build loads cleanly on AnyCA Gateway 25.5.x hosts. - -| Test | What it checks | -|------|---------------| -| `NoPublicConstructor_ReferencesV3Point3OnlyTypes` | No public constructor has a parameter typed as a 3.3-only interface | -| `NoInstanceField_DeclaredTypeReferencesV3Point3OnlyTypes` | No public or private instance field is typed as a 3.3-only type | -| `NoNestedType_ImplementsV3Point3OnlyInterface` | No nested type implements a 3.3-only interface | -| `NoPublicMethod_SignatureReferencesV3Point3OnlyTypes` | No public method has a parameter or return type referencing 3.3-only types | -| `ParameterlessConstructor_IsPublic` | The plugin has a public parameterless constructor (required by the gateway host for reflection-based instantiation) | -| `SetDomainValidatorFactory_AcceptsObject_NotIDomainValidatorFactory` | The DCV injection method accepts `object`, not the 3.3-only `IDomainValidatorFactory`, so the method signature loads on 3.2 hosts | -| `SetDomainValidatorFactory_NullArgument_LeavesDcvDisabled` | Passing `null` does not enable DCV | -| `SetDomainValidatorFactory_NonFactoryArgument_IsIgnored` | Passing a non-factory object does not enable DCV | - ---- - -## BoundedDcvSyncTests - -Pure unit tests for the age-window and per-pass cap logic in `TryRunDcvDuringSyncAsync`. No -network I/O. Verifies that: -- Orders within the configured age window are attempted -- Orders older than the window are skipped (to avoid retrying abandoned orders indefinitely) -- Orders at the exact age boundary are attempted -- Orders with unknown dates are attempted (not starved) -- Age window of 0 disables the filter -- The per-pass cap skips orders once the cap is reached -- Cap of 0 disables the cap -- Age skip takes precedence over the cap check - ---- - -## RateLimitRetryTests - -Pure unit tests for the `IsRateLimitSurface` and `ComputeRateLimitBackoffSeconds` helpers: -- `IsRateLimitSurface` recognises the documented CERTInext rate-limit error phrase and rejects - unrelated strings -- `ComputeRateLimitBackoffSeconds` produces a result within the expected jittered range for each - attempt number -- Attempt values below 1 are clamped to 1 - ---- - -## MockCertificateData - -`MockCertificateData` is a static internal class shared across test suites. It provides realistic -fake CERTInext API response objects and JSON payloads. - -The real CERTInext API uses HTTP POST for all endpoints and wraps every response in a `meta` -block with `status: "1"` (success) or `status: "0"` (failure). - -### Constants - -| Constant | Value | Used for | -|----------|-------|---------| -| `FakePemCertificate` | PEM block starting with `-----BEGIN CERTIFICATE-----` | Certificate body in all responses | -| `FakeCsrPem` | PEM block starting with `-----BEGIN CERTIFICATE REQUEST-----` | CSR body in enroll requests | -| `OrderNumber1` | `"ORD-AAA-111"` | Primary order number (also aliased as `CertId1`) | -| `OrderNumber2` | `"ORD-BBB-222"` | Second order number (also aliased as `CertId2`) | -| `OrderNumber3` | `"ORD-CCC-333"` | Revoked order number (also aliased as `CertId3`) | -| `ProfileIdTls` | `"tls-server"` | TLS server product code placeholder | -| `ProfileIdClient` | `"client-auth"` | Client auth product code placeholder | - -`CertId1/2/3` are backward-compatibility aliases for `OrderNumber1/2/3`. - -### JSON helpers (WireMock stubs) - -| Method | Endpoint | Notes | -|--------|----------|-------| -| `ValidateCredentialsSuccessJson()` | `POST /ValidateCredentials` | Success meta only | -| `ValidateCredentialsFailureJson(code, msg)` | `POST /ValidateCredentials` | Failure meta | -| `GenerateOrderSuccessJson(orderNumber)` | `POST /GenerateOrderSSL` | Includes `orderDetails.orderNumber` | -| `TrackOrderIssuedJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="9"` (GENERATED) | -| `TrackOrderPendingJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="1"` (SetupPending) | -| `TrackOrderRevokedJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="22"`, revocation details present | -| `GetCertificateSuccessJson()` | `POST /GetCertificate` | PEM in `certificateDetails.endEntityCertificate`; serial `"0A1B2C3D4E5F"` | -| `RevokeSuccessJson()` | `POST /RevokeOrder` | Success meta only | -| `OrderReportSinglePageJson()` | `POST /GetOrderReport` | One entry, `ORD-AAA-111` | -| `OrderReportPageJson(orderNumbers, total, pages, current)` | `POST /GetOrderReport` | Multi-entry paginated response | -| `OrderReportEmptyJson()` | `POST /GetOrderReport` | Empty `ordersArray`, `noOfPages=0` | -| `GetProductDetailsJson()` | `POST /GetProductDetails` | Nested category envelope with two products | -| `GetProductDetailsEmptyJson()` | `POST /GetProductDetails` | Empty `productDetails` array | -| `ApiFailureJson(code, msg)` | Any endpoint | Generic `meta.status="0"` failure | -| `GetDcvSuccessJson(token)` | `POST /GetDcv` | `dcvDetails.token` | -| `GetDcvFailureJson(code, msg)` | `POST /GetDcv` | Failure meta | -| `VerifyDcvSuccessJson()` | `POST /VerifyDcv` | Success meta only | -| `VerifyDcvFailureJson(code, msg)` | `POST /VerifyDcv` | Failure meta | -| `OAuth2TokenJson(expiresIn)` | OAuth token endpoint | `access_token="fake-bearer-token-abc123"` | -| `ServerErrorJson()` | Any | Generic 500 error body (not meta-wrapped) | -| `UnauthorizedJson()` | Any | Generic 401 error body (not meta-wrapped) | - -### Object helpers (Moq setups) - -| Method | Returns | -|--------|---------| -| `ActiveProfiles()` | Two `ProfileInfo` objects, both `Active=true`: `ProfileIdTls` and `ProfileIdClient` | -| `MixedProfiles()` | Three `ProfileInfo` objects: `ProfileIdTls` (active), `"legacy-profile"` (inactive), `ProfileIdClient` (active) | -| `IssuedEnrollResponse(id)` | `EnrollCertificateResponse` with `Status="issued"`, PEM, `SerialNumber="0A1B2C3D4E5F"` | -| `PendingEnrollResponse(id)` | `EnrollCertificateResponse` with `Status="pending_approval"`, `Certificate=null` | -| `IssuedCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="issued"`, PEM, `ProfileId=ProfileIdTls` | -| `PendingCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="pending_approval"`, no certificate — maps to `EXTERNALVALIDATION` | -| `RevokedCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="revoked"`, `RevokedAt`, `RevocationReason="keyCompromise"` | -| `DcvPendingTrackResponse(orderNumber, domain)` | `TrackOrderResponse` with one DNS-TXT entry at `dcvStatus="0"` (pending) | -| `DcvVerifiedTrackResponse(orderNumber, domain)` | `TrackOrderResponse` with DNS-TXT entry at `dcvStatus="1"` (validated) | -| `AlreadyIssuedTrackResponse(orderNumber)` | `TrackOrderResponse` with `certificateStatusId="9"` (GENERATED) — DCV should be skipped | -| `DcvTokenResponse(token)` | `GetDcvResponse` with `DcvDetails.Token` set | - ---- - -## Adding New Tests - -### Which suite to add to - -- **`CERTInextClientTests`** — when testing HTTP-level behaviour: a new endpoint, error status - code, authentication header detail, body serialisation, or query parameter. -- **`CERTInextClientRequestShapeTests`** — when verifying that the request body includes or omits - specific JSON blocks based on connector configuration. -- **`CERTInextCAPluginTests` / `CERTInextCAPluginCoverageTests`** — when testing plugin logic: a - new enrollment type, validation rule, status mapping, or response to specific client return values. - -### Adding a new WireMock stub - -1. Register a stub in the test body: - ```csharp - _server - .Given(Request.Create().WithPath("/YourEndpoint").UsingPost()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(MockCertificateData.YourResponseJson())); - ``` -2. Add a `YourResponseJson(...)` JSON helper to `MockCertificateData` if the shape is reused. -3. Verify request details by inspecting `_server.LogEntries` after the call. diff --git a/docsource/architecture.md b/docsource/architecture.md index 2a268ca..d471f6d 100644 --- a/docsource/architecture.md +++ b/docsource/architecture.md @@ -146,7 +146,7 @@ sequenceDiagram end Plugin-->>CMD: Certificate ready if issued within the budget —
otherwise pending, completed by the next synchronization else Pending and product is OV or EV - Plugin-->>CMD: Pending — CERTInext issues OV/EV asynchronously by design
(organization verification); completed by the next synchronization + Plugin-->>CMD: Pending — CERTInext issues OV/EV asynchronously by design
(organization verification) — completed by the next synchronization else Order rejected by CERTInext Plugin-->>CMD: Enrollment failed — see gateway logs end diff --git a/docsource/development.md b/docsource/development.md index 56768bf..0cdc6ef 100644 --- a/docsource/development.md +++ b/docsource/development.md @@ -111,7 +111,7 @@ Run them with: just integration-test ``` -See `CERTInext.IntegrationTests/INTEGRATION_TESTING.md` for a full description of each test, what it validates, and the expected API state. +See `CERTInext.IntegrationTests/README.md` for a full description of each test, what it validates, and the expected API state. ## Product Integration Test Coverage From e0f0a9adb3c3a860b388177d68a9e4282146a0dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 1 Aug 2026 16:29:46 +0000 Subject: [PATCH 16/17] docs: auto-generate README and documentation [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 711faec..bd7e24d 100644 --- a/README.md +++ b/README.md @@ -488,7 +488,7 @@ sequenceDiagram end Plugin-->>CMD: Certificate ready if issued within the budget —
otherwise pending, completed by the next synchronization else Pending and product is OV or EV - Plugin-->>CMD: Pending — CERTInext issues OV/EV asynchronously by design
(organization verification); completed by the next synchronization + Plugin-->>CMD: Pending — CERTInext issues OV/EV asynchronously by design
(organization verification) — completed by the next synchronization else Order rejected by CERTInext Plugin-->>CMD: Enrollment failed — see gateway logs end From 5a54bc3f16dfeecd7fa5f7e9d9e2f81429d3367f Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:23:35 -0700 Subject: [PATCH 17/17] docs: scrub internal support-ticket number from code and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace bare references to internal CERTInext support ticket #162763 with generic attribution ("confirmed by CERTInext support"). Internal ticket numbers and customer names shouldn't end up in code comments or docs — GitHub issue references are the only exception. --- CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs | 2 +- CERTInext.Tests/README.md | 2 +- CERTInext/CERTInextCAPlugin.cs | 2 +- CERTInext/Constants.cs | 2 +- CERTInext/Models/ProductValidationType.cs | 2 +- docsource/configuration.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs b/CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs index 87597f5..722d98b 100644 --- a/CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs @@ -24,7 +24,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests /// that runs at the end of every enrollment path on both build flavors: /// DV products poll GetCertificate and return GENERATED + PEM when CERTInext /// issues within the budget; OV/EV products defer immediately (async by CA design, - /// support ticket #162763); exhaustion or any failure soft-falls back to the pending + /// per CERTInext support); exhaustion or any failure soft-falls back to the pending /// result without throwing. Compiles on both the DCV (3.3.0) and no-DCV (3.2.0) flavors. ///
public class CERTInextCAPluginEnrollmentWaitTests diff --git a/CERTInext.Tests/README.md b/CERTInext.Tests/README.md index 00b5f3a..65ab815 100644 --- a/CERTInext.Tests/README.md +++ b/CERTInext.Tests/README.md @@ -450,7 +450,7 @@ drives a single enrollment for a fixed CSR/subject/SAN. Unit tests for the synchronous enrollment-wait poll (`TryEnrollmentWaitForCertificateAsync`) that runs at the end of every enrollment path on both build flavors: DV products poll `GetCertificate` and return GENERATED + PEM when CERTInext issues within the budget; OV/EV products defer -immediately (async by CA design, support ticket #162763); exhaustion or any failure soft-falls +immediately (async by CA design, per CERTInext support); exhaustion or any failure soft-falls back to the pending result without throwing. Compiles on both the DCV (3.3.0) and no-DCV (3.2.0) flavors. diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 9c7aab0..e0c32e5 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1600,7 +1600,7 @@ private async Task RenewOrReissueAsync( /// /// Only DV products are polled: CERTInext issues OV/EV asynchronously by design — /// the mandatory organization-verification step takes minutes and may be human-gated - /// (support ticket #162763), so holding a Command worker thread for them cannot + /// (confirmed by CERTInext support), so holding a Command worker thread for them cannot /// succeed; those orders return pending immediately with an explanatory message and /// are completed by the next synchronization. Products whose validation level cannot /// be determined are polled optimistically — the poll is bounded and a wasted wait is diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index c3a2613..3f8e428 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -82,7 +82,7 @@ public static class Config // PickUpEnrolledCertificate behavior). EnrollmentWaitSeconds is the maximum time an // enrollment call can occupy a Command worker thread. OV/EV products skip the poll // entirely — CERTInext issues them asynchronously by design (org verification, - // minutes to hours; support ticket #162763) and no in-call poll can absorb that + // minutes to hours, confirmed by CERTInext support) and no in-call poll can absorb that // within Command's enrollment timeout. public const string EnrollmentWaitSeconds = "EnrollmentWaitSeconds"; diff --git a/CERTInext/Models/ProductValidationType.cs b/CERTInext/Models/ProductValidationType.cs index 705f049..fe0dc7c 100644 --- a/CERTInext/Models/ProductValidationType.cs +++ b/CERTInext/Models/ProductValidationType.cs @@ -13,7 +13,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Models /// Validation level of a CERTInext SSL product. Drives whether Enroll() performs a /// synchronous pickup poll: DV products issue in seconds once accepted, while OV/EV products /// go through a mandatory organization-verification step and issue asynchronously — minutes - /// to hours, sometimes human-gated (CERTInext support ticket #162763: "there is no setting + /// to hours, sometimes human-gated (confirmed by CERTInext support: "there is no setting /// on our end that makes this certificate type return instantly in a single call"). /// internal enum ProductValidationType diff --git a/docsource/configuration.md b/docsource/configuration.md index 36b1b7b..3199639 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -252,7 +252,7 @@ CERTInext orders pass through several internal status stages before a certificat Before returning a pending result, `Enroll()` runs a **synchronous enrollment-wait poll** gated by the product's validation level: - **DV products** — the plugin polls `GetCertificate` for up to `EnrollmentWaitSeconds` (default 50 s), every 5 seconds. If CERTInext issues within that budget, the enrollment call returns the issued certificate directly — no waiting for the next sync. If the budget elapses, the pending result is returned unchanged and sync completes the order later. -- **OV/EV products** — the poll is skipped entirely. CERTInext issues OV/EV asynchronously by design: the mandatory organization-verification step takes minutes and may require human review, so no in-call wait can succeed within Command's enrollment timeout (confirmed by CERTInext support, ticket #162763). The pending result carries a status message explaining this; the certificate is imported automatically by the next synchronization. +- **OV/EV products** — the poll is skipped entirely. CERTInext issues OV/EV asynchronously by design: the mandatory organization-verification step takes minutes and may require human review, so no in-call wait can succeed within Command's enrollment timeout (confirmed by CERTInext support). The pending result carries a status message explaining this; the certificate is imported automatically by the next synchronization. The validation level (DV/OV/EV) is resolved from the account's product catalog (`GetProductDetails`, cached for 60 minutes — never fetched per-enrollment), falling back to the DV/OV/EV token in the template's product name when the catalog is unavailable. Products whose level cannot be determined are polled optimistically. When `DcvEnabled` is `true`, pending **new/reissue** orders skip the enrollment-wait poll — the in-call DCV flow owns those waits — but renewals (which never run in-call DCV) and issued-orders-awaiting-PEM-download remain eligible.