diff --git a/.gitignore b/.gitignore
index 609bcd8..68b2f86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,4 +36,10 @@ terraform/terraform.tfvars
# Analysis / scratch — never commit
analysis/
-
+issues/
+.claude/
+.codex/
+CLAUDE.md
+AGENTS.md
+/logs*/
+*.log
diff --git a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
index bd3ec73..6ecc7fa 100644
--- a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
+++ b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
@@ -6,9 +6,9 @@
12.0falsetrue
-
- false
+
+ true$(DefineConstants);SUPPORTS_DCV
@@ -16,14 +16,17 @@
-
+
+
+
+
diff --git a/CERTInext.IntegrationTests/CloudflareDomainValidator.cs b/CERTInext.IntegrationTests/CloudflareDomainValidator.cs
index 89c01eb..db56616 100644
--- a/CERTInext.IntegrationTests/CloudflareDomainValidator.cs
+++ b/CERTInext.IntegrationTests/CloudflareDomainValidator.cs
@@ -23,7 +23,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
/// Credentials are read from the :
/// CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID.
///
- internal sealed class CloudflareDomainValidator : IDomainValidator
+ internal sealed class CloudflareDomainValidator : IDomainValidator, IDisposable
{
private const string CfApiBase = "https://api.cloudflare.com/client/v4";
@@ -113,11 +113,13 @@ public async Task CleanupValidation(string key, Cancella
public Task ValidateConfiguration(Dictionary configuration) => Task.CompletedTask;
public Dictionary GetDomainValidatorAnnotations() => new();
public string GetValidationType() => "dns-01";
+
+ public void Dispose() => _http.Dispose();
}
- internal sealed class CloudflareDomainValidatorFactory : IDomainValidatorFactory
+ internal sealed class CloudflareDomainValidatorFactory : IDomainValidatorFactory, IDisposable
{
- private readonly IDomainValidator _validator;
+ private readonly CloudflareDomainValidator _validator;
public CloudflareDomainValidatorFactory(string apiToken, string zoneId)
{
@@ -125,5 +127,7 @@ public CloudflareDomainValidatorFactory(string apiToken, string zoneId)
}
public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator;
+
+ public void Dispose() => _validator.Dispose();
}
}
diff --git a/CERTInext.IntegrationTests/DcvLifecycleTests.cs b/CERTInext.IntegrationTests/DcvLifecycleTests.cs
index 24ba0f1..5f2d57e 100644
--- a/CERTInext.IntegrationTests/DcvLifecycleTests.cs
+++ b/CERTInext.IntegrationTests/DcvLifecycleTests.cs
@@ -40,10 +40,11 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
/// CERTINEXT_DCV_DOMAIN=<subdomain to use, e.g. dcv-test.example.com>
///
///
- public class DcvLifecycleTests : IClassFixture
+ public class DcvLifecycleTests : IClassFixture, IDisposable
{
private readonly IntegrationTestFixture _fixture;
private readonly ITestOutputHelper _output;
+ private readonly List _toDispose = new List();
public DcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output)
{
@@ -51,6 +52,13 @@ public DcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper outpu
_output = output;
}
+ public void Dispose()
+ {
+ foreach (var d in _toDispose)
+ d.Dispose();
+ _toDispose.Clear();
+ }
+
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -69,11 +77,17 @@ private static string GenerateCsrPem(string commonName)
+ "\n-----END CERTIFICATE REQUEST-----";
}
- private IDomainValidatorFactory BuildDnsFactory() =>
- _fixture.IsCloudflareConfigured
- ? (IDomainValidatorFactory)new CloudflareDomainValidatorFactory(
- _fixture.CloudflareApiToken, _fixture.CloudflareZoneId)
- : new StubDomainValidatorFactory();
+ private IDomainValidatorFactory BuildDnsFactory()
+ {
+ if (_fixture.IsCloudflareConfigured)
+ {
+ var factory = new CloudflareDomainValidatorFactory(
+ _fixture.CloudflareApiToken, _fixture.CloudflareZoneId);
+ _toDispose.Add(factory);
+ return factory;
+ }
+ return new StubDomainValidatorFactory();
+ }
///
/// Runs plugin.Synchronize and returns every record that came out of the
@@ -88,6 +102,7 @@ private static async Task> RunSyncAsync(CERTInextCA
var syncTask = Task.Run(async () =>
{
await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: System.Threading.CancellationToken.None);
+ // Synchronize calls CompleteAdding() in its finally block; guard against double-call.
if (!buffer.IsAddingCompleted)
buffer.CompleteAdding();
});
@@ -655,7 +670,6 @@ public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks()
List synced = null;
System.Diagnostics.Stopwatch syncPhaseSw = System.Diagnostics.Stopwatch.StartNew();
int passesUsed = 0;
- int finalNotIssued = -1;
for (int pass = 1; pass <= maxSyncPasses; pass++)
{
@@ -664,13 +678,27 @@ public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks()
synced = await RunSyncAsync(plugin);
passSw.Stop();
+ // Classify enrolled orders by their current status so that FAILED orders
+ // are not silently counted as still-pending, which would burn the full
+ // pass budget before producing a misleading "expected 0" assertion.
int generated = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.GENERATED);
- int pending = enrolledIds.Count - generated;
- finalNotIssued = pending;
+ int failed = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED);
+ int pending = enrolledIds.Count - generated - failed;
_output.WriteLine(
$"--- Sync pass #{pass}: returned {synced.Count} records, {generated}/{enrolledIds.Count} GENERATED, " +
- $"{pending} still pending, elapsed={passSw.Elapsed:mm\\:ss} ---");
+ $"{failed} FAILED, {pending} still pending, elapsed={passSw.Elapsed:mm\\:ss} ---");
+
+ if (failed > 0)
+ {
+ var failedIds = synced
+ .Where(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED)
+ .Select(r => r.CARequestID)
+ .Take(5);
+ Assert.Fail(
+ $"Pass #{pass}: {failed} order(s) reached FAILED status and will never issue: " +
+ string.Join(", ", failedIds));
+ }
if (pending == 0)
break;
@@ -696,10 +724,14 @@ public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks()
$"{string.Join(", ", missing.Take(5))}{(missing.Count > 5 ? ", ..." : "")}");
// Final assertion — every enrolled order must be GENERATED after the polling window.
- var lookup = synced.ToDictionary(r => r.CARequestID, r => r);
+ // Filter null CARequestIDs before building the lookup (guards against any CA response
+ // that omits the ID, which would otherwise throw ArgumentNullException in ToDictionary).
+ var lookup = synced
+ .Where(r => r.CARequestID != null)
+ .ToDictionary(r => r.CARequestID, r => r);
var notIssued = enrolledIds
+ .Where(id => lookup.TryGetValue(id, out var rec) && rec.Status != (int)EndEntityStatus.GENERATED)
.Select(id => lookup[id])
- .Where(r => r.Status != (int)EndEntityStatus.GENERATED)
.ToList();
if (notIssued.Count > 0)
@@ -711,7 +743,7 @@ public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks()
notIssued.Should().BeEmpty(
$"every enrolled DV order should auto-issue on the new sandbox after {maxSyncPasses} sync passes; " +
- $"{notIssued.Count} did not (last pass: {finalNotIssued} pending).");
+ $"{notIssued.Count} did not.");
_output.WriteLine($"--- SUCCESS: {count}/{count} DV orders enrolled, synced, and issued in {passesUsed} sync pass(es). " +
$"Enroll={sw.Elapsed:mm\\:ss} SyncPhase={syncPhaseSw.Elapsed:mm\\:ss} Total={(sw.Elapsed + syncPhaseSw.Elapsed):mm\\:ss} ---");
diff --git a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
index 441f573..80e1f1a 100644
--- a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
+++ b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
@@ -1,155 +1,212 @@
-# CERTInext Integration Tests
+# CERTInext Integration Tests — Setup and Running
-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.
+This project contains xUnit integration tests that exercise the CERTInext plugin against the
+live CERTInext REST API (V1 and V2). Every test skips automatically when credentials are absent,
+so the project is safe to include in CI pipelines that have no API access. Tests that place
+orders, publish DNS records, or cancel orders are additionally gated behind opt-in environment
+flags. For the list of tests and what each checks, see [TESTING.md](TESTING.md).
---
## 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**
+- .NET 10 SDK (the test project targets `net8.0`)
+- Access to a CERTInext account (a sandbox account is recommended)
+- For V1 tests: an API Access Key from the CERTInext portal under **Integrations → APIs**
+- For V2 tests: an OAuth-mode credential (client ID and secret) from the same page
+- For DNS-01 DCV tests: a Cloudflare API token and zone ID for a domain you control
---
## Credential Setup
-Create the file `~/.env_certinext` with the following content:
+### V1: `~/.env_certinext`
```sh
-# CERTInext API credentials
-CERTINEXT_API_URL=https://api.certinext.io/emSignHub-API/
+# CERTInext V1 API credentials
+CERTINEXT_API_URL=https://sandbox-us-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_PRODUCT_CODE=842
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_API_URL` | Yes | V1 base URL, including the `/emSignHub-API` path segment |
+| `CERTINEXT_ACCESS_KEY` | Yes | REST API Access Key from the CERTInext portal |
| `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 |
-|-------------|-----|
+| `CERTINEXT_GROUP_NUMBER` | No | Group number for order placement and `GetProductDetails`; some accounts need it for the product list to be non-empty |
+| `CERTINEXT_ORG_NUMBER` | No | Pre-vetted organization number for OV/EV order placement |
+| `CERTINEXT_PRODUCT_CODE` | For order-placing tests | Numeric product code for your account. **Product codes are per account** — find yours with `make get-product-details-group` or `make probe-products` |
+| `CERTINEXT_REQUESTOR_EMAIL`, `CERTINEXT_REQUESTOR_NAME` | For order-placing tests | Requestor submitted with test orders; the email must be registered in the account |
+| `CERTINEXT_CF_API_TOKEN`, `CERTINEXT_CF_ZONE_ID` | For DNS-01 DCV tests | Cloudflare token with DNS edit permission on the zone, and the zone ID |
+| `CERTINEXT_DCV_DOMAIN` | For DNS-01 DCV tests | A domain inside that zone that test orders use |
+| `CERTINEXT_ORDER_ID` | For `SmokeTests` order lookups | An existing order number |
+
+| Environment | V1 `CERTINEXT_API_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
+### V2: `~/.env_certinext_v2`
-### Using dotnet CLI
+The V2 tests read this file themselves.
```sh
-dotnet test CERTInext.IntegrationTests/ --verbosity normal
+# CERTInext V2 API credentials
+CERTINEXT_API_URL=https://sandbox-us-api.certinext.io # V2 base URL: no /emSignHub-API suffix
+CERTINEXT_CLIENT_ID=your-oauth-client-id
+CERTINEXT_CLIENT_SECRET=your-oauth-client-secret
+CERTINEXT_USE_V2_API=1 # any non-empty value enables the V2 tests
+CERTINEXT_PRODUCT_CODE=842 # optional; V2 catalog code, default 842
```
-### Using the Makefile
+The V2 file reuses key names from the V1 file (`CERTINEXT_API_URL`, `CERTINEXT_PRODUCT_CODE`, the
+Cloudflare keys, `CERTINEXT_DCV_DOMAIN`) with V2 values, so source only `~/.env_certinext` into
+your shell and never `~/.env_certinext_v2`. The V2 test classes never write those shared keys
+into the process environment.
-```sh
-make integration-test
-```
+### File format
-### From the solution root (all tests including unit tests)
-
-```sh
-dotnet test certinext-caplugin.sln --verbosity normal
-```
+- Lines starting with `#` and blank lines are ignored.
+- Each line is `KEY=VALUE`. One pair of matching surrounding single or double quotes is stripped from the value.
+- Real environment variables override values from the V1 file, which makes CI injection easy.
+- The V1 fixture fails fast, with an actionable message, if the resolved `CERTINEXT_API_URL` lacks
+ `/emSignHub-API`, which indicates a V2 URL leaked into the V1 side.
+- When running from a shell that needs the opt-in flags below, load the V1 file with
+ `set -a; . ~/.env_certinext; set +a`.
---
-## Skip Behaviour
+## Running the Tests
-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.
+```sh
+# all integration tests (live tests skip when credentials are absent)
+dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release --verbosity normal
-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.
+# or via the Makefile
+make integration-test
----
+# a single class
+dotnet test CERTInext.IntegrationTests/ --filter "FullyQualifiedName~LifecycleTests" -v normal
+
+# from the solution root, including the unit tests
+dotnet test certinext-caplugin.sln --verbosity normal
+```
-## Test Classes
+The DCV test classes compile into the default build; no build flag is needed.
-### `ConnectivityTests`
+### V2: pinned and unpinned ("V2 template") runs
-| Test | What it checks |
-|------|---------------|
-| `Ping_ReturnsSuccess` | Calls `ValidateCredentials` endpoint; asserts no exception is thrown |
+Every V2 test that enrolls a product runs twice, mirroring the two gateway template sets that
+`scripts/register/` registers (see [scripts/register/README.md](../scripts/register/README.md)):
-### `ProductTests`
+| Row (`mode:`) | `ProductID` | `ProductCode` parameter | Plugin sends `X-Product-Code` |
+|---|---|---|---|
+| `Pinned` | product name, e.g. `DV SSL` | the product's code | yes |
+| `V2Template` (unpinned) | same product name | none | no, CERTInext resolves the product |
-| Test | What it checks |
-|------|---------------|
-| `GetProductDetails_ReturnsProducts` | Calls `GetProductDetails`; asserts the call succeeds; when products are returned, asserts product code `838` is present |
+Select a set with `--filter "DisplayName~Pinned"` or `--filter "DisplayName~V2Template"`. Pinned
+codes default to the sandbox catalog (DV SSL 842, DV Wildcard 843, DV UCC 844, DV Wildcard UCC 845,
+OV 846-849 in the same order, EV SSL 850, EV UCC 851); override with `CERTINEXT_PRODUCT_CODE` (DV SSL
+only) or `CERTINEXT_PRODUCT_CODE_MAP_JSON` (for example `{"OV SSL":"900"}`). On the sandbox the
+unpinned DV product currently resolves to a product that rejects the CSR at submit, so `V2Template`
+rows that issue are expected to fail until CERTInext fixes the account; that is a product finding, not
+a test bug. Tests handed an existing order through an env variable (`CERTINEXT_V2_ORDER_ID`,
+`CERTINEXT_V2_ISSUED_ORDER_ID`) do not enroll, so only their `Pinned` row runs.
-> 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`
+## Skip Behaviour
-| 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 |
+- **V1 tests** call `IntegrationSkip.IfNotConfigured(fixture)` first. When `~/.env_certinext` is
+ absent, or `CERTINEXT_API_URL` or `CERTINEXT_ACCESS_KEY` is empty, the test is reported as
+ **Skipped**, not failed.
+- **V2 tests** skip unless `CERTINEXT_USE_V2_API`, `CERTINEXT_API_URL`, `CERTINEXT_CLIENT_ID`, and
+ `CERTINEXT_CLIENT_SECRET` are all set (in `~/.env_certinext_v2` or the environment).
+- **DCV tests** additionally skip unless the Cloudflare token and zone ID are set.
+- Some tests skip when the account lacks the state they need, such as no existing orders or an
+ order that has not yet reached an issued state.
+
+### Opt-in flags
+
+Tests that place real orders, publish DNS records, or cancel orders only run when you export the
+matching flag in your shell. These flags are deliberately **not** read from `~/.env_certinext` or
+`~/.env_certinext_v2`, so a flag left in a file can't arm them on every run.
+
+| Flag | Arms |
+|------|------|
+| `CERTINEXT_ALGO_MATRIX=1` | `AlgorithmMatrixTests.Enroll_AcceptsKeyAlgorithm` — one sandbox order per key algorithm |
+| `CERTINEXT_ALGO_MATRIX_DCV=1` | `DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm` (also needs Cloudflare) |
+| `CERTINEXT_RUN_BULK_TEST=1` | `DcvLifecycleTests.BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks` (also needs Cloudflare); tune with `CERTINEXT_BULK_TEST_COUNT` and `CERTINEXT_BULK_TEST_PARALLEL` |
+| `CERTINEXT_COMPLETE_PENDING=1` | `DcvLifecycleTests.CompleteAllPendingDvOrders` (also needs Cloudflare) — repeated full syncs until no DV order is pending |
+| `CERTINEXT_V2_ALGO_MATRIX=1` | `V2DcvLifecycleTests.EnrollWithDcvOn_V2_IssuesPerKeyAlgorithm` (also needs Cloudflare) |
+| `CERTINEXT_V2_RUN_BULK_TEST=1` | `V2DcvLifecycleTests.BulkV2Enrollment_AllOrdersIssue_AndPaginationWorks` (also needs Cloudflare); tune with `CERTINEXT_V2_BULK_TEST_COUNT` and `CERTINEXT_V2_BULK_TEST_PARALLEL` |
+| `CERTINEXT_V2_LIFECYCLE_DV_UCC=1`, `_OV=1`, `_OV_UCC=1`, `_EV=1`, `_WILDCARD_DV=1`, `_RENEW_REISSUE=1` | The matching `V2FullLifecycleTests` method; the OV and OV UCC tests also need `CERTINEXT_ORG_NUMBER`, and the EV test needs its own pre-vetted `CERTINEXT_EV_ORG_NUMBER` |
+| `CERTINEXT_V2_LIFECYCLE_FRESH_DCV=1` | `V2FreshDomainDcvLifecycleTests` (also needs Cloudflare and `CERTINEXT_V2_FRESH_DCV_PARENT`, the parent domain that fresh subdomains are created under) |
+| `CERTINEXT_V2_INLINE_CSR=0` | Not a test gate: runs the V2 enrollment tests (`V2LifecycleTests`, `V2FullLifecycleTests`, `V2DcvLifecycleTests`, `V2FreshDomainDcvLifecycleTests`, `PrivatePkiV2LiveTests`) with the `V2InlineCsr` connector setting off (SSL/TLS two-call: create, then submit CSR). Unset runs the default single-call flow; run the suite once in each mode. Private PKI stays two-call either way |
+| `CERTINEXT_PRIVATE_PKI_LIVE=1` | `PrivatePkiV2LiveTests.PrivatePki_V2_EnrollIntranetSsl_ThenRevoke_Live`; override the product code and CN with `CERTINEXT_PRIVATE_PKI_PRODUCT_CODE` and `CERTINEXT_PRIVATE_PKI_CN` |
+| `CERTINEXT_V2_OPS_TESTS=1` | `V2OrderWindowSweepTests` — see below |
+
+Further variables select existing orders for specific tests: `CERTINEXT_PENDING_ORDER_ID`
+(`DcvLifecycleTests.GetSingleRecord_DrivesDcvForPendingOrder`), `CERTINEXT_V2_PENDING_ORDER_ID`,
+`CERTINEXT_V2_ORDER_ID`, `CERTINEXT_V2_ISSUED_ORDER_ID`, and `CERTINEXT_REVOKE_ORDER_ID` (V2
+lifecycle and revoke tests), and `CERTINEXT_V2_FULL_SYNC_TEST` (any value enables the V2 full-history
+sync test, which can be slow on a shared account).
+
+### Operations and diagnostic tests
+
+Two opt-in tests are maintenance tools rather than checks:
+
+- **`V2OrderWindowSweepTests`** lists every V2 order in a date window and can cancel specific
+ orders. It needs `CERTINEXT_V2_OPS_TESTS=1`, `CERTINEXT_V2_SWEEP_FROM` and `CERTINEXT_V2_SWEEP_TO`
+ (UTC ISO-8601 timestamps), and is read-only unless `CERTINEXT_V2_SWEEP_CANCEL_IDS` lists order
+ IDs, in which case it cancels exactly those orders if they are found in the window and not already
+ in a terminal state.
+- **`PendingDvDiagnosticsTests`** dumps the DCV state of pending V1 orders. It needs
+ `CERTINEXT_DIAG_ORDER_IDS="orderId|domain,orderId|domain"` and makes read-only calls.
+
+### Completing pending DV orders
+
+To drive every pending DV order on a sandbox account to issuance (needs a DNS provider, so the
+Cloudflare variables):
-### `PluginSmokeTests`
+```sh
+set -a; . ~/.env_certinext; set +a
+export CERTINEXT_COMPLETE_PENDING=1
+dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \
+ --filter "FullyQualifiedName~CompleteAllPendingDvOrders" \
+ --logger "console;verbosity=detailed" > /tmp/dvrun.log 2>&1
+```
-End-to-end tests exercising `CERTInextCAPlugin` via the `IAnyCAPlugin` interface with
-a live `CERTInextClient` injected through the `(ICERTInextClient, CERTInextConfig)`
-test constructor.
+xUnit buffers test output until the test ends, so read the per-pass report at the end of the log.
+Run only one full-history synchronization at a time against a shared account; full syncs are
+slow.
-| 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 |
+`CompleteAllPendingDvOrders` builds a **V1** plugin (`UseV2Api` defaults to `false`), so it syncs and
+runs DCV through V1, and it sweeps every pending DV order in the account. For one **V2** order, use
+`V2DcvLifecycleTests.GetSingleRecord_V2_DrivesDcvForPendingOrder` with
+`CERTINEXT_V2_PENDING_ORDER_ID=`. That test writes the plugin's log to the test output.
---
## Authentication
-The CERTInext API uses HMAC-SHA256 authentication computed for every request:
+V1 uses the AccessKey digest the plugin computes 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.
+`accessKey` is `CERTINEXT_ACCESS_KEY`, `ts` is the current ISO 8601 timestamp, and `txn` is a random
+numeric transaction ID. The client computes this itself, and the raw access key is never transmitted.
+V2 tests authenticate with OAuth2 `client_credentials` using `CERTINEXT_CLIENT_ID` and
+`CERTINEXT_CLIENT_SECRET`.
---
@@ -157,7 +214,39 @@ never transmitted over the wire — only the derived `authKey` hash is sent.
| 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 `make generate-order` in the project Makefile) |
+| All tests skipped | Missing or empty `~/.env_certinext` (V1) or `~/.env_certinext_v2` (V2) | Create the file with the required variables |
+| V1 fixture throws about `/emSignHub-API` | A V2 `CERTINEXT_API_URL` leaked into the shell | Run `unset CERTINEXT_API_URL`, or open a fresh shell, and source only `~/.env_certinext` |
+| `Ping` fails with 401/403 | Wrong `CERTINEXT_ACCESS_KEY` | Regenerate the key under Integrations → APIs |
+| `Ping` fails with a timeout or 404 | Wrong `CERTINEXT_API_URL` | Check the URL against your account's region (V1 needs the `/emSignHub-API` path) |
+| V2 token request fails with 401 | Wrong client ID or secret | Check `CERTINEXT_CLIENT_ID` and `CERTINEXT_CLIENT_SECRET` |
+| V2 token request fails with 403 | Key not generated in OAuth mode | Create a new OAuth-mode key in the portal |
+| `Enroll` fails with "Invalid Product Code" (EMS-1162) | `CERTINEXT_PRODUCT_CODE` isn't provisioned for the account | Run `make probe-products` and use a code the account accepts |
+| `GetProductDetails` returns an empty list | `CERTINEXT_GROUP_NUMBER` not set | Add your group number; some accounts need it |
+| `Enroll` fails with "Invalid Organization Number" (EMS-1073) | OV/EV order with an unregistered organization | Use a DV product for automated tests, or register and approve the organization |
+| Revoke step skips with "not GENERATED" | A sandbox DV order needs domain validation before it is issued | Expected without DCV; run the DCV tests with Cloudflare configured, or approve the order in the portal |
+| `OrderReportTests` skip | The account has no orders | Run `LifecycleTests` first to place one |
+
+## Triaging live V2 failures
+
+Sandbox behaviour changes without notice, and several CERTInext errors look like plugin bugs. Check
+these before changing code.
+
+**Get the plugin's own log first.** `TestOutputLogging.RouteTo(_output)` routes `LogHandler` to the test
+output; `V2LifecycleTests.Enroll_V2_ReturnsCARequestID` and
+`V2DcvLifecycleTests.GetSingleRecord_V2_DrivesDcvForPendingOrder` already do. Set
+`CERTINEXT_TEST_LOG_LEVEL=Trace` to add the redacted request and response payloads. Without this, a
+test that "passes" with the order still pending tells you nothing.
+
+| Symptom | Likely cause | How to confirm |
+|---|---|---|
+| Order `issued` but Download Certificate returns 422 `EMS-1165`, and stays that way | The CA approved the order but hasn't generated the certificate (`certificateState: "Approved by System"`). This normally clears in about 20 s; on the sandbox it stalled from 2026-10-13. | Track Order shows `certificateState`. `GET /reports/orders?status=issued` lists only orders whose certificate was actually generated ("Certificate Downloaded"); if the newest is old, generation has stopped CA-side. |
+| No `X-Product-Code`: two-call CSR submit returns 422 "PFX based certificate orders are not allowed" | CERTInext resolved the product to one that doesn't take a client CSR (on the sandbox, DV resolves to `917`, not `842`) | The create response's `resolvedProductCode` (logged as `ResolvedProductCode=`). Pin `ProductCode` on the template. |
+| No `X-Product-Code`: `GET .../dcv` returns 422 `EMS-1164 Domain Name is not matching`, and `certificateState` is "System Error" | Same product resolution as the row above. It isn't the domain or its letter case. | Pin 842 on the same domain shape; the order issues. |
+| Two-call, pinned: `PUT .../csr` returns 422 "Something went Wrong." | The domain is already validated, so the create came back `issued` with `csrSubmitted: false` and the order won't accept a CSR | Trace payloads show the create response. Use `V2InlineCsr=true`, the default since 2.0. |
+| `V2 OAuth2 token acquisition failed ... HttpStatus=0` (enroll, sync, or config save returns 500 "could not connect to CERTInext") | A transient transport failure to the token endpoint. The plugin currently logs no transport detail and doesn't retry the token request; a fix is planned. | Retry once. From a gateway pod, test with `curl --cacert /etc/pki/tls/certs/ca-bundle.crt` (the lab pod's `SSL_CERT_FILE` trusts only the lab CA, so plain curl fails TLS even when .NET works). |
+| Unpinned (`V2Template`) rows fail while pinned rows pass | Expected on the sandbox until CERTInext changes how it resolves DV | See the 917 rows above. |
+
+Before blaming the plugin, separate CA-side from plugin-side failures: run the same request **pinned**,
+on a **fresh** name under the validated parent zone, and with the CSR **inline**. Change one variable
+at a time. Record confirmed spec-vs-live discrepancies as dated Findings in the CERTInext
+support-questions list kept with the maintainers.
diff --git a/CERTInext.IntegrationTests/IntegrationTestFixture.cs b/CERTInext.IntegrationTests/IntegrationTestFixture.cs
index 8e4f637..b7df75d 100644
--- a/CERTInext.IntegrationTests/IntegrationTestFixture.cs
+++ b/CERTInext.IntegrationTests/IntegrationTestFixture.cs
@@ -1,6 +1,9 @@
// Copyright 2024 Keyfactor
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
-// At http://www.apache.org/licenses/LICENSE-2.0
+// 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;
@@ -20,6 +23,95 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
///
public sealed class IntegrationTestFixture : IDisposable
{
+ // ---------------------------------------------------------------------------
+ // Opt-in guard
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Env-var keys that must be set explicitly in the shell and must NOT be
+ /// auto-promoted from either env file. These gate destructive or mutating tests
+ /// so a developer cannot accidentally arm them by leaving flags in ~/.env_certinext
+ /// OR ~/.env_certinext_v2. Exposed internal (rather than private) so
+ /// can exclude the same names from its own
+ /// promotion of ~/.env_certinext_v2 — without that, a flag left in the V2 file would
+ /// be read as unset by the first test class constructed in a run, then promoted into
+ /// process env, silently arming every later test in the same run.
+ ///
+ internal static readonly System.Collections.Generic.HashSet _optInOnlyFlags =
+ new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "CERTINEXT_COMPLETE_PENDING",
+ "CERTINEXT_RUN_BULK_TEST",
+ "CERTINEXT_V2_RUN_BULK_TEST",
+ "CERTINEXT_PRIVATE_PKI_LIVE",
+ "CERTINEXT_V2_OPS_TESTS",
+ // V2 full-lifecycle readiness suite (DV UCC / OV / OV UCC / EV / wildcard DV /
+ // renew+reissue / fresh-domain DCV) — each places real sandbox orders and must
+ // be armed individually in the real shell, never left in either env file.
+ "CERTINEXT_V2_LIFECYCLE_DV_UCC",
+ "CERTINEXT_V2_LIFECYCLE_OV",
+ "CERTINEXT_V2_LIFECYCLE_OV_UCC",
+ "CERTINEXT_V2_LIFECYCLE_EV",
+ "CERTINEXT_V2_LIFECYCLE_WILDCARD_DV",
+ "CERTINEXT_V2_LIFECYCLE_RENEW_REISSUE",
+ "CERTINEXT_V2_LIFECYCLE_FRESH_DCV",
+ // Selects the V2InlineCsr connector setting for the V2 enrollment tests (see V2EnvHelper).
+ "CERTINEXT_V2_INLINE_CSR",
+ // Issue 0090 V2 place-order latency experiment (places real sandbox orders).
+ "CERTINEXT_V2_LATENCY_EXPERIMENT",
+ // Explicit-ID V2 sandbox order cleanup (revokes/cancels the listed orders).
+ "CERTINEXT_V2_CLEANUP_ORDERS",
+ // Issues 0062/0090 V2 inline-CSR vs two-call issuance experiment (places real sandbox orders).
+ "CERTINEXT_V2_INLINE_CSR_EXPERIMENT",
+ // V2 certificate-lifetime probe (V2ValidityProbeTests) — places real sandbox orders.
+ "CERTINEXT_PROBE_VALIDITY",
+ };
+
+ // ---------------------------------------------------------------------------
+ // V1 env keys
+ // ---------------------------------------------------------------------------
+
+ internal const string ApiUrlKey = "CERTINEXT_API_URL";
+ internal const string AccessKeyKey = "CERTINEXT_ACCESS_KEY";
+ internal const string AccountNumberKey = "CERTINEXT_ACCOUNT_NUMBER";
+ internal const string GroupNumberKey = "CERTINEXT_GROUP_NUMBER";
+ internal const string OrgNumberKey = "CERTINEXT_ORG_NUMBER";
+ internal const string ProductCodeKey = "CERTINEXT_PRODUCT_CODE";
+ internal const string RequestorEmailKey = "CERTINEXT_REQUESTOR_EMAIL";
+ internal const string RequestorNameKey = "CERTINEXT_REQUESTOR_NAME";
+ internal const string CloudflareApiTokenKey = "CERTINEXT_CF_API_TOKEN";
+ internal const string CloudflareZoneIdKey = "CERTINEXT_CF_ZONE_ID";
+
+ ///
+ /// Path segment every V1 (emSignHub-API) base URL carries. A resolved
+ /// without it is almost always the V2 base URL from
+ /// ~/.env_certinext_v2.
+ ///
+ internal const string V1ApiPathSegment = "/emSignHub-API";
+
+ ///
+ /// Every env key the V1 side of the harness reads: the keys this fixture resolves, plus
+ /// CERTINEXT_DCV_DOMAIN, which V1 DcvLifecycleTests reads straight from
+ /// process env. must never write these into
+ /// process env, because real env vars take precedence over ~/.env_certinext here
+ /// and the V2 file defines the same names with V2 values.
+ ///
+ internal static readonly IReadOnlySet V1EnvKeys =
+ new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ ApiUrlKey,
+ AccessKeyKey,
+ AccountNumberKey,
+ GroupNumberKey,
+ OrgNumberKey,
+ ProductCodeKey,
+ RequestorEmailKey,
+ RequestorNameKey,
+ CloudflareApiTokenKey,
+ CloudflareZoneIdKey,
+ "CERTINEXT_DCV_DOMAIN",
+ };
+
// ---------------------------------------------------------------------------
// Credential properties
// ---------------------------------------------------------------------------
@@ -83,29 +175,41 @@ public IntegrationTestFixture()
var env = LoadEnvFile(envPath);
- // Promote env-file values into the process environment so that any code
- // calling System.Environment.GetEnvironmentVariable() picks them up.
- foreach (var kv in env)
- if (System.Environment.GetEnvironmentVariable(kv.Key) == null)
- System.Environment.SetEnvironmentVariable(kv.Key, kv.Value);
+ ApiUrl = GetEnvValue(env, ApiUrlKey);
+ AccessKey = GetEnvValue(env, AccessKeyKey);
+ AccountNumber = GetEnvValue(env, AccountNumberKey);
+ GroupNumber = GetEnvValue(env, GroupNumberKey);
+ OrgNumber = GetEnvValue(env, OrgNumberKey);
+ ProductCode = GetEnvValue(env, ProductCodeKey);
+ RequestorEmail = GetEnvValue(env, RequestorEmailKey);
+ RequestorName = GetEnvValue(env, RequestorNameKey);
- ApiUrl = GetEnvValue(env, "CERTINEXT_API_URL");
- AccessKey = GetEnvValue(env, "CERTINEXT_ACCESS_KEY");
- AccountNumber = GetEnvValue(env, "CERTINEXT_ACCOUNT_NUMBER");
- GroupNumber = GetEnvValue(env, "CERTINEXT_GROUP_NUMBER");
- OrgNumber = GetEnvValue(env, "CERTINEXT_ORG_NUMBER");
- ProductCode = GetEnvValue(env, "CERTINEXT_PRODUCT_CODE");
- RequestorEmail = GetEnvValue(env, "CERTINEXT_REQUESTOR_EMAIL");
- RequestorName = GetEnvValue(env, "CERTINEXT_REQUESTOR_NAME");
-
- CloudflareApiToken = GetEnvValue(env, "CERTINEXT_CF_API_TOKEN");
- CloudflareZoneId = GetEnvValue(env, "CERTINEXT_CF_ZONE_ID");
+ CloudflareApiToken = GetEnvValue(env, CloudflareApiTokenKey);
+ CloudflareZoneId = GetEnvValue(env, CloudflareZoneIdKey);
IsCloudflareConfigured = !string.IsNullOrWhiteSpace(CloudflareApiToken) &&
!string.IsNullOrWhiteSpace(CloudflareZoneId);
IsConfigured = !string.IsNullOrWhiteSpace(ApiUrl) &&
!string.IsNullOrWhiteSpace(AccessKey);
+ // Fail fast (before promoting anything into process env and before any
+ // client/network call) when a V2 base URL has leaked into the V1 fixture. Only
+ // checked when the fixture would otherwise be configured, so an unconfigured run
+ // still skips cleanly.
+ if (IsConfigured)
+ EnsureV1ApiUrl(ApiUrl,
+ fromProcessEnvironment: System.Environment.GetEnvironmentVariable(ApiUrlKey) != null);
+
+ // Promote env-file values into the process environment so that any code
+ // calling System.Environment.GetEnvironmentVariable() picks them up.
+ // Opt-in destructive-test flags are deliberately excluded: they must be
+ // set explicitly in the shell so a developer who leaves them in the file
+ // does not accidentally arm bulk/mutating tests on every bare `dotnet test`.
+ foreach (var kv in env)
+ if (System.Environment.GetEnvironmentVariable(kv.Key) == null
+ && !_optInOnlyFlags.Contains(kv.Key))
+ System.Environment.SetEnvironmentVariable(kv.Key, kv.Value);
+
if (IsConfigured)
{
Config = new CERTInextConfig
@@ -141,8 +245,11 @@ public void Dispose() { }
///
/// Reads a KEY=VALUE file, stripping blank lines and lines starting with '#'.
/// Real environment variables overlay the file so CI overrides always win.
+ /// defaults to the real process environment; unit
+ /// tests pass their own so they never have to mutate shared process state.
///
- private static Dictionary LoadEnvFile(string path)
+ internal static Dictionary LoadEnvFile(
+ string path, System.Collections.IDictionary processEnvironment = null)
{
var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
@@ -165,7 +272,8 @@ private static Dictionary LoadEnvFile(string path)
}
// Real environment variables take precedence over the file
- foreach (System.Collections.DictionaryEntry de in System.Environment.GetEnvironmentVariables())
+ foreach (System.Collections.DictionaryEntry de in
+ processEnvironment ?? System.Environment.GetEnvironmentVariables())
{
string k = de.Key?.ToString();
string v = de.Value?.ToString();
@@ -198,6 +306,36 @@ internal static string ParseEnvValue(string rawValue)
return val;
}
+ ///
+ /// Throws when lacks
+ /// the V1 , i.e. a V2 base URL has leaked into the V1
+ /// fixture. Left unchecked, every V1 call 404s and surfaces as a misleading
+ /// "unrecognised error body". The message names the key and where it came from, and
+ /// shows only scheme/host/path — never credentials, userinfo, or query strings.
+ /// Exposed internal for direct unit-testing.
+ ///
+ internal static void EnsureV1ApiUrl(string apiUrl, bool fromProcessEnvironment)
+ {
+ if (string.IsNullOrWhiteSpace(apiUrl) ||
+ apiUrl.IndexOf(V1ApiPathSegment, StringComparison.OrdinalIgnoreCase) >= 0)
+ return;
+
+ string shown = Uri.TryCreate(apiUrl.Trim(), UriKind.Absolute, out Uri uri)
+ ? $"{uri.Scheme}://{uri.Authority}{uri.AbsolutePath}"
+ : "(not an absolute URL)";
+ string source = fromProcessEnvironment
+ ? "the process environment (real env vars override ~/.env_certinext)"
+ : "~/.env_certinext";
+
+ throw new InvalidOperationException(
+ $"IntegrationTestFixture: {ApiUrlKey} resolved to '{shown}' (from {source}), which lacks " +
+ $"the V1 path segment '{V1ApiPathSegment}'. This looks like a CERTInext V2 base URL leaking " +
+ "into the V1 fixture; V1 calls against it fail with 'unrecognised error body'. " +
+ "Source only ~/.env_certinext into the shell (set -a; . ~/.env_certinext; set +a), never " +
+ "~/.env_certinext_v2 — the V2 tests read that file from disk themselves. In an already-" +
+ $"polluted shell, run 'unset {ApiUrlKey}' or open a fresh shell.");
+ }
+
private static string GetEnvValue(Dictionary env, string key)
{
return env.TryGetValue(key, out string val) ? val : string.Empty;
diff --git a/CERTInext.IntegrationTests/KfclabCsrEmitterTests.cs b/CERTInext.IntegrationTests/KfclabCsrEmitterTests.cs
new file mode 100644
index 0000000..159cb8f
--- /dev/null
+++ b/CERTInext.IntegrationTests/KfclabCsrEmitterTests.cs
@@ -0,0 +1,82 @@
+// 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.
+// At http://www.apache.org/licenses/LICENSE-2.0
+//
+// Utility: emit BouncyCastle-generated PKCS#10 CSRs (CN + DNS SANs) to disk for manual
+// Command-driven lab enrollments (e.g. Command Reissue via /Enrollment/CSR, UCC multi-SAN
+// checks). Makes no CA calls. Opt-in: set CERTINEXT_EMIT_CSR_DIR (output directory) and
+// CERTINEXT_EMIT_CSR_SPEC, a ';'-separated list of "=[,...]".
+// The CN is always included as the first DNS SAN.
+//
+// Example:
+// CERTINEXT_EMIT_CSR_DIR=/tmp/csrs \
+// CERTINEXT_EMIT_CSR_SPEC="reissue=a.example.com;ucc=b.example.com,c.example.com" \
+// dotnet test --filter FullyQualifiedName~KfclabCsrEmitterTests
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Org.BouncyCastle.Asn1;
+using Org.BouncyCastle.Asn1.Pkcs;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ public class KfclabCsrEmitterTests
+ {
+ private readonly ITestOutputHelper _out;
+
+ public KfclabCsrEmitterTests(ITestOutputHelper output)
+ {
+ _out = output;
+ }
+
+ private static string GenerateCsrPem(string cn, IReadOnlyList dnsSans)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var kp = keyGen.GenerateKeyPair();
+
+ var names = new GeneralNames(dnsSans.Select(s => new GeneralName(GeneralName.DnsName, s)).ToArray());
+ var extGen = new X509ExtensionsGenerator();
+ extGen.AddExtension(X509Extensions.SubjectAlternativeName, false, names);
+ var attrs = new DerSet(new AttributePkcs(
+ PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, new DerSet(extGen.Generate())));
+
+ var csr = new Pkcs10CertificationRequest(
+ "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attrs, kp.Private);
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----\n";
+ }
+
+ [SkippableFact]
+ public void EmitCsrs()
+ {
+ string dir = Environment.GetEnvironmentVariable("CERTINEXT_EMIT_CSR_DIR");
+ string spec = Environment.GetEnvironmentVariable("CERTINEXT_EMIT_CSR_SPEC");
+ Skip.If(string.IsNullOrWhiteSpace(dir) || string.IsNullOrWhiteSpace(spec),
+ "Set CERTINEXT_EMIT_CSR_DIR and CERTINEXT_EMIT_CSR_SPEC to emit CSRs.");
+
+ Directory.CreateDirectory(dir);
+ foreach (string entry in spec.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ string[] kv = entry.Split('=', 2);
+ Assert.True(kv.Length == 2, $"Bad CSR spec entry '{entry}' (expected =[,...]).");
+ string[] hosts = kv[1].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ Assert.NotEmpty(hosts);
+
+ string path = Path.Combine(dir, kv[0] + ".csr");
+ File.WriteAllText(path, GenerateCsrPem(hosts[0], hosts));
+ _out.WriteLine($"{path}: CN={hosts[0]} SANs={string.Join(",", hosts)}");
+ }
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/OrderDateFormatProbeTests.cs b/CERTInext.IntegrationTests/OrderDateFormatProbeTests.cs
new file mode 100644
index 0000000..713d61e
--- /dev/null
+++ b/CERTInext.IntegrationTests/OrderDateFormatProbeTests.cs
@@ -0,0 +1,60 @@
+// 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.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Read-only guard (issue 0090, V1 sync hold-back): GetOrderReport's per-row orderDate
+ /// must stay "yyyy-MM-dd HH:mm:ss" (whole seconds, no zone marker; read as UTC, verified
+ /// 2026-10-15 against plugin and gateway UTC log timestamps). The sync hold-back measures an
+ /// order's age from it. No orders are created.
+ ///
+ public class OrderDateFormatProbeTests : IClassFixture
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ public OrderDateFormatProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ [SkippableFact]
+ public async Task GetOrderReport_OrderDate_IsSecondPrecisionWithoutZoneMarker()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ var shapes = new Dictionary();
+ int total = 0;
+ await foreach (var entry in _fixture.Client.ListOrdersAsync(
+ orderDateFrom: null, pageSize: 100, ct: CancellationToken.None))
+ {
+ total++;
+ // Digits masked so the output records the wire shape, not the data.
+ string shape = Regex.Replace(entry.OrderDate ?? "(null)", @"\d", "9");
+ shapes[shape] = shapes.TryGetValue(shape, out var n) ? n + 1 : 1;
+ if (total >= 300) break;
+ }
+
+ _output.WriteLine($"rows={total}");
+ foreach (var kv in shapes.OrderByDescending(k => k.Value))
+ _output.WriteLine($"orderDate shape '{kv.Key}' x{kv.Value}");
+
+ Skip.If(total == 0, "account has no orders");
+ Assert.All(shapes.Keys, k => Assert.Equal("9999-99-99 99:99:99", k));
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/PendingDvDiagnosticsTests.cs b/CERTInext.IntegrationTests/PendingDvDiagnosticsTests.cs
new file mode 100644
index 0000000..49fa064
--- /dev/null
+++ b/CERTInext.IntegrationTests/PendingDvDiagnosticsTests.cs
@@ -0,0 +1,123 @@
+// 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.
+// At http://www.apache.org/licenses/LICENSE-2.0
+//
+// Read-only diagnostic for pending-DV orders that won't advance through sync-DCV.
+// For each "orderId|domain" pair in CERTINEXT_DIAG_ORDER_IDS (comma-separated), it
+// dumps the TrackOrder DCV state and probes GetDcv to determine whether CERTInext
+// has actually exposed a DCV challenge for the order — the question that decides
+// whether the plugin's deferred-DCV retry can ever complete it.
+//
+// Run:
+// export CERTINEXT_DIAG_ORDER_IDS="9937569678|bulk-0b3cbd54.scrup.org,6373633518|bulk-49818a84.scrup.org"
+// dotnet test --filter FullyQualifiedName~PendingDvDiagnostics
+
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ public class PendingDvDiagnosticsTests : IClassFixture
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _out;
+
+ public PendingDvDiagnosticsTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _out = output;
+ }
+
+ [SkippableFact]
+ public async Task PendingDvDiagnostics_DumpDcvState()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ string raw = Environment.GetEnvironmentVariable("CERTINEXT_DIAG_ORDER_IDS");
+ Skip.If(string.IsNullOrWhiteSpace(raw),
+ "Set CERTINEXT_DIAG_ORDER_IDS=\"orderId|domain,orderId|domain,...\" to run the diagnostic.");
+
+ var pairs = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(p =>
+ {
+ var bits = p.Split('|', 2);
+ return (Id: bits[0].Trim(), Domain: bits.Length > 1 ? bits[1].Trim() : null);
+ })
+ .ToList();
+
+ var ct = CancellationToken.None;
+ int challengeReady = 0, challengeNotReady = 0, alreadyValidated = 0, errored = 0;
+
+ foreach (var (id, domain) in pairs)
+ {
+ _out.WriteLine($"==================== Order {id} ({domain ?? "?"}) ====================");
+ ICERTInextClient client = _fixture.Client;
+
+ try
+ {
+ var track = await client.TrackOrderAsync(id, ct);
+ var od = track.OrderDetails;
+ _out.WriteLine($" OrderStatus: {od?.OrderStatus} (id={od?.OrderStatusId})");
+ _out.WriteLine($" CertStatus: {od?.CertificateStatus} (id={od?.CertificateStatusId})");
+
+ var dv = od?.DomainVerification;
+ if (dv == null)
+ {
+ _out.WriteLine(" DomainVerification: (CERTInext has NOT exposed a DCV challenge slot)");
+ }
+ else
+ {
+ _out.WriteLine($" DomainVerification.status: '{dv.Status}' (0=Pending,1=Validated,2=Rejected)");
+ var entries = dv.GetDomainEntries();
+ if (entries.Count == 0)
+ _out.WriteLine(" per-domain entries: ");
+ foreach (var kv in entries)
+ _out.WriteLine(
+ $" [{kv.Key}] dcvMethod='{kv.Value.DcvMethod}' dcvStatus='{kv.Value.DcvStatus}' " +
+ $"status='{kv.Value.Status}' caaStatus='{kv.Value.CaaStatus}' verifiedDate='{kv.Value.VerifiedDate}'");
+
+ if (dv.Status == Constants.Dcv.StatusValidated ||
+ entries.Values.All(e => e.DcvStatus == Constants.Dcv.StatusValidated))
+ alreadyValidated++;
+ }
+
+ // Probe GetDcv — the decisive test: does CERTInext hand back a challenge token?
+ if (!string.IsNullOrWhiteSpace(domain))
+ {
+ try
+ {
+ var dcv = await client.GetDcvAsync(id, domain, Constants.Dcv.MethodDnsTxt, ct);
+ bool tokenPresent = !string.IsNullOrWhiteSpace(dcv.DcvDetails?.Token);
+ _out.WriteLine($" GetDcv: tokenPresent={tokenPresent}");
+ if (tokenPresent) challengeReady++;
+ }
+ catch (Exception gex)
+ {
+ _out.WriteLine($" GetDcv: FAILED -> {gex.Message}");
+ if (gex.Message.Contains("956", StringComparison.OrdinalIgnoreCase) ||
+ gex.Message.Contains("not ready", StringComparison.OrdinalIgnoreCase))
+ challengeNotReady++;
+ else
+ errored++;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _out.WriteLine($" TrackOrder FAILED: {ex.Message}");
+ errored++;
+ }
+ }
+
+ _out.WriteLine("");
+ _out.WriteLine($"=== SUMMARY over {pairs.Count} orders: " +
+ $"challengeReady={challengeReady}, challengeNotReady={challengeNotReady}, " +
+ $"alreadyValidated={alreadyValidated}, errored={errored} ===");
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/PrivatePkiV2LiveTests.cs b/CERTInext.IntegrationTests/PrivatePkiV2LiveTests.cs
new file mode 100644
index 0000000..ad8aa96
--- /dev/null
+++ b/CERTInext.IntegrationTests/PrivatePkiV2LiveTests.cs
@@ -0,0 +1,474 @@
+// 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.Linq;
+using System.Net;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Org.BouncyCastle.Asn1;
+using Org.BouncyCastle.Asn1.Pkcs;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using Org.BouncyCastle.X509;
+using Org.BouncyCastle.X509.Extension;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Opt-in live verification of the V2 private-pki enrollment path end to end through
+ /// the real plugin surface: plugin.Enroll with
+ /// ProductFamily=private-pki / ProductVariant=intranet-ssl against the CERTInext
+ /// sandbox, then plugin.Revoke (CRL reason 4, superseded) in cleanup.
+ ///
+ /// PLACES EXACTLY ONE REAL ORDER. Gated behind CERTINEXT_PRIVATE_PKI_LIVE=1, which must be
+ /// exported in the shell (it is read from the process environment before the V2 env file is
+ /// promoted). Skips with no network calls when the flag or the V2 OAuth2 credentials are absent.
+ /// No retries anywhere: a thrown or FAILED enroll is reported, never re-attempted.
+ ///
+ /// Env:
+ /// CERTINEXT_PRIVATE_PKI_LIVE=1 required opt-in
+ /// CERTINEXT_PRIVATE_PKI_PRODUCT_CODE default 149 (Sandbox emSign Intranet SSL 1 Year)
+ /// CERTINEXT_PRIVATE_PKI_CN default pki0033-<UTC MMddHHmm>.intranet.lab
+ /// V2 creds (CERTINEXT_API_URL / CERTINEXT_CLIENT_ID / CERTINEXT_CLIENT_SECRET) are loaded
+ /// from ~/.env_certinext_v2 by , same as .
+ ///
+ [Collection(PrivatePkiV2LiveCollection.Name)]
+ public class PrivatePkiV2LiveTests : IClassFixture
+ {
+ private const string OptInFlag = "CERTINEXT_PRIVATE_PKI_LIVE";
+ private const string IpSan = "10.0.0.50";
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ private readonly bool _optedIn;
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly string _productCode;
+ private readonly string _cnOverride;
+ private readonly bool _v2CredsPresent;
+
+ public PrivatePkiV2LiveTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+
+ // Read the opt-in flag from the real process environment BEFORE promoting the V2 env
+ // file, so leaving the flag in ~/.env_certinext_v2 cannot arm this order-placing test.
+ _optedIn = Environment.GetEnvironmentVariable(OptInFlag)?.Trim() == "1";
+
+ var env = V2EnvHelper.LoadAndPromote();
+ _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL");
+ _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID");
+ _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET");
+ _productCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRIVATE_PKI_PRODUCT_CODE", "149");
+ _cnOverride = V2EnvHelper.GetEnv(env, "CERTINEXT_PRIVATE_PKI_CN");
+
+ _v2CredsPresent = !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+ }
+
+ ///
+ /// V2 config for the private-pki order: mirrors V2LifecycleTests.BuildV2Config (V2
+ /// mode, no V1-only fields, requestor placeholders) with DCV disabled. Private PKI has no DCV
+ /// anyway; disabling it keeps the no-DCV and DCV builds on the same path.
+ ///
+ private CERTInextConfig BuildV2Config() => new CERTInextConfig
+ {
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ V2InlineCsr = V2EnvHelper.InlineCsrFromEnv, // default single-call; CERTINEXT_V2_INLINE_CSR=0 selects two-call
+ OAuthClientId = _v2ClientId,
+ OAuthClientSecret = _v2ClientSecret,
+
+ RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com",
+ RequestorIsdCode = "1",
+ RequestorMobileNumber = "0000000000",
+ SignerPlace = "Gateway Lab",
+ SignerIp = "127.0.0.1",
+ PageSize = 100,
+
+ DcvEnabled = false
+ };
+
+ ///
+ /// BouncyCastle-only RSA-2048 PKCS#10 CSR with a SAN extension request. Same construction as
+ /// KfclabCsrEmitterTests.GenerateCsrPem (which is private and DNS-only), extended to
+ /// carry IP SANs.
+ ///
+ private static string GenerateCsrPem(string cn, IReadOnlyList dnsSans, IReadOnlyList ipSans)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var kp = keyGen.GenerateKeyPair();
+
+ var generalNames = dnsSans.Select(d => new GeneralName(GeneralName.DnsName, d))
+ .Concat(ipSans.Select(ip => new GeneralName(GeneralName.IPAddress, ip)))
+ .ToArray();
+ var extGen = new X509ExtensionsGenerator();
+ extGen.AddExtension(X509Extensions.SubjectAlternativeName, false, new GeneralNames(generalNames));
+ var attrs = new DerSet(new AttributePkcs(
+ PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, new DerSet(extGen.Generate())));
+
+ var csr = new Pkcs10CertificationRequest(
+ "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attrs, kp.Private);
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----\n";
+ }
+
+ /// Parses the first (leaf) PEM block of a possibly chained PEM string.
+ private static X509Certificate ParseLeaf(string pem)
+ {
+ var m = Regex.Match(pem ?? string.Empty,
+ @"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", RegexOptions.Singleline);
+ if (!m.Success) return null;
+ string b64 = m.Groups[1].Value.Replace("\r", string.Empty).Replace("\n", string.Empty).Trim();
+ return new X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64));
+ }
+
+ /// SAN entries as ("dns"|"ip"|"other:<tag>", value), read with BouncyCastle.
+ private static List<(string Type, string Value)> ReadSans(X509Certificate cert)
+ {
+ var result = new List<(string, string)>();
+ var ext = cert.GetExtensionValue(X509Extensions.SubjectAlternativeName);
+ if (ext == null) return result;
+
+ var names = GeneralNames.GetInstance(X509ExtensionUtilities.FromExtensionValue(ext));
+ foreach (var gn in names.GetNames())
+ {
+ switch (gn.TagNo)
+ {
+ case GeneralName.DnsName:
+ result.Add(("dns", DerIA5String.GetInstance(gn.Name).GetString()));
+ break;
+ case GeneralName.IPAddress:
+ // IPAddress parses raw octets only (no crypto) — not a BCL-crypto dependency.
+ result.Add(("ip", new IPAddress(Asn1OctetString.GetInstance(gn.Name).GetOctets()).ToString()));
+ break;
+ default:
+ result.Add(($"other:{gn.TagNo}", gn.Name.ToString()));
+ break;
+ }
+ }
+ return result;
+ }
+
+ [SkippableFact]
+ public async Task PrivatePki_V2_EnrollIntranetSsl_ThenRevoke_Live()
+ {
+ Skip.If(!_optedIn,
+ $"{OptInFlag} is not set to 1 — this test places ONE real private-pki order; skipping (no network calls).");
+ Skip.If(!_v2CredsPresent,
+ "V2 OAuth2 credentials (CERTINEXT_API_URL / CERTINEXT_CLIENT_ID / CERTINEXT_CLIENT_SECRET) not configured — skipping (no network calls).");
+
+ string cn = string.IsNullOrWhiteSpace(_cnOverride)
+ ? $"pki0033-{DateTime.UtcNow:MMddHHmm}.intranet.lab"
+ : _cnOverride.Trim();
+
+ var config = BuildV2Config();
+ var realClient = new CERTInextClient(config);
+ // Pass-through proxy around the real client: records the order id the moment
+ // PlaceOrderV2Async returns, so cleanup still knows the order if a later step inside
+ // Enroll (track, download) throws before an EnrollmentResult exists. The CSR goes inline on the create call.
+ var recorder = OrderIdRecordingClientProxy.Wrap(realClient, out ICERTInextClient proxiedClient);
+ var plugin = new CERTInextCAPlugin(proxiedClient, config);
+
+ var productInfo = new EnrollmentProductInfo
+ {
+ ProductID = _productCode,
+ ProductParameters = new Dictionary
+ {
+ [Constants.EnrollmentParam.ProductFamily] = "private-pki",
+ [Constants.EnrollmentParam.ProductVariant] = Constants.ApiV2.PrivatePkiVariantIntranetSsl,
+ [Constants.EnrollmentParam.ProductCode] = _productCode,
+ }
+ };
+ // Gateway SAN dictionary exactly as Command sends it ("dnsname" / "ipaddress" keys).
+ var san = new Dictionary
+ {
+ ["dnsname"] = new[] { cn },
+ ["ipaddress"] = new[] { IpSan },
+ };
+
+ _output.WriteLine("=== private-pki live enrollment (ONE order, no retries) ===");
+ _output.WriteLine($"Family=private-pki, Variant={Constants.ApiV2.PrivatePkiVariantIntranetSsl}, ProductCode={_productCode}");
+ _output.WriteLine($"CN={cn}, SANs: dnsname=[{cn}], ipaddress=[{IpSan}]");
+
+ string orderId = null;
+ // Set only once Enroll returned GENERATED with a certificate body; cleanup revokes an
+ // issued order and cancels anything else.
+ bool issued = false;
+ try
+ {
+ EnrollmentResult result = null;
+ Exception enrollEx = null;
+ try
+ {
+ result = await plugin.Enroll(
+ csr: GenerateCsrPem(cn, new[] { cn }, new[] { IpSan }),
+ subject: $"CN={cn}",
+ san: san,
+ productInfo: productInfo,
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ }
+ catch (Exception ex)
+ {
+ enrollEx = ex;
+ }
+
+ orderId = !string.IsNullOrWhiteSpace(result?.CARequestID) ? result.CARequestID : recorder.PlacedOrderId;
+
+ // Order id first — before anything below that can fail.
+ _output.WriteLine($"CARequestID (order id): {orderId ?? ""}");
+ _output.WriteLine($" (recorded from PlaceOrderV2Async: {recorder.PlacedOrderId ?? ""}, " +
+ $"initial CA status: {recorder.PlacedOrderStatus ?? ""})");
+
+ if (enrollEx != null)
+ {
+ _output.WriteLine($"Enroll THREW {enrollEx.GetType().Name}: {enrollEx.Message}");
+ _output.WriteLine("Not retrying. NOTE: a client-side timeout does not prove no order exists — if the " +
+ "order id above is , check the CERTInext portal for a private-pki order " +
+ $"with hostname '{cn}'.");
+ ExceptionDispatchInfo.Capture(enrollEx).Throw();
+ }
+
+ if (result == null)
+ {
+ _output.WriteLine("Enroll returned a null EnrollmentResult. Not retrying.");
+ Assert.Fail("private-pki Enroll returned a null EnrollmentResult.");
+ return; // unreachable
+ }
+
+ _output.WriteLine($"Enroll status: {result.Status} ({(EndEntityStatus)result.Status})");
+ _output.WriteLine($"Enroll message: {result.StatusMessage}");
+
+ if (result.Status == (int)EndEntityStatus.FAILED)
+ {
+ _output.WriteLine("Enroll returned FAILED. Not retrying; no further order will be placed.");
+ Assert.Fail($"private-pki Enroll returned FAILED: {result.StatusMessage}");
+ }
+
+ if (result.Status != (int)EndEntityStatus.GENERATED || string.IsNullOrWhiteSpace(result.Certificate))
+ {
+ _output.WriteLine($"Order {orderId} is still pending (not issued within the plugin's pickup poll). " +
+ "Not polling further; cleanup below will cancel it once and otherwise print " +
+ "manual-cleanup instructions.");
+ Assert.Fail($"INCONCLUSIVE: private-pki order '{orderId}' did not issue within the pickup poll " +
+ $"(status {result.Status}); end-to-end issuance not verified.");
+ }
+
+ issued = true;
+ var leaf = ParseLeaf(result.Certificate);
+ leaf.Should().NotBeNull("the GENERATED result must carry a parseable leaf certificate PEM");
+
+ var sans = ReadSans(leaf);
+ _output.WriteLine($"Issued subject: {leaf.SubjectDN}");
+ _output.WriteLine($"Issued issuer: {leaf.IssuerDN}");
+ _output.WriteLine($"Issued serial: {leaf.SerialNumber.ToString(16).ToUpperInvariant()}");
+ _output.WriteLine($"Issued SANs: [{string.Join(", ", sans.Select(s => $"{s.Type}:{s.Value}"))}]");
+ _output.WriteLine($"Validity: {leaf.NotBefore:o} .. {leaf.NotAfter:o}");
+
+ sans.Should().Contain(s => s.Type == "ip" && s.Value == IpSan,
+ "the IP SAN submitted via additionalHosts must appear on the issued certificate");
+ sans.Should().Contain(s => s.Type == "dns" && string.Equals(s.Value, cn, StringComparison.OrdinalIgnoreCase),
+ "the CN (sent as hostname) must appear as a DNS SAN on the issued certificate");
+ }
+ finally
+ {
+ await CleanupAsync(plugin, realClient, orderId, cn, issued);
+ realClient.Dispose();
+ }
+ }
+
+ ///
+ /// Best-effort cleanup: exactly one cleanup action, never retried, never throwing (a cleanup
+ /// failure must not mask the test's own result). An issued order is revoked via
+ /// plugin.Revoke (CRL reason 4, superseded); any other order is cancelled via
+ /// on the private-pki family
+ /// (plugin.Revoke refuses non-issued orders). Manual-cleanup instructions are printed
+ /// only when that action fails. Finishes with one
+ /// read-only GET on the private-pki order.
+ ///
+ private async Task CleanupAsync(CERTInextCAPlugin plugin, CERTInextClient client, string orderId, string cn, bool issued)
+ {
+ _output.WriteLine("--- Cleanup ---");
+ if (string.IsNullOrWhiteSpace(orderId))
+ {
+ _output.WriteLine("No order id captured — nothing to revoke or cancel. If Enroll threw after sending the " +
+ $"create request, check the CERTInext portal for a private-pki order with hostname '{cn}'.");
+ return;
+ }
+
+ bool cleanedUp = false;
+ if (issued)
+ {
+ try
+ {
+ int revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 4 /* superseded */);
+ _output.WriteLine($"Revoke(order={orderId}, reason=4 superseded) returned {revokeResult} ({(EndEntityStatus)revokeResult}).");
+ cleanedUp = true;
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"Revoke FAILED for order {orderId}: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+ else
+ {
+ try
+ {
+ var outcome = await client.CancelOrderV2Async(
+ Constants.ApiV2.FamilyPrivatePki, orderId, "Keyfactor plugin live test cleanup.");
+ _output.WriteLine($"CancelOrderV2Async(private-pki, order={orderId}) returned {outcome}" +
+ (outcome == V2CancelOrderOutcome.AlreadyTerminal
+ ? " (HTTP 422: already in a terminal state; nothing cancelled)."
+ : " (HTTP 2xx: order cancelled)."));
+ cleanedUp = outcome == V2CancelOrderOutcome.Cancelled;
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"Cancel FAILED for order {orderId}: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ if (!cleanedUp)
+ {
+ _output.WriteLine("Not retrying. Unless the track below shows the order cancelled or revoked, MANUAL " +
+ "CLEANUP REQUIRED: in the CERTInext portal, cancel (if pending) or revoke (if issued) " +
+ $"order id {orderId}, product family private-pki " +
+ $"({Constants.ApiV2.PrivatePkiCertificatesPath}/{orderId}).");
+ }
+
+ try
+ {
+ var (status, _, body) = await client.ProbeV2GetAsync($"{Constants.ApiV2.PrivatePkiCertificatesPath}/{orderId}");
+ _output.WriteLine($"Post-cleanup track (read-only GET, private-pki): HTTP {status}, " +
+ $"status={Field(body, "status")}, certificateState={Field(body, "certificateState")}, " +
+ $"orderState={Field(body, "orderState")}, revocation.status={Field(body, "revocation", "status")}, " +
+ $"revocation.reason={Field(body, "revocation", "reason")}");
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"Post-cleanup track failed (read-only; not retried): {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ /// Reads a (nested) string field from a JSON body; "<none>" when absent/unparseable.
+ private static string Field(string json, params string[] path)
+ {
+ if (string.IsNullOrWhiteSpace(json)) return "";
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var el = doc.RootElement;
+ foreach (var p in path)
+ {
+ if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(p, out el))
+ return "";
+ }
+ return el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString();
+ }
+ catch (JsonException)
+ {
+ return "";
+ }
+ }
+
+ ///
+ /// Offline sanity check (no network): the recording proxy can be generated for
+ /// . A generation failure would otherwise only surface inside
+ /// the live test, after the opt-in.
+ ///
+ [Fact]
+ public void PrivatePki_V2_RecordingProxy_BuildsOffline()
+ {
+ using var client = new CERTInextClient(new CERTInextConfig { UseV2Api = true, ApiUrl = "https://invalid.example" });
+ var recorder = OrderIdRecordingClientProxy.Wrap(client, out ICERTInextClient proxied);
+ proxied.Should().NotBeNull();
+ recorder.PlacedOrderId.Should().BeNull();
+ }
+ }
+
+ ///
+ /// Pass-through over a real that
+ /// records the order id returned by any PlaceOrderV2Async overload. Changes no behavior:
+ /// every call is forwarded unchanged and its result/exception returned as-is.
+ ///
+ public class OrderIdRecordingClientProxy : DispatchProxy
+ {
+ private ICERTInextClient _inner;
+
+ public string PlacedOrderId { get; private set; }
+ public string PlacedOrderStatus { get; private set; }
+
+ public static OrderIdRecordingClientProxy Wrap(ICERTInextClient inner, out ICERTInextClient proxied)
+ {
+ proxied = Create();
+ var recorder = (OrderIdRecordingClientProxy)(object)proxied;
+ recorder._inner = inner;
+ return recorder;
+ }
+
+ protected override object Invoke(MethodInfo targetMethod, object[] args)
+ {
+ object result;
+ try
+ {
+ result = targetMethod.Invoke(_inner, args);
+ }
+ catch (TargetInvocationException tie) when (tie.InnerException != null)
+ {
+ ExceptionDispatchInfo.Capture(tie.InnerException).Throw();
+ throw; // unreachable
+ }
+
+ if (targetMethod.Name == nameof(ICERTInextClient.PlaceOrderV2Async)
+ && result is Task placeTask)
+ return RecordAsync(placeTask);
+
+ return result;
+ }
+
+ private async Task RecordAsync(Task placeTask)
+ {
+ var resp = await placeTask;
+ PlacedOrderId = resp?.OrderId;
+ PlacedOrderStatus = resp?.Status;
+ return resp;
+ }
+ }
+
+ ///
+ /// Runs alone: its constructor promotes ~/.env_certinext_v2
+ /// into process env ().
+ ///
+ [CollectionDefinition(Name, DisableParallelization = true)]
+ public sealed class PrivatePkiV2LiveCollection
+ {
+ public const string Name = "PrivatePkiV2Live-NoParallel";
+ }
+}
diff --git a/CERTInext.IntegrationTests/ProductTests.cs b/CERTInext.IntegrationTests/ProductTests.cs
index 99f45f3..6ea9b88 100644
--- a/CERTInext.IntegrationTests/ProductTests.cs
+++ b/CERTInext.IntegrationTests/ProductTests.cs
@@ -2,11 +2,13 @@
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
// At http://www.apache.org/licenses/LICENSE-2.0
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
using Keyfactor.Extensions.CAPlugin.CERTInext.API;
using Xunit;
@@ -73,5 +75,40 @@ await act.Should().NotThrowAsync(
"in the account's product list when GetProductDetails returns results");
}
}
+
+ ///
+ /// Drives
+ ///
+ /// (not just the client method) through the plugin, the same path AnyGatewayREST's
+ /// ConfigurationValidator exercises when a template is saved. V1 mode (the
+ /// default, UseV2Api unset) must be unaffected by V2-specific validation paths.
+ ///
+ [SkippableFact]
+ public async Task ValidateProductInfo_V1_AcceptsConfiguredProductCode()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+ Skip.If(string.IsNullOrWhiteSpace(_fixture.ProductCode),
+ "CERTINEXT_PRODUCT_CODE not set — cannot assert against a real product code.");
+
+ var plugin = new Keyfactor.Extensions.CAPlugin.CERTInext.CERTInextCAPlugin();
+ var connectionInfo = new Dictionary
+ {
+ ["ApiUrl"] = _fixture.ApiUrl,
+ ["AuthMode"] = "AccessKey",
+ ["ApiKey"] = _fixture.AccessKey,
+ ["AccountNumber"] = _fixture.AccountNumber,
+ ["GroupNumber"] = _fixture.GroupNumber
+ };
+ var productInfo = new EnrollmentProductInfo
+ {
+ ProductID = "ssl",
+ ProductParameters = new Dictionary { ["ProductCode"] = _fixture.ProductCode }
+ };
+
+ Func act = () => plugin.ValidateProductInfo(productInfo, connectionInfo);
+
+ await act.Should().NotThrowAsync(
+ $"configured product code \"{_fixture.ProductCode}\" should validate in V1 mode");
+ }
}
}
diff --git a/CERTInext.IntegrationTests/RecordingDomainValidator.cs b/CERTInext.IntegrationTests/RecordingDomainValidator.cs
new file mode 100644
index 0000000..4ecaeac
--- /dev/null
+++ b/CERTInext.IntegrationTests/RecordingDomainValidator.cs
@@ -0,0 +1,94 @@
+// 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.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Keyfactor.AnyGateway.Extensions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// spy that wraps a real (Cloudflare or stub) validator
+ /// and records every StageValidation/CleanupValidation call, including the
+ /// FQDN and staged value, so DCV-on tests can assert whether the plugin actually staged
+ /// a TXT record rather than just asserting that Enroll did not throw.
+ ///
+ internal sealed class RecordingDomainValidator : IDomainValidator
+ {
+ private readonly IDomainValidator _inner;
+ private readonly ConcurrentQueue<(string Fqdn, string Value)> _staged = new();
+ private readonly ConcurrentQueue _cleanedUp = new();
+
+ public RecordingDomainValidator(IDomainValidator inner)
+ {
+ _inner = inner;
+ }
+
+ public IReadOnlyList<(string Fqdn, string Value)> StagedCalls => _staged.ToList();
+ public IReadOnlyList CleanedUpFqdns => _cleanedUp.ToList();
+
+ public void Initialize(IDomainValidatorConfigProvider configProvider) => _inner.Initialize(configProvider);
+
+ public async Task StageValidation(string key, string value, CancellationToken cancellationToken)
+ {
+ _staged.Enqueue((key, value));
+ return await _inner.StageValidation(key, value, cancellationToken);
+ }
+
+ public async Task CleanupValidation(string key, CancellationToken cancellationToken)
+ {
+ _cleanedUp.Enqueue(key);
+ return await _inner.CleanupValidation(key, cancellationToken);
+ }
+
+ public Task ValidateConfiguration(Dictionary configuration) => _inner.ValidateConfiguration(configuration);
+ public Dictionary GetDomainValidatorAnnotations() => _inner.GetDomainValidatorAnnotations();
+ public string GetValidationType() => _inner.GetValidationType();
+ }
+
+ ///
+ /// that wraps another factory and hands out
+ /// spies so tests can inspect what the plugin
+ /// actually did with the DNS provider, keyed by (domain, validationType). Does not own
+ /// disposal of the wrapped factory — callers that build a disposable inner factory
+ /// (e.g. CloudflareDomainValidatorFactory) remain responsible for disposing it.
+ ///
+ internal sealed class RecordingDomainValidatorFactory : IDomainValidatorFactory
+ {
+ private readonly IDomainValidatorFactory _inner;
+ private readonly ConcurrentDictionary _wrapped = new();
+
+ public RecordingDomainValidatorFactory(IDomainValidatorFactory inner)
+ {
+ _inner = inner;
+ }
+
+ public IDomainValidator ResolveDomainValidator(string domain, string validationType)
+ {
+ string cacheKey = $"{domain}|{validationType}";
+ return _wrapped.GetOrAdd(cacheKey, _ => new RecordingDomainValidator(_inner.ResolveDomainValidator(domain, validationType)));
+ }
+
+ /// All StageValidation calls recorded across every domain resolved so far.
+ public IReadOnlyList<(string Fqdn, string Value)> StagedCalls =>
+ _wrapped.Values.SelectMany(v => v.StagedCalls).ToList();
+
+ /// All CleanupValidation calls recorded across every domain resolved so far.
+ public IReadOnlyList CleanedUpFqdns =>
+ _wrapped.Values.SelectMany(v => v.CleanedUpFqdns).ToList();
+ }
+}
diff --git a/CERTInext.IntegrationTests/TESTING.md b/CERTInext.IntegrationTests/TESTING.md
index b961130..e7a313b 100644
--- a/CERTInext.IntegrationTests/TESTING.md
+++ b/CERTInext.IntegrationTests/TESTING.md
@@ -1,194 +1,126 @@
-# CERTInext Integration Tests
+# CERTInext Integration Tests — Test Catalog
-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.
+This page lists the tests in `CERTInext.IntegrationTests`, what each one checks, and what to expect
+for a given account state. For credentials, opt-in flags, and run commands, see
+[INTEGRATION_TESTING.md](INTEGRATION_TESTING.md).
+
+Every live test skips (is reported as Skipped, not Failed) when its credentials or opt-in flag are
+absent. Tests marked **opt-in** place real orders, publish DNS records, or cancel orders, and run
+only when their flag is exported in the shell.
---
## Product Codes Are Per-Account
-**CERTInext product codes are provisioned per account by eMudhra.** The codes available
-to your account are established when the account is created and may differ from any
-documentation examples or from codes used by other accounts.
-
-Key findings verified against sandbox account `9374221333` in April 2026:
+CERTInext product codes are provisioned per account by eMudhra. The codes your account can order
+are set when the account is created and can differ from documentation examples and from other
+accounts. Notes that apply when choosing `CERTINEXT_PRODUCT_CODE`:
-- `GetProductDetails` returns an empty list when called without `groupNumber` in the
- `productDetails` block on some sandbox accounts. The plugin now passes `groupNumber`
- automatically when `GroupNumber` is set in the connector config.
-- The SSL/TLS product codes on this sandbox account are `842–851` (not `838–847` as on
- the prior dev account). DV SSL is `842` on this account.
-- Product code `100` (Private PKI / emSign Intranet SSL) is not provisioned on this
- account — `GenerateOrderSSL` returns `EMS-1162: Invalid Product Code`.
-- Product code `149` (Sandbox emSign Intranet SSL) appears in `GetProductDetails` for
- this account but also returns `EMS-1162` when ordering — it is not usable for orders.
-- EV SSL (codes `850`, `851`) requires an `organizationNumber` that is registered and
- approved in CERTInext; using an unregistered org returns `EMS-1073: Invalid Organization Number`.
-- The `GenerateOrderSSL` API requires `additionalInformation.remarks` in the request body.
- Omitting it returns `EMS-918: Additional Information cannot be empty`.
+- `GetProductDetails` returns an empty list on some sandbox accounts unless the request carries
+ `groupNumber`. The plugin sends it automatically when `GroupNumber` is configured.
+- Private PKI codes (for example `100`, or `149` for the sandbox "emSign Intranet SSL") need a
+ separate entitlement. On an account without it, placing an order returns `EMS-1162: Invalid
+ Product Code` even when the code appears in the catalog.
+- EV SSL needs a registered and approved `organizationNumber`; an unregistered one returns
+ `EMS-1073: Invalid Organization Number`.
+- The V1 `GenerateOrderSSL` call requires `additionalInformation.remarks`; the plugin always sends it.
-To discover the valid product codes for a new account, use:
+To discover the codes your account accepts:
```sh
make probe-products
```
-This places `saveAndHold=1` draft orders for all known SSL/TLS product codes and reports
-which ones return a `requestNumber` (valid) vs. an error (invalid or not provisioned).
-
----
-
-## Prerequisites
-
-- .NET 8 or .NET 10 SDK
-- Access to a CERTInext sandbox or production account
-- 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://sandbox-us-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=842
-CERTINEXT_REQUESTOR_EMAIL=you@example.com
-CERTINEXT_REQUESTOR_NAME=Your Name
-CERTINEXT_REQUESTOR_MOBILE=0000000000
-```
-
-### Field reference
-
-| Variable | Required | Description |
-|----------|----------|-------------|
-| `CERTINEXT_API_URL` | Yes | Base URL of the CERTInext API (no trailing slash) |
-| `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 placement, filtering, and `GetProductDetails`. Required on some sandbox accounts for `GetProductDetails` to return a non-empty list. |
-| `CERTINEXT_ORG_NUMBER` | No | Organization number for OV/EV order placement |
-| `CERTINEXT_PRODUCT_CODE` | Yes | Numeric product code for the target account. **This is per-account** — obtain the correct code for your account by calling `GetProductDetails` (or `make probe-products`). Default shown is for sandbox account `9374221333`. |
-| `CERTINEXT_REQUESTOR_EMAIL` | Yes | Email submitted with test orders — must be registered in the account |
-| `CERTINEXT_REQUESTOR_NAME` | Yes | Name submitted with test orders |
-| `CERTINEXT_REQUESTOR_MOBILE` | No | Mobile number 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
-
-### Build only
-
-```sh
-dotnet build CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj --configuration Release
-```
-
-### Run all integration tests
-
-```sh
-dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj --configuration Release -v normal
-```
-
-### Run a single test class
-
-```sh
-dotnet test CERTInext.IntegrationTests/ --filter "FullyQualifiedName~LifecycleTests" -v normal
-```
-
-### 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.
-
-Some tests additionally skip when the account has no orders yet (e.g. on a fresh sandbox
-account). These tests display a skip reason explaining that the account state does not
-satisfy the test's pre-condition.
+This places `saveAndHold=1` draft orders for the known SSL/TLS product codes and reports which return
+a `requestNumber` (valid) and which return an error (invalid or not provisioned).
---
## Test Classes
-### `ConnectivityTests`
-
-Verifies basic API reachability and credential validity.
-
-| Test | What it checks |
-|------|---------------|
-| `Ping_ReturnsSuccess` | Calls `ValidateCredentials`; asserts no exception is thrown |
-
-### `ProductTests`
-
-Verifies product discovery.
-
-| Test | What it checks |
-|------|---------------|
-| `GetProductDetails_ReturnsProducts` | Calls `GetProductDetails`; asserts the call succeeds without throwing; when products are returned, asserts the expected product code from `CERTINEXT_PRODUCT_CODE` is among them |
-
-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 — only the absence of an exception is mandatory.
-
-### `OrderReportTests`
-
-Exercises the `ListOrdersAsync` path used by `Synchronize`. Tests skip gracefully
-when the account has no orders rather than failing.
-
-| Test | What it checks |
-|------|---------------|
-| `GetOrderReport_ReturnsOrders` | Fetches page 1; skips when account has no orders; otherwise asserts the list is non-empty |
-| `GetOrderReport_AllOrders_HaveRequiredFields` | For each order on page 1: `requestNumber`, `productCode`, and `orderDate` are non-empty; skips when account has no orders |
-
-### `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; skips when account has no records; otherwise asserts at least one `AnyCAPluginCertificate` is produced |
-
-### `LifecycleTests`
-
-Full end-to-end lifecycle tests that create real orders against the configured CERTInext
-account. These tests do not require any pre-existing account state.
-
-| Test | What it checks |
-|------|---------------|
-| `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 |
+### V1 — read-only and basic
+
+| Class | Test | What it checks |
+|---|---|---|
+| `ConnectivityTests` | `Ping_ReturnsSuccess` | `ValidateCredentials` succeeds |
+| `ProductTests` | `GetProductDetails_ReturnsProducts` | `GetProductDetails` succeeds; when products come back, the configured product code is among them. An empty list is accepted, because some accounts return one |
+| | `ValidateProductInfo_V1_AcceptsConfiguredProductCode` | `CERTInextCAPlugin.ValidateProductInfo` in V1 mode accepts `CERTINEXT_PRODUCT_CODE`; skips if it is unset |
+| `OrderReportTests` | `GetOrderReport_ReturnsOrders` | Page 1 of `GetOrderReport` is non-empty; skips when the account has no orders |
+| | `GetOrderReport_AllOrders_HaveRequiredFields` | Every order on page 1 has `requestNumber`, `productCode`, and `orderDate`; skips when the account has no orders |
+| `PluginSmokeTests` | `Ping_ThroughPlugin_Succeeds` | `IAnyCAPlugin.Ping()` through a live client |
+| | `GetProductIds_ReturnsAtLeastOneProduct` | `IAnyCAPlugin.GetProductIds()` returns a non-null list |
+| | `Synchronize_ReturnsAtLeastOneRecord` | A full sync produces at least one record; skips when the account has none |
+| `SmokeTests` | `Ping_Succeeds`, `GetProductDetails_ReturnsProducts`, `ListOrders_ReturnsFirstPage` | Client-level checks of the same endpoints |
+| | `TrackOrder_ReturnsDetails`, `GetSingleRecord_ReturnsRecord` | Look up the order in `CERTINEXT_ORDER_ID`; skip when it is unset |
+| | `GetSingleRecord_ForAllOrders_AllSucceed`, `Synchronize_DumpsAllRecords` | Read every order and write the results to the test output |
+
+### V1 — order lifecycle
+
+| Class | Test | What it checks |
+|---|---|---|
+| `LifecycleTests` | `Enroll_Synchronize_Revoke_FullLifecycle` | Generates an RSA-2048 CSR, enrolls it and asserts a `CARequestID`, runs a full sync and finds the new order, then attempts revocation. Skips gracefully if the order isn't issued yet |
+| `AlgorithmMatrixTests` | `Csr_RoundTripsKeyAlgorithm` | Offline: each key algorithm in the matrix generates a CSR whose signature verifies and whose public key parses back to the same type and size |
+| | `Enroll_AcceptsKeyAlgorithm` | **Opt-in** (`CERTINEXT_ALGO_MATRIX`). Submits one order per key algorithm and records whether CERTInext accepts it. CERTInext accepts RSA 2048/3072/4096 and ECC P-256/P-384, and rejects larger RSA, ECC P-521, and Ed25519/Ed448 |
+
+### V1 — DNS-01 DCV (need Cloudflare credentials unless noted)
+
+| Class | Test | What it checks |
+|---|---|---|
+| `DcvLifecycleTests` | `DcvEnroll_CompletesWithoutThrowing` | An enrollment with DCV enabled completes |
+| | `EnrollWithoutDcv_DoesNotInvokeDnsProvider` | With DCV disabled, the DNS provider is never called |
+| | `EnrollWithDcvOff_OrderAppearsInSync_PluginDidNotInvokeDcv` | An order placed with DCV off still appears in a full sync, and the plugin didn't run DCV |
+| | `EnrollWithDcvOn_OrderIssuedEndToEnd_AndAppearsInSync` | Enroll with DCV on, issue the certificate, and find it in a sync |
+| | `EnrollWithDcvOn_IssuesPerKeyAlgorithm` | **Opt-in** (`CERTINEXT_ALGO_MATRIX_DCV`). DCV issuance for each key algorithm |
+| | `GetSingleRecord_DrivesDcvForPendingOrder` | Needs `CERTINEXT_PENDING_ORDER_ID`. A single-record refresh drives a pending order through DCV |
+| | `BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks` | **Opt-in** (`CERTINEXT_RUN_BULK_TEST`). Many concurrent DV enrollments all issue, and sync pagination returns them |
+| | `CompleteAllPendingDvOrders` | **Opt-in** (`CERTINEXT_COMPLETE_PENDING`). Repeated full syncs until no DV order remains pending |
+| | `FullSync_AllIssuedCerts_CarryParseableCertificateBody` | Every issued record from a full sync carries a parseable certificate |
+| `PendingDvDiagnosticsTests` | `PendingDvDiagnostics_DumpDcvState` | Diagnostic. Needs `CERTINEXT_DIAG_ORDER_IDS`. Read-only dump of each listed order's DCV state |
+
+### V2 — API and lifecycle
+
+The V2 tests skip unless the V2 credentials are present (see [INTEGRATION_TESTING.md](INTEGRATION_TESTING.md#skip-behaviour)).
+
+Every V2 test that enrolls a product is a `[SkippableTheory]` that runs twice: `mode: Pinned` (the
+template carries a `ProductCode`) and `mode: V2Template` (no `ProductCode`; CERTInext resolves the
+product). See [INTEGRATION_TESTING.md](INTEGRATION_TESTING.md#v2-pinned-and-unpinned-v2-template-runs).
+`BulkV2Enrollment_AllOrdersIssue_AndPaginationWorks` (a volume/pagination test, 101 orders) runs pinned
+only. `PrivatePki_V2_*`, the validity/latency/inline-CSR experiments, and the read-only probes are not doubled.
+
+| Class | Test | What it checks |
+|---|---|---|
+| `V2ApiTests` | `Connectivity_V2_Ping` | `GET /auth/me` succeeds with an OAuth token |
+| | `Lifecycle_V2_EnrollTrackRevoke` | Place an order with the CSR inline (a raw single create call), track it, then revoke or cancel it in the class cleanup. Every raw-client order in `V2ApiTests` is cleaned up the same way |
+| | `Sync_UsesV2_WithZeroV1Credentials` | Synchronize works with no V1 credentials configured |
+| | `GetProductDetails_V2_ReturnsProducts` | The V2 catalog returns products |
+| | `ValidateProductInfo_V2_AcceptsConfiguredProductCode`, `ValidateProductInfo_V2_RejectsUnknownProductCode` | Template validation against the V2 catalog |
+| | `GetSingleRecord_V2_ReturnsOrderDetails`, `Revoke_V2_IssuedOrder`, `ChainPem_V2_IsAssembled` | Single-record lookup, revocation, and chain assembly for an issued order (`CERTINEXT_V2_ISSUED_ORDER_ID`, or a fresh order) |
+| | `DcvFlow_V2_PublishesAndVerifies` | Needs Cloudflare. The V2 DCV flow publishes the TXT record and CERTInext verifies it |
+| `V2LifecycleTests` | `Enroll_V2_ReturnsCARequestID`, `Enroll_Synchronize_Revoke_V2_FullLifecycle` | V2 enrollment returns an ID; a full enroll, sync, revoke cycle |
+| | `Revoke_V2_ExplicitOrder_Superseded`, `Revoke_V2_IssuedOrder_ReturnsRevoked` | Revocation with an explicit reason, and of an issued order (`CERTINEXT_REVOKE_ORDER_ID`) |
+| | `GetSingleRecord_V2_Plugin_ReturnsDetails`, `GetSingleRecord_V2_IssuedOrder_HasParseableCertBody`, `GetSingleRecord_V2_AllSyncedOrders_DoNotThrow` | Single-record lookup through the plugin |
+| | `Sync_V2_UsesV2ReportsOrders_ReturnsRecords`, `Sync_V2_WithZeroV1Credentials_Succeeds`, `Sync_V2_SmallPageSize_PaginatesAcrossMultiplePages` | V2 synchronization, with no V1 credentials and across several small pages |
+| | `Sync_V2_FullSync_PaginatesEntireHistory` | Set `CERTINEXT_V2_FULL_SYNC_TEST` to run it; can be slow on a shared account |
+| `V2DcvLifecycleTests` | `DcvEnroll_V2_CompletesWithoutThrowing`, `EnrollWithoutDcv_V2_DoesNotInvokeDnsProvider`, `GetSingleRecord_V2_DrivesDcvForPendingOrder`, `EnrollWithDcvOn_V2_OrderIssuedEndToEnd_AndAppearsInSync` | The V2 counterparts of the V1 DCV tests (`CERTINEXT_V2_PENDING_ORDER_ID` for the single-record one) |
+| | `EnrollWithDcvOn_V2_IssuesPerKeyAlgorithm` | **Opt-in** (`CERTINEXT_V2_ALGO_MATRIX`) |
+| | `BulkV2Enrollment_AllOrdersIssue_AndPaginationWorks` | **Opt-in** (`CERTINEXT_V2_RUN_BULK_TEST`) |
+| `V2FreshDomainDcvLifecycleTests` | `EnrollWithDcvOn_V2_FreshUnverifiedSubdomain_StagesAndCleansUpTxt`, `EnrollWithDcvOn_V2_WildcardFreshSubdomain_RecordsTxtHostnameAndCleansUp` | **Opt-in** (`CERTINEXT_V2_LIFECYCLE_FRESH_DCV`). DCV against a never-validated subdomain, and against a wildcard on one: the TXT record is staged at the expected hostname and removed afterward |
+| `V2FullLifecycleTests` | `Enroll_V2_DvUcc_WithMultipleSans_FullLifecycle`, `Enroll_V2_Ov_FullLifecycle`, `Enroll_V2_OvUcc_WithMultipleSans_FullLifecycle`, `Enroll_V2_Ev_FullLifecycle`, `Enroll_V2_WildcardDv_WildcardOnly_FullLifecycle`, `Enroll_V2_WildcardDv_WildcardPlusApexSan_RecordsActualBehavior`, `EnrollRenewReissue_V2_IssuedDvOrder_RecordsActualBehavior` | **Opt-in** (one `CERTINEXT_V2_LIFECYCLE_*` flag per product, see [INTEGRATION_TESTING.md](INTEGRATION_TESTING.md#opt-in-flags)). Each enrolls, waits, synchronizes, and cleans up its order |
+| `PrivatePkiV2LiveTests` | `PrivatePki_V2_EnrollIntranetSsl_ThenRevoke_Live` | **Opt-in** (`CERTINEXT_PRIVATE_PKI_LIVE`). Enrolls a Private PKI Intranet SSL order with DNS and IP SANs, checks the issued SANs, and revokes it. Needs a Private PKI entitlement. Always two-call (create, then `PUT .../csr`): the sandbox rejects an inline CSR on Private PKI create with HTTP 422. The order parks at `pending-approval` and is cancelled, so the test reports it as inconclusive on this sandbox |
+| | `PrivatePki_V2_RecordingProxy_BuildsOffline` | Offline sanity check of the test's recording proxy |
+| `V2OrderWindowSweepTests` | `Sweep_ListRecentOrders_ByWindow_DryRun_ThenCancelExplicitIds` | **Opt-in** (`CERTINEXT_V2_OPS_TESTS`). Operations tool: lists V2 orders in a date window and optionally cancels listed IDs |
+
+### Offline tests and utilities
+
+| Class | What it covers |
+|---|---|
+| `IntegrationTestFixtureTests` | Parsing of `KEY=VALUE` env-file values: quote handling and null input |
+| `V1FixtureApiUrlGuardTests` | The V1 fixture rejects a V2 `CERTINEXT_API_URL`; the V2 file loader never promotes V1 keys or opt-in flags into the process environment |
+| `KfclabCsrEmitterTests` | Utility, not an API test. With `CERTINEXT_EMIT_CSR_DIR` and `CERTINEXT_EMIT_CSR_SPEC` set, writes CSR files for use by external tooling. Makes no CA calls |
+
+Shared helpers (not tests): `IntegrationTestFixture`, `IntegrationSkip`, `KeyAlgorithms`,
+`V2EnvHelper`, `V2DomainStatusHelper`, `V2RawHttpHelpers`, and the DNS validators
+`CloudflareDomainValidator`, `RecordingDomainValidator`, and `StubDomainValidator`.
---
@@ -199,104 +131,29 @@ account. These tests do not require any pre-existing account state.
| Test class | Expected result |
|-----------|----------------|
| `ConnectivityTests` | Pass — credentials only |
-| `ProductTests` | Pass — product list may be empty if `CERTINEXT_GROUP_NUMBER` is not set and the account requires it; test tolerates an empty list |
+| `ProductTests` | Pass — the product list may be empty if `CERTINEXT_GROUP_NUMBER` is unset and the account needs it; the test tolerates an empty list |
| `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 |
+| `LifecycleTests.Enroll_Synchronize_Revoke_FullLifecycle` | Skip with "Invalid Product Code" if `CERTINEXT_PRODUCT_CODE` isn't provisioned for the account; otherwise enroll and sync pass, and revoke skips because a sandbox DV order needs domain validation before it is issued |
-### Account with history (orders previously placed)
+### Account with history
| Test class | Expected result |
|-----------|----------------|
-| `ConnectivityTests` | Pass |
-| `ProductTests` | Pass |
-| `OrderReportTests` | Pass |
-| `PluginSmokeTests` | Pass |
-| `LifecycleTests` | Pass (all three steps) |
-
----
-
-## Removed Tests
-
-The following test files were present in earlier versions but have been removed because
-they relied on pre-existing account state that is not portable across accounts or
-sandbox environments:
-
-- **`DraftOrderTests.cs`** — contained five tests that asserted specific `requestNumber`
- values (e.g. `4572531551`, `9149755266`) hardcoded from a different developer account.
- On any other account these request numbers do not exist so all five tests failed.
-
-- **`TrackOrderTests.cs`** — contained one test that located a known draft order by
- `requestNumber` and asserted its `orderNumber` was null (draft/on-hold semantic).
- Same problem: the hardcoded `requestNumber` does not exist on other accounts.
-
-The intent of those tests (verifying draft-order and track-order semantics) is now
-covered indirectly by `LifecycleTests`, which creates its own order and verifies the
-resulting state without relying on account-specific identifiers.
-
----
-
-## 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.
-
----
-
-## Fresh Account Setup for Integration Tests
-
-When setting up a brand-new CERTInext sandbox account to run integration tests:
-
-1. **Discover valid product codes** — run `make probe-products` from the repo root. This places
- `saveAndHold=1` draft orders for all known SSL/TLS product codes and reports which ones your
- account accepts. Use the first DV SSL code that returns a `requestNumber` as your
- `CERTINEXT_PRODUCT_CODE`.
-
-2. **Set `CERTINEXT_GROUP_NUMBER`** — if `make probe-products` or `GetProductDetails` returns no
- products, find your group number in the CERTInext portal under **Delegation → Groups** and add
- it to `~/.env_certinext`. The `GetProductDetails` API requires it on some accounts.
-
-3. **Run connectivity tests first** — `make integration-test` or
- `dotnet test CERTInext.IntegrationTests/ -v normal`. The `ConnectivityTests` class verifies
- credentials. The `LifecycleTests` class places real orders — it can be run even before any
- orders exist.
-
-4. **Expect the revoke step to skip** — DV SSL orders on the sandbox require domain control
- verification (DCV) and RA approval before they are issued. The `LifecycleTests` enroll step
- will succeed and sync will find the order, but revoke will skip because the order is in a
- pending state. This is the expected behavior for a public DV SSL order in sandbox. To test
- revocation, either use a private PKI product that auto-approves, or log in to the CERTInext
- portal and manually approve the pending order after `LifecycleTests` runs.
-
-5. **Account-specific product codes** — update `CERTINEXT_PRODUCT_CODE` in `~/.env_certinext`
- with the code discovered in step 1. Do not use `100` (private PKI, not provisioned on
- standard accounts) or codes from documentation examples — they may not be provisioned for your
- account.
-
----
-
-## Troubleshooting
-
-| Symptom | Likely cause | Fix |
-|---------|-------------|-----|
-| All tests skipped | Missing or empty `~/.env_certinext` | Create the file with `CERTINEXT_API_URL` and `CERTINEXT_ACCESS_KEY` |
-| `Ping` fails with 401/403 | Wrong `CERTINEXT_ACCESS_KEY` | Regenerate the key in the CERTInext portal under Integrations → APIs |
-| `Ping` fails with timeout or 404 | Wrong `CERTINEXT_API_URL` | Verify the URL matches your account region (see API URL table above) |
-| `Enroll` fails with "Invalid Product Code" (EMS-1162) | Wrong `CERTINEXT_PRODUCT_CODE` | Run `make probe-products` to discover the codes provisioned for your account |
-| `GetProductDetails` returns empty list | `CERTINEXT_GROUP_NUMBER` not set | Add your group number to `~/.env_certinext`; some accounts require it for `GetProductDetails` to return results |
-| `Enroll` fails with "Additional Information cannot be empty" (EMS-918) | Old plugin version missing `additionalInformation.remarks` | Rebuild and redeploy the plugin — the `remarks` field is now populated automatically |
-| `Enroll` fails with "Invalid Organization Number" (EMS-1073) | OV/EV product code selected with an unregistered org | Use a DV SSL product code for automated tests, or register and approve your org in CERTInext first |
-| Revoke step skips with "not GENERATED" | Sandbox DV SSL order requires domain validation and RA approval | Expected behavior for public DV SSL in sandbox — log in to the CERTInext portal and approve the pending order, then re-run; or use a private PKI product that auto-approves |
-| `OrderReportTests` all skip | Fresh account with no orders | Run `LifecycleTests` first to place at least one order |
-| `ProductTests` asserts configured product code is not found | `CERTINEXT_PRODUCT_CODE` set to a code not provisioned for the account | Run `make probe-products` and update `CERTINEXT_PRODUCT_CODE` with a valid code |
+| `ConnectivityTests`, `ProductTests`, `OrderReportTests`, `PluginSmokeTests` | Pass |
+| `LifecycleTests` | Pass for enroll and sync; revoke runs only if the new order is issued |
+
+The DCV tests complete a DV order end to end only when Cloudflare credentials are configured and the
+domain in `CERTINEXT_DCV_DOMAIN` is in that zone. Without them, the revoke step of `LifecycleTests`
+skips, because DV orders on the sandbox can't be issued without domain validation.
+
+### Fresh account setup
+
+1. **Discover valid product codes** with `make probe-products`. Use the first DV SSL code that
+ returns a `requestNumber` as `CERTINEXT_PRODUCT_CODE`.
+2. **Set `CERTINEXT_GROUP_NUMBER`** if `make probe-products` or `GetProductDetails` returns no
+ products. Find it in the portal under **Delegation → Groups**.
+3. **Run `ConnectivityTests` first**, then `LifecycleTests`, which places a real order and can run
+ before any orders exist.
+4. **Expect the revoke step to skip** without DCV. To exercise revocation, configure Cloudflare so a
+ DV order can issue, or use a product that issues without domain validation.
diff --git a/CERTInext.IntegrationTests/TestOutputLogging.cs b/CERTInext.IntegrationTests/TestOutputLogging.cs
new file mode 100644
index 0000000..5512917
--- /dev/null
+++ b/CERTInext.IntegrationTests/TestOutputLogging.cs
@@ -0,0 +1,74 @@
+// 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 Microsoft.Extensions.Logging;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Routes plugin/client logs (LogHandler.Factory) to a test's xUnit output so a live run
+ /// shows why the plugin took a path, e.g. the V2 DCV early exits or a CA rejection at Submit CSR.
+ /// Minimum level is Debug, or CERTINEXT_TEST_LOG_LEVEL (e.g. Trace to include the
+ /// redacted request/response payloads). Plugin logs keep their default redaction
+ /// (LogSensitiveRequestData off). Must be called before the plugin is constructed — its
+ /// logger is created in a field initializer.
+ ///
+ internal static class TestOutputLogging
+ {
+ /// Points the plugin's log factory at ; returns the restore action.
+ public static Action RouteTo(ITestOutputHelper output)
+ {
+ var min = Enum.TryParse(Environment.GetEnvironmentVariable("CERTINEXT_TEST_LOG_LEVEL"), true, out LogLevel parsed)
+ ? parsed
+ : LogLevel.Debug;
+ Keyfactor.Logging.LogHandler.Factory = new Factory(output, min);
+ return () => Keyfactor.Logging.LogHandler.Factory =
+ Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance;
+ }
+
+ private sealed class Factory : ILoggerFactory
+ {
+ private readonly ITestOutputHelper _out;
+ private readonly LogLevel _min;
+ public Factory(ITestOutputHelper output, LogLevel min) { _out = output; _min = min; }
+ public ILogger CreateLogger(string categoryName) => new Logger(_out, categoryName, _min);
+ public void AddProvider(ILoggerProvider provider) { }
+ public void Dispose() { }
+ }
+
+ private sealed class Logger : ILogger
+ {
+ private readonly ITestOutputHelper _out;
+ private readonly string _category;
+ private readonly LogLevel _min;
+ public Logger(ITestOutputHelper output, string category, LogLevel min)
+ {
+ _out = output;
+ _category = category.Substring(category.LastIndexOf('.') + 1);
+ _min = min;
+ }
+ public IDisposable BeginScope(TState state) => null;
+ public bool IsEnabled(LogLevel logLevel) => logLevel >= _min;
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception,
+ Func formatter)
+ {
+ if (!IsEnabled(logLevel)) return;
+ try
+ {
+ _out.WriteLine($"[{logLevel}] {_category}: {formatter(state, exception)}" +
+ (exception == null ? string.Empty : $" | {exception.GetType().Name}: {exception.Message}"));
+ }
+ catch (InvalidOperationException)
+ {
+ // ITestOutputHelper throws once the test has finished (background continuations).
+ }
+ }
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/V1FixtureApiUrlGuardTests.cs b/CERTInext.IntegrationTests/V1FixtureApiUrlGuardTests.cs
new file mode 100644
index 0000000..541826d
--- /dev/null
+++ b/CERTInext.IntegrationTests/V1FixtureApiUrlGuardTests.cs
@@ -0,0 +1,179 @@
+// 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;
+using System.IO;
+using FluentAssertions;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Pure offline regression tests (no live-API dependency, no process-env mutation, so they
+ /// are safe to run in parallel with every other class):
+ ///
+ /// the V1 fixture's guard
+ /// rejects a V2 base URL with an actionable message that never echoes secrets;
+ /// never promotes a key the V1 side reads,
+ /// nor any of the fixture's opt-in-only flags.
+ ///
+ ///
+ public class V1FixtureApiUrlGuardTests
+ {
+ private const string V1Url = "https://sandbox-us-api.certinext.io/emSignHub-API";
+ private const string V2Url = "https://sandbox-us-api.certinext.io";
+
+ // -------------------------------------------------------------------------
+ // (D) fail-fast guard
+ // -------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(V1Url)]
+ [InlineData(V1Url + "/")]
+ [InlineData("https://api.certinext.io/emsignhub-api/")] // case-insensitive
+ [InlineData("")] // unconfigured: nothing to check
+ [InlineData(null)]
+ public void EnsureV1ApiUrl_V1OrEmptyUrl_DoesNotThrow(string apiUrl)
+ {
+ Action act = () => IntegrationTestFixture.EnsureV1ApiUrl(apiUrl, fromProcessEnvironment: false);
+ act.Should().NotThrow();
+ }
+
+ [Theory]
+ [InlineData(V2Url, true)]
+ [InlineData(V2Url + "/", false)]
+ [InlineData("https://sandbox-us-api.certinext.io/v2", true)]
+ public void EnsureV1ApiUrl_V2BaseUrl_ThrowsActionableMessage(string apiUrl, bool fromProcessEnv)
+ {
+ Action act = () => IntegrationTestFixture.EnsureV1ApiUrl(apiUrl, fromProcessEnv);
+
+ var ex = act.Should().Throw().Which;
+ ex.Message.Should().Contain("CERTINEXT_API_URL")
+ .And.Contain("/emSignHub-API")
+ .And.Contain("V2 base URL")
+ .And.Contain("set -a; . ~/.env_certinext; set +a")
+ .And.Contain("~/.env_certinext_v2");
+ ex.Message.Should().Contain(fromProcessEnv ? "process environment" : "(from ~/.env_certinext)");
+ }
+
+ [Fact]
+ public void EnsureV1ApiUrl_UrlWithUserInfoAndQuery_NeverEchoesThem()
+ {
+ Action act = () => IntegrationTestFixture.EnsureV1ApiUrl(
+ "https://someuser:not-a-real-secret@sandbox-us-api.certinext.io/?token=not-a-real-token",
+ fromProcessEnvironment: true);
+
+ var ex = act.Should().Throw().Which;
+ ex.Message.Should().Contain("sandbox-us-api.certinext.io");
+ ex.Message.Should().NotContain("someuser")
+ .And.NotContain("not-a-real-secret")
+ .And.NotContain("not-a-real-token");
+ }
+
+ ///
+ /// End-to-end offline composition of the shell-overlay path: a correct V1 file, a
+ /// V2 CERTINEXT_API_URL in the (simulated) process environment. Real env vars keep
+ /// precedence (documented behaviour), and the guard then rejects the leaked value — the
+ /// same two steps the fixture constructor runs before it builds any client.
+ ///
+ [Fact]
+ public void LoadEnvFile_ProcessEnvV2UrlOverridesV1File_GuardRejectsIt()
+ {
+ string path = Path.Combine(Path.GetTempPath(), $"certinext-v1url-{Guid.NewGuid():N}.env");
+ try
+ {
+ File.WriteAllLines(path, new[]
+ {
+ $"CERTINEXT_API_URL={V1Url}",
+ "CERTINEXT_ACCESS_KEY=dummy-access-key",
+ });
+ var processEnv = new Hashtable { ["CERTINEXT_API_URL"] = V2Url };
+
+ var env = IntegrationTestFixture.LoadEnvFile(path, processEnv);
+
+ env["CERTINEXT_API_URL"].Should().Be(V2Url, "real env vars still override the file");
+ Action act = () => IntegrationTestFixture.EnsureV1ApiUrl(env["CERTINEXT_API_URL"], true);
+ act.Should().Throw()
+ .Which.Message.Should().NotContain("dummy-access-key");
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // (B) V2EnvHelper no longer promotes V1-shared keys
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void PromotableKeys_ExcludesEveryV1Key_KeepsV2OnlyKeys()
+ {
+ // Mirrors the key set ~/.env_certinext_v2 defines today (names only).
+ string[] v2FileKeys =
+ {
+ "CERTINEXT_ACCOUNT_NUMBER", "CERTINEXT_API_URL", "CERTINEXT_CF_API_TOKEN",
+ "CERTINEXT_CF_ZONE_ID", "CERTINEXT_CLIENT_ID", "CERTINEXT_CLIENT_SECRET",
+ "CERTINEXT_DCV_DOMAIN", "CERTINEXT_GROUP_NUMBER", "CERTINEXT_ORG_NUMBER",
+ "CERTINEXT_PRODUCT_CODE", "CERTINEXT_REQUESTOR_EMAIL", "CERTINEXT_REQUESTOR_MOBILE",
+ "CERTINEXT_REQUESTOR_NAME", "CERTINEXT_SIGNER_IP", "CERTINEXT_USE_V2_API",
+ "certinext_api_url", // case-insensitive match
+ };
+
+ var promoted = V2EnvHelper.PromotableKeys(v2FileKeys);
+
+ promoted.Should().BeEquivalentTo(
+ "CERTINEXT_CLIENT_ID", "CERTINEXT_CLIENT_SECRET", "CERTINEXT_REQUESTOR_MOBILE",
+ "CERTINEXT_SIGNER_IP", "CERTINEXT_USE_V2_API");
+ }
+
+ ///
+ /// If a developer ever left one of the fixture's opt-in-only flags (e.g.
+ /// CERTINEXT_V2_OPS_TESTS, CERTINEXT_PRIVATE_PKI_LIVE) in ~/.env_certinext_v2, it must
+ /// NOT come back out of — otherwise the first
+ /// test class constructed in a run reads the flag as unset, then promotes it into real
+ /// process env, silently arming every later-constructed test class in the same run even
+ /// though nothing was ever exported in the shell. Covers every flag in
+ /// , so a future addition to that set
+ /// is covered automatically.
+ ///
+ [Fact]
+ public void PromotableKeys_ExcludesEveryOptInOnlyFlag()
+ {
+ var promoted = V2EnvHelper.PromotableKeys(IntegrationTestFixture._optInOnlyFlags);
+
+ promoted.Should().BeEmpty(
+ "every opt-in-only flag must be excluded from V2-file promotion, or a value left " +
+ "in ~/.env_certinext_v2 could silently arm a later test in the same run");
+ }
+
+ [Theory]
+ [InlineData("CERTINEXT_API_URL")]
+ [InlineData("CERTINEXT_ACCESS_KEY")]
+ [InlineData("CERTINEXT_ACCOUNT_NUMBER")]
+ [InlineData("CERTINEXT_GROUP_NUMBER")]
+ [InlineData("CERTINEXT_ORG_NUMBER")]
+ [InlineData("CERTINEXT_PRODUCT_CODE")]
+ [InlineData("CERTINEXT_REQUESTOR_EMAIL")]
+ [InlineData("CERTINEXT_REQUESTOR_NAME")]
+ [InlineData("CERTINEXT_CF_API_TOKEN")]
+ [InlineData("CERTINEXT_CF_ZONE_ID")]
+ [InlineData("CERTINEXT_DCV_DOMAIN")] // read from process env by V1 DcvLifecycleTests
+ public void V1EnvKeys_CoversEveryKeyTheV1SideReads(string key)
+ {
+ IntegrationTestFixture.V1EnvKeys.Should().Contain(key);
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/V2ApiTests.cs b/CERTInext.IntegrationTests/V2ApiTests.cs
new file mode 100644
index 0000000..3da4b09
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2ApiTests.cs
@@ -0,0 +1,829 @@
+// 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.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Integration test stubs for the V2 REST API code path.
+ ///
+ /// All tests are gated behind the CERTINEXT_USE_V2_API=1 environment variable
+ /// and skip gracefully when it is not set, so they are safe to run in CI environments
+ /// that do not have V2 credentials configured.
+ ///
+ /// To run against a live V2 environment:
+ ///
+ /// set -a; . ~/.env_certinext; set +a
+ /// export CERTINEXT_USE_V2_API=1
+ /// dotnet test CERTInext.IntegrationTests/ --filter "FullyQualifiedName~V2ApiTests"
+ ///
+ /// Note: the shell must source ONLY ~/.env_certinext (never ~/.env_certinext_v2);
+ /// this class loads ~/.env_certinext_v2 itself from disk at test-construction time.
+ ///
+ /// Required variables in ~/.env_certinext_v2 (or real env vars):
+ ///
+ /// CERTINEXT_API_URL — V2 base URL (e.g. https://sandbox-us-api.certinext.io)
+ /// CERTINEXT_CLIENT_ID — OAuth2 client ID
+ /// CERTINEXT_CLIENT_SECRET — OAuth2 client secret
+ /// CERTINEXT_PRODUCT_CODE — product code for lifecycle test (e.g. 842)
+ /// CERTINEXT_DCV_DOMAIN — domain for lifecycle test (e.g. dcv-test.example.com)
+ ///
+ /// V1 variables (CERTINEXT_API_URL, CERTINEXT_ACCESS_KEY, etc.) are NOT required
+ /// for V2-mode tests — Synchronize uses V2 /reports/orders when UseV2Api is true,
+ /// and V1 credentials are optional in that mode.
+ ///
+ public class V2ApiTests : IClassFixture, IAsyncLifetime
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly string _v2ProductCode;
+ private readonly string _v2Domain;
+ private readonly bool _v2Enabled;
+ private readonly string _cfApiToken;
+ private readonly string _cfZoneId;
+ private readonly bool _dcvEnabled;
+ private readonly string _issuedOrderId;
+
+ public V2ApiTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+
+ // Load ~/.env_certinext_v2 via the shared helper (one env loader, not a private
+ // copy per test class). V2 file values take priority over
+ // process env because IntegrationTestFixture may have already promoted the V1
+ // CERTINEXT_API_URL (with /emSignHub-API suffix) into process env, and the V2 base
+ // URL is different.
+ var env = V2EnvHelper.LoadAndPromote();
+
+ _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL");
+ _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID");
+ _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET");
+ _v2ProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE", "842");
+ _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com");
+ _cfApiToken = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_API_TOKEN");
+ _cfZoneId = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_ZONE_ID");
+ _issuedOrderId = V2EnvHelper.GetEnv(env, "CERTINEXT_V2_ISSUED_ORDER_ID");
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+
+ _dcvEnabled = _v2Enabled
+ && !string.IsNullOrWhiteSpace(_cfApiToken)
+ && !string.IsNullOrWhiteSpace(_cfZoneId);
+ }
+
+ // ---------------------------------------------------------------------------
+ // V2 Connectivity
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Calls GET /api/certinext/v2/auth/me and verifies a non-empty accountNumber
+ /// is returned. Skips when CERTINEXT_USE_V2_API is not set.
+ ///
+ [SkippableFact]
+ public async Task Connectivity_V2_Ping()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ using var client = BuildV2Client();
+ var me = await client.GetAuthMeV2Async();
+
+ me.Should().NotBeNull();
+ me.AccountNumber.Should().NotBeNullOrEmpty("auth/me must return accountNumber for a valid OAuth2 client");
+ me.AuthType.Should().Be("oauth2");
+ }
+
+ // ---------------------------------------------------------------------------
+ // V2 Lifecycle: place order → track → revoke
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Places a V2 SSL order, asserts that the CARequestID starts with "ord_",
+ /// then revokes the order.
+ /// Skips when CERTINEXT_USE_V2_API is not set.
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task Lifecycle_V2_EnrollTrackRevoke(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ using var client = BuildV2Client();
+
+ // Place order
+ var orderReq = BuildStandardOrderRequest();
+ var createResp = await PlaceOrderTrackedAsync(client, orderReq, mode);
+
+ createResp.Should().NotBeNull();
+ createResp.OrderId.Should().NotBeNullOrEmpty(
+ "V2 place-order must return a non-empty orderId (sandbox may return numeric IDs rather than 'ord_' prefix)");
+
+ // Track the order
+ var (_, trackResp) = await ResolveOrderFamilyAsync(client, createResp.OrderId);
+ trackResp.OrderId.Should().Be(createResp.OrderId);
+ trackResp.Status.Should().NotBeNullOrEmpty(
+ "V2 TrackOrder must return a status for the placed order");
+ // Best-effort structural check: this sandbox's TrackOrder response can omit
+ // "_links" entirely, so this logs rather than hard-fails — the shape actually
+ // guarded against here is OrderId/Status.
+ if (trackResp.Links?.Self?.Href is string href && !string.IsNullOrWhiteSpace(href))
+ _output.WriteLine($"TrackOrder links.self.href: {href}");
+ else
+ _output.WriteLine("TrackOrder response did not include a links.self.href (sandbox may omit _links).");
+
+ // Note: revoke requires the order to reach 'issued' state first.
+ // The sandbox processes orders asynchronously, so we only assert enroll + track here.
+ // A full revoke smoke test requires waiting for issuance (run separately with DCV configured).
+ }
+
+ // ---------------------------------------------------------------------------
+ // Synchronize uses V2 /reports/orders when UseV2Api=true
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Verifies that Synchronize calls V2 /reports/orders (not V1 GetOrderReport)
+ /// when UseV2Api=true, and succeeds with ZERO V1 credentials configured at all.
+ /// A single serves both modes, so the V1-only
+ /// fields (ApiKey/AccountNumber/AuthMode) below are simply never set.
+ ///
+ [SkippableFact]
+ public async Task Sync_UsesV2_WithZeroV1Credentials()
+ {
+ Skip.If(!_v2Enabled, "V2 opt-in (CERTINEXT_USE_V2_API) or V2 credentials not configured — skipping.");
+
+ var config = new CERTInextConfig
+ {
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ OAuthClientId = _v2ClientId,
+ OAuthClientSecret = _v2ClientSecret,
+ RequestorName = "Keyfactor Test",
+ RequestorEmail = "test@example.com",
+ SignerPlace = "Gateway Lab",
+ SignerIp = "127.0.0.1",
+ PageSize = 10,
+ // A small lookback keeps this test's live API call volume bounded — every
+ // issued row in the window needs a live certificate download (the report
+ // carries no body), and ResolveAndDownloadCertificateV2Async re-resolves the
+ // product family via a sequential TrackOrder probe when it isn't already known.
+ // The DEFAULT 72h lookback margin (Constants.ApiV2.DefaultSyncLookbackHours) is
+ // always added on top of lastSync regardless of how recent lastSync is, so on a
+ // busy shared sandbox account even a "last hour" delta sync still touches
+ // several days of orders unless this is overridden. This is a real, currently
+ // unbounded cost on the live path, not just a test-tuning artifact.
+ V2SyncLookbackHours = 1
+ // Deliberately NOT set: ApiKey, AccountNumber, AuthMode, OAuthTokenUrl — all
+ // V1-only fields. Proving Synchronize succeeds without them is the point of
+ // this test.
+ };
+
+ using var client = new CERTInextClient(config);
+ var plugin = new CERTInextCAPlugin(client, config);
+
+ var buffer = new BlockingCollection(1000);
+ // 300s: this shared sandbox can return 100+ orders even within a narrow ~1-2h
+ // window (heavy ongoing test activity), and each issued row costs a live download
+ // plus (when family isn't already known) a family-probe TrackOrder call — a
+ // genuine current performance characteristic of the live path, not a test artifact.
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(300));
+
+ await plugin.Synchronize(buffer, DateTime.UtcNow.AddHours(-1), false, cts.Token);
+ if (!buffer.IsAddingCompleted)
+ buffer.CompleteAdding();
+
+ var records = new List();
+ foreach (var record in buffer.GetConsumingEnumerable())
+ records.Add(record);
+
+ // The delta sync window is narrow (see V2SyncLookbackHours above), so this only
+ // proves correctness (zero V1 creds, records returned, shape is sane) — not sync
+ // performance at scale, which is a separate, real concern.
+ records.Should().NotBeEmpty(
+ "Synchronize must return records via V2 /reports/orders when UseV2Api=true, with zero V1 " +
+ "credentials configured — an empty result here proves nothing about which code path ran");
+ records.Should().OnlyContain(r => !string.IsNullOrWhiteSpace(r.CARequestID));
+
+ _output.WriteLine(
+ $"Sync_UsesV2_WithZeroV1Credentials: {records.Count} record(s) returned via V2 /reports/orders, " +
+ "with no ApiKey/AccountNumber/AuthMode configured.");
+ }
+
+ // ---------------------------------------------------------------------------
+ // V2 Product catalogue
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Calls GET /api/certinext/v2/catalog/products and asserts a non-empty list
+ /// is returned. Skips when CERTINEXT_USE_V2_API is not set.
+ ///
+ [SkippableFact]
+ public async Task GetProductDetails_V2_ReturnsProducts()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ using var client = BuildV2Client();
+ List products = await client.GetProductDetailsV2Async();
+
+ products.Should().NotBeNull("V2 catalog/products must return a non-null list");
+ products.Should().NotBeEmpty("V2 catalog/products must return at least one product");
+
+ // The live catalog/products response is a nested category envelope, the same
+ // shape V1's GetProductDetails returns. ParseProductDetailsV2Response flattens
+ // it, so every parsed product must carry a non-empty ProductCode.
+ products.Should().OnlyContain(p => !string.IsNullOrWhiteSpace(p.ProductCode),
+ "ParseProductDetailsV2Response must flatten the nested category envelope into ProductCode-bearing rows");
+ _output.WriteLine($"{products.Count}/{products.Count} catalog products carry a non-empty ProductCode.");
+ }
+
+ ///
+ /// Drives (not just the client
+ /// method) end-to-end in V2 mode against the configured product code. Read-only.
+ ///
+ [SkippableFact]
+ public async Task ValidateProductInfo_V2_AcceptsConfiguredProductCode()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ var plugin = new CERTInextCAPlugin();
+ var connectionInfo = BuildV2ConnectionInfo();
+ var productInfo = new EnrollmentProductInfo
+ {
+ ProductID = "ssl",
+ ProductParameters = new Dictionary { ["ProductCode"] = _v2ProductCode }
+ };
+
+ Func act = () => plugin.ValidateProductInfo(productInfo, connectionInfo);
+
+ await act.Should().NotThrowAsync(
+ $"ProductCode '{_v2ProductCode}' should be present in the live V2 catalog");
+ }
+
+ ///
+ /// Same as but with a
+ /// product code that should never exist, asserting the same "not found" failure mode
+ /// V1 has always had. Read-only — no order is placed.
+ ///
+ [SkippableFact]
+ public async Task ValidateProductInfo_V2_RejectsUnknownProductCode()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ var plugin = new CERTInextCAPlugin();
+ var connectionInfo = BuildV2ConnectionInfo();
+ var productInfo = new EnrollmentProductInfo
+ {
+ ProductID = "ssl",
+ ProductParameters = new Dictionary { ["ProductCode"] = "999999" }
+ };
+
+ Func act = () => plugin.ValidateProductInfo(productInfo, connectionInfo);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*not found*");
+ }
+
+ ///
+ /// ignores the constructor-injected
+ /// client/config and builds its own from connectionInfo, so integration tests
+ /// must pass a real dictionary — UseV2Api is a bool, not a string
+ /// (CERTInextCAPluginConfig.cs, CERTInextCAPlugin.cs's is bool check).
+ ///
+ private Dictionary BuildV2ConnectionInfo()
+ {
+ var info = new Dictionary
+ {
+ ["UseV2Api"] = true,
+ ["ApiUrl"] = _v2ApiUrl,
+ ["OAuthClientId"] = _v2ClientId,
+ ["OAuthClientSecret"] = _v2ClientSecret
+ };
+
+ string groupNumber = _fixture.IsConfigured ? _fixture.GroupNumber : null;
+ if (!string.IsNullOrWhiteSpace(groupNumber))
+ info["GroupNumber"] = groupNumber;
+
+ return info;
+ }
+
+ // ---------------------------------------------------------------------------
+ // GetSingleRecord via V2 (ResolveAndTrackOrderV2Async)
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Places a fresh DV SSL order then calls ResolveAndTrackOrderV2Async on the
+ /// returned orderId. Asserts that the order can be found and has a non-empty
+ /// status. The order will typically be issued, pending-approval or pending-dcv (the CSR is inline on create); that is fine.
+ /// Skips when CERTINEXT_USE_V2_API is not set.
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task GetSingleRecord_V2_ReturnsOrderDetails(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ using var client = BuildV2Client();
+
+ var orderReq = BuildStandardOrderRequest();
+ var createResp = await PlaceOrderTrackedAsync(client, orderReq, mode);
+
+ createResp.Should().NotBeNull();
+ string orderId = createResp.OrderId;
+ orderId.Should().NotBeNullOrEmpty("PlaceOrderV2Async must return a non-empty orderId");
+
+ var status = await client.ResolveAndTrackOrderV2Async(orderId);
+
+ status.Should().NotBeNull("ResolveAndTrackOrderV2Async must return a non-null status");
+ status.OrderId.Should().Be(orderId, "tracked order ID must match the placed order");
+ status.Status.Should().NotBeNullOrEmpty("TrackOrder must return a non-empty status string");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Revoke a known-issued V2 order
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Revokes a previously issued V2 order. Prefers CERTINEXT_V2_ISSUED_ORDER_ID;
+ /// otherwise self-enrolls a fresh order via
+ /// and polls (bounded) for issuance, so the test does not depend on another test's
+ /// run order to have a usable order ID.
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task Revoke_V2_IssuedOrder(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ using var client = BuildV2Client();
+ var (orderId, family) = await EnsureIssuedOrderIdAsync(client, mode);
+
+ // Revoke — sandbox may report 'issued' via track but reject revocation
+ // with 422 ("Certificate Request still being processed") while the order
+ // is still being processed internally.
+ var revokeReq = new V2RevokeRequest
+ {
+ Reason = "superseded",
+ Note = "V2 integration test cleanup"
+ };
+
+ try
+ {
+ await client.RevokeOrderV2Async(family, orderId, revokeReq);
+ }
+ catch (InvalidOperationException ex) when (ex.Message.Contains("still being processed"))
+ {
+ // Retry once after a short delay before giving up — any other exception
+ // (or a second failure) must fail the test rather than be swallowed here.
+ _output.WriteLine($"Revoke rejected as still-processing; retrying once after 15s: {ex.Message}");
+ await Task.Delay(TimeSpan.FromSeconds(15));
+ try
+ {
+ await client.RevokeOrderV2Async(family, orderId, revokeReq);
+ }
+ catch (InvalidOperationException ex2) when (ex2.Message.Contains("still being processed"))
+ {
+ Skip.If(true,
+ $"Order {orderId} tracked as 'issued' but CA rejected revocation twice (sandbox timing): {ex2.Message}");
+ return; // unreachable; satisfies compiler
+ }
+ }
+
+ // Re-track — must be revoked
+ var trackAfter = await client.ResolveAndTrackOrderV2Async(orderId);
+ trackAfter.Status.Should().Be(
+ Constants.ApiV2.StatusRevoked,
+ $"order {orderId} must be 'revoked' after revocation");
+ }
+
+ // ---------------------------------------------------------------------------
+ // DCV flow (publishes real Cloudflare TXT record) — requires SUPPORTS_DCV build
+ // ---------------------------------------------------------------------------
+
+#if SUPPORTS_DCV
+ ///
+ /// Places a DV SSL order, publishes the DCV TXT token via real Cloudflare DNS,
+ /// calls VerifyDcvV2Async, and polls until the order leaves pending-dcv.
+ /// Requires CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID in addition to
+ /// CERTINEXT_USE_V2_API. Skips if either is absent.
+ ///
+ /// CERTInext's domain DCV is account-scoped and reusable (BR 3.2.2.5): once
+ /// CERTINEXT_DCV_DOMAIN is verified once, it stays verified for the
+ /// validTill reuse window, and GetDcv/VerifyDcv return EMS-1080
+ /// ("Domain is already verified") instead of issuing a fresh challenge. That is
+ /// treated here as the reuse-path outcome, not a failure: the publish/verify
+ /// steps are skipped and the order is polled directly for leaving pending-dcv.
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task DcvFlow_V2_PublishesAndVerifies(V2PinMode mode)
+ {
+ Skip.If(!_dcvEnabled,
+ "DCV test requires CERTINEXT_USE_V2_API + CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID — skipping.");
+
+ using var client = BuildV2Client();
+ var dns = new CloudflareDomainValidator(_cfApiToken, _cfZoneId);
+ string txtKey = null;
+ string orderId = null;
+
+ var (domainVerifiedBeforeEnroll, rawStatus) = await V2DomainStatusHelper.GetDcvStatusAsync(client, _v2Domain);
+ _output.WriteLine($"Pre-enroll domain status for '{_v2Domain}': dcvStatus={rawStatus ?? ""}");
+
+ try
+ {
+ // 1. Place a DV SSL order — it lands in pending-dcv
+ var orderReq = BuildStandardOrderRequest();
+ var createResp = await PlaceOrderTrackedAsync(client, orderReq, mode);
+ orderId = createResp.OrderId;
+ orderId.Should().NotBeNullOrEmpty();
+
+ // 2. Get DCV challenge
+ V2DcvChallengeResponse dcvResp;
+ try
+ {
+ dcvResp = await client.GetDcvV2Async(orderId, Constants.ApiV2.FamilySsl);
+ }
+ catch (Exception ex) when (ex.Message.Contains("EMS-1080"))
+ {
+ // Reuse path: the domain is already verified account-wide, so there is no
+ // fresh challenge to publish. Prove the order still reaches a non-pending-dcv
+ // state without ever staging a TXT record.
+ _output.WriteLine($"GetDcv returned EMS-1080 (domain already verified) — reuse path: {ex.Message}");
+ _output.WriteLine($"(pre-enroll domain probe {(domainVerifiedBeforeEnroll ? "agreed: VERIFIED" : "did NOT show VERIFIED — status may have changed between the probe and this order")}.)");
+
+ V2OrderStatusResponse reuseStatus = null;
+ var reuseDeadline = DateTime.UtcNow.AddSeconds(30);
+ while (DateTime.UtcNow < reuseDeadline)
+ {
+ reuseStatus = await client.ResolveAndTrackOrderV2Async(orderId);
+ _output.WriteLine($"Poll (reuse path): orderId={orderId} status={reuseStatus.Status}");
+ if (reuseStatus.Status != Constants.ApiV2.StatusPendingDcv)
+ break;
+ await Task.Delay(TimeSpan.FromSeconds(5));
+ }
+
+ reuseStatus.Should().NotBeNull();
+ reuseStatus!.Status.Should().NotBe(
+ Constants.ApiV2.StatusPendingDcv,
+ $"order {orderId} must leave pending-dcv on a reused/already-verified domain (EMS-1080) " +
+ "without a fresh TXT challenge.");
+ return;
+ }
+ dcvResp.Should().NotBeNull();
+ dcvResp.Token.Should().NotBeNullOrEmpty(
+ "GetDcvV2Async must return a TXT token in Token");
+
+ // The live response has no domainName field — the domain is already known
+ // locally from the order-placement request.
+ string domainName = _v2Domain;
+
+ // 3. Publish TXT record
+ txtKey = $"_emudhra-challenge.{domainName}";
+ _output.WriteLine($"Publishing TXT {txtKey} = {dcvResp.Token}");
+ var staged = await dns.StageValidation(txtKey, dcvResp.Token, CancellationToken.None);
+ staged.Success.Should().BeTrue($"Cloudflare TXT record creation must succeed: {staged.ErrorMessage}");
+
+ // Brief propagation pause
+ await Task.Delay(TimeSpan.FromSeconds(5));
+
+ // 4. Ask CERTInext to verify
+ var verifyResp = await client.VerifyDcvV2Async(orderId, _v2Domain, Constants.ApiV2.FamilySsl);
+ verifyResp.Should().NotBeNull();
+ verifyResp.OverallStatus.Should().Be("VERIFIED",
+ "VerifyDcvV2Async must return OverallStatus=VERIFIED after DNS record is published");
+
+ // 5. Poll until order leaves pending-dcv (up to 60s)
+ V2OrderStatusResponse finalStatus = null;
+ var deadline = DateTime.UtcNow.AddSeconds(60);
+ while (DateTime.UtcNow < deadline)
+ {
+ finalStatus = await client.ResolveAndTrackOrderV2Async(orderId);
+ _output.WriteLine($"Poll: orderId={orderId} status={finalStatus.Status}");
+ if (finalStatus.Status != Constants.ApiV2.StatusPendingDcv)
+ break;
+ await Task.Delay(TimeSpan.FromSeconds(5));
+ }
+
+ finalStatus.Should().NotBeNull();
+ finalStatus!.Status.Should().NotBe(
+ Constants.ApiV2.StatusPendingDcv,
+ "order must leave pending-dcv after successful DCV verification");
+ }
+ finally
+ {
+ if (txtKey != null)
+ {
+ _output.WriteLine($"Cleaning up TXT record: {txtKey}");
+ await dns.CleanupValidation(txtKey, CancellationToken.None);
+ }
+ }
+ }
+#endif
+
+ // ---------------------------------------------------------------------------
+ // Chain PEM assembly
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Downloads the certificate for a known-issued V2 order and logs whether
+ /// ChainPem is populated. The test passes in either case — it is a
+ /// best-effort diagnostic to confirm chain assembly works in production.
+ /// Prefers CERTINEXT_V2_ISSUED_ORDER_ID; otherwise self-enrolls a fresh order
+ /// via .
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task ChainPem_V2_IsAssembled(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ using var client = BuildV2Client();
+ var (orderId, family) = await EnsureIssuedOrderIdAsync(client, mode);
+
+ V2CertificateDownloadResponse downloadResp;
+ try
+ {
+ downloadResp = await client.DownloadCertificateV2Async(family, orderId);
+ }
+ catch (Exception ex) when (ex.Message.Contains("422") || ex.Message.Contains("Invalid request status"))
+ {
+ Skip.If(true,
+ $"Order {orderId} tracked as 'issued' but CA rejected download (sandbox timing): {ex.Message}");
+ return; // unreachable; satisfies compiler
+ }
+
+ downloadResp.Should().NotBeNull("DownloadCertificateV2Async must return a non-null response");
+ downloadResp.CertificatePem.Should().NotBeNull(
+ "CertificatePem must be present for an issued order");
+ downloadResp.CertificatePem.Should().StartWith(
+ "-----BEGIN CERTIFICATE-----",
+ "leaf certificate must be PEM-encoded");
+
+ bool chainPresent = downloadResp.ChainPem != null && downloadResp.ChainPem.Count > 0;
+ _output.WriteLine(chainPresent
+ ? $"ChainPem: {downloadResp.ChainPem!.Count} intermediate(s) returned."
+ : "ChainPem: null or empty — sandbox may not return chain.");
+
+ if (chainPresent)
+ {
+ foreach (string chainCert in downloadResp.ChainPem!)
+ {
+ chainCert.Should().StartWith(
+ "-----BEGIN CERTIFICATE-----",
+ "each chain entry must be a PEM-encoded certificate");
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Private helpers
+ // ---------------------------------------------------------------------------
+
+ private V2CreateSslOrderRequest BuildStandardOrderRequest() =>
+ new V2CreateSslOrderRequest
+ {
+ ProductVariant = "dv",
+ EmailNotifications = "all",
+ Requestor = new V2Requestor
+ {
+ Name = _fixture.Config?.RequestorName ?? "Keyfactor Test",
+ Email = _fixture.Config?.RequestorEmail ?? "test@example.com",
+ Phone = "0000000000",
+ Designation = "IT Administrator"
+ },
+ Certificate = new V2CertificateParams
+ {
+ Domain = _v2Domain,
+ AutoSecureWww = false
+ },
+ Subscription = new V2SubscriptionParams
+ {
+ ValidityYears = 1,
+ AutoRenew = false,
+ RenewBeforeDays = 30
+ },
+ Agreement = new V2AgreementParams
+ {
+ SignerName = _fixture.Config?.RequestorName ?? "Keyfactor Test",
+ SignerPlace = "Gateway Lab",
+ Accepted = true
+ },
+ Remarks = "Keyfactor V2 integration test — safe to revoke immediately.",
+ // Single-call create: the CSR goes inline on POST (a later PUT .../csr is rejected 422),
+ // so these raw-client orders issue exactly as a plugin Enroll would.
+ Csr = GenerateCsrPem(_v2Domain)
+ };
+
+ // ---------------------------------------------------------------------------
+ // Single-call create helpers + cleanup of every order this class places
+ // ---------------------------------------------------------------------------
+
+ private readonly List _placedOrderIds = new List();
+
+ public Task InitializeAsync() => Task.CompletedTask;
+
+ ///
+ /// Revokes (if issued) or cancels (if not yet terminal) every order placed through
+ /// . One attempt each (one retry for the sandbox's
+ /// "still being processed" revoke rejection); failures are logged, never thrown, so cleanup can
+ /// not mask the test's own result — revoke/cancel by hand in the portal if one is reported.
+ ///
+ public async Task DisposeAsync()
+ {
+ if (_placedOrderIds.Count == 0) return;
+ using var client = BuildV2Client();
+ foreach (string orderId in _placedOrderIds)
+ {
+ try
+ {
+ var (family, status) = await ResolveOrderFamilyAsync(client, orderId);
+ string st = status.Status ?? string.Empty;
+ if (st == Constants.ApiV2.StatusRevoked || st == Constants.ApiV2.StatusCancelled
+ || st == Constants.ApiV2.StatusRejected)
+ {
+ _output.WriteLine($"Cleanup: order {orderId} already terminal ({st}).");
+ continue;
+ }
+
+ if (st == Constants.ApiV2.StatusIssued)
+ {
+ var revokeReq = new V2RevokeRequest { Reason = "superseded", Note = "V2 integration test cleanup" };
+ try { await client.RevokeOrderV2Async(family, orderId, revokeReq); }
+ catch (InvalidOperationException ex) when (ex.Message.Contains("still being processed"))
+ {
+ await Task.Delay(TimeSpan.FromSeconds(15));
+ await client.RevokeOrderV2Async(family, orderId, revokeReq);
+ }
+ var after = await client.ResolveAndTrackOrderV2Async(orderId);
+ _output.WriteLine($"Cleanup: revoked issued order {orderId}; re-tracked status={after.Status}.");
+ }
+ else
+ {
+ var outcome = await client.CancelOrderV2Async(family, orderId, "V2 integration test cleanup");
+ var after = await client.ResolveAndTrackOrderV2Async(orderId);
+ _output.WriteLine($"Cleanup: cancel of {orderId} (was {st}) -> {outcome}; re-tracked status={after.Status}.");
+ }
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"Cleanup FAILED for order {orderId}: {ex.GetType().Name}: {ex.Message}. " +
+ "Revoke/cancel it by hand in the CERTInext portal.");
+ }
+ }
+ }
+
+ ///
+ /// Places the standard DV SSL order in : pinned sends the product code
+ /// (X-Product-Code), the V2 template sends none (productCode null omits the header)
+ /// and lets CERTInext resolve the product. Logs the mode and the response's
+ /// ResolvedProductCode (the product CERTInext actually picked).
+ ///
+ private async Task PlaceOrderTrackedAsync(
+ CERTInextClient client, V2CreateSslOrderRequest req, V2PinMode mode)
+ {
+ V2ProductPinning.Log(_output, Constants.Products.DvSsl, mode);
+ V2CreateOrderResponse resp;
+ try
+ {
+ resp = await client.PlaceOrderV2Async(
+ Constants.ApiV2.FamilySsl, V2ProductPinning.CodeSent(Constants.Products.DvSsl, mode), req);
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"{V2ProductPinning.Label(Constants.Products.DvSsl, mode)} order create FAILED: " +
+ $"{ex.GetType().Name}: {ex.Message}");
+ throw;
+ }
+
+ if (!string.IsNullOrWhiteSpace(resp?.OrderId))
+ _placedOrderIds.Add(resp.OrderId);
+ V2ProductPinning.Log(_output, Constants.Products.DvSsl, mode,
+ resp?.ResolvedProductCode ?? "(not reported)");
+ return resp;
+ }
+
+ // BouncyCastle only (project crypto policy).
+ private static string GenerateCsrPem(string commonName)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
+ var csr = new Pkcs10CertificationRequest(
+ "SHA256withRSA", new X509Name($"CN={commonName}"), keyPair.Public, null, keyPair.Private);
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ private CERTInextClient BuildV2Client()
+ {
+ return new CERTInextClient(new CERTInextConfig
+ {
+ // A single ApiUrl serves V2 — no V1-only fields are set here.
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ OAuthClientId = _v2ClientId,
+ OAuthClientSecret = _v2ClientSecret,
+ RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Test",
+ RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com",
+ SignerIp = "127.0.0.1",
+ SignerPlace = "Gateway Lab",
+ PageSize = 100
+ });
+ }
+
+ ///
+ /// Returns an issued V2 order (and the family it lives in) to exercise. Prefers
+ /// CERTINEXT_V2_ISSUED_ORDER_ID if set; otherwise places a fresh order on
+ /// and polls (bounded) until it reaches issued, so
+ /// tests using this helper are self-contained and don't depend on env state or
+ /// another test's run order. Skip.Ifs when
+ /// no env ID is set and the freshly-placed order never reaches issued within
+ /// the poll budget — sandboxes may require DCV to auto-issue.
+ ///
+ private async Task<(string orderId, string family)> EnsureIssuedOrderIdAsync(CERTInextClient client, V2PinMode mode)
+ {
+ // With an env order the test does not place one, so only the pinned row is meaningful.
+ V2ProductPinning.SkipUnpinnedWhenOrderSuppliedByEnv(mode, _issuedOrderId, "CERTINEXT_V2_ISSUED_ORDER_ID");
+ if (!string.IsNullOrWhiteSpace(_issuedOrderId))
+ {
+ var (family, status) = await ResolveOrderFamilyAsync(client, _issuedOrderId);
+ Skip.If(status.Status != Constants.ApiV2.StatusIssued,
+ $"Order '{_issuedOrderId}' is in '{status.Status}' state, not 'issued' — skipping.");
+ return (_issuedOrderId, family);
+ }
+
+ var orderReq = BuildStandardOrderRequest();
+ var createResp = await PlaceOrderTrackedAsync(client, orderReq, mode);
+ createResp.Should().NotBeNull();
+ string orderId = createResp.OrderId;
+ orderId.Should().NotBeNullOrEmpty("PlaceOrderV2Async must return a non-empty orderId");
+ _output.WriteLine(
+ $"EnsureIssuedOrderIdAsync: no CERTINEXT_V2_ISSUED_ORDER_ID set — placed fresh order {orderId}.");
+
+ V2OrderStatusResponse trackResp = null;
+ var deadline = DateTime.UtcNow.AddSeconds(90);
+ while (DateTime.UtcNow < deadline)
+ {
+ trackResp = await client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, orderId);
+ _output.WriteLine($"EnsureIssuedOrderIdAsync poll: orderId={orderId} status={trackResp.Status}");
+ if (trackResp.Status == Constants.ApiV2.StatusIssued)
+ break;
+ await Task.Delay(TimeSpan.FromSeconds(15));
+ }
+
+ Skip.If(trackResp?.Status != Constants.ApiV2.StatusIssued,
+ $"Freshly-placed order '{orderId}' did not reach 'issued' within the poll budget " +
+ $"(status={trackResp?.Status}) — sandbox may require DCV to auto-issue. Set " +
+ "CERTINEXT_V2_ISSUED_ORDER_ID to a known-issued order to bypass placement.");
+
+ return (orderId, Constants.ApiV2.FamilySsl);
+ }
+
+ private static async Task<(string family, V2OrderStatusResponse status)> ResolveOrderFamilyAsync(
+ CERTInextClient client, string orderId)
+ {
+ foreach (var family in new[] { Constants.ApiV2.FamilySsl, Constants.ApiV2.FamilyPrivatePki, Constants.ApiV2.FamilySignature })
+ {
+ try
+ {
+ var s = await client.TrackOrderV2Async(family, orderId);
+ return (family, s);
+ }
+ catch (KeyNotFoundException)
+ {
+ // try next
+ }
+ }
+ throw new KeyNotFoundException($"Order '{orderId}' not found in any V2 product family.");
+ }
+
+ }
+}
diff --git a/CERTInext.IntegrationTests/V2DcvLifecycleTests.cs b/CERTInext.IntegrationTests/V2DcvLifecycleTests.cs
new file mode 100644
index 0000000..43aa9f3
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2DcvLifecycleTests.cs
@@ -0,0 +1,646 @@
+// 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.
+
+#if SUPPORTS_DCV
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Plugin-level DCV integration tests for the V2 (OAuth2) API path (gaps 5-8, 14-15).
+ /// Mirrors 's structure for V1: uses a real
+ /// when Cloudflare credentials are
+ /// configured, otherwise a .
+ ///
+ /// Requires the SUPPORTS_DCV build (-p:DcvSupport=true) because it uses
+ /// the v3.3-only constructor. Excluded from the
+ /// no-DCV build via the test project's <Compile Remove> item group.
+ ///
+ public class V2DcvLifecycleTests : IClassFixture, IDisposable
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private readonly List _toDispose = new List();
+
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly string _v2Domain;
+ private readonly bool _v2Enabled;
+ private readonly string _cfApiToken;
+ private readonly string _cfZoneId;
+ private readonly bool _dcvEnabled;
+
+ public V2DcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+
+ var env = V2EnvHelper.LoadAndPromote();
+
+ _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL");
+ _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID");
+ _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET");
+ _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com");
+ _cfApiToken = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_API_TOKEN");
+ _cfZoneId = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_ZONE_ID");
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+
+ _dcvEnabled = _v2Enabled
+ && !string.IsNullOrWhiteSpace(_cfApiToken)
+ && !string.IsNullOrWhiteSpace(_cfZoneId);
+ }
+
+ public void Dispose()
+ {
+ foreach (var d in _toDispose)
+ d.Dispose();
+ _toDispose.Clear();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private static string GenerateCsrPem(string commonName)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
+
+ var subject = new X509Name($"CN={commonName}");
+ var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private);
+
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ private static async Task> RunSyncAsync(
+ CERTInextCAPlugin plugin, DateTime? lastSync = null, bool fullSync = true)
+ {
+ var buffer = new BlockingCollection(boundedCapacity: 10_000);
+ var collected = new List();
+
+ var syncTask = Task.Run(async () =>
+ {
+ await plugin.Synchronize(buffer, lastSync: lastSync, fullSync: fullSync, cancelToken: CancellationToken.None);
+ if (!buffer.IsAddingCompleted)
+ buffer.CompleteAdding();
+ });
+
+ foreach (var record in buffer.GetConsumingEnumerable())
+ collected.Add(record);
+
+ await syncTask;
+ return collected;
+ }
+
+ private IDomainValidatorFactory BuildV2DnsFactory()
+ {
+ if (_dcvEnabled)
+ {
+ var factory = new CloudflareDomainValidatorFactory(_cfApiToken, _cfZoneId);
+ _toDispose.Add(factory);
+ return factory;
+ }
+ return new StubDomainValidatorFactory();
+ }
+
+ ///
+ /// Builds a wired for the V2 API. A single
+ /// serves both modes, and V2 auth reuses
+ /// /.
+ /// Deliberately omits every V1-only field (ApiKey/AccountNumber/AuthMode) — V1
+ /// credentials are optional when UseV2Api is true, including for Synchronize
+ /// (which uses V2 /reports/orders).
+ ///
+ private CERTInextConfig BuildV2Config(
+ bool dcvEnabled = true, int propagationDelaySeconds = 5, int? pageSize = null, int? syncLookbackHours = null)
+ {
+ return new CERTInextConfig
+ {
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ V2InlineCsr = V2EnvHelper.InlineCsrFromEnv, // default single-call; CERTINEXT_V2_INLINE_CSR=0 selects two-call
+ OAuthClientId = _v2ClientId,
+ OAuthClientSecret = _v2ClientSecret,
+
+ // Default 72h (Constants.ApiV2.DefaultSyncLookbackHours) is always added on top
+ // of lastSync — on a busy shared sandbox that makes an un-narrowed delta sync
+ // slow, since every issued row costs a live certificate download. Narrow via
+ // syncLookbackHours in tests that don't need the full margin.
+ V2SyncLookbackHours = syncLookbackHours ?? Constants.ApiV2.DefaultSyncLookbackHours,
+
+ RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com",
+ RequestorIsdCode = "1",
+ RequestorMobileNumber = "0000000000",
+ SignerPlace = "Gateway Lab",
+ SignerIp = "127.0.0.1",
+
+ PageSize = pageSize ?? 100,
+
+ DcvEnabled = dcvEnabled,
+ DcvPropagationDelaySeconds = propagationDelaySeconds,
+ DcvTimeoutMinutes = 3
+ };
+ }
+
+ ///
+ /// Builds a plugin wired for the V2 API with a real DNS factory injected via the
+ /// v3.3-only three-arg test constructor, so EnrollV2Async /
+ /// GetSingleRecordV2Async can drive DCV inline.
+ ///
+ private CERTInextCAPlugin BuildV2DcvPlugin(
+ bool dcvEnabled = true, int propagationDelaySeconds = 5, int? pageSize = null, int? syncLookbackHours = null)
+ {
+ var config = BuildV2Config(dcvEnabled, propagationDelaySeconds, pageSize, syncLookbackHours);
+ var client = new CERTInextClient(config);
+ return new CERTInextCAPlugin(client, BuildV2DnsFactory(), config);
+ }
+
+ ///
+ /// Product info for the "DV SSL" gateway template in : pinned
+ /// (ProductCode set, e.g. 842) or the unpinned "V2 template" (no ProductCode, so
+ /// CERTInext resolves the product). See .
+ ///
+ private static EnrollmentProductInfo BuildV2ProductInfo(V2PinMode mode) =>
+ V2ProductPinning.Info(Constants.Products.DvSsl, mode);
+
+ ///
+ /// Parses an issued certificate PEM and asserts its public key matches the requested
+ /// algorithm/size. Copy of the equivalent helper in .
+ ///
+ private static void AssertIssuedCertMatchesAlgorithm(string certPem, KeyAlgorithmSpec spec, string tag)
+ {
+ var b64 = certPem
+ .Replace("-----BEGIN CERTIFICATE-----", string.Empty)
+ .Replace("-----END CERTIFICATE-----", string.Empty)
+ .Replace("\r", string.Empty).Replace("\n", string.Empty).Trim();
+
+ var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64));
+ cert.Should().NotBeNull($"{tag}: issued cert PEM must parse");
+
+ var pub = cert.GetPublicKey();
+ switch (spec.Kind)
+ {
+ case KeyKind.Rsa:
+ pub.Should().BeOfType();
+ ((RsaKeyParameters)pub).Modulus.BitLength.Should().Be(spec.Strength,
+ $"{tag}: issued RSA cert must have a {spec.Strength}-bit modulus");
+ break;
+ case KeyKind.Ecdsa:
+ pub.Should().BeOfType();
+ ((ECPublicKeyParameters)pub).Parameters.Curve.FieldSize.Should().Be(spec.Strength,
+ $"{tag}: issued EC cert must use a {spec.Strength}-bit curve");
+ break;
+ case KeyKind.Ed25519:
+ pub.Should().BeOfType();
+ break;
+ case KeyKind.Ed448:
+ pub.Should().BeOfType();
+ break;
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Enroll with DCV on, V2 path, does not throw
+ // ---------------------------------------------------------------------------
+
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task DcvEnroll_V2_CompletesWithoutThrowing(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSsl, mode);
+
+ var config = BuildV2Config(dcvEnabled: true);
+ using var probeClient = new CERTInextClient(config);
+ var (domainVerified, rawStatus) = await V2DomainStatusHelper.GetDcvStatusAsync(probeClient, _v2Domain);
+ _output.WriteLine($"Pre-enroll domain status for '{_v2Domain}': dcvStatus={rawStatus ?? ""}");
+
+ var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory());
+ var plugin = new CERTInextCAPlugin(new CERTInextClient(config), recordingFactory, config);
+
+ var result = await plugin.Enroll(
+ csr: GenerateCsrPem(_v2Domain),
+ subject: $"CN={_v2Domain}",
+ san: new Dictionary { ["dns"] = new[] { _v2Domain } },
+ productInfo: BuildV2ProductInfo(mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Should().NotBeNull("Enroll must return a result even when DCV verification does not complete inline");
+ _output.WriteLine($"CARequestID: {result.CARequestID}");
+ _output.WriteLine($"Status: {result.Status}");
+ _output.WriteLine($"Message: {result.StatusMessage}");
+
+ var staged = recordingFactory.StagedCalls;
+ var cleaned = recordingFactory.CleanedUpFqdns;
+ _output.WriteLine($"DNS provider calls: staged={staged.Count}, cleaned={cleaned.Count}");
+
+ if (domainVerified)
+ {
+ // Reuse path: the domain is already verified account-wide, so no fresh TXT
+ // record should ever be staged for it.
+ staged.Should().BeEmpty(
+ $"domain '{_v2Domain}' was already VERIFIED before enrollment (reuse path) — no TXT record " +
+ "should be staged. The plugin must treat the CA's EMS-1080 'already verified' response as " +
+ "satisfied rather than as a failure requiring a deferred retry.");
+ new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED }
+ .Should().Contain(result.Status,
+ $"a reused, already-verified domain must let the order proceed to pending or issued; " +
+ $"got {result.Status}. Message: {result.StatusMessage}");
+ }
+ else
+ {
+ // Publish path: a fresh challenge must actually get staged and cleaned up.
+ staged.Should().NotBeEmpty(
+ $"domain '{_v2Domain}' was not yet VERIFIED (dcvStatus={rawStatus ?? ""}) — Enroll " +
+ "must stage a TXT record to exercise the publish path.");
+ cleaned.Should().NotBeEmpty(
+ "a staged DCV TXT record must be cleaned up after the publish-path attempt.");
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Enroll with DCV off, V2 path, does not invoke the DNS provider
+ // ---------------------------------------------------------------------------
+
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task EnrollWithoutDcv_V2_DoesNotInvokeDnsProvider(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSsl, mode);
+
+ var config = BuildV2Config(dcvEnabled: false);
+ var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory());
+ var plugin = new CERTInextCAPlugin(new CERTInextClient(config), recordingFactory, config);
+
+ var result = await plugin.Enroll(
+ csr: GenerateCsrPem(_v2Domain),
+ subject: $"CN={_v2Domain}",
+ san: new Dictionary { ["dns"] = new[] { _v2Domain } },
+ productInfo: BuildV2ProductInfo(mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Should().NotBeNull();
+ result.CARequestID.Should().NotBeNullOrWhiteSpace(
+ "the CA must accept the order even with DCV off — DCV-off must not block enrollment");
+
+ recordingFactory.StagedCalls.Should().BeEmpty(
+ "with DcvEnabled=false the plugin must never stage a DCV TXT record — this test's name promised " +
+ "that, but nothing previously checked it");
+ recordingFactory.CleanedUpFqdns.Should().BeEmpty(
+ "with DcvEnabled=false the plugin must never attempt DCV cleanup either");
+ }
+
+ // ---------------------------------------------------------------------------
+ // GetSingleRecord drives DCV for an existing pending V2 order
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task GetSingleRecord_V2_DrivesDcvForPendingOrder()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ string orderId = Environment.GetEnvironmentVariable("CERTINEXT_V2_PENDING_ORDER_ID");
+ Skip.If(string.IsNullOrWhiteSpace(orderId),
+ "Set CERTINEXT_V2_PENDING_ORDER_ID to a real pending-dcv V2 order to run this test.");
+ Skip.If(!_dcvEnabled,
+ "CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID must be set so the plugin can publish a real TXT record.");
+
+ // Route plugin/client logs to the test output: the V2 DCV path has many quiet early exits
+ // (no challenge, no validator, stage failure...) that are otherwise invisible from a test.
+ // Must be set before the plugin is constructed — its logger is created in the field initializer.
+ var restoreLogging = TestOutputLogging.RouteTo(_output);
+ AnyCAPluginCertificate record;
+ try
+ {
+ var plugin = BuildV2DcvPlugin(dcvEnabled: true);
+ record = await plugin.GetSingleRecord(orderId);
+ }
+ finally
+ {
+ restoreLogging();
+ }
+
+ record.Should().NotBeNull();
+ _output.WriteLine($"CARequestID: {record.CARequestID}");
+ _output.WriteLine($"Status: {record.Status}");
+
+ new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION }
+ .Should().Contain(record.Status,
+ "deferred-DCV retry should leave the V2 order in a valid pending or issued state");
+ }
+
+ // ---------------------------------------------------------------------------
+ // End-to-end DCV-on enrollment, issued cert appears in sync
+ // ---------------------------------------------------------------------------
+
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task EnrollWithDcvOn_V2_OrderIssuedEndToEnd_AndAppearsInSync(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSsl, mode);
+ Skip.If(!_dcvEnabled,
+ "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — DCV-on test must publish real TXT records.");
+
+ var config = BuildV2Config(dcvEnabled: true);
+ using var probeClient = new CERTInextClient(config);
+ var (domainVerified, rawStatus) = await V2DomainStatusHelper.GetDcvStatusAsync(probeClient, _v2Domain);
+ _output.WriteLine($"Pre-enroll domain status for '{_v2Domain}': dcvStatus={rawStatus ?? ""}");
+
+ var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory());
+ var plugin = new CERTInextCAPlugin(new CERTInextClient(config), recordingFactory, config);
+
+ var enrollResult = await plugin.Enroll(
+ csr: GenerateCsrPem(_v2Domain),
+ subject: $"CN={_v2Domain}",
+ san: new Dictionary { ["dns"] = new[] { _v2Domain } },
+ productInfo: BuildV2ProductInfo(mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ enrollResult.Should().NotBeNull();
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace();
+ _output.WriteLine($"Enroll CARequestID={enrollResult.CARequestID}, Status={enrollResult.Status}");
+
+ new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED }
+ .Should().Contain(enrollResult.Status,
+ $"DCV-on V2 Enroll must return pending or issued; got {enrollResult.Status}");
+
+ var staged = recordingFactory.StagedCalls;
+ var cleaned = recordingFactory.CleanedUpFqdns;
+ _output.WriteLine($"DNS provider calls: staged={staged.Count}, cleaned={cleaned.Count}");
+
+ if (domainVerified)
+ {
+ // Reuse path: no fresh TXT record should be staged for an already-verified
+ // domain.
+ staged.Should().BeEmpty(
+ $"domain '{_v2Domain}' was already VERIFIED before enrollment (reuse path) — no TXT record " +
+ "should be staged.");
+ }
+ else
+ {
+ staged.Should().NotBeEmpty(
+ $"domain '{_v2Domain}' was not yet VERIFIED (dcvStatus={rawStatus ?? ""}) — Enroll " +
+ "must stage a TXT record to exercise the publish path.");
+ cleaned.Should().NotBeEmpty(
+ "a staged DCV TXT record must be cleaned up after the publish-path attempt.");
+ }
+
+ // Delta sync — this sandbox account has 1000+ historical orders.
+ var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddDays(-1), fullSync: false);
+ var record = synced.FirstOrDefault(r => r.CARequestID == enrollResult.CARequestID);
+ record.Should().NotBeNull(
+ $"the enrolled V2 order ({enrollResult.CARequestID}) must appear in plugin.Synchronize results");
+
+ _output.WriteLine($"Synced record status: {record!.Status}");
+
+ if (record.Status == (int)EndEntityStatus.GENERATED)
+ {
+ record.Certificate.Should().NotBeNullOrWhiteSpace(
+ "Synchronize must populate the cert body for an issued V2 order (mirroring V1 behavior)");
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Key-algorithm issuance matrix, V2 path (opt-in)
+ // ---------------------------------------------------------------------------
+
+ [SkippableTheory]
+ [V2PinModesWithKeyAlgorithms]
+ public async Task EnrollWithDcvOn_V2_IssuesPerKeyAlgorithm(string tag, V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSsl, mode);
+ Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_ALGO_MATRIX") != "1",
+ "Opt-in: set CERTINEXT_V2_ALGO_MATRIX=1 to issue one real V2 cert per key algorithm.");
+ Skip.If(!_dcvEnabled,
+ "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — DCV issuance must publish real TXT records.");
+
+ var spec = KeyAlgorithms.For(tag);
+ string suffix = Guid.NewGuid().ToString("N").Substring(0, 8);
+ string cn = $"algo-{KeyAlgorithms.Slug(tag)}-{suffix}.{_v2Domain}";
+ string csr = KeyAlgorithms.GenerateCsrPem(cn, spec);
+
+ var plugin = BuildV2DcvPlugin(dcvEnabled: true);
+
+ EnrollmentResult enrollResult;
+ try
+ {
+ enrollResult = await plugin.Enroll(
+ csr: csr,
+ subject: $"CN={cn}",
+ san: new Dictionary { ["dns"] = new[] { cn } },
+ productInfo: BuildV2ProductInfo(mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ }
+ catch (Exception ex)
+ {
+ string reason = KeyAlgorithms.ClassifyRejection(ex.Message);
+ _output.WriteLine($"[SKIP] {tag}: {reason} — {ex.Message}");
+ Skip.If(true, $"CERTInext did not issue a {tag} V2 cert: {reason}. CA message: {ex.Message}");
+ return; // unreachable
+ }
+
+ enrollResult.Should().NotBeNull();
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace($"{tag}: CA must return a CARequestID when it accepts the order");
+ _output.WriteLine($"[{tag}] enrolled cn={cn} id={enrollResult.CARequestID} status={enrollResult.Status}");
+
+ const int maxPolls = 6;
+ const int delaySeconds = 15;
+ AnyCAPluginCertificate record = null;
+ for (int poll = 1; poll <= maxPolls; poll++)
+ {
+ record = await plugin.GetSingleRecord(enrollResult.CARequestID);
+ int status = record?.Status ?? -1;
+ _output.WriteLine($"[{tag}] poll #{poll}: status={status} certLen={record?.Certificate?.Length ?? 0}");
+
+ if (status == (int)EndEntityStatus.GENERATED && !string.IsNullOrWhiteSpace(record?.Certificate))
+ break;
+ if (status == (int)EndEntityStatus.FAILED)
+ {
+ Skip.If(true, $"CERTInext FAILED the {tag} V2 order — algorithm not issuable on this account/profile.");
+ return; // unreachable
+ }
+ if (poll < maxPolls)
+ await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
+ }
+
+ record.Should().NotBeNull($"{tag}: enrolled order {enrollResult.CARequestID} must be retrievable");
+ if (record!.Status != (int)EndEntityStatus.GENERATED)
+ {
+ Skip.If(true, $"CERTInext accepted the {tag} V2 order but it did not reach GENERATED within the polling window " +
+ $"(Status={record.Status}).");
+ return; // unreachable
+ }
+
+ record.Certificate.Should().NotBeNullOrWhiteSpace($"{tag}: issued V2 cert must carry a PEM body");
+ AssertIssuedCertMatchesAlgorithm(record.Certificate, spec, tag);
+ _output.WriteLine($"--- {tag}: V2 DCV-on issuance OK — order {enrollResult.CARequestID} GENERATED. ---");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Bulk V2 enrollment + pagination smoke test (opt-in)
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task BulkV2Enrollment_AllOrdersIssue_AndPaginationWorks()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_RUN_BULK_TEST") != "1",
+ "Opt-in: set CERTINEXT_V2_RUN_BULK_TEST=1 to run the V2 volume/pagination test.");
+ Skip.If(!_dcvEnabled,
+ "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — bulk test must publish real TXT records.");
+
+ int count = int.TryParse(Environment.GetEnvironmentVariable("CERTINEXT_V2_BULK_TEST_COUNT"), out int c) ? c : 101;
+ int parallel = int.TryParse(Environment.GetEnvironmentVariable("CERTINEXT_V2_BULK_TEST_PARALLEL"), out int p) ? p : 5;
+
+ // PageSize=100 ensures the 101st order forces a second page during Synchronize.
+ // Since this plugin is UseV2Api=true, Synchronize pages through V2
+ // /reports/orders (ListOrdersV2Async) rather than V1 GetOrderReport —
+ // this is the live pagination proof for that path, not just the WireMock-based
+ // client unit tests.
+ // syncLookbackHours narrowed to 2h: the default 72h margin would otherwise re-download
+ // every issued cert in a multi-day window on EACH of the (up to 8) sync passes below,
+ // compounded by the retry loop.
+ // Volume/pagination test (101 orders): deliberately NOT doubled by pin mode. Product resolution is
+ // not what it exercises, and a second pass would place another 101 sandbox orders. Runs pinned.
+ var plugin = BuildV2DcvPlugin(dcvEnabled: true, propagationDelaySeconds: 5, pageSize: 100, syncLookbackHours: 2);
+
+ var enrolled = new ConcurrentBag<(int idx, string cn, EnrollmentResult result)>();
+ var failures = new ConcurrentBag<(int idx, string error)>();
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+
+ using (var sem = new SemaphoreSlim(parallel, parallel))
+ {
+ var tasks = Enumerable.Range(0, count).Select(async i =>
+ {
+ await sem.WaitAsync();
+ try
+ {
+ string suffix = Guid.NewGuid().ToString("N").Substring(0, 8);
+ string cn = $"v2bulk-{suffix}.{_v2Domain}";
+ string csr = GenerateCsrPem(cn);
+
+ var result = await plugin.Enroll(
+ csr: csr,
+ subject: $"CN={cn}",
+ san: new Dictionary { ["dns"] = new[] { cn } },
+ productInfo: BuildV2ProductInfo(V2PinMode.Pinned),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ enrolled.Add((i, cn, result));
+ _output.WriteLine($"[{i:000}] OK cn={cn} id={result.CARequestID} status={result.Status}");
+ }
+ catch (Exception ex)
+ {
+ failures.Add((i, ex.Message));
+ _output.WriteLine($"[{i:000}] FAIL {ex.GetType().Name}: {ex.Message}");
+ }
+ finally
+ {
+ sem.Release();
+ }
+ });
+ await Task.WhenAll(tasks);
+ }
+
+ sw.Stop();
+ _output.WriteLine($"--- Enroll phase: enrolled={enrolled.Count}, failed={failures.Count}, elapsed={sw.Elapsed:mm\\:ss} ---");
+
+ failures.Should().BeEmpty($"every V2 Enroll() call must succeed; got {failures.Count} hard failures.");
+ enrolled.Count.Should().Be(count, $"expected {count} successful V2 Enroll() calls");
+
+ var enrolledIds = enrolled
+ .Where(e => !string.IsNullOrEmpty(e.result.CARequestID))
+ .Select(e => e.result.CARequestID)
+ .ToHashSet();
+ enrolledIds.Count.Should().Be(count, "every V2 enrollment must return a CARequestID");
+
+ const int maxSyncPasses = 8;
+ const int delayBetweenPassesSeconds = 30;
+
+ List synced = null;
+ int passesUsed = 0;
+
+ for (int pass = 1; pass <= maxSyncPasses; pass++)
+ {
+ passesUsed = pass;
+ synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddDays(-1), fullSync: false);
+
+ int generated = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.GENERATED);
+ int failed = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED);
+ int pending = enrolledIds.Count - generated - failed;
+
+ _output.WriteLine($"--- Sync pass #{pass}: {generated}/{enrolledIds.Count} GENERATED, {failed} FAILED, {pending} pending ---");
+
+ if (failed > 0)
+ {
+ var failedIds = synced
+ .Where(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED)
+ .Select(r => r.CARequestID)
+ .Take(5);
+ Assert.Fail($"Pass #{pass}: {failed} V2 order(s) reached FAILED status: {string.Join(", ", failedIds)}");
+ }
+
+ if (pending == 0)
+ break;
+
+ if (pass < maxSyncPasses)
+ await Task.Delay(TimeSpan.FromSeconds(delayBetweenPassesSeconds));
+ }
+
+ var syncedIds = synced!.Select(r => r.CARequestID).ToHashSet();
+ var missing = enrolledIds.Where(id => !syncedIds.Contains(id)).ToList();
+ missing.Should().BeEmpty(
+ $"{missing.Count} enrolled V2 orders did not appear in sync results: {string.Join(", ", missing.Take(5))}");
+
+ var lookup = synced!.Where(r => r.CARequestID != null).ToDictionary(r => r.CARequestID, r => r);
+ var notIssued = enrolledIds
+ .Where(id => lookup.TryGetValue(id, out var rec) && rec.Status != (int)EndEntityStatus.GENERATED)
+ .Select(id => lookup[id])
+ .ToList();
+
+ notIssued.Should().BeEmpty(
+ $"every enrolled V2 order should auto-issue after {maxSyncPasses} sync passes; {notIssued.Count} did not.");
+
+ _output.WriteLine($"--- SUCCESS: {count}/{count} V2 orders enrolled and issued in {passesUsed} sync pass(es). ---");
+ }
+ }
+}
+#endif
diff --git a/CERTInext.IntegrationTests/V2DomainStatusHelper.cs b/CERTInext.IntegrationTests/V2DomainStatusHelper.cs
new file mode 100644
index 0000000..9340e6b
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2DomainStatusHelper.cs
@@ -0,0 +1,58 @@
+// 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.Text.Json;
+using System.Threading.Tasks;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Read-only helper for querying GET /api/certinext/v2/domains?search=&exactMatch=true
+ /// via the existing escape hatch, so DCV-on V2
+ /// tests can tell the reuse path (domain already VERIFIED) apart from the publish path
+ /// before asserting what the DNS provider spy should have recorded.
+ ///
+ internal static class V2DomainStatusHelper
+ {
+ ///
+ /// Returns whether currently has dcvStatus=VERIFIED, plus
+ /// the raw dcvStatus string (null if the domain has no row at all, e.g. never
+ /// submitted on any order yet).
+ ///
+ public static async Task<(bool IsVerified, string RawStatus)> GetDcvStatusAsync(
+ CERTInextClient client, string domain)
+ {
+ string query = $"/api/certinext/v2/domains?search={Uri.EscapeDataString(domain)}&exactMatch=true";
+ var (statusCode, _, content) = await client.ProbeV2GetAsync(query);
+
+ // A failed lookup must not masquerade as "not verified" — that would steer the caller
+ // into asserting the publish path for the wrong reason.
+ if (statusCode != 200 || string.IsNullOrWhiteSpace(content))
+ throw new InvalidOperationException(
+ $"GET /domains lookup for '{domain}' failed: HTTP {statusCode}; cannot tell reuse path from publish path.");
+
+ using var doc = JsonDocument.Parse(content);
+ if (!doc.RootElement.TryGetProperty("content", out var arr)
+ || arr.ValueKind != JsonValueKind.Array
+ || arr.GetArrayLength() == 0)
+ return (false, null);
+
+ var row = arr[0];
+ string dcvStatus = row.TryGetProperty("dcvStatus", out var v) ? v.GetString() : null;
+ return (string.Equals(dcvStatus, "VERIFIED", StringComparison.OrdinalIgnoreCase), dcvStatus);
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/V2FreshDomainDcvLifecycleTests.cs b/CERTInext.IntegrationTests/V2FreshDomainDcvLifecycleTests.cs
new file mode 100644
index 0000000..369c03f
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2FreshDomainDcvLifecycleTests.cs
@@ -0,0 +1,388 @@
+// 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.
+//
+// V2 release-candidate readiness: DCV against a FRESH, never-before-seen domain. Every DCV
+// test in V2DcvLifecycleTests.cs targets CERTINEXT_DCV_DOMAIN, which this sandbox account has
+// reused across dozens of prior test runs and is therefore typically already VERIFIED
+// account-wide — so those tests take the reuse path (staged=0) and never actually exercise
+// the TXT publish/verify/cleanup path. This file's test targets a freshly-generated subdomain
+// instead, so a real TXT challenge must be staged and cleaned up (staged>0).
+
+#if SUPPORTS_DCV
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ public class V2FreshDomainDcvLifecycleTests : IClassFixture, IDisposable
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private readonly List _toDispose = new List();
+
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly string _v2Domain;
+ private readonly string _freshDcvParent;
+ private readonly bool _v2Enabled;
+ private readonly string _cfApiToken;
+ private readonly string _cfZoneId;
+ private readonly bool _dcvEnabled;
+
+ public V2FreshDomainDcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+
+ var env = V2EnvHelper.LoadAndPromote();
+
+ _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL");
+ _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID");
+ _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET");
+ _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com");
+ _cfApiToken = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_API_TOKEN");
+ _cfZoneId = V2EnvHelper.GetEnv(env, "CERTINEXT_CF_ZONE_ID");
+
+ // A fresh subdomain of CERTINEXT_DCV_DOMAIN is NOT genuinely unverified — this
+ // sandbox account has already completed DCV for CERTINEXT_DCV_DOMAIN itself, and
+ // CERTInext (like most DCV implementations) treats that as covering every
+ // subdomain beneath it. A dcv-fresh- name built under CERTINEXT_DCV_DOMAIN
+ // therefore never actually exercises the publish path (staged stays 0) — it is
+ // simply inheriting the parent's prior verification. A sibling domain under a
+ // DIFFERENT, still-unverified parent is required instead. Defaults to
+ // CERTINEXT_DCV_DOMAIN with its first label stripped (e.g.
+ // "dcv-test.scrup.org" -> "scrup.org"), which this sandbox account has never
+ // itself completed DCV against.
+ _freshDcvParent = V2EnvHelper.GetEnv(env, "CERTINEXT_V2_FRESH_DCV_PARENT", DeriveDefaultFreshDcvParent(_v2Domain));
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+
+ _dcvEnabled = _v2Enabled
+ && !string.IsNullOrWhiteSpace(_cfApiToken)
+ && !string.IsNullOrWhiteSpace(_cfZoneId);
+ }
+
+ ///
+ /// Strips the first DNS label from (e.g.
+ /// "dcv-test.scrup.org" -> "scrup.org") to derive a default value for
+ /// CERTINEXT_V2_FRESH_DCV_PARENT when it is unset — a sibling built under this
+ /// parent is not covered by the DCV domain's own prior verification. Falls back to the
+ /// input unchanged if it has no "." to strip.
+ ///
+ private static string DeriveDefaultFreshDcvParent(string domain)
+ {
+ if (string.IsNullOrWhiteSpace(domain)) return domain;
+ int dot = domain.IndexOf('.');
+ return dot >= 0 && dot < domain.Length - 1 ? domain.Substring(dot + 1) : domain;
+ }
+
+ public void Dispose()
+ {
+ foreach (var d in _toDispose)
+ d.Dispose();
+ _toDispose.Clear();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private static string GenerateCsrPem(string commonName)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
+
+ var subject = new X509Name($"CN={commonName}");
+ var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private);
+
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ private IDomainValidatorFactory BuildV2DnsFactory()
+ {
+ if (_dcvEnabled)
+ {
+ var factory = new CloudflareDomainValidatorFactory(_cfApiToken, _cfZoneId);
+ _toDispose.Add(factory);
+ return factory;
+ }
+ return new StubDomainValidatorFactory();
+ }
+
+ private CERTInextConfig BuildV2Config()
+ {
+ return new CERTInextConfig
+ {
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ V2InlineCsr = V2EnvHelper.InlineCsrFromEnv, // default single-call; CERTINEXT_V2_INLINE_CSR=0 selects two-call
+ DefaultProductCode = Environment.GetEnvironmentVariable("CERTINEXT_PRODUCT_CODE") ?? "842",
+ OAuthClientId = _v2ClientId,
+ OAuthClientSecret = _v2ClientSecret,
+
+ V2SyncLookbackHours = 1,
+
+ RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com",
+ RequestorIsdCode = "1",
+ RequestorMobileNumber = "0000000000",
+ SignerPlace = "Gateway Lab",
+ SignerIp = "127.0.0.1",
+
+ PageSize = 100,
+
+ DcvEnabled = true,
+ DcvPropagationDelaySeconds = 5,
+ DcvTimeoutMinutes = 3
+ };
+ }
+
+ ///
+ /// Revoke-if-issued / cancel-otherwise cleanup keyed on the order's V2 status (V2OrderCleanup) —
+ /// single attempt only, never retries a cancel, logs rather than
+ /// throws so a cleanup problem never masks the test's own assertion result. Returns
+ /// whether cleanup completed without throwing (true = revoked or cancelled successfully,
+ /// or nothing to do), so a caller that wants to assert "nothing leaks" — e.g. the
+ /// wildcard fresh-subdomain test below — has something other than log text to check.
+ ///
+ private async System.Threading.Tasks.Task CleanupOrderAsync(CERTInextCAPlugin plugin, string orderId)
+ {
+ // Keyed on the order's V2 status (see V2OrderCleanup): GetSingleRecord can still report an
+ // already-issued order as pending, and cancelling it leaks an issued certificate.
+ using var client = new CERTInextClient(BuildV2Config());
+ return await V2OrderCleanup.RevokeOrCancelAsync(
+ client, orderId, "V2 fresh-domain DCV test cleanup.", _output.WriteLine);
+ }
+
+ // ---------------------------------------------------------------------------
+ // 7. DCV against a fresh, never-before-verified subdomain
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Enrolls a DV order for a freshly-generated subdomain of a genuinely unverified parent
+ /// (CERTINEXT_V2_FRESH_DCV_PARENT, NOT a subdomain of CERTINEXT_DCV_DOMAIN itself
+ /// — that domain's own prior DCV covers every subdomain beneath it, so a
+ /// dcv-fresh-<ts>.CERTINEXT_DCV_DOMAIN name never actually exercises the publish
+ /// path), with DcvEnabled=true and a real Cloudflare-backed
+ /// wrapped in .
+ /// Because the domain is guaranteed unseen, this is the one DCV test in the V2 suite that
+ /// actually exercises the publish path: every existing V2DcvLifecycleTests case targets
+ /// the long-reused CERTINEXT_DCV_DOMAIN, which this account has verified account-wide, so
+ /// those always take the reuse path and observe staged=0. Asserts staged>0
+ /// and cleaned==staged.
+ /// Expected sandbox order count: 1.
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async System.Threading.Tasks.Task EnrollWithDcvOn_V2_FreshUnverifiedSubdomain_StagesAndCleansUpTxt(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSsl, mode);
+ Skip.If(!_dcvEnabled,
+ "CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID must be set so the plugin can publish a real TXT record.");
+ Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_FRESH_DCV") != "1",
+ "CERTINEXT_V2_LIFECYCLE_FRESH_DCV=1 not set — this places a real sandbox order and publishes a live DNS TXT record. Skipping.");
+
+ string freshDomain = $"dcv-fresh-{DateTime.UtcNow:yyyyMMddHHmmssfff}.{_freshDcvParent}";
+
+ var config = BuildV2Config();
+ var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory());
+ var plugin = new CERTInextCAPlugin(new CERTInextClient(config), recordingFactory, config);
+
+ string orderId = null;
+ try
+ {
+ var result = await plugin.Enroll(
+ csr: GenerateCsrPem(freshDomain),
+ subject: $"CN={freshDomain}",
+ san: new Dictionary { ["dns"] = new[] { freshDomain } },
+ productInfo: V2ProductPinning.Info(Constants.Products.DvSsl, mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Should().NotBeNull();
+ result.CARequestID.Should().NotBeNullOrWhiteSpace("Enroll must return a CARequestID even if DCV verification does not complete inline");
+ orderId = result.CARequestID;
+ _output.WriteLine($"Fresh-domain order {orderId} for '{freshDomain}' (parent={_freshDcvParent}): Status={result.Status}, Message={result.StatusMessage}");
+
+ var staged = recordingFactory.StagedCalls;
+ var cleaned = recordingFactory.CleanedUpFqdns;
+ _output.WriteLine($"DNS provider calls: staged={staged.Count}, cleaned={cleaned.Count}");
+
+ // Expected behavior is staged>0 (see class-level remarks). The CA may instead
+ // treat the fresh subdomain's parent as already covering it and issue
+ // immediately with zero TXT records staged — in which case the TXT
+ // publish/verify path was never exercised and the assertions below cannot be
+ // meaningfully evaluated. Skip rather than fail; the finally below still runs
+ // cleanup regardless of this skip.
+ Skip.If(staged.Count == 0,
+ $"blocked by sandbox: CA treated {freshDomain} as pre-validated; TXT publish/verify path not exercised");
+
+ staged.Should().NotBeEmpty(
+ $"domain '{freshDomain}' is freshly generated under a genuinely unverified parent " +
+ "and cannot already be VERIFIED on this account — unlike every pre-existing " +
+ "V2DcvLifecycleTests case (which targets the long-reused CERTINEXT_DCV_DOMAIN and " +
+ "always takes the reuse path with staged=0), Enroll must actually stage " +
+ "a TXT record here.");
+ cleaned.Count.Should().Be(staged.Count,
+ "every staged DCV TXT record for a fresh domain must be cleaned up after the attempt.");
+
+ new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED }
+ .Should().Contain(result.Status,
+ $"DCV-on Enroll for a fresh domain must return pending or issued; got {result.Status}. Message: {result.StatusMessage}");
+ }
+ finally
+ {
+ await CleanupOrderAsync(plugin, orderId);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // 8. Wildcard DV DCV against a fresh, never-before-verified subdomain
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Wildcard-only CSR shape (CN = SAN = the wildcard) on a freshly-generated,
+ /// never-before-seen subdomain of a genuinely unverified parent
+ /// (CERTINEXT_V2_FRESH_DCV_PARENT, same freshness rationale as
+ /// above),
+ /// for . Asserts the staged TXT hostname
+ /// does NOT contain a literal '*' (a wildcard's "*." label is not a queryable DNS name —
+ /// see the base-domain hostname fix). DOES fail if the order ends FAILED. If the order
+ /// is still at EXTERNALVALIDATION (pending DCV) when this test's wait elapses, wildcard
+ /// DCV completion was never actually exercised, so the test Skips with a "blocked by
+ /// sandbox" message rather than claiming wildcard DCV works — cleanup (cancel) still
+ /// runs regardless. Also records what Track Order's
+ /// verifications.domain.domains[].domain echoes back for the same order, and
+ /// whether any domain entry reached VERIFIED within the wait. Cleanup (revoke-if-issued
+ /// or cancel) must succeed regardless of outcome, so this probe never leaks a live order.
+ /// Expected sandbox order count: 1.
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async System.Threading.Tasks.Task EnrollWithDcvOn_V2_WildcardFreshSubdomain_RecordsTxtHostnameAndCleansUp(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSslWildcard, mode);
+ Skip.If(!_dcvEnabled,
+ "CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID must be set so the plugin can publish a real TXT record.");
+ Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_FRESH_DCV") != "1",
+ "CERTINEXT_V2_LIFECYCLE_FRESH_DCV=1 not set — this places a real sandbox order and publishes a live DNS TXT record. Skipping.");
+
+ string freshSubdomain = $"dcv-fresh-{DateTime.UtcNow:yyyyMMddHHmmssfff}.{_freshDcvParent}";
+ string wildcard = $"*.{freshSubdomain}";
+
+ var config = BuildV2Config();
+ var recordingFactory = new RecordingDomainValidatorFactory(BuildV2DnsFactory());
+ var client = new CERTInextClient(config);
+ var plugin = new CERTInextCAPlugin(client, recordingFactory, config);
+
+ string orderId = null;
+ try
+ {
+ var result = await plugin.Enroll(
+ csr: GenerateCsrPem(wildcard),
+ subject: $"CN={wildcard}",
+ san: new Dictionary { ["dns"] = new[] { wildcard } },
+ productInfo: V2ProductPinning.Info(Constants.Products.DvSslWildcard, mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Should().NotBeNull();
+ _output.WriteLine($"Wildcard fresh-subdomain order ({wildcard}, parent={_freshDcvParent}): Status={result.Status}, Message={result.StatusMessage}");
+
+ if (!string.IsNullOrWhiteSpace(result.CARequestID))
+ orderId = result.CARequestID;
+
+ // Don't fail solely on CA verification timing (EXTERNALVALIDATION is fine) —
+ // but a FAILED order (CERTInext rejected/cancelled it) is a real problem, not a
+ // timing artifact.
+ result.Status.Should().NotBe((int)EndEntityStatus.FAILED,
+ $"the order must not end FAILED; Message: {result.StatusMessage}");
+
+ // Ending at EXTERNALVALIDATION means DCV never actually completed within this
+ // test's wait — the wildcard DCV path was not exercised to issuance, so this test
+ // cannot claim wildcard DCV works. Skip rather than pass silently; cleanup
+ // (cancel) still runs in the finally below regardless of this skip.
+ Skip.If(result.Status == (int)EndEntityStatus.EXTERNALVALIDATION,
+ $"blocked by sandbox: wildcard order for '{wildcard}' ended at pending-approval (EXTERNALVALIDATION); wildcard DCV completion not exercised");
+
+ var staged = recordingFactory.StagedCalls;
+ var cleaned = recordingFactory.CleanedUpFqdns;
+ _output.WriteLine($"DNS provider calls: staged={staged.Count}, cleaned={cleaned.Count}");
+ foreach (var call in staged)
+ _output.WriteLine($"Staged TXT hostname: Fqdn='{call.Fqdn}'.");
+ foreach (var fqdn in cleaned)
+ _output.WriteLine($"Cleaned-up TXT hostname: Fqdn='{fqdn}'.");
+
+ staged.Should().OnlyContain(call => call.Fqdn == null || !call.Fqdn.Contains('*'),
+ "a literal '*' DNS label is not queryable by the CA and must never be staged — " +
+ "see the wildcard base-domain hostname fix.");
+
+ if (!string.IsNullOrWhiteSpace(orderId))
+ {
+ try
+ {
+ var tracked = await client.ResolveAndTrackOrderV2Async(orderId);
+ var domainEntries = tracked?.Verifications?.Domain?.Domains;
+ if (domainEntries != null && domainEntries.Count > 0)
+ {
+ foreach (var entry in domainEntries)
+ _output.WriteLine(
+ $"Track Order verifications.domain.domains[]: domain='{entry.Domain}', dcvStatus={entry.DcvStatus ?? ""}.");
+
+ bool anyVerified = domainEntries.Any(e =>
+ string.Equals(e.DcvStatus, "VERIFIED", StringComparison.OrdinalIgnoreCase));
+ _output.WriteLine(anyVerified
+ ? "At least one domain entry reached VERIFIED within this test's wait — the CA " +
+ "accepted a base-domain TXT record for a wildcard domain entry."
+ : "No domain entry reached VERIFIED within this test's wait (CA-side timing, or " +
+ "the base-domain TXT record is not accepted for a wildcard domain entry — still " +
+ "UNVERIFIED; this is not asserted as a test failure).");
+ }
+ else
+ {
+ _output.WriteLine("Track Order returned no verifications.domain.domains[] entries for this order.");
+ }
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"Track Order (for verifications detail) FAILED: {ex.GetType().Name}: {ex.Message}.");
+ }
+ }
+ }
+ finally
+ {
+ bool cleanedUp = await CleanupOrderAsync(plugin, orderId);
+ cleanedUp.Should().BeTrue(
+ "cleanup (revoke-if-issued or cancel) must succeed so this wildcard fresh-subdomain probe " +
+ "never leaves a live order on the sandbox, regardless of what the TXT-hostname/Track-Order " +
+ "observations above turn out to show — see the 'Cleanup FAILED' output above if this fails.");
+ }
+ }
+ }
+}
+#endif
diff --git a/CERTInext.IntegrationTests/V2FullLifecycleTests.cs b/CERTInext.IntegrationTests/V2FullLifecycleTests.cs
new file mode 100644
index 0000000..a841ec5
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2FullLifecycleTests.cs
@@ -0,0 +1,947 @@
+// 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.
+//
+// V2 release-candidate readiness: assertion-bearing live lifecycle coverage for the product
+// families/shapes the existing V2 suite (V2LifecycleTests/V2ApiTests/V2DcvLifecycleTests)
+// never exercised end to end — DV UCC, OV, OV UCC, EV, wildcard DV, and renew/reissue. Each
+// test is gated by its own CERTINEXT_V2_LIFECYCLE_=1 flag (never
+// promoted from ~/.env_certinext_v2 — see IntegrationTestFixture._optInOnlyFlags), places real
+// sandbox orders, and always cleans up (revoke if issued, cancel otherwise) via
+// CleanupOrderAsync in a try/finally. Fresh-domain DCV coverage lives in
+// V2FreshDomainDcvLifecycleTests.cs (requires the SUPPORTS_DCV build).
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Plugin-level V2 lifecycle tests for product shapes/flows the pre-existing V2 suite did
+ /// not cover with an assertion-bearing live test (readiness audit, 2026-10-01): DV UCC, OV,
+ /// OV UCC, EV, wildcard DV (both CSR shapes), and renew/reissue of an issued DV order.
+ ///
+ public class V2FullLifecycleTests : IClassFixture
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly string _v2Domain;
+ private readonly bool _v2Enabled;
+
+ ///
+ /// 300s target for OV/EV order-creation calls (CA latency for these product types can
+ /// be significant). NOT actually enforceable at this (plugin-level) layer:
+ /// CERTInextClient's V2 RestClient hard-codes Timeout = TimeSpan.FromSeconds(120)
+ /// (CERTInextClient.cs, both the V1 and V2 RestClientOptions blocks) with no
+ /// CERTInextConfig override to raise it. OV/EV tests below catch a client-side timeout
+ /// distinctly from a CA-side rejection and record it rather than assert past it — see
+ /// IsClientTimeout below. This is a known production gap: the client-side timeout can
+ /// trip before the CA itself would reject or accept the order.
+ ///
+ private static readonly TimeSpan OvEvCreateTimeoutTarget = TimeSpan.FromSeconds(300);
+
+ public V2FullLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+
+ var env = V2EnvHelper.LoadAndPromote();
+
+ _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL");
+ _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID");
+ _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET");
+ _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com");
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private static string Timestamp() => DateTime.UtcNow.ToString("yyyyMMddHHmmssfff");
+
+ private static string GenerateCsrPem(string commonName) => GenerateCsrPem(commonName, ouTag: null);
+
+ ///
+ /// , when supplied, is folded into the CSR subject as an OU —
+ /// e.g. ov- for the OV/OV-UCC orphan-sweep probes below. The orders report
+ /// () does not surface OU anywhere, so this
+ /// tag is NOT how an orphan is actually located (that's domain + creation-time window —
+ /// see ); it exists only so a human reviewing
+ /// the order in the CERTInext portal or a raw CSR dump can see which test run placed it.
+ ///
+ private static string GenerateCsrPem(string commonName, string ouTag)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
+
+ string subjectDn = string.IsNullOrWhiteSpace(ouTag) ? $"CN={commonName}" : $"CN={commonName},OU={ouTag}";
+ var subject = new X509Name(subjectDn);
+ var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private);
+
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ private static async Task> RunSyncAsync(
+ CERTInextCAPlugin plugin, DateTime? lastSync = null, bool fullSync = true)
+ {
+ var buffer = new BlockingCollection(boundedCapacity: 10_000);
+ var collected = new List();
+
+ var syncTask = Task.Run(async () =>
+ {
+ await plugin.Synchronize(buffer, lastSync: lastSync, fullSync: fullSync, cancelToken: CancellationToken.None);
+ if (!buffer.IsAddingCompleted)
+ buffer.CompleteAdding();
+ });
+
+ foreach (var record in buffer.GetConsumingEnumerable())
+ collected.Add(record);
+
+ await syncTask;
+ return collected;
+ }
+
+ private CERTInextConfig BuildV2Config(
+ int? syncLookbackHours = null, string organizationNumber = null)
+ {
+ return new CERTInextConfig
+ {
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ V2InlineCsr = V2EnvHelper.InlineCsrFromEnv, // default single-call; CERTINEXT_V2_INLINE_CSR=0 selects two-call
+ DefaultProductCode = Environment.GetEnvironmentVariable("CERTINEXT_PRODUCT_CODE") ?? "842",
+ OAuthClientId = _v2ClientId,
+ OAuthClientSecret = _v2ClientSecret,
+
+ RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com",
+ RequestorIsdCode = "1",
+ RequestorMobileNumber = "0000000000",
+ SignerPlace = "Gateway Lab",
+ SignerIp = "127.0.0.1",
+
+ PageSize = 100,
+
+ V2SyncLookbackHours = syncLookbackHours ?? 1,
+
+ OrganizationNumber = organizationNumber ?? string.Empty,
+
+ DcvEnabled = false
+ };
+ }
+
+ private static CERTInextCAPlugin BuildV2Plugin(CERTInextConfig config)
+ {
+ var client = new CERTInextClient(config);
+ return new CERTInextCAPlugin(client, config);
+ }
+
+ ///
+ /// Distinguishes a client-side HTTP timeout (RestSharp/TaskCanceledException — the
+ /// plugin's hard-coded 120s V2 RestClient timeout expiring before the CA responds) from a
+ /// genuine CA-side rejection. See 's doc comment.
+ ///
+ /// Also matches the shape the CA can return for the same condition: CERTInextClient's
+ /// ThrowOnV2Failure does not always surface a
+ /// for a RestSharp-level transport timeout — it can instead produce a plain
+ /// reading "CERTInext V2 API error during '...'. HTTP 0.
+ /// CERTInext V2 returned no body for '...'." (StatusCode 0 = no HTTP response was ever
+ /// received). Both substrings ("HTTP 0" and "returned no body") must be present so this
+ /// never also matches a genuine HTTP-0-with-a-body CA-side condition.
+ ///
+ private static bool IsClientTimeout(Exception ex) =>
+ ex is TaskCanceledException
+ || ex is OperationCanceledException
+ || (ex.Message?.IndexOf("timed out", StringComparison.OrdinalIgnoreCase) >= 0)
+ // PlaceOrderV2CoreAsync maps a create that got no usable response (no HTTP status / 5xx) to this
+ // "created but we never heard back" message (single-call create: the order may exist AND issue).
+ || (ex.Message?.IndexOf("did not return a usable response to the V2 order create", StringComparison.OrdinalIgnoreCase) >= 0)
+ || (ex.Message != null
+ && ex.Message.IndexOf("HTTP 0", StringComparison.OrdinalIgnoreCase) >= 0
+ && ex.Message.IndexOf("returned no body", StringComparison.OrdinalIgnoreCase) >= 0);
+
+ ///
+ /// Best-effort search for an order the CA may have created despite the plugin's own
+ /// client-side timeout () — a timeout proves nothing about
+ /// what happened server-side. Scans the V2 orders report
+ /// ( via ListOrdersV2Async) for the
+ /// "UTC today" window, matches on domainName == domain (the only field this report
+ /// row model exposes — no OU/SAN/tag field is echoed there) plus
+ /// orderDate >= windowStartUtc - 5min to avoid grabbing an older, unrelated
+ /// order on the same long-reused , picks the single most-recent
+ /// match if more than one row qualifies, and cancels it (one attempt, never retried) if
+ /// it is not already terminal. Never throws — every failure path is folded into the
+ /// returned description string so the caller's Skip.If message always has something
+ /// actionable. is logged only (see ).
+ ///
+ private async Task TryCancelOrphanByWindowAsync(string domain, DateTime windowStartUtc, string probeTag)
+ {
+ try
+ {
+ using var client = new CERTInextClient(BuildV2Config());
+
+ string from = windowStartUtc.Date.ToString("yyyy-MM-dd");
+ string to = windowStartUtc.Date.AddDays(1).ToString("yyyy-MM-dd");
+
+ OrderReportEntryV2 best = null;
+ DateTime bestDate = DateTime.MinValue;
+ int scanned = 0;
+
+ await foreach (var row in client.ListOrdersV2Async(from, to, pageSize: 100))
+ {
+ scanned++;
+ if (!string.Equals(row.DomainName, domain, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ DateTime rowDate = DateTime.TryParse(
+ row.OrderDate, null,
+ System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal,
+ out var parsed)
+ ? parsed
+ : windowStartUtc; // unparseable date: don't exclude it from consideration on that basis alone
+
+ if (rowDate < windowStartUtc.AddMinutes(-5))
+ continue;
+
+ if (best == null || rowDate >= bestDate)
+ {
+ best = row;
+ bestDate = rowDate;
+ }
+ }
+
+ _output.WriteLine(
+ $"Orphan sweep (tag={probeTag}): scanned {scanned} report row(s) for domain '{domain}', " +
+ $"window >= {windowStartUtc:O} (-5min grace).");
+
+ if (best == null)
+ return "orphan sweep found no matching report row for this domain/window (nothing to cancel, " +
+ "or the order has not appeared in the report yet — try again later by hand if needed)";
+
+ string orderId = best.OrderNumber;
+ if (string.IsNullOrWhiteSpace(orderId))
+ return $"orphan sweep found a matching report row for domain '{domain}' with no orderNumber — cannot cancel it programmatically";
+
+ var (family, status) = await client.ResolveAndTrackOrderV2WithFamilyAsync(orderId);
+ bool terminal =
+ string.Equals(status.Status, Constants.ApiV2.StatusCancelled, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(status.Status, Constants.ApiV2.StatusRevoked, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(status.Status, Constants.ApiV2.StatusRejected, StringComparison.OrdinalIgnoreCase);
+
+ if (terminal)
+ return $"orphan sweep found order {orderId} already terminal (status={status.Status}) — nothing to cancel";
+
+ try
+ {
+ var outcome = await client.CancelOrderV2Async(
+ family, orderId,
+ $"V2 full-lifecycle test orphan sweep — client-side timeout at submission, tag={probeTag}.");
+ return $"orphan sweep found order {orderId} (status was {status.Status}) and cancelled it (outcome={outcome})";
+ }
+ catch (Exception cancelEx)
+ {
+ return $"orphan sweep found order {orderId} but the cancel call itself FAILED " +
+ $"({cancelEx.GetType().Name}: {cancelEx.Message}) — not retried; cancel it by hand in the CERTInext portal";
+ }
+ }
+ catch (Exception ex)
+ {
+ return $"orphan sweep itself FAILED ({ex.GetType().Name}: {ex.Message}) — could not search for an orphaned order; check the CERTInext portal by hand";
+ }
+ }
+
+ ///
+ /// Cleans up a sandbox order this test created, keyed on the order's own V2 status
+ /// (): revoke if issued, cancel otherwise, then re-track.
+ /// Single attempt only — never retries a cancel. Logs rather than throws on failure so a
+ /// cleanup problem never masks the test's own assertion result; failures are surfaced in test
+ /// output for manual follow-up in the CERTInext portal.
+ ///
+ private async Task CleanupOrderAsync(CERTInextCAPlugin plugin, string orderId)
+ {
+ // Keyed on the order's V2 status (see V2OrderCleanup): GetSingleRecord can still report an
+ // already-issued order as pending, and cancelling it leaks an issued certificate.
+ using var client = new CERTInextClient(BuildV2Config());
+ await V2OrderCleanup.RevokeOrCancelAsync(
+ client, orderId, "V2 full-lifecycle test cleanup.", _output.WriteLine);
+ }
+
+ // ---------------------------------------------------------------------------
+ // 1. DV UCC — enroll (2+ SANs) -> track -> sync/GetSingleRecord -> revoke
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Places one V2 DV SSL UCC order () with the
+ /// primary domain on the account's long-reused, likely-already-verified
+ /// CERTINEXT_DCV_DOMAIN, plus two fresh never-seen subdomains as additional SANs
+ /// (so the order itself places cleanly regardless of whether the extra SANs clear DCV).
+ /// Exercises Enroll -> GetSingleRecord -> Synchronize, then cleans up (revoke if
+ /// GENERATED, else cancel).
+ /// Expected sandbox order count: 1.
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task Enroll_V2_DvUcc_WithMultipleSans_FullLifecycle(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSslUcc, mode);
+ Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_DV_UCC") != "1",
+ "CERTINEXT_V2_LIFECYCLE_DV_UCC=1 not set — this places a real DV UCC sandbox order. Skipping.");
+
+ string ts = Timestamp();
+ string primary = _v2Domain;
+ string sanA = $"ucc-a-{ts}.{_v2Domain}";
+ string sanB = $"ucc-b-{ts}.{_v2Domain}";
+
+ var config = BuildV2Config();
+ var plugin = BuildV2Plugin(config);
+
+ string orderId = null;
+ try
+ {
+ var productInfo = V2ProductPinning.Info(Constants.Products.DvSslUcc, mode);
+
+ var enrollResult = await plugin.Enroll(
+ csr: GenerateCsrPem(primary),
+ subject: $"CN={primary}",
+ san: new Dictionary { ["dns"] = new[] { sanA, sanB } },
+ productInfo: productInfo,
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ enrollResult.Should().NotBeNull();
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(
+ "V2 DV UCC Enroll must return a non-empty CARequestID");
+ orderId = enrollResult.CARequestID;
+ enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED,
+ $"DV UCC Enroll must not FAILED at submission; message: {enrollResult.StatusMessage}");
+ _output.WriteLine($"DV UCC order {orderId}: Status={enrollResult.Status}, Primary={primary}, SANs=[{sanA}, {sanB}]");
+
+ var tracked = await plugin.GetSingleRecord(orderId);
+ tracked.Should().NotBeNull("GetSingleRecord must return a record for a just-placed DV UCC order");
+ tracked.CARequestID.Should().Be(orderId);
+ _output.WriteLine($"Tracked: Status={tracked.Status}");
+
+ var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false);
+ synced.Should().Contain(r => r.CARequestID == orderId,
+ $"the newly placed DV UCC order '{orderId}' must appear in a delta sync via V2 /reports/orders");
+
+ var syncedRecord = synced.First(r => r.CARequestID == orderId);
+ _output.WriteLine($"Synced status: {syncedRecord.Status}");
+ if (syncedRecord.Status == (int)EndEntityStatus.GENERATED)
+ syncedRecord.Certificate.Should().NotBeNullOrWhiteSpace("an issued DV UCC order must carry a cert body via Synchronize");
+ }
+ finally
+ {
+ await CleanupOrderAsync(plugin, orderId);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // 2. OV — enroll -> track -> sync -> revoke/cancel
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Places one V2 OV SSL order (); productVariant
+ /// "ov" and the organization block are both derived/required automatically by
+ /// EnrollV2Async from the connector's OrganizationNumber. Sandbox OV orders commonly
+ /// park in a pending-vetting state rather than auto-issuing — this test asserts on
+ /// whatever state machine actually results (only FAILED at submission is treated as a
+ /// hard failure) rather than forcing GENERATED. See
+ /// for the 120s-vs-300s client timeout caveat.
+ /// Expected sandbox order count: 1.
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task Enroll_V2_Ov_FullLifecycle(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.OvSsl, mode);
+ Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_OV") != "1",
+ "CERTINEXT_V2_LIFECYCLE_OV=1 not set — this places a real OV sandbox order. Skipping.");
+
+ string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null;
+ Skip.If(string.IsNullOrWhiteSpace(organizationNumber),
+ "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — OV requires a pre-vetted organization number. Skipping.");
+
+ var config = BuildV2Config(organizationNumber: organizationNumber);
+ var plugin = BuildV2Plugin(config);
+
+ string domain = _v2Domain;
+ string probeTag = $"ov-{Timestamp()}";
+ DateTime windowStart = DateTime.UtcNow;
+ string orderId = null;
+ try
+ {
+ EnrollmentResult enrollResult;
+ try
+ {
+ enrollResult = await plugin.Enroll(
+ csr: GenerateCsrPem(domain, probeTag),
+ subject: $"CN={domain},OU={probeTag}",
+ san: new Dictionary { ["dns"] = new[] { domain } },
+ productInfo: V2ProductPinning.Info(Constants.Products.OvSsl, mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ }
+ catch (Exception ex) when (IsClientTimeout(ex))
+ {
+ string sweepResult = await TryCancelOrphanByWindowAsync(domain, windowStart, probeTag);
+ Skip.If(true,
+ "OV order creation did not return within the plugin's hard-coded 120s V2 HTTP client " +
+ "timeout. That timeout is a known client-side limitation rather than a CA-side " +
+ "rejection, so this is an expected skip — but a client timeout does not prove the CA " +
+ $"never created the order; it likely did. {sweepResult}. " +
+ $"Observed: {ex.GetType().Name}: {ex.Message}");
+ return;
+ }
+
+ enrollResult.Should().NotBeNull();
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace("V2 OV Enroll must return a non-empty CARequestID");
+ orderId = enrollResult.CARequestID;
+ enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED,
+ $"OV Enroll must not FAILED at submission; message: {enrollResult.StatusMessage}");
+ _output.WriteLine($"OV order {orderId}: Status={enrollResult.Status}, Message={enrollResult.StatusMessage}");
+
+ var tracked = await plugin.GetSingleRecord(orderId);
+ tracked.Should().NotBeNull();
+ _output.WriteLine($"Tracked OV order {orderId}: Status={tracked.Status} (OV sandbox orders commonly sit in a pending-vetting state rather than a forced GENERATED state).");
+
+ var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false);
+ synced.Should().Contain(r => r.CARequestID == orderId,
+ $"the newly placed OV order '{orderId}' must appear in a delta sync");
+ }
+ finally
+ {
+ await CleanupOrderAsync(plugin, orderId);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // 3. OV UCC — enroll (2+ SANs) -> track -> sync -> revoke/cancel
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Places one V2 OV SSL UCC order () — the
+ /// organization-block requirement (OV/EV) and the UCC multi-SAN path (additionalDomains)
+ /// are exercised together in one order. Same pending-vetting behavior and timeout
+ /// caveat as the plain OV test above.
+ /// Expected sandbox order count: 1.
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task Enroll_V2_OvUcc_WithMultipleSans_FullLifecycle(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.OvSslUcc, mode);
+ Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_OV_UCC") != "1",
+ "CERTINEXT_V2_LIFECYCLE_OV_UCC=1 not set — this places a real OV UCC sandbox order. Skipping.");
+
+ string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null;
+ Skip.If(string.IsNullOrWhiteSpace(organizationNumber),
+ "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — OV UCC requires a pre-vetted organization number. Skipping.");
+
+ string ts = Timestamp();
+ string primary = _v2Domain;
+ string sanA = $"ovucc-a-{ts}.{_v2Domain}";
+ string sanB = $"ovucc-b-{ts}.{_v2Domain}";
+
+ var config = BuildV2Config(organizationNumber: organizationNumber);
+ var plugin = BuildV2Plugin(config);
+
+ string probeTag = $"ovucc-{ts}";
+ DateTime windowStart = DateTime.UtcNow;
+ string orderId = null;
+ try
+ {
+ EnrollmentResult enrollResult;
+ try
+ {
+ enrollResult = await plugin.Enroll(
+ csr: GenerateCsrPem(primary, probeTag),
+ subject: $"CN={primary},OU={probeTag}",
+ san: new Dictionary { ["dns"] = new[] { sanA, sanB } },
+ productInfo: V2ProductPinning.Info(Constants.Products.OvSslUcc, mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ }
+ catch (Exception ex) when (IsClientTimeout(ex))
+ {
+ string sweepResult = await TryCancelOrphanByWindowAsync(primary, windowStart, probeTag);
+ Skip.If(true,
+ "OV UCC order creation did not return within the plugin's hard-coded 120s V2 HTTP client " +
+ "timeout — same known client-side limitation as the plain OV test. A client timeout " +
+ $"does not prove the CA never created the order; it likely did. {sweepResult}. " +
+ $"Observed: {ex.GetType().Name}: {ex.Message}");
+ return;
+ }
+
+ enrollResult.Should().NotBeNull();
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace("V2 OV UCC Enroll must return a non-empty CARequestID");
+ orderId = enrollResult.CARequestID;
+ enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED,
+ $"OV UCC Enroll must not FAILED at submission; message: {enrollResult.StatusMessage}");
+ _output.WriteLine($"OV UCC order {orderId}: Status={enrollResult.Status}, Primary={primary}, SANs=[{sanA}, {sanB}]");
+
+ var tracked = await plugin.GetSingleRecord(orderId);
+ tracked.Should().NotBeNull();
+ _output.WriteLine($"Tracked OV UCC order {orderId}: Status={tracked.Status}");
+
+ var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false);
+ synced.Should().Contain(r => r.CARequestID == orderId,
+ $"the newly placed OV UCC order '{orderId}' must appear in a delta sync");
+ }
+ finally
+ {
+ await CleanupOrderAsync(plugin, orderId);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // 4. EV — enroll -> track -> sync -> revoke/cancel
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Places one V2 EV SSL order () — same
+ /// organization-block requirement as OV (ProductVariantsV2 mapping resolves "ev"
+ /// automatically), same pending-vetting behavior, same client-timeout caveat. Uses
+ /// CERTINEXT_EV_ORG_NUMBER — NOT CERTINEXT_ORG_NUMBER/
+ /// , which is only pre-vetted for OV. EV
+ /// requires its own, separately-vetted organization number that this account does not
+ /// currently have; the test skips cleanly rather than guessing.
+ /// Expected sandbox order count: 1.
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task Enroll_V2_Ev_FullLifecycle(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.EvSsl, mode);
+ Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_EV") != "1",
+ "CERTINEXT_V2_LIFECYCLE_EV=1 not set — this places a real EV sandbox order. Skipping.");
+
+ string organizationNumber = Environment.GetEnvironmentVariable("CERTINEXT_EV_ORG_NUMBER");
+ Skip.If(string.IsNullOrWhiteSpace(organizationNumber),
+ "CERTINEXT_EV_ORG_NUMBER not set — EV requires its own pre-vetted organization number " +
+ "(distinct from CERTINEXT_ORG_NUMBER, which is only vetted for OV). Skipping.");
+
+ var config = BuildV2Config(organizationNumber: organizationNumber);
+ var plugin = BuildV2Plugin(config);
+
+ string domain = _v2Domain;
+ string orderId = null;
+ try
+ {
+ EnrollmentResult enrollResult;
+ try
+ {
+ enrollResult = await plugin.Enroll(
+ csr: GenerateCsrPem(domain),
+ subject: $"CN={domain}",
+ san: new Dictionary { ["dns"] = new[] { domain } },
+ productInfo: V2ProductPinning.Info(Constants.Products.EvSsl, mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ }
+ catch (Exception ex) when (IsClientTimeout(ex))
+ {
+ Skip.If(true,
+ $"EV order creation did not return within the plugin's hard-coded 120s V2 HTTP " +
+ $"client timeout — same known client-side limitation flagged for OV. " +
+ $"Observed: {ex.GetType().Name}: {ex.Message}");
+ return;
+ }
+
+ enrollResult.Should().NotBeNull();
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace("V2 EV Enroll must return a non-empty CARequestID");
+ orderId = enrollResult.CARequestID;
+ enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED,
+ $"EV Enroll must not FAILED at submission; message: {enrollResult.StatusMessage}");
+ _output.WriteLine($"EV order {orderId}: Status={enrollResult.Status}, Message={enrollResult.StatusMessage}");
+
+ var tracked = await plugin.GetSingleRecord(orderId);
+ tracked.Should().NotBeNull();
+ _output.WriteLine($"Tracked EV order {orderId}: Status={tracked.Status} (EV sandbox orders commonly sit in a pending-vetting state).");
+
+ var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false);
+ synced.Should().Contain(r => r.CARequestID == orderId,
+ $"the newly placed EV order '{orderId}' must appear in a delta sync");
+ }
+ finally
+ {
+ await CleanupOrderAsync(plugin, orderId);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // 5. Wildcard DV — both CSR shapes (wildcard-only, wildcard+apex SAN)
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Resolves the wildcard domain to use: CERTINEXT_V2_WILDCARD_DOMAIN if set,
+ /// else the literal *.dcv-test.scrup.org — this repo's own always-reused sandbox
+ /// base domain, not customer data.
+ ///
+ private static string ResolveWildcardDomain() =>
+ Environment.GetEnvironmentVariable("CERTINEXT_V2_WILDCARD_DOMAIN") ?? "*.dcv-test.scrup.org";
+
+ ///
+ /// Wildcard-only CSR shape: CN and sole SAN are both the wildcard
+ /// (). Expected to be accepted — wildcard is a
+ /// first-class DV SSL Wildcard product shape.
+ /// Expected sandbox order count: 1.
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task Enroll_V2_WildcardDv_WildcardOnly_FullLifecycle(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSslWildcard, mode);
+ Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_WILDCARD_DV") != "1",
+ "CERTINEXT_V2_LIFECYCLE_WILDCARD_DV=1 not set — this places real wildcard DV sandbox orders. Skipping.");
+
+ string wildcard = ResolveWildcardDomain();
+ var config = BuildV2Config();
+ var plugin = BuildV2Plugin(config);
+
+ string orderId = null;
+ try
+ {
+ var enrollResult = await plugin.Enroll(
+ csr: GenerateCsrPem(wildcard),
+ subject: $"CN={wildcard}",
+ san: new Dictionary { ["dns"] = new[] { wildcard } },
+ productInfo: V2ProductPinning.Info(Constants.Products.DvSslWildcard, mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ enrollResult.Should().NotBeNull();
+ _output.WriteLine($"Wildcard-only ({wildcard}): Status={enrollResult.Status}, Message={enrollResult.StatusMessage}");
+ enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED,
+ $"a wildcard-only CSR/SAN shape must not be rejected; message: {enrollResult.StatusMessage}");
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace();
+ orderId = enrollResult.CARequestID;
+
+ var tracked = await plugin.GetSingleRecord(orderId);
+ tracked.Should().NotBeNull();
+ _output.WriteLine($"Tracked: Status={tracked.Status}");
+ }
+ finally
+ {
+ await CleanupOrderAsync(plugin, orderId);
+ }
+ }
+
+ ///
+ /// Wildcard+apex CSR shape: CN is the wildcard, SAN dictionary carries BOTH the wildcard
+ /// and its bare apex domain. The non-UCC V2 SAN guard (CERTInextCAPlugin.cs, EnrollV2Async)
+ /// explicitly exempts exactly this shape for a wildcard product — the guard computes
+ /// domain as the literal CN ("*.dcv-test.scrup.org" here), and without the
+ /// exemption the apex ("dcv-test.scrup.org") would match neither that nor its "www."
+ /// variant and be treated as a disallowed "extra SAN", even though a wildcard+apex
+ /// pairing is an extremely common, legitimate certificate shape. The order is therefore
+ /// expected to be accepted and issued. This test records the actual resulting behavior
+ /// rather than hard-asserting on it everywhere: if the order is rejected anyway,
+ /// it asserts the rejection is specifically this guard's (by message content) rather than
+ /// some unrelated failure; if accepted and issued, it parses the issued leaf (BouncyCastle)
+ /// and logs — as an observation only, not an assertion — whether the apex is covered by
+ /// the certificate's own SAN list (CERTInext may or may not add the apex to
+ /// additionalDomains automatically for a non-UCC wildcard product).
+ /// Expected sandbox order count: 0 or 1 (0 if CERTInext itself rejects a FAILED result
+ /// before any order is ever placed — see the FAILED branch below).
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task Enroll_V2_WildcardDv_WildcardPlusApexSan_RecordsActualBehavior(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSslWildcard, mode);
+ Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_WILDCARD_DV") != "1",
+ "CERTINEXT_V2_LIFECYCLE_WILDCARD_DV=1 not set — this places real wildcard DV sandbox orders. Skipping.");
+
+ string wildcard = ResolveWildcardDomain();
+ string apex = wildcard.StartsWith("*.", StringComparison.Ordinal) ? wildcard.Substring(2) : wildcard;
+
+ var config = BuildV2Config();
+ var plugin = BuildV2Plugin(config);
+
+ string orderId = null;
+ try
+ {
+ var enrollResult = await plugin.Enroll(
+ csr: GenerateCsrPem(wildcard),
+ subject: $"CN={wildcard}",
+ san: new Dictionary { ["dns"] = new[] { wildcard, apex } },
+ productInfo: V2ProductPinning.Info(Constants.Products.DvSslWildcard, mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ enrollResult.Should().NotBeNull();
+ _output.WriteLine($"Wildcard+apex ({wildcard} + {apex}): Status={enrollResult.Status}, Message={enrollResult.StatusMessage}");
+
+ if (enrollResult.Status == (int)EndEntityStatus.FAILED)
+ {
+ _output.WriteLine(
+ "RESULT: wildcard+apex was REJECTED before any CA call — the non-UCC SAN guard's " +
+ $"wildcard-apex exemption did not cover this case: domain==CN=='{wildcard}', and the " +
+ $"apex '{apex}' matched neither that nor its 'www.' variant.");
+ enrollResult.StatusMessage.Should().Contain("SAN",
+ "a FAILED result here must specifically be the non-UCC multi-SAN guard's rejection " +
+ "(StatusMessage mentions SAN/domain count), not some unrelated failure masquerading as it");
+ enrollResult.CARequestID.Should().BeNullOrWhiteSpace(
+ "the guard rejects before PlaceOrderV2Async — no CARequestID should be minted");
+ }
+ else
+ {
+ _output.WriteLine(
+ "RESULT: wildcard+apex was ACCEPTED — the non-UCC single-domain SAN guard's " +
+ "wildcard-apex exemption allows the bare apex alongside the wildcard CN.");
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace();
+ orderId = enrollResult.CARequestID;
+
+ var tracked = await plugin.GetSingleRecord(orderId);
+ tracked.Should().NotBeNull();
+ _output.WriteLine($"Tracked: Status={tracked.Status}");
+
+ if (tracked.Status == (int)EndEntityStatus.GENERATED && !string.IsNullOrWhiteSpace(tracked.Certificate))
+ {
+ var sans = ExtractDnsSansOrEmpty(tracked.Certificate);
+ _output.WriteLine($"Issued certificate SAN list: [{string.Join(", ", sans)}]");
+
+ bool apexCovered = sans.Any(s => string.Equals(s, apex, StringComparison.OrdinalIgnoreCase));
+ _output.WriteLine(apexCovered
+ ? $"The apex '{apex}' IS covered by the issued certificate's SAN list."
+ : $"The apex '{apex}' is NOT covered by the issued certificate's SAN list " +
+ "(not asserted — CERTInext may or may not add the apex to additionalDomains " +
+ "automatically for a non-UCC wildcard product).");
+ }
+ else
+ {
+ _output.WriteLine(
+ $"Order not yet issued (Status={tracked.Status}) — skipping the SAN-coverage observation.");
+ }
+ }
+ }
+ finally
+ {
+ await CleanupOrderAsync(plugin, orderId);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // 6. Renew and Reissue of an issued DV order
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Enrolls a DV order (New), then calls Enroll again with EnrollmentType.Renew and then
+ /// EnrollmentType.Reissue for the same domain, passing the prior order's serial via
+ /// ProductParameters["PriorCertSN"] (the V1 RenewOrReissueAsync convention). V2's
+ /// EnrollV2Async never branches on enrollmentType beyond logging it — every enrollment
+ /// type is dispatched identically (CERTInextCAPlugin.cs: "V2 path: all enrollment types
+ /// go through EnrollV2Async", and EnrollV2Async itself never reads PriorCertSN or
+ /// enrollmentType except in log statements). This test records the actual resulting
+ /// behavior (distinct CARequestIDs, original never implicitly revoked) but does NOT pass
+ /// merely because the CA accepted each submission; a FAILED order at the CA must still
+ /// fail this test, since "records actual behavior" was never meant to license "observe
+ /// FAILED three times and call it a pass."
+ /// Expected sandbox order count: up to 3 (original + renew + reissue).
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task EnrollRenewReissue_V2_IssuedDvOrder_RecordsActualBehavior(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSsl, mode);
+ Skip.If(Environment.GetEnvironmentVariable("CERTINEXT_V2_LIFECYCLE_RENEW_REISSUE") != "1",
+ "CERTINEXT_V2_LIFECYCLE_RENEW_REISSUE=1 not set — this places up to 3 real DV sandbox orders. Skipping.");
+
+ var config = BuildV2Config();
+ var plugin = BuildV2Plugin(config);
+
+ string domain = _v2Domain;
+ string originalOrderId = null, renewOrderId = null, reissueOrderId = null;
+ try
+ {
+ var original = await plugin.Enroll(
+ csr: GenerateCsrPem(domain),
+ subject: $"CN={domain}",
+ san: new Dictionary { ["dns"] = new[] { domain } },
+ productInfo: V2ProductPinning.Info(Constants.Products.DvSsl, mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ original.Should().NotBeNull();
+ original.CARequestID.Should().NotBeNullOrWhiteSpace();
+ originalOrderId = original.CARequestID;
+ _output.WriteLine($"Original order {originalOrderId}: Status={original.Status}, Message={original.StatusMessage}");
+ original.Status.Should().BeOneOf(
+ new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION },
+ $"the original New enrollment must actually reach an in-flight or issued state for this to be a " +
+ $"meaningful renew/reissue lifecycle test, not FAILED; message: {original.StatusMessage}");
+
+ string priorSn = ExtractHexSerialOrEmpty(original.Certificate);
+ var priorParams = new Dictionary { ["PriorCertSN"] = priorSn };
+
+ var renewResult = await plugin.Enroll(
+ csr: GenerateCsrPem(domain),
+ subject: $"CN={domain}",
+ san: new Dictionary { ["dns"] = new[] { domain } },
+ productInfo: V2ProductPinning.Info(Constants.Products.DvSsl, mode, priorParams),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.Renew);
+
+ renewResult.Should().NotBeNull();
+ renewResult.CARequestID.Should().NotBeNullOrWhiteSpace();
+ renewOrderId = renewResult.CARequestID;
+ renewOrderId.Should().NotBe(originalOrderId,
+ "V2 has no dedicated renew endpoint — EnrollV2Async dispatches every EnrollmentType " +
+ "identically, so Renew places a brand-new order with a new CARequestID rather than " +
+ "reusing or superseding the original's ID");
+ _output.WriteLine($"Renew order {renewOrderId}: Status={renewResult.Status}, Message={renewResult.StatusMessage} (new order, distinct CARequestID).");
+ renewResult.Status.Should().BeOneOf(
+ new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION },
+ $"Renew must actually reach an in-flight or issued state, not FAILED; message: {renewResult.StatusMessage}");
+
+ var reissueResult = await plugin.Enroll(
+ csr: GenerateCsrPem(domain),
+ subject: $"CN={domain}",
+ san: new Dictionary { ["dns"] = new[] { domain } },
+ productInfo: V2ProductPinning.Info(Constants.Products.DvSsl, mode, priorParams),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.Reissue);
+
+ reissueResult.Should().NotBeNull();
+ reissueResult.CARequestID.Should().NotBeNullOrWhiteSpace();
+ reissueOrderId = reissueResult.CARequestID;
+ reissueOrderId.Should().NotBe(originalOrderId);
+ reissueOrderId.Should().NotBe(renewOrderId);
+ _output.WriteLine($"Reissue order {reissueOrderId}: Status={reissueResult.Status}, Message={reissueResult.StatusMessage} (new order, distinct CARequestID).");
+ reissueResult.Status.Should().BeOneOf(
+ new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION },
+ $"Reissue must actually reach an in-flight or issued state, not FAILED; message: {reissueResult.StatusMessage}");
+
+ var originalAfter = await plugin.GetSingleRecord(originalOrderId);
+ originalAfter.Should().NotBeNull();
+ originalAfter.Status.Should().NotBe((int)EndEntityStatus.REVOKED,
+ "neither Renew nor Reissue should implicitly revoke the original order under the V2 " +
+ "path — EnrollV2Async never calls Revoke on a prior order");
+ _output.WriteLine($"Original order {originalOrderId} after renew+reissue: Status={originalAfter.Status} (unaffected, as expected).");
+ }
+ finally
+ {
+ await CleanupOrderAsync(plugin, originalOrderId);
+ await CleanupOrderAsync(plugin, renewOrderId);
+ await CleanupOrderAsync(plugin, reissueOrderId);
+ }
+ }
+
+ ///
+ /// Extracts the issued certificate's serial number as an uppercase hex string using
+ /// BouncyCastle (never BCL System.Security.Cryptography). Returns empty when
+ /// is null/blank/unparseable — e.g. a DV order still pending
+ /// DCV at enrollment time has no cert body yet, and PriorCertSN is not read at all by
+ /// EnrollV2Async under V2 (see this method's caller), so an empty value is harmless here.
+ ///
+ private static string ExtractHexSerialOrEmpty(string certPem)
+ {
+ if (string.IsNullOrWhiteSpace(certPem))
+ return string.Empty;
+
+ try
+ {
+ var match = System.Text.RegularExpressions.Regex.Match(
+ certPem,
+ @"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----",
+ System.Text.RegularExpressions.RegexOptions.Singleline);
+ if (!match.Success)
+ return string.Empty;
+
+ string b64 = match.Groups[1].Value.Replace("\r", string.Empty).Replace("\n", string.Empty).Trim();
+ var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64));
+ return cert.SerialNumber.ToString(16).ToUpperInvariant();
+ }
+ catch
+ {
+ return string.Empty;
+ }
+ }
+
+ ///
+ /// Extracts the issued certificate's dNSName SAN entries using BouncyCastle (never BCL
+ /// System.Security.Cryptography) — mirrors the main plugin's own GeneralNameToSanEntry
+ /// dNSName handling, but reading the ISSUED certificate's own SAN extension rather than a
+ /// CSR's. Returns an empty list when is null/blank/unparseable,
+ /// or the certificate carries no SAN extension.
+ ///
+ private static List ExtractDnsSansOrEmpty(string certPem)
+ {
+ var result = new List();
+ if (string.IsNullOrWhiteSpace(certPem))
+ return result;
+
+ try
+ {
+ var match = System.Text.RegularExpressions.Regex.Match(
+ certPem,
+ @"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----",
+ System.Text.RegularExpressions.RegexOptions.Singleline);
+ if (!match.Success)
+ return result;
+
+ string b64 = match.Groups[1].Value.Replace("\r", string.Empty).Replace("\n", string.Empty).Trim();
+ var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64));
+
+ var sanExtensionOctets = cert.GetExtensionValue(X509Extensions.SubjectAlternativeName)?.GetOctets();
+ if (sanExtensionOctets == null)
+ return result;
+
+ var generalNames = GeneralNames.GetInstance(
+ Org.BouncyCastle.Asn1.Asn1Object.FromByteArray(sanExtensionOctets));
+
+ foreach (var generalName in generalNames.GetNames())
+ {
+ if (generalName.TagNo == GeneralName.DnsName)
+ result.Add(Org.BouncyCastle.Asn1.DerIA5String.GetInstance(generalName.Name).GetString());
+ }
+ }
+ catch
+ {
+ // Observation-only helper — an unparseable cert/extension just yields no SAN
+ // observations rather than failing the test.
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/V2InlineCsrIssuanceExperimentTests.cs b/CERTInext.IntegrationTests/V2InlineCsrIssuanceExperimentTests.cs
new file mode 100644
index 0000000..95e0f8e
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2InlineCsrIssuanceExperimentTests.cs
@@ -0,0 +1,979 @@
+// 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.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.OpenSsl;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using Org.BouncyCastle.X509;
+using RestSharp;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Measurement experiment (issues 0062 + 0090, V2): does a SINGLE V2 call
+ /// (POST /ssl-certificates with the CSR inline in the create body, no follow-up
+ /// PUT .../csr) actually issue a certificate, and does the ~20 s "certificate is
+ /// generated inside the call that delivers the CSR" hold move from PUT /csr onto the
+ /// create call?
+ ///
+ /// Two arms, interleaved (A, B, A, B, ...), sequential, on the verified base domain
+ /// (CERTINEXT_DCV_DOMAIN itself, DV product 842 pinned via X-Product-Code by default):
+ ///
+ /// A - inline CSR single call: create body carries "csr"; nothing else is sent.
+ /// B - two-call control exactly as the plugin does it: create (no csr), then
+ /// PUT .../csr with the PEM CSR.
+ ///
+ /// The plugin's V2CreateSslOrderRequest has no csr property, so every call here is
+ /// raw HTTP (RestSharp, same headers the plugin sends); the body is the plugin's own DTO
+ /// serialized with its own serializer options, with csr injected for arm A. No plugin
+ /// production code is exercised or changed.
+ ///
+ /// Per order the test records: create latency + status/orderState/certificateState; PUT latency
+ /// (arm B); Track Order polled every ~1.5 s from the create response (status/orderState/
+ /// certificateState transitions, incl. whether "System Error" appears and clears); the orders
+ /// report (the call Synchronize makes) polled concurrently every ~1.5 s with a narrow
+ /// today..tomorrow window (first appearance and every status transition = the enroll/sync race
+ /// window); whether GET .../certificate succeeds and when, and that the issued certificate
+ /// matches the CSR's public key and CN. Every order is then revoked/cancelled and re-tracked.
+ ///
+ /// Opt-in: CERTINEXT_V2_INLINE_CSR_EXPERIMENT=1 as a REAL environment variable.
+ /// Optional env:
+ /// CERTINEXT_V2_INLINE_CSR_COUNT orders per arm (default 5, max 8);
+ /// CERTINEXT_V2_INLINE_CSR_ARMS (AB default, BA runs the control first in each pair, A, or B);
+ /// CERTINEXT_V2_INLINE_CSR_ENCODING arm-A CSR encoding (pem default, der =
+ /// headerless Base64 DER, alt = alternate per arm-A order); arm B is always PEM (the plugin's shape);
+ /// CERTINEXT_V2_INLINE_CSR_PRODUCT_HEADER (on default = X-Product-Code: CERTINEXT_PRODUCT_CODE
+ /// [842]; off = omit the header; anything else = that literal code);
+ /// CERTINEXT_V2_INLINE_CSR_SPACING_SECONDS pause between orders (default 20);
+ /// CERTINEXT_V2_INLINE_CSR_TIMEOUT_SECONDS per-order observation cap (default 180).
+ /// No retries of order creation. Emails are scrubbed from every output line; credentials come
+ /// from ~/.env_certinext_v2. Raw CSV goes to experiment-results/.
+ ///
+ public class V2InlineCsrIssuanceExperimentTests : IClassFixture
+ {
+ private const string OptInFlag = "CERTINEXT_V2_INLINE_CSR_EXPERIMENT";
+ private const int MaxPerArm = 8;
+ private const int MaxListPollsInFlight = 2;
+ private const string CleanupReason = "V2 inline-CSR experiment cleanup (issues 0062/0090) - sandbox test order.";
+ private static readonly TimeSpan ListPollInterval = TimeSpan.FromSeconds(1.5);
+ private static readonly TimeSpan TrackPollInterval = TimeSpan.FromSeconds(1.5);
+
+ private readonly ITestOutputHelper _output;
+ private readonly Stopwatch _clock = Stopwatch.StartNew();
+
+ private readonly string _apiUrl;
+ private readonly string _clientId;
+ private readonly string _clientSecret;
+ private readonly string _productCode;
+ private readonly string _baseDomain;
+ private readonly string _requestorName;
+ private readonly string _requestorEmail;
+ private readonly string _groupNumber;
+ private readonly bool _v2Enabled;
+
+ public V2InlineCsrIssuanceExperimentTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _output = output;
+
+ var env = V2EnvHelper.LoadAndPromote();
+ _apiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL");
+ _clientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID");
+ _clientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET");
+ _productCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE", "842");
+ _baseDomain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", string.Empty);
+ _requestorName = V2EnvHelper.GetEnv(env, "CERTINEXT_REQUESTOR_NAME", "Keyfactor Integration Test");
+ _requestorEmail = V2EnvHelper.GetEnv(env, "CERTINEXT_REQUESTOR_EMAIL", "test@example.com");
+ _groupNumber = V2EnvHelper.GetEnv(env, "CERTINEXT_GROUP_NUMBER", string.Empty);
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_apiUrl)
+ && !string.IsNullOrWhiteSpace(_clientId)
+ && !string.IsNullOrWhiteSpace(_clientSecret);
+ }
+
+ private double Now => _clock.Elapsed.TotalSeconds;
+
+ private static readonly Regex EmailRx = new(@"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", RegexOptions.Compiled);
+
+ /// Strips anything email-shaped and caps length; used for every line that leaves the test.
+ private static string Scrub(string s, int max = 400)
+ {
+ if (string.IsNullOrEmpty(s)) return s ?? string.Empty;
+ s = EmailRx.Replace(s, "");
+ s = s.Replace("\r", " ").Replace("\n", " ");
+ return s.Length > max ? s.Substring(0, max) + "..." : s;
+ }
+
+ private static readonly Regex PersonBlockRx = new(
+ "\"(orderedBy|requestor|technicalPointOfContact|requestedBy)\"\\s*:\\s*\\{[^}]*\\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ ///
+ /// Scrub for raw API bodies: drops the person blocks (the response echoes the requestor and the
+ /// account user's name/email) and then applies the normal email scrub and length cap.
+ ///
+ private static string ScrubBody(string s, int max = 400) =>
+ Scrub(PersonBlockRx.Replace(s ?? string.Empty, m => $"\"{m.Groups[1].Value}\":{{}}"), max);
+
+ private static string Describe(Exception ex) => Scrub($"{ex.GetType().Name}: {ex.Message}");
+
+ private void W(string s) => _output.WriteLine(Scrub(s, 1200));
+
+ // ---------------------------------------------------------------------------
+ // Observation records
+ // ---------------------------------------------------------------------------
+
+ private sealed class HttpResult
+ {
+ public double Start;
+ public double End = double.NaN;
+ public int Status;
+ public string Body = string.Empty;
+ public string Error = string.Empty;
+ public double Ms => (End - Start) * 1000.0;
+ public bool Ok => Status >= 200 && Status < 300;
+ }
+
+ private sealed class TrackObs
+ {
+ public double Sent;
+ public double Recv;
+ public int Http;
+ public string Status = string.Empty;
+ public string OrderState = string.Empty;
+ public string CertState = string.Empty;
+ public string Error = string.Empty;
+ public string Body = string.Empty;
+ public string Sig => $"{Status}|{OrderState}|{CertState}";
+ }
+
+ private sealed class ListObs
+ {
+ public double Start;
+ public double End;
+ public int Rows;
+ public string Error = string.Empty;
+ /// Orders on the experiment domain that did not exist before t0: orderNumber -> "orderStatus|certStatus".
+ public Dictionary New = new(StringComparer.Ordinal);
+ }
+
+ private sealed class Trial
+ {
+ public int Index;
+ public string Arm;
+ public string Encoding = "pem";
+ public string Domain;
+ public string OrderId = string.Empty;
+ public DateTime T0Utc;
+ public double T0;
+
+ public int CreateHttp;
+ public double CreateMs = double.NaN, TCreateResp = double.NaN;
+ public string CreateStatus = string.Empty, CreateOrderState = string.Empty, CreateCertState = string.Empty;
+ public string CreateBody = string.Empty;
+ public int PutHttp;
+ public double PutMs = double.NaN, TPutResp = double.NaN;
+ public string PutBody = string.Empty;
+
+ public double TFirstTrackObs = double.NaN;
+ public double TIssuedTrack = double.NaN, TIssuedTrackLower = double.NaN;
+ public string TrackSeq = string.Empty;
+ public bool SystemErrorSeen;
+ public double SysErrFirst = double.NaN, SysErrCleared = double.NaN;
+ public int TrackPolls, TrackErrors;
+
+ public double FirstSeenList = double.NaN, FirstSeenListLower = double.NaN;
+ public string FirstSeenListStatus = string.Empty;
+ public double TIssuedList = double.NaN;
+ public string ListSeq = string.Empty;
+ public int ListPolls, ListErrors;
+ public double ListPollMedianS = double.NaN;
+
+ public double DlFirstAt = double.NaN, DlSuccessAt = double.NaN, DlMs = double.NaN;
+ public int DlAttempts;
+ public volatile bool TrackSaidIssued;
+ public string DlLastFailure = string.Empty;
+ public string DlFirstResult = string.Empty;
+ public string CertPubKeyMatch = string.Empty, CertCnMatch = string.Empty;
+
+ public List Strays = new();
+ public string CleanupNote = string.Empty;
+ public string FinalState = string.Empty;
+ public string Error = string.Empty;
+
+ public bool Issued => !double.IsNaN(TIssuedTrack) || !double.IsNaN(TIssuedList);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Small helpers
+ // ---------------------------------------------------------------------------
+
+ private static string F(double x) => double.IsNaN(x) ? "" : x.ToString("0.00", CultureInfo.InvariantCulture);
+
+ private static double Median(IEnumerable xs)
+ {
+ var a = xs.Where(x => !double.IsNaN(x)).OrderBy(x => x).ToArray();
+ if (a.Length == 0) return double.NaN;
+ return a.Length % 2 == 1 ? a[a.Length / 2] : (a[a.Length / 2 - 1] + a[a.Length / 2]) / 2.0;
+ }
+
+ private static string RepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !File.Exists(Path.Combine(dir.FullName, "certinext-caplugin.sln")))
+ dir = dir.Parent;
+ return dir?.FullName ?? Directory.GetCurrentDirectory();
+ }
+
+ private static string EnvOr(string name, string fallback)
+ {
+ string v = Environment.GetEnvironmentVariable(name);
+ return string.IsNullOrWhiteSpace(v) ? fallback : v.Trim();
+ }
+
+ /// First string value of at the top level of the JSON body, else anywhere (depth-first); "" if none.
+ private static string JsonString(string body, string name, bool topLevelOnly)
+ {
+ if (string.IsNullOrWhiteSpace(body)) return string.Empty;
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ if (doc.RootElement.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var p in doc.RootElement.EnumerateObject())
+ if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.String)
+ return p.Value.GetString() ?? string.Empty;
+ }
+ if (topLevelOnly) return string.Empty;
+ return FindDeep(doc.RootElement, name) ?? string.Empty;
+ }
+ catch (JsonException) { return string.Empty; }
+ }
+
+ private static string FindDeep(JsonElement e, string name)
+ {
+ if (e.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var p in e.EnumerateObject())
+ {
+ if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.String)
+ return p.Value.GetString();
+ string d = FindDeep(p.Value, name);
+ if (d != null) return d;
+ }
+ }
+ else if (e.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var item in e.EnumerateArray())
+ {
+ string d = FindDeep(item, name);
+ if (d != null) return d;
+ }
+ }
+ return null;
+ }
+
+ private static bool IsIssuedStatus(string s) =>
+ string.Equals(s, Constants.ApiV2.StatusIssued, StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsTerminalNonIssued(string s) =>
+ string.Equals(s, Constants.ApiV2.StatusRevoked, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(s, Constants.ApiV2.StatusCancelled, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(s, Constants.ApiV2.StatusRejected, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(s, Constants.ApiV2.StatusExpired, StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsPendingStatus(string s) =>
+ !string.IsNullOrEmpty(s) && s.StartsWith("pending-", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsIssuedListing(string combined) =>
+ combined != null &&
+ (combined.IndexOf("Generated", StringComparison.OrdinalIgnoreCase) >= 0
+ || combined.IndexOf("Downloaded", StringComparison.OrdinalIgnoreCase) >= 0);
+
+ private static bool IsSystemError(string certState) =>
+ !string.IsNullOrEmpty(certState) && certState.IndexOf("error", StringComparison.OrdinalIgnoreCase) >= 0;
+
+ private static readonly JsonSerializerOptions PluginJsonOptions = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
+ };
+
+ private CERTInextConfig BuildConfig() => new CERTInextConfig
+ {
+ ApiUrl = _apiUrl,
+ UseV2Api = true,
+ OAuthClientId = _clientId,
+ OAuthClientSecret = _clientSecret,
+ RequestorName = _requestorName,
+ RequestorEmail = _requestorEmail,
+ RequestorIsdCode = "1",
+ RequestorMobileNumber = "0000000000",
+ SignerPlace = "Gateway Lab",
+ SignerIp = "127.0.0.1",
+ GroupNumber = _groupNumber,
+ PageSize = 100,
+ DcvEnabled = false
+ };
+
+ // ---------------------------------------------------------------------------
+ // CSR (BouncyCastle only)
+ // ---------------------------------------------------------------------------
+
+ private sealed class CsrBundle
+ {
+ public AsymmetricCipherKeyPair KeyPair;
+ public string Pem;
+ public string Base64Der;
+ }
+
+ private static CsrBundle GenerateCsr(string commonName)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
+ var csr = new Pkcs10CertificationRequest("SHA256withRSA", new X509Name($"CN={commonName}"),
+ keyPair.Public, null, keyPair.Private);
+ byte[] der = csr.GetEncoded();
+ return new CsrBundle
+ {
+ KeyPair = keyPair,
+ Base64Der = Convert.ToBase64String(der),
+ Pem = "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(der, Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----"
+ };
+ }
+
+ /// Parses the downloaded PEM with BouncyCastle and compares public key + CN to the CSR.
+ private static void CheckCertificate(Trial t, string certPem, CsrBundle csr)
+ {
+ try
+ {
+ using var sr = new StringReader(certPem);
+ var cert = new PemReader(sr).ReadObject() as Org.BouncyCastle.X509.X509Certificate;
+ if (cert == null) { t.CertPubKeyMatch = "unparseable"; t.CertCnMatch = "unparseable"; return; }
+ var certKey = cert.GetPublicKey() as RsaKeyParameters;
+ var csrKey = csr.KeyPair.Public as RsaKeyParameters;
+ t.CertPubKeyMatch = certKey != null && csrKey != null && certKey.Modulus.Equals(csrKey.Modulus)
+ && certKey.Exponent.Equals(csrKey.Exponent) ? "match" : "MISMATCH";
+ string subject = cert.SubjectDN.ToString();
+ t.CertCnMatch = subject.IndexOf("CN=" + t.Domain, StringComparison.OrdinalIgnoreCase) >= 0 ? "match" : "MISMATCH(" + Scrub(subject, 120) + ")";
+ }
+ catch (Exception ex)
+ {
+ t.CertPubKeyMatch = "error: " + Describe(ex);
+ t.CertCnMatch = t.CertPubKeyMatch;
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Raw HTTP
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// One raw V2 call with the same headers the plugin client sends (Bearer, Accept, optional
+ /// Idempotency-Key and X-Product-Code). Never throws: transport errors land in .
+ ///
+ private async Task SendAsync(RestClient api, string token, Method method, string path,
+ string jsonBody, string productHeader, bool idempotency, TimeSpan timeout)
+ {
+ var r = new HttpResult { Start = Now };
+ try
+ {
+ var req = new RestRequest(path, method);
+ req.AddHeader("Authorization", $"Bearer {token}");
+ req.AddHeader("Accept", "application/json");
+ if (idempotency) req.AddHeader("Idempotency-Key", Guid.NewGuid().ToString());
+ if (!string.IsNullOrWhiteSpace(productHeader)) req.AddHeader("X-Product-Code", productHeader);
+ if (jsonBody != null) req.AddJsonBody(jsonBody);
+ using var cts = new CancellationTokenSource(timeout);
+ var resp = await api.ExecuteAsync(req, cts.Token);
+ r.Status = (int)resp.StatusCode;
+ r.Body = resp.Content ?? string.Empty;
+ if (resp.StatusCode == 0)
+ r.Error = Scrub($"{resp.ResponseStatus}: {resp.ErrorMessage}");
+ }
+ catch (Exception ex)
+ {
+ r.Error = Describe(ex);
+ }
+ r.End = Now;
+ return r;
+ }
+
+ // ---------------------------------------------------------------------------
+ // The experiment
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task V2InlineCsr_SingleCall_Vs_TwoCall_Issuance_Experiment()
+ {
+ Skip.If(!_v2Enabled, "V2 credentials not configured in ~/.env_certinext_v2 (CERTINEXT_USE_V2_API, API_URL, CLIENT_ID, CLIENT_SECRET).");
+ Skip.If(Environment.GetEnvironmentVariable(OptInFlag) != "1",
+ $"Opt-in: set {OptInFlag}=1 as a real environment variable (places live sandbox orders).");
+ Skip.If(string.IsNullOrWhiteSpace(_baseDomain), "CERTINEXT_DCV_DOMAIN is required (verified base domain used as the order CN).");
+
+ int perArm = int.TryParse(Environment.GetEnvironmentVariable("CERTINEXT_V2_INLINE_CSR_COUNT"), out int c) ? c : 5;
+ perArm = Math.Max(1, Math.Min(MaxPerArm, perArm));
+ string arms = EnvOr("CERTINEXT_V2_INLINE_CSR_ARMS", "AB").ToUpperInvariant();
+ bool doA = arms.Contains('A'), doB = arms.Contains('B');
+ Assert.True(doA || doB, "CERTINEXT_V2_INLINE_CSR_ARMS must contain A and/or B.");
+ string encMode = EnvOr("CERTINEXT_V2_INLINE_CSR_ENCODING", "pem").ToLowerInvariant();
+ Assert.Contains(encMode, new[] { "pem", "der", "alt" });
+ string headerMode = EnvOr("CERTINEXT_V2_INLINE_CSR_PRODUCT_HEADER", "on");
+ string productHeader = headerMode.Equals("on", StringComparison.OrdinalIgnoreCase) ? _productCode
+ : (headerMode.Equals("off", StringComparison.OrdinalIgnoreCase) || headerMode.Equals("none", StringComparison.OrdinalIgnoreCase)) ? null
+ : headerMode;
+ double spacing = double.TryParse(Environment.GetEnvironmentVariable("CERTINEXT_V2_INLINE_CSR_SPACING_SECONDS"),
+ NumberStyles.Float, CultureInfo.InvariantCulture, out double sp) ? sp : 20;
+ double timeoutS = double.TryParse(Environment.GetEnvironmentVariable("CERTINEXT_V2_INLINE_CSR_TIMEOUT_SECONDS"),
+ NumberStyles.Float, CultureInfo.InvariantCulture, out double to) ? to : 180;
+
+ string runId = DateTime.UtcNow.ToString("MMddHHmm", CultureInfo.InvariantCulture);
+ var cfg = BuildConfig();
+ using var listClient = new CERTInextClient(cfg);
+ using var cleanupClient = new CERTInextClient(cfg);
+ var api = V2RawHttpHelpers.NewApiClient(_apiUrl.TrimEnd('/'), TimeSpan.FromSeconds(300));
+
+ W($"V2 inline-CSR experiment run {runId}: domain={_baseDomain}, X-Product-Code={(productHeader ?? "")}, " +
+ $"arms={arms}, perArm={perArm}, armAEncoding={encMode}, spacingS={F(spacing)}, observeCapS={F(timeoutS)}");
+
+ // Interleaved schedule A,B,A,B,...
+ var schedule = new List();
+ for (int i = 0; i < perArm; i++)
+ {
+ // Arm order within each pair follows the ARMS string (e.g. "BA" puts the two-call control first).
+ foreach (char ch in arms)
+ if (ch == 'A' || ch == 'B') schedule.Add(ch.ToString());
+ }
+
+ var trials = new List();
+ var consecutiveNoIssue = new Dictionary { ["A"] = 0, ["B"] = 0 };
+ var aCount = 0;
+ double lastEnd = double.NegativeInfinity;
+ try
+ {
+ foreach (string arm in schedule)
+ {
+ if (consecutiveNoIssue[arm] >= 2)
+ {
+ W($"arm {arm}: skipped - the previous 2 orders in this arm did not issue; stopping the arm (report as a finding).");
+ continue;
+ }
+ var t = new Trial { Index = trials.Count + 1, Arm = arm, Domain = _baseDomain.ToLowerInvariant() };
+ if (arm == "A")
+ {
+ t.Encoding = encMode == "alt" ? (aCount % 2 == 0 ? "pem" : "der") : encMode;
+ aCount++;
+ }
+ trials.Add(t);
+
+ double wait = lastEnd + spacing - Now;
+ if (wait > 0) await Task.Delay(TimeSpan.FromSeconds(wait));
+
+ try
+ {
+ await RunTrialAsync(t, api, listClient, productHeader, timeoutS);
+ }
+ catch (Exception ex)
+ {
+ t.Error += "trial: " + Describe(ex) + "; ";
+ }
+ finally
+ {
+ await CleanupTrialAsync(cleanupClient, t);
+ lastEnd = Now;
+ }
+ consecutiveNoIssue[arm] = t.Issued ? 0 : consecutiveNoIssue[arm] + 1;
+ LogTrial(t);
+ }
+ }
+ finally
+ {
+ WriteCsv(runId, trials, productHeader, encMode);
+ WriteSummary(trials);
+ W("ORDER IDS: " + string.Join(",", trials.SelectMany(t => new[] { t.OrderId }.Concat(t.Strays)).Where(s => !string.IsNullOrEmpty(s)).Distinct()));
+ }
+
+ var notTerminal = trials.Where(t => !string.IsNullOrEmpty(t.OrderId) && !IsTerminalNonIssued(t.FinalState))
+ .Select(t => $"{t.OrderId}({t.FinalState})").ToList();
+ Assert.True(notTerminal.Count == 0, "Orders not in a terminal state after cleanup: " + string.Join(", ", notTerminal));
+ }
+
+ private async Task RunTrialAsync(Trial t, RestClient api, CERTInextClient listClient, string productHeader, double timeoutS)
+ {
+ string basePath = Constants.ApiV2.SslCertificatesPath;
+ bool inline = t.Arm == "A";
+
+ // Everything not under test happens before t0.
+ var csr = GenerateCsr(t.Domain);
+ string token = await V2RawHttpHelpers.GetV2AccessTokenAsync(_apiUrl, _clientId, _clientSecret);
+ string today = DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ string tomorrow = DateTime.UtcNow.AddDays(1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+
+ var snapshot = new HashSet(StringComparer.Ordinal);
+ await foreach (var e in listClient.ListOrdersV2Async(today, tomorrow, 100))
+ if (e?.OrderNumber != null && string.Equals(e.DomainName, t.Domain, StringComparison.OrdinalIgnoreCase))
+ snapshot.Add(e.OrderNumber);
+
+ // Body = the plugin's own DTO + serializer options (identical to what Enroll sends for DV 842),
+ // plus "csr" injected for arm A.
+ string requestorPhone = CERTInextCAPlugin.ComposeV2Phone("1", "0000000000");
+ var orderReq = new V2CreateSslOrderRequest
+ {
+ ProductVariant = "dv",
+ Requestor = new V2Requestor { Name = _requestorName, Email = _requestorEmail, Phone = requestorPhone },
+ Certificate = new V2CertificateParams { Domain = t.Domain, AutoSecureWww = false },
+ Subscription = new V2SubscriptionParams { ValidityYears = 1, AutoRenew = false },
+ Agreement = new V2AgreementParams { SignerName = _requestorName, SignerPlace = "Gateway Lab", Accepted = true },
+ TechnicalPointOfContact = new V2TechnicalPointOfContact
+ {
+ Name = _requestorName, Email = _requestorEmail, Phone = requestorPhone,
+ Designation = Constants.ApiV2.DefaultTechnicalContactDesignation
+ },
+ Remarks = "Issued via Keyfactor Command AnyCA REST Gateway.",
+ GroupNumber = string.IsNullOrWhiteSpace(_groupNumber) ? null : _groupNumber
+ };
+ string createJson = JsonSerializer.Serialize(orderReq, PluginJsonOptions);
+ if (inline)
+ {
+ var node = JsonNode.Parse(createJson) as JsonObject ?? throw new InvalidOperationException("create body is not a JSON object");
+ node["csr"] = t.Encoding == "der" ? csr.Base64Der : csr.Pem;
+ createJson = node.ToJsonString();
+ }
+ string putJson = JsonSerializer.Serialize(new V2SubmitCsrRequest { Csr = csr.Pem }, PluginJsonOptions);
+
+ var trackObs = new ConcurrentQueue();
+ var listObs = new ConcurrentQueue();
+ using var stopAll = new CancellationTokenSource();
+
+ var inflight = new List();
+ var listerTask = Task.Run(async () =>
+ {
+ async Task PollOnce()
+ {
+ var o = new ListObs { Start = Now };
+ try
+ {
+ await foreach (var e in listClient.ListOrdersV2Async(today, tomorrow, 100))
+ {
+ o.Rows++;
+ if (e?.OrderNumber != null
+ && string.Equals(e.DomainName, t.Domain, StringComparison.OrdinalIgnoreCase)
+ && !snapshot.Contains(e.OrderNumber))
+ o.New[e.OrderNumber] = $"{e.OrderStatus}|{e.CertificateStatus}";
+ }
+ }
+ catch (Exception ex) { o.Error = Describe(ex); }
+ o.End = Now;
+ listObs.Enqueue(o);
+ }
+ while (!stopAll.IsCancellationRequested)
+ {
+ inflight.RemoveAll(x => x.IsCompleted);
+ if (inflight.Count < MaxListPollsInFlight) inflight.Add(Task.Run(PollOnce));
+ try { await Task.Delay(ListPollInterval, stopAll.Token); } catch (OperationCanceledException) { }
+ }
+ await Task.WhenAll(inflight);
+ });
+ await Task.Delay(ListPollInterval); // listing poller is already running before t0
+
+ Task trackerTask = Task.CompletedTask;
+ string orderId = string.Empty;
+ try
+ {
+ // ---- t0: create ----
+ t.T0Utc = DateTime.UtcNow;
+ t.T0 = Now;
+ var create = await SendAsync(api, token, Method.Post, basePath, createJson, productHeader, idempotency: true, TimeSpan.FromSeconds(280));
+ t.CreateHttp = create.Status;
+ t.CreateMs = create.Ms;
+ t.TCreateResp = create.End;
+ t.CreateBody = ScrubBody(create.Body, 900);
+ t.CreateStatus = JsonString(create.Body, "status", topLevelOnly: true);
+ t.CreateOrderState = JsonString(create.Body, "orderState", topLevelOnly: false);
+ t.CreateCertState = JsonString(create.Body, "certificateState", topLevelOnly: false);
+ if (!string.IsNullOrEmpty(create.Error)) t.Error += "create: " + create.Error + "; ";
+ if (!create.Ok) t.Error += $"create HTTP {create.Status}: {ScrubBody(create.Body, 300)}; ";
+ orderId = create.Ok ? JsonString(create.Body, "orderId", topLevelOnly: true) : string.Empty;
+ t.OrderId = orderId ?? string.Empty;
+
+ if (!string.IsNullOrEmpty(orderId))
+ {
+ // Tracker starts the instant the create response lands.
+ trackerTask = Task.WhenAll(
+ Task.Run(() => TrackLoopAsync(t, api, token, basePath, orderId, trackObs, stopAll.Token)),
+ Task.Run(() => DownloadLoopAsync(t, api, token, basePath, orderId, csr, stopAll.Token)));
+
+ if (!inline)
+ {
+ var put = await SendAsync(api, token, Method.Put, $"{basePath}/{orderId}/csr", putJson, null, idempotency: false, TimeSpan.FromSeconds(280));
+ t.PutHttp = put.Status;
+ t.PutMs = put.Ms;
+ t.TPutResp = put.End;
+ t.PutBody = ScrubBody(put.Body, 400);
+ if (!string.IsNullOrEmpty(put.Error)) t.Error += "put: " + put.Error + "; ";
+ if (!put.Ok) t.Error += $"put HTTP {put.Status}: {ScrubBody(put.Body, 300)}; ";
+ }
+
+ // Observe until issued + downloaded + listing agrees, a terminal failure, or the cap.
+ double deadline = t.T0 + timeoutS;
+ double terminalAt = double.NaN;
+ double settledAt = double.NaN;
+ while (Now < deadline)
+ {
+ await Task.Delay(500);
+ var snapT = trackObs.ToArray();
+ var latest = snapT.OrderBy(o => o.Recv).LastOrDefault(o => string.IsNullOrEmpty(o.Error));
+ if (latest != null && IsTerminalNonIssued(latest.Status))
+ {
+ if (double.IsNaN(terminalAt)) terminalAt = Now;
+ if (Now >= terminalAt + 3) break;
+ }
+ bool issuedSeen = snapT.Any(o => IsIssuedStatus(o.Status));
+ bool listingIssued = listObs.ToArray().Any(l => l.New.TryGetValue(orderId, out var s) && IsIssuedListing(s));
+ if (issuedSeen && !double.IsNaN(t.DlSuccessAt) && (listingIssued || Now > t.TIssuedTrack + 30))
+ {
+ // Tail: keep polling a few more seconds so the final Track Order / listing states are captured.
+ if (double.IsNaN(settledAt)) settledAt = Now;
+ if (Now >= settledAt + 5) break;
+ }
+ if (t.DlAttempts >= 60 && issuedSeen) break;
+ }
+ }
+ else
+ {
+ // Create failed or returned no order id: keep listing for a moment so any stray order is seen.
+ await Task.Delay(TimeSpan.FromSeconds(10));
+ }
+ }
+ finally
+ {
+ stopAll.Cancel();
+ try { await Task.WhenAll(trackerTask, listerTask); } catch { /* observation only */ }
+ }
+
+ Analyze(t, trackObs.OrderBy(o => o.Recv).ToList(), listObs.OrderBy(o => o.Start).ToList());
+ }
+
+ private async Task TrackLoopAsync(Trial t, RestClient api, string token, string basePath, string orderId,
+ ConcurrentQueue trackObs, CancellationToken stop)
+ {
+ while (!stop.IsCancellationRequested)
+ {
+ var o = new TrackObs { Sent = Now };
+ var r = await SendAsync(api, token, Method.Get, $"{basePath}/{orderId}", null, null, idempotency: false, TimeSpan.FromSeconds(30));
+ o.Recv = Now;
+ o.Http = r.Status;
+ o.Error = r.Ok ? string.Empty : (string.IsNullOrEmpty(r.Error) ? $"HTTP {r.Status} {Scrub(r.Body, 150)}" : r.Error);
+ if (r.Ok)
+ {
+ o.Status = JsonString(r.Body, "status", topLevelOnly: true);
+ o.OrderState = JsonString(r.Body, "orderState", topLevelOnly: false);
+ o.CertState = JsonString(r.Body, "certificateState", topLevelOnly: false);
+ o.Body = ScrubBody(r.Body, 700);
+ }
+ trackObs.Enqueue(o);
+
+ if (r.Ok && IsIssuedStatus(o.Status)) t.TrackSaidIssued = true;
+ try { await Task.Delay(TrackPollInterval, stop); } catch (OperationCanceledException) { }
+ }
+ }
+
+ ///
+ /// Separate from the tracker so slow/failing downloads never delay Track Order polling.
+ /// Starts probing GET .../certificate once Track Order first says "issued" and retries every
+ /// ~2 s until one call returns a certificate (or the observation ends). The first failure body is kept.
+ ///
+ private async Task DownloadLoopAsync(Trial t, RestClient api, string token, string basePath, string orderId,
+ CsrBundle csr, CancellationToken stop)
+ {
+ while (!stop.IsCancellationRequested && double.IsNaN(t.DlSuccessAt) && t.DlAttempts < 60)
+ {
+ if (t.TrackSaidIssued)
+ {
+ t.DlAttempts++;
+ var dl = await SendAsync(api, token, Method.Get, $"{basePath}/{orderId}/certificate", null, null, idempotency: false, TimeSpan.FromSeconds(60));
+ string pem = dl.Ok ? JsonString(dl.Body, "certificatePem", topLevelOnly: true) : string.Empty;
+ string result = dl.Ok ? (string.IsNullOrWhiteSpace(pem) ? "ok-empty" : "ok-with-certificate")
+ : $"HTTP {dl.Status} {ScrubBody(string.IsNullOrEmpty(dl.Error) ? dl.Body : dl.Error, 220)}";
+ if (double.IsNaN(t.DlFirstAt))
+ {
+ t.DlFirstAt = dl.Start;
+ t.DlFirstResult = result;
+ }
+ t.DlLastFailure = dl.Ok && !string.IsNullOrWhiteSpace(pem) ? t.DlLastFailure : $"{result} @{F(dl.End - t.T0)}s ({F(dl.Ms / 1000)}s call)";
+ if (dl.Ok && !string.IsNullOrWhiteSpace(pem))
+ {
+ t.DlSuccessAt = dl.End;
+ t.DlMs = dl.Ms;
+ CheckCertificate(t, pem, csr);
+ return;
+ }
+ }
+ try { await Task.Delay(TimeSpan.FromSeconds(2), stop); } catch (OperationCanceledException) { }
+ }
+ }
+
+ private void Analyze(Trial t, List track, List lists)
+ {
+ string id = t.OrderId;
+
+ // ---- tracker ----
+ t.TrackPolls = track.Count;
+ t.TrackErrors = track.Count(o => !string.IsNullOrEmpty(o.Error));
+ var ok = track.Where(o => string.IsNullOrEmpty(o.Error)).ToList();
+ if (ok.Count > 0) t.TFirstTrackObs = ok[0].Recv;
+ var issuedTrack = ok.FirstOrDefault(o => IsIssuedStatus(o.Status));
+ if (issuedTrack != null) { t.TIssuedTrack = issuedTrack.Recv; t.TIssuedTrackLower = issuedTrack.Sent; }
+
+ // Sequence starts with the create response's own state so "System Error" at create time is visible.
+ var seq = new List();
+ string createSig = $"{t.CreateStatus}|{t.CreateOrderState}|{t.CreateCertState}";
+ if (!string.IsNullOrEmpty(t.OrderId)) seq.Add($"create:{createSig}@{F(t.TCreateResp - t.T0)}");
+ string prev = createSig;
+ foreach (var o in ok)
+ if (o.Sig != prev)
+ {
+ seq.Add($"{o.Sig}@{F(o.Recv - t.T0)}");
+ prev = o.Sig;
+ }
+ t.TrackSeq = string.Join("; ", seq);
+
+ // System Error: first seen (create response counts) and when it clears.
+ var states = new List<(double at, string cs)>();
+ if (!string.IsNullOrEmpty(t.OrderId)) states.Add((t.TCreateResp, t.CreateCertState));
+ states.AddRange(ok.Select(o => (o.Recv, o.CertState)));
+ var firstErr = states.FirstOrDefault(s => IsSystemError(s.cs));
+ if (firstErr.cs != null)
+ {
+ t.SystemErrorSeen = true;
+ t.SysErrFirst = firstErr.at;
+ var cleared = states.FirstOrDefault(s => s.at > firstErr.at && !IsSystemError(s.cs));
+ if (cleared.cs != null) t.SysErrCleared = cleared.at;
+ }
+ foreach (var o in track.Where(o => !string.IsNullOrEmpty(o.Error)).Take(2))
+ t.Error += "track: " + o.Error + "; ";
+
+ // ---- listing ----
+ t.ListPolls = lists.Count;
+ t.ListErrors = lists.Count(l => !string.IsNullOrEmpty(l.Error));
+ t.ListPollMedianS = Median(lists.Select(l => l.End - l.Start));
+ var allNew = lists.SelectMany(l => l.New.Keys).Distinct().ToList();
+ if (string.IsNullOrEmpty(id))
+ {
+ // No order id from create: any new order on the domain is a stray from this trial.
+ t.Strays.AddRange(allNew);
+ return;
+ }
+ if (allNew.Any(n => n != id)) t.Error += $"other new orders on domain during trial: {string.Join("/", allNew.Where(n => n != id))}; ";
+
+ var matched = lists.Where(l => l.New.ContainsKey(id)).OrderBy(l => l.End).ToList();
+ if (matched.Count > 0)
+ {
+ t.FirstSeenList = matched[0].End;
+ t.FirstSeenListLower = matched[0].Start;
+ t.FirstSeenListStatus = Scrub(matched[0].New[id]);
+ var issuedL = matched.FirstOrDefault(l => IsIssuedListing(l.New[id]));
+ if (issuedL != null) t.TIssuedList = issuedL.End;
+ }
+ var lseq = new List();
+ foreach (var l in matched)
+ {
+ string s = Scrub(l.New[id]);
+ if (lseq.Count == 0 || !lseq[^1].StartsWith(s + "@", StringComparison.Ordinal))
+ lseq.Add($"{s}@{F(l.End - t.T0)}");
+ }
+ t.ListSeq = string.Join("; ", lseq);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Cleanup: revoke issued / cancel pending, then re-track and record the final state
+ // ---------------------------------------------------------------------------
+
+ private async Task CleanupTrialAsync(CERTInextClient client, Trial t)
+ {
+ var ids = new List();
+ if (!string.IsNullOrEmpty(t.OrderId)) ids.Add(t.OrderId);
+ // Strays are only populated when create returned no order id, so there is exactly one order we created.
+ if (string.IsNullOrEmpty(t.OrderId) && t.Strays.Count == 1) ids.AddRange(t.Strays);
+ if (ids.Count == 0) { t.CleanupNote = "no order"; return; }
+
+ var notes = new List();
+ foreach (string id in ids)
+ {
+ string note = await CleanupOneAsync(client, id, t);
+ notes.Add($"{id}: {note}");
+ }
+ t.CleanupNote = string.Join(" | ", notes);
+ if (string.IsNullOrEmpty(t.OrderId) && t.Strays.Count == 1) t.OrderId = t.Strays[0]; // reported as this trial's order
+ }
+
+ private async Task CleanupOneAsync(CERTInextClient client, string id, Trial t)
+ {
+ string family = Constants.ApiV2.FamilySsl;
+ string before = "?";
+ string action = "none";
+ try
+ {
+ before = (await client.TrackOrderV2Async(family, id)).Status ?? "(null)";
+ if (IsTerminalNonIssued(before)) action = "skip (already " + before + ")";
+ else if (IsIssuedStatus(before))
+ {
+ try
+ {
+ await client.RevokeOrderV2Async(family, id, new V2RevokeRequest { Reason = "superseded", Note = CleanupReason });
+ action = "revoke(superseded)";
+ }
+ catch (Exception rex)
+ {
+ action = "revoke(superseded) rejected: " + Describe(rex);
+ await client.RevokeOrderV2Async(family, id, new V2RevokeRequest { Reason = "unspecified", Note = CleanupReason });
+ action += " -> revoke(unspecified)";
+ }
+ }
+ else if (IsPendingStatus(before))
+ {
+ var outcome = await client.CancelOrderV2Async(family, id, CleanupReason);
+ action = "cancel -> " + outcome;
+ }
+ else action = "skip (unrecognised status)";
+ }
+ catch (Exception ex)
+ {
+ action += "; ACTION FAILED: " + Describe(ex);
+ }
+
+ string after = "?";
+ for (int attempt = 0; attempt < 4; attempt++)
+ {
+ try
+ {
+ after = (await client.TrackOrderV2Async(family, id)).Status ?? "(null)";
+ if (IsTerminalNonIssued(after)) break;
+ }
+ catch (Exception ex) { after = "TRACK ERROR: " + Describe(ex); }
+ await Task.Delay(TimeSpan.FromSeconds(2));
+ }
+ if (id == t.OrderId || string.IsNullOrEmpty(t.OrderId)) t.FinalState = after;
+ return $"before={before}; {action}; after={after}";
+ }
+
+ // ---------------------------------------------------------------------------
+ // Output
+ // ---------------------------------------------------------------------------
+
+ private void LogTrial(Trial t)
+ {
+ W($"trial {t.Index:D2} arm={t.Arm} enc={(t.Arm == "A" ? t.Encoding : "pem(put)")} order={(string.IsNullOrEmpty(t.OrderId) ? "-" : t.OrderId)} " +
+ $"createHttp={t.CreateHttp} createS={F(t.CreateMs / 1000)} createStatus={t.CreateStatus} createOrderState={t.CreateOrderState} createCertState={t.CreateCertState} " +
+ $"putHttp={t.PutHttp} putS={F(t.PutMs / 1000)} " +
+ $"issuedTrack={F(t.TIssuedTrack - t.T0)}s issuedListing={F(t.TIssuedList - t.T0)}s firstSeenListing={F(t.FirstSeenList - t.T0)}s({t.FirstSeenListStatus}) " +
+ $"dlFirst@{F(t.DlFirstAt - t.T0)}s={t.DlFirstResult} dlAttempts={t.DlAttempts} dlLastFail=[{t.DlLastFailure}] dlOkAt={F(t.DlSuccessAt - t.T0)}s pubkey={t.CertPubKeyMatch} cn={t.CertCnMatch} " +
+ $"sysErr={t.SystemErrorSeen}(first={F(t.SysErrFirst - t.T0)}s cleared={F(t.SysErrCleared - t.T0)}s) " +
+ $"trackSeq=[{t.TrackSeq}] listSeq=[{t.ListSeq}] cleanup=[{t.CleanupNote}] final={t.FinalState} {t.Error}");
+ if (t.Arm == "B" || !string.IsNullOrEmpty(t.PutBody))
+ W($"trial {t.Index:D2} put body: {t.PutBody}");
+ W($"trial {t.Index:D2} create body: {t.CreateBody}");
+ }
+
+ private void WriteCsv(string runId, List trials, string productHeader, string encMode)
+ {
+ try
+ {
+ string dir = Path.Combine(RepoRoot(), "experiment-results");
+ Directory.CreateDirectory(dir);
+ string path = Path.Combine(dir, $"v2-inline-csr-{runId}.csv");
+ var sb = new StringBuilder();
+ sb.AppendLine("trial,arm,arm_a_encoding,product_header,domain,order_id,t0_utc," +
+ "create_http,create_ms,create_status,create_order_state,create_cert_state,create_body," +
+ "put_http,put_ms,put_body," +
+ "t_create_resp_s,t_put_resp_s,t_first_track_obs_s,t_issued_track_s,t_issued_track_lower_s,track_seq," +
+ "system_error_seen,system_error_first_s,system_error_cleared_s," +
+ "first_seen_listing_s,first_seen_listing_lower_s,first_seen_listing_status,t_issued_listing_s,listing_seq," +
+ "seen_in_listing_before_create_resp,listing_first_seen_to_create_resp_s,issued_in_listing_before_create_resp,create_resp_minus_issued_listing_s," +
+ "dl_first_at_s,dl_first_result,dl_last_failure,dl_success_at_s,dl_ms,dl_attempts,cert_pubkey,cert_cn," +
+ "track_polls,track_errors,list_polls,list_poll_median_s,list_errors,cleanup,final_state,error");
+ foreach (var t in trials)
+ {
+ string Rel(double x) => double.IsNaN(x) ? "" : F(x - t.T0);
+ string Q(string s) => "\"" + Scrub(s ?? "", 800).Replace("\"", "'") + "\"";
+ bool seenBefore = !double.IsNaN(t.FirstSeenList) && !double.IsNaN(t.TCreateResp) && t.FirstSeenList < t.TCreateResp;
+ bool issuedBefore = !double.IsNaN(t.TIssuedList) && !double.IsNaN(t.TCreateResp) && t.TIssuedList < t.TCreateResp;
+ sb.AppendLine(string.Join(",", new[]
+ {
+ t.Index.ToString(CultureInfo.InvariantCulture), t.Arm, t.Arm == "A" ? t.Encoding : "n/a", Q(productHeader ?? ""), t.Domain, t.OrderId,
+ t.T0Utc.ToString("o", CultureInfo.InvariantCulture),
+ t.CreateHttp.ToString(CultureInfo.InvariantCulture), F(t.CreateMs), t.CreateStatus, Q(t.CreateOrderState), Q(t.CreateCertState), Q(t.CreateBody),
+ t.PutHttp.ToString(CultureInfo.InvariantCulture), F(t.PutMs), Q(t.PutBody),
+ Rel(t.TCreateResp), Rel(t.TPutResp), Rel(t.TFirstTrackObs), Rel(t.TIssuedTrack), Rel(t.TIssuedTrackLower), Q(t.TrackSeq),
+ t.SystemErrorSeen ? "1" : "0", Rel(t.SysErrFirst), Rel(t.SysErrCleared),
+ Rel(t.FirstSeenList), Rel(t.FirstSeenListLower), Q(t.FirstSeenListStatus), Rel(t.TIssuedList), Q(t.ListSeq),
+ seenBefore ? "1" : "0", (!double.IsNaN(t.FirstSeenList) && !double.IsNaN(t.TCreateResp)) ? F(t.TCreateResp - t.FirstSeenList) : "",
+ issuedBefore ? "1" : "0", (!double.IsNaN(t.TIssuedList) && !double.IsNaN(t.TCreateResp)) ? F(t.TCreateResp - t.TIssuedList) : "",
+ Rel(t.DlFirstAt), Q(t.DlFirstResult), Q(t.DlLastFailure), Rel(t.DlSuccessAt), F(t.DlMs), t.DlAttempts.ToString(CultureInfo.InvariantCulture), Q(t.CertPubKeyMatch), Q(t.CertCnMatch),
+ t.TrackPolls.ToString(CultureInfo.InvariantCulture), t.TrackErrors.ToString(CultureInfo.InvariantCulture),
+ t.ListPolls.ToString(CultureInfo.InvariantCulture), F(t.ListPollMedianS), t.ListErrors.ToString(CultureInfo.InvariantCulture),
+ Q(t.CleanupNote), Q(t.FinalState), Q(t.Error)
+ }));
+ }
+ File.WriteAllText(path, sb.ToString());
+ W($"CSV written: experiment-results/{Path.GetFileName(path)}");
+ }
+ catch (Exception ex)
+ {
+ W($"CSV write failed: {Describe(ex)}");
+ }
+ }
+
+ private void WriteSummary(List trials)
+ {
+ foreach (string arm in new[] { "A", "B" })
+ {
+ var ts = trials.Where(t => t.Arm == arm && !string.IsNullOrEmpty(t.OrderId)).ToList();
+ if (ts.Count == 0) continue;
+ W($"=== arm {arm} summary (seconds after t0; min / median / max; n) over {ts.Count} orders, {ts.Count(t => t.Issued)} issued ===");
+ void Row(string name, Func get)
+ {
+ var xs = ts.Select(get).Where(x => !double.IsNaN(x)).ToArray();
+ W(xs.Length == 0 ? $"{name,-34} n=0"
+ : $"{name,-34} {F(xs.Min())} / {F(Median(xs))} / {F(xs.Max())} (n={xs.Length})");
+ }
+ Row("create HTTP", t => t.CreateMs / 1000);
+ if (arm == "B") Row("PUT /csr HTTP", t => t.PutMs / 1000);
+ Row("first seen in listing", t => t.FirstSeenList - t.T0);
+ Row("issued (track)", t => t.TIssuedTrack - t.T0);
+ Row("issued (listing)", t => t.TIssuedList - t.T0);
+ Row("create resp (t_create_resp)", t => t.TCreateResp - t.T0);
+ Row("listing first seen -> create resp", t => t.TCreateResp - t.FirstSeenList);
+ Row("download ok at", t => t.DlSuccessAt - t.T0);
+ W($" system error seen in {ts.Count(t => t.SystemErrorSeen)}/{ts.Count}; cleared in {ts.Count(t => !double.IsNaN(t.SysErrCleared))}; " +
+ $"download ok {ts.Count(t => !double.IsNaN(t.DlSuccessAt))}/{ts.Count}; pubkey match {ts.Count(t => t.CertPubKeyMatch == "match")}; cn match {ts.Count(t => t.CertCnMatch == "match")}");
+ }
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/V2LifecycleTests.cs b/CERTInext.IntegrationTests/V2LifecycleTests.cs
new file mode 100644
index 0000000..68a4859
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2LifecycleTests.cs
@@ -0,0 +1,796 @@
+// 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.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Plugin-level integration tests for the V2 (OAuth2) API path — Tiers 1–3 (no DCV
+ /// build required). Unlike , which exercises
+ /// methods directly, these tests drive the full
+ /// IAnyCAPlugin surface (Enroll, Revoke, GetSingleRecord,
+ /// Synchronize) the way Keyfactor Command actually calls the plugin.
+ ///
+ /// All tests are gated behind CERTINEXT_USE_V2_API=1 plus valid V2 OAuth2
+ /// credentials and skip gracefully otherwise. See for the
+ /// full list of required environment variables.
+ ///
+ public class V2LifecycleTests : IClassFixture
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly string _v2Domain;
+ private readonly bool _v2Enabled;
+
+ public V2LifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+
+ var env = V2EnvHelper.LoadAndPromote();
+
+ _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL");
+ _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID");
+ _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET");
+ _v2Domain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com");
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Builds a wired for the V2 API. A single
+ /// serves both modes — in V2 mode it is the V2
+ /// base URL, and V2 auth reuses
+ /// /.
+ /// Deliberately does NOT set any V1-only field (ApiKey/AccountNumber/AuthMode) — proving
+ /// those are optional when UseV2Api is true is itself part of what these tests exercise
+ /// (Synchronize now uses V2 /reports/orders, not V1 GetOrderReport).
+ ///
+ private CERTInextConfig BuildV2Config(bool dcvEnabled = false, int? pageSize = null, int? syncLookbackHours = null)
+ {
+ return new CERTInextConfig
+ {
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ V2InlineCsr = V2EnvHelper.InlineCsrFromEnv, // default single-call; CERTINEXT_V2_INLINE_CSR=0 selects two-call
+ OAuthClientId = _v2ClientId,
+ OAuthClientSecret = _v2ClientSecret,
+
+ RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com",
+ RequestorIsdCode = "1",
+ RequestorMobileNumber = "0000000000",
+ SignerPlace = "Gateway Lab",
+ SignerIp = "127.0.0.1",
+
+ PageSize = pageSize ?? 100,
+
+ // Default 72h (Constants.ApiV2.DefaultSyncLookbackHours) is always added on top
+ // of lastSync regardless of how recent it is — on a busy shared sandbox that
+ // means every delta-sync test touches several days of orders (each issued row
+ // costs a live certificate download) unless narrowed here.
+ V2SyncLookbackHours = syncLookbackHours ?? Constants.ApiV2.DefaultSyncLookbackHours,
+
+ DcvEnabled = dcvEnabled,
+ DcvPropagationDelaySeconds = 5,
+ DcvTimeoutMinutes = 3
+ };
+ }
+
+ ///
+ /// Constructs a plugin instance wired to a real
+ /// built from (or a fresh
+ /// if none is supplied). Uses the two-arg test constructor so no
+ /// Initialize call is required.
+ ///
+ private CERTInextCAPlugin BuildV2Plugin(CERTInextConfig config = null)
+ {
+ config ??= BuildV2Config();
+ var client = new CERTInextClient(config);
+ return new CERTInextCAPlugin(client, config);
+ }
+
+ ///
+ /// Generates a fresh RSA-2048 PKCS#10 CSR for the given common name using
+ /// BouncyCastle only.
+ ///
+ private static string GenerateCsrPem(string commonName)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
+
+ var subject = new X509Name($"CN={commonName}");
+ var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private);
+
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ ///
+ /// Runs a full synchronization via the plugin and returns all collected records.
+ ///
+ private static async Task> RunSyncAsync(
+ CERTInextCAPlugin plugin, DateTime? lastSync = null, bool fullSync = true)
+ {
+ var buffer = new BlockingCollection(boundedCapacity: 10_000);
+ var collected = new List();
+
+ var syncTask = Task.Run(async () =>
+ {
+ await plugin.Synchronize(
+ buffer,
+ lastSync: lastSync,
+ fullSync: fullSync,
+ cancelToken: CancellationToken.None);
+
+ if (!buffer.IsAddingCompleted)
+ buffer.CompleteAdding();
+ });
+
+ foreach (var record in buffer.GetConsumingEnumerable())
+ collected.Add(record);
+
+ await syncTask;
+ return collected;
+ }
+
+ ///
+ /// Polls until the order reaches
+ /// GENERATED or FAILED, or the poll budget is exhausted.
+ ///
+ private static async Task WaitForIssuanceAsync(
+ CERTInextCAPlugin plugin, string caRequestId, int maxPolls = 6, int delaySeconds = 15)
+ {
+ AnyCAPluginCertificate record = null;
+ for (int poll = 1; poll <= maxPolls; poll++)
+ {
+ record = await plugin.GetSingleRecord(caRequestId);
+ if (record?.Status == (int)EndEntityStatus.GENERATED
+ || record?.Status == (int)EndEntityStatus.FAILED)
+ break;
+ if (poll < maxPolls)
+ await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
+ }
+ return record;
+ }
+
+ ///
+ /// Product info for the "DV SSL" gateway template in : pinned
+ /// (ProductCode set, e.g. 842) or the unpinned "V2 template" (no ProductCode, so
+ /// CERTInext resolves the product). See .
+ ///
+ private static EnrollmentProductInfo BuildV2ProductInfo(V2PinMode mode) =>
+ V2ProductPinning.Info(Constants.Products.DvSsl, mode);
+
+ ///
+ /// Resolves the order ID to exercise for tests that need a pre-existing V2 order.
+ /// Reads only CERTINEXT_V2_ORDER_ID — deliberately does not fall back to an
+ /// order ID produced by another test in this class, so results do not depend on
+ /// test run order.
+ ///
+ private static string ResolveOrderId()
+ => Environment.GetEnvironmentVariable("CERTINEXT_V2_ORDER_ID");
+
+ ///
+ /// Returns an issued (GENERATED) V2 order to exercise, plus the plugin instance
+ /// that owns it. Prefers CERTINEXT_V2_ORDER_ID if set; otherwise enrolls a
+ /// fresh order in this test and polls (bounded) for issuance, so tests using this
+ /// helper are self-contained and don't depend on env state or another test's run
+ /// order. Skip.Ifs (via ) when no env ID
+ /// is set and the freshly-enrolled order never reaches GENERATED within the poll
+ /// budget — sandboxes may require DCV to auto-issue.
+ ///
+ private async Task<(string orderId, CERTInextCAPlugin plugin)> EnsureIssuedOrderIdAsync(V2PinMode mode)
+ {
+ var plugin = BuildV2Plugin();
+ string envOrderId = ResolveOrderId();
+ // With an env order the test does not enroll, so only the pinned row is meaningful.
+ V2ProductPinning.SkipUnpinnedWhenOrderSuppliedByEnv(mode, envOrderId, "CERTINEXT_V2_ORDER_ID");
+ if (!string.IsNullOrWhiteSpace(envOrderId))
+ return (envOrderId, plugin);
+
+ var enrollResult = await plugin.Enroll(
+ csr: GenerateCsrPem(_v2Domain),
+ subject: $"CN={_v2Domain}",
+ san: new Dictionary { ["dns"] = new[] { _v2Domain } },
+ productInfo: BuildV2ProductInfo(mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ enrollResult.Should().NotBeNull();
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace();
+ _output.WriteLine(
+ $"{V2ProductPinning.Label(Constants.Products.DvSsl, mode)} EnsureIssuedOrderIdAsync: no CERTINEXT_V2_ORDER_ID set — enrolled fresh order {enrollResult.CARequestID}.");
+
+ var record = await WaitForIssuanceAsync(plugin, enrollResult.CARequestID);
+ Skip.If(record?.Status != (int)EndEntityStatus.GENERATED,
+ $"Freshly-enrolled order '{enrollResult.CARequestID}' did not reach GENERATED within the poll " +
+ $"budget (status={record?.Status}) — sandbox may require DCV to auto-issue. Set " +
+ "CERTINEXT_V2_ORDER_ID to a known-issued order to bypass enrollment.");
+
+ return (enrollResult.CARequestID, plugin);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Enroll() via the plugin, V2 path
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Runs twice: (template carries a ProductCode) and
+ /// (no ProductCode; CERTInext resolves the product).
+ ///
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task Enroll_V2_ReturnsCARequestID(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSsl, mode);
+
+ // Plugin logs to the test output, so a CA rejection (e.g. at Submit CSR) shows the call
+ // sequence; CERTINEXT_TEST_LOG_LEVEL=Trace adds the redacted request payloads.
+ var restoreLogging = TestOutputLogging.RouteTo(_output);
+ EnrollmentResult result;
+ try
+ {
+ var plugin = BuildV2Plugin();
+
+ result = await plugin.Enroll(
+ csr: GenerateCsrPem(_v2Domain),
+ subject: $"CN={_v2Domain}",
+ san: new Dictionary { ["dns"] = new[] { _v2Domain } },
+ productInfo: BuildV2ProductInfo(mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ }
+ finally
+ {
+ restoreLogging();
+ }
+
+ result.Should().NotBeNull();
+ result.CARequestID.Should().NotBeNullOrWhiteSpace(
+ "V2 Enroll must return a non-empty CARequestID — it is the stable foreign key for all future operations");
+ result.Status.Should().NotBe((int)EndEntityStatus.FAILED,
+ $"V2 Enroll must not FAILED at submission time; message: {result.StatusMessage}");
+
+ _output.WriteLine($"CARequestID: {result.CARequestID}");
+ _output.WriteLine($"Status: {result.Status}");
+ _output.WriteLine($"Message: {result.StatusMessage}");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Revoke() via the plugin, V2 path
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Opt-in cleanup: revokes one explicit, already-issued order through the plugin's
+ /// V2 Revoke with reason superseded (4), outside Command. Used to clean up lab
+ /// orders Command never imported and to reproduce an out-of-band CA-side revoke.
+ /// Gated behind CERTINEXT_REVOKE_ORDER_ID; never retries.
+ ///
+ [SkippableFact]
+ public async Task Revoke_V2_ExplicitOrder_Superseded()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ string orderId = Environment.GetEnvironmentVariable("CERTINEXT_REVOKE_ORDER_ID");
+ Skip.If(string.IsNullOrWhiteSpace(orderId), "CERTINEXT_REVOKE_ORDER_ID not set — skipping.");
+
+ var plugin = BuildV2Plugin();
+ var before = await plugin.GetSingleRecord(orderId);
+ _output.WriteLine($"Before: CARequestID={orderId}, Status={before?.Status}");
+ Skip.If(before?.Status != (int)EndEntityStatus.GENERATED,
+ $"Order '{orderId}' is in status {before?.Status} (not GENERATED) — not revoking.");
+
+ int revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 4 /* superseded */);
+ _output.WriteLine($"Revoke result: {revokeResult}");
+
+ var after = await plugin.GetSingleRecord(orderId);
+ _output.WriteLine($"After: Status={after?.Status}, RevocationDate={after?.RevocationDate:o}, RevocationReason={after?.RevocationReason}");
+ revokeResult.Should().Be((int)EndEntityStatus.REVOKED);
+ }
+
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task Revoke_V2_IssuedOrder_ReturnsRevoked(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSsl, mode);
+
+ var (orderId, plugin) = await EnsureIssuedOrderIdAsync(mode);
+
+ var current = await plugin.GetSingleRecord(orderId);
+ Skip.If(current?.Status != (int)EndEntityStatus.GENERATED,
+ $"Order '{orderId}' is in status {current?.Status} (not GENERATED) — revocation requires an issued certificate; skipping.");
+
+ int revokeResult;
+ try
+ {
+ revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 1 /* keyCompromise */);
+ }
+ catch (InvalidOperationException ex) when (ex.Message.Contains("still being processed"))
+ {
+ // Documented sandbox-timing quirk: the CA reports 'issued' via GetSingleRecord
+ // while still internally finalizing the order, and rejects revoke with 422
+ // ("Certificate Request still being processed") in that window. Retry once
+ // after a short delay before giving up — any other exception (or a second
+ // failure) must fail the test rather than be swallowed here.
+ _output.WriteLine($"Revoke rejected as still-processing; retrying once after 15s: {ex.Message}");
+ await Task.Delay(TimeSpan.FromSeconds(15));
+ try
+ {
+ revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 1);
+ }
+ catch (InvalidOperationException ex2) when (ex2.Message.Contains("still being processed"))
+ {
+ Skip.If(true,
+ $"Order '{orderId}' tracked as GENERATED but CA rejected revocation twice (sandbox timing): {ex2.Message}");
+ return; // unreachable
+ }
+ }
+
+ revokeResult.Should().Be((int)EndEntityStatus.REVOKED,
+ "V2 Revoke must return the REVOKED status code on success");
+ }
+
+ // ---------------------------------------------------------------------------
+ // GetSingleRecord() via the plugin, V2 path
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task GetSingleRecord_V2_Plugin_ReturnsDetails()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ string orderId = ResolveOrderId();
+ Skip.If(string.IsNullOrWhiteSpace(orderId),
+ "No V2 order ID available — set CERTINEXT_V2_ORDER_ID to a real V2 order to run this test.");
+
+ var plugin = BuildV2Plugin();
+ var record = await plugin.GetSingleRecord(orderId);
+
+ record.Should().NotBeNull("plugin.GetSingleRecord must return a record for a known V2 order");
+ record.CARequestID.Should().Be(orderId);
+ _output.WriteLine($"CARequestID: {record.CARequestID}");
+ _output.WriteLine($"Status: {record.Status}");
+ _output.WriteLine($"ProductID: {record.ProductID}");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Enroll -> Synchronize -> Revoke, full V2 lifecycle via the plugin
+ // ---------------------------------------------------------------------------
+
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task Enroll_Synchronize_Revoke_V2_FullLifecycle(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSsl, mode);
+
+ // Narrow lookback (1h): the plugin's default 72h margin makes an un-narrowed delta
+ // sync slow against this busy shared sandbox, and the order enrolled below is
+ // only seconds old.
+ var config = BuildV2Config(syncLookbackHours: 1);
+ var plugin = BuildV2Plugin(config);
+
+ // --- Enroll ---
+ var enrollResult = await plugin.Enroll(
+ csr: GenerateCsrPem(_v2Domain),
+ subject: $"CN={_v2Domain}",
+ san: new Dictionary { ["dns"] = new[] { _v2Domain } },
+ productInfo: BuildV2ProductInfo(mode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ enrollResult.Should().NotBeNull();
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace();
+ enrollResult.Status.Should().NotBe((int)EndEntityStatus.FAILED,
+ $"V2 Enroll must not FAILED at submission time; message: {enrollResult.StatusMessage}");
+
+ _output.WriteLine($"Enrolled V2 order {enrollResult.CARequestID}, status={enrollResult.Status}");
+
+ // --- Synchronize (V2 /reports/orders) ---
+ // Delta sync (fullSync=false, lastSync=recent) rather than a full historical
+ // pull — this sandbox account has accumulated 1000+ orders from prior test
+ // runs, and a full sync of the entire history is unnecessarily slow here; the
+ // order we just enrolled is recent, so a delta sync (with the configured
+ // lookback window) is sufficient to prove it surfaces via Synchronize.
+ var synced = await RunSyncAsync(BuildV2Plugin(config), lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false);
+ synced.Should().Contain(
+ r => r.CARequestID == enrollResult.CARequestID,
+ $"the newly enrolled V2 order '{enrollResult.CARequestID}' must appear in a delta sync " +
+ "via V2 /reports/orders");
+
+ var syncedRecord = synced.First(r => r.CARequestID == enrollResult.CARequestID);
+ _output.WriteLine($"Synced record status: {syncedRecord.Status}");
+
+ // --- Revoke — only if the sandbox has already auto-issued ---
+ if (syncedRecord.Status != (int)EndEntityStatus.GENERATED)
+ {
+ Skip.If(true,
+ $"Order '{enrollResult.CARequestID}' is in status {syncedRecord.Status} (not GENERATED) — " +
+ "sandbox may not auto-issue a V2 order without DCV; skipping revoke step.");
+ }
+
+ int revokeResult;
+ try
+ {
+ revokeResult = await plugin.Revoke(enrollResult.CARequestID, hexSerialNumber: string.Empty, revocationReason: 1);
+ }
+ catch (InvalidOperationException ex) when (ex.Message.Contains("still being processed"))
+ {
+ // Documented sandbox-timing quirk: the sandbox can report an order as 'issued'
+ // via TrackOrder/GetSingleRecord while still internally finalizing it, and
+ // reject a revoke attempted in that window with 422 "Certificate Request
+ // still being processed". Retry once after a short delay before giving up —
+ // any other exception must fail the test rather than be swallowed here.
+ _output.WriteLine($"Revoke rejected as still-processing; retrying once after 15s: {ex.Message}");
+ await Task.Delay(TimeSpan.FromSeconds(15));
+ try
+ {
+ revokeResult = await plugin.Revoke(enrollResult.CARequestID, hexSerialNumber: string.Empty, revocationReason: 1);
+ }
+ catch (InvalidOperationException ex2) when (ex2.Message.Contains("still being processed"))
+ {
+ Skip.If(true,
+ $"Order '{enrollResult.CARequestID}' tracked as GENERATED but CA rejected revocation " +
+ $"twice (sandbox timing): {ex2.Message}");
+ return; // unreachable
+ }
+ }
+
+ revokeResult.Should().Be((int)EndEntityStatus.REVOKED,
+ "Revoke must return the REVOKED status code on success");
+ }
+
+ // ---------------------------------------------------------------------------
+ // GetSingleRecord() cert-body check, V2 path
+ // ---------------------------------------------------------------------------
+
+ [SkippableTheory]
+ [V2PinModes]
+ public async Task GetSingleRecord_V2_IssuedOrder_HasParseableCertBody(V2PinMode mode)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ V2ProductPinning.Log(_output, Constants.Products.DvSsl, mode);
+
+ var (orderId, plugin) = await EnsureIssuedOrderIdAsync(mode);
+ var record = await WaitForIssuanceAsync(plugin, orderId, maxPolls: 1);
+
+ Skip.If(record?.Status != (int)EndEntityStatus.GENERATED,
+ $"Order '{orderId}' is not GENERATED (status={record?.Status}) — skipping cert-body check.");
+
+ record!.Certificate.Should().NotBeNullOrWhiteSpace(
+ "GetSingleRecord must populate the PEM body for a GENERATED V2 order");
+ record.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----");
+
+ // record.Certificate may be the leaf cert alone, or the leaf followed by one or
+ // more chain PEM blocks (AssembleV2CertChain concatenates them) — extract only the
+ // FIRST block. Naively stripping every BEGIN/END marker and decoding the
+ // concatenation as one base64 blob breaks as soon as a chain is present, because
+ // each block's own '=' padding then lands mid-string, which is illegal base64.
+ var firstBlock = System.Text.RegularExpressions.Regex.Match(
+ record.Certificate,
+ @"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----",
+ System.Text.RegularExpressions.RegexOptions.Singleline);
+ firstBlock.Success.Should().BeTrue("the certificate body must contain at least one PEM block");
+
+ var b64 = firstBlock.Groups[1].Value
+ .Replace("\r", string.Empty).Replace("\n", string.Empty).Trim();
+
+ Action parse = () => new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64));
+ parse.Should().NotThrow("the issued V2 certificate's leaf PEM block must be parseable");
+ }
+
+ // ---------------------------------------------------------------------------
+ // GetSingleRecord() across all synced orders, V2-configured plugin
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Runs a delta sync via V2 /reports/orders with a V2-configured (UseV2Api=true)
+ /// plugin, then calls GetSingleRecord for a sample of the resulting CARequestIDs.
+ /// All sampled IDs are now V2-native (from the V2 report itself, not a V1 listing), so
+ /// they are expected to resolve via the V2 family probe;
+ /// is tolerated only as a defensive allowance (e.g. an order deleted between sync and
+ /// this call) — this test's real job is to guard against any *other* unhandled exception
+ /// type escaping GetSingleRecord.
+ ///
+ [SkippableFact]
+ public async Task GetSingleRecord_V2_AllSyncedOrders_DoNotThrow()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ // Narrow lookback (1h) — this sandbox account has 1000+ historical orders, and the
+ // plugin's default 72h lookback margin is always added on top of lastSync
+ // regardless of how recent it is, so an un-narrowed delta sync here would touch
+ // several days of orders. Every issued row costs a live certificate download, and
+ // family resolution costs a sequential TrackOrder probe when not already known —
+ // an un-narrowed window can take several minutes against this shared sandbox.
+ var plugin = BuildV2Plugin(BuildV2Config(syncLookbackHours: 1));
+ var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false);
+ synced.Should().NotBeNull();
+ synced.Should().NotBeEmpty(
+ "the delta sync window must return at least one record from this sandbox account to sample " +
+ "GetSingleRecord against — an empty sync makes the rest of this test vacuous");
+
+ var sample = synced.Take(10).ToList();
+ _output.WriteLine($"Sampling {sample.Count} of {synced.Count} synced records for GetSingleRecord (V2-configured plugin).");
+
+ int ok = 0, keyNotFound = 0;
+ foreach (var rec in sample)
+ {
+ try
+ {
+ await plugin.GetSingleRecord(rec.CARequestID);
+ ok++;
+ }
+ catch (KeyNotFoundException)
+ {
+ // Tolerated defensively (e.g. sandbox timing/deletion) — every sampled ID
+ // came from the V2 report itself, so this should be rare, not expected.
+ keyNotFound++;
+ }
+ }
+
+ _output.WriteLine($"GetSingleRecord results: {ok} succeeded, {keyNotFound} KeyNotFoundException.");
+ (ok + keyNotFound).Should().Be(sample.Count,
+ "every sampled GetSingleRecord call must either succeed or throw the tolerated " +
+ "KeyNotFoundException — any other exception type must escape this loop and fail the test");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Synchronize() uses V2 /reports/orders when UseV2Api=true
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Sync_V2_UsesV2ReportsOrders_ReturnsRecords()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ // Narrow lookback (1h) — see the comment in GetSingleRecord_V2_AllSyncedOrders_DoNotThrow
+ // above for why the plugin's default 72h margin makes an un-narrowed delta sync slow
+ // against this shared, busy sandbox.
+ var plugin = BuildV2Plugin(BuildV2Config(syncLookbackHours: 1));
+ var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false);
+
+ synced.Should().NotBeNull();
+ synced.Should().NotBeEmpty(
+ "Synchronize must return the account's recent order inventory via V2 /reports/orders " +
+ "(Synchronize no longer falls back to V1 GetOrderReport when UseV2Api=true)");
+ synced.Should().OnlyContain(r => !string.IsNullOrWhiteSpace(r.CARequestID));
+
+ _output.WriteLine($"Synchronize (V2 /reports/orders) returned {synced.Count} record(s).");
+ foreach (var r in synced.Take(5))
+ _output.WriteLine($" CARequestID={r.CARequestID}, Status={r.Status}, ProductID={r.ProductID}");
+ }
+
+ ///
+ /// Hard acceptance criterion: Synchronize with
+ /// UseV2Api=true must succeed and return records with ZERO V1 credentials
+ /// configured at all — no ApiKey, no AccountNumber, no AuthMode, no V1-shaped ApiUrl.
+ /// Builds its own config (rather than reusing 's default) so
+ /// the absence of every V1-only field is explicit and self-evident at the call site.
+ ///
+ [SkippableFact]
+ public async Task Sync_V2_WithZeroV1Credentials_Succeeds()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ var config = new CERTInextConfig
+ {
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ OAuthClientId = _v2ClientId,
+ OAuthClientSecret = _v2ClientSecret,
+ RequestorName = "Keyfactor Test",
+ RequestorEmail = "test@example.com",
+ SignerPlace = "Gateway Lab",
+ SignerIp = "127.0.0.1",
+ PageSize = 100,
+ // Narrowed to keep this test's live API call volume bounded against a busy
+ // shared sandbox.
+ V2SyncLookbackHours = 1
+ // Deliberately NOT set: ApiKey, AccountNumber, AuthMode, OAuthTokenUrl — all
+ // V1-only fields. Their CERTInextConfig defaults (empty string / "AccessKey")
+ // are never read on this path once UseV2Api is true.
+ };
+
+ var client = new CERTInextClient(config);
+ var plugin = new CERTInextCAPlugin(client, config);
+
+ var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-1), fullSync: false);
+
+ synced.Should().NotBeNull();
+ _output.WriteLine(
+ $"Synchronize succeeded with UseV2Api=true and ZERO V1 credentials configured " +
+ $"(ApiKey/AccountNumber/AuthMode all unset). Returned {synced.Count} record(s).");
+ }
+
+ ///
+ /// Opt-in (walks the sandbox's entire order history — 1000+ orders per the other
+ /// tests' comments in this class): proves a full sync (fullSync=true,
+ /// lastSync=null) paginates to completion via V2 /reports/orders without
+ /// throwing or truncating silently.
+ ///
+ [SkippableFact]
+ public async Task Sync_V2_FullSync_PaginatesEntireHistory()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ Skip.If(string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("CERTINEXT_V2_FULL_SYNC_TEST")),
+ "CERTINEXT_V2_FULL_SYNC_TEST not set — a full sync walks this sandbox's entire order " +
+ "history and is opt-in to keep the default .V2 filter fast.");
+
+ var plugin = BuildV2Plugin();
+ var synced = await RunSyncAsync(plugin, lastSync: null, fullSync: true);
+
+ synced.Should().NotBeNull();
+ synced.Should().NotBeEmpty("a full sync of a non-empty sandbox account must return records");
+ _output.WriteLine($"Full sync (V2, entire history) returned {synced.Count} record(s).");
+ }
+
+ ///
+ /// Forces multi-page traversal with a small page size (5) on a delta sync, proving
+ /// ListOrdersV2Async's pagination is exercised end-to-end through Synchronize
+ /// against the live sandbox (not just the WireMock-based client unit tests).
+ ///
+ [SkippableFact]
+ public async Task Sync_V2_SmallPageSize_PaginatesAcrossMultiplePages()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ // A narrow (2h) window with pageSize=5 still forces multi-page traversal whenever
+ // this busy shared sandbox has more than 5 matching orders — no need for a wide
+ // window (e.g. 30 days), which would also multiply live per-row download calls
+ // for no added pagination proof.
+ var config = BuildV2Config(pageSize: 5, syncLookbackHours: 1);
+ var plugin = BuildV2Plugin(config);
+
+ var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddHours(-2), fullSync: false);
+
+ synced.Should().NotBeNull();
+ _output.WriteLine(
+ $"Delta sync (2h window, pageSize=5) returned {synced.Count} record(s) — pageSize=5 " +
+ "forces multi-page traversal whenever the account has more than 5 matching orders.");
+ }
+ }
+
+ ///
+ /// Shared helper for loading ~/.env_certinext_v2. V2 test classes must read their
+ /// values from the dictionary returns, never from process env:
+ /// keys the V1 also reads are deliberately NOT
+ /// promoted. Used by , V2DcvLifecycleTests, and the
+ /// other V2 test classes so they don't duplicate env-loading logic.
+ ///
+ internal static class V2EnvHelper
+ {
+ ///
+ /// The V2 enrollment tests run with the connector's default V2InlineCsr=true (SSL/TLS
+ /// single-call create). CERTINEXT_V2_INLINE_CSR=0 in the real process environment selects
+ /// the two-call flow instead; unset or any other value keeps the default. Read from the process environment only (it is an
+ /// opt-in-only flag, never promoted from an env file).
+ ///
+ public static bool InlineCsrFromEnv =>
+ Environment.GetEnvironmentVariable("CERTINEXT_V2_INLINE_CSR")?.Trim() != "0";
+
+ ///
+ /// Loads ~/.env_certinext_v2 and returns the merged environment dictionary (V2 file
+ /// values win over process env). V2-only file keys (e.g. CERTINEXT_CLIENT_ID,
+ /// CERTINEXT_USE_V2_API) are still promoted into process env; keys in
+ /// (CERTINEXT_API_URL and the rest)
+ /// are never written to process env. The V1 fixture lets real env vars override
+ /// ~/.env_certinext, so promoting the V2 values of those shared names would
+ /// corrupt the V1 fixture of any class constructed later in the same test process.
+ ///
+ public static Dictionary LoadAndPromote()
+ {
+ string v2Path = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".env_certinext_v2");
+
+ var (env, fileKeys) = LoadEnvFile(v2Path);
+
+ foreach (string key in PromotableKeys(fileKeys))
+ if (env.TryGetValue(key, out string fv))
+ Environment.SetEnvironmentVariable(key, fv);
+
+ return env;
+ }
+
+ ///
+ /// The V2-file keys may write into process env: every file
+ /// key except those the V1 side reads ()
+ /// and the fixture's opt-in-only flags ().
+ /// Without the latter exclusion, a value left in ~/.env_certinext_v2 for
+ /// one of those flags (e.g. CERTINEXT_V2_OPS_TESTS, CERTINEXT_PRIVATE_PKI_LIVE) would be
+ /// read as unset by the first test class constructed in a run (before this method's
+ /// promotion step runs), then promoted into real process env, silently arming every
+ /// later-constructed test class in the same run even though no flag was ever exported in
+ /// the shell. Exposed internal for direct unit-testing.
+ ///
+ internal static List PromotableKeys(IEnumerable fileKeys)
+ {
+ var keys = new List();
+ foreach (string key in fileKeys)
+ if (!IntegrationTestFixture.V1EnvKeys.Contains(key)
+ && !IntegrationTestFixture._optInOnlyFlags.Contains(key))
+ keys.Add(key);
+ return keys;
+ }
+
+ ///
+ /// Loads a KEY=VALUE env file and merges with process env vars. File values take
+ /// priority over process env because the fixture may have already promoted V1
+ /// values (e.g. CERTINEXT_API_URL with /emSignHub-API suffix) into process env,
+ /// and the V2 base URL differs. Returns the merged dict and the set of keys
+ /// defined in the file.
+ ///
+ public static (Dictionary env, HashSet fileKeys) LoadEnvFile(string path)
+ {
+ var fileKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (System.Collections.DictionaryEntry de in Environment.GetEnvironmentVariables())
+ {
+ string k = de.Key?.ToString();
+ string v = de.Value?.ToString();
+ if (!string.IsNullOrEmpty(k)) result[k] = v ?? string.Empty;
+ }
+
+ if (File.Exists(path))
+ {
+ foreach (string rawLine in File.ReadAllLines(path))
+ {
+ string line = rawLine.Trim();
+ if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue;
+
+ int idx = line.IndexOf('=');
+ if (idx <= 0) continue;
+
+ string key = line.Substring(0, idx).Trim();
+ string val = line.Substring(idx + 1).Trim().Trim('"').Trim('\'');
+ result[key] = val;
+ fileKeys.Add(key);
+ }
+ }
+
+ return (result, fileKeys);
+ }
+
+ public static string GetEnv(Dictionary env, string key, string defaultValue = "")
+ => env.TryGetValue(key, out string v) && !string.IsNullOrWhiteSpace(v) ? v : defaultValue;
+ }
+}
diff --git a/CERTInext.IntegrationTests/V2OrderCleanup.cs b/CERTInext.IntegrationTests/V2OrderCleanup.cs
new file mode 100644
index 0000000..c6e1d02
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2OrderCleanup.cs
@@ -0,0 +1,85 @@
+// 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.Threading.Tasks;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Sandbox order cleanup keyed on the order's own V2 status, not on what GetSingleRecord
+ /// returns. GetSingleRecord reports an order as pending until its certificate is
+ /// downloadable, and right after issued the download can still return 422 (EMS-1165); a
+ /// cleanup that cancels whenever the record is not yet GENERATED therefore cancels an order that
+ /// is already issuing, and the sandbox still produces the certificate (leaked order 9185757376,
+ /// and the F001 child of a cancelled OV order). So: issued is revoked (one retry on
+ /// "still being processed"), anything not yet terminal is cancelled, and the order is re-tracked
+ /// afterwards to confirm the outcome.
+ ///
+ internal static class V2OrderCleanup
+ {
+ public static async Task RevokeOrCancelAsync(
+ CERTInextClient client, string orderId, string cancelReason, Action log)
+ {
+ if (string.IsNullOrWhiteSpace(orderId))
+ return true;
+
+ try
+ {
+ var (family, status) = await client.ResolveAndTrackOrderV2WithFamilyAsync(orderId);
+ string st = status.Status ?? string.Empty;
+ if (IsTerminal(st))
+ {
+ log($"Cleanup: order {orderId} already terminal ({st}).");
+ return true;
+ }
+
+ if (string.Equals(st, Constants.ApiV2.StatusIssued, StringComparison.OrdinalIgnoreCase))
+ {
+ var revoke = new V2RevokeRequest { Reason = "superseded", Note = "V2 integration test cleanup" };
+ try
+ {
+ await client.RevokeOrderV2Async(family, orderId, revoke);
+ }
+ catch (InvalidOperationException ex) when (ex.Message.Contains("still being processed"))
+ {
+ await Task.Delay(TimeSpan.FromSeconds(15));
+ await client.RevokeOrderV2Async(family, orderId, revoke);
+ }
+ var after = await client.ResolveAndTrackOrderV2Async(orderId);
+ log($"Cleanup: revoked issued order {orderId}; re-tracked status={after.Status}.");
+ return IsTerminal(after.Status);
+ }
+
+ var outcome = await client.CancelOrderV2Async(family, orderId, cancelReason);
+ var tracked = await client.ResolveAndTrackOrderV2Async(orderId);
+ log($"Cleanup: cancelled non-issued order {orderId} (was {st}) -> {outcome}; re-tracked status={tracked.Status}.");
+ return IsTerminal(tracked.Status);
+ }
+ catch (Exception ex)
+ {
+ log($"Cleanup FAILED for order {orderId}: {ex.GetType().Name}: {ex.Message}. " +
+ "Revoke/cancel it by hand in the CERTInext portal if it should not remain pending.");
+ return false;
+ }
+ }
+
+ private static bool IsTerminal(string status) =>
+ string.Equals(status, Constants.ApiV2.StatusRevoked, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(status, Constants.ApiV2.StatusCancelled, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(status, Constants.ApiV2.StatusRejected, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/CERTInext.IntegrationTests/V2OrderCleanupTests.cs b/CERTInext.IntegrationTests/V2OrderCleanupTests.cs
new file mode 100644
index 0000000..3ae301f
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2OrderCleanupTests.cs
@@ -0,0 +1,312 @@
+// 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.Globalization;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Explicit-ID cleanup of V2 SANDBOX orders (fake credits) left behind by experiments.
+ /// Opt-in: CERTINEXT_V2_CLEANUP_ORDERS=<comma-separated order ids> as a REAL
+ /// environment variable (it is never read from an env file). Credentials come from
+ /// ~/.env_certinext_v2.
+ ///
+ /// Only the listed ids are ever acted on. For each: TrackOrder (record status) -> revoke if
+ /// issued, cancel if pending, skip if already revoked / cancelled / rejected / expired ->
+ /// TrackOrder again (verify). A narrow read-only order-report window (yesterday..tomorrow) is
+ /// read once to report each listed order's creation date and domain; no full sync is run and
+ /// nothing is acted on by pattern. Set CERTINEXT_V2_CLEANUP_DRYRUN=1 to track and report
+ /// details only (no revoke/cancel). Set
+ /// CERTINEXT_V2_CLEANUP_FORCE_CANCEL=1 (single id only) to make one raw cancel attempt instead of a revoke. Emails are scrubbed from all output.
+ ///
+ public class V2OrderCleanupTests : IClassFixture
+ {
+ private const string OptInFlag = "CERTINEXT_V2_CLEANUP_ORDERS";
+ private const int MaxOrders = 25;
+ private const string CleanupReason = "Sandbox test order cleanup (issue 0090 lab race repro).";
+
+ private readonly ITestOutputHelper _output;
+ private readonly string _apiUrl;
+ private readonly string _clientId;
+ private readonly string _clientSecret;
+ private readonly string _groupNumber;
+ private readonly bool _v2Enabled;
+
+ private static readonly Regex EmailRx =
+ new(@"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", RegexOptions.Compiled);
+
+ public V2OrderCleanupTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _output = output;
+ var env = V2EnvHelper.LoadAndPromote();
+ _apiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL");
+ _clientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID");
+ _clientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET");
+ _groupNumber = V2EnvHelper.GetEnv(env, "CERTINEXT_GROUP_NUMBER", string.Empty);
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_apiUrl)
+ && !string.IsNullOrWhiteSpace(_clientId)
+ && !string.IsNullOrWhiteSpace(_clientSecret);
+ }
+
+ private static string Scrub(string s)
+ {
+ if (string.IsNullOrEmpty(s)) return s ?? string.Empty;
+ s = EmailRx.Replace(s, "");
+ return s.Length > 500 ? s.Substring(0, 500) + "..." : s;
+ }
+
+ private void W(string s) => _output.WriteLine(Scrub(s));
+
+ private static string Describe(Exception ex) => $"{ex.GetType().Name}: {ex.Message}";
+
+ private CERTInextConfig BuildConfig() => new CERTInextConfig
+ {
+ ApiUrl = _apiUrl,
+ UseV2Api = true,
+ OAuthClientId = _clientId,
+ OAuthClientSecret = _clientSecret,
+ RequestorName = "Keyfactor Integration Test",
+ RequestorIsdCode = "1",
+ RequestorMobileNumber = "0000000000",
+ SignerPlace = "Gateway Lab",
+ SignerIp = "127.0.0.1",
+ GroupNumber = _groupNumber,
+ PageSize = 100,
+ DcvEnabled = false
+ };
+
+ private static bool IsTerminalNoAction(string status) =>
+ string.Equals(status, Constants.ApiV2.StatusRevoked, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(status, Constants.ApiV2.StatusCancelled, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(status, Constants.ApiV2.StatusRejected, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(status, Constants.ApiV2.StatusExpired, StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsPending(string status) =>
+ !string.IsNullOrEmpty(status) && status.StartsWith("pending-", StringComparison.OrdinalIgnoreCase);
+
+ private sealed class Row
+ {
+ public string OrderId;
+ public string Before = "-";
+ public string Action = "none";
+ public string After = "-";
+ public string Notes = string.Empty;
+ }
+
+ [SkippableFact]
+ public async Task V2_CleanupExplicitOrders_RevokeOrCancelListedIdsOnly()
+ {
+ Skip.If(!_v2Enabled,
+ "V2 credentials not configured in ~/.env_certinext_v2 (CERTINEXT_USE_V2_API, API_URL, CLIENT_ID, CLIENT_SECRET).");
+ string flag = Environment.GetEnvironmentVariable(OptInFlag);
+ Skip.If(string.IsNullOrWhiteSpace(flag),
+ $"Opt-in: set {OptInFlag}= as a real environment variable.");
+
+ var ids = flag.Split(new[] { ',', ' ', ';' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(s => s.Trim()).Distinct(StringComparer.Ordinal).ToList();
+ Assert.NotEmpty(ids);
+ Assert.True(ids.Count <= MaxOrders, $"At most {MaxOrders} order ids per run.");
+ Assert.All(ids, id => Assert.Matches("^[0-9]{1,20}$", id)); // digits only: refuse anything pattern-like
+
+ var cfg = BuildConfig();
+ using var client = new CERTInextClient(cfg);
+ string family = Constants.ApiV2.FamilySsl;
+ bool dryRun = Environment.GetEnvironmentVariable("CERTINEXT_V2_CLEANUP_DRYRUN") == "1";
+ if (dryRun) W("DRY RUN: track + details only; no revoke/cancel will be sent.");
+ bool forceCancel = Environment.GetEnvironmentVariable("CERTINEXT_V2_CLEANUP_FORCE_CANCEL") == "1";
+ Assert.True(!forceCancel || ids.Count == 1, "CERTINEXT_V2_CLEANUP_FORCE_CANCEL requires exactly one order id.");
+ if (forceCancel) W("FORCE CANCEL: one raw cancel attempt (no revoke, no retry) for the single listed order.");
+
+ W($"V2 order cleanup: {ids.Count} listed order(s), family={family}");
+
+ // 1) Read-only detail lookup (creation date, domain) from a narrow order-report window.
+ // Only the listed ids are retained; nothing here drives an action.
+ var listed = new Dictionary(StringComparer.Ordinal);
+ try
+ {
+ string from = DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ string to = DateTime.UtcNow.AddDays(1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ int scanned = 0;
+ await foreach (var e in client.ListOrdersV2Async(from, to, 100))
+ {
+ scanned++;
+ if (e?.OrderNumber != null && ids.Contains(e.OrderNumber))
+ listed[e.OrderNumber] = e;
+ }
+ W($"order-report window {from}..{to}: scanned={scanned}, matched listed ids={listed.Count}");
+ }
+ catch (Exception ex)
+ {
+ W("order-report lookup failed (details only; cleanup continues): " + Describe(ex));
+ }
+
+ foreach (var id in ids)
+ {
+ if (listed.TryGetValue(id, out var e))
+ W($"detail order={id} domain={e.DomainName} orderStatus={e.OrderStatus} certStatus={e.CertificateStatus} " +
+ $"orderDate={e.OrderDate} productCode={e.ProductCode} requestNumber={e.RequestNumber}");
+ else
+ W($"detail order={id} NOT present in order-report window");
+ }
+
+ // 2) Per-order: track -> act -> track.
+ var rows = new List();
+ foreach (var id in ids)
+ {
+ var row = new Row { OrderId = id };
+ rows.Add(row);
+
+ V2OrderStatusResponse before = null;
+ try
+ {
+ before = await client.TrackOrderV2Async(family, id);
+ row.Before = before.Status ?? "(null)";
+ W($"track-before order={id} status={before.Status} domain={before.Domain} variant={before.ProductVariant} " +
+ $"issuedAt={before.IssuedAt} expiresAt={before.ExpiresAt}");
+ }
+ catch (KeyNotFoundException)
+ {
+ row.Before = "NOT FOUND";
+ row.Action = "skipped (not found in " + family + ")";
+ W($"order={id} not found in family {family}; no action taken");
+ continue;
+ }
+ catch (Exception ex)
+ {
+ row.Before = "TRACK ERROR";
+ row.Action = "skipped";
+ row.Notes = Scrub(Describe(ex));
+ continue;
+ }
+
+ string status = before.Status;
+ if (dryRun)
+ {
+ row.Action = "dry-run (none)";
+ row.After = row.Before;
+ continue;
+ }
+ try
+ {
+ if (IsTerminalNoAction(status))
+ {
+ row.Action = "skip (already " + status + ")";
+ }
+ else if (forceCancel)
+ {
+ // Single attempt via the raw helper (same POST .../cancel endpoint as CancelOrderV2Async)
+ // so the exact HTTP status and body detail are captured; no retry, no other mutation.
+ row.Action = "force-cancel (single attempt)";
+ try
+ {
+ await V2RawHttpHelpers.CancelSslOrderRawAsync(_apiUrl, _clientId, _clientSecret, id, CleanupReason);
+ row.Action += " -> accepted (2xx)";
+ }
+ catch (Exception cex)
+ {
+ row.Action += " -> REJECTED";
+ row.Notes += "cancel response: " + Scrub(Describe(cex));
+ }
+ }
+ else if (string.Equals(status, Constants.ApiV2.StatusIssued, StringComparison.OrdinalIgnoreCase))
+ {
+ row.Action = await RevokeAsync(client, family, id, row);
+ }
+ else if (IsPending(status))
+ {
+ var outcome = await client.CancelOrderV2Async(family, id, CleanupReason);
+ row.Action = "cancel -> " + outcome;
+ }
+ else
+ {
+ row.Action = "skip (unrecognised status; not acting)";
+ }
+ }
+ catch (Exception ex)
+ {
+ row.Action += (row.Action == "none" ? "" : "; ") + "ACTION FAILED";
+ row.Notes += (row.Notes.Length > 0 ? " | " : "") + Scrub(Describe(ex));
+ }
+
+ try
+ {
+ var after = await client.TrackOrderV2Async(family, id);
+ row.After = after.Status ?? "(null)";
+ if (after.Revocation != null)
+ row.Notes += (row.Notes.Length > 0 ? " | " : "") +
+ $"revocation.reason={after.Revocation.Reason} processedAt={after.Revocation.ProcessedAt}";
+ }
+ catch (Exception ex)
+ {
+ row.After = "TRACK ERROR";
+ row.Notes += (row.Notes.Length > 0 ? " | " : "") + Scrub(Describe(ex));
+ }
+ }
+
+ if (forceCancel)
+ {
+ try
+ {
+ string from2 = DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ string to2 = DateTime.UtcNow.AddDays(1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ bool found = false;
+ await foreach (var e2 in client.ListOrdersV2Async(from2, to2, 100))
+ if (e2?.OrderNumber != null && ids.Contains(e2.OrderNumber))
+ {
+ found = true;
+ W($"listing-after order={e2.OrderNumber} orderStatus={e2.OrderStatus} certStatus={e2.CertificateStatus} orderDate={e2.OrderDate}");
+ }
+ if (!found) W("listing-after: listed order not present in window");
+ }
+ catch (Exception lex) { W("listing-after failed: " + Describe(lex)); }
+ }
+
+ W("=== result: order | before | action | after | notes ===");
+ foreach (var r in rows)
+ W($"RESULT {r.OrderId} | {r.Before} | {r.Action} | {r.After} | {r.Notes}");
+
+ if (dryRun || forceCancel) return; // force-cancel is a probe: outcome is reported, not asserted
+
+ // Pass only when every listed order ended in a terminal non-issued state.
+ var bad = rows.Where(r => !IsTerminalNoAction(r.After)).Select(r => $"{r.OrderId}({r.After})").ToList();
+ Assert.True(bad.Count == 0, "Orders not in a terminal state after cleanup: " + string.Join(", ", bad));
+ }
+
+ ///
+ /// Revoke an issued order. Tries "superseded" (what plugin reason 4 maps to); if CERTInext
+ /// rejects it, records the exact rejection in and retries once with
+ /// "unspecified" so a spec-vs-live reason discrepancy is surfaced instead of hidden.
+ ///
+ private static async Task RevokeAsync(CERTInextClient client, string family, string id, Row row)
+ {
+ try
+ {
+ await client.RevokeOrderV2Async(family, id,
+ new V2RevokeRequest { Reason = "superseded", Note = CleanupReason });
+ return "revoke(reason=superseded)";
+ }
+ catch (Exception ex)
+ {
+ row.Notes += "revoke(superseded) rejected: " + Scrub(Describe(ex));
+ }
+
+ await client.RevokeOrderV2Async(family, id,
+ new V2RevokeRequest { Reason = "unspecified", Note = CleanupReason });
+ return "revoke(reason=unspecified, after superseded rejected)";
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/V2OrderWindowSweepTests.cs b/CERTInext.IntegrationTests/V2OrderWindowSweepTests.cs
new file mode 100644
index 0000000..65ab0c8
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2OrderWindowSweepTests.cs
@@ -0,0 +1,268 @@
+// 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.
+//
+// Opt-in, read-only-by-default sweep over an arbitrary UTC date/time window of the V2 orders
+// report (GET /api/certinext/v2/reports/orders). Takes an explicit, caller-supplied window and
+// lists every order it finds in it — a general-purpose tool for manually auditing/cleaning up a
+// date range after a batch of live-API work (e.g. a day's worth of V2 lifecycle tests).
+//
+// Env:
+// CERTINEXT_V2_SWEEP_FROM / CERTINEXT_V2_SWEEP_TO — UTC ISO-8601 timestamps, e.g.
+// 2026-09-30T00:00:00Z. Both required; the test skips (does not default to any window) if
+// either is unset.
+// CERTINEXT_V2_SWEEP_CANCEL_IDS — optional, comma-separated V2 order IDs. When unset, this
+// test only LISTS orders in the window (orderId, domain, status, productCode, orderDate) —
+// fully read-only. When set, it additionally cancels exactly those IDs, but only if each one
+// is actually found in the listed window and is not already in a terminal state
+// (cancelled/revoked/rejected). No bulk "cancel everything" mode exists here deliberately.
+//
+// Terminal state is decided by Track Order's own `status` field (via
+// CERTInextClient.ResolveAndTrackOrderV2WithFamilyAsync), not the orders report's human-readable
+// orderStatus/certificateStatus display strings. Exactly one cancel attempt per id; never
+// retried, matching every other cleanup/sweep helper in this project
+// (V2FullLifecycleTests.CleanupOrderAsync, etc.).
+//
+// The V2 orders report only filters by calendar date (YYYY-MM-DD) server-side — this test
+// requests the covering date range, then re-applies the caller's precise sub-day window
+// client-side against each row's own orderDate.
+//
+// Gating: requires the CERTINEXT_V2_OPS_TESTS=1 opt-in (listed in
+// IntegrationTestFixture._optInOnlyFlags, read from the real process environment before
+// V2EnvHelper.LoadAndPromote() runs) — this sweep can cancel real sandbox orders, so it needs an
+// explicit go/no-go, shared with the other opt-in V2 ops/diagnostic tests.
+//
+// Logging: every domain value is passed through CERTInextClient.ApplyLoggingRedaction (same
+// default-off PII posture as every other V2 live test in this repo) before being written via
+// ITestOutputHelper.
+//
+// Run (list-only):
+// set -a; . ~/.env_certinext; set +a
+// export CERTINEXT_V2_OPS_TESTS=1
+// export CERTINEXT_V2_SWEEP_FROM=2026-09-25T00:00:00Z
+// export CERTINEXT_V2_SWEEP_TO=2026-10-01T00:00:00Z
+// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release -p:DcvSupport=false \
+// --filter "FullyQualifiedName~Sweep_ListRecentOrders_ByWindow" --logger "console;verbosity=detailed"
+//
+// Add CERTINEXT_V2_SWEEP_CANCEL_IDS=12345,67890 to also cancel those two specific orders (only
+// if each is found in the window and is not already terminal).
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Globalization;
+ using System.Linq;
+ using System.Threading.Tasks;
+ using FluentAssertions;
+ using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+ using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ public class V2OrderWindowSweepTests : IClassFixture
+ {
+ private const string OptInFlag = "CERTINEXT_V2_OPS_TESTS";
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ private readonly bool _armed;
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly bool _v2Enabled;
+
+ public V2OrderWindowSweepTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+
+ // Read the opt-in flag from the real process environment BEFORE promoting the V2 env
+ // file (mirrors PrivatePkiV2LiveTests) — a value left in ~/.env_certinext_v2 must
+ // never arm this file. IntegrationTestFixture's own _optInOnlyFlags list already
+ // keeps ~/.env_certinext from arming it either.
+ _armed = Environment.GetEnvironmentVariable(OptInFlag)?.Trim() == "1";
+
+ var env = V2EnvHelper.LoadAndPromote();
+ _v2ApiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL");
+ _v2ClientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID");
+ _v2ClientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET");
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+ }
+
+ private CERTInextClient BuildV2Client() => new CERTInextClient(new CERTInextConfig
+ {
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ OAuthClientId = _v2ClientId,
+ OAuthClientSecret = _v2ClientSecret,
+ RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com",
+ SignerIp = "127.0.0.1",
+ SignerPlace = "Gateway Lab",
+ PageSize = 100
+ });
+
+ ///
+ /// Redacts emails/other personal data before any row reaches ITestOutputHelper — same
+ /// default-off PII posture as every other V2 live test in this repo (see
+ /// CERTInextClient.ApplyLoggingRedaction; reachable here via
+ /// InternalsVisibleTo("CERTInext.IntegrationTests")). Domain names themselves are not
+ /// touched by this redaction.
+ ///
+ private static string RedactForLog(string value) =>
+ CERTInextClient.ApplyLoggingRedaction(value, logSensitiveRequestData: false);
+
+ [SkippableFact]
+ public async Task Sweep_ListRecentOrders_ByWindow_DryRun_ThenCancelExplicitIds()
+ {
+ Skip.If(!_armed,
+ $"{OptInFlag}=1 not set in the real process environment — this sweep can cancel real sandbox " +
+ "orders when CERTINEXT_V2_SWEEP_CANCEL_IDS is set, and requires an explicit go/no-go. Skipping.");
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ string fromRaw = Environment.GetEnvironmentVariable("CERTINEXT_V2_SWEEP_FROM");
+ string toRaw = Environment.GetEnvironmentVariable("CERTINEXT_V2_SWEEP_TO");
+ Skip.If(string.IsNullOrWhiteSpace(fromRaw) || string.IsNullOrWhiteSpace(toRaw),
+ "CERTINEXT_V2_SWEEP_FROM and CERTINEXT_V2_SWEEP_TO (UTC ISO-8601) must both be set — this sweep " +
+ "does not default to any particular window. Skipping.");
+
+ const DateTimeStyles utcStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal;
+
+ if (!DateTime.TryParse(fromRaw, CultureInfo.InvariantCulture, utcStyles, out DateTime fromUtc))
+ throw new ArgumentException($"CERTINEXT_V2_SWEEP_FROM='{fromRaw}' is not a parseable UTC ISO-8601 timestamp.");
+ if (!DateTime.TryParse(toRaw, CultureInfo.InvariantCulture, utcStyles, out DateTime toUtc))
+ throw new ArgumentException($"CERTINEXT_V2_SWEEP_TO='{toRaw}' is not a parseable UTC ISO-8601 timestamp.");
+ if (toUtc <= fromUtc)
+ throw new ArgumentException($"CERTINEXT_V2_SWEEP_TO ({toUtc:O}) must be after CERTINEXT_V2_SWEEP_FROM ({fromUtc:O}).");
+
+ var cancelIds = (Environment.GetEnvironmentVariable("CERTINEXT_V2_SWEEP_CANCEL_IDS") ?? string.Empty)
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ // The V2 orders report only filters by calendar date (YYYY-MM-DD) server-side —
+ // request the covering date range, then apply the caller's precise sub-day window
+ // client-side against each row's own orderDate.
+ string apiFrom = fromUtc.Date.ToString("yyyy-MM-dd");
+ string apiTo = toUtc.Date.AddDays(1).ToString("yyyy-MM-dd");
+
+ _output.WriteLine("=== V2 order window sweep ===");
+ _output.WriteLine($"Window: {fromUtc:O} .. {toUtc:O} (UTC). Report query date range: {apiFrom}..{apiTo}.");
+ _output.WriteLine(cancelIds.Count > 0
+ ? $"Cancel targets (CERTINEXT_V2_SWEEP_CANCEL_IDS): {string.Join(", ", cancelIds)}"
+ : "No CERTINEXT_V2_SWEEP_CANCEL_IDS set — list-only dry run; nothing will be cancelled.");
+
+ using CERTInextClient client = BuildV2Client();
+
+ var rowsInWindow = new List();
+ int rowsScanned = 0;
+
+ await foreach (var row in client.ListOrdersV2Async(apiFrom, apiTo, pageSize: 100))
+ {
+ rowsScanned++;
+
+ bool parsed = DateTime.TryParse(row.OrderDate, CultureInfo.InvariantCulture, utcStyles, out DateTime rowDate);
+
+ // A row with an unparseable/missing orderDate is kept rather than silently
+ // dropped — this is a read-only listing, so erring toward showing more (and
+ // flagging the parse miss) beats erring toward hiding a row the operator
+ // actually wanted to see.
+ if (parsed && (rowDate < fromUtc || rowDate > toUtc))
+ continue;
+
+ rowsInWindow.Add(row);
+
+ _output.WriteLine(
+ $"LISTED | OrderId={row.OrderNumber ?? ""} Domain={RedactForLog(row.DomainName)} " +
+ $"Status={row.OrderStatus ?? ""}/{row.CertificateStatus ?? ""} " +
+ $"ProductCode={row.ProductCode ?? ""} OrderDate={row.OrderDate ?? ""}" +
+ (parsed ? string.Empty : " (orderDate unparseable — kept anyway)"));
+ }
+
+ _output.WriteLine($"Report rows scanned (date-range query): {rowsScanned}. Rows within the precise window: {rowsInWindow.Count}.");
+
+ bool anyCancelFailed = false;
+ var matchedCancelIds = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (string targetId in cancelIds)
+ {
+ var row = rowsInWindow.FirstOrDefault(r => string.Equals(r.OrderNumber, targetId, StringComparison.OrdinalIgnoreCase));
+ if (row == null)
+ {
+ _output.WriteLine(
+ $"SUMMARY | OrderId={targetId} Cancel=SKIPPED (not found in the listed window — " +
+ "not touching an order outside it)");
+ continue;
+ }
+
+ matchedCancelIds.Add(targetId);
+
+ try
+ {
+ var (family, status) = await client.ResolveAndTrackOrderV2WithFamilyAsync(targetId);
+
+ bool terminal =
+ string.Equals(status.Status, Constants.ApiV2.StatusCancelled, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(status.Status, Constants.ApiV2.StatusRevoked, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(status.Status, Constants.ApiV2.StatusRejected, StringComparison.OrdinalIgnoreCase);
+
+ string cancelOutcome;
+ if (terminal)
+ {
+ cancelOutcome = $"SKIPPED (already {status.Status})";
+ }
+ else
+ {
+ try
+ {
+ var outcome = await client.CancelOrderV2Async(
+ family, targetId,
+ "V2 order-window sweep — explicitly listed in CERTINEXT_V2_SWEEP_CANCEL_IDS.");
+ cancelOutcome = outcome.ToString();
+ }
+ catch (Exception cancelEx)
+ {
+ anyCancelFailed = true;
+ cancelOutcome = $"FAILED ({cancelEx.GetType().Name}: {cancelEx.Message})";
+ }
+ }
+
+ _output.WriteLine(
+ $"SUMMARY | OrderId={targetId} Domain={RedactForLog(row.DomainName)} " +
+ $"StatusBefore={status.Status ?? ""} Cancel={cancelOutcome}");
+ }
+ catch (Exception ex)
+ {
+ anyCancelFailed = true;
+ _output.WriteLine(
+ $"SUMMARY | OrderId={targetId} Domain={RedactForLog(row.DomainName)} " +
+ $"Cancel=FAILED (could not resolve product family/status: {ex.GetType().Name}: {ex.Message})");
+ }
+ }
+
+ _output.WriteLine("");
+ _output.WriteLine(
+ $"SUMMARY | Sweep complete. RowsScanned={rowsScanned} RowsInWindow={rowsInWindow.Count} " +
+ $"CancelTargets={cancelIds.Count} CancelsMatched={matchedCancelIds.Count}");
+
+ anyCancelFailed.Should().BeFalse(
+ "one or more explicitly-listed orders could not be cancelled (or could not have their " +
+ "status/family resolved) during the sweep — see the FAILED outcome(s) logged above; this " +
+ "sweep does not retry a failed cancel automatically.");
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/V2PlaceOrderLatencyExperimentTests.cs b/CERTInext.IntegrationTests/V2PlaceOrderLatencyExperimentTests.cs
new file mode 100644
index 0000000..1c0ee8c
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2PlaceOrderLatencyExperimentTests.cs
@@ -0,0 +1,700 @@
+// 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.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Measurement experiment (issue 0090, V2 side): does the V2 API hold order placement open
+ /// the way V1 GenerateOrderSSL does in production (response returned 19 s / 100 s after
+ /// the order already exists and is issued), and how long is the window between "order visible
+ /// to a CA Sync" and "plugin Enroll returns the CARequestID to the gateway"?
+ ///
+ /// V2 order placement is three calls: POST create (returns orderId, order sits in
+ /// pending-csr), PUT /csr, then GET track (+ download / pickup poll). The plugin's real
+ /// Enroll is driven end to end; a wraps the real client
+ /// and timestamps every plugin-to-CA call, so the numbers are the plugin's own call pattern.
+ /// Two independent clients run beside it:
+ ///
+ /// a listing poller (the call Synchronize makes: /reports/orders) every ~1.5 s,
+ /// which finds the order by its unique domain BEFORE the place response returns;
+ /// an order tracker (GET track) every ~1 s, started the instant the place response lands.
+ ///
+ /// Opt-in: CERTINEXT_V2_LATENCY_EXPERIMENT=1 as a REAL environment variable.
+ /// Optional: CERTINEXT_V2_LATENCY_COUNT (default 7, max 12),
+ /// CERTINEXT_V2_LATENCY_SPACING_SECONDS (default 30),
+ /// CERTINEXT_V2_LATENCY_DOMAIN_MODE (unique = fresh subdomain of
+ /// CERTINEXT_DCV_DOMAIN per order [default]; reuse = CERTINEXT_DCV_DOMAIN itself).
+ /// Credentials come from ~/.env_certinext_v2. Raw CSV goes to experiment-results/.
+ /// No order numbers or timings are tied to personal data; emails are never printed.
+ ///
+ public class V2PlaceOrderLatencyExperimentTests : IClassFixture
+ {
+ private const string OptInFlag = "CERTINEXT_V2_LATENCY_EXPERIMENT";
+ private const int MaxOrders = 12;
+ private static readonly TimeSpan ListPollInterval = TimeSpan.FromSeconds(1.5);
+ private static readonly TimeSpan TrackPollInterval = TimeSpan.FromSeconds(1.0);
+ private const int MaxListPollsInFlight = 2;
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private readonly Stopwatch _clock = Stopwatch.StartNew();
+
+ private readonly string _apiUrl;
+ private readonly string _clientId;
+ private readonly string _clientSecret;
+ private readonly string _productCode;
+ private readonly string _baseDomain;
+ private readonly string _requestorName;
+ private readonly string _requestorEmail;
+ private readonly string _groupNumber;
+ private readonly bool _v2Enabled;
+
+ public V2PlaceOrderLatencyExperimentTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+
+ var env = V2EnvHelper.LoadAndPromote();
+ _apiUrl = V2EnvHelper.GetEnv(env, "CERTINEXT_API_URL");
+ _clientId = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_ID");
+ _clientSecret = V2EnvHelper.GetEnv(env, "CERTINEXT_CLIENT_SECRET");
+ _productCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE", "842");
+ _baseDomain = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", string.Empty);
+ _requestorName = V2EnvHelper.GetEnv(env, "CERTINEXT_REQUESTOR_NAME", "Keyfactor Integration Test");
+ _requestorEmail = V2EnvHelper.GetEnv(env, "CERTINEXT_REQUESTOR_EMAIL", "test@example.com");
+ _groupNumber = V2EnvHelper.GetEnv(env, "CERTINEXT_GROUP_NUMBER", string.Empty);
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_apiUrl)
+ && !string.IsNullOrWhiteSpace(_clientId)
+ && !string.IsNullOrWhiteSpace(_clientSecret);
+ }
+
+ private double Now => _clock.Elapsed.TotalSeconds;
+
+ private static readonly Regex EmailRx = new(@"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", RegexOptions.Compiled);
+
+ /// Strips anything email-shaped and caps length; used for every line that leaves the test.
+ private static string Scrub(string s)
+ {
+ if (string.IsNullOrEmpty(s)) return s ?? string.Empty;
+ s = EmailRx.Replace(s, "");
+ return s.Length > 400 ? s.Substring(0, 400) + "..." : s;
+ }
+
+ private static string Describe(Exception ex) => Scrub($"{ex.GetType().Name}: {ex.Message}");
+
+ private void W(string s) => _output.WriteLine(Scrub(s));
+
+ // ---------------------------------------------------------------------------
+ // Timing proxy: wraps the real client, timestamps every plugin-to-CA call
+ // ---------------------------------------------------------------------------
+
+ public sealed class CallRecord
+ {
+ public string Name;
+ public double Start;
+ public double End;
+ public string Error;
+ public double DurationMs => (End - Start) * 1000.0;
+ }
+
+ /// DispatchProxy over recording start/end of every async call.
+ public class TimingClientProxy : DispatchProxy
+ {
+ internal ICERTInextClient Inner;
+ internal Func Clock;
+ internal ConcurrentQueue Calls = new();
+ internal Action OnResult;
+
+ protected override object Invoke(MethodInfo targetMethod, object[] args)
+ {
+ double start = Clock();
+ object ret;
+ try
+ {
+ ret = targetMethod.Invoke(Inner, args);
+ }
+ catch (TargetInvocationException tie)
+ {
+ Calls.Enqueue(new CallRecord { Name = targetMethod.Name, Start = start, End = Clock(), Error = Describe(tie.InnerException ?? tie) });
+ throw tie.InnerException ?? tie;
+ }
+
+ if (ret is Task t)
+ {
+ Type rt = targetMethod.ReturnType;
+ if (rt.IsGenericType && rt.GetGenericTypeDefinition() == typeof(Task<>))
+ {
+ var m = typeof(TimingClientProxy).GetMethod(nameof(WrapTaskT), BindingFlags.NonPublic | BindingFlags.Instance)
+ ?.MakeGenericMethod(rt.GetGenericArguments()[0]);
+ return m?.Invoke(this, new object[] { t, targetMethod.Name, start });
+ }
+ return WrapTask(t, targetMethod.Name, start);
+ }
+ return ret; // sync members and IAsyncEnumerable pass straight through, untimed
+ }
+
+ private async Task WrapTask(Task t, string name, double start)
+ {
+ try { await t.ConfigureAwait(false); Calls.Enqueue(new CallRecord { Name = name, Start = start, End = Clock() }); }
+ catch (Exception ex) { Calls.Enqueue(new CallRecord { Name = name, Start = start, End = Clock(), Error = Describe(ex) }); throw; }
+ }
+
+ private async Task WrapTaskT(Task t, string name, double start)
+ {
+ try
+ {
+ T r = await t.ConfigureAwait(false);
+ Calls.Enqueue(new CallRecord { Name = name, Start = start, End = Clock() });
+ OnResult?.Invoke(name, r);
+ return r;
+ }
+ catch (Exception ex)
+ {
+ Calls.Enqueue(new CallRecord { Name = name, Start = start, End = Clock(), Error = Describe(ex) });
+ throw;
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Observations
+ // ---------------------------------------------------------------------------
+
+ private sealed class TrackObs
+ {
+ public double Sent;
+ public double Recv;
+ public string Status;
+ public string Error;
+ }
+
+ private sealed class ListObs
+ {
+ public double Start;
+ public double End;
+ public int Rows;
+ public string OrderNumber;
+ public string OrderStatus;
+ public string CertStatus;
+ public string Error;
+ public bool Matched => OrderNumber != null;
+ public string Combined => $"{OrderStatus}|{CertStatus}";
+ }
+
+ private sealed class Trial
+ {
+ public int Index;
+ public string Domain;
+ public string OrderId;
+ public DateTime T0Utc;
+ public double T0;
+ public double TPlaceResp = double.NaN;
+ public double TCsrResp = double.NaN;
+ public double TEnrollEnd = double.NaN;
+ public double PlaceMs = double.NaN, CsrMs = double.NaN, DownloadMs = double.NaN;
+ public List TrackCallMs = new();
+ public string EnrollStatus = string.Empty;
+ public bool EnrollHasCert;
+ public string EnrollMessage = string.Empty;
+ public string CreateRespStatus = string.Empty;
+ public double FirstSeenList = double.NaN, FirstSeenListLower = double.NaN;
+ public string FirstSeenListStatus = string.Empty;
+ public double TrackFirstObsRecv = double.NaN;
+ public string TrackFirstObsStatus = string.Empty;
+ public string StatusAfterCsr = string.Empty;
+ public double TIssuedTrack = double.NaN, TIssuedTrackLower = double.NaN;
+ public double TIssuedList = double.NaN;
+ public string TrackSeq = string.Empty;
+ public string ListSeq = string.Empty;
+ public int ListPolls, ListErrors, TrackPolls, TrackErrors;
+ public double ListPollMedianS = double.NaN;
+ public string CleanupNote = string.Empty;
+ public string Error = string.Empty;
+ // One extra GET /certificate fired by the tracker the first time track reports 'issued'
+ // (while the CSR PUT may still be in flight): does 'issued' mean the cert is downloadable?
+ public double DlProbeAt = double.NaN, DlProbeMs = double.NaN;
+ public string DlProbeResult = string.Empty;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private static string F(double x) => double.IsNaN(x) ? "" : x.ToString("0.00", CultureInfo.InvariantCulture);
+
+ private static double Median(IEnumerable xs)
+ {
+ var a = xs.Where(x => !double.IsNaN(x)).OrderBy(x => x).ToArray();
+ if (a.Length == 0) return double.NaN;
+ return a.Length % 2 == 1 ? a[a.Length / 2] : (a[a.Length / 2 - 1] + a[a.Length / 2]) / 2.0;
+ }
+
+ private static string RepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !File.Exists(Path.Combine(dir.FullName, "certinext-caplugin.sln")))
+ dir = dir.Parent;
+ return dir?.FullName ?? Directory.GetCurrentDirectory();
+ }
+
+ /// RSA-2048 PKCS#10 CSR, BouncyCastle only.
+ private static string GenerateCsrPem(string commonName)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
+ var csr = new Pkcs10CertificationRequest("SHA256withRSA", new X509Name($"CN={commonName}"),
+ keyPair.Public, null, keyPair.Private);
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ private CERTInextConfig BuildConfig() => new CERTInextConfig
+ {
+ ApiUrl = _apiUrl,
+ UseV2Api = true,
+ OAuthClientId = _clientId,
+ OAuthClientSecret = _clientSecret,
+ RequestorName = _requestorName,
+ RequestorEmail = _requestorEmail,
+ RequestorIsdCode = "1",
+ RequestorMobileNumber = "0000000000",
+ SignerPlace = "Gateway Lab",
+ SignerIp = "127.0.0.1",
+ GroupNumber = _groupNumber,
+ PageSize = 100,
+ DcvEnabled = false
+ };
+
+ private static bool IsIssuedListing(ListObs o) =>
+ o.CertStatus != null &&
+ (o.CertStatus.IndexOf("Generated", StringComparison.OrdinalIgnoreCase) >= 0
+ || o.CertStatus.IndexOf("Downloaded", StringComparison.OrdinalIgnoreCase) >= 0);
+
+ private static bool IsIssuedTrack(string status) =>
+ string.Equals(status, Constants.ApiV2.StatusIssued, StringComparison.OrdinalIgnoreCase);
+
+ // ---------------------------------------------------------------------------
+ // The experiment
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task V2PlaceOrder_Latency_And_ExposureWindow_Experiment()
+ {
+ Skip.If(!_v2Enabled, "V2 credentials not configured in ~/.env_certinext_v2 (CERTINEXT_USE_V2_API, API_URL, CLIENT_ID, CLIENT_SECRET).");
+ Skip.If(Environment.GetEnvironmentVariable(OptInFlag) != "1",
+ $"Opt-in: set {OptInFlag}=1 as a real environment variable (places live sandbox orders).");
+ Skip.If(string.IsNullOrWhiteSpace(_baseDomain), "CERTINEXT_DCV_DOMAIN is required (base domain for test CNs).");
+
+ int count = int.TryParse(Environment.GetEnvironmentVariable("CERTINEXT_V2_LATENCY_COUNT"), out int c) ? c : 7;
+ count = Math.Max(1, Math.Min(MaxOrders, count));
+ double spacing = double.TryParse(Environment.GetEnvironmentVariable("CERTINEXT_V2_LATENCY_SPACING_SECONDS"),
+ NumberStyles.Float, CultureInfo.InvariantCulture, out double sp) ? sp : 30;
+ bool reuse = string.Equals(Environment.GetEnvironmentVariable("CERTINEXT_V2_LATENCY_DOMAIN_MODE"),
+ "reuse", StringComparison.OrdinalIgnoreCase);
+
+ string runId = DateTime.UtcNow.ToString("MMddHHmm", CultureInfo.InvariantCulture);
+ var cfg = BuildConfig();
+
+ // Independent clients: listing poller (stands in for the sync pod) and order tracker.
+ using var listClient = new CERTInextClient(cfg);
+ using var trackClient = new CERTInextClient(cfg);
+ using var realClient = new CERTInextClient(cfg);
+ using var cleanupClient = new CERTInextClient(cfg);
+ var cleanupPlugin = new CERTInextCAPlugin(cleanupClient, cfg);
+
+ W($"V2 latency experiment run {runId}: product={_productCode}, mode={(reuse ? "reuse" : "unique")}, " +
+ $"count={count}, spacingS={F(spacing)}, pickupRetries={cfg.GetEffectivePickupRetries()}");
+
+ var trials = new List();
+ double lastT0 = double.NegativeInfinity;
+ try
+ {
+ for (int i = 0; i < count; i++)
+ {
+ var t = new Trial
+ {
+ Index = i + 1,
+ Domain = reuse ? _baseDomain : $"v2lat-{runId}-{i + 1:D2}.{_baseDomain}".ToLowerInvariant()
+ };
+ trials.Add(t);
+
+ double wait = lastT0 + spacing - Now;
+ if (wait > 0) await Task.Delay(TimeSpan.FromSeconds(wait));
+
+ try
+ {
+ await RunTrialAsync(t, cfg, realClient, listClient, trackClient, reuse);
+ lastT0 = t.T0;
+ }
+ catch (Exception ex)
+ {
+ t.Error = (t.Error.Length > 0 ? t.Error + "; " : "") + "trial: " + Describe(ex);
+ lastT0 = t.T0 > 0 ? t.T0 : Now;
+ }
+ finally
+ {
+ t.CleanupNote = await CleanupAsync(cleanupPlugin, t.OrderId);
+ }
+ LogTrial(t);
+ }
+ }
+ finally
+ {
+ WriteCsv(runId, trials);
+ WriteSummary(trials);
+ }
+ }
+
+ private async Task RunTrialAsync(Trial t, CERTInextConfig cfg, CERTInextClient realClient,
+ CERTInextClient listClient, CERTInextClient trackClient, bool reuse)
+ {
+ // Everything not under test happens before t0.
+ string csr = GenerateCsrPem(t.Domain);
+ string today = DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ string tomorrow = DateTime.UtcNow.AddDays(1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+
+ // reuse mode: orders already on the base domain today are excluded from "mine".
+ var snapshot = new HashSet(StringComparer.Ordinal);
+ if (reuse)
+ {
+ await foreach (var e in listClient.ListOrdersV2Async(today, tomorrow, 100))
+ if (string.Equals(e.DomainName, t.Domain, StringComparison.OrdinalIgnoreCase) && e.OrderNumber != null)
+ snapshot.Add(e.OrderNumber);
+ }
+ bool Mine(OrderReportEntryV2 e) =>
+ string.Equals(e.DomainName, t.Domain, StringComparison.OrdinalIgnoreCase)
+ && e.OrderNumber != null && !snapshot.Contains(e.OrderNumber);
+
+ var proxy = DispatchProxy.Create();
+ var tp = (TimingClientProxy)(object)proxy;
+ tp.Inner = realClient;
+ tp.Clock = () => Now;
+
+ var trackObs = new ConcurrentQueue();
+ var listObs = new ConcurrentQueue();
+ using var stopAll = new CancellationTokenSource();
+ Task trackerTask = Task.CompletedTask;
+ string orderIdFromCreate = null;
+
+ // The instant the place response lands, start tracking the order with the independent client.
+ tp.OnResult = (name, result) =>
+ {
+ if (name == "PlaceOrderV2Async" && result is V2CreateOrderResponse cr && orderIdFromCreate == null)
+ {
+ orderIdFromCreate = cr.OrderId;
+ t.CreateRespStatus = cr.Status ?? string.Empty;
+ trackerTask = Task.Run(async () =>
+ {
+ while (!stopAll.IsCancellationRequested)
+ {
+ var o = new TrackObs { Sent = Now };
+ try
+ {
+ var s = await trackClient.TrackOrderV2Async(Constants.ApiV2.FamilySsl, cr.OrderId);
+ o.Status = s.Status;
+ }
+ catch (Exception ex) { o.Error = Describe(ex); }
+ o.Recv = Now;
+ trackObs.Enqueue(o);
+ if (o.Error == null && IsIssuedTrack(o.Status) && double.IsNaN(t.DlProbeAt))
+ {
+ t.DlProbeAt = Now;
+ double p0 = Now;
+ try
+ {
+ var dl = await trackClient.DownloadCertificateV2Async(Constants.ApiV2.FamilySsl, cr.OrderId);
+ t.DlProbeResult = string.IsNullOrWhiteSpace(dl?.CertificatePem) ? "ok-empty" : "ok-with-certificate";
+ }
+ catch (Exception ex) { t.DlProbeResult = "error: " + Describe(ex); }
+ t.DlProbeMs = (Now - p0) * 1000.0;
+ }
+ try { await Task.Delay(TrackPollInterval, stopAll.Token); } catch (OperationCanceledException) { }
+ }
+ });
+ }
+ };
+
+ var inflight = new List();
+ var listerTask = Task.Run(async () =>
+ {
+ async Task PollOnce()
+ {
+ var o = new ListObs { Start = Now };
+ try
+ {
+ await foreach (var e in listClient.ListOrdersV2Async(today, tomorrow, 100))
+ {
+ o.Rows++;
+ if (Mine(e) && o.OrderNumber == null)
+ {
+ o.OrderNumber = e.OrderNumber;
+ o.OrderStatus = e.OrderStatus;
+ o.CertStatus = e.CertificateStatus;
+ }
+ }
+ }
+ catch (Exception ex) { o.Error = Describe(ex); }
+ o.End = Now;
+ listObs.Enqueue(o);
+ }
+ while (!stopAll.IsCancellationRequested)
+ {
+ inflight.RemoveAll(x => x.IsCompleted);
+ if (inflight.Count < MaxListPollsInFlight) inflight.Add(Task.Run(PollOnce));
+ try { await Task.Delay(ListPollInterval, stopAll.Token); } catch (OperationCanceledException) { }
+ }
+ await Task.WhenAll(inflight);
+ });
+ await Task.Delay(ListPollInterval); // listing poller is already running before t0
+
+ var plugin = new CERTInextCAPlugin(proxy, cfg);
+ var productInfo = new EnrollmentProductInfo
+ {
+ ProductID = _productCode,
+ ProductParameters = new Dictionary
+ {
+ [Constants.EnrollmentParam.ProductCode] = _productCode,
+ [Constants.EnrollmentParam.ProfileId] = _productCode,
+ }
+ };
+
+ t.T0Utc = DateTime.UtcNow;
+ t.T0 = Now;
+ EnrollmentResult result = null;
+ try
+ {
+ result = await plugin.Enroll(
+ csr: csr,
+ subject: $"CN={t.Domain}",
+ san: new Dictionary { ["dns"] = new[] { t.Domain } },
+ productInfo: productInfo,
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ }
+ catch (Exception ex)
+ {
+ t.Error = "Enroll: " + Describe(ex);
+ }
+ t.TEnrollEnd = Now;
+ t.OrderId = result?.CARequestID ?? orderIdFromCreate;
+ if (result != null)
+ {
+ t.EnrollStatus = ((EndEntityStatus)result.Status).ToString();
+ t.EnrollHasCert = !string.IsNullOrWhiteSpace(result.Certificate);
+ t.EnrollMessage = Scrub(result.StatusMessage ?? string.Empty);
+ }
+
+ // Keep observing: until issued is seen by tracker, or 90 s after the enroll returned.
+ double deadline = t.TEnrollEnd + 90;
+ while (Now < deadline)
+ {
+ await Task.Delay(500);
+ bool issuedSeen = trackObs.Any(o => IsIssuedTrack(o.Status));
+ if (issuedSeen && Now >= t.TEnrollEnd + 4) break;
+ if (orderIdFromCreate == null && Now >= t.TEnrollEnd + 6) break; // nothing was placed
+ }
+ stopAll.Cancel();
+ try { await Task.WhenAll(trackerTask, listerTask); } catch { /* observation only */ }
+
+ Analyze(t, tp, trackObs.OrderBy(o => o.Recv).ToList(), listObs.OrderBy(o => o.Start).ToList());
+ }
+
+ private void Analyze(Trial t, TimingClientProxy tp, List track, List lists)
+ {
+ var calls = tp.Calls.OrderBy(c => c.Start).ToList();
+ var place = calls.FirstOrDefault(c => c.Name == "PlaceOrderV2Async");
+ var csr = calls.FirstOrDefault(c => c.Name == "SubmitCsrV2Async");
+ var dl = calls.FirstOrDefault(c => c.Name == "DownloadCertificateV2Async");
+ if (place != null) { t.PlaceMs = place.DurationMs; t.TPlaceResp = place.End; if (place.Error != null) t.Error += "place: " + place.Error + "; "; }
+ if (csr != null) { t.CsrMs = csr.DurationMs; t.TCsrResp = csr.End; if (csr.Error != null) t.Error += "csr: " + csr.Error + "; "; }
+ if (dl != null) t.DownloadMs = dl.DurationMs;
+ t.TrackCallMs = calls.Where(c => c.Name == "TrackOrderV2Async").Select(c => c.DurationMs).ToList();
+
+ // Listing: first poll whose rows contained the order.
+ t.ListPolls = lists.Count;
+ t.ListErrors = lists.Count(l => l.Error != null);
+ t.ListPollMedianS = Median(lists.Select(l => l.End - l.Start));
+ var firstSeen = lists.Where(l => l.Matched).OrderBy(l => l.End).FirstOrDefault();
+ if (firstSeen != null)
+ {
+ t.FirstSeenList = firstSeen.End;
+ t.FirstSeenListLower = firstSeen.Start;
+ t.FirstSeenListStatus = Scrub(firstSeen.Combined);
+ }
+ var issuedList = lists.Where(l => l.Matched && IsIssuedListing(l)).OrderBy(l => l.End).FirstOrDefault();
+ if (issuedList != null) t.TIssuedList = issuedList.End;
+
+ var lseq = new List();
+ foreach (var l in lists.Where(l => l.Matched).OrderBy(l => l.End))
+ {
+ string s = Scrub(l.Combined);
+ if (lseq.Count == 0 || !lseq[^1].StartsWith(s + "@", StringComparison.Ordinal))
+ lseq.Add($"{s}@{F(l.End - t.T0)}");
+ }
+ t.ListSeq = string.Join("; ", lseq);
+
+ // Tracker.
+ t.TrackPolls = track.Count;
+ t.TrackErrors = track.Count(o => o.Error != null);
+ var okObs = track.Where(o => o.Error == null).ToList();
+ if (okObs.Count > 0)
+ {
+ t.TrackFirstObsRecv = okObs[0].Recv;
+ t.TrackFirstObsStatus = okObs[0].Status;
+ }
+ if (!double.IsNaN(t.TCsrResp))
+ {
+ var afterCsr = okObs.FirstOrDefault(o => o.Recv >= t.TCsrResp);
+ if (afterCsr != null) t.StatusAfterCsr = afterCsr.Status + "@" + F(afterCsr.Recv - t.T0);
+ }
+ var issuedTrack = okObs.FirstOrDefault(o => IsIssuedTrack(o.Status));
+ if (issuedTrack != null) { t.TIssuedTrack = issuedTrack.Recv; t.TIssuedTrackLower = issuedTrack.Sent; }
+
+ var tseq = new List();
+ foreach (var o in okObs)
+ if (tseq.Count == 0 || !tseq[^1].StartsWith(o.Status + "@", StringComparison.Ordinal))
+ tseq.Add($"{o.Status}@{F(o.Recv - t.T0)}");
+ t.TrackSeq = string.Join("; ", tseq);
+ foreach (var o in track.Where(o => o.Error != null).Take(2))
+ t.Error += "track: " + o.Error + "; ";
+ }
+
+ private void LogTrial(Trial t)
+ {
+ W($"trial {t.Index:D2} order={(string.IsNullOrEmpty(t.OrderId) ? "-" : t.OrderId)} " +
+ $"place={F(t.PlaceMs / 1000)}s csr={F(t.CsrMs / 1000)}s tracks=[{string.Join(",", t.TrackCallMs.Select(x => F(x / 1000)))}]s " +
+ $"download={F(t.DownloadMs / 1000)}s enrollWall={F(t.TEnrollEnd - t.T0)}s enroll={t.EnrollStatus} cert={t.EnrollHasCert} " +
+ $"createStatus={t.CreateRespStatus} firstSeenInListing={F(t.FirstSeenList - t.T0)}s({t.FirstSeenListStatus}) " +
+ $"placeResp={F(t.TPlaceResp - t.T0)}s csrResp={F(t.TCsrResp - t.T0)}s " +
+ $"dlProbe@{F(t.DlProbeAt - t.T0)}s={t.DlProbeResult} issuedTrack={F(t.TIssuedTrack - t.T0)}s issuedList={F(t.TIssuedList - t.T0)}s " +
+ $"trackSeq=[{t.TrackSeq}] listSeq=[{t.ListSeq}] cleanup={t.CleanupNote} {t.Error}");
+ }
+
+ private void WriteCsv(string runId, List trials)
+ {
+ try
+ {
+ string dir = Path.Combine(RepoRoot(), "experiment-results");
+ Directory.CreateDirectory(dir);
+ string path = Path.Combine(dir, $"v2-placeorder-latency-{runId}.csv");
+ var sb = new StringBuilder();
+ sb.AppendLine("trial,domain,order_id,t0_utc,create_resp_status,place_ms,csr_ms,track_calls_ms,download_ms," +
+ "enroll_wall_s,enroll_status,enroll_has_cert,t_place_resp_s,t_csr_resp_s,t_enroll_end_s," +
+ "first_seen_listing_s,first_seen_listing_lower_s,first_seen_listing_status," +
+ "seen_in_listing_before_place_resp,seen_in_listing_before_csr_resp,exposure_seen_to_enroll_end_s," +
+ "track_first_obs_s,track_first_obs_status,status_after_csr," +
+ "t_issued_track_s,t_issued_track_lower_s,t_issued_listing_s," +
+ "issued_before_place_resp,issued_before_csr_resp,issued_before_enroll_end," +
+ "dl_probe_at_s,dl_probe_ms,dl_probe_result,track_seq,listing_seq,list_polls,list_poll_median_s,track_polls,list_errors,track_errors,cleanup,error");
+ foreach (var t in trials)
+ {
+ string Rel(double x) => double.IsNaN(x) ? "" : F(x - t.T0);
+ string Q(string s) => "\"" + Scrub(s ?? "").Replace("\"", "'") + "\"";
+ bool seenBeforePlace = !double.IsNaN(t.FirstSeenList) && !double.IsNaN(t.TPlaceResp) && t.FirstSeenList < t.TPlaceResp;
+ bool seenBeforeCsr = !double.IsNaN(t.FirstSeenList) && !double.IsNaN(t.TCsrResp) && t.FirstSeenList < t.TCsrResp;
+ // Issued strictly before a response returned: use the observation's RECEIVE time vs that response's time.
+ bool issuedBeforePlace = !double.IsNaN(t.TIssuedList) && !double.IsNaN(t.TPlaceResp) && t.TIssuedList < t.TPlaceResp;
+ double issuedAny = double.IsNaN(t.TIssuedTrack) ? t.TIssuedList
+ : double.IsNaN(t.TIssuedList) ? t.TIssuedTrack : Math.Min(t.TIssuedTrack, t.TIssuedList);
+ bool issuedBeforeCsr = !double.IsNaN(issuedAny) && !double.IsNaN(t.TCsrResp) && issuedAny < t.TCsrResp;
+ bool issuedBeforeEnd = !double.IsNaN(issuedAny) && !double.IsNaN(t.TEnrollEnd) && issuedAny < t.TEnrollEnd;
+ double exposure = (!double.IsNaN(t.FirstSeenList) && !double.IsNaN(t.TEnrollEnd)) ? t.TEnrollEnd - t.FirstSeenList : double.NaN;
+ sb.AppendLine(string.Join(",", new[]
+ {
+ t.Index.ToString(CultureInfo.InvariantCulture), t.Domain, t.OrderId ?? "", t.T0Utc.ToString("o", CultureInfo.InvariantCulture),
+ t.CreateRespStatus, F(t.PlaceMs), F(t.CsrMs), Q(string.Join(";", t.TrackCallMs.Select(F))), F(t.DownloadMs),
+ F(t.TEnrollEnd - t.T0), t.EnrollStatus, t.EnrollHasCert ? "1" : "0", Rel(t.TPlaceResp), Rel(t.TCsrResp), Rel(t.TEnrollEnd),
+ Rel(t.FirstSeenList), Rel(t.FirstSeenListLower), Q(t.FirstSeenListStatus),
+ seenBeforePlace ? "1" : "0", seenBeforeCsr ? "1" : "0", F(exposure),
+ Rel(t.TrackFirstObsRecv), Q(t.TrackFirstObsStatus), Q(t.StatusAfterCsr),
+ Rel(t.TIssuedTrack), Rel(t.TIssuedTrackLower), Rel(t.TIssuedList),
+ issuedBeforePlace ? "1" : "0", issuedBeforeCsr ? "1" : "0", issuedBeforeEnd ? "1" : "0",
+ Rel(t.DlProbeAt), F(t.DlProbeMs), Q(t.DlProbeResult),
+ Q(t.TrackSeq), Q(t.ListSeq), t.ListPolls.ToString(CultureInfo.InvariantCulture), F(t.ListPollMedianS),
+ t.TrackPolls.ToString(CultureInfo.InvariantCulture), t.ListErrors.ToString(CultureInfo.InvariantCulture),
+ t.TrackErrors.ToString(CultureInfo.InvariantCulture), Q(t.CleanupNote), Q(t.Error)
+ }));
+ }
+ File.WriteAllText(path, sb.ToString());
+ W($"CSV written: experiment-results/{Path.GetFileName(path)}");
+ }
+ catch (Exception ex)
+ {
+ W($"CSV write failed: {Describe(ex)}");
+ }
+ }
+
+ private void WriteSummary(List trials)
+ {
+ var ok = trials.Where(t => !string.IsNullOrEmpty(t.OrderId)).ToList();
+ W($"=== summary (seconds; min / median / max; n) over {ok.Count} placed orders ===");
+ void Row(string name, Func get)
+ {
+ var xs = ok.Select(get).Where(x => !double.IsNaN(x)).ToArray();
+ W(xs.Length == 0 ? $"{name,-22} n=0"
+ : $"{name,-22} {F(xs.Min())} / {F(Median(xs))} / {F(xs.Max())} (n={xs.Length})");
+ }
+ Row("place (HTTP)", t => t.PlaceMs / 1000);
+ Row("csr submit (HTTP)", t => t.CsrMs / 1000);
+ Row("download (HTTP)", t => t.DownloadMs / 1000);
+ Row("enroll wall", t => t.TEnrollEnd - t.T0);
+ Row("first seen in listing", t => t.FirstSeenList - t.T0);
+ Row("issued (track)", t => t.TIssuedTrack - t.T0);
+ Row("issued (listing)", t => t.TIssuedList - t.T0);
+ Row("seen->enroll end", t => t.TEnrollEnd - t.FirstSeenList);
+ }
+
+ /// Revoke if issued, otherwise cancel via the raw endpoint (single attempt). Never throws.
+ private async Task CleanupAsync(CERTInextCAPlugin plugin, string orderId)
+ {
+ if (string.IsNullOrWhiteSpace(orderId)) return "no order";
+ try
+ {
+ var current = await plugin.GetSingleRecord(orderId);
+ if (current?.Status == (int)EndEntityStatus.GENERATED)
+ {
+ int r = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 4);
+ return $"revoked ({r})";
+ }
+ await V2RawHttpHelpers.CancelSslOrderRawAsync(_apiUrl, _clientId, _clientSecret, orderId,
+ "V2 latency experiment cleanup — order not issued, cancelling.");
+ return $"cancelled (status was {current?.Status})";
+ }
+ catch (Exception ex)
+ {
+ return "cleanup FAILED: " + Describe(ex);
+ }
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/V2ProductPinning.cs b/CERTInext.IntegrationTests/V2ProductPinning.cs
new file mode 100644
index 0000000..c1683c7
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2ProductPinning.cs
@@ -0,0 +1,181 @@
+// 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.Linq;
+using System.Reflection;
+using System.Text.Json;
+using Keyfactor.AnyGateway.Extensions;
+using Xunit.Sdk;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// How a V2 enrollment test selects the CERTInext product, mirroring the two gateway template
+ /// sets registered by scripts/register/02-gateway-ca-config.sh.
+ ///
+ public enum V2PinMode
+ {
+ ///
+ /// "Pinned" template: ProductID is the product name (e.g. "DV SSL") and the template
+ /// carries an explicit ProductCode, so the plugin sends X-Product-Code.
+ ///
+ Pinned,
+
+ ///
+ /// "V2 template" (unpinned): same ProductID but NO ProductCode parameter, so
+ /// the plugin sends no X-Product-Code and CERTInext resolves the product itself.
+ ///
+ V2Template,
+ }
+
+ ///
+ /// Builds the a gateway template would hand the plugin, in
+ /// either , and supplies the pin-mode rows for parameterised V2 tests.
+ ///
+ internal static class V2ProductPinning
+ {
+ ///
+ /// Sandbox V2 catalog product codes per product name (the same codes stage 02's
+ /// PRODUCT_CODE_MAP_JSON pins on the lab gateway: DV 842-845, OV 846-849, EV 850-851).
+ /// Product codes are per-environment; override with CERTINEXT_PRODUCT_CODE_MAP_JSON
+ /// (e.g. {"OV SSL":"900"}) or, for DV SSL only, CERTINEXT_PRODUCT_CODE.
+ ///
+ private static readonly Dictionary SandboxCodes =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [Constants.Products.DvSsl] = "842",
+ [Constants.Products.DvSslWildcard] = "843",
+ [Constants.Products.DvSslUcc] = "844",
+ [Constants.Products.DvSslWildcardUcc] = "845",
+ [Constants.Products.OvSsl] = "846",
+ [Constants.Products.OvSslWildcard] = "847",
+ [Constants.Products.OvSslUcc] = "848",
+ [Constants.Products.OvSslWildcardUcc] = "849",
+ [Constants.Products.EvSsl] = "850",
+ [Constants.Products.EvSslUcc] = "851",
+ };
+
+ /// Both modes, in run order (pinned first).
+ public static readonly V2PinMode[] AllModes = { V2PinMode.Pinned, V2PinMode.V2Template };
+
+ ///
+ /// The product code a pinned template would carry for :
+ /// CERTINEXT_PRODUCT_CODE_MAP_JSON entry, else (DV SSL only) CERTINEXT_PRODUCT_CODE,
+ /// else the sandbox default. Reads the V2 env file via so a V2-file value
+ /// wins over the V1 fixture's.
+ ///
+ public static string CodeFor(string productId)
+ {
+ var env = V2EnvHelper.LoadAndPromote();
+
+ string mapJson = V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE_MAP_JSON");
+ if (!string.IsNullOrWhiteSpace(mapJson))
+ {
+ var map = JsonSerializer.Deserialize>(mapJson)
+ ?? new Dictionary();
+ var hit = map.FirstOrDefault(kv => string.Equals(kv.Key, productId, StringComparison.OrdinalIgnoreCase));
+ if (!string.IsNullOrWhiteSpace(hit.Value))
+ return hit.Value;
+ }
+
+ if (string.Equals(productId, Constants.Products.DvSsl, StringComparison.OrdinalIgnoreCase))
+ return V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE", SandboxCodes[Constants.Products.DvSsl]);
+
+ if (SandboxCodes.TryGetValue(productId, out string code))
+ return code;
+
+ throw new ArgumentException(
+ $"No sandbox product code known for ProductID '{productId}'; add it to CERTINEXT_PRODUCT_CODE_MAP_JSON.",
+ nameof(productId));
+ }
+
+ ///
+ /// The ProductCode a template in carries: the pinned code, or
+ /// null for a V2 template (the parameter is absent entirely).
+ ///
+ public static string CodeSent(string productId, V2PinMode mode) =>
+ mode == V2PinMode.Pinned ? CodeFor(productId) : null;
+
+ ///
+ /// Product info as the gateway would deliver it for the template in :
+ /// ProductID = product name, ValidityYears = 1 (as on the lab templates), plus
+ /// ProductCode only when pinned. (e.g. PriorCertSN) are added last.
+ ///
+ public static EnrollmentProductInfo Info(
+ string productId, V2PinMode mode, IDictionary extraParameters = null)
+ {
+ var parameters = new Dictionary
+ {
+ [Constants.EnrollmentParam.ValidityYears] = "1",
+ };
+
+ string code = CodeSent(productId, mode);
+ if (code != null)
+ parameters[Constants.EnrollmentParam.ProductCode] = code;
+
+ if (extraParameters != null)
+ foreach (var kv in extraParameters)
+ parameters[kv.Key] = kv.Value;
+
+ return new EnrollmentProductInfo { ProductID = productId, ProductParameters = parameters };
+ }
+
+ /// Short label for test output, e.g. [pinned DV SSL ProductCode=842].
+ public static string Label(string productId, V2PinMode mode) =>
+ mode == V2PinMode.Pinned
+ ? $"[pinned {productId} ProductCode={CodeFor(productId)}]"
+ : $"[V2 template (unpinned) {productId} no ProductCode]";
+
+ ///
+ /// Logs which mode a test row runs in plus, when the response exposes it (raw-client order
+ /// create), the product CERTInext actually resolved.
+ ///
+ public static void Log(
+ Xunit.Abstractions.ITestOutputHelper output, string productId, V2PinMode mode, string resolvedProductCode = null)
+ {
+ output.WriteLine(Label(productId, mode)
+ + (resolvedProductCode == null ? string.Empty : $" ResolvedProductCode={resolvedProductCode}"));
+ }
+
+ ///
+ /// A V2-template (unpinned) row does not exercise pinning when the test is handed a pre-existing
+ /// order via an env var instead of enrolling: skip it so the same order is not reused twice.
+ ///
+ public static void SkipUnpinnedWhenOrderSuppliedByEnv(V2PinMode mode, string envOrderId, string envVarName)
+ {
+ Xunit.Skip.If(mode == V2PinMode.V2Template && !string.IsNullOrWhiteSpace(envOrderId),
+ $"{envVarName} is set: the test uses that existing order and does not enroll, so the pin mode is " +
+ "irrelevant. Only the pinned row runs.");
+ }
+ }
+
+ ///
+ /// xUnit data source: one row per (mode as the sole argument).
+ /// Rows display as Test(mode: Pinned) / Test(mode: V2Template), so
+ /// --filter "DisplayName~V2Template" or "DisplayName~Pinned" selects one set.
+ ///
+ [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
+ internal sealed class V2PinModesAttribute : DataAttribute
+ {
+ public override IEnumerable