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..b1ba31b 100644
--- a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
+++ b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
@@ -6,9 +6,9 @@
12.0
false
true
-
- 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/EmailNotificationsV2ProbeTests.cs b/CERTInext.IntegrationTests/EmailNotificationsV2ProbeTests.cs
new file mode 100644
index 0000000..4f9e096
--- /dev/null
+++ b/CERTInext.IntegrationTests/EmailNotificationsV2ProbeTests.cs
@@ -0,0 +1,843 @@
+// 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.
+//
+// Live probe for issue 0027 item 1b (EmailNotifications value vocabulary — V2). The V2
+// spec documents `emailNotifications` as "Optional (default `all`)" but every single
+// create-request example across all three product families sends exactly `"all"` — no
+// alternate value appears anywhere in the spec text. This probe places ONE real V2 OV SSL
+// order with `emailNotifications` set to `"0"` (V1's connector-config "notifications off"
+// value) instead of `"all"`, and captures the raw HTTP status + response body from the
+// create-order call (and, if the order is accepted, the Track Order response too) to
+// determine whether the live CA accepts the value as-is, silently coerces it, or rejects
+// the order outright.
+//
+// Raw HTTP only (same idiom as IdempotencyKeyV2ProbeTests.PlaceOrderRawAsync /
+// OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync) — deliberately bypasses
+// CERTInextClient.PlaceOrderV2Async so the exact, unmodified response body can be
+// inspected (the V2CreateOrderResponse/V2OrderStatusResponse DTOs have no
+// emailNotifications slot at all, so going through them would silently discard the one
+// piece of evidence this probe needs if the CA does echo the field back).
+//
+// Does NOT submit a CSR and does NOT attempt to push the order to issuance — the question
+// this probe answers is fully contained in the create-order (and optional Track Order)
+// response. Best-effort cancels the order afterward via the same raw cancel helper used by
+// the other V2 probes in this project.
+//
+// Opt-in: requires CERTINEXT_PROBE_EMAIL_NOTIFICATIONS=1 (same convention as this project's
+// sibling probes' CERTINEXT_PROBE_ORG_MISSING/CERTINEXT_PROBE_ORG_FIX/
+// CERTINEXT_PROBE_IDEMPOTENCY_KEY flags). Not armed by default in ~/.env_certinext or
+// ~/.env_certinext_v2 — an operator must deliberately opt in, since this places one real,
+// potentially cost-bearing V2 OV SSL order. Uses an OV product code confirmed orderable on
+// this sandbox account (issues/V2_AUDIT_TRIAGE_HANDOFF.md's "Credentials / live access"
+// section: 846/847/848/849/850/851) — default 846 (OV SSL, single-domain, non-UCC),
+// overridable via CERTINEXT_OV_PRODUCT_CODE. Requires CERTINEXT_ORG_NUMBER (already present
+// in most ~/.env_certinext files per this repo's other live tests) since OV orders require
+// the `organization` block (issue 0028).
+//
+// IMPORTANT — observed during authoring (2026-09-26): on this sandbox, an OV order-create
+// call can silently exceed 120s and cause a client-side TaskCanceledException with NO HTTP
+// response ever received (RestSharp reports it as StatusCode=0/empty body), even though the
+// CA actually created the order server-side. A client-side timeout on this endpoint is
+// therefore NOT proof the order was never created — before assuming a failed create call
+// means "no order," check the orders report (ListOrdersV2Async / GET
+// /api/certinext/v2/reports/orders) for the domain used. This is why NewApiClient below uses
+// an explicit long timeout matching CERTInextClient's own 120s, and why a caller hitting this
+// timeout should not simply retry without checking for an orphaned order first — retrying
+// blindly after a timeout is exactly how this authoring session ended up with two live OV
+// orders (1815749817, 2743834762 — both since cancelled) instead of the single order this
+// probe is meant to place.
+//
+// --- Phase 2 (added 2026-09-28): real-inbox test, for the same open question this file's
+// original probe above could not resolve ---
+//
+// The API-only probe above could not distinguish "CERTInext honors emailNotifications='0'"
+// from "CERTInext silently coerces it back to 'all'" — the field is never echoed back in any
+// response, create or Track Order. The user's chosen resolution (issues/
+// V2_AUDIT_TRIAGE_HANDOFF.md, "EmailNotifications' open decision") is the strongest test
+// available short of asking CERTInext directly: place two real V2 DV SSL orders, two minutes
+// apart, one with emailNotifications="all" (baseline) and one with emailNotifications="0"
+// (test), and have a human compare what actually arrives in the requestor's real inbox for
+// each. Product: DV SSL, non-UCC, catalog code 842 — verified against the live catalog's
+// productTypeID ("13" = DV SSL, non-UCC; see ProductDetail.ProductTypeId's own doc comment)
+// before either order is placed, matched on productTypeID only, never productName (catalog
+// productName strings are unstable across account/catalog-version — see this repo's Gotchas
+// in issues/V2_AUDIT_TRIAGE_HANDOFF.md). No CSR is submitted and DCV is never invoked for
+// either order.
+//
+// Both orders deliberately omit requestor.designation — the JSON key itself is absent, not
+// null/empty, via the same global `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
+// serializer setting this file already relies on for other optional fields — which is also an
+// (unplanned, incidental) live-answer opportunity for issue 0027 item 5e's still-open
+// "requestor.designation hardcoded, no config field" design question: if either create call
+// is rejected with a 4xx that mentions "designation", that rejection is logged verbatim as a
+// live answer to 5e. The probe is not designed around that outcome, but it is free
+// information if it happens. technicalPointOfContact is omitted entirely, matching this
+// file's existing EmailNotifications_V2_NonAllValue_ObservesCaResponse probe above (which also
+// never sets it). No delegation/recipientEmails field is set either (the DTO has none).
+//
+// Split into two [SkippableFact] tests, both gated behind a NEW flag,
+// CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX=1 — deliberately distinct from
+// CERTINEXT_PROBE_EMAIL_NOTIFICATIONS above, since this test places two real orders and
+// requires a human to manually check a real inbox afterward, a materially bigger commitment
+// than the existing single-order, API-only probe. Not armed by default in ~/.env_certinext or
+// ~/.env_certinext_v2.
+//
+// Phase 1 — EmailNotifications_V2_RealInboxTest_PlaceOrders: verifies the catalog product
+// code resolves to productTypeID "13" (aborts before placing anything if it does not),
+// places the baseline ("all") order, waits 2 minutes, places the test ("0") order. Exactly
+// one create call per run — no retry path of any kind. A timeout or any non-2xx response on
+// either call logs the domain/timestamp/error, prints "STOP — check the orders report for
+// this domain before re-running" (per this file's own timeout gotcha above — a client-side
+// timeout does not mean the CA never processed the request), and skips placing the second
+// order. Never cancels either order in this phase — they are meant to sit open long enough
+// for notification emails to actually arrive. Ends with an ACTION REQUIRED block naming both
+// order IDs/domains and instructing the operator to check the inbox (including spam) at the
+// 5- and 30-minute marks, recording subject/sender/time for every email that arrives, for
+// each run.
+//
+// Phase 2 — EmailNotifications_V2_RealInboxTest_CancelOrders: run only after the human has
+// finished checking the inbox. Gated behind the same flag plus
+// CERTINEXT_INBOX_TEST_BASELINE_ORDER_ID / CERTINEXT_INBOX_TEST_ORDER_ID (the two order IDs
+// phase 1 printed in its ACTION REQUIRED block). Skips entirely if neither is set; if only
+// one is set (phase 1 stopped early after placing the baseline order but before the test
+// order), cancels only that one rather than requiring both. Cancels via this file's existing
+// CancelOrderRawAsync, then confirms cancellation via a follow-up read-only Track Order call
+// (orderState == "Order Cancelled").
+//
+// Like the probe above, this makes NO live API calls of any kind unless
+// CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX=1 is explicitly set.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading.Tasks;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using RestSharp;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ public class EmailNotificationsV2ProbeTests : IClassFixture
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly string _v2OvProductCode;
+ private readonly string _dcvDomainBase;
+ private readonly string _inboxTestEmail;
+ private readonly bool _v2Enabled;
+
+ ///
+ /// Catalog product code for the phase 1/2 real-inbox probe — V2 DV SSL, non-UCC.
+ /// Deliberately a fixed constant (not read from CERTINEXT_PRODUCT_CODE/
+ /// CERTINEXT_OV_PRODUCT_CODE) per the approved design: this probe's product choice is
+ /// locked in independently of whatever other env-configured product code a given shell
+ /// session happens to have set for unrelated tests. Verified against the live catalog's
+ /// productTypeID ("13") at the start of phase 1 before any order is placed — see
+ /// .
+ ///
+ private const string InboxProbeProductCode = "842";
+
+ public EmailNotificationsV2ProbeTests(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");
+ _v2OvProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_OV_PRODUCT_CODE", "846");
+ _dcvDomainBase = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "example.com");
+ _inboxTestEmail = Environment.GetEnvironmentVariable("CERTINEXT_INBOX_TEST_EMAIL")?.Trim();
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+ }
+
+ ///
+ /// Builds a typed against the V2 API — needed only for
+ /// 's catalog lookup (phase 1's
+ /// productTypeID guard), which has no raw-HTTP equivalent already in this file. Mirrors
+ /// OrganizationBlockV2ProbeTests.BuildV2Client exactly.
+ ///
+ private CERTInextClient BuildV2Client()
+ {
+ return new CERTInextClient(new CERTInextConfig
+ {
+ 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
+ });
+ }
+
+ ///
+ /// Places ONE real V2 OV order with emailNotifications: "0" (not the spec's
+ /// only-documented value, "all") and reports the raw create-order HTTP
+ /// status/body, whether the field is echoed back anywhere (create response or Track
+ /// Order), and whether the order was accepted, rejected, or something in between.
+ /// No CSR is submitted and the order is never pushed toward issuance. Best-effort
+ /// cancels the order afterward.
+ ///
+ /// Opt-in: requires CERTINEXT_PROBE_EMAIL_NOTIFICATIONS=1, plus a configured
+ /// CERTINEXT_ORG_NUMBER for the OV order's required organization block. Neither is
+ /// set by default.
+ ///
+ [SkippableFact]
+ public async Task EmailNotifications_V2_NonAllValue_ObservesCaResponse()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_EMAIL_NOTIFICATIONS");
+ Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1",
+ "CERTINEXT_PROBE_EMAIL_NOTIFICATIONS=1 not set — this probe places a real, " +
+ "potentially cost-bearing OV order and is opt-in only. Skipping.");
+
+ string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null;
+ Skip.If(string.IsNullOrWhiteSpace(organizationNumber),
+ "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — no pre-vetted organization " +
+ "number is available to populate the OV order's required organization block. Skipping.");
+
+ string domain = $"probe-0027-emailnotif-{DateTime.UtcNow:yyyyMMddHHmmss}.example.com";
+ var orderReq = new V2CreateSslOrderRequest
+ {
+ ProductVariant = "ov",
+ EmailNotifications = "0", // <-- the value under test; spec only documents "all"
+ Requestor = new V2Requestor
+ {
+ Name = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ Email = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com",
+ Phone = "0000000000",
+ Designation = "IT Administrator"
+ },
+ Organization = new V2OrganizationParams
+ {
+ OrganizationNumber = organizationNumber,
+ PreVetted = true
+ },
+ Certificate = new V2CertificateParams
+ {
+ Domain = domain,
+ AutoSecureWww = false
+ },
+ Subscription = new V2SubscriptionParams
+ {
+ ValidityYears = 1,
+ AutoRenew = false,
+ RenewBeforeDays = 30
+ },
+ Agreement = new V2AgreementParams
+ {
+ SignerName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ SignerIp = "127.0.0.1",
+ SignerPlace = "Gateway Lab",
+ Accepted = true
+ },
+ Remarks = "Issue 0027 item 1b live probe — emailNotifications=\"0\" instead of \"all\". " +
+ "No CSR submitted; not pushed to issuance."
+ };
+
+ string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions());
+
+ _output.WriteLine("=== Issue 0027 item 1b live probe: V2 emailNotifications=\"0\" on an OV order ===");
+ _output.WriteLine($"Domain={domain} ProductCode={_v2OvProductCode} OrganizationNumber={organizationNumber}");
+ _output.WriteLine($"Request body: {requestJson}");
+ _output.WriteLine("");
+
+ var createResp = await PlaceOrderRawAsync(_v2OvProductCode, requestJson);
+ _output.WriteLine($"--- Create-order response ---");
+ _output.WriteLine($"HTTP {createResp.StatusCode}");
+ _output.WriteLine($"Body: {createResp.Body}");
+
+ bool echoedInCreate = createResp.Body?.IndexOf("emailNotifications", StringComparison.OrdinalIgnoreCase) >= 0;
+ _output.WriteLine($"emailNotifications present in create response body: {echoedInCreate}");
+
+ if (!createResp.IsSuccessful)
+ {
+ _output.WriteLine("");
+ _output.WriteLine("=== VERDICT: REJECTED — CERTInext returned a non-success status for " +
+ $"emailNotifications=\"0\". HTTP {createResp.StatusCode}: {createResp.Body} ===");
+ return;
+ }
+
+ string orderId = TryExtractOrderId(createResp.Body);
+ _output.WriteLine($"OrderId={orderId ?? ""}");
+
+ string trackBody = null;
+ bool echoedInTrack = false;
+ if (!string.IsNullOrWhiteSpace(orderId))
+ {
+ try
+ {
+ var trackResp = await TrackOrderRawAsync(orderId);
+ trackBody = trackResp.Body;
+ _output.WriteLine("");
+ _output.WriteLine("--- Track Order response ---");
+ _output.WriteLine($"HTTP {trackResp.StatusCode}");
+ _output.WriteLine($"Body: {trackResp.Body}");
+ echoedInTrack = trackBody?.IndexOf("emailNotifications", StringComparison.OrdinalIgnoreCase) >= 0;
+ _output.WriteLine($"emailNotifications present in Track Order response body: {echoedInTrack}");
+ }
+ catch (Exception trackEx)
+ {
+ _output.WriteLine($"Track Order call failed (non-fatal to this probe): {trackEx.Message}");
+ }
+ }
+
+ _output.WriteLine("");
+ if (echoedInCreate || echoedInTrack)
+ {
+ _output.WriteLine("=== VERDICT: ACCEPTED, AND emailNotifications IS ECHOED BACK — inspect the " +
+ "captured body above to see the echoed value (accepted-as-sent vs. coerced). ===");
+ }
+ else
+ {
+ _output.WriteLine("=== VERDICT: ACCEPTED (HTTP 2xx, no rejection) but emailNotifications is NOT " +
+ "echoed back anywhere in the create or Track Order response — matches the spec, " +
+ "which never surfaces this field in any response schema. Whether the CA silently " +
+ "coerces \"0\" back to \"all\" server-side cannot be confirmed or ruled out via " +
+ "the API alone from this probe; only that a non-\"all\" value does not cause a " +
+ "hard rejection at create time. ===");
+ }
+
+ if (!string.IsNullOrWhiteSpace(orderId))
+ {
+ _output.WriteLine("");
+ _output.WriteLine($"Attempting best-effort cleanup: cancelling order {orderId}...");
+ try
+ {
+ var cancelResp = await CancelOrderRawAsync(orderId,
+ "Issue 0027 item 1b live probe — cleaning up after observing CA response.");
+ _output.WriteLine(cancelResp.IsSuccessful
+ ? $"Cleanup: order {orderId} cancel request returned HTTP {cancelResp.StatusCode} (success)."
+ : $"Cleanup FAILED for order {orderId}: HTTP {cancelResp.StatusCode}: {cancelResp.Body}. " +
+ "Cancel it by hand in the CERTInext portal if it should not remain pending.");
+ }
+ catch (Exception cleanupEx)
+ {
+ _output.WriteLine(
+ $"Cleanup FAILED for order {orderId}: {cleanupEx.Message}. " +
+ "Cancel it by hand in the CERTInext portal if it should not remain pending.");
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Phase 1/2 real-inbox probe (issue 0027 item 1b) — see this file's header comment
+ // for the full design. Both phases share InboxProbeProductCode/_dcvDomainBase and the
+ // CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX gate.
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Phase 1 of the EmailNotifications real-inbox probe (issue 0027 item 1b). Verifies
+ /// the catalog resolves to productTypeID "13" (DV
+ /// SSL, non-UCC) — matched on productTypeID only, never productName — and aborts
+ /// before placing any order if it does not. Places the baseline
+ /// (emailNotifications="all") order, waits 2 minutes, then places the test
+ /// (emailNotifications="0") order. Exactly one create call per run; no retry
+ /// path of any kind. A timeout or any non-2xx response on either call stops the run
+ /// (and, for the baseline call, skips the test order entirely) — see
+ /// . Neither order is cancelled by this phase; run
+ /// after the human
+ /// inbox-check window has elapsed.
+ ///
+ /// Opt-in: requires CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX=1. Not armed by default
+ /// in ~/.env_certinext or ~/.env_certinext_v2 — this places two real, potentially
+ /// cost-bearing V2 DV SSL orders and requires a human to manually check a real inbox
+ /// afterward, which cannot be automated or concluded by an agent.
+ ///
+ [SkippableFact]
+ public async Task EmailNotifications_V2_RealInboxTest_PlaceOrders()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX");
+ Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1",
+ "CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX=1 not set — this probe places TWO real, " +
+ "potentially cost-bearing V2 DV SSL orders and requires a human to manually check a " +
+ "real inbox afterward. Opt-in only. Skipping.");
+
+ // The inbox must be one a human actually reads — the env files' CERTINEXT_REQUESTOR_EMAIL
+ // is a non-deliverable placeholder, so this probe never falls back to it.
+ Skip.If(string.IsNullOrWhiteSpace(_inboxTestEmail),
+ "CERTINEXT_INBOX_TEST_EMAIL not set — set it to the real mailbox that will be checked. Skipping.");
+
+ _output.WriteLine("=== Issue 0027 item 1b real-inbox probe — phase 1: place baseline + test orders ===");
+ _output.WriteLine($"Requestor email (inbox to check): {_inboxTestEmail}");
+
+ // Catalog guard — abort before placing anything if the pinned product code does
+ // not resolve to productTypeID "13" (DV SSL, non-UCC) on this account/catalog
+ // version. Matched on productTypeID only, never productName (catalog productName
+ // strings are unstable across account/catalog-version/spellings — see this
+ // repo's Gotchas in issues/V2_AUDIT_TRIAGE_HANDOFF.md).
+ using CERTInextClient catalogClient = BuildV2Client();
+ List catalog = await catalogClient.GetProductDetailsV2Async();
+ ProductDetail product = catalog.FirstOrDefault(p => p.ProductCode == InboxProbeProductCode);
+
+ Skip.If(product == null,
+ $"Catalog product code {InboxProbeProductCode} was not found in the live V2 catalog " +
+ "for this account — aborting before placing any order.");
+ Skip.If(product.ProductTypeId != "13",
+ $"Catalog product code {InboxProbeProductCode} has productTypeID=\"{product.ProductTypeId}\" " +
+ "(expected \"13\" for DV SSL, non-UCC) — aborting before placing any order.");
+
+ _output.WriteLine($"Catalog check OK: ProductCode={product.ProductCode} " +
+ $"ProductTypeId={product.ProductTypeId} ProductName={product.ProductName}");
+
+ DateTime baselineTs = DateTime.UtcNow;
+ string baselineDomain = $"emailnotif-baseline-{baselineTs:yyyyMMddHHmmss}.{_dcvDomainBase}";
+
+ InboxProbeRunResult baselineResult =
+ await RunInboxProbeOrderAsync("BASELINE", baselineDomain, "all");
+
+ if (!baselineResult.Success)
+ {
+ // RunInboxProbeOrderAsync already logged the domain/timestamp/error and the
+ // STOP message (and the designation-answer block, if applicable). Per the
+ // approved design, a failed baseline call means the test order must not be
+ // placed at all.
+ return;
+ }
+
+ _output.WriteLine("");
+ _output.WriteLine("Waiting 2 minutes before placing the test (\"0\") order...");
+ await Task.Delay(TimeSpan.FromMinutes(2));
+
+ DateTime testTs = DateTime.UtcNow;
+ string testDomain = $"emailnotif-test0-{testTs:yyyyMMddHHmmss}.{_dcvDomainBase}";
+
+ InboxProbeRunResult testResult = await RunInboxProbeOrderAsync("TEST", testDomain, "0");
+
+ _output.WriteLine("");
+ _output.WriteLine("=== ACTION REQUIRED ===");
+ _output.WriteLine($"Baseline (\"all\") order: OrderId={baselineResult.OrderId ?? ""} " +
+ $"Domain={baselineDomain}");
+ _output.WriteLine(testResult.Success
+ ? $"Test (\"0\") order: OrderId={testResult.OrderId ?? ""} Domain={testDomain}"
+ : $"Test (\"0\") order: FAILED — see STOP message above. Domain={testDomain}");
+ _output.WriteLine("");
+ _output.WriteLine(
+ "Check the requestor's real inbox (INCLUDING SPAM) at the 5-minute and 30-minute marks " +
+ "after each order above was placed. For EVERY email that arrives for either domain, " +
+ "record its subject, sender, and time received, and which run (baseline/test) it " +
+ "corresponds to. This step cannot be automated or concluded by an agent — a human must " +
+ "check the inbox and report back before issue 0027 item 1b can be closed.");
+ _output.WriteLine("");
+ _output.WriteLine(
+ "When the inbox-check window is done, set CERTINEXT_INBOX_TEST_BASELINE_ORDER_ID / " +
+ "CERTINEXT_INBOX_TEST_ORDER_ID to the order ID(s) above (whichever were placed) and run " +
+ $"{nameof(EmailNotifications_V2_RealInboxTest_CancelOrders)} to clean up.");
+ }
+
+ ///
+ /// Phase 2 of the EmailNotifications real-inbox probe (issue 0027 item 1b) — run only
+ /// after the human inbox-check window from phase 1 has elapsed. Cancels whichever
+ /// order ID(s) are supplied via CERTINEXT_INBOX_TEST_BASELINE_ORDER_ID /
+ /// CERTINEXT_INBOX_TEST_ORDER_ID, then confirms cancellation via a follow-up read-only
+ /// Track Order call (orderState == "Order Cancelled").
+ ///
+ /// Opt-in: requires CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX=1 (same flag as phase 1)
+ /// plus at least one of the two order-ID env vars. Skips entirely if neither is set.
+ /// If only one is set — e.g. phase 1 stopped early after the baseline order but before
+ /// the test order — this cancels only that one rather than requiring both, so a
+ /// partial phase 1 run is never stuck without a cleanup path.
+ ///
+ [SkippableFact]
+ public async Task EmailNotifications_V2_RealInboxTest_CancelOrders()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX");
+ Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1",
+ "CERTINEXT_PROBE_EMAIL_NOTIFICATIONS_INBOX=1 not set — skipping.");
+
+ string baselineOrderId = Environment.GetEnvironmentVariable("CERTINEXT_INBOX_TEST_BASELINE_ORDER_ID");
+ string testOrderId = Environment.GetEnvironmentVariable("CERTINEXT_INBOX_TEST_ORDER_ID");
+
+ Skip.If(string.IsNullOrWhiteSpace(baselineOrderId) && string.IsNullOrWhiteSpace(testOrderId),
+ "Neither CERTINEXT_INBOX_TEST_BASELINE_ORDER_ID nor CERTINEXT_INBOX_TEST_ORDER_ID is " +
+ "set — nothing to cancel. Skipping.");
+
+ _output.WriteLine("=== Issue 0027 item 1b real-inbox probe — phase 2: cancel + confirm ===");
+
+ if (!string.IsNullOrWhiteSpace(baselineOrderId))
+ {
+ await CancelAndConfirmInboxProbeOrderAsync(baselineOrderId, "baseline (\"all\")");
+ }
+ else
+ {
+ _output.WriteLine(
+ "CERTINEXT_INBOX_TEST_BASELINE_ORDER_ID not set — skipping baseline-order cancel " +
+ "(phase 1 apparently never placed it, or it is being handled separately).");
+ }
+
+ if (!string.IsNullOrWhiteSpace(testOrderId))
+ {
+ await CancelAndConfirmInboxProbeOrderAsync(testOrderId, "test (\"0\")");
+ }
+ else
+ {
+ _output.WriteLine(
+ "CERTINEXT_INBOX_TEST_ORDER_ID not set — skipping test-order cancel " +
+ "(phase 1 apparently stopped before placing it, or it is being handled separately).");
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Private helpers — same raw-HTTP idiom as IdempotencyKeyV2ProbeTests /
+ // OrganizationBlockV2ProbeTests (see those files' header comments for rationale).
+ // ---------------------------------------------------------------------------
+
+ private static JsonSerializerOptions GetJsonOptions() => new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ private sealed class RawApiResponse
+ {
+ public int StatusCode { get; set; }
+ public string Body { get; set; }
+ public bool IsSuccessful { get; set; }
+ }
+
+ ///
+ /// 120s timeout — matches CERTInextClient's own V1/V2 RestClientOptions
+ /// (CERTInextClient.cs:71/88). The framework's default HttpClient timeout (100s) is
+ /// too short for this sandbox's OV order-create latency and was observed to trip
+ /// during authoring (HTTP 0 / empty body after ~100s, i.e. a transport-level
+ /// timeout, not a real CA rejection).
+ ///
+ private static RestClient NewApiClient(string baseUrl) =>
+ new RestClient(new RestClientOptions(baseUrl) { Timeout = TimeSpan.FromSeconds(120) });
+
+ private async Task GetV2AccessTokenAsync()
+ {
+ string tokenUrl = _v2ApiUrl.TrimEnd('/') + "/oauth/token";
+ using var tokenClient = NewApiClient(tokenUrl);
+ var tokenReq = new RestRequest(string.Empty, Method.Post);
+ tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded");
+ tokenReq.AddParameter("grant_type", "client_credentials");
+ tokenReq.AddParameter("client_id", _v2ClientId);
+ tokenReq.AddParameter("client_secret", _v2ClientSecret);
+ var tokenResp = await tokenClient.ExecuteAsync(tokenReq);
+ if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content))
+ throw new Exception($"Token request failed: {(int)tokenResp.StatusCode}");
+
+ using var tokenDoc = JsonDocument.Parse(tokenResp.Content);
+ return tokenDoc.RootElement.GetProperty("access_token").GetString();
+ }
+
+ ///
+ /// Raw HTTP POST to /api/certinext/v2/ssl-certificates. Does not throw on non-2xx —
+ /// the caller needs the exact raw status/body either way, which is the entire point
+ /// of this probe.
+ ///
+ private async Task PlaceOrderRawAsync(string productCode, string requestJson)
+ {
+ string accessToken = await GetV2AccessTokenAsync();
+
+ using var apiClient = NewApiClient(_v2ApiUrl.TrimEnd('/'));
+ var req = new RestRequest(Constants.ApiV2.SslCertificatesPath, Method.Post);
+ req.AddHeader("Authorization", $"Bearer {accessToken}");
+ req.AddHeader("Accept", "application/json");
+ req.AddHeader("X-Product-Code", productCode ?? string.Empty);
+ req.AddHeader("Idempotency-Key", Guid.NewGuid().ToString());
+ req.AddJsonBody(requestJson);
+
+ var resp = await apiClient.ExecuteAsync(req);
+ return ToRawApiResponse(resp);
+ }
+
+ ///
+ /// Raw HTTP GET to /api/certinext/v2/ssl-certificates/{orderId} (Track Order) — used
+ /// here purely to check whether emailNotifications is echoed back post-creation, not
+ /// to drive any lifecycle logic.
+ ///
+ private async Task TrackOrderRawAsync(string orderId)
+ {
+ string accessToken = await GetV2AccessTokenAsync();
+
+ using var apiClient = NewApiClient(_v2ApiUrl.TrimEnd('/'));
+ var req = new RestRequest($"{Constants.ApiV2.SslCertificatesPath}/{orderId}", Method.Get);
+ req.AddHeader("Authorization", $"Bearer {accessToken}");
+ req.AddHeader("Accept", "application/json");
+
+ var resp = await apiClient.ExecuteAsync(req);
+ return ToRawApiResponse(resp);
+ }
+
+ ///
+ /// Raw HTTP POST to /api/certinext/v2/ssl-certificates/{orderId}/cancel — same idiom
+ /// as OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync, but returns the raw
+ /// status/body instead of throwing on non-2xx so cleanup failure can be reported
+ /// without losing the underlying detail.
+ ///
+ private async Task CancelOrderRawAsync(string orderId, string reason)
+ {
+ string accessToken = await GetV2AccessTokenAsync();
+
+ using var apiClient = NewApiClient(_v2ApiUrl.TrimEnd('/'));
+ var cancelReq = new RestRequest($"{Constants.ApiV2.SslCertificatesPath}/{orderId}/cancel", Method.Post);
+ cancelReq.AddHeader("Authorization", $"Bearer {accessToken}");
+ cancelReq.AddJsonBody(new { reason });
+ var cancelResp = await apiClient.ExecuteAsync(cancelReq);
+ return ToRawApiResponse(cancelResp);
+ }
+
+ ///
+ /// Converts a RestSharp into the probe's raw
+ /// status/body/success tuple. On a transport-level failure (no HTTP status ever
+ /// received — e.g. a timeout), StatusCode/Content come back empty/zero; in that case
+ /// this falls back to RestSharp's own ErrorMessage/ErrorException so the failure
+ /// reason is still visible in the report rather than an unexplained "HTTP 0".
+ ///
+ private static RawApiResponse ToRawApiResponse(RestResponse resp)
+ {
+ string body = resp.Content;
+ if (string.IsNullOrEmpty(body) && !resp.IsSuccessful)
+ {
+ body = resp.ErrorException != null
+ ? $""
+ : $"";
+ }
+
+ return new RawApiResponse
+ {
+ StatusCode = (int)resp.StatusCode,
+ Body = body,
+ IsSuccessful = resp.IsSuccessful
+ };
+ }
+
+ ///
+ /// Best-effort orderId extraction from a raw JSON response body. Returns null rather
+ /// than throwing if the body is empty, malformed, or lacks an orderId field.
+ ///
+ private static string TryExtractOrderId(string body)
+ {
+ if (string.IsNullOrWhiteSpace(body))
+ return null;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ return doc.RootElement.TryGetProperty("orderId", out var el) ? el.GetString() : null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Best-effort extraction of a named top-level string field (e.g. orderState,
+ /// certificateState) from a raw Track Order JSON response body. Returns null
+ /// rather than throwing if the body is empty, malformed, or lacks the field.
+ ///
+ private static string TryExtractStringField(string body, string fieldName)
+ {
+ if (string.IsNullOrWhiteSpace(body))
+ return null;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ return doc.RootElement.TryGetProperty(fieldName, out var el) ? el.GetString() : null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Phase 1/2 real-inbox probe helpers (issue 0027 item 1b).
+ // ---------------------------------------------------------------------------
+
+ /// Outcome of a single create-order run inside .
+ private sealed class InboxProbeRunResult
+ {
+ public bool Success { get; set; }
+ public string OrderId { get; set; }
+ public bool MentionsDesignation { get; set; }
+ }
+
+ ///
+ /// Builds the request body for one baseline/test run — DV SSL, non-UCC, no CSR, no
+ /// DCV. Only requestor.email/requestor.name are populated (from the
+ /// fixture's requestor config); requestor.phone and, deliberately,
+ /// requestor.designation are left unset so the global
+ /// DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull serializer
+ /// option () omits the JSON key entirely rather than
+ /// sending null or an empty string — issue 0027 item 5e's open design question.
+ /// technicalPointOfContact is left unset for the same reason, matching this
+ /// file's existing
+ /// probe (which also never sets it). No organization block (DV never requires
+ /// one) and no delegation/recipientEmails field (the DTO has none).
+ ///
+ private V2CreateSslOrderRequest BuildInboxProbeOrderRequest(
+ string domain, string emailNotificationsValue, string runLabel) =>
+ new V2CreateSslOrderRequest
+ {
+ ProductVariant = "dv",
+ EmailNotifications = emailNotificationsValue,
+ Requestor = new V2Requestor
+ {
+ Name = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ Email = _inboxTestEmail
+ // Phone and Designation deliberately left unset (null) — omitted from the
+ // wire body entirely, not sent as null/empty.
+ },
+ Certificate = new V2CertificateParams
+ {
+ Domain = domain,
+ AutoSecureWww = false
+ },
+ Subscription = new V2SubscriptionParams
+ {
+ ValidityYears = 1,
+ AutoRenew = false,
+ RenewBeforeDays = 30
+ },
+ Agreement = new V2AgreementParams
+ {
+ SignerName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ SignerIp = "127.0.0.1",
+ SignerPlace = "Gateway Lab",
+ Accepted = true
+ },
+ // TechnicalPointOfContact deliberately left unset (null) — see doc comment above.
+ Remarks = $"Issue 0027 item 1b real-inbox probe — {runLabel} run, " +
+ $"emailNotifications=\"{emailNotificationsValue}\", requestor.designation " +
+ "omitted (issue 0027 item 5e). No CSR submitted, DCV never invoked."
+ };
+
+ ///
+ /// Runs one baseline/test create-order call for the phase 1 real-inbox probe: builds
+ /// and logs the request body (no token in it to redact), places EXACTLY one
+ /// create-order call (no retry of any kind), logs the HTTP status/full response
+ /// body/order ID, and — on success — a follow-up read-only Track Order status. On a
+ /// timeout or any non-2xx response, logs the domain/timestamp/error and prints "STOP —
+ /// check the orders report for this domain before re-running" (a client-side timeout
+ /// on this endpoint does not mean the CA never processed the request — see this file's
+ /// header comment). If the failing response body mentions "designation", that is
+ /// additionally logged verbatim as a live answer to issue 0027 item 5e.
+ ///
+ private async Task RunInboxProbeOrderAsync(
+ string runLabel, string domain, string emailNotificationsValue)
+ {
+ var orderReq = BuildInboxProbeOrderRequest(domain, emailNotificationsValue, runLabel);
+ string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions());
+
+ _output.WriteLine("");
+ _output.WriteLine($"--- {runLabel} run: emailNotifications=\"{emailNotificationsValue}\", domain={domain} ---");
+ _output.WriteLine($"Request body: {requestJson}");
+
+ var createResp = await PlaceOrderRawAsync(InboxProbeProductCode, requestJson);
+ _output.WriteLine($"Create-order response ({runLabel}): HTTP {createResp.StatusCode}");
+ _output.WriteLine($"Body: {createResp.Body}");
+
+ var result = new InboxProbeRunResult();
+
+ if (!createResp.IsSuccessful)
+ {
+ result.Success = false;
+ result.MentionsDesignation =
+ createResp.Body?.IndexOf("designation", StringComparison.OrdinalIgnoreCase) >= 0;
+
+ _output.WriteLine("");
+ _output.WriteLine($"=== {runLabel} run FAILED at {DateTime.UtcNow:O} — Domain={domain} " +
+ $"HTTP {createResp.StatusCode}: {createResp.Body} ===");
+ _output.WriteLine("STOP — check the orders report for this domain before re-running.");
+
+ if (result.MentionsDesignation)
+ {
+ _output.WriteLine("");
+ _output.WriteLine(
+ "=== DESIGNATION ANSWER (issue 0027 item 5e) — the create call was rejected and " +
+ $"the error mentions \"designation\"; verbatim response: {createResp.Body} ===");
+ }
+
+ return result;
+ }
+
+ result.Success = true;
+ result.OrderId = TryExtractOrderId(createResp.Body);
+ _output.WriteLine($"OrderId={result.OrderId ?? ""}");
+
+ if (!string.IsNullOrWhiteSpace(result.OrderId))
+ {
+ try
+ {
+ var trackResp = await TrackOrderRawAsync(result.OrderId);
+ string orderState = TryExtractStringField(trackResp.Body, "orderState");
+ string certState = TryExtractStringField(trackResp.Body, "certificateState");
+ _output.WriteLine(
+ $"Track Order ({runLabel}): HTTP {trackResp.StatusCode}, " +
+ $"orderState={orderState ?? ""}, certificateState={certState ?? ""}");
+ }
+ catch (Exception trackEx)
+ {
+ _output.WriteLine($"Track Order call failed for {runLabel} (non-fatal to this probe): {trackEx.Message}");
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Cancels one order from the real-inbox probe (phase 2) via the existing
+ /// idiom, then confirms cancellation via a
+ /// follow-up read-only Track Order call, logging whether orderState came back
+ /// as "Order Cancelled".
+ ///
+ private async Task CancelAndConfirmInboxProbeOrderAsync(string orderId, string label)
+ {
+ _output.WriteLine("");
+ _output.WriteLine($"--- Cancelling {label} order {orderId} ---");
+
+ var cancelResp = await CancelOrderRawAsync(orderId,
+ "Issue 0027 item 1b real-inbox probe — cleanup after the inbox-check window.");
+ _output.WriteLine($"Cancel response: HTTP {cancelResp.StatusCode}: {cancelResp.Body}");
+
+ var trackResp = await TrackOrderRawAsync(orderId);
+ _output.WriteLine($"Track Order (post-cancel) response: HTTP {trackResp.StatusCode}: {trackResp.Body}");
+
+ string orderState = TryExtractStringField(trackResp.Body, "orderState");
+ bool confirmed = string.Equals(orderState, "Order Cancelled", StringComparison.OrdinalIgnoreCase);
+
+ _output.WriteLine(confirmed
+ ? $"CONFIRMED: order {orderId} ({label}) orderState=\"Order Cancelled\"."
+ : $"NOT CONFIRMED: order {orderId} ({label}) orderState=\"{orderState ?? ""}\" " +
+ "(expected \"Order Cancelled\"). Check manually in the CERTInext portal.");
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
index 441f573..5a982ef 100644
--- a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
+++ b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
@@ -59,6 +59,10 @@ The file is parsed line by line:
- 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).
+- Exception: the fixture fails fast if the resolved `CERTINEXT_API_URL` lacks `/emSignHub-API`
+ (a V2 base URL leaked in, issue 0017). Source only `~/.env_certinext` into the shell, never
+ `~/.env_certinext_v2`. The V2 test classes read that file from disk themselves and never write
+ V1-shared keys (`CERTINEXT_API_URL`, `CERTINEXT_ACCESS_KEY`, ...) into the process environment.
---
diff --git a/CERTInext.IntegrationTests/IdempotencyKeyV2ProbeTests.cs b/CERTInext.IntegrationTests/IdempotencyKeyV2ProbeTests.cs
new file mode 100644
index 0000000..930021e
--- /dev/null
+++ b/CERTInext.IntegrationTests/IdempotencyKeyV2ProbeTests.cs
@@ -0,0 +1,327 @@
+// 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.
+//
+// Live investigation probe for issue 0032 (V2 Idempotency-Key is minted fresh on every
+// call, so it can never dedupe a retry). This is purely informational — no code fix is
+// planned regardless of the outcome (see issues/0032-v2-idempotency-key-not-reused-on-
+// retry.md's "Live investigation" section); the maintainer asked whether the live sandbox
+// already enforces Idempotency-Key dedup on V2 order-create today, ahead of the spec's own
+// "parsed today, enforced in a future release" wording. Places TWO real V2 DV SSL
+// order-create calls with byte-identical request bodies and the SAME hardcoded
+// Idempotency-Key header value, back-to-back, and reports which of three outcomes was
+// observed:
+//
+// 1. Same orderId returned both times -> dedup IS enforced today.
+// 2. Two distinct orderIds returned -> dedup is NOT enforced (matches the
+// spec's "future release" wording).
+// 3. Second call returns a non-2xx (e.g. 409) -> an explicit-rejection enforcement
+// mode (neither of the above).
+//
+// Raw HTTP only (mirrors OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync's idiom) —
+// deliberately does NOT go through CERTInextClient.PlaceOrderV2Async, since that method
+// mints a fresh GUID per call (the exact behavior under investigation) and has no
+// parameter for a caller-supplied idempotency key. No production code is touched by this
+// probe.
+//
+// Opt-in: requires CERTINEXT_PROBE_IDEMPOTENCY_KEY=1 (same convention as this file's
+// sibling OrganizationBlockV2ProbeTests's CERTINEXT_PROBE_ORG_MISSING/CERTINEXT_PROBE_ORG_FIX
+// flags). Not armed by default in ~/.env_certinext or ~/.env_certinext_v2 — an operator
+// must deliberately opt in, since this places one or two real, potentially cost-bearing V2
+// DV SSL orders (product code from CERTINEXT_PRODUCT_CODE, default 842 — the standard
+// cheap/throwaway DV SSL test product already reused across V2LifecycleTests.cs/
+// V2ApiTests.cs). Both orders (if dedup fails and two distinct orders are created) are
+// best-effort cancelled in a finally block. Neither order ever reaches 'issued' state (no
+// DCV/CSR step is involved), so plugin.Revoke is never called — matching this repo's
+// convention for never-issued probe orders.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+using RestSharp;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ public class IdempotencyKeyV2ProbeTests : IClassFixture
+ {
+ 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;
+
+ ///
+ /// Fixed, hardcoded Idempotency-Key value reused across BOTH order-create calls in
+ /// — the entire
+ /// point of the probe is that this value does NOT change between calls (unlike
+ /// CERTInextClient.PlaceOrderV2Async's own Guid.NewGuid()-per-call
+ /// behavior, issue 0032's core finding).
+ ///
+ private const string ProbeIdempotencyKey = "00320032-0032-0032-0032-003200320032";
+
+ public IdempotencyKeyV2ProbeTests(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");
+ _v2ProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE", "842");
+ _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);
+ }
+
+ ///
+ /// Places two real V2 DV SSL order-create calls, back-to-back, with byte-identical
+ /// request bodies and the same hardcoded header
+ /// value, and reports which of the three outcomes described in this file's header
+ /// comment was observed. Informational only — no assertion is made on WHICH outcome
+ /// occurs (that is the unknown this probe exists to answer); the only hard assertion
+ /// is that the first call itself succeeds, since a first-call failure would be an
+ /// unrelated test-environment/account problem, not a dedup-behavior finding.
+ ///
+ /// Opt-in: requires CERTINEXT_PROBE_IDEMPOTENCY_KEY=1. Not armed by default in
+ /// ~/.env_certinext or ~/.env_certinext_v2.
+ ///
+ [SkippableFact]
+ public async Task IdempotencyKey_V2_SameKeyTwice_ObservesDedupBehavior()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_IDEMPOTENCY_KEY");
+ Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1",
+ "CERTINEXT_PROBE_IDEMPOTENCY_KEY=1 not set — this probe places one or two real, " +
+ "potentially cost-bearing V2 DV SSL orders and is opt-in only. Skipping.");
+
+ var orderReq = BuildStandardOrderRequest();
+ string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions());
+
+ _output.WriteLine("=== Issue 0032 live probe: V2 Idempotency-Key dedup on order-create ===");
+ _output.WriteLine($"ProductCode={_v2ProductCode} Domain={_v2Domain} IdempotencyKey={ProbeIdempotencyKey}");
+ _output.WriteLine($"Request body (identical bytes sent on both calls): {requestJson}");
+
+ var orderIds = new List();
+ try
+ {
+ _output.WriteLine("");
+ _output.WriteLine("--- Call 1 ---");
+ var firstResp = await PlaceOrderRawAsync(ProbeIdempotencyKey, requestJson);
+ _output.WriteLine($"HTTP {firstResp.StatusCode}: {firstResp.Body}");
+
+ firstResp.IsSuccessful.Should().BeTrue(
+ "the FIRST order-create call must succeed on its own merits (unrelated to " +
+ $"idempotency-key behavior) — got HTTP {firstResp.StatusCode}: {firstResp.Body}");
+
+ string firstOrderId = TryExtractOrderId(firstResp.Body);
+ firstOrderId.Should().NotBeNullOrWhiteSpace(
+ "a successful order-create response must carry a non-empty orderId");
+ orderIds.Add(firstOrderId);
+
+ _output.WriteLine("");
+ _output.WriteLine("--- Call 2 (same Idempotency-Key, same request body) ---");
+ var secondResp = await PlaceOrderRawAsync(ProbeIdempotencyKey, requestJson);
+ _output.WriteLine($"HTTP {secondResp.StatusCode}: {secondResp.Body}");
+
+ string verdict;
+ if (secondResp.IsSuccessful)
+ {
+ string secondOrderId = TryExtractOrderId(secondResp.Body);
+ if (!string.IsNullOrWhiteSpace(secondOrderId) && secondOrderId != firstOrderId)
+ orderIds.Add(secondOrderId);
+
+ verdict = !string.IsNullOrWhiteSpace(secondOrderId) && secondOrderId == firstOrderId
+ ? $"OUTCOME 1 — DEDUP IS ENFORCED TODAY: both calls returned the same orderId '{firstOrderId}'."
+ : "OUTCOME 2 — DEDUP IS NOT ENFORCED: two distinct orderIds returned " +
+ $"('{firstOrderId}' and '{secondOrderId ?? ""}'). Matches the spec's " +
+ "\"parsed today, enforced in a future release\" wording.";
+ }
+ else
+ {
+ verdict = $"OUTCOME 3 — EXPLICIT REJECTION: first call succeeded (orderId='{firstOrderId}'), " +
+ "second call with the same Idempotency-Key was rejected: " +
+ $"HTTP {secondResp.StatusCode}: {secondResp.Body}";
+ }
+
+ _output.WriteLine("");
+ _output.WriteLine($"=== VERDICT: {verdict} ===");
+ }
+ finally
+ {
+ _output.WriteLine("");
+ foreach (string orderId in orderIds)
+ {
+ _output.WriteLine($"Attempting best-effort cleanup: cancelling order {orderId}...");
+ try
+ {
+ await CancelSslOrderRawAsync(orderId,
+ "Issue 0032 idempotency-key probe — cleaning up after observing CA response.");
+ _output.WriteLine($"Cleanup: order {orderId} cancel request returned success.");
+ }
+ catch (Exception cleanupEx)
+ {
+ _output.WriteLine(
+ $"Cleanup FAILED for order {orderId}: {cleanupEx.Message}. " +
+ "Cancel it by hand in the CERTInext portal if it should not remain pending.");
+ }
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Private helpers
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Mirrors V2ApiTests.BuildStandardOrderRequest's exact shape/fields — a
+ /// standard DV SSL order-create body with no CSR and no per-call-unique field, so
+ /// serializing it once and reusing the resulting JSON string for both calls
+ /// guarantees byte-identical request bodies.
+ ///
+ 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",
+ SignerIp = "127.0.0.1",
+ SignerPlace = "Gateway Lab",
+ Accepted = true
+ },
+ Remarks = "Issue 0032 idempotency-key live probe — safe to cancel immediately."
+ };
+
+ ///
+ /// Same serializer options as CERTInextClient.GetJsonOptions (private in that
+ /// class, so duplicated here) — needed so the JSON body this probe sends matches
+ /// what the production client would actually send byte-for-byte.
+ ///
+ private static JsonSerializerOptions GetJsonOptions() => new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ private sealed class RawApiResponse
+ {
+ public int StatusCode { get; set; }
+ public string Body { get; set; }
+ public bool IsSuccessful { get; set; }
+ }
+
+ ///
+ /// Raw HTTP POST to /api/certinext/v2/ssl-certificates with an explicit,
+ /// caller-supplied Idempotency-Key header — deliberately bypasses
+ /// CERTInextClient.PlaceOrderV2Async, which mints its own fresh GUID per call
+ /// and has no parameter for a caller-supplied key. Mirrors
+ /// OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync's token-fetch idiom.
+ /// Does not throw on non-2xx — the caller needs the raw status/body either way.
+ ///
+ private async Task PlaceOrderRawAsync(string idempotencyKey, string requestJson)
+ {
+ string accessToken = await GetV2AccessTokenAsync();
+
+ using var apiClient = new RestClient(_v2ApiUrl.TrimEnd('/'));
+ var req = new RestRequest(Constants.ApiV2.SslCertificatesPath, Method.Post);
+ req.AddHeader("Authorization", $"Bearer {accessToken}");
+ req.AddHeader("Accept", "application/json");
+ req.AddHeader("X-Product-Code", _v2ProductCode);
+ req.AddHeader("Idempotency-Key", idempotencyKey);
+ req.AddJsonBody(requestJson);
+
+ var resp = await apiClient.ExecuteAsync(req);
+ return new RawApiResponse
+ {
+ StatusCode = (int)resp.StatusCode,
+ Body = resp.Content,
+ IsSuccessful = resp.IsSuccessful
+ };
+ }
+
+ ///
+ /// Standalone OAuth2 client_credentials token fetch. Delegates to
+ /// (issue 0058) — this file used
+ /// to carry its own private copy, near-identical to
+ /// OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync's inline token request;
+ /// extracted so this file, OrganizationBlockV2ProbeTests, and
+ /// V2GapProbeTests share one implementation. Behavior is unchanged.
+ ///
+ private Task GetV2AccessTokenAsync() =>
+ V2RawProbeHelpers.GetV2AccessTokenAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret);
+
+ ///
+ /// Standalone cancel call for triage cleanup only. Delegates to
+ /// (issue 0058) — same idiom as
+ /// above. Behavior is unchanged.
+ ///
+ private Task CancelSslOrderRawAsync(string orderId, string reason) =>
+ V2RawProbeHelpers.CancelSslOrderRawAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret, orderId, reason);
+
+ ///
+ /// Best-effort orderId extraction from a raw JSON response body. Returns null rather
+ /// than throwing if the body is empty, malformed, or lacks an orderId field —
+ /// callers treat a null/empty result as "could not confirm an orderId", which is
+ /// itself part of the outcome this probe reports.
+ ///
+ private static string TryExtractOrderId(string body)
+ {
+ if (string.IsNullOrWhiteSpace(body))
+ return null;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ return doc.RootElement.TryGetProperty("orderId", out var el) ? el.GetString() : null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/IntegrationTestFixture.cs b/CERTInext.IntegrationTests/IntegrationTestFixture.cs
index 8e4f637..67f2888 100644
--- a/CERTInext.IntegrationTests/IntegrationTestFixture.cs
+++ b/CERTInext.IntegrationTests/IntegrationTestFixture.cs
@@ -20,6 +20,85 @@ 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 (issue 0058).
+ ///
+ 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_GAP_PROBES",
+ // 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",
+ };
+
+ // ---------------------------------------------------------------------------
+ // 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 (issue 0017).
+ ///
+ 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 (issue 0017).
+ ///
+ 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 +162,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);
+ // Issue 0017: 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 +232,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 +259,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 +293,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 (issue 0017). Left unchecked, every V1 call 404s and surfaces as the
+ /// misleading "unrecognised error body" (issue 0044). 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 (issue 0017); 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/KfclabFieldProbeTests.cs b/CERTInext.IntegrationTests/KfclabFieldProbeTests.cs
new file mode 100644
index 0000000..b7b250c
--- /dev/null
+++ b/CERTInext.IntegrationTests/KfclabFieldProbeTests.cs
@@ -0,0 +1,169 @@
+// 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
+//
+// Probe: reproduce the kfclab gateway's "Inactive Account User." failure on
+// PlaceOrder by submitting the exact field shape kfclab sends. Then flip one
+// field at a time back to the integration-test default to isolate which
+// individual field trips the CERTInext error.
+//
+// Baseline integration test config (proven to work — created order 7518968666):
+// SignerIp = "127.0.0.1"
+// SignerPlace = "Gateway"
+// RequestorMobileNumber = "0000000000"
+// RequestorIsdCode = "1"
+//
+// kfclab gateway config:
+// SignerIp = "0.0.0.0"
+// SignerPlace = "Lab"
+// RequestorMobileNumber = "" (unset → empty string on the wire)
+// RequestorIsdCode = "" (unset → plugin defaults to "1")
+//
+// Run with: dotnet test --filter FullyQualifiedName~KfclabFieldProbeTests
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Keyfactor.Extensions.CAPlugin.CERTInext;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+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 KfclabFieldProbeTests : IClassFixture
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _out;
+
+ public KfclabFieldProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _out = output;
+ }
+
+ private static string GenerateCsrPem(string cn)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var kp = keyGen.GenerateKeyPair();
+ var csr = new Pkcs10CertificationRequest(
+ "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private);
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ private CERTInextConfig BuildConfig(
+ string signerIp,
+ string signerPlace,
+ string mobile,
+ string isdCode,
+ string requestorName)
+ {
+ return new CERTInextConfig
+ {
+ ApiUrl = _fixture.ApiUrl.TrimEnd('/') + "/",
+ AuthMode = "AccessKey",
+ ApiKey = _fixture.AccessKey,
+ AccountNumber = _fixture.AccountNumber,
+ GroupNumber = _fixture.GroupNumber,
+ OrganizationNumber = _fixture.OrgNumber,
+ RequestorName = requestorName,
+ RequestorEmail = _fixture.RequestorEmail,
+ RequestorIsdCode = isdCode,
+ RequestorMobileNumber = mobile,
+ SignerPlace = signerPlace,
+ SignerIp = signerIp,
+ DefaultProductCode = "842",
+ PageSize = 100
+ };
+ }
+
+ private async Task<(bool ok, string detail)> TryEnrollAsync(CERTInextConfig cfg, string label)
+ {
+ var client = new CERTInextClient(cfg);
+ string cn = $"probe-{label}-{DateTime.UtcNow:yyyyMMddHHmmss}.lab.example.com";
+ string csr = GenerateCsrPem(cn);
+
+ var req = new EnrollCertificateRequest
+ {
+ Csr = csr,
+ Subject = $"CN={cn}",
+ Sans = new List { new SanEntry { Type = "DNS", Value = cn } },
+ ProfileId = "842",
+ RequesterName = cfg.RequestorName,
+ RequesterEmail = cfg.RequestorEmail,
+ };
+
+ try
+ {
+ var resp = await client.EnrollCertificateAsync(req);
+ return (true, $"OrderNumber={resp?.Id} Status={resp?.Status}");
+ }
+ catch (Exception ex)
+ {
+ return (false, ex.Message);
+ }
+ }
+
+ // Run each variant in a single fact so we get one consolidated report.
+ [SkippableFact]
+ public async Task Probe_FieldByField()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ // 1. baseline: integration-test config (known good)
+ var baseline = BuildConfig(
+ signerIp: "127.0.0.1",
+ signerPlace: "Gateway",
+ mobile: "0000000000",
+ isdCode: "1",
+ requestorName: _fixture.RequestorName);
+
+ // 2. kfclab exact
+ var kfclab = BuildConfig(
+ signerIp: "0.0.0.0",
+ signerPlace: "Lab",
+ mobile: "",
+ isdCode: "",
+ requestorName: "Keyfactor Plugin Test"); // no quotes
+
+ // 3..n. baseline-but-one-field-set-to-kfclab-value
+ var bSignerIp = BuildConfig("0.0.0.0", "Gateway", "0000000000", "1", _fixture.RequestorName);
+ var bSignerPlc = BuildConfig("127.0.0.1", "Lab", "0000000000", "1", _fixture.RequestorName);
+ var bMobile = BuildConfig("127.0.0.1", "Gateway", "", "1", _fixture.RequestorName);
+ var bIsd = BuildConfig("127.0.0.1", "Gateway", "0000000000", "", _fixture.RequestorName);
+ var bReqName = BuildConfig("127.0.0.1", "Gateway", "0000000000", "1", "Keyfactor Plugin Test");
+
+ var probes = new (string Label, CERTInextConfig Cfg)[]
+ {
+ ("baseline (integration-test defaults)", baseline),
+ ("kfclab exact", kfclab),
+ ("baseline + SignerIp=0.0.0.0", bSignerIp),
+ ("baseline + SignerPlace=Lab", bSignerPlc),
+ ("baseline + RequestorMobile=empty", bMobile),
+ ("baseline + RequestorIsdCode=empty", bIsd),
+ ("baseline + RequestorName=unquoted", bReqName),
+ };
+
+ _out.WriteLine("=== Field-by-field PlaceOrder probe ===");
+ _out.WriteLine($"fixture RequestorName (literal, may contain quotes): [{_fixture.RequestorName}]");
+ _out.WriteLine("");
+
+ foreach (var (label, cfg) in probes)
+ {
+ var (ok, detail) = await TryEnrollAsync(cfg, label.Replace(" ", "-"));
+ _out.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {label,-44} → {detail}");
+ // throttle ~1s between probes — sandbox sometimes throttles bursts
+ await Task.Delay(1000);
+ }
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/ListOrdersErrorBodyProbeTests.cs b/CERTInext.IntegrationTests/ListOrdersErrorBodyProbeTests.cs
new file mode 100644
index 0000000..5409514
--- /dev/null
+++ b/CERTInext.IntegrationTests/ListOrdersErrorBodyProbeTests.cs
@@ -0,0 +1,239 @@
+// 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.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.Logging;
+using Microsoft.Extensions.Logging;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Issue 0044 triage probe: why did V2ReportProbeTests.Probe4 fail inside the V1
+ /// with "unrecognised error body for operation
+ /// 'list orders page 1'"?
+ ///
+ /// Read-only. Makes exactly two V1 GetOrderReport page-1 list calls (pageSize 5,
+ /// enumeration stopped after page 1) with the same access-key config the
+ /// builds, differing only in ApiUrl:
+ ///
+ /// - Control: the V1 URL read straight from ~/.env_certinext (immune to shell env).
+ /// - Repro: the V2 base URL from ~/.env_certinext_v2, which is what the fixture's
+ /// ApiUrl becomes when that file is sourced into the shell (issue 0017).
+ ///
+ /// It also shows, offline, which ApiUrl the fixture resolves under each env overlay.
+ /// Since the 0017 fix the shell overlay makes the fixture fail fast with an actionable message
+ /// (printed as FAIL-FAST: ...), and an earlier
+ /// no longer changes the fixture's ApiUrl. The repro call still hits the V2 base URL
+ /// directly (bypassing the fixture) to capture the raw error body.
+ ///
+ /// The response body is captured from the client's own non-success log line (already
+ /// redacted via ApplyLoggingRedaction) by swapping
+ /// before the first is constructed. That only works when this
+ /// class runs alone in the test process, which is why it takes no class fixture and must be
+ /// run with its own filter. It also briefly mutates process env (restored afterwards), so it
+ /// sits in a non-parallel collection. Opt-in: CERTINEXT_0044_PROBE=1 must be set in
+ /// the shell. Both env files are read from disk; don't source either into the shell.
+ ///
+ /// CERTINEXT_0044_PROBE=1 dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \
+ /// --filter "FullyQualifiedName~ListOrdersErrorBodyProbeTests" \
+ /// --logger "console;verbosity=detailed" > /tmp/0044-probe.log 2>&1
+ ///
+ ///
+ [Collection(ListOrdersErrorBodyProbeCollection.Name)]
+ public class ListOrdersErrorBodyProbeTests
+ {
+ private const string ProbeFlag = "CERTINEXT_0044_PROBE";
+ private const string ApiUrlKey = "CERTINEXT_API_URL";
+
+ private readonly ITestOutputHelper _output;
+
+ public ListOrdersErrorBodyProbeTests(ITestOutputHelper output)
+ {
+ _output = output;
+ }
+
+ private sealed class CapturingLoggerFactory : ILoggerFactory
+ {
+ public ConcurrentQueue Messages { get; } = new();
+ public ILogger CreateLogger(string categoryName) => new CapturingLogger(Messages);
+ public void AddProvider(ILoggerProvider provider) { }
+ public void Dispose() { }
+
+ private sealed class CapturingLogger : ILogger
+ {
+ private readonly ConcurrentQueue _messages;
+ public CapturingLogger(ConcurrentQueue messages) => _messages = messages;
+ public IDisposable BeginScope(TState state) => null;
+ public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception,
+ Func formatter)
+ => _messages.Enqueue($"[{logLevel}] {formatter(state, exception)}");
+ }
+ }
+
+ [SkippableFact]
+ public async Task ListOrdersPage1_V1UrlVsV2BaseUrl_CapturesErrorBody()
+ {
+ Skip.If(string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(ProbeFlag)),
+ $"{ProbeFlag} not set — skipping issue 0044 ListOrders error-body probe.");
+
+ string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var (v1File, _) = V2EnvHelper.LoadEnvFile(Path.Combine(home, ".env_certinext"));
+ var (v2File, _) = V2EnvHelper.LoadEnvFile(Path.Combine(home, ".env_certinext_v2"));
+ string v1Url = V2EnvHelper.GetEnv(v1File, ApiUrlKey);
+ string v2Url = V2EnvHelper.GetEnv(v2File, ApiUrlKey);
+ Skip.If(string.IsNullOrWhiteSpace(v1Url) || string.IsNullOrWhiteSpace(v2Url),
+ "Need CERTINEXT_API_URL in both ~/.env_certinext and ~/.env_certinext_v2.");
+
+ string shellApiUrl = Environment.GetEnvironmentVariable(ApiUrlKey);
+ _output.WriteLine("=== Issue 0044: V1 ListOrdersAsync page 1 ===");
+ _output.WriteLine($"V1 file ApiUrl = {v1Url}");
+ _output.WriteLine($"V2 file ApiUrl = {v2Url}");
+ _output.WriteLine($"Shell {ApiUrlKey} = {shellApiUrl ?? "(unset)"}");
+
+ var capture = new CapturingLoggerFactory();
+ string pollutedResolved;
+ string promotedResolved;
+ try
+ {
+ // Must happen before any CERTInextClient exists: its Logger is static readonly.
+ LogHandler.Factory = capture;
+
+ // Offline: which ApiUrl the fixture resolves under the current shell env.
+ var fixture = new IntegrationTestFixture();
+ Skip.IfNot(fixture.IsConfigured, "V1 fixture not configured (~/.env_certinext).");
+ _output.WriteLine($"Fixture ApiUrl (current shell env) = {fixture.Config.ApiUrl}");
+
+ // Live call 1 (control): V1 URL from the file.
+ await RunListOrdersPage1Async("control: V1 file URL", fixture.Config, v1Url, capture);
+
+ // Live call 2 (repro): V2 base URL, as the fixture sees it under the 0017 overlay.
+ await RunListOrdersPage1Async("repro: V2 base URL", fixture.Config, v2Url, capture);
+
+ // Offline: fixture ApiUrl when the shell has sourced ~/.env_certinext_v2 (0017).
+ pollutedResolved = ResolveFixtureApiUrlWith(() => Environment.SetEnvironmentVariable(ApiUrlKey, v2Url));
+
+ // Offline: fixture ApiUrl when another V2 test class's constructor already ran
+ // V2EnvHelper.LoadAndPromote() earlier in the same test process.
+ promotedResolved = ResolveFixtureApiUrlWith(() => V2EnvHelper.LoadAndPromote());
+ }
+ finally
+ {
+ // Factory is write-only; reset to the unconfigured default (same as the unit tests).
+ LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance;
+ }
+
+ _output.WriteLine($"Fixture ApiUrl after shell sources ~/.env_certinext_v2 = {pollutedResolved}");
+ _output.WriteLine($"Fixture ApiUrl after an earlier V2EnvHelper.LoadAndPromote() = {promotedResolved}");
+ }
+
+ private async Task RunListOrdersPage1Async(
+ string label, CERTInextConfig template, string apiUrl, CapturingLoggerFactory capture)
+ {
+ _output.WriteLine($"--- {label}: POST {apiUrl.TrimEnd('/')}/{Constants.Api.GetOrderReportPath} (page 1, pageSize 5) ---");
+ while (capture.Messages.TryDequeue(out _)) { }
+
+ using var client = new CERTInextClient(WithApiUrl(template, apiUrl));
+ int count = 0;
+ try
+ {
+ await foreach (var entry in client.ListOrdersAsync(pageSize: 5))
+ {
+ count++;
+ if (count >= 5) break; // page 1 only
+ }
+ _output.WriteLine($"RESULT: success, {count} order(s) on page 1.");
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"RESULT: {ex.GetType().Name}: {ex.Message}");
+ }
+
+ var lines = capture.Messages.Where(m =>
+ m.Contains(Constants.Api.GetOrderReportPath, StringComparison.Ordinal) ||
+ m.Contains("list orders", StringComparison.Ordinal))
+ .ToList();
+ foreach (string line in lines)
+ _output.WriteLine($"LOG {line}");
+ if (lines.Count == 0)
+ _output.WriteLine("NOTE: no client log lines captured. CERTInextClient's static logger was " +
+ "bound before this probe ran; run this class alone.");
+ }
+
+ ///
+ /// Builds a fresh fixture after and reports its ApiUrl. Since
+ /// the 0017 fix, the shell-overlay case makes the fixture fail fast instead of resolving the
+ /// V2 URL (reported as FAIL-FAST: ...), and
+ /// no longer promotes CERTINEXT_API_URL, so the in-process case resolves the V1 URL.
+ ///
+ private static string ResolveFixtureApiUrlWith(Action mutateEnv)
+ {
+ var snapshot = new System.Collections.Generic.Dictionary(StringComparer.Ordinal);
+ foreach (System.Collections.DictionaryEntry de in Environment.GetEnvironmentVariables())
+ snapshot[(string)de.Key] = (string)de.Value;
+ try
+ {
+ mutateEnv();
+ return new IntegrationTestFixture().Config?.ApiUrl ?? "(not configured)";
+ }
+ catch (InvalidOperationException ex)
+ {
+ return $"FAIL-FAST: {ex.Message}";
+ }
+ finally
+ {
+ var current = Environment.GetEnvironmentVariables().Keys.Cast().ToList();
+ foreach (string key in current.Where(k => !snapshot.ContainsKey(k)))
+ Environment.SetEnvironmentVariable(key, null);
+ foreach (var kv in snapshot)
+ Environment.SetEnvironmentVariable(kv.Key, kv.Value);
+ }
+ }
+
+ private static CERTInextConfig WithApiUrl(CERTInextConfig c, string apiUrl) => new CERTInextConfig
+ {
+ ApiUrl = apiUrl.TrimEnd('/') + "/",
+ AuthMode = c.AuthMode,
+ ApiKey = c.ApiKey,
+ AccountNumber = c.AccountNumber,
+ GroupNumber = c.GroupNumber,
+ OrganizationNumber = c.OrganizationNumber,
+ RequestorName = c.RequestorName,
+ RequestorEmail = c.RequestorEmail,
+ RequestorIsdCode = c.RequestorIsdCode,
+ RequestorMobileNumber = c.RequestorMobileNumber,
+ SignerPlace = c.SignerPlace,
+ SignerIp = c.SignerIp,
+ DefaultProductCode = c.DefaultProductCode,
+ PageSize = c.PageSize
+ };
+ }
+
+ ///
+ /// Runs on its own, never alongside other
+ /// classes, because it swaps and mutates process env.
+ ///
+ [CollectionDefinition(Name, DisableParallelization = true)]
+ public sealed class ListOrdersErrorBodyProbeCollection
+ {
+ public const string Name = "ListOrdersErrorBodyProbe-NoParallel";
+ }
+}
diff --git a/CERTInext.IntegrationTests/OrganizationBlockV2ProbeTests.cs b/CERTInext.IntegrationTests/OrganizationBlockV2ProbeTests.cs
new file mode 100644
index 0000000..e9cd009
--- /dev/null
+++ b/CERTInext.IntegrationTests/OrganizationBlockV2ProbeTests.cs
@@ -0,0 +1,370 @@
+// 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.
+//
+// Triage probe for issue 0028 (V2 OrganizationNumber not sent). Three tests:
+//
+// 1. Catalog_V2_ListsOrganizationVettedEntitlements — read-only, always safe to run
+// whenever V2 creds are configured. Lists the live catalog/products response and
+// flags anything that looks like an OV/EV entitlement, so we know BEFORE attempting
+// any order-placement probe whether one is even possible on this sandbox account.
+//
+// 2. PlaceOvOrder_WithoutOrganizationBlock_ObservesCaResponse — places one real V2 OV
+// order with no `organization` block (the pre-fix code path) and reports whether
+// CERTInext rejects it (400/422) or silently accepts it. This is opt-in behind
+// CERTINEXT_PROBE_ORG_MISSING=1 AND an explicit CERTINEXT_OV_PRODUCT_CODE — neither
+// is set by default in ~/.env_certinext or ~/.env_certinext_v2, so this test skips
+// unless an operator deliberately configures both after confirming an OV/EV product
+// is entitled and understanding a real order (and its cost) may result.
+//
+// 3. PlaceOvOrder_WithOrganizationBlock_ExpectsAcceptance — places one real V2 OV order
+// WITH the fix's `organization` block populated (organizationNumber + preVetted=true)
+// and asserts CERTInext accepts it (no 422), then best-effort cancels it. This is the
+// live acceptance check for the fix itself. Opt-in behind CERTINEXT_PROBE_ORG_FIX=1,
+// CERTINEXT_OV_PRODUCT_CODE, and a configured CERTINEXT_ORG_NUMBER (~/.env_certinext) —
+// none set by default. NOT executed by the agent that authored this fix; a human must
+// deliberately opt in and run it, since it places a real, potentially cost-bearing
+// order (same standard already applied to test #2 and to issues/f3-v2-multi-san-
+// limitation.md's UCC probe).
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using RestSharp;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ public class OrganizationBlockV2ProbeTests : IClassFixture
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly bool _v2Enabled;
+
+ public OrganizationBlockV2ProbeTests(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");
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+ }
+
+ private CERTInextClient BuildV2Client()
+ {
+ return new CERTInextClient(new CERTInextConfig
+ {
+ 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
+ });
+ }
+
+ ///
+ /// Read-only. Lists the live V2 catalog and reports every product whose name or
+ /// productType text suggests OV/EV entitlement. No order is placed. Safe to run
+ /// any time V2 creds are configured.
+ ///
+ [SkippableFact]
+ public async Task Catalog_V2_ListsOrganizationVettedEntitlements()
+ {
+ 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();
+
+ _output.WriteLine($"=== V2 catalog: {products.Count} product(s) ===");
+ foreach (var p in products)
+ {
+ _output.WriteLine(
+ $"ProductCode={p.ProductCode,-8} ProductTypeId={p.ProductTypeId,-6} " +
+ $"ProductType={p.ProductType,-30} ProductName={p.ProductName} Active={p.Active}");
+ }
+
+ var ovEvCandidates = products
+ .Where(p =>
+ (p.ProductName ?? "").IndexOf("OV", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ (p.ProductName ?? "").IndexOf("EV", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ (p.ProductName ?? "").IndexOf("Organization", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ (p.ProductName ?? "").IndexOf("Extended Validation", StringComparison.OrdinalIgnoreCase) >= 0)
+ .ToList();
+
+ _output.WriteLine("");
+ _output.WriteLine(ovEvCandidates.Count > 0
+ ? $"=== {ovEvCandidates.Count} OV/EV-looking product(s) found — a live order-placement probe (issue 0028) is feasible: ==="
+ : "=== No OV/EV-looking product found in this account's catalog — issue 0028's order-placement probe is NOT feasible on this sandbox account. ===");
+ foreach (var p in ovEvCandidates)
+ _output.WriteLine($" ProductCode={p.ProductCode} ProductName={p.ProductName}");
+ }
+
+ ///
+ /// Places ONE real V2 OV order with no organization block — i.e. exercises the
+ /// exact code path issue 0028 flags as broken/degraded — and reports whether CERTInext
+ /// rejects it (400/422, with body) or silently accepts it. Best-effort cancel afterward
+ /// via a raw DELETE/cancel call (the plugin has no V2 CancelOrderAsync client method to
+ /// reuse) so the order does not linger if the CA does accept it.
+ ///
+ /// Opt-in: requires BOTH CERTINEXT_PROBE_ORG_MISSING=1 and CERTINEXT_OV_PRODUCT_CODE to
+ /// be set. Neither exists in ~/.env_certinext or ~/.env_certinext_v2 by default — an
+ /// operator must deliberately add both after confirming (via
+ /// Catalog_V2_ListsOrganizationVettedEntitlements above) that an OV/EV product is
+ /// actually entitled on the target account.
+ ///
+ [SkippableFact]
+ public async Task PlaceOvOrder_WithoutOrganizationBlock_ObservesCaResponse()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_ORG_MISSING");
+ string ovProductCode = Environment.GetEnvironmentVariable("CERTINEXT_OV_PRODUCT_CODE");
+
+ Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1",
+ "CERTINEXT_PROBE_ORG_MISSING=1 not set — this probe places a real, potentially " +
+ "cost-bearing OV order and is opt-in only. Skipping.");
+ Skip.If(string.IsNullOrWhiteSpace(ovProductCode),
+ "CERTINEXT_OV_PRODUCT_CODE not set — no confirmed-entitled OV/EV product code " +
+ "was supplied. Run Catalog_V2_ListsOrganizationVettedEntitlements first. Skipping.");
+
+ using var client = BuildV2Client();
+
+ string domain = $"probe-0028-{DateTime.UtcNow:yyyyMMddHHmmss}.example.com";
+ var orderReq = new V2CreateSslOrderRequest
+ {
+ ProductVariant = "ov",
+ EmailNotifications = "all",
+ Requestor = new V2Requestor
+ {
+ Name = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ Email = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com",
+ Phone = "0000000000",
+ Designation = "IT Administrator"
+ },
+ Certificate = new V2CertificateParams
+ {
+ Domain = domain,
+ AutoSecureWww = false
+ },
+ Subscription = new V2SubscriptionParams
+ {
+ ValidityYears = 1,
+ AutoRenew = false,
+ RenewBeforeDays = 30
+ },
+ Agreement = new V2AgreementParams
+ {
+ SignerName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ SignerIp = "127.0.0.1",
+ SignerPlace = "Gateway Lab",
+ Accepted = true
+ },
+ Remarks = "Issue 0028 triage probe — no organization block sent on purpose."
+ };
+ // Deliberately NOT setting any organization/organizationNumber/preVetted field —
+ // this is the exact gap issue 0028 describes.
+
+ string outcome;
+ string orderId = null;
+ try
+ {
+ var resp = await client.PlaceOrderV2Async(Constants.ApiV2.FamilySsl, ovProductCode, orderReq);
+ orderId = resp.OrderId;
+ outcome = $"ACCEPTED — OrderId={resp.OrderId}, Status={resp.Status}. " +
+ "CERTInext did NOT reject the OV order for missing organization data.";
+ }
+ catch (Exception ex)
+ {
+ outcome = $"REJECTED — {ex.GetType().Name}: {ex.Message}";
+ }
+
+ _output.WriteLine("=== Issue 0028 live probe: OV order with no organization block ===");
+ _output.WriteLine($"Domain={domain} ProductCode={ovProductCode}");
+ _output.WriteLine(outcome);
+
+ if (orderId != null)
+ {
+ _output.WriteLine($"Attempting best-effort cleanup: cancelling order {orderId}...");
+ try
+ {
+ await CancelSslOrderRawAsync(orderId,
+ "Issue 0028 triage probe — cleaning up after observing CA response.");
+ _output.WriteLine($"Cleanup: order {orderId} cancel request returned success.");
+ }
+ catch (Exception cleanupEx)
+ {
+ _output.WriteLine(
+ $"Cleanup FAILED for order {orderId}: {cleanupEx.Message}. " +
+ "Cancel it by hand in the CERTInext portal if it should not remain pending.");
+ }
+ }
+ }
+
+ ///
+ /// Places ONE real V2 OV order WITH the fix's organization block populated
+ /// (organizationNumber from CERTINEXT_ORG_NUMBER, preVetted=true)
+ /// and asserts CERTInext accepts it — i.e. does NOT return the HTTP 422 EMS-1180
+ /// "Organization Name cannot be empty" that test #2 above observes for the pre-fix,
+ /// no-organization-block request. Best-effort cancels the order afterward via the same
+ /// raw cancel helper.
+ ///
+ /// Opt-in: requires CERTINEXT_PROBE_ORG_FIX=1, CERTINEXT_OV_PRODUCT_CODE, AND a
+ /// configured CERTINEXT_ORG_NUMBER (already present in most `~/.env_certinext` files
+ /// per this repo's other live tests, e.g. DcvLifecycleTests). None of these are armed
+ /// by default. This test was authored as part of the issue 0028 fix but deliberately
+ /// NOT executed by the authoring agent — placing a real order has cost/state
+ /// implications an operator should explicitly authorize, the same standard already
+ /// applied to .
+ ///
+ [SkippableFact]
+ public async Task PlaceOvOrder_WithOrganizationBlock_ExpectsAcceptance()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_ORG_FIX");
+ string ovProductCode = Environment.GetEnvironmentVariable("CERTINEXT_OV_PRODUCT_CODE");
+ string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null;
+
+ Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1",
+ "CERTINEXT_PROBE_ORG_FIX=1 not set — this probe places a real, potentially " +
+ "cost-bearing OV order and is opt-in only. Skipping.");
+ Skip.If(string.IsNullOrWhiteSpace(ovProductCode),
+ "CERTINEXT_OV_PRODUCT_CODE not set — no confirmed-entitled OV/EV product code " +
+ "was supplied. Run Catalog_V2_ListsOrganizationVettedEntitlements first. Skipping.");
+ Skip.If(string.IsNullOrWhiteSpace(organizationNumber),
+ "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — no pre-vetted organization " +
+ "number is available to populate the organization block. Skipping.");
+
+ using var client = BuildV2Client();
+
+ string domain = $"probe-0028-fix-{DateTime.UtcNow:yyyyMMddHHmmss}.example.com";
+ var orderReq = new V2CreateSslOrderRequest
+ {
+ ProductVariant = "ov",
+ EmailNotifications = "all",
+ Requestor = new V2Requestor
+ {
+ Name = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ Email = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com",
+ Phone = "0000000000",
+ Designation = "IT Administrator"
+ },
+ Organization = new V2OrganizationParams
+ {
+ OrganizationNumber = organizationNumber,
+ PreVetted = true
+ },
+ Certificate = new V2CertificateParams
+ {
+ Domain = domain,
+ AutoSecureWww = false
+ },
+ Subscription = new V2SubscriptionParams
+ {
+ ValidityYears = 1,
+ AutoRenew = false,
+ RenewBeforeDays = 30
+ },
+ Agreement = new V2AgreementParams
+ {
+ SignerName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ SignerIp = "127.0.0.1",
+ SignerPlace = "Gateway Lab",
+ Accepted = true
+ },
+ Remarks = "Issue 0028 fix-verification probe — organization block populated."
+ };
+
+ string orderId = null;
+ try
+ {
+ var resp = await client.PlaceOrderV2Async(Constants.ApiV2.FamilySsl, ovProductCode, orderReq);
+ orderId = resp.OrderId;
+
+ _output.WriteLine("=== Issue 0028 fix-verification probe: OV order WITH organization block ===");
+ _output.WriteLine($"Domain={domain} ProductCode={ovProductCode} OrganizationNumber={organizationNumber}");
+ _output.WriteLine($"ACCEPTED — OrderId={resp.OrderId}, Status={resp.Status}.");
+
+ resp.OrderId.Should().NotBeNullOrWhiteSpace();
+ resp.Status.Should().NotBe("rejected");
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine("=== Issue 0028 fix-verification probe: OV order WITH organization block ===");
+ _output.WriteLine($"Domain={domain} ProductCode={ovProductCode} OrganizationNumber={organizationNumber}");
+ _output.WriteLine($"REJECTED — {ex.GetType().Name}: {ex.Message}");
+ throw;
+ }
+ finally
+ {
+ if (orderId != null)
+ {
+ _output.WriteLine($"Attempting best-effort cleanup: cancelling order {orderId}...");
+ try
+ {
+ await CancelSslOrderRawAsync(orderId,
+ "Issue 0028 fix-verification probe — cleaning up after confirming acceptance.");
+ _output.WriteLine($"Cleanup: order {orderId} cancel request returned success.");
+ }
+ catch (Exception cleanupEx)
+ {
+ _output.WriteLine(
+ $"Cleanup FAILED for order {orderId}: {cleanupEx.Message}. " +
+ "Cancel it by hand in the CERTInext portal if it should not remain pending.");
+ }
+ }
+ }
+ }
+
+ ///
+ /// Standalone cancel call for triage cleanup only — the plugin's
+ /// has no V2 CancelOrderAsync method to reuse (issue
+ /// 0028's probe is the only caller), so this authenticates and calls
+ /// POST /api/certinext/v2/ssl-certificates/{orderId}/cancel directly per the spec
+ /// (docs/reference/specs/CERTInext API v2.postman_collection (1).json,
+ /// "SSL/TLS Certificates/Cancel Order"). Not a product code path.
+ ///
+ /// Delegates to (issue 0058) —
+ /// this file's own token-fetch-plus-cancel body used to be inlined here; extracted so
+ /// this file, IdempotencyKeyV2ProbeTests, and V2GapProbeTests share one
+ /// implementation instead of three near-duplicates. Behavior is unchanged: same
+ /// endpoint, same headers, same throw-on-failure semantics.
+ ///
+ private Task CancelSslOrderRawAsync(string orderId, string reason) =>
+ V2RawProbeHelpers.CancelSslOrderRawAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret, orderId, reason);
+
+ }
+}
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..028c6b3
--- /dev/null
+++ b/CERTInext.IntegrationTests/PrivatePkiV2LiveTests.cs
@@ -0,0 +1,481 @@
+// 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 (issue 0033, commit
+ /// 88845bf) 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,
+ 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 (CSR submit, track, download) throws before an EnrollmentResult exists.
+ 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("=== Issue 0033 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 (issue 0039).
+ 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 (issue 0039 —
+ /// plugin.Revoke refuses non-issued orders). Manual-cleanup instructions are printed
+ /// only when that action fails. If Enroll's own Submit CSR failure path already cancelled the
+ /// order, this cancel reports the CA's 422 "already terminal" answer. 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 (issue 0033).");
+ _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..51dd85b 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");
}
}
+
+ ///
+ /// V1 parity test for issue 0025 — 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 the V2 branch this issue adds.
+ ///
+ [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..be46395
--- /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 (gap G5, issues/0020).
+ ///
+ 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/RevocationShapeV2ProbeTests.cs b/CERTInext.IntegrationTests/RevocationShapeV2ProbeTests.cs
new file mode 100644
index 0000000..178e415
--- /dev/null
+++ b/CERTInext.IntegrationTests/RevocationShapeV2ProbeTests.cs
@@ -0,0 +1,316 @@
+// 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.
+//
+// Read-only investigation probe for issue 0034 (V2 revocation date/reason DTO shape
+// mismatch). Static analysis (issues/0034-v2-revocation-date-reason-dto-mismatch.md)
+// already confirmed the CERTInext V2 spec documents a nested
+// `revocation: {status, reason, processedAt}` object on Track Order, while
+// V2OrderStatusResponse (CertificateResponseV2.cs:130-136) instead models flat top-level
+// `revocationReason`/`revocationDate` properties. This probe settles the exact live wire
+// shape before that DTO is fixed: it searches the sandbox account's recent V2 order
+// history for an order already in a revoked state (issues/0026's live revoke work left
+// several behind on 2026-09-24), then calls the raw, unparsed Track Order response for
+// that order and reports the revocation-related JSON verbatim.
+//
+// Deliberately read-only: no order is placed, no order is revoked. Only GET calls are
+// made — ListOrdersV2Async's report endpoint to search, and a raw GET against each of
+// the three V2 product-family Track Order paths in turn to locate the revoked order's
+// family. If no already-revoked order is found within the search window, the test
+// reports that and passes without asserting a shape — creating one on demand would be a
+// mutating, cost-bearing action requiring separate, explicit sign-off (out of scope here).
+//
+// Opt-in: requires CERTINEXT_PROBE_REVOCATION_SHAPE=1. Not armed by default in
+// ~/.env_certinext or ~/.env_certinext_v2 — an operator must deliberately opt in, per
+// this repo's convention for live-API probes (mirrors OrganizationBlockV2ProbeTests /
+// IdempotencyKeyV2ProbeTests).
+
+using System;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using RestSharp;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ public class RevocationShapeV2ProbeTests : IClassFixture
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly bool _v2Enabled;
+
+ ///
+ /// V2 product-family URL path segments to probe, in the same order as
+ /// CERTInextClient.ResolveV2OrderFamilyAsync (SSL, then Private PKI, then
+ /// Signature) — an order id is only ever valid within exactly one family.
+ ///
+ private static readonly string[] Families =
+ {
+ Constants.ApiV2.FamilySsl,
+ Constants.ApiV2.FamilyPrivatePki,
+ Constants.ApiV2.FamilySignature
+ };
+
+ public RevocationShapeV2ProbeTests(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");
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+ }
+
+ private CERTInextClient BuildV2Client()
+ {
+ return new CERTInextClient(new CERTInextConfig
+ {
+ 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
+ });
+ }
+
+ ///
+ /// Read-only. Searches recent V2 order-report history (last 90 days) for an order
+ /// whose orderStatus/certificateStatus display string indicates it has been
+ /// revoked (mirrors CERTInextCAPlugin.TryMapV2ReportDisplayStatus's
+ /// "revoked" / "certificate revoked" vocabulary), then calls the raw Track Order
+ /// endpoint for that order and reports the unparsed JSON response body —
+ /// specifically the revocation-related portion — so the exact wire shape (nested
+ /// vs flat, field names, casing, timestamp format) can be confirmed before issue
+ /// 0034's DTO fix is written.
+ ///
+ /// Bounded to a 90-day lookback and a capped number of scanned rows so this never
+ /// walks full account history (this repo's convention for shared-account
+ /// contention — see the "Only run 1 fullSync at a time" memory note). issues/0026's
+ /// live revoke work (2026-09-24) left multiple V2 orders in a revoked state, so a
+ /// 90-day window from today should already cover them without a full-history scan.
+ ///
+ /// If no revoked order is found within that window, the test reports so and passes
+ /// without asserting a shape — placing/revoking a fresh order to force one is a
+ /// mutating, cost-bearing action out of scope for this probe.
+ ///
+ [SkippableFact]
+ public async Task RevocationShape_V2_FindsRevokedOrderAndCapturesRawTrackOrderJson()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ string probeFlag = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_REVOCATION_SHAPE");
+ Skip.If(string.IsNullOrWhiteSpace(probeFlag) || probeFlag != "1",
+ "CERTINEXT_PROBE_REVOCATION_SHAPE=1 not set — this probe is opt-in only per this " +
+ "repo's live-API-probe convention. Skipping.");
+
+ using var client = BuildV2Client();
+
+ string from = DateTime.UtcNow.AddDays(-90).ToString("yyyy-MM-dd");
+
+ _output.WriteLine("=== Issue 0034 live probe: V2 revocation date/reason wire shape ===");
+ _output.WriteLine($"Searching V2 order report from={from} for an already-revoked order...");
+
+ string revokedOrderId = null;
+ string matchedOn = null;
+ int scanned = 0;
+ const int maxScanned = 1000; // bound the scan regardless of account size
+
+ await foreach (var row in client.ListOrdersV2Async(from, null, 100, CancellationToken.None))
+ {
+ scanned++;
+
+ if (LooksRevoked(row.CertificateStatus))
+ {
+ revokedOrderId = row.OrderNumber;
+ matchedOn = $"certificateStatus='{row.CertificateStatus}'";
+ break;
+ }
+ if (LooksRevoked(row.OrderStatus))
+ {
+ revokedOrderId = row.OrderNumber;
+ matchedOn = $"orderStatus='{row.OrderStatus}'";
+ break;
+ }
+ if (scanned >= maxScanned)
+ break;
+ }
+
+ _output.WriteLine($"Scanned {scanned} order report row(s).");
+
+ if (revokedOrderId == null)
+ {
+ _output.WriteLine(
+ "No already-revoked V2 order found within the search window. Not creating one — " +
+ "that would be a mutating, cost-bearing action requiring separate, explicit " +
+ "sign-off. Reporting NONE FOUND for issue 0034; re-run with a wider window (or " +
+ "after a live revoke exists) if this needs to be re-verified.");
+ return;
+ }
+
+ _output.WriteLine($"Found revoked order candidate: orderId={revokedOrderId} ({matchedOn})");
+
+ var raw = await TrackOrderRawAsync(revokedOrderId);
+
+ raw.Should().NotBeNull("a revoked order located via the report endpoint must resolve to some family");
+
+ _output.WriteLine($"Family={raw.Family} HTTP={raw.StatusCode}");
+ _output.WriteLine("Raw Track Order response body:");
+ _output.WriteLine(raw.Body);
+
+ // Best-effort slice of just the revocation-related keys, for a quick eyeball of
+ // nested-vs-flat shape without re-reading the whole body by hand — the full body
+ // is logged above regardless.
+ string revocationSlice = ExtractRevocationSlice(raw.Body);
+ _output.WriteLine("");
+ _output.WriteLine(revocationSlice != null
+ ? $"Revocation-related JSON slice: {revocationSlice}"
+ : "No top-level 'revocation' object or flat 'revocationReason'/'revocationDate' " +
+ "keys found in the raw body — see the full body above.");
+
+ raw.Body.Should().NotBeNullOrWhiteSpace(
+ "the raw Track Order response for a revoked order must have a non-empty body");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Private helpers
+ // ---------------------------------------------------------------------------
+
+ private static bool LooksRevoked(string displayStatus)
+ {
+ if (string.IsNullOrWhiteSpace(displayStatus))
+ return false;
+ return displayStatus.Trim().ToLowerInvariant().Contains("revok");
+ }
+
+ private sealed class RawTrackOrderResult
+ {
+ public string Family { get; set; }
+ public int StatusCode { get; set; }
+ public string Body { get; set; }
+ }
+
+ ///
+ /// Raw HTTP GET against each V2 product-family Track Order path in turn, stopping
+ /// at the first non-404 response — mirrors
+ /// CERTInextClient.ResolveV2OrderFamilyAsync's try-each-family algorithm, but
+ /// deliberately bypasses CERTInextClient.TrackOrderV2Async's typed
+ /// deserialization so the exact unparsed response body can be captured (same raw-
+ /// HTTP idiom as OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync /
+ /// IdempotencyKeyV2ProbeTests.PlaceOrderRawAsync). Returns null if the order
+ /// is not found in any of the three families.
+ ///
+ private async Task TrackOrderRawAsync(string orderId)
+ {
+ string accessToken = await GetV2AccessTokenAsync();
+
+ using var apiClient = new RestClient(_v2ApiUrl.TrimEnd('/'));
+ foreach (string family in Families)
+ {
+ var req = new RestRequest($"/api/certinext/v2/{family}/{orderId}", Method.Get);
+ req.AddHeader("Authorization", $"Bearer {accessToken}");
+ req.AddHeader("Accept", "application/json");
+ var resp = await apiClient.ExecuteAsync(req);
+
+ if ((int)resp.StatusCode == 404)
+ continue;
+
+ return new RawTrackOrderResult
+ {
+ Family = family,
+ StatusCode = (int)resp.StatusCode,
+ Body = resp.Content
+ };
+ }
+ return null;
+ }
+
+ ///
+ /// Same OAuth2 client_credentials token-fetch idiom as
+ /// OrganizationBlockV2ProbeTests.CancelSslOrderRawAsync /
+ /// IdempotencyKeyV2ProbeTests.GetV2AccessTokenAsync.
+ ///
+ private async Task GetV2AccessTokenAsync()
+ {
+ string tokenUrl = _v2ApiUrl.TrimEnd('/') + "/oauth/token";
+ using var tokenClient = new RestClient(tokenUrl);
+ var tokenReq = new RestRequest(string.Empty, Method.Post);
+ tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded");
+ tokenReq.AddParameter("grant_type", "client_credentials");
+ tokenReq.AddParameter("client_id", _v2ClientId);
+ tokenReq.AddParameter("client_secret", _v2ClientSecret);
+ var tokenResp = await tokenClient.ExecuteAsync(tokenReq);
+ if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content))
+ throw new Exception($"Token request failed: {(int)tokenResp.StatusCode}");
+
+ using var tokenDoc = JsonDocument.Parse(tokenResp.Content);
+ return tokenDoc.RootElement.GetProperty("access_token").GetString();
+ }
+
+ ///
+ /// Best-effort extraction of just the revocation-related portion of a raw Track
+ /// Order JSON body, checking both the spec-documented nested revocation
+ /// object and the DTO's current (likely-wrong) flat revocationReason/
+ /// revocationDate keys, whichever is present. Returns null if neither is
+ /// found — the full body is still logged by the caller either way.
+ ///
+ private static string ExtractRevocationSlice(string body)
+ {
+ if (string.IsNullOrWhiteSpace(body))
+ return null;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ var root = doc.RootElement;
+
+ if (root.TryGetProperty("revocation", out var nested))
+ return nested.GetRawText();
+
+ bool hasFlatReason = root.TryGetProperty("revocationReason", out var flatReason);
+ bool hasFlatDate = root.TryGetProperty("revocationDate", out var flatDate);
+ if (hasFlatReason || hasFlatDate)
+ {
+ return "{" +
+ (hasFlatReason ? $"\"revocationReason\":{flatReason.GetRawText()}" : "") +
+ (hasFlatReason && hasFlatDate ? "," : "") +
+ (hasFlatDate ? $"\"revocationDate\":{flatDate.GetRawText()}" : "") +
+ "}";
+ }
+
+ return null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/SanSubmissionProbeTests.cs b/CERTInext.IntegrationTests/SanSubmissionProbeTests.cs
new file mode 100644
index 0000000..23681d1
--- /dev/null
+++ b/CERTInext.IntegrationTests/SanSubmissionProbeTests.cs
@@ -0,0 +1,391 @@
+// 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
+//
+// Probe: establish empirically how CERTInext treats the SAN/domain fields on
+// GenerateOrderSSL. Written because the plugin's original behaviour encoded three
+// assumptions that were never measured:
+//
+// A. certificateInformation.additionalDomains is the field that puts extra names on
+// the certificate (so a UCC order that omits it yields a CN-only certificate).
+// B. additionalDomains accepts DNS names only, so a non-DNS SAN is rejected by the CA.
+// C. Repeating the primary domainName inside additionalDomains is harmful (duplicate
+// domain / consumes the UCC allowance), so it should be de-duplicated.
+//
+// None of these had a test. This probe answers them against the live API by placing one
+// order per variant and reading back the domain set CERTInext actually registered, via
+// TrackOrder's domainVerification block (keys are the domains on the order). That is
+// ground truth for "which names did the CA put on this order" without waiting for DCV
+// and issuance to complete.
+//
+// ---------------------------------------------------------------------------------------
+// MEASURED RESULTS — SANDBOX ONLY: sandbox-us, account 4951571271, product 844 (OV SSL UCC),
+// 2026-08-12. (Product 840 / DV UCC is not enabled on that account: "Invalid Product Code".)
+//
+// These are sandbox observations. Re-run against production before treating B or C as
+// settled there — point ~/.env_certinext at the production account and set
+// CERTINEXT_SAN_PROBE_PRODUCTS to a UCC code that account can actually order (product
+// numbering is per-account; the codes in Constants.Products are defaults, not guarantees).
+// Finding A and the CSR-SAN result below are separately corroborated by production: the
+// customer report that prompted this work was a production UCC order whose CSR carried the
+// SANs and whose issued certificate held only the CN.
+//
+// A. CONFIRMED. additionalDomains is what puts extra names on the order. Submitting
+// CN + extra1. registered BOTH domains.
+//
+// B. DISPROVEN. Non-DNS values are NOT rejected. An email address, an IPv4 literal and
+// an https:// URI were each accepted at placement AND registered as order domains
+// ("san-probe@example.com", "192.0.2.10", "https://san-probe.example.com/x" all came
+// back as domainVerification keys). So the CA does not validate the field's contents
+// at order time; such an order is created and then cannot pass DCV, rather than
+// failing cleanly up front.
+//
+// C. PARTLY DISPROVEN. Repeating the primary domainName inside additionalDomains is
+// accepted and CERTInext collapses it itself — the order came back with the CN
+// registered once. De-duplicating on our side is therefore belt-and-braces, not a
+// correctness requirement.
+//
+// Root cause of the customer-reported "UCC SANs not populating": CERTInext IGNORES the
+// subjectAltName extension in the CSR. A CSR carrying CN + extra2., submitted with
+// additionalDomains omitted, registered ONLY the CN. SANs must be sent in
+// additionalDomains or they do not reach the certificate, no matter what the CSR says.
+// ---------------------------------------------------------------------------------------
+//
+// Opt-in: this places real orders against whatever account ~/.env_certinext points at.
+//
+// set -a; . ~/.env_certinext; set +a
+// export CERTINEXT_SAN_PROBE=1
+// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \
+// --filter "FullyQualifiedName~SanSubmissionProbeTests" \
+// --logger "console;verbosity=detailed" > /tmp/sanprobe.log 2>&1
+//
+// (xUnit buffers ITestOutputHelper output until the test ends — read the report at the tail.)
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+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 SanSubmissionProbeTests : IClassFixture
+ {
+ private const string OptInFlag = "CERTINEXT_SAN_PROBE";
+
+ ///
+ /// Comma-separated product codes to probe. Defaults to the Multi-Domain (UCC) codes,
+ /// because additional domains are only meaningful on a UCC product — a single-domain
+ /// product (e.g. 842 = OV SSL) registers the CN and nothing else no matter what
+ /// additionalDomains contains, which makes it useless as a probe target.
+ ///
+ private const string ProductCodesFlag = "CERTINEXT_SAN_PROBE_PRODUCTS";
+ private const string DefaultProductCodes = "840,844";
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _out;
+
+ public SanSubmissionProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _out = output;
+ }
+
+ // -------------------------------------------------------------------------
+ // CSR generation (BouncyCastle — project crypto policy)
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Generates a PKCS#10 CSR for , optionally carrying a
+ /// subjectAltName extension (via the PKCS#9 extensionRequest attribute) holding
+ /// . The SAN-bearing form is what lets this probe ask
+ /// whether CERTInext reads SANs out of the CSR at all.
+ ///
+ private static string GenerateCsrPem(string cn, params string[] dnsSans)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair();
+
+ Asn1Set attributes = null;
+ if (dnsSans != null && dnsSans.Length > 0)
+ {
+ var names = new GeneralNames(
+ dnsSans.Select(d => new GeneralName(GeneralName.DnsName, d)).ToArray());
+
+ var extGen = new X509ExtensionsGenerator();
+ extGen.AddExtension(X509Extensions.SubjectAlternativeName, critical: false, extValue: names);
+
+ attributes = new DerSet(new AttributePkcs(
+ PkcsObjectIdentifiers.Pkcs9AtExtensionRequest,
+ new DerSet(extGen.Generate())));
+ }
+
+ var csr = new Pkcs10CertificationRequest(
+ "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attributes, kp.Private);
+
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ // -------------------------------------------------------------------------
+ // One probe variant
+ // -------------------------------------------------------------------------
+
+ private sealed class ProbeOutcome
+ {
+ public string ProductCode;
+ public string Label;
+ public bool Accepted;
+ public string OrderNumber;
+ public string Detail;
+ /// Domains CERTInext registered on the order, per TrackOrder.
+ public List RegisteredDomains = new List();
+ /// Names we asked CERTInext to put on the order, for comparison.
+ public List RequestedDomains = new List();
+
+ ///
+ /// True when the rejection was "Invalid Product Code" — the product simply is not
+ /// enabled on this account, which is not a data point about SAN handling.
+ ///
+ public bool ProductUnavailable;
+ }
+
+ ///
+ /// Places one order and reads back the domain set CERTInext registered for it.
+ /// drives certificateInformation.additionalDomains;
+ /// drives the SAN extension inside the CSR. They are
+ /// varied independently on purpose — that separation is the whole point of the probe.
+ ///
+ private async Task ProbeAsync(
+ string productCode,
+ string label,
+ Func> sansFactory,
+ string[] csrSans)
+ {
+ var outcome = new ProbeOutcome { ProductCode = productCode, Label = label };
+
+ var client = new CERTInextClient(_fixture.Config);
+ string cn = $"sanprobe-{DateTime.UtcNow:yyyyMMddHHmmssfff}.{SafeLabel(label)}.example.com";
+
+ var sans = sansFactory?.Invoke(cn);
+ outcome.RequestedDomains = sans == null
+ ? new List()
+ : sans.Select(s => $"{s.Type}:{s.Value}").ToList();
+
+ var req = new EnrollCertificateRequest
+ {
+ Csr = GenerateCsrPem(cn, csrSans == null ? null : csrSans.Select(s => Format(s, cn)).ToArray()),
+ Subject = $"CN={cn}",
+ Sans = sans,
+ ProfileId = productCode,
+ RequesterName = _fixture.RequestorName,
+ RequesterEmail = _fixture.RequestorEmail
+ };
+
+ try
+ {
+ var resp = await client.EnrollCertificateAsync(req);
+ outcome.Accepted = true;
+ outcome.OrderNumber = resp?.Id;
+ outcome.Detail = $"OrderNumber={resp?.Id} Status={resp?.Status}";
+ }
+ catch (Exception ex)
+ {
+ outcome.Accepted = false;
+ outcome.Detail = ex.Message;
+ outcome.ProductUnavailable =
+ ex.Message.IndexOf("Invalid Product Code", StringComparison.OrdinalIgnoreCase) >= 0;
+ return outcome;
+ }
+
+ // Read back which domains the CA actually put on the order.
+ try
+ {
+ var track = await client.TrackOrderAsync(outcome.OrderNumber);
+ var entries = track.OrderDetails?.DomainVerification?.GetDomainEntries();
+ if (entries != null)
+ outcome.RegisteredDomains = entries.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList();
+ }
+ catch (Exception ex)
+ {
+ outcome.Detail += $" | TrackOrder failed: {ex.Message}";
+ }
+
+ return outcome;
+ }
+
+ /// Substitutes the generated CN into a variant's placeholder template.
+ private static string Format(string template, string cn) => template.Replace("{cn}", cn);
+
+ private static string SafeLabel(string label) =>
+ new string(label.ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray())
+ .Trim('-');
+
+ // -------------------------------------------------------------------------
+ // The probe
+ // -------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Probe_SanSubmissionBehaviour()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+ Skip.IfNot(
+ Environment.GetEnvironmentVariable(OptInFlag) == "1",
+ $"Set {OptInFlag}=1 to run this probe — it places real orders on the configured account.");
+
+ var variants = new List<(string Label, Func> Sans, string[] CsrSans)>
+ {
+ // 1. Assumption A, positive control: additionalDomains carries an extra DNS
+ // name. If the extra name comes back registered, additionalDomains works.
+ ("dns-extra-via-additionalDomains",
+ cn => new List
+ {
+ new SanEntry { Type = "dns", Value = cn },
+ new SanEntry { Type = "dns", Value = $"extra1.{cn}" }
+ },
+ new[] { "{cn}", "extra1.{cn}" }),
+
+ // 2. Assumption A, the actual bug: CSR carries both names, additionalDomains
+ // is omitted entirely. This is what v1.0.1 sent for every UCC enrollment.
+ // If only the CN comes back registered, the CA does NOT read CSR SANs and
+ // the diagnosis is confirmed.
+ ("csr-sans-only-no-additionalDomains",
+ _ => null,
+ new[] { "{cn}", "extra2.{cn}" }),
+
+ // 3. Assumption C: primary domainName repeated inside additionalDomains.
+ // Does the CA reject it, or silently collapse it?
+ ("cn-duplicated-in-additionalDomains",
+ cn => new List
+ {
+ new SanEntry { Type = "dns", Value = cn },
+ new SanEntry { Type = "dns", Value = cn }
+ },
+ new[] { "{cn}" }),
+
+ // 4-6. Assumption B: non-DNS values in additionalDomains. Rejected, ignored,
+ // or accepted? Each is submitted alongside a valid DNS name so a rejection
+ // is attributable to the non-DNS value rather than an empty domain set.
+ ("nondns-email-in-additionalDomains",
+ cn => new List
+ {
+ new SanEntry { Type = "dns", Value = cn },
+ new SanEntry { Type = "email", Value = "san-probe@example.com" }
+ },
+ new[] { "{cn}" }),
+
+ ("nondns-ip-in-additionalDomains",
+ cn => new List
+ {
+ new SanEntry { Type = "dns", Value = cn },
+ new SanEntry { Type = "ip", Value = "192.0.2.10" }
+ },
+ new[] { "{cn}" }),
+
+ ("nondns-uri-in-additionalDomains",
+ cn => new List
+ {
+ new SanEntry { Type = "dns", Value = cn },
+ new SanEntry { Type = "uri", Value = "https://san-probe.example.com/x" }
+ },
+ new[] { "{cn}" }),
+ };
+
+ string[] productCodes =
+ (Environment.GetEnvironmentVariable(ProductCodesFlag) ?? DefaultProductCodes)
+ .Split(',', StringSplitOptions.RemoveEmptyEntries)
+ .Select(p => p.Trim())
+ .Where(p => p.Length > 0)
+ .ToArray();
+
+ var results = new List();
+ foreach (string productCode in productCodes)
+ {
+ bool unavailable = false;
+ foreach (var (label, sans, csrSans) in variants)
+ {
+ var outcome = await ProbeAsync(productCode, label, sans, csrSans);
+ results.Add(outcome);
+
+ // Don't burn five more orders proving the same product code is not
+ // enabled on this account.
+ if (outcome.ProductUnavailable)
+ {
+ unavailable = true;
+ break;
+ }
+
+ // Throttle: the sandbox rate-limits order bursts (~16 orders / 10 s).
+ await Task.Delay(1500);
+ }
+
+ if (unavailable)
+ _out.WriteLine($"(product {productCode} is not enabled on this account — skipped)");
+ }
+
+ _out.WriteLine("=== CERTInext SAN submission probe ===");
+ _out.WriteLine($"ProductCodes probed : {string.Join(", ", productCodes)}");
+ _out.WriteLine($"(fixture default : {_fixture.ProductCode})");
+ _out.WriteLine("");
+
+ foreach (var group in results.GroupBy(r => r.ProductCode))
+ {
+ _out.WriteLine($"--- ProductCode {group.Key} ---");
+ foreach (var r in group)
+ {
+ _out.WriteLine($"[{(r.Accepted ? "ACCEPTED" : "REJECTED")}] {r.Label}");
+ _out.WriteLine($" requested (additionalDomains): {(r.RequestedDomains.Count > 0 ? string.Join(", ", r.RequestedDomains) : "(field omitted)")}");
+ _out.WriteLine($" detail : {r.Detail}");
+ _out.WriteLine($" registeredDomains (TrackOrder): {(r.RegisteredDomains.Count > 0 ? string.Join(", ", r.RegisteredDomains) : "(none reported)")}");
+ _out.WriteLine("");
+ }
+ }
+
+ _out.WriteLine("=== How to read this ===");
+ _out.WriteLine("registeredDomains is TrackOrder's domainVerification key set — the domains");
+ _out.WriteLine("CERTInext put on the order. Compare it against 'requested':");
+ _out.WriteLine("(1) vs (2): if (1) registers the extra name and (2) does not, then");
+ _out.WriteLine(" additionalDomains is required and CSR SANs alone are ignored.");
+ _out.WriteLine("(3) : whether repeating the CN is rejected or collapsed.");
+ _out.WriteLine("(4)-(6) : whether non-DNS values are rejected, ignored, or accepted");
+ _out.WriteLine(" AT PLACEMENT TIME. An order accepted here can still be");
+ _out.WriteLine(" rejected later during validation/approval.");
+
+ // The probe reports; it does not assert a specific CA behaviour, because its purpose
+ // is to discover what that behaviour is. What must hold is that at least one UCC
+ // product was actually exercised — otherwise the run proved nothing and should not
+ // read as a pass.
+ var usable = results
+ .Where(r => !r.ProductUnavailable)
+ .GroupBy(r => r.ProductCode)
+ .ToList();
+
+ Skip.If(
+ usable.Count == 0,
+ "None of the probed product codes are enabled on this account " +
+ $"({string.Join(", ", productCodes)}). Set {ProductCodesFlag} to a Multi-Domain (UCC) " +
+ "code this account can order.");
+
+ foreach (var group in usable)
+ {
+ var control = group.First(r => r.Label == "dns-extra-via-additionalDomains");
+ Assert.True(
+ control.Accepted,
+ $"Positive control failed on product {group.Key} — could not place even a " +
+ $"plain DNS UCC order: {control.Detail}");
+ }
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/TESTING.md b/CERTInext.IntegrationTests/TESTING.md
index b961130..c17dddd 100644
--- a/CERTInext.IntegrationTests/TESTING.md
+++ b/CERTInext.IntegrationTests/TESTING.md
@@ -94,6 +94,10 @@ The file is parsed line by line:
- 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).
+- Exception: the fixture fails fast if the resolved `CERTINEXT_API_URL` lacks `/emSignHub-API`
+ (a V2 base URL leaked in, issue 0017). Source only `~/.env_certinext` into the shell, never
+ `~/.env_certinext_v2`. The V2 test classes read that file from disk themselves and never write
+ V1-shared keys (`CERTINEXT_API_URL`, `CERTINEXT_ACCESS_KEY`, ...) into the process environment.
---
@@ -154,6 +158,7 @@ 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 |
+| `ValidateProductInfo_V1_AcceptsConfiguredProductCode` | (issue 0025) Drives `CERTInextCAPlugin.ValidateProductInfo` (not just the client) in V1 mode with `CERTINEXT_PRODUCT_CODE`; asserts no throw. Skips if not configured or `CERTINEXT_PRODUCT_CODE` unset |
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
diff --git a/CERTInext.IntegrationTests/UccDcvShapeV2ProbeTests.cs b/CERTInext.IntegrationTests/UccDcvShapeV2ProbeTests.cs
new file mode 100644
index 0000000..6374665
--- /dev/null
+++ b/CERTInext.IntegrationTests/UccDcvShapeV2ProbeTests.cs
@@ -0,0 +1,399 @@
+// 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.
+//
+// Read-only investigation probe for issue 0042 (V2 DCV machinery only drives the primary
+// domain on a UCC order — issues/0042-v2-ucc-dcv-only-drives-primary-domain.md). Before
+// generalizing PerformDcvV2IfNeededAsync to loop over a UCC order's full SAN set, this
+// confirms the real wire shape of a live UCC order sitting in pending-dcv:
+//
+// 1. Does Track Order (GET /api/certinext/v2/{family}/{orderId}) surface per-SAN DCV
+// status anywhere (field names/shape), or only ever the single top-level "domain"
+// field that V2OrderStatusResponse currently models (CertificateResponseV2.cs:124)?
+// 2. Does Get DCV Challenges (GET /api/certinext/v2/ssl-certificates/{orderId}/dcv),
+// called once per SAN via its documented optional "domain" query parameter, return a
+// distinct challenge token per domain, or does it 404 / error / silently ignore the
+// query param for a UCC order?
+//
+// Targets a specific already-existing live order rather than placing a new one — no
+// order-create, CSR-submission, or DCV-verify call is made by this probe. Points at
+// order 9295677273 (product 844, DV SSL Multi-Domain UCC; primary
+// ucc-0047-09282059.dcv-test.scrup.org, additionalDomains
+// ucc-0047-09282059-b.dcv-test.scrup.org / ucc-0047-09282059-c.dcv-test.scrup.org) by
+// default via env vars, so this probe can be re-pointed at a different UCC order later
+// without editing code.
+//
+// Raw HTTP only (same idiom as RevocationShapeV2ProbeTests.TrackOrderRawAsync /
+// EmailNotificationsV2ProbeTests' raw-response helpers) — deliberately bypasses
+// CERTInextClient.TrackOrderV2Async / GetDcvV2Async's typed deserialization (the whole
+// point is to see the exact, unmodified response body, including any fields those DTOs
+// do not yet model — V2DcvChallengeResponse in particular is already known to have
+// diverged from earlier spec examples once before, issues/0037).
+//
+// Strictly GET-only: Track Order once, then Get DCV Challenges once per domain. No
+// verify-DCV, publish, cancel, revoke, or order-create calls of any kind, and no
+// retries/polling loops — each call is attempted exactly once and its raw result (success
+// or failure) is logged as-is.
+//
+// Opt-in: requires CERTINEXT_PROBE_UCC_ORDER_ID to be set (skips otherwise — this repo's
+// convention for live-API probes). CERTINEXT_PROBE_UCC_DOMAINS (comma-separated) is
+// optional; if unset, this falls back to a best-effort scan of the raw Track Order body
+// for a primary "domain" field plus a handful of plausible SAN-array field names
+// (additionalDomains/domains/sans/sanList/sanDomains/domainList) — logged either way, full
+// raw body always printed regardless of what the scan finds.
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Text.Json;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using FluentAssertions;
+ using RestSharp;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ public class UccDcvShapeV2ProbeTests : IClassFixture
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly bool _v2Enabled;
+
+ ///
+ /// V2 product-family URL path segments to probe, in the same order as
+ /// CERTInextClient.ResolveV2OrderFamilyAsync (SSL, then Private PKI, then
+ /// Signature) — an order id is only ever valid within exactly one family. The
+ /// target order for issue 0042 is a UCC SSL order, so this is expected to resolve
+ /// on the first entry, but all three are tried for robustness (mirrors
+ /// RevocationShapeV2ProbeTests.Families).
+ ///
+ private static readonly string[] Families =
+ {
+ Constants.ApiV2.FamilySsl,
+ Constants.ApiV2.FamilyPrivatePki,
+ Constants.ApiV2.FamilySignature
+ };
+
+ ///
+ /// Best-effort candidate field names for a UCC order's SAN list on the raw Track
+ /// Order body, tried only when CERTINEXT_PROBE_UCC_DOMAINS is unset. None of these
+ /// are confirmed live — issue 0042 explicitly flags this as unconfirmed — this is
+ /// purely a diagnostic aid; the full raw body is always logged regardless of what
+ /// (if anything) this scan finds.
+ ///
+ private static readonly string[] CandidateSanArrayFields =
+ {
+ "additionalDomains", "domains", "sans", "sanList", "sanDomains", "domainList"
+ };
+
+ public UccDcvShapeV2ProbeTests(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");
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+ }
+
+ ///
+ /// Read-only. Fetches the raw Track Order response for the order named by
+ /// CERTINEXT_PROBE_UCC_ORDER_ID, then calls the raw Get DCV Challenges endpoint
+ /// once per domain (CERTINEXT_PROBE_UCC_DOMAINS, or a best-effort scan of the Track
+ /// Order body if that env var is unset), logging every raw status/body verbatim.
+ /// Makes no mutating call of any kind. Passes as long as the Track Order call
+ /// itself succeeds — the actual DCV-shape findings are reported via the logged raw
+ /// bodies, not asserted, since the whole point of this probe is that the shape is
+ /// currently unknown.
+ ///
+ [SkippableFact]
+ public async Task UccDcvShape_V2_TrackOrderAndGetDcvChallengesRawJson()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ string orderId = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_UCC_ORDER_ID");
+ Skip.If(string.IsNullOrWhiteSpace(orderId),
+ "CERTINEXT_PROBE_UCC_ORDER_ID not set — this probe is opt-in only and targets a " +
+ "specific live UCC order (issue 0042). Skipping.");
+
+ _output.WriteLine("=== Issue 0042 live probe: V2 UCC order DCV wire shape ===");
+ _output.WriteLine($"OrderId={orderId}");
+
+ // --- 1. Track Order (raw) ---
+ var track = await TrackOrderRawAsync(orderId);
+
+ track.Should().NotBeNull(
+ $"order {orderId} must resolve to one of the known V2 product families (ssl/private-pki/signature)");
+
+ _output.WriteLine("");
+ _output.WriteLine($"--- Track Order response --- Family={track.Family} HTTP={track.StatusCode}");
+ _output.WriteLine("Raw body:");
+ _output.WriteLine(track.Body);
+
+ track.Body.Should().NotBeNullOrWhiteSpace(
+ "the raw Track Order response for a known live order must have a non-empty body");
+
+ // --- 2. Resolve the domain list to probe ---
+ string domainsEnv = Environment.GetEnvironmentVariable("CERTINEXT_PROBE_UCC_DOMAINS");
+ List domains;
+ if (!string.IsNullOrWhiteSpace(domainsEnv))
+ {
+ domains = domainsEnv
+ .Split(',')
+ .Select(d => d.Trim())
+ .Where(d => !string.IsNullOrWhiteSpace(d))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ _output.WriteLine("");
+ _output.WriteLine($"Domains from CERTINEXT_PROBE_UCC_DOMAINS: {string.Join(", ", domains)}");
+ }
+ else
+ {
+ domains = DeriveDomainsFromTrackOrderBody(track.Body, out string derivationNote);
+ _output.WriteLine("");
+ _output.WriteLine("CERTINEXT_PROBE_UCC_DOMAINS not set — derived from raw Track Order body instead.");
+ _output.WriteLine(derivationNote);
+ _output.WriteLine(domains.Count > 0
+ ? $"Derived domain(s): {string.Join(", ", domains)}"
+ : "No domain(s) could be derived — see the full Track Order body above.");
+ }
+
+ if (domains.Count == 0)
+ {
+ _output.WriteLine("");
+ _output.WriteLine("No domains to probe against Get DCV Challenges — stopping after Track Order.");
+ return;
+ }
+
+ // --- 3. Get DCV Challenges (raw), once per domain, exactly one call each ---
+ foreach (string domain in domains)
+ {
+ var dcv = await GetDcvChallengesRawAsync(orderId, domain, track.Family);
+
+ _output.WriteLine("");
+ _output.WriteLine($"--- Get DCV Challenges response --- Domain={domain} HTTP={dcv.StatusCode}");
+ _output.WriteLine("Raw body:");
+ _output.WriteLine(dcv.Body);
+ }
+
+ _output.WriteLine("");
+ _output.WriteLine("=== End of issue 0042 probe. See the raw bodies above for the live DCV wire shape. ===");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Private helpers
+ // ---------------------------------------------------------------------------
+
+ private sealed class RawV2Response
+ {
+ public string Family { get; set; }
+ public int StatusCode { get; set; }
+ public string Body { get; set; }
+ }
+
+ ///
+ /// 120s timeout — matches CERTInextClient's own V1/V2 RestClientOptions and this
+ /// repo's other V2 probes (e.g. EmailNotificationsV2ProbeTests.NewApiClient) — this
+ /// sandbox has been observed to occasionally exceed the framework default HttpClient
+ /// timeout (100s) on some V2 endpoints.
+ ///
+ private static RestClient NewApiClient(string baseUrl) =>
+ new RestClient(new RestClientOptions(baseUrl) { Timeout = TimeSpan.FromSeconds(120) });
+
+ ///
+ /// Same OAuth2 client_credentials token-fetch idiom as this project's other V2
+ /// probes (RevocationShapeV2ProbeTests / EmailNotificationsV2ProbeTests /
+ /// IdempotencyKeyV2ProbeTests / OrganizationBlockV2ProbeTests). The token itself is
+ /// never logged by this file — only used to set the Authorization header on the
+ /// raw requests below.
+ ///
+ private async Task GetV2AccessTokenAsync()
+ {
+ string tokenUrl = _v2ApiUrl.TrimEnd('/') + "/oauth/token";
+ using var tokenClient = NewApiClient(tokenUrl);
+ var tokenReq = new RestRequest(string.Empty, Method.Post);
+ tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded");
+ tokenReq.AddParameter("grant_type", "client_credentials");
+ tokenReq.AddParameter("client_id", _v2ClientId);
+ tokenReq.AddParameter("client_secret", _v2ClientSecret);
+ var tokenResp = await tokenClient.ExecuteAsync(tokenReq);
+ if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content))
+ throw new Exception($"Token request failed: {(int)tokenResp.StatusCode}");
+
+ using var tokenDoc = JsonDocument.Parse(tokenResp.Content);
+ return tokenDoc.RootElement.GetProperty("access_token").GetString();
+ }
+
+ ///
+ /// Raw HTTP GET against each V2 product-family Track Order path in turn, stopping
+ /// at the first non-404 response — same algorithm as
+ /// CERTInextClient.ResolveV2OrderFamilyAsync / this project's
+ /// RevocationShapeV2ProbeTests.TrackOrderRawAsync, but deliberately bypasses
+ /// CERTInextClient.TrackOrderV2Async's typed deserialization so the exact
+ /// unparsed response body can be captured. Returns null if the order is not found
+ /// in any of the three families.
+ ///
+ private async Task TrackOrderRawAsync(string orderId)
+ {
+ string accessToken = await GetV2AccessTokenAsync();
+
+ using var apiClient = NewApiClient(_v2ApiUrl.TrimEnd('/'));
+ foreach (string family in Families)
+ {
+ var req = new RestRequest($"/api/certinext/v2/{family}/{orderId}", Method.Get);
+ req.AddHeader("Authorization", $"Bearer {accessToken}");
+ req.AddHeader("Accept", "application/json");
+ var resp = await apiClient.ExecuteAsync(req);
+
+ if ((int)resp.StatusCode == 404)
+ continue;
+
+ return new RawV2Response
+ {
+ Family = family,
+ StatusCode = (int)resp.StatusCode,
+ Body = resp.Content
+ };
+ }
+ return null;
+ }
+
+ ///
+ /// Raw HTTP GET against the V2 "Get DCV Challenges" endpoint
+ /// (GET /api/certinext/v2/ssl-certificates/{orderId}/dcv?domain={domain}) for a
+ /// single domain, within the already-resolved product family. Deliberately
+ /// bypasses CERTInextClient.GetDcvV2Async (which never sends a "domain"
+ /// query parameter today — issue 0042) so this probe can send it explicitly and
+ /// observe the CA's raw response verbatim, whatever it turns out to be. Does not
+ /// throw on a non-success status — the raw status/body is exactly what this probe
+ /// needs either way.
+ ///
+ private async Task GetDcvChallengesRawAsync(string orderId, string domain, string family)
+ {
+ string accessToken = await GetV2AccessTokenAsync();
+
+ using var apiClient = NewApiClient(_v2ApiUrl.TrimEnd('/'));
+ var req = new RestRequest($"/api/certinext/v2/{family}/{orderId}/dcv", Method.Get);
+ req.AddHeader("Authorization", $"Bearer {accessToken}");
+ req.AddHeader("Accept", "application/json");
+ req.AddQueryParameter("domain", domain);
+ var resp = await apiClient.ExecuteAsync(req);
+
+ string body = resp.Content;
+ if (string.IsNullOrEmpty(body) && !resp.IsSuccessful)
+ {
+ body = resp.ErrorException != null
+ ? $""
+ : $"";
+ }
+
+ return new RawV2Response
+ {
+ Family = family,
+ StatusCode = (int)resp.StatusCode,
+ Body = body
+ };
+ }
+
+ ///
+ /// Best-effort scan of a raw Track Order JSON body for a domain list to probe
+ /// against Get DCV Challenges, used only when CERTINEXT_PROBE_UCC_DOMAINS is
+ /// unset. Collects the single top-level "domain" field (the only SAN-related field
+ /// currently models) plus, if present, any of
+ /// as a string array. None of the array field
+ /// names are confirmed live for issue 0042 — this is purely a diagnostic
+ /// convenience; the caller always logs the full raw body regardless of this
+ /// method's result.
+ ///
+ private static List DeriveDomainsFromTrackOrderBody(string body, out string derivationNote)
+ {
+ var domains = new List();
+ var notes = new List();
+
+ if (string.IsNullOrWhiteSpace(body))
+ {
+ derivationNote = "Track Order body was empty — nothing to derive from.";
+ return domains;
+ }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ var root = doc.RootElement;
+
+ if (root.TryGetProperty("domain", out var domainEl) && domainEl.ValueKind == JsonValueKind.String)
+ {
+ string primary = domainEl.GetString();
+ if (!string.IsNullOrWhiteSpace(primary))
+ {
+ domains.Add(primary);
+ notes.Add($"Found top-level \"domain\"=\"{primary}\".");
+ }
+ }
+ else
+ {
+ notes.Add("No top-level \"domain\" string field found.");
+ }
+
+ foreach (string field in CandidateSanArrayFields)
+ {
+ if (root.TryGetProperty(field, out var arrEl) && arrEl.ValueKind == JsonValueKind.Array)
+ {
+ var found = arrEl.EnumerateArray()
+ .Where(e => e.ValueKind == JsonValueKind.String)
+ .Select(e => e.GetString())
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .ToList();
+
+ if (found.Count > 0)
+ {
+ notes.Add($"Found array field \"{field}\" with {found.Count} entrie(s): {string.Join(", ", found)}.");
+ domains.AddRange(found);
+ }
+ else
+ {
+ notes.Add($"Array field \"{field}\" is present but empty.");
+ }
+ }
+ }
+
+ if (notes.Count == 1 && domains.Count <= 1)
+ {
+ notes.Add(
+ "None of the candidate SAN-array field names " +
+ $"({string.Join(", ", CandidateSanArrayFields)}) were found on the Track Order body — " +
+ "matches issue 0042's finding that V2OrderStatusResponse has no confirmed field for a " +
+ "UCC order's additional domains yet.");
+ }
+ }
+ catch (JsonException ex)
+ {
+ notes.Add($"Track Order body was not valid JSON: {ex.Message}");
+ }
+
+ derivationNote = string.Join(" ", notes);
+ return domains.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/UccPendingSanOrderProbeTests.cs b/CERTInext.IntegrationTests/UccPendingSanOrderProbeTests.cs
new file mode 100644
index 0000000..f609a5b
--- /dev/null
+++ b/CERTInext.IntegrationTests/UccPendingSanOrderProbeTests.cs
@@ -0,0 +1,414 @@
+// 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.
+//
+// Live-order support for issue 0042 (V2 DCV machinery only drives the primary domain on a UCC
+// order — issues/0042-v2-ucc-dcv-only-drives-primary-domain.md). UccDcvShapeV2ProbeTests is
+// strictly GET-only and targets an *existing* order; this file is the one mutating step the
+// 0042 investigation needs — placing exactly one V2 DV SSL UCC order whose additionalDomains
+// land on a never-before-verified base domain (*.example.com, RFC 2606 reserved), so the SANs
+// stay genuinely PENDING for UccDcvShapeV2ProbeTests to inspect, instead of validating
+// instantly via base-domain reuse the way order 9295677273's scrup.org SANs did.
+//
+// Two independent opt-in tests, each gated on its own env var so neither runs by accident:
+//
+// 1. PlaceUccOrder_V2_PendingSanDcv_ForIssue0042Probe (CERTINEXT_LIVE_UCC_ENROLL=1) — places
+// the order. Built with DcvEnabled=false (default) so no inline DCV publish is attempted
+// against example.com. Never retries: on any exception (including a client-side timeout,
+// which does not prove the order was never created — see v2-api-support-questions.md
+// Finding 1/2), it makes exactly one read-only /reports/orders lookup for the same primary
+// domain and logs whatever it finds, then rethrows without placing a second order.
+//
+// 2. CancelOrder_V2_Issue0042Probe (CERTINEXT_CANCEL_ORDER_ID=) — cleanup. Same shape as
+// V2LifecycleTests.Revoke_V2_ExplicitOrder_Superseded: one plugin.GetSingleRecord check
+// before (skips if already terminal), one raw Cancel Order call (no client method exists
+// for this yet — same raw-HTTP idiom as EmailNotificationsV2ProbeTests/
+// IdempotencyKeyV2ProbeTests/OrganizationBlockV2ProbeTests' CancelSslOrderRawAsync, but
+// non-throwing so a failed cancel is reported rather than escalated), then one fresh
+// plugin.GetSingleRecord check after. No retries either direction.
+//
+// Run (place):
+// set -a; . ~/.env_certinext; set +a
+// export CERTINEXT_LIVE_UCC_ENROLL=1
+// dotnet test CERTInext.IntegrationTests -c Release \
+// --filter "FullyQualifiedName~PlaceUccOrder_V2_PendingSanDcv_ForIssue0042Probe" \
+// --logger "console;verbosity=detailed" > /tmp/lab0042/place.log 2>&1
+//
+// Run (cancel, after capturing the wire shape via UccDcvShapeV2ProbeTests):
+// export CERTINEXT_CANCEL_ORDER_ID=
+// dotnet test CERTInext.IntegrationTests -c Release \
+// --filter "FullyQualifiedName~CancelOrder_V2_Issue0042Probe" \
+// --logger "console;verbosity=detailed" > /tmp/lab0042/cancel.log 2>&1
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Text.Json;
+ using System.Threading;
+ 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.X509;
+ using Org.BouncyCastle.Crypto;
+ using Org.BouncyCastle.Crypto.Generators;
+ using Org.BouncyCastle.Pkcs;
+ using Org.BouncyCastle.Security;
+ using RestSharp;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ public class UccPendingSanOrderProbeTests : IClassFixture
+ {
+ private const string LiveEnrollFlag = "CERTINEXT_LIVE_UCC_ENROLL";
+ private const string CancelOrderIdFlag = "CERTINEXT_CANCEL_ORDER_ID";
+ private const string CancelFamilyFlag = "CERTINEXT_CANCEL_FAMILY";
+
+ ///
+ /// V2 catalog product code for "DV SSL Multi-Domain (UCC)" on this sandbox account, as
+ /// live-confirmed by the 0042 wire-shape probe (order 9295677273, see the issue file's
+ /// "Live wire shape" section) — NOT the V1-era Constants.Products.DefaultProductCodes
+ /// value (issue 0036: V1 numbering does not match the live V2 catalog). Overridable via
+ /// CERTINEXT_UCC_PRODUCT_CODE for re-use against a different account.
+ ///
+ private const string DefaultUccProductCode = "844";
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly string _uccProductCode;
+ private readonly bool _v2Enabled;
+
+ public UccPendingSanOrderProbeTests(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");
+ _uccProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_UCC_PRODUCT_CODE", DefaultUccProductCode);
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Shared helpers (deliberately duplicated per this repo's existing convention
+ // of small per-test-file helpers — see V2UccEnrollmentTests.cs's own comment
+ // to the same effect — rather than sharing test infrastructure across files).
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// DcvEnabled is fixed false — this probe must not attempt to publish a DNS-01 TXT
+ /// record for any *.example.com SAN (there is no real DNS provider for it, and the
+ /// whole point is to observe the CA's PENDING challenge shape, not drive it to
+ /// issuance). Matches this repo's documented default build (DcvSupport=false on this
+ /// branch) — see CLAUDE.md.
+ ///
+ private CERTInextConfig BuildV2Config()
+ {
+ return 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",
+ RequestorIsdCode = "1",
+ RequestorMobileNumber = "0000000000",
+ SignerPlace = "Gateway Lab",
+ SignerIp = "127.0.0.1",
+
+ PageSize = 100,
+
+ DcvEnabled = false
+ };
+ }
+
+ 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 CN 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-----";
+ }
+
+ // ---------------------------------------------------------------------------
+ // 1. Place the order
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Places exactly one V2 DV SSL UCC order: primary domain on the always-verified
+ /// dcv-test.scrup.org base domain (so the order itself places cleanly), additionalDomains
+ /// on two fresh *.example.com subdomains that have never been validated on this account
+ /// and cannot be (no real DNS provider is wired for RFC 2606 reserved space) — so they
+ /// stay PENDING for UccDcvShapeV2ProbeTests to inspect. Never retries: a caught exception
+ /// (including a client-side timeout — see v2-api-support-questions.md Finding 2's
+ /// "Secondary observation") triggers exactly one read-only orders-report lookup for the
+ /// same primary domain, logs whatever it finds, and rethrows.
+ ///
+ [SkippableFact]
+ public async Task PlaceUccOrder_V2_PendingSanDcv_ForIssue0042Probe()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ Skip.If(Environment.GetEnvironmentVariable(LiveEnrollFlag) != "1",
+ $"Set {LiveEnrollFlag}=1 to run this probe — it places one real UCC order (issue 0042). " +
+ "See this file's header comment for the full run recipe.");
+
+ string stamp = DateTime.UtcNow.ToString("yyyyMMddHHmm");
+ string primary = $"ucc0042-{stamp}.dcv-test.scrup.org";
+ string sanA = $"a.pending0042-{stamp}.example.com";
+ string sanB = $"b.pending0042-{stamp}.example.com";
+
+ var config = BuildV2Config();
+ var plugin = BuildV2Plugin(config);
+
+ var productInfo = new EnrollmentProductInfo
+ {
+ ProductID = Constants.Products.DvSslUcc,
+ ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [Constants.EnrollmentParam.ProductCode] = _uccProductCode
+ }
+ };
+
+ _output.WriteLine("=== Issue 0042 live probe: placing one V2 UCC order ===");
+ _output.WriteLine($"Primary domain: {primary}");
+ _output.WriteLine($"Additional domain A: {sanA}");
+ _output.WriteLine($"Additional domain B: {sanB}");
+ _output.WriteLine($"ProductCode: {_uccProductCode}");
+
+ EnrollmentResult result;
+ try
+ {
+ result = await plugin.Enroll(
+ csr: GenerateCsrPem(primary),
+ subject: $"CN={primary}",
+ san: new Dictionary { ["dns"] = new[] { sanA, sanB } },
+ productInfo: productInfo,
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine("");
+ _output.WriteLine($"Enroll threw: {ex.GetType().Name}: {ex.Message}");
+ _output.WriteLine(
+ "Per issue 0042's no-retry rule: NOT placing a second order. A client-side error " +
+ "(including a timeout) does not prove no order was created server-side — checking " +
+ "the read-only orders report once for a same-domain match before giving up.");
+
+ try
+ {
+ string today = DateTime.UtcNow.ToString("yyyy-MM-dd");
+ var rawClient = new CERTInextClient(config);
+ var matches = new List();
+ await foreach (var entry in rawClient.ListOrdersV2Async(from: today, to: today, ct: CancellationToken.None))
+ {
+ if (string.Equals(entry.DomainName, primary, StringComparison.OrdinalIgnoreCase))
+ matches.Add(entry);
+ }
+
+ if (matches.Count == 0)
+ {
+ _output.WriteLine($"No order for domain '{primary}' found in today's orders report.");
+ }
+ else
+ {
+ foreach (var m in matches)
+ {
+ _output.WriteLine(
+ $"FOUND despite the exception above: OrderNumber={m.OrderNumber}, " +
+ $"OrderStatus={m.OrderStatus}, CertificateStatus={m.CertificateStatus}, " +
+ $"Domain={m.DomainName}. This order must be accounted for (report/cancel) " +
+ "even though Enroll() itself threw.");
+ }
+ }
+ }
+ catch (Exception lookupEx)
+ {
+ _output.WriteLine($"Read-only orders-report lookup also failed: {lookupEx.Message}");
+ }
+
+ throw;
+ }
+
+ result.Should().NotBeNull();
+ result.CARequestID.Should().NotBeNullOrWhiteSpace(
+ "V2 Enroll must return a non-empty CARequestID even for a UCC order with pending SANs");
+
+ _output.WriteLine("");
+ _output.WriteLine("=== Order placed ===");
+ _output.WriteLine($"CARequestID (OrderId): {result.CARequestID}");
+ _output.WriteLine($"Status: {result.Status}");
+ _output.WriteLine($"StatusMessage: {result.StatusMessage}");
+ _output.WriteLine("");
+ _output.WriteLine("Next: capture the wire shape with UccDcvShapeV2ProbeTests:");
+ _output.WriteLine($" CERTINEXT_PROBE_UCC_ORDER_ID={result.CARequestID}");
+ _output.WriteLine($" CERTINEXT_PROBE_UCC_DOMAINS={primary},{sanA},{sanB}");
+ _output.WriteLine("Then clean up with CancelOrder_V2_Issue0042Probe:");
+ _output.WriteLine($" CERTINEXT_CANCEL_ORDER_ID={result.CARequestID}");
+ }
+
+ // ---------------------------------------------------------------------------
+ // 2. Cancel (cleanup) — same shape as V2LifecycleTests.Revoke_V2_ExplicitOrder_Superseded
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Cleanup for the order placed above. One plugin.GetSingleRecord check before (skips if
+ /// the order is already GENERATED/REVOKED — a terminal state Cancel Order does not apply
+ /// to), one raw Cancel Order call (no ICERTInextClient method exists for this V2
+ /// endpoint yet — same non-throwing raw-HTTP idiom as
+ /// EmailNotificationsV2ProbeTests.CancelOrderRawAsync), and one fresh
+ /// plugin.GetSingleRecord check after. Never retries the cancel call itself; a failed
+ /// cancel is reported as-is and the order is left in place, per issue 0042's runbook.
+ ///
+ [SkippableFact]
+ public async Task CancelOrder_V2_Issue0042Probe()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ string orderId = Environment.GetEnvironmentVariable(CancelOrderIdFlag);
+ Skip.If(string.IsNullOrWhiteSpace(orderId), $"{CancelOrderIdFlag} not set — skipping.");
+
+ var plugin = BuildV2Plugin();
+
+ var before = await plugin.GetSingleRecord(orderId);
+ _output.WriteLine($"Before cancel: CARequestID={orderId}, Status={before?.Status}");
+
+ Skip.If(
+ before?.Status == (int)EndEntityStatus.GENERATED || before?.Status == (int)EndEntityStatus.REVOKED,
+ $"Order '{orderId}' is already terminal (status={before?.Status}) — Cancel Order does not apply; not cancelling.");
+
+ // Optional CERTINEXT_CANCEL_FAMILY (ssl | private-pki | signature, default ssl) selects
+ // the family's Cancel Order path — each family has its own /{family}/:orderId/cancel.
+ string family = Environment.GetEnvironmentVariable(CancelFamilyFlag)?.Trim().ToLowerInvariant();
+ string familyPath = family switch
+ {
+ null or "" or "ssl" => Constants.ApiV2.SslCertificatesPath,
+ "private-pki" => Constants.ApiV2.PrivatePkiCertificatesPath,
+ "signature" => Constants.ApiV2.SignatureCertificatesPath,
+ _ => throw new ArgumentException($"{CancelFamilyFlag} must be ssl, private-pki or signature (got '{family}').")
+ };
+ _output.WriteLine($"Cancel path: {familyPath}/{orderId}/cancel");
+
+ var cancelResp = await CancelOrderRawAsync(
+ familyPath, orderId, "Keyfactor plugin live probe cleanup.");
+
+ _output.WriteLine("");
+ _output.WriteLine($"Cancel response: HTTP {cancelResp.StatusCode}, IsSuccessful={cancelResp.IsSuccessful}");
+ _output.WriteLine($"Cancel response body: {cancelResp.Body}");
+
+ if (!cancelResp.IsSuccessful)
+ {
+ _output.WriteLine(
+ "Cancel did not succeed. Per issue 0042's runbook: NOT escalating to any other " +
+ "destructive call. Leaving the order as-is; see the response above for the exact detail.");
+ }
+
+ var after = await plugin.GetSingleRecord(orderId);
+ _output.WriteLine("");
+ _output.WriteLine($"After cancel: Status={after?.Status}, RevocationDate={after?.RevocationDate:o}");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Raw HTTP helpers for the one V2 operation with no ICERTInextClient method yet
+ // (Cancel Order) — same idiom as EmailNotificationsV2ProbeTests/
+ // IdempotencyKeyV2ProbeTests/OrganizationBlockV2ProbeTests' CancelSslOrderRawAsync.
+ // ---------------------------------------------------------------------------
+
+ private sealed class RawApiResponse
+ {
+ public int StatusCode { get; set; }
+ public string Body { get; set; }
+ public bool IsSuccessful { get; set; }
+ }
+
+ private static RestClient NewApiClient(string baseUrl) =>
+ new RestClient(new RestClientOptions(baseUrl) { Timeout = TimeSpan.FromSeconds(120) });
+
+ private async Task GetV2AccessTokenAsync()
+ {
+ string tokenUrl = _v2ApiUrl.TrimEnd('/') + "/oauth/token";
+ using var tokenClient = NewApiClient(tokenUrl);
+ var tokenReq = new RestRequest(string.Empty, Method.Post);
+ tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded");
+ tokenReq.AddParameter("grant_type", "client_credentials");
+ tokenReq.AddParameter("client_id", _v2ClientId);
+ tokenReq.AddParameter("client_secret", _v2ClientSecret);
+ var tokenResp = await tokenClient.ExecuteAsync(tokenReq);
+ if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content))
+ throw new Exception($"Token request failed: {(int)tokenResp.StatusCode}");
+
+ using var tokenDoc = JsonDocument.Parse(tokenResp.Content);
+ return tokenDoc.RootElement.GetProperty("access_token").GetString();
+ }
+
+ ///
+ /// Raw HTTP POST to {familyPath}/{orderId}/cancel. Returns the raw
+ /// status/body instead of throwing on non-2xx, so a failed cancel can be reported without
+ /// losing detail and without the caller needing to catch an exception to see it.
+ ///
+ private async Task CancelOrderRawAsync(string familyPath, string orderId, string reason)
+ {
+ string accessToken = await GetV2AccessTokenAsync();
+
+ using var apiClient = NewApiClient(_v2ApiUrl.TrimEnd('/'));
+ var cancelReq = new RestRequest($"{familyPath}/{orderId}/cancel", Method.Post);
+ cancelReq.AddHeader("Authorization", $"Bearer {accessToken}");
+ cancelReq.AddHeader("Accept", "application/json");
+ cancelReq.AddJsonBody(new { reason });
+ var cancelResp = await apiClient.ExecuteAsync(cancelReq);
+
+ string body = cancelResp.Content;
+ if (string.IsNullOrEmpty(body) && !cancelResp.IsSuccessful)
+ {
+ body = cancelResp.ErrorException != null
+ ? $""
+ : $"";
+ }
+
+ return new RawApiResponse
+ {
+ StatusCode = (int)cancelResp.StatusCode,
+ Body = body,
+ IsSuccessful = cancelResp.IsSuccessful
+ };
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/UnmodeledSpecFeaturesV2ProbeTests.cs b/CERTInext.IntegrationTests/UnmodeledSpecFeaturesV2ProbeTests.cs
new file mode 100644
index 0000000..e28109a
--- /dev/null
+++ b/CERTInext.IntegrationTests/UnmodeledSpecFeaturesV2ProbeTests.cs
@@ -0,0 +1,274 @@
+// 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.Text.Json;
+using System.Threading.Tasks;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Issue 0039 triage probes — READ-ONLY. Every call here is a GET; nothing is placed,
+ /// cancelled, revoked or modified.
+ ///
+ ///
+ /// - — for every product in this
+ /// account's V2 catalog, calls the spec's "Get Custom Fields for Product"
+ /// (GET /catalog/products/{code}/custom-fields) and reports whether any field is
+ /// isMandatory="1" (the EMS-918 "Additional information missing" risk).
+ /// - — reads the ledger statement
+ /// and the orders report and reports, per order status, how many orders have a ledger
+ /// row. Answers "does an order the plugin abandons (pending/cancelled) get billed?".
+ /// Only counts and column names are printed — no amounts or invoice numbers.
+ ///
+ ///
+ /// Gated by CERTINEXT_0039_PROBE=1 in addition to CERTINEXT_USE_V2_API + V2
+ /// credentials (loaded from ~/.env_certinext_v2 via ).
+ ///
+ /// set -a; . ~/.env_certinext; set +a
+ /// export CERTINEXT_USE_V2_API=1 CERTINEXT_0039_PROBE=1
+ /// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \
+ /// --filter "FullyQualifiedName~UnmodeledSpecFeaturesV2Probe" \
+ /// --logger "console;verbosity=detailed" > /tmp/probe0039.log 2>&1
+ ///
+ ///
+ public class UnmodeledSpecFeaturesV2ProbeTests : IClassFixture
+ {
+ private const string ProbeFlag = "CERTINEXT_0039_PROBE";
+ private const int MaxPages = 5;
+ private const int PageSize = 100;
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly bool _probeEnabled;
+
+ public UnmodeledSpecFeaturesV2ProbeTests(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");
+
+ bool v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+ _probeEnabled = v2Enabled && V2EnvHelper.GetEnv(env, ProbeFlag) == "1";
+ }
+
+ [SkippableFact]
+ public async Task CustomFields_V2_PerCatalogProduct()
+ {
+ Skip.IfNot(_probeEnabled, $"{ProbeFlag}=1 not set (or V2 not enabled) — skipping 0039 custom-fields probe.");
+
+ using var client = BuildV2Client();
+ var catalog = await client.GetProductDetailsV2Async();
+ _output.WriteLine($"Catalog products (groupNumber scoped={!string.IsNullOrWhiteSpace(_fixture.GroupNumber)}): {catalog.Count}");
+
+ int withMandatory = 0;
+ foreach (var product in catalog.Where(p => !string.IsNullOrWhiteSpace(p.ProductCode))
+ .GroupBy(p => p.ProductCode).Select(g => g.First()))
+ {
+ string path = $"/api/certinext/v2/catalog/products/{Uri.EscapeDataString(product.ProductCode)}/custom-fields";
+ var (status, _, content) = await client.ProbeV2GetAsync(path);
+ string header = $"[{product.ProductCode}] typeId={product.ProductTypeId} name='{product.ProductName}'";
+
+ if (status != 200 || string.IsNullOrWhiteSpace(content))
+ {
+ _output.WriteLine($"{header} -> HTTP {status}; body: {Truncate(content, 300)}");
+ continue;
+ }
+
+ var fields = new List<(string Group, string Name, string FieldId, string Type, string Mandatory)>();
+ string shape;
+ try
+ {
+ using var doc = JsonDocument.Parse(content);
+ shape = DescribeShape(doc.RootElement);
+ CollectFields(doc.RootElement, "(root)", fields);
+ }
+ catch (JsonException jex)
+ {
+ _output.WriteLine($"{header} -> HTTP 200 but non-JSON body ({jex.Message}): {Truncate(content, 300)}");
+ continue;
+ }
+
+ var mandatory = fields.Where(f => f.Mandatory == "1").ToList();
+ if (mandatory.Count > 0) withMandatory++;
+ _output.WriteLine($"{header} -> HTTP 200 shape={shape} fields={fields.Count} mandatory={mandatory.Count}");
+ foreach (var f in fields)
+ _output.WriteLine($" group={f.Group} name='{f.Name}' fieldId={f.FieldId ?? "(none)"} type={f.Type ?? "(none)"} isMandatory={f.Mandatory ?? "(absent)"}");
+ if (fields.Count == 0)
+ _output.WriteLine($" raw: {Truncate(content, 400)}");
+ }
+
+ _output.WriteLine($"FINDING: {withMandatory} catalog product(s) report at least one isMandatory=\"1\" custom field.");
+ }
+
+ [SkippableFact]
+ public async Task Ledger_V2_OrderStatusCrossReference()
+ {
+ Skip.IfNot(_probeEnabled, $"{ProbeFlag}=1 not set (or V2 not enabled) — skipping 0039 ledger probe.");
+
+ using var client = BuildV2Client();
+
+ var ledgerOrderIds = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var ledgerKeys = new SortedSet();
+ int ledgerRows = 0;
+ for (int page = 1; page <= MaxPages; page++)
+ {
+ var (status, _, content) = await client.ProbeV2GetAsync($"/api/certinext/v2/reports/ledger?page={page}&size={PageSize}");
+ if (status != 200)
+ {
+ _output.WriteLine($"Ledger page {page}: HTTP {status}; body: {Truncate(content, 300)}");
+ break;
+ }
+ using var doc = JsonDocument.Parse(content);
+ if (page == 1)
+ {
+ var root = doc.RootElement;
+ string envelope = root.ValueKind == JsonValueKind.Object
+ ? string.Join(", ", root.EnumerateObject().Select(p =>
+ p.Value.ValueKind == JsonValueKind.Array ? $"{p.Name}:array[{p.Value.GetArrayLength()}]"
+ : p.Value.ValueKind == JsonValueKind.Number ? $"{p.Name}={p.Value}"
+ : $"{p.Name}:{p.Value.ValueKind}"))
+ : root.ValueKind.ToString();
+ _output.WriteLine($"Ledger page 1: HTTP 200 envelope=[{envelope}]");
+ }
+ if (!doc.RootElement.TryGetProperty("content", out var arr) || arr.ValueKind != JsonValueKind.Array || arr.GetArrayLength() == 0)
+ break;
+ foreach (var row in arr.EnumerateArray())
+ {
+ ledgerRows++;
+ foreach (var p in row.EnumerateObject()) ledgerKeys.Add(p.Name);
+ foreach (string k in new[] { "orderId", "orderNumber", "requestNumber" })
+ if (row.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(v.GetString()))
+ ledgerOrderIds.Add(v.GetString());
+ }
+ if (doc.RootElement.TryGetProperty("totalPages", out var tp) && tp.TryGetInt32(out int total) && page >= total)
+ break;
+ }
+ _output.WriteLine($"Ledger: rows read={ledgerRows}, distinct order/request ids={ledgerOrderIds.Count}, row keys=[{string.Join(", ", ledgerKeys)}]");
+
+ var byStatus = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (int page = 1; page <= MaxPages; page++)
+ {
+ var (status, _, content) = await client.ProbeV2GetAsync($"/api/certinext/v2/reports/orders?page={page}&size={PageSize}");
+ if (status != 200)
+ {
+ _output.WriteLine($"Orders report page {page}: HTTP {status}; body: {Truncate(content, 300)}");
+ break;
+ }
+ using var doc = JsonDocument.Parse(content);
+ if (!doc.RootElement.TryGetProperty("content", out var arr) || arr.ValueKind != JsonValueKind.Array || arr.GetArrayLength() == 0)
+ break;
+ foreach (var row in arr.EnumerateArray())
+ {
+ string orderStatus = row.TryGetProperty("orderStatus", out var s) && s.ValueKind == JsonValueKind.String ? s.GetString() : "(none)";
+ string certStatus = row.TryGetProperty("certificateStatus", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() : "(none)";
+ string key = $"orderStatus='{orderStatus}' certificateStatus='{certStatus}'";
+ bool inLedger = new[] { "orderNumber", "requestNumber" }.Any(k =>
+ row.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String && ledgerOrderIds.Contains(v.GetString() ?? string.Empty));
+ byStatus.TryGetValue(key, out var agg);
+ byStatus[key] = (agg.Total + 1, agg.InLedger + (inLedger ? 1 : 0));
+ }
+ if (doc.RootElement.TryGetProperty("totalPages", out var tp) && tp.TryGetInt32(out int total) && page >= total)
+ break;
+ }
+
+ foreach (var kv in byStatus.OrderByDescending(k => k.Value.Total))
+ _output.WriteLine($" {kv.Key}: orders={kv.Value.Total}, withLedgerRow={kv.Value.InLedger}");
+ _output.WriteLine("FINDING: see per-status withLedgerRow counts above (ledger ids matched against orderNumber/requestNumber).");
+ }
+
+ private static string DescribeShape(JsonElement root)
+ {
+ if (root.ValueKind != JsonValueKind.Object)
+ return root.ValueKind.ToString();
+ if (!root.TryGetProperty("customFields", out var cf))
+ return $"object[{string.Join(",", root.EnumerateObject().Select(p => p.Name))}]";
+ if (cf.ValueKind != JsonValueKind.Array)
+ return $"customFields:{cf.ValueKind}";
+ if (cf.GetArrayLength() == 0)
+ return "customFields:[] (empty)";
+ var first = cf[0];
+ bool nested = first.ValueKind == JsonValueKind.Object
+ && (first.TryGetProperty("certificateInformation", out _) || first.TryGetProperty("additionalInformation", out _));
+ return nested ? "customFields:[{certificateInformation,additionalInformation}] (spec description shape)"
+ : "customFields:[flat rows] (spec example shape)";
+ }
+
+ private static void CollectFields(JsonElement el, string group, List<(string, string, string, string, string)> sink)
+ {
+ if (el.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var item in el.EnumerateArray()) CollectFields(item, group, sink);
+ return;
+ }
+ if (el.ValueKind != JsonValueKind.Object) return;
+
+ bool looksLikeField = el.TryGetProperty("isMandatory", out _) || el.TryGetProperty("fieldId", out _);
+ if (looksLikeField)
+ {
+ sink.Add((group,
+ Str(el, "name") ?? Str(el, "displayName") ?? Str(el, "fieldName"),
+ Str(el, "fieldId"),
+ Str(el, "type"),
+ Str(el, "isMandatory")));
+ return;
+ }
+ foreach (var p in el.EnumerateObject())
+ if (p.Value.ValueKind == JsonValueKind.Array || p.Value.ValueKind == JsonValueKind.Object)
+ CollectFields(p.Value, p.Name, sink);
+ }
+
+ private static string Str(JsonElement el, string name)
+ {
+ if (!el.TryGetProperty(name, out var v)) return null;
+ return v.ValueKind == JsonValueKind.String ? v.GetString() : v.GetRawText();
+ }
+
+ private CERTInextClient BuildV2Client()
+ {
+ return new CERTInextClient(new CERTInextConfig
+ {
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ OAuthClientId = _v2ClientId,
+ OAuthClientSecret = _v2ClientSecret,
+ GroupNumber = _fixture.IsConfigured ? _fixture.GroupNumber : null,
+ RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Test",
+ RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com",
+ PageSize = PageSize
+ });
+ }
+
+ private static string Truncate(string value, int maxLength)
+ {
+ if (string.IsNullOrEmpty(value) || value.Length <= maxLength) return value;
+ return value.Substring(0, maxLength) + "...(truncated)";
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/V1FixtureApiUrlGuardTests.cs b/CERTInext.IntegrationTests/V1FixtureApiUrlGuardTests.cs
new file mode 100644
index 0000000..5ab11d6
--- /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 for issue 0017 (no live-API dependency, no process-env
+ /// mutation, so they are safe to run in parallel with every other class):
+ ///
+ /// - (D) the V1 fixture's guard
+ /// rejects a V2 base URL with an actionable message that never echoes secrets;
+ /// - (B) never promotes a key the V1 side reads,
+ /// nor any of the fixture's opt-in-only flags (issue 0058).
+ ///
+ ///
+ 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 0017 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-0017-{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");
+ }
+
+ ///
+ /// Issue 0058: if a developer ever left one of the fixture's opt-in-only flags (e.g.
+ /// CERTINEXT_V2_GAP_PROBES, 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
+ /// , not just the two named in the
+ /// issue, 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..6bcad75
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2ApiTests.cs
@@ -0,0 +1,735 @@
+// 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 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 —
+ /// see issue 0017); 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 now uses V2 /reports/orders when UseV2Api is true
+ /// (issues/0022), and V1 credentials are optional in that mode.
+ ///
+ public class V2ApiTests : IClassFixture
+ {
+ 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 (issues/0017, gap G14 — 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.
+ ///
+ [SkippableFact]
+ public async Task Lifecycle_V2_EnrollTrackRevoke()
+ {
+ 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 client.PlaceOrderV2Async(
+ Constants.ApiV2.FamilySsl, _v2ProductCode, orderReq);
+
+ 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 has been
+ // observed to omit "_links" entirely (see issues/0016), so we log rather than
+ // hard-fail — the regression we actually guard against is OrderId/Status shape.
+ 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 (issues/0022)
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Verifies that Synchronize calls V2 /reports/orders (not V1 GetOrderReport)
+ /// when UseV2Api=true, and succeeds with ZERO V1 credentials configured at all —
+ /// the hard acceptance criterion from the Phase 4 parent plan. A single
+ /// now serves both modes (issues/0022 config
+ /// consolidation), 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. See issues/0022's "V2 sync
+ // per-row download cost" note — 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 has been observed to 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 — real, measured durations for comparable scope elsewhere in this class are
+ // 3-4.5 minutes. See issues/0022's "V2 sync per-row download cost" note — this is 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 issues/0022 flags as 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");
+
+ // Hard assertion restored (issues/0016 item 1, fixed by issues/0025): the live
+ // catalog/products response is a nested category envelope, the same shape V1's
+ // GetProductDetails returns. ParseProductDetailsV2Response now 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 — the regression
+ /// test for issue 0025. 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 pending-csr or pending-dcv; that is fine.
+ /// Skips when CERTINEXT_USE_V2_API is not set.
+ ///
+ [SkippableFact]
+ public async Task GetSingleRecord_V2_ReturnsOrderDetails()
+ {
+ 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 client.PlaceOrderV2Async(
+ Constants.ApiV2.FamilySsl, _v2ProductCode, orderReq);
+
+ 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 (V2_TEST_GAP_PLAN.md Phase 1.4b) — so the test
+ /// no longer depends on another test's run order (issues/0017, gap G7) to have a
+ /// usable order ID.
+ ///
+ [SkippableFact]
+ public async Task Revoke_V2_IssuedOrder()
+ {
+ 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);
+
+ // 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 (issues/0019).
+ 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 — see
+ /// issues/0020. 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.
+ ///
+ [SkippableFact]
+ public async Task DcvFlow_V2_PublishesAndVerifies()
+ {
+ 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 client.PlaceOrderV2Async(
+ Constants.ApiV2.FamilySsl, _v2ProductCode, orderReq);
+ 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. If this fails, see issues/0020.");
+ return;
+ }
+ dcvResp.Should().NotBeNull();
+ dcvResp.Token.Should().NotBeNullOrEmpty(
+ "GetDcvV2Async must return a TXT token in Token");
+
+ // The live response has no domainName field (issues/0037) — 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 (V2_TEST_GAP_PLAN.md Phase 1.4b).
+ ///
+ [SkippableFact]
+ public async Task ChainPem_V2_IsAssembled()
+ {
+ 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);
+
+ 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",
+ SignerIp = "127.0.0.1",
+ SignerPlace = "Gateway Lab",
+ Accepted = true
+ },
+ Remarks = "Keyfactor V2 integration test — safe to revoke immediately."
+ };
+
+ private CERTInextClient BuildV2Client()
+ {
+ return new CERTInextClient(new CERTInextConfig
+ {
+ // A single ApiUrl now serves V2 (issues/0022 config consolidation) — 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 (V2_TEST_GAP_PLAN.md Phase 1.4b). 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)
+ {
+ 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 client.PlaceOrderV2Async(Constants.ApiV2.FamilySsl, _v2ProductCode, orderReq);
+ 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..533c405
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2DcvLifecycleTests.cs
@@ -0,0 +1,637 @@
+// 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 _v2ProductCode;
+ 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");
+ _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");
+
+ _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
+ /// now serves both modes (issues/0022 config
+ /// consolidation), and V2 auth reuses /
+ /// . Deliberately omits every V1-only
+ /// field (ApiKey/AccountNumber/AuthMode) — V1 credentials are optional when UseV2Api
+ /// is true, including for Synchronize (now V2 /reports/orders — issues/0022).
+ ///
+ private CERTInextConfig BuildV2Config(
+ bool dcvEnabled = true, int propagationDelaySeconds = 5, int? pageSize = null, int? syncLookbackHours = null)
+ {
+ return new CERTInextConfig
+ {
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ 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 (issues/0022's
+ // "V2 sync per-row download cost" note). 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);
+ }
+
+ private EnrollmentProductInfo BuildV2ProductInfo() =>
+ new EnrollmentProductInfo
+ {
+ ProductID = _v2ProductCode,
+ ProductParameters = new Dictionary
+ {
+ [Constants.EnrollmentParam.ProductCode] = _v2ProductCode,
+ [Constants.EnrollmentParam.ProfileId] = _v2ProductCode,
+ }
+ };
+
+ ///
+ /// 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;
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 5 — Enroll with DCV on, V2 path, does not throw
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task DcvEnroll_V2_CompletesWithoutThrowing()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ 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(),
+ 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 (issues/0020): 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. If this fails, see issues/0020 (the plugin currently treats the CA's " +
+ "EMS-1080 'already verified' response as a failure and defers, rather than as satisfied).");
+ 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.");
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 6 — Enroll with DCV off, V2 path, does not invoke the DNS provider
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task EnrollWithoutDcv_V2_DoesNotInvokeDnsProvider()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ 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(),
+ 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");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 7 — 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.");
+
+ var plugin = BuildV2DcvPlugin(dcvEnabled: true);
+ var record = await plugin.GetSingleRecord(orderId);
+
+ 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");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 8 — End-to-end DCV-on enrollment, issued cert appears in sync
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task EnrollWithDcvOn_V2_OrderIssuedEndToEnd_AndAppearsInSync()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ 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(),
+ 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 (issues/0020): 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. See issues/0020.");
+ }
+ 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 (mirrors issue 0001 for V1)");
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 14 — Key-algorithm issuance matrix, V2 path (opt-in)
+ // ---------------------------------------------------------------------------
+
+ [SkippableTheory]
+ [MemberData(nameof(KeyAlgorithms.AsMemberData), MemberType = typeof(KeyAlgorithms))]
+ public async Task EnrollWithDcvOn_V2_IssuesPerKeyAlgorithm(string tag)
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ 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(),
+ 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. ---");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 15 — 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 now pages through V2
+ // /reports/orders (ListOrdersV2Async, issues/0022) 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
+ // — issues/0022's "V2 sync per-row download cost" note, compounded by the retry loop.
+ 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(),
+ 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..92909b7
--- /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, see issues/0020) 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..e5c71fe
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2FreshDomainDcvLifecycleTests.cs
@@ -0,0 +1,412 @@
+// 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 observe staged=0 (the reuse path, issue 0020) 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,
+ 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
+ };
+ }
+
+ ///
+ /// Same revoke-if-issued / cancel-otherwise cleanup as V2FullLifecycleTests.
+ /// CleanupOrderAsync — 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)
+ {
+ if (string.IsNullOrWhiteSpace(orderId))
+ return true;
+
+ try
+ {
+ var current = await plugin.GetSingleRecord(orderId);
+ if (current?.Status == (int)EndEntityStatus.GENERATED)
+ {
+ int revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 4 /* superseded */);
+ _output.WriteLine($"Cleanup: revoked issued order {orderId} -> {revokeResult}.");
+ }
+ else
+ {
+ await V2RawProbeHelpers.CancelSslOrderRawAsync(
+ _v2ApiUrl, _v2ClientId, _v2ClientSecret, orderId,
+ "V2 fresh-domain DCV test cleanup — order not issued, cancelling.");
+ _output.WriteLine($"Cleanup: cancelled non-issued order {orderId} (status={current?.Status}).");
+ }
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine(
+ $"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;
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // 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 (issue 0020) and observe staged=0. Asserts staged>0
+ /// and cleaned==staged.
+ /// Expected sandbox order count: 1.
+ ///
+ [SkippableFact]
+ public async System.Threading.Tasks.Task EnrollWithDcvOn_V2_FreshUnverifiedSubdomain_StagesAndCleansUpTxt()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ 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: new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl },
+ 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). In practice this
+ // sandbox account's CA has been observed treating the fresh subdomain's parent
+ // as already covering it and issuing 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 observes staged=0 via the reuse path, issue 0020), 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.
+ ///
+ [SkippableFact]
+ public async System.Threading.Tasks.Task EnrollWithDcvOn_V2_WildcardFreshSubdomain_RecordsTxtHostnameAndCleansUp()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ 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: new EnrollmentProductInfo { ProductID = Constants.Products.DvSslWildcard },
+ 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($"OBSERVATION: 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(
+ $"OBSERVATION: 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
+ ? "OBSERVATION: at least one domain entry reached VERIFIED within this test's wait — " +
+ "CA-side acceptance of a base-domain TXT record for a wildcard domain entry is CONFIRMED live."
+ : "OBSERVATION: 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 an observation, not 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..2818922
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2FullLifecycleTests.cs
@@ -0,0 +1,960 @@
+// 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/
+// V2GapProbeTests) 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 per the task brief (known CA latency —
+ /// issue 0064). 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. Flagged in the
+ /// handoff report as a production gap, not silently worked around here.
+ ///
+ 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,
+ 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 actually observed on a live run: 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)
+ || (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 (issue 0064), 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: revokes it via the plugin's real V2
+ /// Revoke if it reached GENERATED, otherwise cancels it via the raw cancel endpoint
+ /// (the plugin has no V2 cancel method — ). 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)
+ {
+ if (string.IsNullOrWhiteSpace(orderId))
+ return;
+
+ try
+ {
+ var current = await plugin.GetSingleRecord(orderId);
+ if (current?.Status == (int)EndEntityStatus.GENERATED)
+ {
+ int revokeResult = await plugin.Revoke(orderId, hexSerialNumber: string.Empty, revocationReason: 4 /* superseded */);
+ _output.WriteLine($"Cleanup: revoked issued order {orderId} -> {revokeResult}.");
+ }
+ else
+ {
+ await V2RawProbeHelpers.CancelSslOrderRawAsync(
+ _v2ApiUrl, _v2ClientId, _v2ClientSecret, orderId,
+ "V2 full-lifecycle test cleanup — order not issued, cancelling.");
+ _output.WriteLine($"Cleanup: cancelled non-issued order {orderId} (status={current?.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 if it should not remain pending.");
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // 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 —
+ /// mirrors UccPendingSanOrderProbeTests' reasoning). Exercises Enroll -> GetSingleRecord
+ /// -> Synchronize, then cleans up (revoke if GENERATED, else cancel).
+ /// Expected sandbox order count: 1.
+ ///
+ [SkippableFact]
+ public async Task Enroll_V2_DvUcc_WithMultipleSans_FullLifecycle()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ 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 = new EnrollmentProductInfo { ProductID = Constants.Products.DvSslUcc };
+
+ 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 (issues 0028, 0059) 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 is actually observed (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.
+ ///
+ [SkippableFact]
+ public async Task Enroll_V2_Ov_FullLifecycle()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ 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: new EnrollmentProductInfo { ProductID = Constants.Products.OvSsl },
+ 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. Per issue 0064 (closed won't-fix) that timeout stays as-is, so this is an " +
+ "expected skip rather than a production bug on its own — 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 — this is the observed, not forced, state machine).");
+
+ 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, which neither OrganizationBlockV2ProbeTests nor
+ /// UccPendingSanOrderProbeTests combined into one order. Same pending-vetting
+ /// observation and timeout caveat as the plain OV test above.
+ /// Expected sandbox order count: 1.
+ ///
+ [SkippableFact]
+ public async Task Enroll_V2_OvUcc_WithMultipleSans_FullLifecycle()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ 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: new EnrollmentProductInfo { ProductID = Constants.Products.OvSslUcc },
+ 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 accepted-stays-as-is condition as the plain OV test (issue 0064, closed " +
+ "won't-fix). 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 (issue 0028/0059's ProductVariantsV2 mapping
+ /// resolves "ev" automatically), same pending-vetting observation, 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.
+ ///
+ [SkippableFact]
+ public async Task Enroll_V2_Ev_FullLifecycle()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ 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: new EnrollmentProductInfo { ProductID = Constants.Products.EvSsl },
+ 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 production gap flagged for OV (issue 0064). " +
+ $"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 named in the task brief — this repo's own
+ /// always-reused sandbox base domain (see UccPendingSanOrderProbeTests' header comment),
+ /// 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.
+ ///
+ [SkippableFact]
+ public async Task Enroll_V2_WildcardDv_WildcardOnly_FullLifecycle()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ 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: new EnrollmentProductInfo { ProductID = Constants.Products.DvSslWildcard },
+ 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)
+ /// now explicitly exempts exactly this shape for a wildcard product (fix: 1f1de1b) — 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 still RECORDS the actual observed
+ /// 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; unconfirmed live).
+ /// 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).
+ ///
+ [SkippableFact]
+ public async Task Enroll_V2_WildcardDv_WildcardPlusApexSan_RecordsActualBehavior()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ 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: new EnrollmentProductInfo { ProductID = Constants.Products.DvSslWildcard },
+ 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 (fix: 1f1de1b) 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($"OBSERVATION: issued certificate SAN list: [{string.Join(", ", sans)}]");
+
+ bool apexCovered = sans.Any(s => string.Equals(s, apex, StringComparison.OrdinalIgnoreCase));
+ _output.WriteLine(apexCovered
+ ? $"OBSERVATION: the apex '{apex}' IS covered by the issued certificate's SAN list."
+ : $"OBSERVATION: the apex '{apex}' is NOT covered by the issued certificate's SAN " +
+ "list (observation only, not asserted — whether CERTInext adds the apex to " +
+ "additionalDomains automatically for a non-UCC wildcard product is unconfirmed live).");
+ }
+ 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 real observed
+ /// behavior (distinct CARequestIDs, original never implicitly revoked) but — unlike an
+ /// earlier version of this test — does NOT pass merely because the CA accepted each
+ /// submission; a FAILED order at the CA (e.g. the product-selection bug this plugin's own
+ /// catalog code resolves — now a separate, actively-fixed issue) 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).
+ ///
+ [SkippableFact]
+ public async Task EnrollRenewReissue_V2_IssuedDvOrder_RecordsActualBehavior()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ 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: new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl },
+ 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: new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl, ProductParameters = 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: new EnrollmentProductInfo { ProductID = Constants.Products.DvSsl, ProductParameters = 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/V2GapProbeTests.cs b/CERTInext.IntegrationTests/V2GapProbeTests.cs
new file mode 100644
index 0000000..cbc078b
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2GapProbeTests.cs
@@ -0,0 +1,1207 @@
+// 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.
+//
+// Sandbox gap probes P1-P5 (issue 0058 — issues/0058-v2-sandbox-gap-probes.md), settling open
+// V2 wire behaviors that the pending designs 0060 (multi-domain auto-resolve), 0062 (inline
+// CSR lifecycle) and 0063 (UCC CSR SAN shape) depend on. All five probes place a real V2 SSL
+// order on the sandbox, record what CERTInext does with it (never asserting on the CA's own
+// answer — that is the finding, not a test failure), then cancel it in a `finally` and confirm
+// the cancellation with a fresh read-only GET.
+//
+// PRE — read-only. Tracks the existing UCC order 9295677273 (already placed in issue 0047)
+// and logs its status/domain, so the probes below have a live wire-shape baseline
+// before placing anything new.
+// P1 — productVariant:"ov" + one additionalDomains entry + an organization block, with NO
+// X-Product-Code header. Does auto-resolve pick an OV UCC product? Records whatever
+// the response echoes back as a resolved product code (best-effort scan — none of
+// this repo's V2 response DTOs model a productCode field on any order response).
+// P2 — the same body, with X-Product-Code pinned to the catalog's live, non-UCC OV SSL
+// product (productTypeID 16 — Constants.Products.ProductTypeIdsV2[OvSsl] = "16"),
+// resolved from the live catalog at run time (never hard-coded). Records reject
+// (HTTP + EMS code), re-route to UCC, or a silently-dropped additionalDomains.
+// P3a/P3b — a DV create with an inline "csr" field the plugin's own V2CreateSslOrderRequest
+// DTO does not model (issue 0062) — added by hand to the raw JSON body, as PEM (P3a)
+// and as headerless Base64 DER exactly like the spec's own create-order samples
+// (P3b). Records whether the order skips Constants.ApiV2.StatusPendingCsr, then
+// attempts the documented follow-up PUT .../csr and records accepted vs rejected.
+// P4 — a DV UCC order (productTypeID 15, live-resolved) with 2 additionalDomains, followed
+// by PUT .../csr with a CN-only CSR — the spec's own documented shape for a UCC CSR
+// (SANs come from the order, not the CSR). Order 9295677273 (issue 0047) already
+// confirmed a CSR carrying every SAN is accepted; this probe covers the other shape
+// the spec itself documents. Records accepted vs EMS-921/EMS-922.
+// P5 — productVariant:"dv" with X-Product-Code pinned to the live, non-UCC OV SSL product
+// (same productTypeID 16 resolution as P2), and no organization block — issue 0059's
+// variant/product mismatch. Records reject, DV, or a stalled OV-shaped order.
+//
+// Pattern: raw-body place-then-cancel, same idiom as OrganizationBlockV2ProbeTests /
+// IdempotencyKeyV2ProbeTests / EmailNotificationsV2ProbeTests — deliberately bypasses
+// CERTInextClient's typed request/response DTOs so the exact, unmodified wire body can be
+// inspected (several of this file's own findings are about fields those DTOs do not model at
+// all). The two pre-existing files' token-fetch and cancel helpers are shared via
+// V2RawProbeHelpers (issue 0058's helper-extraction requirement) rather than copied a third
+// time; every other raw-HTTP helper below (place/track/submit-CSR, and a non-throwing cancel
+// that reports rather than throws) is local to this file, matching this project's existing
+// convention of small per-probe-file helpers for the parts that are NOT shared duplicates.
+//
+// Gating (deliberately layered, same two-part mechanism as PrivatePkiV2LiveTests, plus a third
+// layer issue 0058 added to close a latent hole in V2EnvHelper itself):
+// 1. CERTINEXT_V2_GAP_PROBES=1 must be set in the real process environment. It is read here
+// BEFORE V2EnvHelper.LoadAndPromote() runs, so a value left in ~/.env_certinext_v2 can
+// never arm THIS constructor, even if V2EnvHelper went on to promote it.
+// 2. It has also been added to IntegrationTestFixture's own _optInOnlyFlags (same guard as
+// 83968ee added for CERTINEXT_PRIVATE_PKI_LIVE), so a value left in ~/.env_certinext can
+// never arm it either.
+// 3. V2EnvHelper.PromotableKeys itself now excludes every _optInOnlyFlags name (issue 0058),
+// not just the V1-shared keys it already excluded — without this, a value left in
+// ~/.env_certinext_v2 would be read as unset by whichever test class is constructed
+// FIRST in a run (this constructor runs before LoadAndPromote), but LoadAndPromote would
+// then still write 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. See
+// V1FixtureApiUrlGuardTests.PromotableKeys_ExcludesEveryOptInOnlyFlag.
+// All three layers must agree for this flag to ever be considered "on".
+// 2. CERTINEXT_INBOX_TEST_EMAIL must be set (no fallback to CERTINEXT_REQUESTOR_EMAIL, which
+// is a non-deliverable placeholder — issue 0058's own constraint). Used as both the
+// requestor email and the technicalPointOfContact email; name and phone come from the V1
+// fixture's CERTINEXT_REQUESTOR_NAME plus the fixture's own ISD/mobile defaults via
+// CERTInextCAPlugin.ComposeV2Phone (issue 0027 item 5b's phone-composition helper).
+// 3. Live V2 OAuth2 credentials (CERTINEXT_API_URL/CLIENT_ID/CLIENT_SECRET, ~/.env_certinext_v2
+// via V2EnvHelper) must be present.
+//
+// emailNotifications: every probe sends "all" (full notification set) rather than omitting the
+// field or sending "0" — the user's explicit choice for these probes (issue 0058's Constraints
+// section: "emailNotifications: confirm... whether the user wants CERTInext's emails"; resolved
+// 2026-09-29 in favor of "all"). "all" is also the only value the V2 spec's own create-order
+// examples ever show (docs/reference/specs/CERTInext API v2.postman_collection (1).json — every
+// SSL/private-pki/signature create sample sends exactly "all"; see also
+// EmailNotificationsV2ProbeTests's header comment, which independently confirms the same reading
+// of the spec text for issue 0027 item 1b).
+//
+// Domains: unique subdomains of CERTINEXT_DCV_DOMAIN (via V2EnvHelper, default "example.com"),
+// e.g. gap-p1-. — never a real customer domain. Org number:
+// CERTINEXT_ORG_NUMBER (via the V1 fixture). Every SSL create includes an `agreement` block
+// with signerPlace populated (Constants default "Gateway Lab", matching this project's sibling
+// V2 probes) — 0039 already made SignerPlace required for V2, so a raw body omitting it would
+// only test 0039's own already-answered question, not one of P1-P5.
+//
+// CSRs: BouncyCastle only (repo convention — see CLAUDE.md). No System.Security.Cryptography
+// anywhere in this file.
+//
+// Logging: every raw request/response body is passed through
+// CERTInextClient.ApplyLoggingRedaction(body, logSensitiveRequestData: false) before being
+// written via ITestOutputHelper, so the real inbox email these probes carry in
+// requestor/technicalPointOfContact is never written to a log file in the clear (issue 0058
+// constraint: "Logging: log each raw request and response through the redaction helpers
+// (0040)"; ApplyLoggingRedaction is internal — reachable here via CERTInext's
+// InternalsVisibleTo("CERTInext.IntegrationTests"), same access RedactPersonalDataTests uses).
+// Each probe ends with one ITestOutputHelper summary line: probe id, HTTP status, EMS code (if
+// any), status, orderId, cancel outcome.
+//
+// No automatic retries of any place call anywhere in this file — exactly one create call per
+// probe, matching UccPendingSanOrderProbeTests/EmailNotificationsV2ProbeTests' documented
+// no-retry rule for this sandbox (a client-side timeout does not prove the CA never processed
+// the request).
+//
+// Run (after the user's go/no-go on this design):
+// set -a; . ~/.env_certinext; set +a
+// export CERTINEXT_V2_GAP_PROBES=1
+// export CERTINEXT_INBOX_TEST_EMAIL=
+// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release -p:DcvSupport=false \
+// --filter "FullyQualifiedName~V2GapProbeTests" --logger "console;verbosity=detailed" > /tmp/v2gap.log 2>&1
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Text.Json;
+ using System.Text.Json.Nodes;
+ using System.Text.Json.Serialization;
+ using System.Text.RegularExpressions;
+ using System.Threading.Tasks;
+ using FluentAssertions;
+ using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+ 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.Pkcs;
+ using Org.BouncyCastle.Security;
+ using RestSharp;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ public class V2GapProbeTests : IClassFixture
+ {
+ private const string OptInFlag = "CERTINEXT_V2_GAP_PROBES";
+
+ /// Order placed for issue 0047's UCC CSR-shape probe; see this file's PRE probe.
+ private const string TrackedOrderId = "9295677273";
+
+ ///
+ /// 120s — matches this repo's other slow-endpoint V2 probes (EmailNotificationsV2ProbeTests/
+ /// UccDcvShapeV2ProbeTests/UccPendingSanOrderProbeTests' own NewApiClient timeout). Observed
+ /// during earlier probe authoring: an OV/OV-shaped order-create call on this sandbox can
+ /// exceed the framework's 100s default.
+ ///
+ private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(120);
+
+ ///
+ /// 300s — P1 and P2 both place an OV-shaped order (productVariant:"ov" + an
+ /// organization block + an additionalDomains entry) and both hit the 120s
+ /// as a client-side TaskCanceledException (HTTP 0)
+ /// on a live sandbox run (issue 0058 sweep follow-up — see
+ /// Sweep_FindsAndCancelsOrphanedGapProbeOrders's header comment). A client timeout does
+ /// not prove CERTInext never created the order, so raising only the OV-create timeout
+ /// (not every probe's) gives a future P1/P2 run enough headroom to get a real HTTP
+ /// response — success or CA-side rejection — back from the create call itself.
+ ///
+ private static readonly TimeSpan OvCreateProbeTimeout = TimeSpan.FromSeconds(300);
+
+ 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 string _dcvDomainBase;
+ private readonly string _inboxTestEmail;
+ private readonly bool _v2Enabled;
+
+ public V2GapProbeTests(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 (this file's
+ // constructor parameter) 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");
+ _dcvDomainBase = V2EnvHelper.GetEnv(env, "CERTINEXT_DCV_DOMAIN", "example.com");
+
+ // Never falls back to CERTINEXT_REQUESTOR_EMAIL — that placeholder is non-deliverable
+ // (issue 0058's own constraint; see also EmailNotificationsV2ProbeTests).
+ _inboxTestEmail = Environment.GetEnvironmentVariable("CERTINEXT_INBOX_TEST_EMAIL")?.Trim();
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Shared skip guards
+ // ---------------------------------------------------------------------------
+
+ private void SkipUnlessArmedAndConfigured()
+ {
+ Skip.If(!_armed,
+ $"{OptInFlag}=1 not set in the real process environment — these probes place real, " +
+ "potentially cost-bearing V2 SSL orders and require the user's explicit go/no-go " +
+ "(issue 0058). Skipping.");
+ Skip.If(!_v2Enabled,
+ "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ Skip.If(string.IsNullOrWhiteSpace(_inboxTestEmail),
+ "CERTINEXT_INBOX_TEST_EMAIL not set — set it to a real inbox you can check, " +
+ "never CERTINEXT_REQUESTOR_EMAIL (non-deliverable placeholder). Skipping.");
+ }
+
+ ///
+ /// Narrower gate than for
+ /// — that sweep lists/tracks/
+ /// cancels pre-existing orders rather than building a requestor/technicalPointOfContact
+ /// block, so it does not need CERTINEXT_INBOX_TEST_EMAIL. Still requires the same
+ /// go/no-go opt-in and live V2 credentials as P1-P5, since it can cancel real sandbox
+ /// orders.
+ ///
+ private void SkipUnlessArmedForSweep()
+ {
+ Skip.If(!_armed,
+ $"{OptInFlag}=1 not set in the real process environment — this sweep can cancel " +
+ "real sandbox orders and requires the same explicit go/no-go as P1-P5 (issue 0058). Skipping.");
+ Skip.If(!_v2Enabled,
+ "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ }
+
+ // ---------------------------------------------------------------------------
+ // PRE — read-only baseline: order 9295677273 (issue 0047)
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Read-only. Tracks order and logs its status and, if the
+ /// response carries them, its SAN(s) — a best-effort scan, since none of this repo's
+ /// response DTOs model a confirmed SAN-array field on a Track Order response (issue
+ /// 0042). Gated behind the same as P1-P5 even though it makes no
+ /// mutating call, per issue 0058's explicit instruction.
+ ///
+ [SkippableFact]
+ public async Task Pre_TrackOrder_V2_ExistingOrder9295677273_LogsStatusAndSans()
+ {
+ SkipUnlessArmedAndConfigured();
+
+ var track = await TrackOrderRawAsync(TrackedOrderId);
+
+ _output.WriteLine("=== Issue 0058 PRE probe: Track Order 9295677273 (issue 0047 baseline) ===");
+ _output.WriteLine($"HTTP {track.StatusCode}");
+ _output.WriteLine(RedactForLog(track.Body));
+
+ string status = TryExtractStringField(track.Body, "status");
+ string domain = TryExtractStringField(track.Body, "domain");
+ List sanFieldHits = ScanForKeyValues(track.Body, "domain");
+
+ _output.WriteLine("");
+ _output.WriteLine(sanFieldHits.Count > 0
+ ? $"Domain-related fields found: {string.Join("; ", sanFieldHits.Select(RedactForLog))}"
+ : "No domain-related fields found in the raw body.");
+
+ _output.WriteLine("");
+ _output.WriteLine(
+ $"SUMMARY | Probe=PRE OrderId={TrackedOrderId} HTTP={track.StatusCode} " +
+ $"Status={status ?? ""} PrimaryDomain={domain ?? ""} Cancel=N/A (read-only)");
+
+ track.StatusCode.Should().NotBe(0,
+ "the token call and the Track Order GET itself must succeed — HTTP 0 means a " +
+ "transport-level failure reaching the CA, not a CA response to record");
+ }
+
+ // ---------------------------------------------------------------------------
+ // P1 — OV + 1 additionalDomains + organization, NO X-Product-Code
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// productVariant:"ov" + one additionalDomains entry + an organization block, with NO
+ /// X-Product-Code header at all. Records whatever the CA echoes back as a resolved
+ /// product code (best-effort scan of the create/Track Order bodies).
+ ///
+ [SkippableFact]
+ public async Task P1_OvWithAdditionalDomains_NoProductCodeHeader_RecordsAutoResolve()
+ {
+ SkipUnlessArmedAndConfigured();
+
+ string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null;
+ Skip.If(string.IsNullOrWhiteSpace(organizationNumber),
+ "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — P1 needs it for the OV order's " +
+ "organization block. Skipping.");
+
+ string stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
+ string primary = $"gap-p1-{stamp}.{_dcvDomainBase}";
+ string additional = $"gap-p1-{stamp}-b.{_dcvDomainBase}";
+
+ var orderReq = new V2CreateSslOrderRequest
+ {
+ ProductVariant = "ov",
+ EmailNotifications = "all",
+ Requestor = BuildRequestor(),
+ Organization = new V2OrganizationParams { OrganizationNumber = organizationNumber, PreVetted = true },
+ Certificate = new V2CertificateParams { Domain = primary, AutoSecureWww = false, AdditionalDomains = new List { additional } },
+ Subscription = BuildSubscription(),
+ Agreement = BuildAgreement(),
+ TechnicalPointOfContact = BuildTechnicalPointOfContact(),
+ Remarks = "Issue 0058 P1 probe — OV + additionalDomains, no X-Product-Code header."
+ };
+ string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions());
+
+ await RunCreateThenCancelAsync(
+ probeId: "P1",
+ productCodeOrNull: null,
+ requestJson: requestJson,
+ primaryDomain: primary,
+ createTimeoutOverride: OvCreateProbeTimeout,
+ extraProbeWork: async (createResp, orderId) =>
+ {
+ List productCodeHits = ScanForKeyValues(createResp.Body, "productcode");
+ _output.WriteLine(productCodeHits.Count > 0
+ ? $"resolvedProductCode candidate field(s): {string.Join("; ", productCodeHits)}"
+ : "resolvedProductCode: not echoed in the create response — check the order in the CERTInext portal.");
+
+ if (!string.IsNullOrWhiteSpace(orderId))
+ {
+ var track = await TrackOrderRawAsync(orderId);
+ _output.WriteLine("");
+ _output.WriteLine("--- Track Order (post-create) ---");
+ _output.WriteLine(RedactForLog(track.Body));
+ List trackProductCodeHits = ScanForKeyValues(track.Body, "productcode");
+ if (trackProductCodeHits.Count > 0)
+ _output.WriteLine($"resolvedProductCode candidate field(s) in Track Order: {string.Join("; ", trackProductCodeHits)}");
+ }
+ });
+ }
+
+ // ---------------------------------------------------------------------------
+ // P2 — same body, X-Product-Code pinned to the live non-UCC OV SSL code
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Same body as P1, but with X-Product-Code pinned to the catalog's live, non-UCC OV
+ /// SSL product (productTypeID 16), resolved from the live catalog at run time. Records
+ /// rejected (HTTP + EMS code), re-routed to UCC, or additionalDomains silently dropped.
+ ///
+ [SkippableFact]
+ public async Task P2_OvWithAdditionalDomains_PinnedNonUccProductCode_RecordsCaResponse()
+ {
+ SkipUnlessArmedAndConfigured();
+
+ string organizationNumber = _fixture.IsConfigured ? _fixture.OrgNumber : null;
+ Skip.If(string.IsNullOrWhiteSpace(organizationNumber),
+ "CERTINEXT_ORG_NUMBER not set in ~/.env_certinext — P2 needs it for the OV order's " +
+ "organization block. Skipping.");
+
+ string ovSslProductCode = await ResolveProductCodeByTypeIdAsync(Constants.Products.ProductTypeIdsV2[Constants.Products.OvSsl]);
+ Skip.If(ovSslProductCode == null,
+ $"No live catalog product found with productTypeID=\"{Constants.Products.ProductTypeIdsV2[Constants.Products.OvSsl]}\" " +
+ "(non-UCC OV SSL) on this account — P2 cannot resolve a product code to pin. Skipping.");
+
+ string stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
+ string primary = $"gap-p2-{stamp}.{_dcvDomainBase}";
+ string additional = $"gap-p2-{stamp}-b.{_dcvDomainBase}";
+
+ var orderReq = new V2CreateSslOrderRequest
+ {
+ ProductVariant = "ov",
+ EmailNotifications = "all",
+ Requestor = BuildRequestor(),
+ Organization = new V2OrganizationParams { OrganizationNumber = organizationNumber, PreVetted = true },
+ Certificate = new V2CertificateParams { Domain = primary, AutoSecureWww = false, AdditionalDomains = new List { additional } },
+ Subscription = BuildSubscription(),
+ Agreement = BuildAgreement(),
+ TechnicalPointOfContact = BuildTechnicalPointOfContact(),
+ Remarks = "Issue 0058 P2 probe — OV + additionalDomains, X-Product-Code pinned to non-UCC OV SSL."
+ };
+ string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions());
+
+ _output.WriteLine($"Resolved non-UCC OV SSL product code from live catalog: {ovSslProductCode}");
+
+ await RunCreateThenCancelAsync(
+ probeId: "P2",
+ productCodeOrNull: ovSslProductCode,
+ requestJson: requestJson,
+ primaryDomain: primary,
+ createTimeoutOverride: OvCreateProbeTimeout,
+ extraProbeWork: async (createResp, orderId) =>
+ {
+ if (string.IsNullOrWhiteSpace(orderId))
+ {
+ _output.WriteLine("No orderId parsed — likely a hard rejection; see the create response above for the EMS code.");
+ return;
+ }
+
+ var track = await TrackOrderRawAsync(orderId);
+ _output.WriteLine("");
+ _output.WriteLine("--- Track Order (post-create) ---");
+ _output.WriteLine(RedactForLog(track.Body));
+
+ List additionalDomainHits = ScanForKeyValues(track.Body, "additionaldomains");
+ _output.WriteLine(additionalDomainHits.Count > 0
+ ? $"additionalDomains field found on Track Order: {string.Join("; ", additionalDomainHits.Select(RedactForLog))} — NOT silently dropped."
+ : "No additionalDomains field found on Track Order — either silently dropped, or the field is never echoed back for this product (compare against the create response above).");
+ });
+ }
+
+ // ---------------------------------------------------------------------------
+ // P3a / P3b — DV create with an inline "csr" field (issue 0062)
+ // ---------------------------------------------------------------------------
+
+ /// P3a — inline csr as PEM (with BEGIN/END markers).
+ [SkippableFact]
+ public async Task P3a_DvCreate_InlineCsrPem_RecordsLifecycleAndFollowUpPut()
+ {
+ SkipUnlessArmedAndConfigured();
+ await RunInlineCsrProbeAsync(probeId: "P3a", useDerEncoding: false);
+ }
+
+ /// P3b — inline csr as headerless Base64 DER, exactly like the spec's own samples.
+ [SkippableFact]
+ public async Task P3b_DvCreate_InlineCsrBase64Der_RecordsLifecycleAndFollowUpPut()
+ {
+ SkipUnlessArmedAndConfigured();
+ await RunInlineCsrProbeAsync(probeId: "P3b", useDerEncoding: true);
+ }
+
+ private async Task RunInlineCsrProbeAsync(string probeId, bool useDerEncoding)
+ {
+ string stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
+ string primary = $"gap-{probeId.ToLowerInvariant()}-{stamp}.{_dcvDomainBase}";
+
+ // Resolved live, never from the V1 fixture's CERTINEXT_PRODUCT_CODE — Constants.cs
+ // documents the V1-era numbering as wrong for V2 (issue 0036), same reasoning as
+ // P2/P4/P5's own live catalog resolution below.
+ string dvSslTypeId = Constants.Products.ProductTypeIdsV2[Constants.Products.DvSsl];
+ string productCode = await ResolveProductCodeByTypeIdAsync(dvSslTypeId);
+ Skip.If(productCode == null,
+ $"No live catalog product found with productTypeID=\"{dvSslTypeId}\" (DV SSL) on " +
+ $"this account — {probeId} cannot resolve a product code. Skipping.");
+
+ var csr = GenerateCsr(primary);
+ string inlineCsrValue = useDerEncoding ? CsrToBase64Der(csr) : CsrToPem(csr);
+
+ var orderReq = new V2CreateSslOrderRequest
+ {
+ ProductVariant = "dv",
+ EmailNotifications = "all",
+ Requestor = BuildRequestor(),
+ Certificate = new V2CertificateParams { Domain = primary, AutoSecureWww = false },
+ Subscription = BuildSubscription(),
+ Agreement = BuildAgreement(),
+ TechnicalPointOfContact = BuildTechnicalPointOfContact(),
+ Remarks = $"Issue 0058 {probeId} probe — DV create with an inline csr field " +
+ $"({(useDerEncoding ? "Base64 DER, no PEM markers" : "PEM")})."
+ };
+ string baseJson = JsonSerializer.Serialize(orderReq, GetJsonOptions());
+
+ // V2CreateSslOrderRequest has no "csr" property (issue 0062 — the gap this probe
+ // exists to test) — added by hand onto the serialized JSON so the exact wire body
+ // matches what a hand-authored request could send.
+ JsonNode node = JsonNode.Parse(baseJson)!;
+ node["csr"] = inlineCsrValue;
+ string requestJson = node.ToJsonString();
+
+ _output.WriteLine($"Resolved DV SSL product code from live catalog: {productCode}");
+
+ // Routed through RunCreateThenCancelAsync (rather than place/PUT/cancel inline) so
+ // the cancel-in-finally guarantee applies to the follow-up PUT and its token fetch
+ // too — either one throwing used to skip cleanup entirely.
+ string putStatus = "not attempted (no orderId)";
+ await RunCreateThenCancelAsync(
+ probeId: probeId,
+ productCodeOrNull: productCode,
+ requestJson: requestJson,
+ primaryDomain: primary,
+ extraProbeWork: async (createResp, orderId) =>
+ {
+ string createStatus = TryExtractStringField(createResp.Body, "status");
+ bool skippedPendingCsr = !string.IsNullOrWhiteSpace(createStatus)
+ && !string.Equals(createStatus, Constants.ApiV2.StatusPendingCsr, StringComparison.OrdinalIgnoreCase);
+ _output.WriteLine("");
+ _output.WriteLine(!string.IsNullOrWhiteSpace(createStatus)
+ ? $"Order status after create: \"{createStatus}\" — {(skippedPendingCsr ? "skips" : "does NOT skip")} \"{Constants.ApiV2.StatusPendingCsr}\"."
+ : "No status field parsed from the create response.");
+
+ if (string.IsNullOrWhiteSpace(orderId))
+ return;
+
+ var freshCsr = GenerateCsr(primary);
+ var putResp = await SubmitCsrRawAsync(orderId, CsrToPem(freshCsr));
+ _output.WriteLine("");
+ _output.WriteLine("--- Follow-up PUT .../csr ---");
+ _output.WriteLine($"HTTP {putResp.StatusCode}");
+ _output.WriteLine(RedactForLog(putResp.Body));
+ putStatus = putResp.IsSuccessful
+ ? "ACCEPTED"
+ : $"REJECTED (HTTP {putResp.StatusCode}, EMS={TryExtractEmsCode(putResp.Body) ?? ""})";
+ });
+
+ _output.WriteLine($"[{probeId}] FollowUpPut={putStatus}");
+ }
+
+ // ---------------------------------------------------------------------------
+ // P4 — DV UCC order + 2 additionalDomains, then PUT .../csr with a CN-only CSR
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Places a DV UCC order (productTypeID 15, live-resolved) with 2 additionalDomains,
+ /// then submits a CN-only CSR (the spec's own documented UCC CSR shape — SANs come from
+ /// the order, not the CSR). Records accepted vs EMS-921/EMS-922.
+ ///
+ [SkippableFact]
+ public async Task P4_DvUccOrder_TwoAdditionalDomains_CnOnlyCsr_RecordsAcceptance()
+ {
+ SkipUnlessArmedAndConfigured();
+
+ string uccProductCode = await ResolveProductCodeByTypeIdAsync(Constants.Products.ProductTypeIdsV2[Constants.Products.DvSslUcc]);
+ Skip.If(uccProductCode == null,
+ $"No live catalog product found with productTypeID=\"{Constants.Products.ProductTypeIdsV2[Constants.Products.DvSslUcc]}\" " +
+ "(DV SSL UCC) on this account — P4 cannot resolve a product code. Skipping.");
+
+ string stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
+ string primary = $"gap-p4-{stamp}.{_dcvDomainBase}";
+ string sanA = $"gap-p4-{stamp}-a.{_dcvDomainBase}";
+ string sanB = $"gap-p4-{stamp}-b.{_dcvDomainBase}";
+
+ var orderReq = new V2CreateSslOrderRequest
+ {
+ ProductVariant = "dv",
+ EmailNotifications = "all",
+ Requestor = BuildRequestor(),
+ Certificate = new V2CertificateParams { Domain = primary, AutoSecureWww = false, AdditionalDomains = new List { sanA, sanB } },
+ Subscription = BuildSubscription(),
+ Agreement = BuildAgreement(),
+ TechnicalPointOfContact = BuildTechnicalPointOfContact(),
+ Remarks = "Issue 0058 P4 probe — DV UCC + 2 additionalDomains, CN-only follow-up CSR."
+ };
+ string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions());
+
+ _output.WriteLine($"Resolved DV SSL UCC product code from live catalog: {uccProductCode}");
+
+ await RunCreateThenCancelAsync(
+ probeId: "P4",
+ productCodeOrNull: uccProductCode,
+ requestJson: requestJson,
+ primaryDomain: primary,
+ extraProbeWork: async (createResp, orderId) =>
+ {
+ if (string.IsNullOrWhiteSpace(orderId))
+ {
+ _output.WriteLine("No orderId parsed — the CN-only CSR follow-up cannot be attempted.");
+ return;
+ }
+
+ var cnOnlyCsr = GenerateCsr(primary);
+ var putResp = await SubmitCsrRawAsync(orderId, CsrToPem(cnOnlyCsr));
+ _output.WriteLine("");
+ _output.WriteLine("--- PUT .../csr with a CN-only CSR (spec's documented UCC shape) ---");
+ _output.WriteLine($"HTTP {putResp.StatusCode}");
+ _output.WriteLine(RedactForLog(putResp.Body));
+ string putEms = TryExtractEmsCode(putResp.Body);
+ _output.WriteLine(putResp.IsSuccessful
+ ? "CN-only CSR ACCEPTED."
+ : $"CN-only CSR REJECTED — EMS={putEms ?? ""}. " +
+ "Compare against EMS-921/EMS-922 in v2-api-support-questions.md.");
+ });
+ }
+
+ // ---------------------------------------------------------------------------
+ // P5 — productVariant:"dv" + X-Product-Code pinned to the live OV SSL code, no organization
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// productVariant:"dv" with X-Product-Code pinned to the catalog's live, non-UCC OV SSL
+ /// product (productTypeID 16, same resolution as P2) and no organization block — issue
+ /// 0059's variant/product mismatch. Records reject, DV, or a stalled OV-shaped order.
+ ///
+ [SkippableFact]
+ public async Task P5_DvVariant_PinnedOvProductCode_NoOrganization_RecordsMismatchBehavior()
+ {
+ SkipUnlessArmedAndConfigured();
+
+ string ovSslProductCode = await ResolveProductCodeByTypeIdAsync(Constants.Products.ProductTypeIdsV2[Constants.Products.OvSsl]);
+ Skip.If(ovSslProductCode == null,
+ $"No live catalog product found with productTypeID=\"{Constants.Products.ProductTypeIdsV2[Constants.Products.OvSsl]}\" " +
+ "(non-UCC OV SSL) on this account — P5 cannot resolve a product code to pin. Skipping.");
+
+ string stamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
+ string primary = $"gap-p5-{stamp}.{_dcvDomainBase}";
+
+ var orderReq = new V2CreateSslOrderRequest
+ {
+ ProductVariant = "dv", // deliberately mismatched against the pinned OV product code
+ EmailNotifications = "all",
+ Requestor = BuildRequestor(),
+ // Deliberately NO Organization block — issue 0059's exact mismatch shape.
+ Certificate = new V2CertificateParams { Domain = primary, AutoSecureWww = false },
+ Subscription = BuildSubscription(),
+ Agreement = BuildAgreement(),
+ TechnicalPointOfContact = BuildTechnicalPointOfContact(),
+ Remarks = "Issue 0058 P5 probe — productVariant:dv, X-Product-Code pinned to non-UCC OV SSL, no organization."
+ };
+ string requestJson = JsonSerializer.Serialize(orderReq, GetJsonOptions());
+
+ _output.WriteLine($"Resolved non-UCC OV SSL product code from live catalog: {ovSslProductCode}");
+
+ await RunCreateThenCancelAsync(
+ probeId: "P5",
+ productCodeOrNull: ovSslProductCode,
+ requestJson: requestJson,
+ primaryDomain: primary,
+ extraProbeWork: async (createResp, orderId) =>
+ {
+ if (string.IsNullOrWhiteSpace(orderId))
+ {
+ _output.WriteLine("No orderId parsed — likely a hard EMS-915-style reject; see the create response above.");
+ return;
+ }
+
+ var track = await TrackOrderRawAsync(orderId);
+ _output.WriteLine("");
+ _output.WriteLine("--- Track Order (post-create) ---");
+ _output.WriteLine(RedactForLog(track.Body));
+ string echoedVariant = TryExtractStringField(track.Body, "productVariant");
+ _output.WriteLine($"productVariant echoed on Track Order: {echoedVariant ?? ""} " +
+ "(compare against the pinned OV product code above to see which one 'won').");
+ });
+ }
+
+ // ---------------------------------------------------------------------------
+ // Sweep — find and clean up orders P1-P5 may have orphaned on the sandbox
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// P1 and P2 both place an OV-shaped order (productVariant:"ov" + an organization block
+ /// + one additionalDomains entry) and both hit as a
+ /// client-side TaskCanceledException (HTTP 0) on a live sandbox run. A client
+ /// timeout does not prove CERTInext never created the order — if it did, that order is
+ /// now orphaned on the sandbox with no CARequestID ever recorded by this test
+ /// process. This sweep answers that by listing the V2 orders report for "today" (UTC),
+ /// finding every row whose domainName starts with "gap-p" (covers all of
+ /// P1-P5's own domain naming, not just P1/P2 — any of them could have left an orphan
+ /// the same way), tracking each one raw, and cancelling it if it is not already
+ /// cancelled/revoked/rejected — confirming with a fresh GET afterward.
+ ///
+ /// Read-only discovery, not a mutation gate: gated by the same +
+ /// V2-credentials guard as P1-P5 (this sweep can cancel real sandbox orders), but — unlike
+ /// — it does NOT require
+ /// CERTINEXT_INBOX_TEST_EMAIL: it never builds a requestor/technicalPointOfContact block,
+ /// so the placeholder email otherwise threads through
+ /// (CERTInextConfig.RequestorEmail) is immaterial to a report-list/track/cancel-only run.
+ ///
+ /// Uses GET /api/certinext/v2/reports/orders via the already-typed
+ /// (paging handled internally,
+ /// domainName confirmed live per 's own
+ /// doc comment) rather than hand-rolling pagination against
+ /// a second time. If that call throws, the
+ /// finding is logged (nothing else in the report envelope carries a domain value to fall
+ /// back to) and the exception is rethrown — a report failure is a probe-mechanism break,
+ /// not a CA-response finding.
+ ///
+ [SkippableFact]
+ public async Task Sweep_FindsAndCancelsOrphanedGapProbeOrders()
+ {
+ SkipUnlessArmedForSweep();
+
+ DateTime todayUtc = DateTime.UtcNow.Date;
+ string from = todayUtc.ToString("yyyy-MM-dd");
+ string to = todayUtc.AddDays(1).ToString("yyyy-MM-dd"); // +1 day margin — Probe3 confirmed from/to are inclusive date brackets.
+
+ _output.WriteLine("=== Issue 0058 sweep: orphaned gap-probe orders ===");
+ _output.WriteLine($"Report window: GET {Constants.ApiV2.OrdersReportPath}?from={from}&to={to} (UTC 'today' + 1 day margin).");
+
+ using CERTInextClient client = BuildV2Client();
+
+ var candidates = new List();
+ int rowsScanned = 0;
+ try
+ {
+ await foreach (var row in client.ListOrdersV2Async(from, to, pageSize: 100))
+ {
+ rowsScanned++;
+ if (!string.IsNullOrWhiteSpace(row.DomainName) &&
+ row.DomainName.StartsWith("gap-p", StringComparison.OrdinalIgnoreCase))
+ {
+ candidates.Add(row);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"FINDING: GET {Constants.ApiV2.OrdersReportPath} (paged) threw: " +
+ $"{ex.GetType().Name}: {RedactForLog(ex.Message)}");
+ _output.WriteLine("Tried: OrderReportEntryV2.DomainName (confirmed-live field) via " +
+ "CERTInextClient.ListOrdersV2Async, paging page=1.. with size=100, " +
+ $"from={from}&to={to}. No other field on this report row models a " +
+ "domain value to fall back to.");
+ throw;
+ }
+
+ _output.WriteLine($"Report rows scanned: {rowsScanned}. Candidates (domainName starts with \"gap-p\"): {candidates.Count}.");
+
+ bool foundP1 = false, foundP2 = false;
+ bool anyCancelFailed = false;
+
+ foreach (var row in candidates)
+ {
+ string orderId = row.OrderNumber;
+ string domainLower = row.DomainName?.ToLowerInvariant() ?? string.Empty;
+ if (domainLower.StartsWith("gap-p1-")) foundP1 = true;
+ if (domainLower.StartsWith("gap-p2-")) foundP2 = true;
+
+ _output.WriteLine("");
+ _output.WriteLine($"--- Candidate: OrderId={orderId ?? ""} Domain={RedactForLog(row.DomainName)} " +
+ $"OrderStatus={row.OrderStatus} CertificateStatus={row.CertificateStatus} OrderDate={row.OrderDate} ---");
+
+ if (string.IsNullOrWhiteSpace(orderId))
+ {
+ _output.WriteLine("No orderNumber on this report row — cannot track or cancel it. Skipping.");
+ _output.WriteLine($"SUMMARY | OrderId= Domain={RedactForLog(row.DomainName)} " +
+ "StatusBefore= StatusAfter= ProductVariant= " +
+ "ResolvedProductCode= AdditionalDomainsPresent=False Cancel=SKIPPED (no orderNumber)");
+ continue;
+ }
+
+ var before = await TrackOrderRawAsync(orderId);
+ _output.WriteLine($"Track (before): HTTP {before.StatusCode}");
+ _output.WriteLine(RedactForLog(before.Body));
+
+ string statusBefore = TryExtractStringField(before.Body, "status");
+ string productVariant = TryExtractStringField(before.Body, "productVariant");
+ List productCodeHits = ScanForKeyValues(before.Body, "productcode");
+ List additionalDomainHits = ScanForKeyValues(before.Body, "additionaldomains");
+ List domainHits = ScanForKeyValues(before.Body, "domain");
+ string resolvedProductCode = productCodeHits.Count > 0
+ ? string.Join("; ", productCodeHits)
+ : "";
+
+ _output.WriteLine($"StatusBefore={statusBefore ?? ""} ProductVariant={productVariant ?? ""} " +
+ $"ResolvedProductCode={resolvedProductCode}");
+ _output.WriteLine(additionalDomainHits.Count > 0
+ ? $"additionalDomains field(s) found: {string.Join("; ", additionalDomainHits.Select(RedactForLog))}"
+ : "No additionalDomains field found on Track Order.");
+ _output.WriteLine(domainHits.Count > 0
+ ? $"domain-related field(s) found: {string.Join("; ", domainHits.Select(RedactForLog))}"
+ : "No domain-related field found on Track Order.");
+
+ bool alreadyTerminal =
+ string.Equals(statusBefore, Constants.ApiV2.StatusCancelled, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(statusBefore, Constants.ApiV2.StatusRevoked, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(statusBefore, Constants.ApiV2.StatusRejected, StringComparison.OrdinalIgnoreCase);
+
+ string statusAfter = statusBefore;
+ string cancelOutcome;
+
+ if (alreadyTerminal)
+ {
+ cancelOutcome = $"SKIPPED (already {statusBefore})";
+ _output.WriteLine($"Order is already terminal ({statusBefore}) — not cancelling.");
+ }
+ else
+ {
+ try
+ {
+ V2CancelOrderOutcome outcome = await client.CancelOrderV2Async(
+ Constants.ApiV2.FamilySsl,
+ orderId,
+ "Issue 0058 sweep — cancelling an orphaned gap-probe order found via the orders report.");
+ cancelOutcome = outcome.ToString();
+ _output.WriteLine($"Cancel outcome: {cancelOutcome}");
+
+ var after = await TrackOrderRawAsync(orderId);
+ statusAfter = TryExtractStringField(after.Body, "status");
+ _output.WriteLine($"Track (after): HTTP {after.StatusCode}");
+ _output.WriteLine(RedactForLog(after.Body));
+ }
+ catch (Exception ex)
+ {
+ cancelOutcome = $"FAILED ({ex.GetType().Name}: {RedactForLog(ex.Message)})";
+ statusAfter = "";
+ anyCancelFailed = true;
+ _output.WriteLine($"Cancel call FAILED — not retried: {RedactForLog(ex.Message)}");
+ }
+ }
+
+ _output.WriteLine(
+ $"SUMMARY | OrderId={orderId} Domain={RedactForLog(row.DomainName)} " +
+ $"StatusBefore={statusBefore ?? ""} StatusAfter={statusAfter ?? ""} " +
+ $"ProductVariant={productVariant ?? ""} ResolvedProductCode={resolvedProductCode} " +
+ $"AdditionalDomainsPresent={additionalDomainHits.Count > 0} Cancel={cancelOutcome}");
+ }
+
+ _output.WriteLine("");
+ _output.WriteLine(
+ $"SUMMARY | Sweep complete. RowsScanned={rowsScanned} CandidatesFound={candidates.Count} " +
+ $"P1Found={foundP1} P2Found={foundP2}");
+
+ anyCancelFailed.Should().BeFalse(
+ "one or more gap-probe orders could not be cancelled during the sweep — see the FAILED " +
+ "cancel outcome(s) logged above; per issue 0058's own rule, this sweep does not retry a " +
+ "failed cancel automatically.");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Shared create-then-cancel runner
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Places exactly one raw SSL create-order call, logs and records the outcome, invokes
+ /// for any probe-specific follow-up read/write, then
+ /// unconditionally cancels the order in a finally and confirms cancellation with
+ /// a fresh read-only GET — even if throws. Emits the
+ /// probe's one-line summary at the end.
+ ///
+ private async Task RunCreateThenCancelAsync(
+ string probeId,
+ string productCodeOrNull,
+ string requestJson,
+ string primaryDomain,
+ Func extraProbeWork,
+ TimeSpan? createTimeoutOverride = null)
+ {
+ _output.WriteLine($"=== Issue 0058 {probeId} probe ===");
+ _output.WriteLine($"Domain={primaryDomain} ProductCode={productCodeOrNull ?? ""}");
+ _output.WriteLine($"Request body: {RedactForLog(requestJson)}");
+
+ string orderId = null;
+ RawApiResponse createResp = null;
+ try
+ {
+ createResp = await PlaceSslOrderRawAsync(productCodeOrNull, requestJson, createTimeoutOverride);
+ _output.WriteLine("");
+ _output.WriteLine("--- Create-order response ---");
+ _output.WriteLine($"HTTP {createResp.StatusCode}");
+ _output.WriteLine(RedactForLog(createResp.Body));
+
+ orderId = TryExtractOrderId(createResp.Body);
+
+ if (extraProbeWork != null)
+ await extraProbeWork(createResp, orderId);
+ }
+ finally
+ {
+ await CancelAndConfirmAsync(probeId, orderId);
+ }
+
+ string status = createResp != null ? TryExtractStringField(createResp.Body, "status") : null;
+ string emsCode = createResp != null ? TryExtractEmsCode(createResp.Body) : null;
+ _output.WriteLine("");
+ _output.WriteLine(
+ $"SUMMARY | Probe={probeId} HTTP={createResp?.StatusCode.ToString() ?? ""} " +
+ $"EMS={emsCode ?? ""} Status={status ?? ""} OrderId={orderId ?? ""}");
+ }
+
+ ///
+ /// Cancels (no-op, logged, if null/empty — some probes never
+ /// get an orderId back), then always does a fresh read-only GET afterward so cleanup is
+ /// verifiable regardless of the cancel outcome. A failed cancel call itself (not merely
+ /// a non-2xx CANCEL response — see below) or a failed confirming GET is a probe-mechanism
+ /// break and is asserted; the CA's own cancel response code is logged either way.
+ ///
+ private async Task CancelAndConfirmAsync(string probeId, string orderId)
+ {
+ if (string.IsNullOrWhiteSpace(orderId))
+ {
+ _output.WriteLine("");
+ _output.WriteLine($"[{probeId}] No orderId to cancel — nothing to clean up.");
+ return;
+ }
+
+ _output.WriteLine("");
+ _output.WriteLine($"[{probeId}] Cancelling order {orderId}...");
+ var cancelResp = await CancelOrderRawAsync(orderId,
+ $"Issue 0058 {probeId} probe — cleaning up after recording the CA's response.");
+ _output.WriteLine($"Cancel response: HTTP {cancelResp.StatusCode}: {RedactForLog(cancelResp.Body)}");
+
+ var after = await TrackOrderRawAsync(orderId);
+ _output.WriteLine($"Post-cancel status (fresh GET): HTTP {after.StatusCode}: {RedactForLog(after.Body)}");
+ string afterStatus = TryExtractStringField(after.Body, "status");
+
+ cancelResp.IsSuccessful.Should().BeTrue(
+ $"[{probeId}] cleanup must succeed to avoid leaving a live order on the sandbox — " +
+ $"HTTP {cancelResp.StatusCode}: {RedactForLog(cancelResp.Body)}");
+
+ _output.WriteLine($"[{probeId}] Cancel outcome confirmed — post-cancel status: {afterStatus ?? ""}.");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Shared request-body builders
+ // ---------------------------------------------------------------------------
+
+ private string RequestorName() => _fixture.IsConfigured && !string.IsNullOrWhiteSpace(_fixture.Config.RequestorName)
+ ? _fixture.Config.RequestorName
+ : "Keyfactor Test";
+
+ private string RequestorPhone() => _fixture.IsConfigured
+ ? CERTInextCAPlugin.ComposeV2Phone(_fixture.Config.RequestorIsdCode, _fixture.Config.RequestorMobileNumber)
+ : "+10000000000";
+
+ private V2Requestor BuildRequestor() => new V2Requestor
+ {
+ Name = RequestorName(),
+ Email = _inboxTestEmail,
+ Phone = RequestorPhone(),
+ Designation = "IT Administrator"
+ };
+
+ private V2TechnicalPointOfContact BuildTechnicalPointOfContact() => new V2TechnicalPointOfContact
+ {
+ Name = RequestorName(),
+ Email = _inboxTestEmail,
+ Phone = RequestorPhone(),
+ Designation = "Technical Contact"
+ };
+
+ private V2SubscriptionParams BuildSubscription() => new V2SubscriptionParams
+ {
+ ValidityYears = 1,
+ AutoRenew = false,
+ RenewBeforeDays = 30
+ };
+
+ private V2AgreementParams BuildAgreement() => new V2AgreementParams
+ {
+ SignerName = RequestorName(),
+ SignerIp = "127.0.0.1",
+ SignerPlace = "Gateway Lab",
+ Accepted = true
+ };
+
+ // ---------------------------------------------------------------------------
+ // Live catalog resolution (never hard-code a pinned code — issue 0058's own rule for P2,
+ // applied here to P4/P5 too for the same reason)
+ // ---------------------------------------------------------------------------
+
+ private CERTInextClient BuildV2Client() => new CERTInextClient(new CERTInextConfig
+ {
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ OAuthClientId = _v2ClientId,
+ OAuthClientSecret = _v2ClientSecret,
+ RequestorName = RequestorName(),
+ RequestorEmail = _inboxTestEmail,
+ SignerIp = "127.0.0.1",
+ SignerPlace = "Gateway Lab",
+ PageSize = 100
+ });
+
+ private async Task ResolveProductCodeByTypeIdAsync(string productTypeId)
+ {
+ using CERTInextClient client = BuildV2Client();
+ List catalog = await client.GetProductDetailsV2Async();
+ return catalog.FirstOrDefault(p => string.Equals(p.ProductTypeId, productTypeId, StringComparison.OrdinalIgnoreCase))
+ ?.ProductCode;
+ }
+
+ // ---------------------------------------------------------------------------
+ // BouncyCastle CSR generation (repo convention — never System.Security.Cryptography)
+ // ---------------------------------------------------------------------------
+
+ private static Pkcs10CertificationRequest GenerateCsr(string commonName)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
+
+ var subject = new X509Name($"CN={commonName}");
+ return new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private);
+ }
+
+ private static string CsrToPem(Pkcs10CertificationRequest csr) =>
+ "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+
+ /// Headerless Base64 DER — matches the V2 spec's own inline "csr" create-order samples (no PEM markers, no line breaks).
+ private static string CsrToBase64Der(Pkcs10CertificationRequest csr) =>
+ Convert.ToBase64String(csr.GetEncoded());
+
+ // ---------------------------------------------------------------------------
+ // Local raw-HTTP helpers (place/track/submit-csr/non-throwing-cancel) — NOT extracted
+ // to V2RawProbeHelpers: only the token-fetch and throwing CancelSslOrderRawAsync were
+ // shared duplicates across files (issue 0058's explicit ask); these are specific to this
+ // file's non-throwing, raw-status/body-returning needs.
+ // ---------------------------------------------------------------------------
+
+ private sealed class RawApiResponse
+ {
+ public int StatusCode { get; set; }
+ public string Body { get; set; }
+ public bool IsSuccessful { get; set; }
+ }
+
+ private static RawApiResponse ToRawApiResponse(RestResponse resp)
+ {
+ string body = resp.Content;
+ if (string.IsNullOrEmpty(body) && !resp.IsSuccessful)
+ {
+ body = resp.ErrorException != null
+ ? $""
+ : $"";
+ }
+
+ return new RawApiResponse
+ {
+ StatusCode = (int)resp.StatusCode,
+ Body = body,
+ IsSuccessful = resp.IsSuccessful
+ };
+ }
+
+ ///
+ /// Raw HTTP POST to /api/certinext/v2/ssl-certificates. When
+ /// is null, the X-Product-Code header is omitted
+ /// entirely (not sent empty) — P1's exact probe condition. Does not throw on non-2xx.
+ /// defaults to ; P1/P2 pass
+ /// instead (see that field's comment).
+ ///
+ private async Task PlaceSslOrderRawAsync(
+ string productCodeOrNull, string requestJson, TimeSpan? timeoutOverride = null)
+ {
+ TimeSpan timeout = timeoutOverride ?? ProbeTimeout;
+ string accessToken = await V2RawProbeHelpers.GetV2AccessTokenAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret, timeout);
+
+ using var apiClient = V2RawProbeHelpers.NewApiClient(_v2ApiUrl.TrimEnd('/'), timeout);
+ var req = new RestRequest(Constants.ApiV2.SslCertificatesPath, Method.Post);
+ req.AddHeader("Authorization", $"Bearer {accessToken}");
+ req.AddHeader("Accept", "application/json");
+ if (productCodeOrNull != null)
+ req.AddHeader("X-Product-Code", productCodeOrNull);
+ req.AddHeader("Idempotency-Key", Guid.NewGuid().ToString());
+ req.AddJsonBody(requestJson);
+
+ var resp = await apiClient.ExecuteAsync(req);
+ return ToRawApiResponse(resp);
+ }
+
+ private async Task TrackOrderRawAsync(string orderId)
+ {
+ string accessToken = await V2RawProbeHelpers.GetV2AccessTokenAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret, ProbeTimeout);
+
+ using var apiClient = V2RawProbeHelpers.NewApiClient(_v2ApiUrl.TrimEnd('/'), ProbeTimeout);
+ var req = new RestRequest($"{Constants.ApiV2.SslCertificatesPath}/{orderId}", Method.Get);
+ req.AddHeader("Authorization", $"Bearer {accessToken}");
+ req.AddHeader("Accept", "application/json");
+
+ var resp = await apiClient.ExecuteAsync(req);
+ return ToRawApiResponse(resp);
+ }
+
+ ///
+ /// Raw HTTP PUT to /api/certinext/v2/ssl-certificates/{orderId}/csr — the same
+ /// { "csr", "attested" } body shape as V2SubmitCsrRequest, sent raw (rather than via
+ /// CERTInextClient.SubmitCsrV2Async, which throws on failure) so a rejection's exact
+ /// HTTP status and EMS code can be recorded rather than only an exception message.
+ ///
+ private async Task SubmitCsrRawAsync(string orderId, string csrPem, bool attested = false)
+ {
+ string accessToken = await V2RawProbeHelpers.GetV2AccessTokenAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret, ProbeTimeout);
+
+ using var apiClient = V2RawProbeHelpers.NewApiClient(_v2ApiUrl.TrimEnd('/'), ProbeTimeout);
+ var req = new RestRequest($"{Constants.ApiV2.SslCertificatesPath}/{orderId}/csr", Method.Put);
+ req.AddHeader("Authorization", $"Bearer {accessToken}");
+ req.AddHeader("Accept", "application/json");
+ req.AddJsonBody(JsonSerializer.Serialize(new V2SubmitCsrRequest { Csr = csrPem, Attested = attested }, GetJsonOptions()));
+
+ var resp = await apiClient.ExecuteAsync(req);
+ return ToRawApiResponse(resp);
+ }
+
+ ///
+ /// Raw HTTP POST to /api/certinext/v2/ssl-certificates/{orderId}/cancel — non-throwing
+ /// (unlike V2RawProbeHelpers.CancelSslOrderRawAsync, which throws) so cleanup failure can
+ /// be logged and asserted with full detail rather than only an exception message.
+ ///
+ private async Task CancelOrderRawAsync(string orderId, string reason)
+ {
+ string accessToken = await V2RawProbeHelpers.GetV2AccessTokenAsync(_v2ApiUrl, _v2ClientId, _v2ClientSecret, ProbeTimeout);
+
+ using var apiClient = V2RawProbeHelpers.NewApiClient(_v2ApiUrl.TrimEnd('/'), ProbeTimeout);
+ var req = new RestRequest($"{Constants.ApiV2.SslCertificatesPath}/{orderId}/cancel", Method.Post);
+ req.AddHeader("Authorization", $"Bearer {accessToken}");
+ req.AddHeader("Accept", "application/json");
+ req.AddJsonBody(new { reason });
+
+ var resp = await apiClient.ExecuteAsync(req);
+ return ToRawApiResponse(resp);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Parsing / redaction helpers
+ // ---------------------------------------------------------------------------
+
+ private static JsonSerializerOptions GetJsonOptions() => new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ ///
+ /// Redacts credentials and personal data (emails/names) before any raw body reaches
+ /// ITestOutputHelper — issue 0058's logging constraint. logSensitiveRequestData:
+ /// false always, so the real inbox email this file carries is never written to a
+ /// log file in the clear, matching the repo-wide default-off PII posture (see
+ /// CERTInextClient.ApplyLoggingRedaction; reachable here via
+ /// InternalsVisibleTo("CERTInext.IntegrationTests")). Domain names are never touched by
+ /// this redaction — only email/other-personal-data fields are — so the DCV/order-shape
+ /// detail these probes exist to observe is unaffected.
+ ///
+ private static string RedactForLog(string body) =>
+ CERTInextClient.ApplyLoggingRedaction(body, logSensitiveRequestData: false);
+
+ private static string TryExtractOrderId(string body)
+ {
+ if (string.IsNullOrWhiteSpace(body)) return null;
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ return doc.RootElement.TryGetProperty("orderId", out var el) ? el.GetString() : null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static string TryExtractStringField(string body, string fieldName)
+ {
+ if (string.IsNullOrWhiteSpace(body)) return null;
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ return doc.RootElement.TryGetProperty(fieldName, out var el) && el.ValueKind == JsonValueKind.String
+ ? el.GetString()
+ : null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static readonly Regex EmsCodeRegex = new Regex(@"\[?EMS-(\d+)\]?", RegexOptions.Compiled);
+
+ private static string TryExtractEmsCode(string body)
+ {
+ if (string.IsNullOrWhiteSpace(body)) return null;
+ var match = EmsCodeRegex.Match(body);
+ return match.Success ? $"EMS-{match.Groups[1].Value}" : null;
+ }
+
+ ///
+ /// Best-effort recursive scan of a raw JSON body for any property whose name contains
+ /// (case-insensitive), returning "name=value" for
+ /// each hit. Used where this repo's response DTOs do not model a confirmed field for
+ /// what a probe needs to check (e.g. no productCode on any order response, no confirmed
+ /// additionalDomains echo — issues 0042/0058). Never throws; returns an empty list for
+ /// unparseable or empty bodies.
+ ///
+ private static List ScanForKeyValues(string body, string keyNameSubstring)
+ {
+ var found = new List();
+ if (string.IsNullOrWhiteSpace(body)) return found;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ Walk(doc.RootElement, keyNameSubstring, found);
+ }
+ catch (JsonException)
+ {
+ // Unparseable body — nothing to scan; caller already logs the raw body separately.
+ }
+
+ return found;
+
+ static void Walk(JsonElement element, string needle, List accumulator)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ foreach (JsonProperty prop in element.EnumerateObject())
+ {
+ if (prop.Name.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
+ accumulator.Add($"{prop.Name}={prop.Value}");
+ Walk(prop.Value, needle, accumulator);
+ }
+ break;
+ case JsonValueKind.Array:
+ foreach (JsonElement item in element.EnumerateArray())
+ Walk(item, needle, accumulator);
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/V2LifecycleTests.cs b/CERTInext.IntegrationTests/V2LifecycleTests.cs
new file mode 100644
index 0000000..d90c198
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2LifecycleTests.cs
@@ -0,0 +1,777 @@
+// 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 _v2ProductCode;
+ 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");
+ _v2ProductCode = V2EnvHelper.GetEnv(env, "CERTINEXT_PRODUCT_CODE", "842");
+ _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
+ /// now serves both modes (issues/0022 config
+ /// consolidation) — 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,
+ 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. See issues/0022's
+ // "V2 sync per-row download cost" note.
+ 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;
+ }
+
+ private EnrollmentProductInfo BuildV2ProductInfo() =>
+ new EnrollmentProductInfo
+ {
+ ProductID = _v2ProductCode,
+ ProductParameters = new Dictionary
+ {
+ [Constants.EnrollmentParam.ProductCode] = _v2ProductCode,
+ [Constants.EnrollmentParam.ProfileId] = _v2ProductCode,
+ }
+ };
+
+ ///
+ /// 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 (see issues/0017, gap G7).
+ ///
+ 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 (V2_TEST_GAP_PLAN.md Phase 1.4b). 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()
+ {
+ var plugin = BuildV2Plugin();
+ string envOrderId = ResolveOrderId();
+ 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(),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ enrollResult.Should().NotBeNull();
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace();
+ _output.WriteLine(
+ $"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);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 1 — Enroll() via the plugin, V2 path
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Enroll_V2_ReturnsCARequestID()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ var plugin = BuildV2Plugin();
+
+ var result = await plugin.Enroll(
+ csr: GenerateCsrPem(_v2Domain),
+ subject: $"CN={_v2Domain}",
+ san: new Dictionary { ["dns"] = new[] { _v2Domain } },
+ productInfo: BuildV2ProductInfo(),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ 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}");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 2 — Revoke() via the plugin, V2 path
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Opt-in cleanup/probe: 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 (issues/0049).
+ /// 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);
+ }
+
+ [SkippableFact]
+ public async Task Revoke_V2_IssuedOrder_ReturnsRevoked()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ var (orderId, plugin) = await EnsureIssuedOrderIdAsync();
+
+ 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 (issues/0019).
+ // 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");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 3 — 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}");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 4 — Enroll -> Synchronize -> Revoke, full V2 lifecycle via the plugin
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Enroll_Synchronize_Revoke_V2_FullLifecycle()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ // Narrow lookback (1h) — see issues/0022's "V2 sync per-row download cost" note;
+ // 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(),
+ 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 — issues/0022) ---
+ // 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 has been observed to 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" (see issues/0019). Retry once
+ // after a short delay before giving up — any other exception (e.g. the
+ // camelCase-reason HTTP 400 that 0019 describes) 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");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 9 — GetSingleRecord() cert-body regression, V2 path
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task GetSingleRecord_V2_IssuedOrder_HasParseableCertBody()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+
+ var (orderId, plugin) = await EnsureIssuedOrderIdAsync();
+ 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");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 10 — 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
+ // (see issues/0022's "V2 sync per-row download cost" note) — an un-narrowed window
+ // was observed to 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 (see gap G6)");
+
+ 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");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 11 — Synchronize() uses V2 /reports/orders when UseV2Api=true (issues/0022)
+ // ---------------------------------------------------------------------------
+
+ [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 (issues/0022's "V2 sync per-row download cost").
+ 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 " +
+ "(issues/0022 — 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 (Phase 4 parent plan): 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,
+ // See issues/0022's "V2 sync per-row download cost" note — 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
+ // (issues/0022's "V2 sync per-row download cost" note) 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
+ /// (issue 0017). Used by , V2DcvLifecycleTests, and the
+ /// other V2 test classes so they don't duplicate env-loading logic.
+ ///
+ internal static class V2EnvHelper
+ {
+ ///
+ /// 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 corrupted the
+ /// V1 fixture of any class constructed later in the same test process (issues 0017, 0044).
+ ///
+ 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 (
+ /// — issue 0058). Without the latter exclusion, a value left in ~/.env_certinext_v2 for
+ /// one of those flags (e.g. CERTINEXT_V2_GAP_PROBES, 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/V2OrderWindowSweepTests.cs b/CERTInext.IntegrationTests/V2OrderWindowSweepTests.cs
new file mode 100644
index 0000000..caafd39
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2OrderWindowSweepTests.cs
@@ -0,0 +1,273 @@
+// 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). Unlike V2GapProbeTests' own
+// Sweep_FindsAndCancelsOrphanedGapProbeOrders (hard-scoped to "today" and matched only on
+// domainName starting with "gap-p"), this sweep takes an explicit, caller-supplied window and
+// lists every order it finds in it — a general-purpose tool for manually auditing/cleaning up a
+// broader date range after a batch of live-API work (e.g. a day's worth of V2 lifecycle tests),
+// rather than a probe tied to one issue's naming convention.
+//
+// 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 — matching V2GapProbeTests' own sweep. Exactly
+// one cancel attempt per id; never retried, matching every other cleanup/sweep helper in this
+// project (V2FullLifecycleTests.CleanupOrderAsync, V2GapProbeTests' sweep, etc.).
+//
+// The V2 orders report only filters by calendar date (YYYY-MM-DD) server-side (issues/0022 Phase
+// 0) — 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: reuses V2GapProbeTests' existing CERTINEXT_V2_GAP_PROBES=1 opt-in (already present in
+// IntegrationTestFixture._optInOnlyFlags, read from the real process environment before
+// V2EnvHelper.LoadAndPromote() runs) rather than introducing a new flag — this sweep can cancel
+// real sandbox orders the same way that file's own sweep can, so it needs the same explicit
+// go/no-go, no more and no less.
+//
+// Logging: every domain value is passed through CERTInextClient.ApplyLoggingRedaction (same
+// default-off PII posture as every other V2 probe in this repo) before being written via
+// ITestOutputHelper.
+//
+// Run (list-only):
+// set -a; . ~/.env_certinext; set +a
+// export CERTINEXT_V2_GAP_PROBES=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_GAP_PROBES";
+
+ 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 V2GapProbeTests/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 probe 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 the same explicit go/no-go as " +
+ "V2GapProbeTests' own sweep. 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/V2RawProbeHelpers.cs b/CERTInext.IntegrationTests/V2RawProbeHelpers.cs
new file mode 100644
index 0000000..1898461
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2RawProbeHelpers.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.
+//
+// Shared raw-HTTP helpers for this project's V2 "raw-body place-then-cancel" triage probes.
+// Before this file existed, OrganizationBlockV2ProbeTests.cs had its own private
+// GetV2AccessTokenAsync/CancelSslOrderRawAsync pair (token-fetch inlined directly into its
+// CancelSslOrderRawAsync), and IdempotencyKeyV2ProbeTests.cs carried a near-identical private
+// copy of both as two separate methods. Issue 0058 asked that a third file (V2GapProbeTests.cs)
+// reuse rather than triplicate that logic, so both methods below were extracted here and all
+// three files now call them.
+//
+// Behavior for the two pre-existing call sites is unchanged: both methods default to no
+// explicit RestSharp timeout (the timeout parameter defaults to null), exactly matching
+// what the two original private copies did. Callers that need the longer timeout this repo's
+// newer V2 probes use for slow sandbox endpoints (EmailNotificationsV2ProbeTests /
+// UccDcvShapeV2ProbeTests / UccPendingSanOrderProbeTests' 120s NewApiClient) pass it
+// explicitly via the optional timeout parameter on each method.
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ using System;
+ using System.Text.Json;
+ using System.Threading.Tasks;
+ using RestSharp;
+
+ internal static class V2RawProbeHelpers
+ {
+ ///
+ /// Builds a against . When
+ /// is null (the default), this is exactly
+ /// new RestClient(baseUrl) — the framework default timeout the two original
+ /// private helper copies always used.
+ ///
+ public static RestClient NewApiClient(string baseUrl, TimeSpan? timeout = null) =>
+ timeout.HasValue
+ ? new RestClient(new RestClientOptions(baseUrl) { Timeout = timeout.Value })
+ : new RestClient(baseUrl);
+
+ ///
+ /// Standalone OAuth2 client_credentials token fetch against {apiUrl}/oauth/token.
+ /// Throws if the token call itself is not successful — a failed token call is always a
+ /// probe-mechanism failure, never a CA-response finding worth recording.
+ ///
+ public static async Task GetV2AccessTokenAsync(
+ string apiUrl, string clientId, string clientSecret, TimeSpan? timeout = null)
+ {
+ string tokenUrl = apiUrl.TrimEnd('/') + "/oauth/token";
+ using var tokenClient = NewApiClient(tokenUrl, timeout);
+ var tokenReq = new RestRequest(string.Empty, Method.Post);
+ tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded");
+ tokenReq.AddParameter("grant_type", "client_credentials");
+ tokenReq.AddParameter("client_id", clientId);
+ tokenReq.AddParameter("client_secret", clientSecret);
+ var tokenResp = await tokenClient.ExecuteAsync(tokenReq);
+ if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content))
+ throw new Exception($"Token request failed: {(int)tokenResp.StatusCode}");
+
+ using var tokenDoc = JsonDocument.Parse(tokenResp.Content);
+ return tokenDoc.RootElement.GetProperty("access_token").GetString();
+ }
+
+ ///
+ /// Cancels a V2 SSL order via POST {SslCertificatesPath}/{orderId}/cancel. Throws
+ /// on a non-success response — matches the two original private
+ /// CancelSslOrderRawAsync copies exactly (both threw on failure; neither returned
+ /// a raw status/body).
+ ///
+ public static async Task CancelSslOrderRawAsync(
+ string apiUrl, string clientId, string clientSecret, string orderId, string reason,
+ TimeSpan? timeout = null)
+ {
+ string accessToken = await GetV2AccessTokenAsync(apiUrl, clientId, clientSecret, timeout);
+
+ using var apiClient = NewApiClient(apiUrl.TrimEnd('/'), timeout);
+ var cancelReq = new RestRequest($"{Constants.ApiV2.SslCertificatesPath}/{orderId}/cancel", Method.Post);
+ cancelReq.AddHeader("Authorization", $"Bearer {accessToken}");
+ cancelReq.AddJsonBody(new { reason });
+ var cancelResp = await apiClient.ExecuteAsync(cancelReq);
+ if (!cancelResp.IsSuccessful)
+ throw new Exception(
+ $"Cancel request failed: {(int)cancelResp.StatusCode} {cancelResp.Content}");
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/V2ReportProbeTests.cs b/CERTInext.IntegrationTests/V2ReportProbeTests.cs
new file mode 100644
index 0000000..8791893
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2ReportProbeTests.cs
@@ -0,0 +1,677 @@
+// 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.Text.Json;
+using System.Threading.Tasks;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Read-only discovery probes against the V2 /reports/orders and
+ /// /domains endpoints.
+ ///
+ /// This is a fact-finding GATE ahead of switching V2-mode Synchronize from the V1
+ /// GetOrderReport endpoint to V2 reports. It does not place, revoke, or modify any
+ /// order or domain. All findings are printed via and
+ /// must be read from the test's tail output (xUnit buffers it until the test ends).
+ ///
+ /// Gated by CERTINEXT_V2_REPORT_PROBE=1 (in addition to the usual
+ /// CERTINEXT_USE_V2_API=1 + V2 credentials) so it never runs by accident in CI.
+ ///
+ /// To run:
+ ///
+ /// cd <repo>
+ /// set -a; . ~/.env_certinext; set +a
+ /// export CERTINEXT_USE_V2_API=1 CERTINEXT_V2_REPORT_PROBE=1
+ /// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \
+ /// --filter "FullyQualifiedName~V2ReportProbe" \
+ /// --logger "console;verbosity=detailed" > /tmp/v2_probe.log 2>&1
+ ///
+ /// Note: the shell must source ONLY ~/.env_certinext (never ~/.env_certinext_v2 — see
+ /// issue 0017); this class loads ~/.env_certinext_v2 itself, same pattern as V2ApiTests.
+ ///
+ public class V2ReportProbeTests : 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 string _issuedOrderId;
+ private readonly bool _v2Enabled;
+ private readonly bool _probeEnabled;
+
+ public V2ReportProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+
+ // Load ~/.env_certinext_v2 via the shared helper (issues/0017, gap G14 — one env
+ // loader, not a private copy per test class), same priority rules as V2ApiTests:
+ // V2-file-defined keys override whatever the fixture already promoted into
+ // process env (the V1 CERTINEXT_API_URL differs from the V2 base URL).
+ 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");
+ _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);
+
+ _probeEnabled = _v2Enabled && !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(env, "CERTINEXT_V2_REPORT_PROBE"));
+ }
+
+ // ---------------------------------------------------------------------------
+ // Probe 1 + 5: report shape, envelope pagination fields, product/serial/status vocab
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Probe1_OrdersReport_ShapeAndFieldVocabulary()
+ {
+ Skip.IfNot(_probeEnabled, "CERTINEXT_V2_REPORT_PROBE not set (or V2 not enabled) — skipping report probe.");
+
+ using var client = BuildV2Client();
+
+ _output.WriteLine("=== Probe 1: GET /reports/orders?page=1&size=50 ===");
+ var (status, contentType, content) = await client.ProbeV2GetAsync(
+ "/api/certinext/v2/reports/orders?page=1&size=50");
+
+ _output.WriteLine($"HTTP {status} (Content-Type: {contentType})");
+
+ if (status != 200)
+ {
+ _output.WriteLine($"FINDING: /reports/orders is NOT live (200). Raw body: {Redact(content)}");
+ return;
+ }
+
+ _output.WriteLine("FINDING: /reports/orders returned 200 — endpoint IS live (contradicts the " +
+ "plugin's current 501 assumption in CERTInextCAPlugin.cs ~862-868).");
+
+ using var doc = JsonDocument.Parse(content);
+ var root = doc.RootElement;
+
+ var envelopeKeys = root.EnumerateObject().Select(p => p.Name).ToList();
+ _output.WriteLine($"Envelope top-level keys: [{string.Join(", ", envelopeKeys)}]");
+
+ foreach (string field in new[] { "page", "size", "totalElements", "totalPages" })
+ {
+ if (root.TryGetProperty(field, out var v))
+ _output.WriteLine($" envelope.{field} = {v} (kind={v.ValueKind})");
+ else
+ _output.WriteLine($" envelope.{field} = ");
+ }
+
+ if (!root.TryGetProperty("content", out var contentArr) || contentArr.ValueKind != JsonValueKind.Array)
+ {
+ _output.WriteLine("FINDING: no 'content' array in the envelope — cannot inspect rows.");
+ return;
+ }
+
+ int rowCount = contentArr.GetArrayLength();
+ _output.WriteLine($"content[] length on this page: {rowCount}");
+
+ var rows = contentArr.EnumerateArray().Take(3).ToList();
+ var distinctStatusValues = new SortedSet();
+ var sampleRowRaw = string.Empty;
+
+ for (int i = 0; i < rows.Count; i++)
+ {
+ var row = rows[i];
+ var rowKeys = row.EnumerateObject().Select(p => $"{p.Name}:{p.Value.ValueKind}").ToList();
+ _output.WriteLine($"row[{i}] fields (name:type): [{string.Join(", ", rowKeys)}]");
+ if (i == 0)
+ sampleRowRaw = Redact(row.GetRawText());
+
+ foreach (var statusField in new[] { "orderStatus", "state", "certificateStatus" })
+ if (row.TryGetProperty(statusField, out var sv) && sv.ValueKind == JsonValueKind.String)
+ distinctStatusValues.Add($"{statusField}={sv.GetString()}");
+ }
+
+ _output.WriteLine($"Sample row 0 (redacted): {sampleRowRaw}");
+
+ // Spec field table vs example body discrepancy (docs/reference/specs/CERTInext API
+ // v2.postman_collection (1).json, item "Orders Report"): field table documents
+ // orderStatus/domainName/certificateSerialNumber; the example body instead shows
+ // state/identifier/account/group/product. Report which is actually live.
+ bool hasTableFields = rows.Any(r => r.TryGetProperty("orderStatus", out _) ||
+ r.TryGetProperty("domainName", out _) ||
+ r.TryGetProperty("certificateSerialNumber", out _));
+ bool hasExampleFields = rows.Any(r => r.TryGetProperty("state", out _) ||
+ r.TryGetProperty("identifier", out _));
+
+ _output.WriteLine($"FINDING: spec field-table shape present = {hasTableFields}; " +
+ $"spec example-body shape present = {hasExampleFields}.");
+
+ // Probe 5: does the row carry productCode / serial / cert body link?
+ bool hasProductCode = rows.Any(r => r.TryGetProperty("productCode", out _) || r.TryGetProperty("product", out _));
+ bool hasSerial = rows.Any(r => r.TryGetProperty("certificateSerialNumber", out _));
+ bool hasCertLink = rows.Any(r => r.EnumerateObject().Any(p => p.Name.Contains("certificate", StringComparison.OrdinalIgnoreCase)
+ && p.Name.Contains("link", StringComparison.OrdinalIgnoreCase)));
+ _output.WriteLine($"FINDING: row carries product/productCode = {hasProductCode}; " +
+ $"certificateSerialNumber = {hasSerial}; a *Link field naming a cert body = {hasCertLink}.");
+
+ // Probe 5 (continued): collect distinct status vocabulary across up to 3 pages.
+ var allStatusValues = new SortedSet