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 @@
+
+
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/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/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);
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/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs
index f1de8f8..f7ff1fe 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 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
+ // 50s poll budget retrying strict mocks.
+ EnrollmentWaitSeconds = 0
};
private static Mock NewMock() =>
@@ -215,6 +220,200 @@ 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_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_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()
{
@@ -463,7 +662,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/CERTInextCAPluginEnrollmentWaitTests.cs b/CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs
new file mode 100644
index 0000000..722d98b
--- /dev/null
+++ b/CERTInext.Tests/CERTInextCAPluginEnrollmentWaitTests.cs
@@ -0,0 +1,745 @@
+// 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 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,
+ /// 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
+ {
+ 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 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 totalSeconds = 50) =>
+ new CERTInextConfig { EnrollmentWaitSeconds = totalSeconds };
+
+ 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 EnrollmentWait_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, 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 enrollment-wait 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 EnrollmentWait_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, 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.Once);
+ }
+
+ // ---------------------------------------------------------------------------
+ // OV/EV: pending immediately, no poll
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task EnrollmentWait_OvProduct_ReturnsPendingImmediately_WithoutPolling()
+ {
+ var mock = NewMock();
+ SetupPendingEnroll(mock);
+ SetupCatalog(mock);
+
+ var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig());
+
+ 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 EnrollmentWait_EvProduct_ReturnsPendingImmediately_WithoutPolling()
+ {
+ var mock = NewMock();
+ SetupPendingEnroll(mock);
+ SetupCatalog(mock);
+
+ var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig());
+
+ 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 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, EnrollmentWaitConfig());
+
+ // 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 EnrollmentWait_ProductCatalog_IsCachedAcrossEnrollments()
+ {
+ var mock = NewMock();
+ SetupPendingEnroll(mock);
+ SetupCatalog(mock);
+
+ var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig());
+ 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 EnrollmentWait_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, 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).
+ 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 EnrollmentWait_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, 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");
+ // 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),
+ "a 10s budget over the fixed 5s interval must yield exactly two polls");
+ }
+
+ [Fact]
+ public async Task EnrollmentWait_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, EnrollmentWaitConfig());
+
+ 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 EnrollmentWait_Disabled_WhenRetriesNegative()
+ {
+ // "-1 to disable" is a common operator convention — it must not silently
+ // fall back to the enabled default of 50s.
+ var mock = NewMock();
+ SetupPendingEnroll(mock);
+
+ var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(-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 EnrollmentWait_SoftFallsBackToPending_WhenGetCertificateThrows()
+ {
+ var mock = NewMock();
+ SetupPendingEnroll(mock);
+ SetupCatalog(mock);
+ mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()))
+ .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(10));
+
+ var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode));
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION,
+ "a failing enrollment-wait poll must not fail the enrollment — the order was accepted");
+ result.CARequestID.Should().Be(MockCertificateData.CertId2);
+ }
+
+ [Fact]
+ public async Task EnrollmentWait_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, EnrollmentWaitConfig());
+
+ var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode));
+
+ result.Status.Should().Be((int)EndEntityStatus.FAILED,
+ "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");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Opt-out and no-op paths
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task EnrollmentWait_Disabled_WhenRetriesZero()
+ {
+ var mock = NewMock();
+ SetupPendingEnroll(mock);
+
+ var plugin = new CERTInextCAPlugin(mock.Object, EnrollmentWaitConfig(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 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 EnrollmentWait_Skipped_WhenEnrollReturnsIssuedWithPem()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedEnrollResponse());
+
+ 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 enrollment wait");
+ }
+
+ [Fact]
+ public async Task EnrollmentWait_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, 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 enrollment wait must recover the PEM for an issued order whose download failed");
+ }
+
+ [Fact]
+ public async Task EnrollmentWait_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, EnrollmentWaitConfig());
+
+ 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 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 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();
+ 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, EnrollmentWaitConfig(15));
+
+ 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 enrollment wait");
+ result.StatusMessage.Should().Contain("later synchronization");
+ }
+
+ [Fact]
+ 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 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();
+ 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, EnrollmentWaitConfig(15));
+
+ 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 EnrollmentWait_Disabled_DowngradesIssuedWithoutPem_ToPending()
+ {
+ // 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.
+ 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, EnrollmentWaitConfig(0));
+
+ var result = await Enroll(plugin, ProductInfo(Constants.Products.DvSsl, DvCode));
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION,
+ "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, "the disabled enrollment wait must not poll");
+ }
+
+ [Fact]
+ public async Task EnrollmentWait_DegradesIssuedWithoutPem_ToPending_WhenOrderNumberEmpty()
+ {
+ // 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 enrollment wait 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, EnrollmentWaitConfig());
+
+ 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
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task EnrollmentWait_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, EnrollmentWaitConfig());
+ 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 EnrollmentWait_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, EnrollmentWaitConfig());
+ 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 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 enrollment wait must poll the NEW order number returned by the renewal");
+ }
+
+ [Fact]
+ public async Task EnrollmentWait_RenewPath_RunsEvenWhenDcvEnabled()
+ {
+ // In-call DCV only exists on the New/Reissue path, so DcvEnabled must NOT
+ // 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);
+
+ 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 = EnrollmentWaitConfig();
+ 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 enrollment wait — no in-call DCV runs there");
+ }
+
+ [Fact]
+ 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.
+ 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 = EnrollmentWaitConfig();
+ 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 EnrollmentWait_RenewPath_ClassifiesTheProductCodeActuallyOrdered()
+ {
+ // CERTInextClient.RenewCertificateAsync places the renewal order with the
+ // 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);
+
+ readerMock.Setup(r => r.GetRequestIDBySerialNumber(It.IsAny()))
+ .ReturnsAsync(MockCertificateData.CertId1);
+ 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(renewPending);
+ SetupCatalog(clientMock);
+
+ var config = EnrollmentWaitConfig();
+ config.DefaultProductCode = OvCode; // what RenewCertificateAsync orders 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.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs
index 7524624..9d295f6 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);
+ // Enrollment wait disabled: this test verifies the pending-status mapping, not
+ // the synchronous enrollment wait (which has its own suite) — with the default
+ // 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.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.Tests/README.md b/CERTInext.Tests/README.md
new file mode 100644
index 0000000..65ab815
--- /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, 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.
+
+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/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..e0c32e5 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 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;
+
#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,12 @@ public void Dispose()
{
if (!_clientWasInjected)
(_client as IDisposable)?.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).
}
// ---------------------------------------------------------------------------
@@ -1121,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).
@@ -1151,32 +1175,100 @@ 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
{
+ // 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
// 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);
- if (postDcv != null)
+ // 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.
+ int dcvIssuanceBudgetSeconds = _config.GetEffectiveDcvWaitForIssuanceSeconds();
+ postDcv = await WaitForIssuanceAsync(
+ orderNumber, dcvIssuanceBudgetSeconds,
+ Constants.Polling.CertificatePollIntervalSeconds, "PostDcv", dcvCts.Token);
+ // 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 = 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)
+ // 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
+ && StatusMapper.IsTerminalIssuance(
+ StatusMapper.ToRequestDisposition(postDcv.Status), postDcv.Certificate))
{
- 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);
}
}
+ // 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)
+ {
+ // 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}) during {Phase} for order " +
+ "{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
{
@@ -1203,11 +1295,37 @@ 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
+ // 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, dcvIssuanceWaitRan);
+
_logger.MethodExit(LogLevel.Debug);
- return BuildEnrollmentResult(enrollResp, ep.AutoApprove);
+ return newResult;
}
#if SUPPORTS_DCV
@@ -1409,14 +1527,56 @@ 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);
+ // 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). 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.
+ bool renewedProductCodeIsApiReported = !string.IsNullOrWhiteSpace(renewResp.ProfileId);
+ string renewedProductCode = renewedProductCodeIsApiReported
+ ? renewResp.ProfileId
+ : ep.ProductCode;
+ 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 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);
+
return renewResult;
}
else
@@ -1428,6 +1588,338 @@ private async Task RenewOrReissueAsync(
}
}
+ // ---------------------------------------------------------------------------
+ // Synchronous enrollment wait — 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
+ /// (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
+ /// 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 the enrollment-wait poll.
+ ///
+ 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,
+ // 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
+ && 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 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
+ // 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)
+ {
+ // 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 enrollment wait for order {OrderNumber} — the in-call DCV flow owns this order's issuance wait.",
+ orderNumber);
+ return pendingResult;
+ }
+
+ int configuredBudgetSeconds = _config.GetEffectiveEnrollmentWaitSeconds();
+ if (configuredBudgetSeconds <= 0)
+ {
+ // 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 EnrollmentWaitSeconds={Budget}). " +
+ "Order {OrderNumber} (ProductCode={ProductCode}) will be picked up on the next sync cycle.",
+ configuredBudgetSeconds, orderNumber, productCode);
+ return DegradeBodylessIssuedToPending(pendingResult);
+ }
+
+ // Clamp to the hard ceiling — Command abandons enrollment calls long before it, so a
+ // larger budget would only orphan a worker thread (docs: keep EnrollmentWaitSeconds
+ // under ~90 s).
+ 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 = EnrollmentWaitSeconds) exceeds the " +
+ "hard ceiling; clamped to {Max}s for order {OrderNumber} (ProductCode={ProductCode}).",
+ configuredBudgetSeconds, Constants.EnrollmentWait.MaxBudgetSeconds, orderNumber, productCode);
+ }
+
+ try
+ {
+ // 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 "EnrollmentWaitSeconds = max Command-occupied time" honest.
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(budgetSeconds + 30));
+
+ var validationType = ProductValidationType.Unknown;
+ var validationTypeSource = "n/a";
+ if (pendingApproval)
+ {
+ (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. 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}, ValidationTypeSource={Source}. The certificate will be " +
+ "imported by a later synchronization.",
+ typeLabel, orderNumber, productCode, validationTypeSource);
+
+ 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 enrollment-wait poll started. OrderNumber={OrderNumber}, ProductCode={ProductCode}, " +
+ "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);
+
+ // 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
+ // 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.IsTerminalIssuance(finalDisposition, final.Certificate))
+ {
+ _logger.LogInformation(
+ "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.
+ 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 DegradeBodylessIssuedToPending(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<(ProductValidationType type, string source)> 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)
+ {
+ // "catalog" — the authoritative account catalog classified this code.
+ return (fromCatalog, "catalog");
+ }
+ }
+
+ // "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");
+ }
+
+ ///
+ /// 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 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.EnrollmentWait.ProductTypeCacheMinutes);
+ _logger.LogInformation(
+ "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 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-enrollment-wait gating exceeded the enrollment budget; " +
+ "catalog refresh backed off for {BackoffMinutes} minutes. Subsequent enrollments will " +
+ "classify from {Fallback} until it recovers.",
+ 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-enrollment-wait gating; falling back to " +
+ "{Fallback}. Retry backed off for {BackoffMinutes} minutes.",
+ _productTypeByCode != null ? "the stale cached catalog" : "template-name classification",
+ Constants.EnrollmentWait.FailureBackoffMinutes);
+ _productTypeCacheExpiresUtc = DateTime.UtcNow.AddMinutes(Constants.EnrollmentWait.FailureBackoffMinutes);
+ return _productTypeByCode;
+ }
+ }
+ finally
+ {
+ _productTypeCacheLock.Release();
+ }
+ }
+
// ---------------------------------------------------------------------------
// DCV helpers
// ---------------------------------------------------------------------------
@@ -1861,28 +2353,23 @@ 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) and the general synchronous enrollment wait in
+ /// (budget =
+ /// EnrollmentWaitSeconds). Both poll every
+ /// seconds.
///
- private async Task WaitForIssuanceAfterDcvAsync(
- string orderNumber, CancellationToken ct)
+ private async Task WaitForIssuanceAsync(
+ string orderNumber, int waitBudgetSeconds, int pollIntervalSeconds, string phase, 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,48 +2380,99 @@ private async Task WaitForIssuanceAfterDcvAsync(
if (waitBudgetSeconds <= 0)
{
_logger.LogDebug(
- "Post-DCV issuance wait disabled (DcvWaitForIssuanceSeconds<=0). " +
+ "Issuance wait disabled (budget<=0). Phase={Phase}. " +
"Order {OrderNumber} will be picked up on the next sync cycle.",
- orderNumber);
+ phase, orderNumber);
return null;
}
+ // 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 ⇒
+ // 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)
{
attempt++;
ct.ThrowIfCancellationRequested();
+ LegacyGetCertificateResponse current = null;
try
{
- last = await _client.GetCertificateAsync(orderNumber, ct);
+ current = await _client.GetCertificateAsync(orderNumber, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ // 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)
{
- // 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,
- "Post-DCV GetCertificate failed 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);
+
+ // 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
+ // 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.
+ if (StatusMapper.IsTerminalIssuance(disposition, last.Certificate))
+ {
+ return last;
+ }
}
- if (waitBudgetSeconds <= 0 || DateTime.UtcNow >= 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(
- "Post-DCV 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 (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;
}
@@ -1944,6 +2482,13 @@ private async Task WaitForIssuanceAfterDcvAsync(
}
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;
}
}
@@ -2024,6 +2569,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.
@@ -2039,7 +2604,33 @@ private EnrollmentResult BuildEnrollmentResult(EnrollCertificateResponse resp, b
switch (status)
{
case (int)EndEntityStatus.GENERATED:
- message = $"Certificate issued successfully. CERTInext ID: {resp.Id}.";
+ // 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:
diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs
index 33fa362..680a8a8 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
{
@@ -272,6 +275,25 @@ public static Dictionary GetCAConnectorAnnotations()
DefaultValue = true,
Type = "Boolean"
},
+ [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.EnrollmentWaitSecondsEnvVar} environment " +
+ "variable; the env var takes precedence when both are set. Default: 50.",
+ Hidden = false,
+ DefaultValue = Constants.EnrollmentWait.DefaultSeconds,
+ Type = "Number"
+ },
[Constants.Config.DcvEnabled] = new PropertyConfigInfo
{
Comments = "OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) " +
@@ -679,6 +701,18 @@ public class CERTInextConfig
[JsonPropertyName("IgnoreExpired")]
public bool IgnoreExpired { get; set; } = false;
+ ///
+ /// 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("EnrollmentWaitSeconds")]
+ public int EnrollmentWaitSeconds { get; set; } = Constants.EnrollmentWait.DefaultSeconds;
+
[JsonPropertyName("PageSize")]
public int PageSize { get; set; } = Constants.Api.DefaultPageSize;
@@ -769,41 +803,85 @@ 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();
+
///
- /// 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.
+ /// makes negative values coerce to 0 rather
+ /// 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 /
+ /// SOC2 CC7.2: the override changes runtime control behavior, so silently ignoring
+ /// it would leave the deployed value unexplained in the audit trail).
///
- public int GetEffectiveDcvTimeoutMinutes()
+ private static int GetEffectiveInt(string envVarName, int configured, int fallback,
+ bool zeroAllowed, bool negativeMeansZero = false)
{
- var env = System.Environment.GetEnvironmentVariable(Constants.Config.DcvTimeoutMinutesEnvVar);
- if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && envVal > 0)
- return envVal;
- return DcvTimeoutMinutes > 0 ? DcvTimeoutMinutes : 10;
+ 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))
+ 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;
}
+ ///
+ /// 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-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 GetEffectiveEnrollmentWaitSeconds() =>
+ GetEffectiveInt(Constants.Config.EnrollmentWaitSecondsEnvVar, EnrollmentWaitSeconds, Constants.EnrollmentWait.DefaultSeconds,
+ zeroAllowed: true, negativeMeansZero: true);
}
}
diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs
index 2c604aa..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.");
}
@@ -767,6 +832,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
};
@@ -1213,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);
@@ -1229,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);
}
}
diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs
index 061aff8..3f8e428 100644
--- a/CERTInext/Constants.cs
+++ b/CERTInext/Constants.cs
@@ -74,10 +74,23 @@ public static class Config
// the TXT record / resolving the DNS provider plugin (issue 0006). Off by default.
public const string DcvFollowCnameDelegation = "DcvFollowCnameDelegation";
+ // Synchronous enrollment-wait poll inside Enroll() — DCV-independent, both build
+ // flavors. After submitting an order for a DV product, Enroll() polls GetCertificate
+ // 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, confirmed by CERTInext support) 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 EnrollmentWaitSecondsEnvVar = "CERTINEXT_ENROLLMENT_WAIT_SECONDS";
// Auth mode values
public const string AuthModeAccessKey = "AccessKey"; // default; authKey = SHA256(accessKey+ts+txn)
@@ -272,6 +285,41 @@ public static class RevocationReasonId
public const int Default = KeyCompromise;
}
+ public static class EnrollmentWait
+ {
+ // 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
+ // 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 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/CERTInext/Models/ProductValidationType.cs b/CERTInext/Models/ProductValidationType.cs
new file mode 100644
index 0000000..fe0dc7c
--- /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 (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
+ {
+ /// 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/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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bce090e..08f7f1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,13 @@
+# 1.2.0
+
+## Features
+- 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(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
## Features
diff --git a/README.md b/README.md
index 4732d47..bd7e24d 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +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.
+ * **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.
@@ -260,6 +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` |
+| `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` |
@@ -479,8 +481,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, product is DV, and DCV does not own the wait
+ loop Synchronous enrollment wait
(up to EnrollmentWaitSeconds)
+ 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 +496,10 @@ sequenceDiagram
Plugin->>Plugin: Record enrollment outcome in audit log
(order number, serial number, status)
```
+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` 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
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..d471f6d 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, product is DV, and DCV does not own the wait
+ loop Synchronous enrollment wait
(up to EnrollmentWaitSeconds)
+ 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,10 @@ sequenceDiagram
Plugin->>Plugin: Record enrollment outcome in audit log
(order number, serial number, status)
```
+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` 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
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..3199639 100644
--- a/docsource/configuration.md
+++ b/docsource/configuration.md
@@ -113,6 +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` |
+| `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` |
@@ -248,6 +249,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 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). 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.
+
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/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
diff --git a/docsource/overview.md b/docsource/overview.md
index f11d3d3..565b90e 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 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 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`. 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 `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 2276f2d..3fb9938 100644
--- a/integration-manifest.json
+++ b/integration-manifest.json
@@ -141,6 +141,10 @@
"name": "Enabled",
"description": "Enables or disables the CA connector. Set to false to create the connector record before credentials are available. Default: true."
},
+ {
+ "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",
"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."