diff --git a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
index bd3ec73..29d58ed 100644
--- a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
+++ b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
@@ -16,14 +16,16 @@
-
+
+
+
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/IntegrationTestFixture.cs b/CERTInext.IntegrationTests/IntegrationTestFixture.cs
index 8e4f637..0a414f9 100644
--- a/CERTInext.IntegrationTests/IntegrationTestFixture.cs
+++ b/CERTInext.IntegrationTests/IntegrationTestFixture.cs
@@ -20,6 +20,23 @@ 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 the env file. These gate destructive or mutating tests
+ /// so a developer cannot accidentally arm them by leaving flags in ~/.env_certinext.
+ ///
+ private 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",
+ };
+
// ---------------------------------------------------------------------------
// Credential properties
// ---------------------------------------------------------------------------
@@ -85,8 +102,12 @@ public IntegrationTestFixture()
// 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)
+ if (System.Environment.GetEnvironmentVariable(kv.Key) == null
+ && !_optInOnlyFlags.Contains(kv.Key))
System.Environment.SetEnvironmentVariable(kv.Key, kv.Value);
ApiUrl = GetEnvValue(env, "CERTINEXT_API_URL");
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/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/V2ApiTests.cs b/CERTInext.IntegrationTests/V2ApiTests.cs
new file mode 100644
index 0000000..f4a0e3a
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2ApiTests.cs
@@ -0,0 +1,723 @@
+// 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 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.) must remain
+ /// configured because Synchronize continues to use the V1 GetOrderReport endpoint.
+ ///
+ 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 if present. 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.
+ string v2Path = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".env_certinext_v2");
+ var (env, fileKeys) = LoadEnvFile(v2Path);
+
+ // Force-promote V2-file-defined keys into process env so they override
+ // any V1 values the fixture already set.
+ foreach (string key in fileKeys)
+ if (env.TryGetValue(key, out string fv))
+ Environment.SetEnvironmentVariable(key, fv);
+
+ // Promote remaining keys that aren't already in process env
+ foreach (var kv in env)
+ if (Environment.GetEnvironmentVariable(kv.Key) == null)
+ Environment.SetEnvironmentVariable(kv.Key, kv.Value);
+
+ _v2ApiUrl = GetEnv(env, "CERTINEXT_API_URL");
+ _v2ClientId = GetEnv(env, "CERTINEXT_CLIENT_ID");
+ _v2ClientSecret = GetEnv(env, "CERTINEXT_CLIENT_SECRET");
+ _v2ProductCode = GetEnv(env, "CERTINEXT_PRODUCT_CODE", "842");
+ _v2Domain = GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com");
+ _cfApiToken = GetEnv(env, "CERTINEXT_CF_API_TOKEN");
+ _cfZoneId = GetEnv(env, "CERTINEXT_CF_ZONE_ID");
+ _issuedOrderId = GetEnv(env, "CERTINEXT_V2_ISSUED_ORDER_ID");
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(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 still uses V1 when UseV2Api=true
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Verifies that Synchronize calls the V1 GetOrderReport (not V2 /reports/orders)
+ /// even when UseV2Api=true.
+ /// Skips when V1 credentials are absent.
+ ///
+ [SkippableFact]
+ public async Task Sync_UsesV1_WhenV2Enabled()
+ {
+ Skip.If(!_fixture.IsConfigured || !_v2Enabled,
+ "V1 credentials or V2 opt-in (CERTINEXT_USE_V2_API) not configured — skipping.");
+
+ // Build a V2-enabled config that still has V1 creds for sync
+ var config = new CERTInextConfig
+ {
+ // V1 creds (required for sync)
+ ApiUrl = _fixture.Config.ApiUrl,
+ AuthMode = "AccessKey",
+ ApiKey = _fixture.Config.ApiKey,
+ AccountNumber = _fixture.Config.AccountNumber,
+ // V2 creds (only used for enroll/revoke)
+ UseV2Api = true,
+ ApiUrlV2 = _v2Enabled ? _v2ApiUrl : "https://placeholder.certinext.io",
+ ClientId = _v2Enabled ? _v2ClientId : "placeholder-client",
+ ClientSecret = _v2Enabled ? _v2ClientSecret : "placeholder-secret",
+ RequestorName = _fixture.Config.RequestorName,
+ RequestorEmail = _fixture.Config.RequestorEmail,
+ PageSize = 10 // small page — we just want to confirm sync runs via V1
+ };
+
+ using var client = new CERTInextClient(config);
+ var plugin = new CERTInextCAPlugin(client, config);
+
+ var buffer = new BlockingCollection(1000);
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+
+ Exception caughtEx = null;
+ try
+ {
+ await plugin.Synchronize(buffer, DateTime.UtcNow.AddDays(-1), false, cts.Token);
+ }
+ catch (Exception ex)
+ {
+ caughtEx = ex;
+ }
+ if (!buffer.IsAddingCompleted)
+ buffer.CompleteAdding();
+
+ var records = new List();
+ foreach (var record in buffer.GetConsumingEnumerable())
+ records.Add(record);
+
+ // Sync must call V1 GetOrderReport, not V2 endpoints.
+ // A V2-routing bug would throw KeyNotFoundException with "not found in any V2 product family".
+ // A V1 API error (wrong creds / URL mismatch) is acceptable here — it proves the V1 path ran.
+ if (caughtEx != null)
+ {
+ caughtEx.Message.Should().NotContain("V2 product family",
+ "sync must use V1 GetOrderReport, not V2 product-family routing");
+ }
+ else
+ {
+ // Success path must actually prove something: the delta sync window is a day,
+ // so a healthy V1 account is expected to return at least one record. An empty,
+ // silent success here would be exactly as uninformative as the old "only check
+ // the exception message" assertion (see gap G6).
+ records.Should().NotBeEmpty(
+ "Synchronize must return records via V1 GetOrderReport when it succeeds with UseV2Api=true " +
+ "(an empty result here proves nothing about which code path actually ran)");
+ _output.WriteLine($"Sync_UsesV1_WhenV2Enabled: {records.Count} record(s) returned via V1.");
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // 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");
+
+ // Best-effort structural check: this sandbox's catalog/products entries have been
+ // observed to carry null ProductCode/ProductName/ProductType (see issues/0016), so
+ // we log rather than hard-fail — the regression we actually guard against is an
+ // empty/null list, asserted above.
+ int withCode = products.Count(p => !string.IsNullOrWhiteSpace(p.ProductCode));
+ _output.WriteLine($"{withCode}/{products.Count} catalog products carry a non-empty ProductCode.");
+ }
+
+ // ---------------------------------------------------------------------------
+ // 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.FileNameContent.Should().NotBeNullOrEmpty(
+ "GetDcvV2Async must return a TXT token in FileNameContent");
+
+ string domainName = string.IsNullOrWhiteSpace(dcvResp.DomainName)
+ ? _v2Domain
+ : dcvResp.DomainName;
+
+ // 3. Publish TXT record
+ txtKey = $"_emudhra-challenge.{domainName}";
+ _output.WriteLine($"Publishing TXT {txtKey} = {dcvResp.FileNameContent}");
+ var staged = await dns.StageValidation(txtKey, dcvResp.FileNameContent, 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
+ {
+ // V1 fields (still needed for Synchronize)
+ ApiUrl = _fixture.IsConfigured ? _fixture.Config.ApiUrl : "https://v1-placeholder.certinext.io",
+ AuthMode = "AccessKey",
+ ApiKey = _fixture.IsConfigured ? _fixture.Config.ApiKey : "placeholder",
+ AccountNumber = _fixture.IsConfigured ? _fixture.Config.AccountNumber : "0",
+ // V2 fields
+ UseV2Api = true,
+ ApiUrlV2 = _v2ApiUrl,
+ ClientId = _v2ClientId,
+ ClientSecret = _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.");
+ }
+
+ ///
+ /// Loads a KEY=VALUE env file and merges with process env vars.
+ /// V2 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.
+ ///
+ private static (Dictionary env, HashSet fileKeys) LoadEnvFile(string path)
+ {
+ var fileKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ // Seed with process env vars first
+ 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;
+ }
+
+ // V2 env-file values override process env for any key they define.
+ // This is the correct priority: the V2 file is a targeted overlay.
+ 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);
+ }
+
+ private 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/V2DcvLifecycleTests.cs b/CERTInext.IntegrationTests/V2DcvLifecycleTests.cs
new file mode 100644
index 0000000..7ff1833
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2DcvLifecycleTests.cs
@@ -0,0 +1,623 @@
+// 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();
+ }
+
+ private CERTInextConfig BuildV2Config(bool dcvEnabled = true, int propagationDelaySeconds = 5, int? pageSize = null)
+ {
+ return new CERTInextConfig
+ {
+ // V1 fields — required so Synchronize (always V1) keeps working.
+ ApiUrl = _fixture.IsConfigured ? _fixture.Config.ApiUrl : "https://v1-placeholder.certinext.io",
+ AuthMode = "AccessKey",
+ ApiKey = _fixture.IsConfigured ? _fixture.Config.ApiKey : "placeholder",
+ AccountNumber = _fixture.IsConfigured ? _fixture.Config.AccountNumber : "0",
+ GroupNumber = _fixture.IsConfigured ? _fixture.Config.GroupNumber : string.Empty,
+ OrganizationNumber = _fixture.IsConfigured ? _fixture.Config.OrganizationNumber : string.Empty,
+ DefaultProductCode = _fixture.IsConfigured ? _fixture.Config.DefaultProductCode : _v2ProductCode,
+
+ // V2 fields
+ UseV2Api = true,
+ ApiUrlV2 = _v2ApiUrl,
+ ClientId = _v2ClientId,
+ ClientSecret = _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,
+
+ 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)
+ {
+ var config = BuildV2Config(dcvEnabled, propagationDelaySeconds, pageSize);
+ 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.
+ var plugin = BuildV2DcvPlugin(dcvEnabled: true, propagationDelaySeconds: 5, pageSize: 100);
+
+ 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/V2LifecycleTests.cs b/CERTInext.IntegrationTests/V2LifecycleTests.cs
new file mode 100644
index 0000000..c3948bb
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2LifecycleTests.cs
@@ -0,0 +1,632 @@
+// 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. V1 fields are
+ /// populated from the fixture's config because Synchronize always uses
+ /// the V1 GetOrderReport endpoint regardless of UseV2Api.
+ ///
+ private CERTInextConfig BuildV2Config(bool dcvEnabled = false, int? pageSize = null)
+ {
+ return new CERTInextConfig
+ {
+ // V1 fields — required so Synchronize (always V1) keeps working.
+ ApiUrl = _fixture.IsConfigured ? _fixture.Config.ApiUrl : "https://v1-placeholder.certinext.io",
+ AuthMode = "AccessKey",
+ ApiKey = _fixture.IsConfigured ? _fixture.Config.ApiKey : "placeholder",
+ AccountNumber = _fixture.IsConfigured ? _fixture.Config.AccountNumber : "0",
+ GroupNumber = _fixture.IsConfigured ? _fixture.Config.GroupNumber : string.Empty,
+ OrganizationNumber = _fixture.IsConfigured ? _fixture.Config.OrganizationNumber : string.Empty,
+ DefaultProductCode = _fixture.IsConfigured ? _fixture.Config.DefaultProductCode : _v2ProductCode,
+
+ // V2 fields
+ UseV2Api = true,
+ ApiUrlV2 = _v2ApiUrl,
+ ClientId = _v2ClientId,
+ ClientSecret = _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,
+
+ 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
+ // ---------------------------------------------------------------------------
+
+ [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.");
+
+ var config = BuildV2Config();
+ 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 (always V1, even though UseV2Api=true) ---
+ // 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 is sufficient to prove it
+ // surfaces via Synchronize.
+ var synced = await RunSyncAsync(BuildV2Plugin(config), lastSync: DateTime.UtcNow.AddDays(-1), fullSync: false);
+ synced.Should().Contain(
+ r => r.CARequestID == enrollResult.CARequestID,
+ $"the newly enrolled V2 order '{enrollResult.CARequestID}' must appear in a delta sync " +
+ "(Synchronize always uses V1 GetOrderReport regardless of UseV2Api)");
+
+ 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 full sync (always V1) with a V2-configured (UseV2Api=true) plugin,
+ /// then calls GetSingleRecord for a sample of the resulting CARequestIDs.
+ /// Since V1-created order IDs are not resolvable via the V2 family probe,
+ /// is an accepted,
+ /// documented outcome here (see issues/0016) — this test guards 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.");
+ Skip.If(!_fixture.IsConfigured, "V1 credentials not configured — Synchronize requires them.");
+
+ // Delta sync — this sandbox account has 1000+ historical orders; a recent
+ // window is enough to sample GetSingleRecord behavior without paging the
+ // entire multi-month history on every test run.
+ var plugin = BuildV2Plugin();
+ var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddDays(-7), 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)
+ {
+ // Expected: V1-created order IDs are not resolvable via the V2 family
+ // probe when the plugin is UseV2Api=true. See issues/0016.
+ keyNotFound++;
+ }
+ }
+
+ _output.WriteLine($"GetSingleRecord results: {ok} succeeded, {keyNotFound} KeyNotFoundException (expected for V1 orders under V2 config).");
+ (ok + keyNotFound).Should().Be(sample.Count,
+ "every sampled GetSingleRecord call must either succeed or throw the documented KeyNotFoundException " +
+ "(issues/0016) — any other exception type must escape this loop and fail the test (see gap G6)");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gap 11 — Synchronize() still uses V1 when UseV2Api=true, and returns records
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Sync_V2_StillUsesV1_ReturnsRecords()
+ {
+ Skip.If(!_v2Enabled, "CERTINEXT_USE_V2_API not set or V2 credentials not configured — skipping.");
+ Skip.If(!_fixture.IsConfigured, "V1 credentials not configured — Synchronize requires them.");
+
+ // Delta sync — this sandbox account has 1000+ historical orders; a recent
+ // window proves Synchronize returns records without paging the entire
+ // multi-month history on every test run.
+ var plugin = BuildV2Plugin();
+ var synced = await RunSyncAsync(plugin, lastSync: DateTime.UtcNow.AddDays(-7), fullSync: false);
+
+ synced.Should().NotBeNull();
+ synced.Should().NotBeEmpty(
+ "Synchronize must return the account's recent V1 order inventory even when UseV2Api=true " +
+ "(Synchronize always uses V1 GetOrderReport)");
+
+ _output.WriteLine($"Synchronize returned {synced.Count} record(s) with UseV2Api=true.");
+ }
+ }
+
+ ///
+ /// Shared helper for loading ~/.env_certinext_v2 and promoting its values into
+ /// process environment, overriding V1 values the fixture may have already set. Used
+ /// by both and V2DcvLifecycleTests so the two
+ /// files don't duplicate env-loading logic.
+ ///
+ internal static class V2EnvHelper
+ {
+ ///
+ /// Loads ~/.env_certinext_v2, force-promotes its keys into process
+ /// environment (overriding any V1 values already set by ),
+ /// and returns the merged environment dictionary.
+ ///
+ public static Dictionary LoadAndPromote()
+ {
+ string v2Path = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".env_certinext_v2");
+
+ var (env, fileKeys) = LoadEnvFile(v2Path);
+
+ // Force-promote V2-file-defined keys into process env so they override
+ // any V1 values the fixture already set.
+ foreach (string key in fileKeys)
+ if (env.TryGetValue(key, out string fv))
+ Environment.SetEnvironmentVariable(key, fv);
+
+ // Promote remaining keys that aren't already in process env
+ foreach (var kv in env)
+ if (Environment.GetEnvironmentVariable(kv.Key) == null)
+ Environment.SetEnvironmentVariable(kv.Key, kv.Value);
+
+ return env;
+ }
+
+ ///
+ /// 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/V2ReportProbeTests.cs b/CERTInext.IntegrationTests/V2ReportProbeTests.cs
new file mode 100644
index 0000000..1632e53
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2ReportProbeTests.cs
@@ -0,0 +1,568 @@
+// 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.IO;
+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 if present, 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).
+ string v2Path = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".env_certinext_v2");
+ var (env, fileKeys) = LoadEnvFile(v2Path);
+
+ foreach (string key in fileKeys)
+ if (env.TryGetValue(key, out string fv))
+ Environment.SetEnvironmentVariable(key, fv);
+
+ foreach (var kv in env)
+ if (Environment.GetEnvironmentVariable(kv.Key) == null)
+ Environment.SetEnvironmentVariable(kv.Key, kv.Value);
+
+ _v2ApiUrl = GetEnv(env, "CERTINEXT_API_URL");
+ _v2ClientId = GetEnv(env, "CERTINEXT_CLIENT_ID");
+ _v2ClientSecret = GetEnv(env, "CERTINEXT_CLIENT_SECRET");
+ _v2Domain = GetEnv(env, "CERTINEXT_DCV_DOMAIN", "test.example.com");
+ _issuedOrderId = GetEnv(env, "CERTINEXT_V2_ISSUED_ORDER_ID");
+
+ _v2Enabled = !string.IsNullOrWhiteSpace(GetEnv(env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+
+ _probeEnabled = _v2Enabled && !string.IsNullOrWhiteSpace(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(distinctStatusValues);
+ for (int page = 2; page <= 3; page++)
+ {
+ var (pStatus, _, pContent) = await client.ProbeV2GetAsync(
+ $"/api/certinext/v2/reports/orders?page={page}&size=50");
+ if (pStatus != 200 || string.IsNullOrWhiteSpace(pContent)) break;
+ using var pDoc = JsonDocument.Parse(pContent);
+ if (!pDoc.RootElement.TryGetProperty("content", out var pArr) || pArr.GetArrayLength() == 0) break;
+ foreach (var row in pArr.EnumerateArray())
+ foreach (var statusField in new[] { "orderStatus", "state", "certificateStatus" })
+ if (row.TryGetProperty(statusField, out var sv) && sv.ValueKind == JsonValueKind.String)
+ allStatusValues.Add($"{statusField}={sv.GetString()}");
+ }
+ _output.WriteLine($"FINDING: distinct status values observed (pages 1-3): [{string.Join(", ", allStatusValues)}]");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Probe 2: pagination semantics
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Probe2_OrdersReport_Pagination()
+ {
+ Skip.IfNot(_probeEnabled, "CERTINEXT_V2_REPORT_PROBE not set (or V2 not enabled) — skipping pagination probe.");
+
+ using var client = BuildV2Client();
+ _output.WriteLine("=== Probe 2: pagination semantics ===");
+
+ foreach (var (label, query) in new[]
+ {
+ ("size=100", "/api/certinext/v2/reports/orders?page=1&size=100"),
+ ("size=101 (over max)", "/api/certinext/v2/reports/orders?page=1&size=101"),
+ ("page=0", "/api/certinext/v2/reports/orders?page=0&size=10"),
+ ("page=1 (baseline)", "/api/certinext/v2/reports/orders?page=1&size=10"),
+ })
+ {
+ var (status, _, content) = await client.ProbeV2GetAsync(query);
+ string sizeEcho = "n/a", pageEcho = "n/a";
+ if (status == 200 && !string.IsNullOrWhiteSpace(content))
+ {
+ using var doc = JsonDocument.Parse(content);
+ if (doc.RootElement.TryGetProperty("size", out var sv)) sizeEcho = sv.ToString();
+ if (doc.RootElement.TryGetProperty("page", out var pv)) pageEcho = pv.ToString();
+ }
+ _output.WriteLine($"FINDING: {label} -> HTTP {status}, echoed size={sizeEcho}, echoed page={pageEcho}, " +
+ $"bodySnippet={Redact(Truncate(content, 300))}");
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Probe 3: from/to filter param names + semantics (order date vs issue date)
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Probe3_OrdersReport_FromToFilters()
+ {
+ Skip.IfNot(_probeEnabled, "CERTINEXT_V2_REPORT_PROBE not set (or V2 not enabled) — skipping from/to probe.");
+
+ using var v2Client = BuildV2Client();
+ _output.WriteLine("=== Probe 3: from/to filters ===");
+
+ // Baseline: unfiltered totalElements.
+ var (baseStatus, _, baseContent) = await v2Client.ProbeV2GetAsync(
+ "/api/certinext/v2/reports/orders?page=1&size=1");
+ long baseTotal = -1;
+ if (baseStatus == 200 && !string.IsNullOrWhiteSpace(baseContent))
+ {
+ using var doc = JsonDocument.Parse(baseContent);
+ if (doc.RootElement.TryGetProperty("totalElements", out var te)) baseTotal = te.GetInt64();
+ }
+ _output.WriteLine($"Baseline (no filter) totalElements = {baseTotal}");
+
+ // Wide bracket per spec format (YYYY-MM-DD) that should include everything.
+ var (wideStatus, _, wideContent) = await v2Client.ProbeV2GetAsync(
+ "/api/certinext/v2/reports/orders?page=1&size=1&from=2000-01-01&to=2099-12-31");
+ long wideTotal = -1;
+ if (wideStatus == 200 && !string.IsNullOrWhiteSpace(wideContent))
+ {
+ using var doc = JsonDocument.Parse(wideContent);
+ if (doc.RootElement.TryGetProperty("totalElements", out var te)) wideTotal = te.GetInt64();
+ }
+ _output.WriteLine($"FINDING: from=2000-01-01&to=2099-12-31 -> HTTP {wideStatus}, totalElements = {wideTotal} " +
+ $"(vs baseline {baseTotal}; equal => from/to accepted with YYYY-MM-DD and don't drop rows).");
+
+ // Narrow bracket in the far past that should exclude everything, to confirm the
+ // params actually filter (rather than being silently ignored).
+ var (narrowStatus, _, narrowContent) = await v2Client.ProbeV2GetAsync(
+ "/api/certinext/v2/reports/orders?page=1&size=1&from=2000-01-01&to=2000-01-02");
+ long narrowTotal = -1;
+ if (narrowStatus == 200 && !string.IsNullOrWhiteSpace(narrowContent))
+ {
+ using var doc = JsonDocument.Parse(narrowContent);
+ if (doc.RootElement.TryGetProperty("totalElements", out var te)) narrowTotal = te.GetInt64();
+ }
+ _output.WriteLine($"FINDING: from=2000-01-01&to=2000-01-02 -> HTTP {narrowStatus}, totalElements = {narrowTotal} " +
+ "(if 0, from/to do filter; if unchanged from baseline, they are likely no-ops).");
+
+ // Empirical order-date vs issue-date distinction: pick an issued order from the V1
+ // report whose OrderDate we know, and bracket from/to tightly around that date.
+ // If the row still appears in a bracket that excludes its (later) issuance date,
+ // from/to are filtering on order date, not issue date.
+ if (!_fixture.IsConfigured)
+ {
+ _output.WriteLine("V1 fixture not configured — cannot pick a known-orderDate order to " +
+ "distinguish order-date vs issue-date filtering. Skipping that sub-probe.");
+ return;
+ }
+
+ OrderReportSample sample = null;
+ await foreach (var entry in _fixture.Client.ListOrdersAsync(pageSize: 20))
+ {
+ if (!string.IsNullOrWhiteSpace(entry.OrderNumber) &&
+ !string.IsNullOrWhiteSpace(entry.OrderDate) &&
+ DateTime.TryParse(entry.OrderDate, null,
+ System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal,
+ out DateTime parsedOrderDate))
+ {
+ sample = new OrderReportSample(entry.OrderNumber, parsedOrderDate);
+ break;
+ }
+ }
+
+ if (sample == null)
+ {
+ _output.WriteLine("No V1 order with a parsed OrderDate found in the first page — cannot run the " +
+ "order-date-vs-issue-date sub-probe.");
+ return;
+ }
+
+ string from = sample.OrderDate.ToString("yyyy-MM-dd");
+ string to = sample.OrderDate.AddDays(1).ToString("yyyy-MM-dd");
+ var (bracketStatus, _, bracketContent) = await v2Client.ProbeV2GetAsync(
+ $"/api/certinext/v2/reports/orders?page=1&size=50&from={from}&to={to}");
+ bool foundInOrderDateBracket = bracketStatus == 200 &&
+ ContainsOrderNumber(bracketContent, sample.OrderNumber);
+ _output.WriteLine($"FINDING: V1 order {Redact(sample.OrderNumber)} (V1 orderDate={sample.OrderDate:u}) " +
+ $"bracketed from={from}&to={to} in V2 report -> present = {foundInOrderDateBracket}. " +
+ "(V1 report has no separate issue-date field to bracket against, so a full " +
+ "order-date-vs-issue-date distinction could not be made empirically in this pass; " +
+ "see report notes.)");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Probe 4: orderNumber identity between V1 and V2
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Probe4_OrderNumberIdentity_V1VsV2()
+ {
+ Skip.IfNot(_probeEnabled, "CERTINEXT_V2_REPORT_PROBE not set (or V2 not enabled) — skipping identity probe.");
+ Skip.IfNot(_fixture.IsConfigured, "V1 fixture not configured — cannot compare V1 order numbers.");
+
+ using var v2Client = BuildV2Client();
+ _output.WriteLine("=== Probe 4: orderNumber identity (V1 <-> V2) ===");
+
+ // Collect a handful of V1 order numbers.
+ var v1OrderNumbers = new List();
+ await foreach (var entry in _fixture.Client.ListOrdersAsync(pageSize: 20))
+ {
+ if (!string.IsNullOrWhiteSpace(entry.OrderNumber))
+ v1OrderNumbers.Add(entry.OrderNumber);
+ if (v1OrderNumbers.Count >= 5) break;
+ }
+ _output.WriteLine($"Collected {v1OrderNumbers.Count} V1 order numbers to compare.");
+
+ // Pull the V2 report (first few pages, capped) to build a set of V2 orderNumbers.
+ var v2OrderNumbers = new HashSet(StringComparer.OrdinalIgnoreCase);
+ for (int page = 1; page <= 5; page++)
+ {
+ var (status, _, content) = await v2Client.ProbeV2GetAsync(
+ $"/api/certinext/v2/reports/orders?page={page}&size=100");
+ if (status != 200 || string.IsNullOrWhiteSpace(content)) break;
+ using var doc = JsonDocument.Parse(content);
+ if (!doc.RootElement.TryGetProperty("content", out var arr) || arr.GetArrayLength() == 0) break;
+ foreach (var row in arr.EnumerateArray())
+ if (row.TryGetProperty("orderNumber", out var on) && on.ValueKind == JsonValueKind.String)
+ v2OrderNumbers.Add(on.GetString());
+ if (arr.GetArrayLength() < 100) break; // last page
+ }
+ _output.WriteLine($"Collected {v2OrderNumbers.Count} distinct orderNumbers across up to 5 V2 report pages.");
+
+ int matched = v1OrderNumbers.Count(n => v2OrderNumbers.Contains(n));
+ _output.WriteLine($"FINDING: N compared = {v1OrderNumbers.Count}, N matched by exact orderNumber = {matched}.");
+
+ // Cross-check: do V1 order numbers resolve against the V2 TrackOrder endpoint at all
+ // (independent of the report), by probing all three V2 product families.
+ int trackResolved = 0;
+ foreach (var orderNumber in v1OrderNumbers)
+ {
+ bool resolved = false;
+ foreach (var family in new[] { "ssl-certificates", "private-pki-certificates", "signature-certificates" })
+ {
+ var (status, _, _) = await v2Client.ProbeV2GetAsync($"/api/certinext/v2/{family}/{orderNumber}");
+ if (status == 200) { resolved = true; break; }
+ }
+ if (resolved) trackResolved++;
+ }
+ _output.WriteLine($"FINDING: of {v1OrderNumbers.Count} V1 order numbers, {trackResolved} resolved via " +
+ "V2 GET /{family}/{orderId} (TrackOrder-equivalent) in any product family.");
+
+ // If an order was placed via V2 (CERTINEXT_V2_ISSUED_ORDER_ID), check whether its ID
+ // appears in the V2 report and whether it matches the V1 orderNumber format.
+ if (!string.IsNullOrWhiteSpace(_issuedOrderId))
+ {
+ bool v2OrderInReport = v2OrderNumbers.Contains(_issuedOrderId);
+ _output.WriteLine($"FINDING: CERTINEXT_V2_ISSUED_ORDER_ID ({Redact(_issuedOrderId)}) present in V2 report = " +
+ $"{v2OrderInReport}.");
+ }
+ else
+ {
+ _output.WriteLine("CERTINEXT_V2_ISSUED_ORDER_ID not set — cannot check a V2-placed order's ID " +
+ "against V1 orderNumber format.");
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Probe 6: List Domains
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Probe6_ListDomains_Shape()
+ {
+ Skip.IfNot(_probeEnabled, "CERTINEXT_V2_REPORT_PROBE not set (or V2 not enabled) — skipping domains probe.");
+
+ using var client = BuildV2Client();
+ _output.WriteLine("=== Probe 6: GET /domains?search=&exactMatch=true ===");
+
+ string query = $"/api/certinext/v2/domains?search={Uri.EscapeDataString(_v2Domain)}&exactMatch=true";
+ var (status, contentType, content) = await client.ProbeV2GetAsync(query);
+ _output.WriteLine($"HTTP {status} (Content-Type: {contentType})");
+
+ if (status != 200 || string.IsNullOrWhiteSpace(content))
+ {
+ _output.WriteLine($"FINDING: /domains did not return 200 with a body. Raw: {Redact(content)}");
+ return;
+ }
+
+ 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)}]");
+
+ if (!root.TryGetProperty("content", out var arr) || arr.ValueKind != JsonValueKind.Array || arr.GetArrayLength() == 0)
+ {
+ _output.WriteLine($"FINDING: no matching domain row for search={Redact(_v2Domain)}, exactMatch=true.");
+ return;
+ }
+
+ var row = arr[0];
+ var rowKeys = row.EnumerateObject().Select(p => $"{p.Name}:{p.Value.ValueKind}").ToList();
+ _output.WriteLine($"row[0] fields (name:type): [{string.Join(", ", rowKeys)}]");
+ _output.WriteLine($"Sample domain row (redacted): {Redact(row.GetRawText())}");
+
+ foreach (string field in new[] { "domainId", "dcvStatus", "validTill", "domainName", "status" })
+ {
+ if (row.TryGetProperty(field, out var v))
+ _output.WriteLine($" domain.{field} = {Redact(v.ToString())}");
+ else
+ _output.WriteLine($" domain.{field} = ");
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private sealed class OrderReportSample
+ {
+ public OrderReportSample(string orderNumber, DateTime orderDate)
+ {
+ OrderNumber = orderNumber;
+ OrderDate = orderDate;
+ }
+
+ public string OrderNumber { get; }
+ public DateTime OrderDate { get; }
+ }
+
+ private static bool ContainsOrderNumber(string reportJson, string orderNumber)
+ {
+ if (string.IsNullOrWhiteSpace(reportJson)) return false;
+ try
+ {
+ using var doc = JsonDocument.Parse(reportJson);
+ if (!doc.RootElement.TryGetProperty("content", out var arr) || arr.ValueKind != JsonValueKind.Array)
+ return false;
+ return arr.EnumerateArray().Any(row =>
+ row.TryGetProperty("orderNumber", out var on) &&
+ on.ValueKind == JsonValueKind.String &&
+ string.Equals(on.GetString(), orderNumber, StringComparison.OrdinalIgnoreCase));
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+ }
+
+ private CERTInextClient BuildV2Client()
+ {
+ return new CERTInextClient(new CERTInextConfig
+ {
+ // V1 fields (still needed for construction; not exercised by these probes).
+ ApiUrl = _fixture.IsConfigured ? _fixture.Config.ApiUrl : "https://v1-placeholder.certinext.io",
+ AuthMode = "AccessKey",
+ ApiKey = _fixture.IsConfigured ? _fixture.Config.ApiKey : "placeholder",
+ AccountNumber = _fixture.IsConfigured ? _fixture.Config.AccountNumber : "0",
+ // V2 fields
+ UseV2Api = true,
+ ApiUrlV2 = _v2ApiUrl,
+ ClientId = _v2ClientId,
+ ClientSecret = _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
+ });
+ }
+
+ ///
+ /// Redacts anything that looks like a token/secret/key value before it's written to
+ /// test output. This is a best-effort scrub of raw JSON bodies for a discovery probe —
+ /// never log access tokens, client secrets, or API keys.
+ ///
+ private static string Redact(string raw)
+ {
+ if (string.IsNullOrEmpty(raw)) return raw;
+ string redacted = raw;
+ foreach (string key in new[] { "accessToken", "access_token", "clientSecret", "client_secret", "apiKey", "api_key", "authKey", "token" })
+ {
+ redacted = System.Text.RegularExpressions.Regex.Replace(
+ redacted,
+ $"\"{System.Text.RegularExpressions.Regex.Escape(key)}\"\\s*:\\s*\"[^\"]*\"",
+ $"\"{key}\":\"***REDACTED***\"",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+ }
+ return redacted;
+ }
+
+ private static string Truncate(string value, int maxLength)
+ {
+ if (string.IsNullOrEmpty(value) || value.Length <= maxLength) return value;
+ return value.Substring(0, maxLength) + "...(truncated)";
+ }
+
+ private 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);
+ }
+
+ private static string GetEnv(Dictionary env, string key, string defaultValue = "")
+ => env.TryGetValue(key, out string v) && !string.IsNullOrWhiteSpace(v) ? v : defaultValue;
+ }
+}
diff --git a/CERTInext.Tests/CERTInext.Tests.csproj b/CERTInext.Tests/CERTInext.Tests.csproj
index 84ce7a6..fcd3697 100644
--- a/CERTInext.Tests/CERTInext.Tests.csproj
+++ b/CERTInext.Tests/CERTInext.Tests.csproj
@@ -21,6 +21,7 @@
exist, so exclude these files unless SUPPORTS_DCV is defined. See issue 0003. -->
+
diff --git a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs
index f684f7d..1a9faa9 100644
--- a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs
+++ b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs
@@ -259,6 +259,60 @@ public async Task RenewOrReissue_CallsRenewApi_WhenCertWithinRenewalWindow()
It.IsAny()), Times.Never);
}
+ // ---------------------------------------------------------------------------
+ // A1d-2: renewal within window carries the template's product code onto the
+ // RenewCertificateRequest, not just the connector-level DefaultProductCode.
+ // Regression for issue #26 / local issues/0012.
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task RenewOrReissue_CallsRenewApi_UsesTemplateProductCode()
+ {
+ var clientMock = NewMock();
+ var readerMock = NewReaderMock();
+
+ // Expiry is 30 days in the future, renewal window is 90 days → within window
+ DateTime expiry = DateTime.UtcNow.AddDays(30);
+
+ readerMock
+ .Setup(r => r.GetRequestIDBySerialNumber(It.IsAny()))
+ .ReturnsAsync(MockCertificateData.CertId1);
+
+ readerMock
+ .Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1))
+ .Returns(expiry);
+
+ clientMock
+ .Setup(c => c.RenewCertificateAsync(
+ MockCertificateData.CertId1,
+ It.Is(r => r.ProfileId == MockCertificateData.ProfileIdClient),
+ It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedEnrollResponse("cert-renewed-002"));
+
+ var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object);
+
+ // ProfileId is a non-default value distinct from the connector's DefaultProductCode.
+ var productInfo = MakeProductInfo(profileId: MockCertificateData.ProfileIdClient, extras: new Dictionary
+ {
+ ["PriorCertSN"] = "AABBCCDDEEFF",
+ ["RenewalWindowDays"] = "90"
+ });
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem,
+ subject: "CN=test.example.com",
+ san: null,
+ productInfo: productInfo,
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.RenewOrReissue);
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ clientMock.Verify(c => c.RenewCertificateAsync(
+ MockCertificateData.CertId1,
+ It.Is(r => r.ProfileId == MockCertificateData.ProfileIdClient),
+ It.IsAny()), Times.Once);
+ }
+
// ---------------------------------------------------------------------------
// A1e: PriorCertSN present, cert already expired → new enroll
// Semantics: useRenewalApi = expiry > now && expiry <= now + window.
diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs
index 837ae8d..7350abc 100644
--- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs
+++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs
@@ -35,7 +35,8 @@ private static CERTInextConfig DcvConfig(
int propagationDelaySeconds = 1,
int timeoutMinutes = 1,
int dcvWaitForChallengeSeconds = 0,
- int dcvWaitForIssuanceSeconds = 0) =>
+ int dcvWaitForIssuanceSeconds = 0,
+ int pickupRetries = 0) =>
new CERTInextConfig
{
DcvEnabled = enabled,
@@ -45,7 +46,12 @@ private static CERTInextConfig DcvConfig(
// behaviour and run fast. Tests that exercise the new wait paths can opt
// in with a positive value (see WaitsForChallenge_ToAppear / WaitsForIssuance).
DcvWaitForChallengeSeconds = dcvWaitForChallengeSeconds,
- DcvWaitForIssuanceSeconds = dcvWaitForIssuanceSeconds
+ DcvWaitForIssuanceSeconds = dcvWaitForIssuanceSeconds,
+ // Disable the synchronous pickup poll by default (same reasoning as the wait
+ // budgets above): the DCV path owns issuance for these tests, and a DCV-disabled
+ // or no-factory case that ends on a pending result must not pay the real pickup
+ // Task.Delay loop. The dedicated pickup tests live in CERTInextCAPluginTests.
+ PickupRetries = pickupRetries
};
private static Mock NewMock() =>
@@ -437,17 +443,19 @@ public async Task Dcv_Skipped_WhenOrderStatusIdIsTerminal_EvenIfDcvValidated(str
});
var validator = new FakeDomainValidator();
- // Issuance-wait budget > 0 so a wrong-path entry would manifest as a
- // GetCertificate call we DON'T expect.
+ // Issuance-wait budget > 0 AND pickup ENABLED (pickupRetries > 0) so a wrong-path
+ // entry would manifest as a GetCertificate call we DON'T expect — this test must
+ // fail if either the DCV issuance-wait guard OR the synchronous-pickup gate
+ // (dcvIssuanceWaitRan) regresses and starts polling a cancelled/rejected order.
var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
- DcvConfig(dcvWaitForIssuanceSeconds: 10));
+ DcvConfig(dcvWaitForIssuanceSeconds: 10, pickupRetries: 5));
await Enroll(plugin);
mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()),
Times.Never,
- "Enroll must not enter WaitForIssuanceAfterDcvAsync when the order is " +
- "cancelled/rejected, even if DCV happens to be in a 'validated' state");
+ "Enroll must not enter WaitForIssuanceAfterDcvAsync OR the synchronous pickup poll " +
+ "when the order is cancelled/rejected, even if DCV happens to be in a 'validated' state");
validator.StagedRecords.Should().BeEmpty(
"DCV staging must not run for a cancelled/rejected order");
}
@@ -519,7 +527,7 @@ public async Task SyncDcvRetry_DoesSingleShotTrackOrder_WhenChallengeNotReady()
// ---------------------------------------------------------------------------
[Fact]
- public async Task Dcv_Throws_WhenNoProviderForDomain()
+ public async Task Dcv_SkipsAndDefers_WhenNoProviderForDomain()
{
var mock = NewMock();
mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
@@ -531,17 +539,19 @@ public async Task Dcv_Throws_WhenNoProviderForDomain()
mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny()))
.ReturnsAsync(MockCertificateData.DcvTokenResponse());
- // Factory returns null → no DNS provider configured
+ // Factory returns null → no DNS provider configured. Regression: this used to throw and
+ // fail the whole order — including when the "unresolvable" domain was actually just a
+ // non-DNS Subject CN with no config-level way to prevent the throw (SubmitNonDnsSans only
+ // filters the SAN list, not the subject). Now it is logged loudly and deferred instead.
var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator: null));
Func act = () => Enroll(plugin);
- await act.Should().ThrowAsync()
- .WithMessage("*No DNS provider plugin is configured*");
+ await act.Should().NotThrowAsync();
}
[Fact]
- public async Task Dcv_Throws_WhenStageValidationFails()
+ public async Task Dcv_SkipsAndDefers_WhenStageValidationFails()
{
var mock = NewMock();
mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
@@ -558,10 +568,12 @@ public async Task Dcv_Throws_WhenStageValidationFails()
Func act = () => Enroll(plugin);
- await act.Should().ThrowAsync()
- .WithMessage("*Failed to stage DNS validation*DNS zone not writable*");
+ // Regression: a StageValidation failure used to throw and fail the whole order. Now it
+ // is logged loudly and the domain is skipped/deferred — this is the only pending domain,
+ // so nothing gets staged and the order defers to the next sync cycle.
+ await act.Should().NotThrowAsync();
- // No VerifyDcv call — failed before reaching that step
+ // No VerifyDcv call — nothing was staged to verify
mock.Verify(c => c.VerifyDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
@@ -594,7 +606,7 @@ public async Task Dcv_CleanupAlwaysCalled_EvenWhenVerifyDcvThrows()
}
[Fact]
- public async Task Dcv_Throws_WhenGetDcvReturnsNoToken()
+ public async Task Dcv_SkipsAndDefers_WhenGetDcvReturnsNoToken()
{
var mock = NewMock();
mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
@@ -611,8 +623,11 @@ public async Task Dcv_Throws_WhenGetDcvReturnsNoToken()
Func act = () => Enroll(plugin);
- await act.Should().ThrowAsync()
- .WithMessage("*GetDcv returned no token*");
+ // Regression: an empty token used to throw and fail the whole order. It is now logged
+ // loudly (LogError) and the domain is skipped — the order defers to the next sync cycle
+ // rather than failing Enroll with an order already placed at the CA.
+ await act.Should().NotThrowAsync();
+ validator.StagedRecords.Should().BeEmpty("the only pending domain returned no token, so nothing should have been staged");
}
// ---------------------------------------------------------------------------
@@ -681,11 +696,16 @@ public async Task Dcv_Defers_When_GetDcv_ReturnsInvalidRequestMessage_WithoutEms
}
[Fact]
- public async Task Dcv_Rethrows_When_GetDcv_FailsWithUnrelatedError()
+ public async Task Dcv_SkipsAndDefers_WhenGetDcvFailsWithUnrelatedError()
{
- // Tolerance is narrow: a genuine server error (5xx, transport, auth) must still
- // bubble up so the gateway treats the enrollment as failed and the operator can
- // diagnose. This guards against accidentally swallowing every GetDcv exception.
+ // Regression: this test used to assert the opposite — that a genuine server error (5xx,
+ // transport, auth) must bubble up and fail the whole enrollment. That is exactly the
+ // orphaned-order failure mode: GetDcv's live behavior for a non-DNS order-domain is
+ // unmeasured (see BuildSanList's sandbox-only caveat), so treating any unrecognized
+ // GetDcv error as fatal risks failing perfectly good co-tenant DNS domains on the same
+ // order over one domain's transient or CA-side issue, with the enrollment already
+ // placed at CERTInext and no catch anywhere above this call. The failure is still loud
+ // (LogError, with the underlying exception) — it just no longer fails the call.
var mock = NewMock();
mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending_dcv" });
@@ -700,8 +720,8 @@ public async Task Dcv_Rethrows_When_GetDcv_FailsWithUnrelatedError()
var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
Func act = () => Enroll(plugin);
- await act.Should().ThrowAsync()
- .WithMessage("*HTTP 500*");
+ await act.Should().NotThrowAsync();
+ validator.StagedRecords.Should().BeEmpty("the only pending domain's GetDcv call failed, so nothing should have been staged");
}
// ---------------------------------------------------------------------------
@@ -816,5 +836,507 @@ public async Task Dcv_WaitsForIssuance_AfterDcvVerifies()
mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()),
Times.AtLeast(2), "plugin should have polled at least twice for issuance");
}
+
+ // ---------------------------------------------------------------------------
+ // Undrainable pending domains must not strand the valid ones on the same order
+ // ---------------------------------------------------------------------------
+
+ /// Builds a DomainVerificationDetail JsonElement for the given dcvStatus.
+ private static System.Text.Json.JsonElement DcvDetail(string dcvStatus) =>
+ System.Text.Json.JsonSerializer.SerializeToElement(new DomainVerificationDetail
+ {
+ DcvMethod = Constants.Dcv.MethodDnsTxt,
+ DcvStatus = dcvStatus,
+ Status = "1"
+ });
+
+ ///
+ /// Builds a TrackOrder response whose domainVerification block lists several pending
+ /// domains, so tests can mix validatable and unvalidatable keys on one order.
+ ///
+ private static TrackOrderResponse DcvPendingTrackResponseMultiDomain(
+ string orderNumber, params string[] domains)
+ {
+ var detail = DcvDetail(Constants.Dcv.StatusPending);
+ var raw = new Dictionary();
+ foreach (string d in domains)
+ raw[d] = detail;
+
+ return new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "1",
+ CertificateStatusId = "1",
+ DomainVerification = new TrackOrderDomainVerification
+ {
+ Status = Constants.Dcv.StatusPending,
+ RawDomainEntries = raw
+ }
+ }
+ };
+ }
+
+ ///
+ /// Builds a TrackOrder response with one already-validated domain (dcvStatus=1) and one
+ /// still-pending, unresolvable domain (dcvStatus=0) — the shape CERTInext produces when it
+ /// has cached a prior DCV validation for the CN while a non-DNS SAN on the same order is
+ /// still outstanding.
+ ///
+ private static TrackOrderResponse DcvMixedStatusTrackResponse(
+ string validatedDomain, string pendingDomain)
+ {
+ var validated = DcvDetail(Constants.Dcv.StatusValidated);
+ var pending = DcvDetail(Constants.Dcv.StatusPending);
+
+ return new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "1",
+ CertificateStatusId = "1",
+ DomainVerification = new TrackOrderDomainVerification
+ {
+ // Aggregate stays pending because one domain still is — this must not take
+ // the early "already validated" return at the top of the method.
+ Status = Constants.Dcv.StatusPending,
+ RawDomainEntries = new Dictionary
+ {
+ [validatedDomain] = validated,
+ [pendingDomain] = pending
+ }
+ }
+ }
+ };
+ }
+
+ ///
+ /// Regression for the false invariant behind the round-1 fix's own misconfiguration check:
+ /// "the CN is always a pending domain too" is untrue whenever CERTInext has cached a prior
+ /// DCV validation for it (a case this same file's cached-validation branch documents), so a
+ /// non-DNS SAN sharing the order with an already-validated CN must not throw — it must defer
+ /// to the next sync cycle exactly like the single-domain case does.
+ ///
+ [Fact]
+ public async Task Dcv_CachedCnPlusUnresolvableSan_DefersWithoutThrowing()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ const string cn = MockCertificateData.DcvDomain;
+ const string ip = "192.0.2.10";
+
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending" });
+
+ mock.Setup(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvMixedStatusTrackResponse(validatedDomain: cn, pendingDomain: ip));
+
+ // The IP clears the FQDN regex and reaches GetDcv, per the sandbox-measured shape.
+ mock.Setup(c => c.GetDcvAsync(order, ip, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken));
+
+ var validator = new FakeDomainValidator();
+ // Resolves for the CN (a real, working DNS provider) but not for the IP literal — the
+ // scenario that must prove "a provider IS deployed" rather than "nothing is deployed".
+ var plugin = BuildPlugin(
+ mock.Object,
+ new FakeDomainValidatorFactory(validator, resolvableDomain: cn),
+ DcvConfig());
+
+ Func act = () => Enroll(plugin);
+
+ await act.Should().NotThrowAsync(
+ "an unresolvable non-DNS SAN must defer the order to the next sync cycle, not fail " +
+ "the enrollment — the CN having cached DCV proves a provider is deployed and working, " +
+ "so this is not the 'nothing is deployed' misconfiguration case");
+
+ validator.StagedRecords.Should().BeEmpty(
+ "the only pending domain is unresolvable, so nothing should have been staged");
+ }
+
+ ///
+ /// A non-FQDN pending domain must be skipped, not thrown on.
+ ///
+ /// Regression: non-DNS SANs are now submitted to CERTInext, which registers them verbatim
+ /// as order domains, so an email/URI SAN turns up as a domainVerification key that fails the
+ /// FQDN check. That check used to throw for the whole order — escaping Enroll (which has no
+ /// catch) after the order was already placed, so the enrollment failed with an orphaned
+ /// order and no TXT record was staged for the *valid* domains beside it. Every sync retry
+ /// re-threw and TryRunDcvDuringSyncAsync swallowed it, so the order could never progress.
+ ///
+ [Fact]
+ public async Task Dcv_NonFqdnPendingDomain_IsSkipped_AndValidDomainStillStaged()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ const string good = MockCertificateData.DcvDomain;
+ const string bad = "admin@example.com"; // what an rfc822 SAN comes back as
+
+ var mock = NewMock();
+
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" });
+
+ mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad))
+ .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good));
+
+ // Only the valid domain should ever reach GetDcv/VerifyDcv. MockBehavior.Strict means
+ // an unexpected call for `bad` fails the test on its own.
+ mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken));
+ mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ mock.Setup(c => c.GetCertificateAsync(order, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(order));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ // Must not throw — that is the regression.
+ var result = await Enroll(plugin);
+
+ string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good);
+ validator.StagedRecords.Should().ContainSingle(
+ "the valid DNS domain must still be staged even though a co-tenant domain is unusable")
+ .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken));
+
+ mock.Verify(c => c.GetDcvAsync(order, bad, It.IsAny(), It.IsAny()),
+ Times.Never, "a non-FQDN domain must never be sent to GetDcv");
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ }
+
+ ///
+ /// Regression: the FQDN validation regex used ^...$ , and in .NET's default (non-Multiline)
+ /// mode $ matches immediately before a single trailing '\n', not only at the true end of the
+ /// string. A domain value ending in '\n' therefore passed as "valid" and reached several log
+ /// sinks unsanitized further down this same method — a CWE-117 log-injection route into the
+ /// DCV audit trail, reachable via any order visible through Synchronize/GetSingleRecord (not
+ /// just ones this plugin's own Enroll call placed, since TrackOrder's domainVerification keys
+ /// for an externally-created order are never trimmed by this plugin). The regex now anchors
+ /// with \A/\z, which are absolute string-start/end regardless of trailing newlines.
+ ///
+ [Fact]
+ public async Task Dcv_DomainWithTrailingNewline_IsRejectedAsInvalid_AndValidDomainStillStaged()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ const string good = MockCertificateData.DcvDomain;
+ const string bad = "evil.example.com\n";
+
+ var mock = NewMock();
+
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" });
+
+ mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad))
+ .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good));
+
+ // MockBehavior.Strict: an unexpected GetDcv call for `bad` fails the test on its own —
+ // if the regex fix regressed, this domain would reach GetDcv instead of being rejected
+ // by the FQDN check before the staging loop even starts.
+ mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken));
+ mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ mock.Setup(c => c.GetCertificateAsync(order, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(order));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ var result = await Enroll(plugin);
+
+ string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good);
+ validator.StagedRecords.Should().ContainSingle(
+ "the valid domain must still be staged even though a co-tenant domain carries a " +
+ "trailing newline")
+ .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken));
+
+ mock.Verify(c => c.GetDcvAsync(order, bad, It.IsAny(), It.IsAny()),
+ Times.Never, "a domain with a trailing newline must never be sent to GetDcv");
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ }
+
+ ///
+ /// Regression: the generic per-domain catch blocks around GetDcvAsync and StageValidation
+ /// used to catch OperationCanceledException along with genuine GetDcv/DNS-provider failures,
+ /// logging and skipping the domain as an ordinary per-domain failure. A cancellation (the
+ /// shared DcvTimeoutMinutes-bound token expiring mid-loop) is not that — it must propagate to
+ /// the outer catch instead, which is the only place that logs it correctly and is the
+ /// intended timeout-handling path documented at the top of this method's DCV timeout setup.
+ ///
+ [Fact]
+ public async Task Dcv_CancellationDuringGetDcv_PropagatesRatherThanBeingSkippedAsPerDomainFailure()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending_dcv" });
+
+ mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse());
+
+ mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ThrowsAsync(new OperationCanceledException("DCV timeout budget exceeded"));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ Func act = () => Enroll(plugin);
+
+ // Must propagate as a cancellation, not be swallowed and reported as "GetDcv failed" in
+ // the skipped-domains summary while Enroll completes normally.
+ await act.Should().ThrowAsync();
+ }
+
+ ///
+ /// A pending domain that resolves no DNS provider (an IP-literal SAN passes the FQDN regex
+ /// but no zone can match it) must likewise be skipped rather than failing the whole order.
+ ///
+ [Fact]
+ public async Task Dcv_DomainWithNoResolvableValidator_IsSkipped_AndValidDomainStillStaged()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ const string good = MockCertificateData.DcvDomain;
+ const string ip = "192.0.2.10"; // what an iPAddress SAN comes back as
+
+ var mock = NewMock();
+
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" });
+
+ mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, ip))
+ .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good));
+
+ // The IP literal clears the FQDN filter, so GetDcv IS called for it; the dead end is
+ // that no validator resolves. Stub it so reaching that point is legitimate.
+ mock.Setup(c => c.GetDcvAsync(order, It.IsAny(), Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken));
+ mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ mock.Setup(c => c.GetCertificateAsync(order, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(order));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(
+ mock.Object,
+ new FakeDomainValidatorFactory(validator, resolvableDomain: good),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ var result = await Enroll(plugin);
+
+ string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good);
+ validator.StagedRecords.Should().ContainSingle(
+ "only the domain with a resolvable provider should be staged, and it must still be staged")
+ .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken));
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ }
+
+ ///
+ /// Regression: the compensating cleanup call after an early exit from staging (chiefly the
+ /// shared DcvTimeoutMinutes-bound token firing mid-loop, which is what this scenario
+ /// simulates via a domain whose GetDcv call raises OperationCanceledException) must not reuse
+ /// the same token the operation was cancelled by. A cooperative IDomainValidator that forwards
+ /// its token into its own HTTP calls (the reference CloudflareDomainValidator in this repo
+ /// does exactly that) would otherwise throw immediately on an already-cancelled token and
+ /// never even attempt the delete, silently leaving the TXT record published.
+ ///
+ /// CancellationToken.None would fix that but removes the cleanup call's timeout bound
+ /// entirely — a second, adversarially-found regression on top of the first — so the correct
+ /// fix is a fresh token with its OWN short timeout: not cancelled going in, but still bounded.
+ ///
+ [Fact]
+ public async Task Dcv_CleanupAfterCancellation_UsesAFreshBoundedToken_NotTheAmbientToken()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ const string good = "a.example.com";
+ const string bad = "b.example.com";
+
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" });
+
+ mock.Setup(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad));
+
+ mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-a"));
+ // Domain 'good' is processed first (Dictionary enumeration order matches insertion order
+ // in practice for the small dictionaries this test builds); 'bad' then throws, driving the
+ // outer catch's cleanup of the already-staged 'good' entry.
+ mock.Setup(c => c.GetDcvAsync(order, bad, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ThrowsAsync(new OperationCanceledException("DCV timeout budget exceeded"));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ Func act = () => Enroll(plugin);
+ await act.Should().ThrowAsync();
+
+ validator.StagedRecords.Should().ContainSingle(
+ "'good' must have staged before 'bad' threw, for this test to exercise cleanup at all");
+ var cleanupToken = validator.CleanupTokens.Should().ContainSingle(
+ "the staged entry must go through the cancellation cleanup path exactly once").Subject;
+
+ cleanupToken.IsCancellationRequested.Should().BeFalse(
+ "cleanup is a best-effort compensating action and must run with its own token, " +
+ "not the already-cancelled ambient one");
+ cleanupToken.CanBeCanceled.Should().BeTrue(
+ "the cleanup call must still be bounded by its own timeout, not unbounded " +
+ "(CancellationToken.None) — a hanging DNS-provider call must not block forever");
+ }
+
+ ///
+ /// Regression: the routine, always-runs finally-block cleanup used to iterate staged domains
+ /// sequentially. Each cleanup call already has its own independent
+ /// CleanupValidationTimeoutSeconds bound, but running them one after another meant that
+ /// bound was per-call, not in aggregate — a UCC order with N staged domains could hold the
+ /// calling request open for up to N x the per-call ceiling if the DNS provider was merely
+ /// slow (not even hung) on every delete, which can exceed DcvTimeoutMinutes itself for a
+ /// realistic multi-SAN count.
+ ///
+ /// Proven directly via — the number
+ /// of CleanupValidation calls the validator observed in flight at once — rather than total
+ /// wall-clock time. 0023: a prior version of this test asserted elapsed time < 4000ms, which
+ /// failed deterministically (~4801ms) because the surrounding DCV flow carries ~4s of fixed
+ /// overhead unrelated to cleanup concurrency (DcvConfig's 1s propagation delay plus
+ /// WaitForDcvVerificationAsync's separate, hardcoded 3s poll interval,
+ /// Constants.Dcv.SyncPropagationDelaySeconds — not the 1s the old comment assumed), on top of
+ /// which the (already-concurrent) ~800ms cleanup pushed the total past the threshold. That was
+ /// a test-design defect present since the test was introduced, not a cleanup regression — the
+ /// finally block here already runs cleanup via Task.WhenAll. Measuring peak concurrency proves
+ /// the same thing the wall-clock check intended, without being coupled to unrelated fixed
+ /// delays elsewhere in the flow.
+ ///
+ [Fact]
+ public async Task Dcv_CleanupOfMultipleDomains_RunsConcurrently_NotSequentially()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ string[] domains = { "a.example.com", "b.example.com", "c.example.com" };
+ var cleanupDelay = TimeSpan.FromMilliseconds(800);
+
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" });
+
+ var verifiedDetail = DcvDetail(Constants.Dcv.StatusValidated);
+ var verifiedRaw = new Dictionary();
+ foreach (string d in domains) verifiedRaw[d] = verifiedDetail;
+
+ mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, domains))
+ .ReturnsAsync(new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "1",
+ CertificateStatusId = "1",
+ DomainVerification = new TrackOrderDomainVerification
+ {
+ Status = Constants.Dcv.StatusValidated,
+ RawDomainEntries = verifiedRaw
+ }
+ }
+ });
+
+ foreach (string d in domains)
+ {
+ mock.Setup(c => c.GetDcvAsync(order, d, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse($"token-{d}"));
+ mock.Setup(c => c.VerifyDcvAsync(order, d, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ }
+ mock.Setup(c => c.GetCertificateAsync(order, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(order));
+
+ var validator = new FakeDomainValidator { CleanupDelay = cleanupDelay };
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ await Enroll(plugin);
+
+ validator.CleanedUpKeys.Should().HaveCount(3, "all three staged domains must be cleaned up");
+
+ // Direct proof of concurrency: all three CleanupValidation calls must have been in
+ // flight at the same instant. If cleanup ran sequentially, PeakConcurrentCleanups would
+ // be 1 regardless of how long the whole call took — this assertion doesn't depend on any
+ // wall-clock budget or on the fixed overhead elsewhere in the DCV flow (see 0023).
+ validator.PeakConcurrentCleanups.Should().Be(3,
+ "cleanup for independent domains must run concurrently, not sequentially — " +
+ "all three CleanupValidation calls should have been in flight at once");
+ }
+
+ ///
+ /// Regression: a StageValidation failure on one domain of a multi-domain order must not
+ /// leave the TXT records already published for the earlier domains orphaned. Before the
+ /// fix, the staging loop's throw sites were outside the try/finally that owns cleanup, so
+ /// this was reachable only by accident (pre-fix, a UCC order's SANs never reached CERTInext
+ /// at all, so an order rarely had more than one pending domain to stage). Submitting every
+ /// requested SAN makes multi-domain staging the normal case, so this must hold now.
+ ///
+ [Fact]
+ public async Task Dcv_StageFailureOnSecondDomain_DoesNotAbortTheGoodDomain()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ const string good = "a.example.com";
+ const string bad = "b.example.com";
+
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" });
+
+ // First TrackOrder call (inside PerformDcvIfNeededAsync) sees both domains pending;
+ // the second (WaitForDcvVerificationAsync's poll after staging/VerifyDcv) sees the one
+ // domain that actually got staged — 'good' — as verified.
+ mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad))
+ .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good));
+
+ mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-a"));
+ mock.Setup(c => c.GetDcvAsync(order, bad, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-b"));
+
+ mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ mock.Setup(c => c.GetCertificateAsync(order, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(order));
+
+ var validator = new FakeDomainValidator
+ {
+ ShouldFail = key => key.Contains(bad, StringComparison.OrdinalIgnoreCase)
+ };
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ Func act = () => Enroll(plugin);
+
+ // Regression: a StageValidation failure on one domain of a multi-domain order must not
+ // abort the whole order any more — it did before this fix, which both failed the
+ // enrollment with an orphaned CERTInext order AND (before an earlier round's fix)
+ // orphaned the 'good' domain's already-published TXT record. Now the bad domain is
+ // skipped (logged loudly) and the good domain proceeds through the normal DCV lifecycle.
+ await act.Should().NotThrowAsync();
+
+ string goodHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good);
+ string badHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, bad);
+
+ validator.StagedRecords.Should().ContainSingle(
+ "only the domain that did not fail to stage should ever have been staged")
+ .Which.key.Should().Be(goodHostname);
+ validator.CleanedUpKeys.Should().Contain(goodHostname,
+ "the good domain completes its normal verify-then-cleanup lifecycle");
+ validator.CleanedUpKeys.Should().NotContain(badHostname,
+ "the bad domain was never staged, so there is nothing to clean up for it");
+ }
}
}
diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs
index 3ec5df1..5154146 100644
--- a/CERTInext.Tests/CERTInextCAPluginTests.cs
+++ b/CERTInext.Tests/CERTInextCAPluginTests.cs
@@ -31,8 +31,20 @@ public class CERTInextCAPluginTests
// Helpers
// ---------------------------------------------------------------------------
+ // Pickup is disabled by default in the broad fixture (PickupRetries=0) — mirroring how
+ // DcvConfig defaults its wait budgets to 0 — so tests that don't care about the
+ // synchronous pickup don't pay its real Task.Delay-based poll. Tests that DO exercise
+ // pickup opt in via BuildPluginWithPickup.
private static CERTInextCAPlugin BuildPlugin(ICERTInextClient client) =>
- new CERTInextCAPlugin(client);
+ new CERTInextCAPlugin(client, new CERTInextConfig { PickupRetries = 0 });
+
+ // Pickup-enabled fixture for the synchronous-pickup tests. PickupDelay is clamped to a
+ // 1s floor and the loop adds a fixed 5s initial delay, so these tests are intentionally
+ // a few seconds each.
+ private static CERTInextCAPlugin BuildPluginWithPickup(
+ ICERTInextClient client, int retries, int delaySeconds = 1) =>
+ new CERTInextCAPlugin(client,
+ new CERTInextConfig { PickupRetries = retries, PickupDelayInSeconds = delaySeconds });
private static Mock NewMock() => new Mock(MockBehavior.Strict);
@@ -345,6 +357,127 @@ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval()
result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
}
+ [Fact]
+ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReportsIssuedButBodyMissing()
+ {
+ // CERTInext can report an "issued"/auto-approved certificateStatusId before the
+ // certificate bytes actually exist — the immediate GetCertificate download fails
+ // and the legacy client returns Status="issued" with Certificate=null. Reporting
+ // GENERATED with no PEM crashes the gateway framework's PEM parser downstream, so
+ // the plugin must demote this to pending rather than trust the raw status string.
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(MockCertificateData.AutoApprovedNoBodyEnrollResponse());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 0);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem,
+ subject: "CN=test.example.com",
+ san: null,
+ productInfo: MakeProductInfo(),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ result.Certificate.Should().BeNullOrEmpty();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Synchronous certificate pickup (Sectigo parity)
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Pickup_Disabled_WhenPickupRetriesZero_ReturnsPendingWithoutPolling()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingEnrollResponse());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 0);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
+ productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()),
+ Times.Never, "PickupRetries=0 must disable the synchronous pickup poll");
+ }
+
+ [Fact]
+ public async Task Pickup_ReturnsIssuedCert_WhenOrderIssuesDuringPoll()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingEnrollResponse());
+ // The order finishes issuing by the time we poll: GetCertificate reports issued + PEM.
+ mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 2);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
+ productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ result.Certificate.Should().NotBeNullOrEmpty("a synchronously-picked-up cert must carry its PEM");
+ mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()),
+ Times.AtLeastOnce);
+ }
+
+ [Fact]
+ public async Task Pickup_SurfacesTerminalStatus_WhenOrderRevokedDuringPoll()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingEnrollResponse());
+ mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.RevokedCertRecord());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 3);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
+ productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.REVOKED,
+ "a terminal status observed during pickup is surfaced immediately, not polled to exhaustion");
+ }
+
+ [Fact]
+ public async Task Pickup_ReturnsPending_WhenOrderNeverIssuesWithinBudget()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingEnrollResponse());
+ // Every poll still reports pending — the budget is exhausted and Enroll returns the
+ // pending result for a later sync to complete.
+ mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingCertRecord());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 1);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
+ productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()),
+ Times.AtLeastOnce, "an enabled pickup must actually poll before giving up");
+ }
+
[Fact]
public async Task Enroll_New_Throws_WhenProfileIdNotSet()
{
diff --git a/CERTInext.Tests/CERTInextCAPluginV2DcvTests.cs b/CERTInext.Tests/CERTInextCAPluginV2DcvTests.cs
new file mode 100644
index 0000000..fac4dfa
--- /dev/null
+++ b/CERTInext.Tests/CERTInextCAPluginV2DcvTests.cs
@@ -0,0 +1,213 @@
+// Copyright 2026 Keyfactor
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using System.Collections.Generic;
+using System.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 Moq;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Regression tests for issues/0020: EMS-1080 ("Domain is already verified") from either
+ /// V2 DCV entry point (GetDcvV2Async or VerifyDcvV2Async) must be treated as
+ /// DCV already satisfied — skip TXT publish, proceed straight to tracking — not as a
+ /// failure deferred to the next sync cycle. Driven end-to-end through
+ /// (V2 path) so the assertions exercise the same
+ /// code path Command actually calls, using to observe
+ /// whether a TXT record was ever staged.
+ ///
+ public class CERTInextCAPluginV2DcvTests
+ {
+ private static Mock NewMock() =>
+ new Mock(MockBehavior.Strict);
+
+ private static CERTInextCAPlugin BuildV2DcvPlugin(
+ ICERTInextClient client, IDomainValidatorFactory factory) =>
+ new CERTInextCAPlugin(client, factory, new CERTInextConfig
+ {
+ UseV2Api = true,
+ ApiUrlV2 = "https://v2.certinext.io",
+ ClientId = "my-client",
+ ClientSecret = "my-secret",
+ ApiUrl = "https://v1.certinext.io",
+ AccountNumber = "12345",
+ AuthMode = "AccessKey",
+ ApiKey = "v1-key",
+ RequestorName = "Test User",
+ RequestorEmail = "test@example.com",
+ SignerIp = "1.2.3.4",
+ SignerPlace = "New York",
+ PickupRetries = 0,
+ DcvEnabled = true,
+ DcvTimeoutMinutes = 1,
+ DcvPropagationDelaySeconds = 1
+ });
+
+ private static EnrollmentProductInfo MakeV2ProductInfo() =>
+ new EnrollmentProductInfo
+ {
+ ProductID = "DV SSL",
+ ProductParameters = new Dictionary(System.StringComparer.OrdinalIgnoreCase)
+ {
+ ["ProductCode"] = "842",
+ ["ProductFamily"] = "ssl",
+ ["ProductVariant"] = "dv",
+ ["DomainName"] = "example.com"
+ }
+ };
+
+ private static Task Enroll(CERTInextCAPlugin plugin) =>
+ plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem,
+ subject: "CN=example.com",
+ san: new Dictionary { ["dns"] = new[] { "example.com" } },
+ productInfo: MakeV2ProductInfo(),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ private const string OrderId = "ord_ems1080_001";
+
+ private static V2CreateOrderResponse PlaceOrderResponse() =>
+ new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-dcv" };
+
+ private static V2OrderStatusResponse PendingDcvStatus() =>
+ new V2OrderStatusResponse { OrderId = OrderId, Status = "pending-dcv", Domain = "example.com" };
+
+ private static V2OrderStatusResponse IssuedStatus() =>
+ new V2OrderStatusResponse { OrderId = OrderId, Status = "issued", Domain = "example.com" };
+
+ private static V2CertificateDownloadResponse DownloadResponse() =>
+ new V2CertificateDownloadResponse
+ {
+ OrderId = OrderId,
+ SerialNumber = "AA11BB22",
+ CertificatePem = MockCertificateData.FakePemCertificate
+ };
+
+ // ---------------------------------------------------------------------------
+ // Regression (issues/0020): GetDcv returns EMS-1080
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task PerformDcvV2_GetDcvReturnsEms1080_TreatedAsSatisfied_NoStagingAndProceedsToTracking()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.PlaceOrderV2Async(
+ It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(PlaceOrderResponse());
+
+ mock.Setup(c => c.SubmitCsrV2Async(
+ It.IsAny(), OrderId, It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ // 1st call: post-CSR check (pending-dcv). 2nd+: the PerformDcvV2IfNeededAsync poll
+ // loop and EnrollV2Async's post-DCV re-check both see "issued" immediately.
+ mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()))
+ .ReturnsAsync(PendingDcvStatus())
+ .ReturnsAsync(IssuedStatus())
+ .ReturnsAsync(IssuedStatus());
+
+ mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new System.Exception(
+ $"CERTInext V2 API error during 'V2 get DCV challenge'. HTTP 422. " +
+ "Unprocessable Entity: EMS-1080 Domain is already verified."));
+
+ mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny()))
+ .ReturnsAsync(DownloadResponse());
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ var result = await Enroll(plugin);
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED,
+ "EMS-1080 must be treated as DCV satisfied, not a failure — the order should " +
+ "proceed to tracking and come back issued");
+ validator.StagedRecords.Should().BeEmpty(
+ "GetDcv returning EMS-1080 means there is no fresh challenge to publish");
+
+ // VerifyDcv must never be reached — there is nothing to verify when GetDcv itself
+ // reports the domain is already verified.
+ mock.Verify(c => c.VerifyDcvV2Async(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Regression (issues/0020): VerifyDcv returns EMS-1080
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task PerformDcvV2_VerifyDcvReturnsEms1080_TreatedAsSatisfied_ProceedsToTracking()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.PlaceOrderV2Async(
+ It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(PlaceOrderResponse());
+
+ mock.Setup(c => c.SubmitCsrV2Async(
+ It.IsAny(), OrderId, It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ mock.SetupSequence(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()))
+ .ReturnsAsync(PendingDcvStatus())
+ .ReturnsAsync(IssuedStatus())
+ .ReturnsAsync(IssuedStatus());
+
+ // GetDcv succeeds normally and returns a token to publish...
+ mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new V2DcvChallengeResponse
+ {
+ OrderNumber = OrderId,
+ DomainName = "example.com",
+ DcvMethod = "2",
+ FileNameContent = "dcv-token-abc123"
+ });
+
+ // ...but by the time VerifyDcv is called, the domain became already-verified.
+ mock.Setup(c => c.VerifyDcvV2Async(OrderId, "example.com", It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new System.Exception(
+ $"CERTInext V2 API error during 'V2 verify DCV'. HTTP 422. " +
+ "Unprocessable Entity: EMS-1080 Domain is already verified."));
+
+ mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny()))
+ .ReturnsAsync(DownloadResponse());
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildV2DcvPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ var result = await Enroll(plugin);
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED,
+ "EMS-1080 from VerifyDcv must be treated as verified, not a failure — the order " +
+ "should proceed to tracking and come back issued");
+
+ // The TXT record was staged (GetDcv succeeded) — this exercises the "verified between
+ // GetDcv and VerifyDcv" race rather than the GetDcv-level no-op.
+ validator.StagedRecords.Should().ContainSingle();
+ validator.CleanedUpKeys.Should().ContainSingle(
+ "staged records are always cleaned up, including on the EMS-1080 verify path");
+ }
+ }
+}
diff --git a/CERTInext.Tests/CERTInextCAPluginV2Tests.cs b/CERTInext.Tests/CERTInextCAPluginV2Tests.cs
new file mode 100644
index 0000000..f217b1a
--- /dev/null
+++ b/CERTInext.Tests/CERTInextCAPluginV2Tests.cs
@@ -0,0 +1,656 @@
+// 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.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.Extensions.CAPlugin.CERTInext;
+using Keyfactor.PKI.Enums.EJBCA;
+using Moq;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using WireMock.Server;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Moq-based unit tests that verify V2 dispatch in .
+ /// All V2 client methods are mocked — no network calls are made.
+ ///
+ public class CERTInextCAPluginV2Tests
+ {
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private static Mock NewMock() =>
+ new Mock(MockBehavior.Strict);
+
+ private static CERTInextCAPlugin BuildV2Plugin(ICERTInextClient client) =>
+ new CERTInextCAPlugin(client, new CERTInextConfig
+ {
+ UseV2Api = true,
+ ApiUrlV2 = "https://v2.certinext.io",
+ ClientId = "my-client",
+ ClientSecret = "my-secret",
+ ApiUrl = "https://v1.certinext.io",
+ AccountNumber = "12345",
+ AuthMode = "AccessKey",
+ ApiKey = "v1-key",
+ RequestorName = "Test User",
+ RequestorEmail = "test@example.com",
+ SignerIp = "1.2.3.4",
+ SignerPlace = "New York",
+ PickupRetries = 0
+ });
+
+ private static EnrollmentProductInfo MakeV2ProductInfo(
+ string productCode = "842",
+ string productFamily = "ssl",
+ string productVariant = "dv",
+ string domainName = "example.com")
+ {
+ return new EnrollmentProductInfo
+ {
+ ProductID = "DV SSL",
+ ProductParameters = new Dictionary(System.StringComparer.OrdinalIgnoreCase)
+ {
+ ["ProductCode"] = productCode,
+ ["ProductFamily"] = productFamily,
+ ["ProductVariant"] = productVariant,
+ ["DomainName"] = domainName
+ }
+ };
+ }
+
+ // ---------------------------------------------------------------------------
+ // Ping routes to V2
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Ping_V2Enabled_CallsPingV2Async()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.PingV2Async(It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ var plugin = BuildV2Plugin(mock.Object);
+ await plugin.Ping();
+
+ mock.Verify(c => c.PingV2Async(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Ping_V2Enabled_DoesNotCallV1Ping()
+ {
+ var mock = new Mock(); // Loose — verifying absence
+ mock.Setup(c => c.PingV2Async(It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ var plugin = BuildV2Plugin(mock.Object);
+ await plugin.Ping();
+
+ mock.Verify(c => c.PingAsync(It.IsAny()), Times.Never);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Enroll routes to V2
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Enroll_V2Enabled_PlacesV2Order_PendingResult()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.PlaceOrderV2Async(
+ It.IsAny(), It.IsAny