diff --git a/.gitignore b/.gitignore
index 609bcd8..90c6e9c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,4 +36,14 @@ terraform/terraform.tfvars
# Analysis / scratch — never commit
analysis/
+logs/*
+# AI
+handoffs/
+planning/
+plans/
+plan/
+.claude*
+CLAUDE.md
+.codex*
+AGENTS.md
\ No newline at end of file
diff --git a/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs b/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs
new file mode 100644
index 0000000..493fa82
--- /dev/null
+++ b/CERTInext.IntegrationTests/BlankRequestorLiveTests.cs
@@ -0,0 +1,351 @@
+// 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.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Microsoft.Extensions.Logging;
+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
+{
+ ///
+ /// Live probe: does CERTInext accept a DV order whose requestorInformation.requestorName
+ /// is empty (connector RequestorName="", TechnicalContactName="")? In that
+ /// configuration the plugin sends an empty requestorName and omits technicalPointOfContact
+ /// (blank first name). The test only records CERTInext's answer; it asserts nothing about
+ /// accept vs. reject so either outcome is a valid finding.
+ ///
+ /// It also captures the outbound GenerateOrderSSL body (the client's Trace
+ /// "PlaceOrderAsync request payload" dump, taken with LogSensitiveRequestData=true so
+ /// the empty requestorName is not masked; meta.authKey is always redacted by the client
+ /// and the body is never printed) and asserts requestorName is exactly "", no
+ /// technicalPointOfContact key, and signerName "Keyfactor Gateway". The dump's equivalence to
+ /// the WireMock-captured POST body is pinned by BlankRequestorWireTests in CERTInext.Tests.
+ /// For the TrackOrder response it records only blank/non-blank for requestorName (and whether
+ /// it equals a configured value) and the names of any technical-contact-like JSON keys.
+ ///
+ /// Opt-in: set CERTINEXT_BLANK_REQUESTOR_LIVE=1 as a REAL environment variable (a value in
+ /// ~/.env_certinext is refused). The order, if placed, is revoked/cancelled in a finally
+ /// block and re-read read-only to confirm.
+ ///
+ public class BlankRequestorLiveTests : IClassFixture
+ {
+ private const string OptInFlag = "CERTINEXT_BLANK_REQUESTOR_LIVE";
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ public BlankRequestorLiveTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ private void SkipUnlessOptedIn()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ Skip.If(Environment.GetEnvironmentVariable(OptInFlag) != "1",
+ $"Opt-in: set {OptInFlag}=1 as a real environment variable to place a live sandbox order.");
+
+ string envFile = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".env_certinext");
+ bool inFile = File.Exists(envFile) && File.ReadAllLines(envFile)
+ .Any(l => l.TrimStart().StartsWith(OptInFlag + "=", StringComparison.Ordinal));
+ Skip.If(inFile, $"{OptInFlag} must be a real environment variable, not set in ~/.env_certinext.");
+ }
+
+ private CERTInextConfig BuildBlankRequestorConfig()
+ {
+ var c = _fixture.Config;
+ return new CERTInextConfig
+ {
+ ApiUrl = c.ApiUrl,
+ AuthMode = c.AuthMode,
+ ApiKey = c.ApiKey,
+ AccountNumber = c.AccountNumber,
+ GroupNumber = c.GroupNumber,
+ OrganizationNumber = c.OrganizationNumber,
+ RequestorName = string.Empty,
+ TechnicalContactName = string.Empty,
+ RequestorEmail = c.RequestorEmail,
+ RequestorIsdCode = c.RequestorIsdCode,
+ RequestorMobileNumber = c.RequestorMobileNumber,
+ SignerPlace = c.SignerPlace,
+ SignerIp = c.SignerIp,
+ DefaultProductCode = c.DefaultProductCode,
+ PageSize = c.PageSize,
+ DcvEnabled = false,
+ // Needed so the Trace payload dumps keep the (empty) requestorName verbatim.
+ // authKey is redacted regardless; the dumps are only inspected, never printed.
+ LogSensitiveRequestData = true,
+ };
+ }
+
+ private sealed class CapturingLogger : ILogger
+ {
+ public ConcurrentQueue Messages { get; } = new();
+ public IDisposable BeginScope(TState state) => null;
+ public bool IsEnabled(LogLevel logLevel) => true;
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception,
+ Func formatter) => Messages.Enqueue(formatter(state, exception));
+ }
+
+ private const string PlaceOrderDumpPrefix = "PlaceOrderAsync request payload: ";
+
+ /// Last dump of the given kind whose text contains , parsed; null if absent.
+ private static JsonDocument LastDump(CapturingLogger logger, string startsWith, string marker)
+ {
+ string msg = logger.Messages
+ .Where(m => m != null && m.StartsWith(startsWith, StringComparison.Ordinal) && m.Contains(marker))
+ .LastOrDefault();
+ if (msg == null) return null;
+ string json = msg.Substring(msg.IndexOf('{'));
+ return JsonDocument.Parse(json);
+ }
+
+ /// All values of JSON properties named at any depth (case-insensitive).
+ private static List FindProps(JsonElement e, string name)
+ {
+ var hits = new List();
+ void Walk(JsonElement x)
+ {
+ if (x.ValueKind == JsonValueKind.Object)
+ foreach (var p in x.EnumerateObject())
+ {
+ if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) hits.Add(p.Value);
+ Walk(p.Value);
+ }
+ else if (x.ValueKind == JsonValueKind.Array)
+ foreach (var i in x.EnumerateArray()) Walk(i);
+ }
+ Walk(e);
+ return hits;
+ }
+
+ /// Names of every JSON property (any depth) that looks like a technical-contact field.
+ private static List TechContactLikeKeys(JsonElement e)
+ {
+ var names = new List();
+ void Walk(JsonElement x)
+ {
+ if (x.ValueKind == JsonValueKind.Object)
+ foreach (var p in x.EnumerateObject())
+ {
+ if (Regex.IsMatch(p.Name, "technical|poc|contact", RegexOptions.IgnoreCase)) names.Add(p.Name);
+ Walk(p.Value);
+ }
+ else if (x.ValueKind == JsonValueKind.Array)
+ foreach (var i in x.EnumerateArray()) Walk(i);
+ }
+ Walk(e);
+ return names.Distinct().ToList();
+ }
+
+ private static string GenerateCsrPem(string commonName)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
+ var csr = new Pkcs10CertificationRequest(
+ "SHA256withRSA", new X509Name($"CN={commonName}"), keyPair.Public, null, keyPair.Private);
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ [SkippableFact]
+ public async Task DvOrder_WithBlankRequestorNameAndTechnicalContactName_RecordsCertinextResponse()
+ {
+ SkipUnlessOptedIn();
+
+ string cn = $"blankreq-{Guid.NewGuid():N}".Substring(0, 20) + ".scrup.org";
+ string code = _fixture.Config.DefaultProductCode ?? Constants.Products.DvSsl;
+ var productInfo = new EnrollmentProductInfo
+ {
+ ProductID = code,
+ ProductParameters = new Dictionary
+ {
+ ["ProfileId"] = code,
+ ["ValidityYears"] = "1"
+ }
+ };
+
+ var config = BuildBlankRequestorConfig();
+ // The client builds the order body from ITS config, so it must be constructed from the
+ // blank-requestor config (the shared fixture client carries the populated RequestorName).
+ var client = new CERTInextClient(config);
+ var plugin = new CERTInextCAPlugin(client, config);
+ var created = new List();
+
+ // Process-wide client logger swap (restored on dispose) so the Trace payload dumps can be inspected.
+ var capture = new CapturingLogger();
+ using var loggerOverride = CERTInextClient.OverrideLoggerForTests(capture);
+
+ _output.WriteLine($"PROBE: RequestorName=\"\" TechnicalContactName=\"\" ProductCode={code} CN={cn}");
+ try
+ {
+ try
+ {
+ var result = await plugin.Enroll(
+ GenerateCsrPem(cn), $"CN={cn}",
+ new Dictionary { ["dns"] = new[] { cn } },
+ productInfo, RequestFormat.PKCS10, EnrollmentType.New);
+
+ created.Add(result.CARequestID);
+ _output.WriteLine($"RESULT: ACCEPTED order={result.CARequestID} status={result.Status} " +
+ $"statusMessage={TestOutputScrub.Scrub(result.StatusMessage)}");
+
+ var track = await client.TrackOrderAsync(result.CARequestID);
+ string storedName = track.OrderDetails?.RequestorInformation?.RequestorName;
+ _output.WriteLine($"TRACK: orderStatusId={track.OrderDetails?.OrderStatusId} ({TestOutputScrub.Scrub(track.OrderDetails?.OrderStatus)}), " +
+ $"certificateStatusId={track.OrderDetails?.CertificateStatusId} ({TestOutputScrub.Scrub(track.OrderDetails?.CertificateStatus)}), " +
+ $"stored requestorName blank={string.IsNullOrWhiteSpace(storedName)}");
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"RESULT: REJECTED/FAILED {ex.GetType().Name}: {TestOutputScrub.Scrub(ex.Message)}");
+ if (ex.InnerException != null)
+ _output.WriteLine($" inner {ex.InnerException.GetType().Name}: {TestOutputScrub.Scrub(ex.InnerException.Message)}");
+ }
+ }
+ finally
+ {
+ await CleanupAsync(client, plugin, created);
+ }
+
+ // ---- Outbound body (captured from the live PlaceOrder call; body itself is never printed) ----
+ using var sent = LastDump(capture, PlaceOrderDumpPrefix, cn);
+ Assert.True(sent != null, "No 'PlaceOrderAsync request payload' dump was captured for this order.");
+ var od = sent.RootElement.GetProperty("orderDetails");
+
+ bool hasRi = od.TryGetProperty("requestorInformation", out var ri);
+ bool hasName = hasRi && ri.TryGetProperty("requestorName", out _);
+ string sentName = hasName ? ri.GetProperty("requestorName").GetString() : null;
+ bool hasPoc = od.TryGetProperty("technicalPointOfContact", out _);
+ string sentSigner = od.GetProperty("agreementDetails").GetProperty("signerName").GetString();
+ _output.WriteLine($"WIRE: requestorInformation present={hasRi}; requestorName key present={hasName}, " +
+ $"value is empty string={sentName == string.Empty}, length={sentName?.Length}; " +
+ $"technicalPointOfContact key present={hasPoc}; " +
+ $"agreementDetails.signerName=\"Keyfactor Gateway\" -> {sentSigner == "Keyfactor Gateway"}");
+
+ // ---- What CERTInext hands back for that order (blank/non-blank only; values not printed) ----
+ foreach (string id in created)
+ {
+ using var tracked = LastDump(capture, "TrackOrderAsync response payload (Order=" + id + ")", id);
+ if (tracked == null)
+ {
+ _output.WriteLine($"TRACKRAW {id}: no TrackOrder payload dump captured");
+ continue;
+ }
+ var names = FindProps(tracked.RootElement, "requestorName");
+ // Includes the plugin's built-in fallback name so a CERTInext-side substitution is distinguishable.
+ var configured = new[] { _fixture.Config.RequestorName, _fixture.Config.TechnicalContactName,
+ _fixture.Config.RequestorEmail, _fixture.Config.SignerPlace,
+ "Keyfactor Gateway" }
+ .Where(v => !string.IsNullOrWhiteSpace(v)).ToList();
+ foreach (var n in names)
+ {
+ string v = n.ValueKind == JsonValueKind.String ? n.GetString() : null;
+ _output.WriteLine($"TRACKRAW {id}: requestorName kind={n.ValueKind}, blank={string.IsNullOrWhiteSpace(v)}, " +
+ $"equals a configured value or the built-in fallback={(v != null && configured.Contains(v, StringComparer.OrdinalIgnoreCase))}, " +
+ $"looks like an email={(v != null && v.Contains('@'))}");
+ }
+ if (names.Count == 0) _output.WriteLine($"TRACKRAW {id}: no requestorName property in response");
+ var pocKeys = TechContactLikeKeys(tracked.RootElement);
+ _output.WriteLine($"TRACKRAW {id}: technical-contact-like keys in response = " +
+ (pocKeys.Count == 0 ? "(none)" : string.Join(", ", pocKeys)));
+ }
+
+ Assert.True(hasName, "requestorInformation.requestorName key must be present on the wire");
+ Assert.Equal(string.Empty, sentName);
+ Assert.False(hasPoc, "technicalPointOfContact must be omitted from the wire body");
+ Assert.Equal("Keyfactor Gateway", sentSigner);
+ }
+
+ private async Task CleanupAsync(CERTInextClient client, CERTInextCAPlugin plugin, IEnumerable orderNumbers)
+ {
+ var ids = orderNumbers.Where(o => !string.IsNullOrWhiteSpace(o)).Distinct().ToList();
+ if (ids.Count == 0)
+ {
+ _output.WriteLine("CLEANUP: no order was placed; nothing to revoke.");
+ return;
+ }
+
+ foreach (string id in ids)
+ {
+ try
+ {
+ var before = await client.TrackOrderAsync(id);
+ int.TryParse(before.OrderDetails?.CertificateStatusId, out int st);
+ _output.WriteLine($"cleanup {id}: before certificateStatusId={st} ({TestOutputScrub.Scrub(before.OrderDetails?.CertificateStatus)})");
+ if (st == Constants.CertificateStatusId.CertificateRevoked)
+ continue;
+
+ if (st == Constants.CertificateStatusId.CertificateGenerated
+ || st == Constants.CertificateStatusId.CertificateDownloaded)
+ {
+ int rc = await plugin.Revoke(id, string.Empty, 5);
+ _output.WriteLine($"cleanup {id}: plugin.Revoke returned {rc}");
+ }
+ else
+ {
+ // Not issued: record exactly what CERTInext says to a raw revoke/cancel.
+ await client.RevokeCertificateAsync(id, new RevokeCertificateRequest
+ {
+ Reason = Constants.RevocationReason.CessationOfOperation,
+ Comment = "blank-requestor live-test cleanup"
+ });
+ _output.WriteLine($"cleanup {id}: raw RevokeCertificateAsync accepted for non-issued order");
+ }
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"cleanup {id}: REVOKE/CANCEL FAILED -> {ex.GetType().Name}: {TestOutputScrub.Scrub(ex.Message)}");
+ }
+ }
+
+ _output.WriteLine("--- cleanup verification (fresh read-only TrackOrder) ---");
+ foreach (string id in ids)
+ {
+ try
+ {
+ var after = await client.TrackOrderAsync(id);
+ _output.WriteLine($"verify {id}: orderStatusId={after.OrderDetails?.OrderStatusId} ({TestOutputScrub.Scrub(after.OrderDetails?.OrderStatus)}), " +
+ $"certificateStatusId={after.OrderDetails?.CertificateStatusId} ({TestOutputScrub.Scrub(after.OrderDetails?.CertificateStatus)})");
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"verify {id}: TrackOrder failed -> {ex.GetType().Name}: {TestOutputScrub.Scrub(ex.Message)}");
+ }
+ }
+ }
+ }
+}
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..0c05438 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
diff --git a/CERTInext.IntegrationTests/GroupAndWwwLiveTests.cs b/CERTInext.IntegrationTests/GroupAndWwwLiveTests.cs
new file mode 100644
index 0000000..0003f03
--- /dev/null
+++ b/CERTInext.IntegrationTests/GroupAndWwwLiveTests.cs
@@ -0,0 +1,435 @@
+// 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.Reflection;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.PKI.Enums.EJBCA;
+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 Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Live-sandbox proof of the V1 GenerateOrderSSL body fix: orderDetails.groupNumber
+ /// and orderDetails.autoSecureWWW must be honoured by CERTInext for both new enrollment and
+ /// the renewal path (RenewCertificateAsync, which shares BuildSslOrderDetails).
+ ///
+ /// Checks per order:
+ /// 1. The order appears in GetOrderReport (ListOrders) under the configured group.
+ /// 2. The TrackOrder domain list contains the requested name and no www. entry.
+ ///
+ /// Opt-in: set CERTINEXT_WS1C_LIVE=1 as a REAL environment variable. A value placed in
+ /// ~/.env_certinext is deliberately ignored (the fixture promotes file values into the
+ /// process environment, so the file is inspected to refuse that case). Also requires
+ /// CERTINEXT_GROUP_NUMBER. Every order created is revoked in a finally block and the
+ /// final state is re-read and printed.
+ ///
+ public class GroupAndWwwLiveTests : IClassFixture
+ {
+ private const string OptInFlag = "CERTINEXT_WS1C_LIVE";
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ public GroupAndWwwLiveTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Gating + helpers
+ // ---------------------------------------------------------------------------
+
+ private void SkipUnlessOptedIn()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ Skip.If(Environment.GetEnvironmentVariable(OptInFlag) != "1",
+ $"Opt-in: set {OptInFlag}=1 as a real environment variable to place live sandbox orders.");
+
+ string envFile = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".env_certinext");
+ bool inFile = File.Exists(envFile) && File.ReadAllLines(envFile)
+ .Any(l => l.TrimStart().StartsWith(OptInFlag + "=", StringComparison.Ordinal));
+ Skip.If(inFile, $"{OptInFlag} must be a real environment variable, not set in ~/.env_certinext.");
+
+ Skip.If(string.IsNullOrWhiteSpace(_fixture.Config.GroupNumber),
+ "CERTINEXT_GROUP_NUMBER is required to prove group placement.");
+ }
+
+ private CERTInextConfig BuildConfig(bool dcvEnabled)
+ {
+ var c = _fixture.Config;
+ return new CERTInextConfig
+ {
+ ApiUrl = c.ApiUrl,
+ AuthMode = c.AuthMode,
+ ApiKey = c.ApiKey,
+ AccountNumber = c.AccountNumber,
+ GroupNumber = c.GroupNumber,
+ OrganizationNumber = c.OrganizationNumber,
+ RequestorName = c.RequestorName,
+ RequestorEmail = c.RequestorEmail,
+ RequestorIsdCode = c.RequestorIsdCode,
+ RequestorMobileNumber = c.RequestorMobileNumber,
+ SignerPlace = c.SignerPlace,
+ SignerIp = c.SignerIp,
+ DefaultProductCode = c.DefaultProductCode,
+ PageSize = c.PageSize,
+ AutoSecureWww = "0",
+ DcvEnabled = dcvEnabled,
+ DcvPropagationDelaySeconds = 5,
+ DcvTimeoutMinutes = 3,
+ };
+ }
+
+ private static string GenerateCsrPem(string commonName)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
+ var csr = new Pkcs10CertificationRequest(
+ "SHA256withRSA", new X509Name($"CN={commonName}"), keyPair.Public, null, keyPair.Private);
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ // Local copy: IntegrationTestData lives in DcvLifecycleTests.cs, which is excluded on non-DCV builds.
+ private EnrollmentProductInfo DvProductInfo()
+ {
+ string code = _fixture.Config.DefaultProductCode ?? Constants.Products.DvSsl;
+ return new EnrollmentProductInfo
+ {
+ ProductID = code,
+ ProductParameters = new Dictionary
+ {
+ ["ProfileId"] = code,
+ ["ValidityYears"] = "1"
+ }
+ };
+ }
+
+ private static Dictionary DnsSan(string cn) =>
+ new Dictionary { ["dns"] = new[] { cn } };
+
+ /// Finds an order in GetOrderReport (today onward first, then unfiltered).
+ private async Task FindInOrderReportAsync(string orderNumber)
+ {
+ foreach (string from in new[] { DateTime.UtcNow.Date.AddDays(-1).ToString("yyyy-MM-dd"), null })
+ {
+ try
+ {
+ await foreach (var e in _fixture.Client.ListOrdersAsync(orderDateFrom: from, pageSize: 100))
+ if (e.OrderNumber == orderNumber)
+ return e;
+ }
+ catch (Exception ex) when (from != null)
+ {
+ _output.WriteLine($" ListOrders(orderDateFrom={from}) failed ({ex.GetType().Name}); retrying unfiltered.");
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Domain names from TrackOrder.domainVerification. Polled briefly because CERTInext may
+ /// populate the block a few seconds after order placement.
+ ///
+ private async Task> GetDomainListAsync(string orderNumber, string mustContain)
+ {
+ var names = new List();
+ for (int i = 0; i < 8; i++)
+ {
+ var track = await _fixture.Client.TrackOrderAsync(orderNumber);
+ names = track.OrderDetails?.DomainVerification?.RawDomainEntries?.Keys.ToList() ?? new List();
+ if (names.Any(n => string.Equals(n, mustContain, StringComparison.OrdinalIgnoreCase)))
+ break;
+ await Task.Delay(TimeSpan.FromSeconds(5));
+ }
+ return names;
+ }
+
+ private void AssertGroupAndNoWww(string label, string orderNumber, OrderReportEntry entry, List domains, string cn)
+ {
+ entry.Should().NotBeNull($"{label} order {orderNumber} must be listed by GetOrderReport");
+ bool groupMatches = string.Equals(entry!.GroupNumber, _fixture.Config.GroupNumber, StringComparison.Ordinal);
+ _output.WriteLine($" [{label}] {orderNumber}: ListOrders groupNumber matches configured group = {groupMatches} " +
+ $"(report groupNumber blank = {string.IsNullOrWhiteSpace(entry.GroupNumber)})");
+ _output.WriteLine($" [{label}] {orderNumber}: TrackOrder domains = [{TestOutputScrub.Scrub(string.Join(", ", domains))}]; " +
+ $"report domainName = {TestOutputScrub.Scrub(entry.DomainName)}");
+
+ groupMatches.Should().BeTrue($"{label} order must land in the configured CERTInext group");
+ domains.Should().Contain(d => string.Equals(d, cn, StringComparison.OrdinalIgnoreCase),
+ "the TrackOrder domain list must be populated (otherwise 'no www' is vacuous)");
+ domains.Should().NotContain(d => d.StartsWith("www.", StringComparison.OrdinalIgnoreCase),
+ $"{label} order was placed with AutoSecureWww=0 so no www. SAN may be added");
+ }
+
+ ///
+ /// Revokes every created order (plugin path for issued certs, raw revoke attempt otherwise),
+ /// then re-reads each order read-only and prints its final state.
+ ///
+ private async Task CleanupAsync(CERTInextCAPlugin plugin, IEnumerable orderNumbers)
+ {
+ foreach (string id in orderNumbers.Where(o => !string.IsNullOrWhiteSpace(o)).Distinct())
+ {
+ try
+ {
+ var before = await _fixture.Client.TrackOrderAsync(id);
+ int.TryParse(before.OrderDetails?.CertificateStatusId, out int st);
+ _output.WriteLine($" cleanup {id}: before certificateStatusId={st} ({TestOutputScrub.Scrub(before.OrderDetails?.CertificateStatus)})");
+ if (st == Constants.CertificateStatusId.CertificateRevoked)
+ continue;
+
+ if (st == Constants.CertificateStatusId.CertificateGenerated
+ || st == Constants.CertificateStatusId.CertificateDownloaded)
+ {
+ int rc = await plugin.Revoke(id, string.Empty, 5);
+ _output.WriteLine($" cleanup {id}: plugin.Revoke returned {rc}");
+ }
+ else
+ {
+ // Not issued: the plugin refuses (revocable only when GENERATED). Try the raw
+ // revoke and record exactly what CERTInext says; do not work around a refusal.
+ await _fixture.Client.RevokeCertificateAsync(id, new RevokeCertificateRequest
+ {
+ Reason = Constants.RevocationReason.CessationOfOperation,
+ Comment = "ws1c live-test cleanup"
+ });
+ _output.WriteLine($" cleanup {id}: raw RevokeCertificateAsync accepted for non-issued order");
+ }
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($" cleanup {id}: REVOKE/CANCEL FAILED -> {TestOutputScrub.Describe(ex)}");
+ }
+ }
+
+ _output.WriteLine(" --- cleanup verification (fresh read-only TrackOrder) ---");
+ foreach (string id in orderNumbers.Where(o => !string.IsNullOrWhiteSpace(o)).Distinct())
+ {
+ try
+ {
+ var after = await _fixture.Client.TrackOrderAsync(id);
+ _output.WriteLine($" verify {id}: orderStatusId={after.OrderDetails?.OrderStatusId} ({TestOutputScrub.Scrub(after.OrderDetails?.OrderStatus)}), " +
+ $"certificateStatusId={after.OrderDetails?.CertificateStatusId} ({TestOutputScrub.Scrub(after.OrderDetails?.CertificateStatus)})");
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($" verify {id}: TrackOrder failed -> {TestOutputScrub.Describe(ex)}");
+ }
+ }
+ }
+
+
+ // ---------------------------------------------------------------------------
+ // Test A: new enrollment (compiles on both DcvSupport variants)
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task NewOrder_AutoSecureWwwOff_LandsInConfiguredGroup_WithNoWwwSan()
+ {
+ SkipUnlessOptedIn();
+
+ string cn = $"ws1c-a-{Guid.NewGuid():N}".Substring(0, 20) + ".scrup.org";
+ var plugin = new CERTInextCAPlugin(_fixture.Client, BuildConfig(dcvEnabled: false));
+ var created = new List();
+ try
+ {
+ var result = await plugin.Enroll(
+ GenerateCsrPem(cn), $"CN={cn}", DnsSan(cn),
+ DvProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+ created.Add(result.CARequestID);
+ _output.WriteLine($"Enrolled order {result.CARequestID}, status={result.Status}");
+ result.CARequestID.Should().NotBeNullOrWhiteSpace();
+
+ var entry = await FindInOrderReportAsync(result.CARequestID);
+ var domains = await GetDomainListAsync(result.CARequestID, cn);
+ AssertGroupAndNoWww("new", result.CARequestID, entry, domains, cn);
+ }
+ finally
+ {
+ await CleanupAsync(plugin, created);
+ }
+ }
+
+#if SUPPORTS_DCV
+ // ---------------------------------------------------------------------------
+ // Test B: new order -> DCV issuance -> renewal through RenewCertificateAsync
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Minimal ICertificateDataReader for the renewal path (the plugin only calls
+ /// GetRequestIDBySerialNumber and GetExpirationDateByRequestId). Built with DispatchProxy
+ /// so the integration project needs no mocking package.
+ ///
+ public class ReaderProxy : DispatchProxy
+ {
+ public string RequestId { get; set; }
+ public DateTime? Expiry { get; set; }
+
+ protected override object Invoke(MethodInfo targetMethod, object[] args)
+ {
+ switch (targetMethod.Name)
+ {
+ case "GetRequestIDBySerialNumber": return Task.FromResult(RequestId);
+ case "GetExpirationDateByRequestId": return Expiry;
+ default: throw new NotSupportedException(targetMethod.Name);
+ }
+ }
+ }
+
+ ///
+ /// Forwarding decorator over the live client that records which interface methods the plugin
+ /// called, so the test can prove the renewal went through RenewCertificateAsync rather than
+ /// silently falling back to a new enrollment.
+ ///
+ public class RecordingClientProxy : DispatchProxy
+ {
+ public Client.ICERTInextClient Target { get; set; }
+ public System.Collections.Concurrent.ConcurrentQueue Calls { get; } =
+ new System.Collections.Concurrent.ConcurrentQueue();
+
+ protected override object Invoke(MethodInfo targetMethod, object[] args)
+ {
+ Calls.Enqueue(targetMethod.Name);
+ try
+ {
+ return targetMethod.Invoke(Target, args);
+ }
+ catch (TargetInvocationException ex) when (ex.InnerException != null)
+ {
+ System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
+ throw;
+ }
+ }
+ }
+
+ [SkippableFact]
+ public async Task IssuedOrder_RenewedViaRenewCertificateAsync_StaysInGroup_WithNoWwwSan()
+ {
+ SkipUnlessOptedIn();
+ Skip.If(!_fixture.IsCloudflareConfigured,
+ "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required to drive DCV to issuance.");
+
+ string cn = $"ws1c-b-{Guid.NewGuid():N}".Substring(0, 20) + ".scrup.org";
+ var config = BuildConfig(dcvEnabled: true);
+ var factory = new CloudflareDomainValidatorFactory(_fixture.CloudflareApiToken, _fixture.CloudflareZoneId);
+ var recorder = DispatchProxy.Create();
+ ((RecordingClientProxy)(object)recorder).Target = _fixture.Client;
+ var calls = ((RecordingClientProxy)(object)recorder).Calls;
+ var plugin = new CERTInextCAPlugin(recorder, factory, config);
+ var reader = DispatchProxy.Create();
+ typeof(CERTInextCAPlugin)
+ .GetField("_certificateDataReader", BindingFlags.NonPublic | BindingFlags.Instance)!
+ .SetValue(plugin, reader);
+
+ var created = new List();
+ try
+ {
+ // 1. New DV order, AutoSecureWww=0 + group, DCV-driven.
+ var first = await plugin.Enroll(
+ GenerateCsrPem(cn), $"CN={cn}", DnsSan(cn),
+ DvProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+ created.Add(first.CARequestID);
+ first.CARequestID.Should().NotBeNullOrWhiteSpace();
+ _output.WriteLine($"Original order {first.CARequestID}: Enroll status={first.Status}");
+
+ // 2. Group + domain-list checks on the original order.
+ AssertGroupAndNoWww("original", first.CARequestID,
+ await FindInOrderReportAsync(first.CARequestID),
+ await GetDomainListAsync(first.CARequestID, cn), cn);
+
+ // 3. Drive to issuance (GetSingleRecord re-runs DCV for EXTERNALVALIDATION orders).
+ await DriveToIssuanceAsync(plugin, first.CARequestID);
+ var issued = await plugin.GetSingleRecord(first.CARequestID);
+ _output.WriteLine($"Original order {first.CARequestID}: status after DCV = {issued.Status}");
+ issued.Status.Should().Be((int)EndEntityStatus.GENERATED, "the renewal precondition is an issued certificate");
+
+ // 4. Renew through the plugin's renewal path -> RenewCertificateAsync.
+ var track = await _fixture.Client.TrackOrderAsync(first.CARequestID);
+ DateTime.TryParse(track.OrderDetails?.CertificateExpiryDate, out var parsedExpiry);
+ var proxy = (ReaderProxy)(object)reader;
+ proxy.RequestId = first.CARequestID;
+ proxy.Expiry = parsedExpiry == default ? DateTime.UtcNow.AddDays(30) : parsedExpiry.ToUniversalTime();
+
+ var renewInfo = DvProductInfo();
+ renewInfo.ProductParameters["PriorCertSN"] = "ws1c-prior-serial";
+ renewInfo.ProductParameters["RenewalWindowDays"] = "800"; // force the renew API branch
+
+ EnrollmentResult renewed;
+ try
+ {
+ renewed = await plugin.Enroll(
+ GenerateCsrPem(cn), $"CN={cn}", DnsSan(cn), renewInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"RENEWAL REFUSED/FAILED: {TestOutputScrub.Describe(ex)}");
+ throw;
+ }
+
+ created.Add(renewed.CARequestID);
+ _output.WriteLine($"Renewal client calls: RenewCertificateAsync x{calls.Count(c => c == "RenewCertificateAsync")}");
+ calls.Should().Contain("RenewCertificateAsync",
+ "the renewal must go through RenewCertificateAsync, not fall back to a fresh enrollment");
+ _output.WriteLine($"Renewal order {renewed.CARequestID}: Enroll status={renewed.Status}, " +
+ $"distinct from original = {renewed.CARequestID != first.CARequestID}");
+ renewed.CARequestID.Should().NotBe(first.CARequestID);
+
+ AssertGroupAndNoWww("renewal", renewed.CARequestID,
+ await FindInOrderReportAsync(renewed.CARequestID),
+ await GetDomainListAsync(renewed.CARequestID, cn), cn);
+
+ // Let the renewal issue too, so it is cleanly revocable.
+ await DriveToIssuanceAsync(plugin, renewed.CARequestID);
+ }
+ finally
+ {
+ await CleanupAsync(plugin, created);
+ }
+ }
+
+ private async Task DriveToIssuanceAsync(CERTInextCAPlugin plugin, string orderNumber)
+ {
+ for (int pass = 1; pass <= 8; pass++)
+ {
+ var rec = await plugin.GetSingleRecord(orderNumber);
+ _output.WriteLine($" DCV pass {pass}: {orderNumber} status={rec.Status}");
+ if (rec.Status == (int)EndEntityStatus.GENERATED)
+ return;
+ await Task.Delay(TimeSpan.FromSeconds(20));
+ }
+ }
+#endif
+ }
+}
diff --git a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
index 441f573..9948e63 100644
--- a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
+++ b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
@@ -59,6 +59,9 @@ The file is parsed line by line:
- Each line must be in `KEY=VALUE` format.
- Values are not quoted — do not surround values with `"` or `'`.
- Real environment variables override file values (useful for CI injection).
+- Opt-in flags that place real orders (`CERTINEXT_COMPLETE_PENDING`, `CERTINEXT_RUN_BULK_TEST`,
+ `CERTINEXT_ALGO_MATRIX`, `CERTINEXT_ALGO_MATRIX_DCV`, `CERTINEXT_SAN_PROBE`, `CERTINEXT_RENEWAL_E2E`) are **ignored** in this file;
+ they must be exported in the shell (see `IntegrationTestFixture.OptInOnlyFlags`).
---
@@ -133,6 +136,12 @@ test constructor.
| `GetProductIds_ReturnsAtLeastOneProduct` | Calls `IAnyCAPlugin.GetProductIds()`; asserts a non-null list is returned without throwing |
| `Synchronize_ReturnsAtLeastOneRecord` | Runs a full sync; asserts at least one `AnyCAPluginCertificate` record is produced |
+### `RenewalLiveTests` (DCV build only)
+
+| Test | What it checks |
+|------|---------------|
+| `Enroll_RenewOrReissue_ThroughPlugin_PlacesRenewalOrder_WithTemplateProductCode` | Opt-in (`CERTINEXT_RENEWAL_E2E=1`, `-p:DcvSupport=true`, Cloudflare creds): `plugin.Enroll(RenewOrReissue)` with `PriorCertSN` places a renewal order with the template product code and no EMS-918; see `TESTING.md` |
+
---
## Authentication
diff --git a/CERTInext.IntegrationTests/IntegrationTestFixture.cs b/CERTInext.IntegrationTests/IntegrationTestFixture.cs
index 8e4f637..b823360 100644
--- a/CERTInext.IntegrationTests/IntegrationTestFixture.cs
+++ b/CERTInext.IntegrationTests/IntegrationTestFixture.cs
@@ -20,6 +20,30 @@ 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 tests that place real orders or
+ /// drive mutating flows, so a developer cannot accidentally arm them by leaving
+ /// a flag in ~/.env_certinext. Exposed internal for unit-testing.
+ ///
+ internal static readonly HashSet OptInOnlyFlags =
+ new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "CERTINEXT_COMPLETE_PENDING",
+ "CERTINEXT_RUN_BULK_TEST",
+ "CERTINEXT_ALGO_MATRIX",
+ "CERTINEXT_ALGO_MATRIX_DCV",
+ "CERTINEXT_SAN_PROBE",
+ "CERTINEXT_PROBE_VALIDITY",
+ "CERTINEXT_SYNC_HOLDBACK",
+ "CERTINEXT_SYNC_HOLDBACK_MAX_ROWS",
+ "CERTINEXT_RENEWAL_E2E",
+ };
+
// ---------------------------------------------------------------------------
// Credential properties
// ---------------------------------------------------------------------------
@@ -83,11 +107,7 @@ public IntegrationTestFixture()
var env = LoadEnvFile(envPath);
- // Promote env-file values into the process environment so that any code
- // calling System.Environment.GetEnvironmentVariable() picks them up.
- foreach (var kv in env)
- if (System.Environment.GetEnvironmentVariable(kv.Key) == null)
- System.Environment.SetEnvironmentVariable(kv.Key, kv.Value);
+ PromoteToProcessEnvironment(env);
ApiUrl = GetEnvValue(env, "CERTINEXT_API_URL");
AccessKey = GetEnvValue(env, "CERTINEXT_ACCESS_KEY");
@@ -138,6 +158,23 @@ public void Dispose() { }
// Private helpers
// ---------------------------------------------------------------------------
+ ///
+ /// Promotes env-file values into the process environment so that any code
+ /// calling picks them up.
+ /// Variables already set in the process are left untouched. Keys in
+ /// are deliberately excluded: they must be set
+ /// explicitly in the shell so a flag left in the file does not arm
+ /// order-placing tests on every bare dotnet test.
+ /// Exposed internal for direct unit-testing.
+ ///
+ internal static void PromoteToProcessEnvironment(IReadOnlyDictionary values)
+ {
+ foreach (var kv in values)
+ if (Environment.GetEnvironmentVariable(kv.Key) == null
+ && !OptInOnlyFlags.Contains(kv.Key))
+ Environment.SetEnvironmentVariable(kv.Key, kv.Value);
+ }
+
///
/// Reads a KEY=VALUE file, stripping blank lines and lines starting with '#'.
/// Real environment variables overlay the file so CI overrides always win.
diff --git a/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs b/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs
index 1db8470..4822246 100644
--- a/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs
+++ b/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs
@@ -5,6 +5,8 @@
// 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 FluentAssertions;
using Xunit;
@@ -49,5 +51,53 @@ public void ParseEnvValue_DoesNotStripEmbeddedQuotes()
IntegrationTestFixture.ParseEnvValue("foo\"bar\"baz")
.Should().Be("foo\"bar\"baz");
}
+
+ [SkippableTheory]
+ [InlineData("CERTINEXT_COMPLETE_PENDING")]
+ [InlineData("CERTINEXT_RUN_BULK_TEST")]
+ [InlineData("CERTINEXT_ALGO_MATRIX")]
+ [InlineData("CERTINEXT_ALGO_MATRIX_DCV")]
+ [InlineData("CERTINEXT_SAN_PROBE")]
+ [InlineData("CERTINEXT_PROBE_VALIDITY")]
+ [InlineData("CERTINEXT_SYNC_HOLDBACK")]
+ [InlineData("CERTINEXT_SYNC_HOLDBACK_MAX_ROWS")]
+ [InlineData("CERTINEXT_RENEWAL_E2E")]
+ [InlineData("certinext_complete_pending")] // env-file keys are matched case-insensitively
+ public void PromoteToProcessEnvironment_DoesNotPromoteOptInFlags(string flag)
+ {
+ Skip.If(Environment.GetEnvironmentVariable(flag) != null,
+ $"{flag} is already set in the process environment; cannot verify it is not promoted.");
+
+ IntegrationTestFixture.PromoteToProcessEnvironment(
+ new Dictionary { [flag] = "1" });
+
+ Environment.GetEnvironmentVariable(flag).Should().BeNull(
+ "opt-in flags must come from the real environment, never from ~/.env_certinext");
+ }
+
+ [Fact]
+ public void PromoteToProcessEnvironment_PromotesOrdinaryKeys_WithoutOverridingRealEnv()
+ {
+ string fresh = "CERTINEXT_FIXTURE_TEST_" + Guid.NewGuid().ToString("N");
+ string preset = "CERTINEXT_FIXTURE_TEST_" + Guid.NewGuid().ToString("N");
+ try
+ {
+ Environment.SetEnvironmentVariable(preset, "from-shell");
+
+ IntegrationTestFixture.PromoteToProcessEnvironment(new Dictionary
+ {
+ [fresh] = "from-file",
+ [preset] = "from-file",
+ });
+
+ Environment.GetEnvironmentVariable(fresh).Should().Be("from-file");
+ Environment.GetEnvironmentVariable(preset).Should().Be("from-shell");
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(fresh, null);
+ Environment.SetEnvironmentVariable(preset, null);
+ }
+ }
}
}
diff --git a/CERTInext.IntegrationTests/OrderDateFormatProbeTests.cs b/CERTInext.IntegrationTests/OrderDateFormatProbeTests.cs
new file mode 100644
index 0000000..064f4e0
--- /dev/null
+++ b/CERTInext.IntegrationTests/OrderDateFormatProbeTests.cs
@@ -0,0 +1,67 @@
+// Copyright 2026 Keyfactor
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Read-only guard (issue 0090, V1 sync hold-back): GetOrderReport's per-row orderDate
+ /// must stay "yyyy-MM-dd HH:mm:ss" (whole seconds, no zone marker; read as UTC, verified
+ /// 2026-10-15 against plugin and gateway UTC log timestamps). The sync hold-back measures an
+ /// order's age from it. No orders are created.
+ ///
+ public class OrderDateFormatProbeTests : IClassFixture
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ public OrderDateFormatProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ [SkippableFact]
+ public async Task GetOrderReport_OrderDate_IsSecondPrecisionWithoutZoneMarker()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ var shapes = new Dictionary();
+ int total = 0;
+ await foreach (var entry in _fixture.Client.ListOrdersAsync(
+ orderDateFrom: null, pageSize: 100, ct: CancellationToken.None))
+ {
+ total++;
+ // Digits masked so the output records the wire shape, not the data.
+ string shape = Regex.Replace(entry.OrderDate ?? "(null)", @"\d", "9");
+ shapes[shape] = shapes.TryGetValue(shape, out var n) ? n + 1 : 1;
+ if (total >= 300) break;
+ }
+
+ _output.WriteLine($"rows={total}");
+ foreach (var kv in shapes.OrderByDescending(k => k.Value))
+ _output.WriteLine($"orderDate shape '{kv.Key}' x{kv.Value}");
+
+ Skip.If(total == 0, "account has no orders");
+ Assert.All(shapes.Keys, k => Assert.Equal("9999-99-99 99:99:99", k));
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/RenewalLiveTests.cs b/CERTInext.IntegrationTests/RenewalLiveTests.cs
new file mode 100644
index 0000000..bd4c449
--- /dev/null
+++ b/CERTInext.IntegrationTests/RenewalLiveTests.cs
@@ -0,0 +1,551 @@
+// 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.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.PKI.Enums.EJBCA;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.OpenSsl;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using Org.BouncyCastle.X509;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Live-sandbox proof of the plugin-level renewal path that Keyfactor Command drives:
+ /// Enroll(EnrollmentType.RenewOrReissue) with the prior certificate's serial in
+ /// PriorCertSN -> RenewOrReissueAsync -> client.RenewCertificateAsync ->
+ /// GenerateOrderSSL. On 1.0.0 that order carried no additionalInformation.remarks
+ /// and CERTInext rejected it with EMS-918; 1.0.1 rebuilds it through BuildSslOrderDetails.
+ /// The client-level renewal is covered by
+ /// GroupAndWwwLiveTests.IssuedOrder_RenewedViaRenewCertificateAsync_StaysInGroup_WithNoWwwSan;
+ /// this class covers what the customer actually hits (the plugin, the renewal-window decision,
+ /// the PriorCertSN to CARequestID lookup, and the template product code).
+ ///
+ /// Product: the sandbox's product 842 is DV SSL, so the first order is driven to issuance with
+ /// Cloudflare DNS-01 (CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID); there is no
+ /// auto-issuing private/OV product on this account (see TESTING.md, "Product Codes Are Per-Account").
+ ///
+ /// Opt-in: set CERTINEXT_RENEWAL_E2E=1 as a REAL environment variable (it is never
+ /// promoted from ~/.env_certinext, see ).
+ /// Also needs CERTINEXT_DCV_DOMAIN (base domain for unique names) and the Cloudflare
+ /// credentials. Places 2 sandbox orders; both are revoked in a finally block and the final
+ /// state is re-read and printed.
+ ///
+ public class RenewalLiveTests : IClassFixture, IDisposable
+ {
+ private const string OptInFlag = "CERTINEXT_RENEWAL_E2E";
+
+ ///
+ /// Deliberately NOT the product code on the template. If the renewal (or the new
+ /// enrollment) used the connector default instead of the template's code, CERTInext would
+ /// reject it (EMS-1162, invalid product code) or the order report would show this value.
+ ///
+ private const string BogusDefaultProductCode = "999";
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private readonly List _toDispose = new List();
+
+ public RenewalLiveTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ public void Dispose()
+ {
+ foreach (var d in _toDispose)
+ d.Dispose();
+ _toDispose.Clear();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Test doubles
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Stands in for Command's certificate inventory: maps the prior certificate's serial to
+ /// its CARequestID and reports its expiry, and records every serial the plugin looked up.
+ /// Returns null for an unknown serial, exactly like an inventory miss, so a wrong
+ /// PriorCertSN makes the plugin fall back to a new enrollment (which the test detects).
+ ///
+ public class InventoryProxy : DispatchProxy
+ {
+ public string KnownSerialHex { get; set; }
+ public string RequestId { get; set; }
+ public DateTime? Expiry { get; set; }
+ public List SerialsRequested { get; } = new List();
+
+ protected override object Invoke(MethodInfo targetMethod, object[] args)
+ {
+ switch (targetMethod.Name)
+ {
+ case "GetRequestIDBySerialNumber":
+ string sn = (string)args[0];
+ SerialsRequested.Add(sn);
+ return Task.FromResult(SameSerial(sn, KnownSerialHex) ? RequestId : null);
+ case "GetExpirationDateByRequestId":
+ return string.Equals((string)args[0], RequestId, StringComparison.Ordinal) ? Expiry : null;
+ default:
+ throw new NotSupportedException(targetMethod.Name);
+ }
+ }
+
+ private static bool SameSerial(string a, string b) =>
+ !string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b)
+ && string.Equals(a.TrimStart('0'), b.TrimStart('0'), StringComparison.OrdinalIgnoreCase);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private string SkipUnlessOptedIn()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+ Skip.If(Environment.GetEnvironmentVariable(OptInFlag) != "1",
+ $"Opt-in: export {OptInFlag}=1 in the shell to place live sandbox orders.");
+ Skip.If(!_fixture.IsCloudflareConfigured,
+ "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required to drive DV DCV to issuance.");
+ string baseDomain = Environment.GetEnvironmentVariable("CERTINEXT_DCV_DOMAIN");
+ Skip.If(string.IsNullOrWhiteSpace(baseDomain),
+ "CERTINEXT_DCV_DOMAIN is required (base domain for unique test names).");
+ Skip.If(string.IsNullOrWhiteSpace(_fixture.Config.DefaultProductCode),
+ "CERTINEXT_PRODUCT_CODE is required (the template product code under test).");
+ return baseDomain;
+ }
+
+ private CERTInextConfig BuildConfig()
+ {
+ var c = _fixture.Config;
+ return new CERTInextConfig
+ {
+ ApiUrl = c.ApiUrl,
+ AuthMode = c.AuthMode,
+ ApiKey = c.ApiKey,
+ AccountNumber = c.AccountNumber,
+ GroupNumber = c.GroupNumber,
+ OrganizationNumber = c.OrganizationNumber,
+ RequestorName = c.RequestorName,
+ RequestorEmail = c.RequestorEmail,
+ RequestorIsdCode = c.RequestorIsdCode,
+ RequestorMobileNumber = c.RequestorMobileNumber,
+ SignerPlace = c.SignerPlace,
+ SignerIp = c.SignerIp,
+ PageSize = c.PageSize,
+ AutoSecureWww = "0",
+ // Differs from the template product code on purpose: the template code must win.
+ DefaultProductCode = BogusDefaultProductCode,
+ DcvEnabled = true,
+ DcvPropagationDelaySeconds = 5,
+ DcvTimeoutMinutes = 3,
+ };
+ }
+
+ ///
+ /// Template parameters as Command sends them for an enrollment/renewal. ProfileId is
+ /// the template's product code (the real sandbox DV code); ValidityYears follows the
+ /// other live tests.
+ ///
+ private EnrollmentProductInfo TemplateProductInfo(string priorCertSn = null, int? renewalWindowDays = null)
+ {
+ string code = _fixture.Config.DefaultProductCode;
+ var info = new EnrollmentProductInfo
+ {
+ ProductID = code,
+ ProductParameters = new Dictionary
+ {
+ ["ProfileId"] = code,
+ ["ValidityYears"] = "1"
+ }
+ };
+ if (priorCertSn != null)
+ info.ProductParameters["PriorCertSN"] = priorCertSn;
+ if (renewalWindowDays.HasValue)
+ info.ProductParameters["RenewalWindowDays"] = renewalWindowDays.Value.ToString();
+ return info;
+ }
+
+ /// Fresh RSA-2048 key + CSR (BouncyCastle); the public key is returned for comparison with the issued cert.
+ private static (string Pem, AsymmetricKeyParameter PublicKey) GenerateCsr(string commonName)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
+ var csr = new Pkcs10CertificationRequest(
+ "SHA256withRSA", new X509Name($"CN={commonName}"), keyPair.Public, null, keyPair.Private);
+ string pem = "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ return (pem, keyPair.Public);
+ }
+
+ ///
+ /// Parses the leaf certificate the plugin returned. Accepts a PEM block (first certificate
+ /// wins) or bare base64 DER; fails with a descriptive message rather than a null reference.
+ ///
+ private static X509Certificate ParseCert(string certText)
+ {
+ certText.Should().NotBeNullOrWhiteSpace("the plugin must return a certificate body for an issued order");
+ X509Certificate cert;
+ if (certText.Contains("-----BEGIN", StringComparison.Ordinal))
+ {
+ using var reader = new StringReader(certText);
+ cert = new PemReader(reader).ReadObject() as X509Certificate;
+ }
+ else
+ {
+ cert = new X509CertificateParser().ReadCertificate(
+ Convert.FromBase64String(certText.Trim()));
+ }
+ cert.Should().NotBeNull($"the returned certificate body must parse (length={certText.Length}, " +
+ $"startsWith='{certText.Substring(0, Math.Min(12, certText.Length)).Replace("\n", " ")}')");
+ return cert;
+ }
+
+ private static string SerialHex(X509Certificate cert) =>
+ cert.SerialNumber.ToString(16).ToUpperInvariant();
+
+ ///
+ /// Polls GetSingleRecord (which re-runs DCV for EXTERNALVALIDATION orders) until the order is
+ /// GENERATED and carries a certificate body. CERTInext reports "Approved by System"
+ /// (certificateStatusId 15) as GENERATED a little before the certificate is downloadable, so
+ /// the status alone is not enough.
+ ///
+ private async Task DriveToIssuanceAsync(CERTInextCAPlugin plugin, string orderNumber)
+ {
+ AnyCAPluginCertificate rec = null;
+ for (int pass = 1; pass <= 12; pass++)
+ {
+ rec = await plugin.GetSingleRecord(orderNumber);
+ bool hasCert = !string.IsNullOrWhiteSpace(rec.Certificate);
+ _output.WriteLine($" issuance pass {pass}: {orderNumber} status={rec.Status} certificateBody={hasCert}");
+ if (rec.Status == (int)EndEntityStatus.GENERATED && hasCert)
+ break;
+ await Task.Delay(TimeSpan.FromSeconds(20));
+ }
+ return rec;
+ }
+
+ /// Finds an order in GetOrderReport (today onward first, then unfiltered).
+ private async Task FindInOrderReportAsync(string orderNumber)
+ {
+ foreach (string from in new[] { DateTime.UtcNow.Date.AddDays(-1).ToString("yyyy-MM-dd"), null })
+ {
+ try
+ {
+ await foreach (var e in _fixture.Client.ListOrdersAsync(orderDateFrom: from, pageSize: 100))
+ if (e.OrderNumber == orderNumber)
+ return e;
+ }
+ catch (Exception ex) when (from != null)
+ {
+ _output.WriteLine($" ListOrders(orderDateFrom={from}) failed ({ex.GetType().Name}); retrying unfiltered.");
+ }
+ }
+ return null;
+ }
+
+ /// Domain names from TrackOrder.domainVerification, polled briefly while CERTInext populates the block.
+ private async Task> GetDomainListAsync(string orderNumber, string mustContain)
+ {
+ var names = new List();
+ for (int i = 0; i < 8; i++)
+ {
+ var track = await _fixture.Client.TrackOrderAsync(orderNumber);
+ names = track.OrderDetails?.DomainVerification?.RawDomainEntries?.Keys.ToList() ?? new List();
+ if (names.Any(n => string.Equals(n, mustContain, StringComparison.OrdinalIgnoreCase)))
+ break;
+ await Task.Delay(TimeSpan.FromSeconds(5));
+ }
+ return names;
+ }
+
+ ///
+ /// Order-level assertions that hold for any order the test places: it is in the order report
+ /// under the template product code (never the bogus connector default), its primary domain is
+ /// the CN, and its domain list is exactly the requested name (no www.).
+ ///
+ private async Task AssertOrderShapeAsync(string label, string orderNumber, string cn)
+ {
+ var entry = await FindInOrderReportAsync(orderNumber);
+ entry.Should().NotBeNull($"{label} order {orderNumber} must be listed by GetOrderReport");
+ var domains = await GetDomainListAsync(orderNumber, cn);
+
+ _output.WriteLine($" [{label}] {orderNumber}: productCode={entry!.ProductCode} " +
+ $"(template={_fixture.Config.DefaultProductCode}, connector default={BogusDefaultProductCode}); " +
+ $"report domainName={TestOutputScrub.Scrub(entry.DomainName)}; " +
+ $"TrackOrder domains=[{TestOutputScrub.Scrub(string.Join(", ", domains))}]");
+
+ entry.ProductCode.Should().Be(_fixture.Config.DefaultProductCode,
+ $"{label} order must use the template/ProfileId product code, not DefaultProductCode={BogusDefaultProductCode}");
+ entry.DomainName.Should().BeEquivalentTo(cn, $"{label} order's primary domain must be the subject CN");
+ domains.Should().BeEquivalentTo(new[] { cn },
+ $"{label} order's domain list must be exactly the requested SANs (AutoSecureWww=0, so no www.)");
+ }
+
+ ///
+ /// Revokes every created order (plugin path for issued certs, raw revoke attempt otherwise),
+ /// then re-reads each order read-only and prints its final state. Returns true when every
+ /// order ended revoked/cancelled/rejected.
+ ///
+ private async Task CleanupAsync(CERTInextCAPlugin plugin, IEnumerable orderNumbers)
+ {
+ var ids = orderNumbers.Where(o => !string.IsNullOrWhiteSpace(o)).Distinct().ToList();
+ foreach (string id in ids)
+ {
+ try
+ {
+ var before = await _fixture.Client.TrackOrderAsync(id);
+ int.TryParse(before.OrderDetails?.CertificateStatusId, out int st);
+ _output.WriteLine($" cleanup {id}: before certificateStatusId={st} ({TestOutputScrub.Scrub(before.OrderDetails?.CertificateStatus)})");
+ if (st == Constants.CertificateStatusId.CertificateRevoked)
+ continue;
+
+ if (st == Constants.CertificateStatusId.CertificateGenerated
+ || st == Constants.CertificateStatusId.CertificateDownloaded)
+ {
+ // CERTInext answers "Certificate Request still being processed" while it finishes
+ // generating an auto-approved order; retry for a few minutes.
+ for (int attempt = 1; ; attempt++)
+ {
+ try
+ {
+ int rc = await plugin.Revoke(id, string.Empty, 5);
+ _output.WriteLine($" cleanup {id}: plugin.Revoke returned {rc} (attempt {attempt})");
+ break;
+ }
+ catch (Exception ex) when (attempt < 12
+ && ex.Message.Contains("still being processed", StringComparison.OrdinalIgnoreCase))
+ {
+ _output.WriteLine($" cleanup {id}: revoke attempt {attempt} deferred (still being processed)");
+ await Task.Delay(TimeSpan.FromSeconds(20));
+ }
+ }
+ }
+ else
+ {
+ // Not issued: the plugin refuses (revocable only when GENERATED). Try the raw
+ // revoke and record exactly what CERTInext says; do not work around a refusal.
+ await _fixture.Client.RevokeCertificateAsync(id, new RevokeCertificateRequest
+ {
+ Reason = Constants.RevocationReason.CessationOfOperation,
+ Comment = "renewal e2e live-test cleanup"
+ });
+ _output.WriteLine($" cleanup {id}: raw RevokeCertificateAsync accepted for non-issued order");
+ }
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($" cleanup {id}: REVOKE/CANCEL FAILED -> {TestOutputScrub.Describe(ex)}");
+ }
+ }
+
+ _output.WriteLine(" --- cleanup verification (fresh read-only TrackOrder) ---");
+ bool allClean = true;
+ foreach (string id in ids)
+ {
+ try
+ {
+ var after = await _fixture.Client.TrackOrderAsync(id);
+ int.TryParse(after.OrderDetails?.CertificateStatusId, out int certSt);
+ int.TryParse(after.OrderDetails?.OrderStatusId, out int ordSt);
+ bool clean = certSt == Constants.CertificateStatusId.CertificateRevoked
+ || ordSt == 4 || ordSt == 5; // rejected / cancelled
+ allClean &= clean;
+ _output.WriteLine($" verify {id}: orderStatusId={after.OrderDetails?.OrderStatusId} ({TestOutputScrub.Scrub(after.OrderDetails?.OrderStatus)}), " +
+ $"certificateStatusId={after.OrderDetails?.CertificateStatusId} ({TestOutputScrub.Scrub(after.OrderDetails?.CertificateStatus)}) clean={clean}");
+ }
+ catch (Exception ex)
+ {
+ allClean = false;
+ _output.WriteLine($" verify {id}: TrackOrder failed -> {TestOutputScrub.Describe(ex)}");
+ }
+ }
+ _output.WriteLine($" CLEANUP RESULT: {(allClean ? "all orders revoked/cancelled" : "some orders NOT cleaned up")} ({string.Join(", ", ids)})");
+ return allClean;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Test
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// New order -> issued -> Enroll(RenewOrReissue) with a new CSR, the same subject/SAN
+ /// and PriorCertSN = the issued certificate's serial, inside the renewal window.
+ ///
+ /// Accepted outcomes for the renewal Enroll result: GENERATED (issued in-band) or
+ /// EXTERNALVALIDATION (pending, order exists; the renew path does not run in-call DCV and
+ /// returns pending for a DV renewal still waiting on DNS-01). Either way the order must
+ /// exist at CERTInext; the test then drives it to issuance and checks the certificate.
+ ///
+ [SkippableFact]
+ public async Task Enroll_RenewOrReissue_ThroughPlugin_PlacesRenewalOrder_WithTemplateProductCode()
+ {
+ string baseDomain = SkipUnlessOptedIn();
+
+ string cn = $"renew-e2e-{Guid.NewGuid():N}".Substring(0, 20) + "." + baseDomain.TrimStart('.');
+ var san = new Dictionary { ["dns"] = new[] { cn } };
+ string subject = $"CN={cn}";
+
+ var recorder = DispatchProxy.Create();
+ ((GroupAndWwwLiveTests.RecordingClientProxy)(object)recorder).Target = _fixture.Client;
+ var calls = ((GroupAndWwwLiveTests.RecordingClientProxy)(object)recorder).Calls;
+
+ var inventory = DispatchProxy.Create();
+ var inventoryState = (InventoryProxy)(object)inventory;
+
+ var plugin = new CERTInextCAPlugin(recorder, inventory, BuildConfig());
+ var factory = new CloudflareDomainValidatorFactory(_fixture.CloudflareApiToken, _fixture.CloudflareZoneId);
+ _toDispose.Add(factory);
+ plugin.SetDomainValidatorFactory(factory);
+
+ var created = new List();
+ bool cleanedUp = false;
+ try
+ {
+ // 1. Fresh order through the plugin (template product code; connector default is bogus).
+ var (firstCsr, _) = GenerateCsr(cn);
+ var first = await plugin.Enroll(firstCsr, subject, san, TemplateProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+ first.CARequestID.Should().NotBeNullOrWhiteSpace();
+ created.Add(first.CARequestID);
+ _output.WriteLine($"Original order {first.CARequestID}: Enroll status={first.Status}");
+ await AssertOrderShapeAsync("original", first.CARequestID, cn);
+
+ // 2. Drive to issuance and capture the issued cert's serial (what Command stores).
+ var issued = await DriveToIssuanceAsync(plugin, first.CARequestID);
+ // The sandbox sometimes parks a fresh DV order at "Pending for Approver"/"Pending System RA"
+ // for much longer than the polling budget. That is a precondition failure (no issued cert
+ // to renew), not a renewal failure, so it is reported as a skip rather than a red test.
+ bool preconditionMet = issued.Status == (int)EndEntityStatus.GENERATED
+ && !string.IsNullOrWhiteSpace(issued.Certificate);
+ Skip.IfNot(preconditionMet,
+ $"Precondition not met: original order {first.CARequestID} did not issue within the polling budget " +
+ $"(status={issued.Status}); the sandbox is holding it for approval. Re-run; the order is cleaned up.");
+ var priorCert = ParseCert(issued.Certificate);
+ string priorSerial = SerialHex(priorCert);
+ _output.WriteLine($"Original order {first.CARequestID}: issued, serial length={priorSerial.Length} hex chars");
+
+ // 3. Wire the inventory the way Command presents it: serial -> CARequestID + expiry.
+ var track = await _fixture.Client.TrackOrderAsync(first.CARequestID);
+ inventoryState.KnownSerialHex = priorSerial;
+ inventoryState.RequestId = first.CARequestID;
+ inventoryState.Expiry = priorCert.NotAfter.ToUniversalTime();
+ _output.WriteLine($"Prior cert expires {priorCert.NotAfter:yyyy-MM-dd} UTC " +
+ $"(TrackOrder certificateExpiryDate present={!string.IsNullOrWhiteSpace(track.OrderDetails?.CertificateExpiryDate)})");
+
+ // 4. Renewal: new key + CSR, same subject/SAN, PriorCertSN, window wide enough that the
+ // 1-year certificate is inside it so the renew API branch (UseRenewalApi=True) is taken.
+ var (renewCsr, renewPublicKey) = GenerateCsr(cn);
+ var renewInfo = TemplateProductInfo(priorCertSn: priorSerial, renewalWindowDays: 800);
+ int callsBeforeRenewal = calls.Count;
+
+ EnrollmentResult renewed;
+ try
+ {
+ renewed = await plugin.Enroll(renewCsr, subject, san, renewInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"RENEWAL FAILED: {TestOutputScrub.Describe(ex)}");
+ throw;
+ }
+
+ // 5. Assertions on the renewal result.
+ renewed.CARequestID.Should().NotBeNullOrWhiteSpace();
+ created.Add(renewed.CARequestID);
+ _output.WriteLine($"Renewal order {renewed.CARequestID}: Enroll status={renewed.Status} " +
+ $"({(renewed.Status == (int)EndEntityStatus.GENERATED ? "issued in-band" : "pending")}), " +
+ $"distinct from original = {renewed.CARequestID != first.CARequestID}; " +
+ $"message={TestOutputScrub.Scrub(renewed.StatusMessage)}");
+
+ inventoryState.SerialsRequested.Should().ContainSingle(s => SerialHexEquals(s, priorSerial),
+ "the plugin must resolve the prior certificate through PriorCertSN");
+ var renewalCalls = calls.Skip(callsBeforeRenewal).ToList();
+ _output.WriteLine($"Client calls during renewal Enroll: {string.Join(", ", renewalCalls)}");
+ renewalCalls.Should().Contain("RenewCertificateAsync",
+ "the renewal-window branch must go through RenewCertificateAsync, not fall back to a fresh enrollment");
+
+ renewed.CARequestID.Should().NotBe(first.CARequestID);
+ new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION }
+ .Should().Contain(renewed.Status, "the renewal order is either issued or pending validation, never failed");
+
+ // 6. The renewal order exists at CERTInext with the template product code, CN and SANs.
+ await AssertOrderShapeAsync("renewal", renewed.CARequestID, cn);
+
+ // 7. Drive the renewal to issuance; the cert must carry the NEW key and the requested names.
+ var renewedRec = await DriveToIssuanceAsync(plugin, renewed.CARequestID);
+ renewedRec.Status.Should().Be((int)EndEntityStatus.GENERATED, "the renewal order must issue");
+ var renewedCert = ParseCert(renewedRec.Certificate);
+ renewedCert.SerialNumber.Should().NotBe(priorCert.SerialNumber, "a renewal is a new certificate");
+ renewedCert.GetPublicKey().Should().Be(renewPublicKey, "the renewal must be issued against the NEW CSR's key");
+ var sanNames = (renewedCert.GetSubjectAlternativeNames()
+ ?? new List>())
+ .Select(e => e[1]?.ToString())
+ .ToList();
+ _output.WriteLine($"Renewal cert: subject CN matches={renewedCert.SubjectDN.ToString().Contains(cn, StringComparison.OrdinalIgnoreCase)}, " +
+ $"SANs=[{TestOutputScrub.Scrub(string.Join(", ", sanNames))}]");
+ renewedCert.SubjectDN.ToString().Should().Contain(cn);
+ sanNames.Should().Contain(n => string.Equals(n, cn, StringComparison.OrdinalIgnoreCase));
+ sanNames.Should().NotContain(n => n.StartsWith("www.", StringComparison.OrdinalIgnoreCase));
+ }
+ finally
+ {
+ cleanedUp = await CleanupAsync(plugin, created);
+ }
+
+ cleanedUp.Should().BeTrue("every sandbox order the test placed must end revoked/cancelled");
+ }
+
+ ///
+ /// Cleanup for orders left behind by an interrupted or skipped run: set
+ /// CERTINEXT_RENEWAL_E2E_CLEANUP_ORDERS to a comma-separated list of order numbers (plus
+ /// CERTINEXT_RENEWAL_E2E=1) and run this test. No-op when the list is unset.
+ ///
+ [SkippableFact]
+ public async Task CleanupLeftoverOrders()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+ Skip.If(Environment.GetEnvironmentVariable(OptInFlag) != "1",
+ $"Opt-in: export {OptInFlag}=1 in the shell.");
+ string list = Environment.GetEnvironmentVariable("CERTINEXT_RENEWAL_E2E_CLEANUP_ORDERS");
+ Skip.If(string.IsNullOrWhiteSpace(list), "CERTINEXT_RENEWAL_E2E_CLEANUP_ORDERS not set.");
+
+ var plugin = new CERTInextCAPlugin(_fixture.Client, BuildConfig());
+ var ids = list.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ (await CleanupAsync(plugin, ids)).Should().BeTrue("every listed order must end revoked/cancelled");
+ }
+
+ private static bool SerialHexEquals(string a, string b) =>
+ string.Equals(a?.TrimStart('0'), b?.TrimStart('0'), StringComparison.OrdinalIgnoreCase);
+ }
+}
+#endif
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/SyncHoldBackLiveTests.cs b/CERTInext.IntegrationTests/SyncHoldBackLiveTests.cs
new file mode 100644
index 0000000..bb48c82
--- /dev/null
+++ b/CERTInext.IntegrationTests/SyncHoldBackLiveTests.cs
@@ -0,0 +1,754 @@
+// 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.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Models;
+using Keyfactor.Logging;
+using Keyfactor.PKI.Enums.EJBCA;
+using Microsoft.Extensions.Logging;
+using Org.BouncyCastle.OpenSsl;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Live evidence for the issue 0090 sync hold-back (V1): Synchronize must not emit a pending or
+ /// issued order younger than SyncRecentOrderGraceSeconds (default 120) plus the clock margin,
+ /// and the NEXT INCREMENTAL pass must release it. Each scenario drives the real
+ /// with a real and an
+ /// explicit lastSync against the sandbox, and inspects what was emitted for an order the test
+ /// itself placed. Calls on the plugin's client are recorded through a pass-through proxy so "no
+ /// certificate download for a held order" is verified, not assumed.
+ ///
+ /// Opt-in: CERTINEXT_SYNC_HOLDBACK=1 as a REAL environment variable (places up to 2 sandbox orders;
+ /// needs CERTINEXT_DCV_DOMAIN, the base domain for unique per-run test names; no DCV is performed,
+ /// the sandbox issues without it). Every order is revoked (or rejected if never issued) afterwards.
+ /// Only incremental passes are run: a full-history sync against the shared sandbox account is unbounded
+ /// and slow, so the fullSync path stays covered by unit tests only.
+ /// Output goes through ; only allow-listed plugin log lines are retained.
+ ///
+ public class SyncHoldBackLiveTests : IClassFixture
+ {
+ private const string OptInFlag = "CERTINEXT_SYNC_HOLDBACK";
+ private const string MaxTodayRowsFlag = "CERTINEXT_SYNC_HOLDBACK_MAX_ROWS";
+ private const int DefaultMaxTodayRows = 400;
+
+ private static readonly int Grace = Constants.Api.DefaultSyncRecentOrderGraceSeconds;
+ private static readonly int Margin = Constants.Api.SyncRecentOrderClockMarginSeconds;
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ public SyncHoldBackLiveTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ private void W(string s) => _output.WriteLine(TestOutputScrub.Scrub(s));
+
+ // ---------------------------------------------------------------------------
+ // Recording client proxy (pass-through)
+ // ---------------------------------------------------------------------------
+
+ internal sealed class ClientCall
+ {
+ public DateTime At;
+ public string Method;
+ public string StringArg; // order id for per-order calls
+ public DateTime? DateArg; // issuedAfter for the listing
+ }
+
+ /// Forwards every call to the live client and records it.
+ public class RecordingProxy : DispatchProxy
+ {
+ internal ICERTInextClient Inner;
+ internal ConcurrentQueue Calls;
+
+ protected override object Invoke(MethodInfo targetMethod, object[] args)
+ {
+ var call = new ClientCall { At = DateTime.UtcNow, Method = targetMethod.Name };
+ if (args != null)
+ {
+ call.StringArg = args.OfType().FirstOrDefault();
+ if (targetMethod.Name == nameof(ICERTInextClient.ListCertificatesAsync))
+ call.DateArg = args.Length > 0 && args[0] is DateTime d ? d : (DateTime?)null;
+ }
+ Calls.Enqueue(call);
+ try
+ {
+ return targetMethod.Invoke(Inner, args);
+ }
+ catch (TargetInvocationException ex) when (ex.InnerException != null)
+ {
+ ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
+ throw;
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Log capture (allow-listed plugin / client lines only)
+ // ---------------------------------------------------------------------------
+
+ internal sealed class LogLine
+ {
+ public DateTime At;
+ public LogLevel Level;
+ public string Text;
+ }
+
+ private sealed class CapturingLogger : ILogger
+ {
+ public ConcurrentQueue Lines { get; } = new ConcurrentQueue();
+
+ public IDisposable BeginScope(TState state) => null;
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception,
+ Func formatter)
+ {
+ // Decide from the unformatted template so payload dumps are never even rendered.
+ if (!(state is IReadOnlyList> kvs)) return;
+ string fmt = kvs.FirstOrDefault(k => k.Key == "{OriginalFormat}").Value as string ?? string.Empty;
+ if (!(fmt.StartsWith("Sync:", StringComparison.Ordinal)
+ || fmt.StartsWith("Starting CERTInext synchronization", StringComparison.Ordinal)
+ || fmt.StartsWith("CERTInext synchronization complete", StringComparison.Ordinal)
+ || fmt.StartsWith("CERTInext API call:", StringComparison.Ordinal)))
+ return;
+ string text;
+ try { text = formatter(state, exception); } catch { return; }
+ Lines.Enqueue(new LogLine { At = DateTime.UtcNow, Level = logLevel, Text = text });
+ }
+ }
+
+ /// Routes every category to one capturing logger (the full LoggerFactory package is not referenced here).
+ private sealed class CapturingFactory : ILoggerFactory
+ {
+ public CapturingLogger Logger { get; } = new CapturingLogger();
+ public ILogger CreateLogger(string categoryName) => Logger;
+ public void AddProvider(ILoggerProvider provider) { }
+ public void Dispose() { }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Harness: logging swap, recording client, created-order registry
+ // ---------------------------------------------------------------------------
+
+ private sealed class Harness : IDisposable
+ {
+ public CapturingLogger Log;
+ public ConcurrentQueue Calls = new ConcurrentQueue();
+ public ICERTInextClient RecordingClient;
+ public List CreatedOrders = new List();
+
+ private ILoggerFactory _factory;
+ private IDisposable _clientLoggerOverride;
+
+ public static Harness Create(CERTInextClient liveClient)
+ {
+ var h = new Harness();
+ var factory = new CapturingFactory();
+ h.Log = factory.Logger;
+ h._factory = factory;
+ LogHandler.Factory = h._factory; // plugin logger resolves at construction
+ h._clientLoggerOverride = CERTInextClient.OverrideLoggerForTests(h._factory.CreateLogger("client"));
+
+ var proxy = DispatchProxy.Create();
+ var rp = (RecordingProxy)(object)proxy;
+ rp.Inner = liveClient;
+ rp.Calls = h.Calls;
+ h.RecordingClient = proxy;
+ return h;
+ }
+
+ public void Dispose()
+ {
+ _clientLoggerOverride?.Dispose();
+ LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance;
+ _factory?.Dispose();
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Live helpers
+ // ---------------------------------------------------------------------------
+
+ private CERTInextConfig BuildConfig(int? graceSeconds)
+ {
+ var c = _fixture.Config;
+ var cfg = new CERTInextConfig
+ {
+ ApiUrl = c.ApiUrl,
+ AuthMode = c.AuthMode,
+ ApiKey = c.ApiKey,
+ AccountNumber = c.AccountNumber,
+ GroupNumber = c.GroupNumber,
+ OrganizationNumber = c.OrganizationNumber,
+ RequestorName = c.RequestorName,
+ RequestorEmail = c.RequestorEmail,
+ RequestorIsdCode = c.RequestorIsdCode,
+ RequestorMobileNumber = c.RequestorMobileNumber,
+ SignerPlace = c.SignerPlace,
+ SignerIp = c.SignerIp,
+ DefaultProductCode = c.DefaultProductCode,
+ PageSize = c.PageSize,
+ DcvEnabled = false,
+ };
+ if (graceSeconds.HasValue)
+ cfg.SyncRecentOrderGraceSeconds = graceSeconds.Value; // otherwise the shipped default is exercised
+ return cfg;
+ }
+
+ private sealed class Placed
+ {
+ public string Domain;
+ public string OrderNumber;
+ public DateTime T0Utc; // local UTC just before the placement call
+ public DateTime TRespUtc; // local UTC when the placement call (place + TrackOrder) returned
+ public DateTime? CaOrderDateUtc;
+ public string CaOrderDateRaw;
+ }
+
+ private sealed class Probe
+ {
+ public DateTime AtUtc;
+ public int Rows;
+ public OrderReportEntry Mine;
+ public Dictionary StatusCounts = new Dictionary();
+ }
+
+ private static string DayOf(DateTime utc) => utc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+
+ /// One raw GetOrderReport listing from the UTC day of (read-only).
+ private async Task ProbeAsync(CERTInextClient client, DateTime day, string domain)
+ {
+ var p = new Probe { AtUtc = DateTime.UtcNow };
+ await foreach (var e in client.ListOrdersAsync(orderDateFrom: DayOf(day), pageSize: 100))
+ {
+ p.Rows++;
+ string key = $"{e.CertificateStatusId}/{e.CertificateStatus}";
+ p.StatusCounts[key] = p.StatusCounts.TryGetValue(key, out var n) ? n + 1 : 1;
+ if (domain != null && string.Equals(e.DomainName, domain, StringComparison.OrdinalIgnoreCase))
+ p.Mine = e;
+ }
+ return p;
+ }
+
+ private static DateTime? ParseCaDate(string raw) =>
+ !string.IsNullOrWhiteSpace(raw)
+ && DateTime.TryParse(raw, CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var d)
+ ? d : (DateTime?)null;
+
+ private static bool IsCertGenerated(OrderReportEntry e) =>
+ e != null && (e.CertificateStatusId == "20" || e.CertificateStatusId == "9");
+
+ private static bool MapsToIssued(OrderReportEntry e) =>
+ e != null && StatusMapper.CertificateStatusIdToRequestDisposition(e.CertificateStatusId) == (int)EndEntityStatus.GENERATED;
+
+ /// Places the order and fills (so a concurrent observer can already read Domain).
+ private async Task PlaceOrderAsync(CERTInextClient client, Placed placed)
+ {
+ string domain = placed.Domain;
+ string productCode = string.IsNullOrWhiteSpace(_fixture.Config.DefaultProductCode)
+ ? Constants.Products.DvSsl : _fixture.Config.DefaultProductCode;
+ string csr = KeyAlgorithms.GenerateCsrPem(domain, KeyAlgorithms.For("RSA-2048")); // BouncyCastle
+ var req = new EnrollCertificateRequest
+ {
+ ProfileId = productCode,
+ Csr = csr,
+ ValidityYears = 1,
+ Subject = $"CN={domain}",
+ Sans = new List { new SanEntry { Type = "dns", Value = domain } },
+ Comment = "Issue 0090 sync hold-back live test (sandbox)."
+ };
+
+ placed.T0Utc = DateTime.UtcNow;
+ var resp = await client.EnrollCertificateAsync(req); // GenerateOrderSSL + TrackOrder, as Enroll makes them
+ placed.TRespUtc = DateTime.UtcNow;
+ Assert.False(string.IsNullOrWhiteSpace(resp.Id), "placement returned no order number");
+ Assert.True(placed.OrderNumber == null || placed.OrderNumber == resp.Id,
+ "order number seen in the listing during placement differs from the placement response");
+ placed.OrderNumber = resp.Id;
+ return placed;
+ }
+
+ private static string RunId() => DateTime.UtcNow.ToString("MMddHHmmss", CultureInfo.InvariantCulture);
+
+ private string BaseDomainOrSkip()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+ Skip.If(Environment.GetEnvironmentVariable(OptInFlag) != "1",
+ $"Opt-in: set {OptInFlag}=1 as a real environment variable (places live sandbox orders).");
+ string dcvDomain = Environment.GetEnvironmentVariable("CERTINEXT_DCV_DOMAIN");
+ Skip.If(string.IsNullOrWhiteSpace(dcvDomain), "CERTINEXT_DCV_DOMAIN is required (base domain for unique test names).");
+ return dcvDomain;
+ }
+
+ ///
+ /// A sync pass lists the whole UTC day and refetches every issued row; refuse to start on a day that is
+ /// already crowded, so the timed windows are not eaten by foreign rows.
+ ///
+ private async Task PreflightAsync(CERTInextClient client)
+ {
+ var p = await ProbeAsync(client, DateTime.UtcNow, null);
+ W($"preflight: {p.Rows} order rows listed for UTC day {DayOf(DateTime.UtcNow)}: " +
+ string.Join(", ", p.StatusCounts.OrderByDescending(k => k.Value).Select(k => $"{k.Key} x{k.Value}")));
+ int max = int.TryParse(Environment.GetEnvironmentVariable(MaxTodayRowsFlag), out var m) ? m : DefaultMaxTodayRows;
+ Assert.True(p.Rows <= max,
+ $"{p.Rows} rows already listed today (cap {max}, override with {MaxTodayRowsFlag}); each pass would refetch the issued ones, which would distort the timing windows.");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Sync pass runner
+ // ---------------------------------------------------------------------------
+
+ private sealed class Pass
+ {
+ public string Name;
+ public DateTime StartUtc, EndUtc;
+ public DateTime LastSyncUtc;
+ public List IssuedAfterSeen = new List();
+ public List Emitted = new List();
+ public List Log = new List();
+ public List Calls = new List();
+ public string Error;
+ }
+
+ private static async Task RunPassAsync(string name, CERTInextCAPlugin plugin, Harness h, DateTime lastSyncUtc)
+ {
+ var pass = new Pass { Name = name, LastSyncUtc = lastSyncUtc, StartUtc = DateTime.UtcNow };
+ var buffer = new BlockingCollection(boundedCapacity: 10_000);
+
+ var syncTask = Task.Run(async () =>
+ {
+ try
+ {
+ await plugin.Synchronize(buffer, lastSyncUtc, fullSync: false, cancelToken: CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ pass.Error = TestOutputScrub.Describe(ex);
+ }
+ finally
+ {
+ if (!buffer.IsAddingCompleted) buffer.CompleteAdding();
+ }
+ });
+
+ foreach (var rec in buffer.GetConsumingEnumerable())
+ pass.Emitted.Add(rec);
+ await syncTask;
+ pass.EndUtc = DateTime.UtcNow;
+
+ pass.Log = h.Log.Lines.Where(l => l.At >= pass.StartUtc && l.At <= pass.EndUtc.AddMilliseconds(50)).ToList();
+ pass.Calls = h.Calls.Where(c => c.At >= pass.StartUtc && c.At <= pass.EndUtc.AddMilliseconds(50)).ToList();
+ pass.IssuedAfterSeen = pass.Calls
+ .Where(c => c.Method == nameof(ICERTInextClient.ListCertificatesAsync))
+ .Select(c => c.DateArg).ToList();
+ return pass;
+ }
+
+ private static bool Mine(LogLine l, string id) => l.Text.Contains(id);
+
+ private static LogLine HoldLine(Pass p, string id) =>
+ p.Log.FirstOrDefault(l => Mine(l, id) && l.Text.StartsWith("Sync: holding back", StringComparison.Ordinal));
+
+ private static LogLine ProcessingLine(Pass p, string id) =>
+ p.Log.FirstOrDefault(l => Mine(l, id) && l.Text.StartsWith("Sync: processing order", StringComparison.Ordinal));
+
+ private static LogLine SummaryLine(Pass p) =>
+ p.Log.LastOrDefault(l => l.Text.StartsWith("CERTInext synchronization complete", StringComparison.Ordinal));
+
+ private static int SummaryInt(Pass p, string key)
+ {
+ var m = Regex.Match(SummaryLine(p)?.Text ?? string.Empty, key + @"=(\d+)");
+ return m.Success ? int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture) : -1;
+ }
+
+ private static string F(double x) => x.ToString("0.0", CultureInfo.InvariantCulture);
+
+ private void Describe(Pass p, Placed o, OrderReportEntry listedBefore)
+ {
+ string id = o.OrderNumber;
+ DateTime? ca = o.CaOrderDateUtc;
+ string SinceOrder(DateTime t) => ca.HasValue ? $"t+{F((t - ca.Value).TotalSeconds)}s" : "t+?";
+ var proc = ProcessingLine(p, id);
+ var hold = HoldLine(p, id);
+ string holdAge = hold == null ? "-" : Regex.Match(hold.Text, @"AgeSeconds=([0-9.\-]+)").Groups[1].Value;
+ var mine = p.Emitted.Where(r => r.CARequestID == id).ToList();
+ var calls = p.Calls.Where(c => c.StringArg == id).Select(c => c.Method).ToList();
+ var paths = p.Log.Where(l => l.Text.StartsWith("CERTInext API call:", StringComparison.Ordinal))
+ .Select(l => Regex.Match(l.Text, @"Path=(\w+)").Groups[1].Value)
+ .GroupBy(x => x).Select(g => $"{g.Key}x{g.Count()}");
+
+ W($"--- pass {p.Name}: start {SinceOrder(p.StartUtc)}, end {SinceOrder(p.EndUtc)} (took {F((p.EndUtc - p.StartUtc).TotalSeconds)}s)");
+ W($" lastSync={p.LastSyncUtc:HH:mm:ss.fff}Z; issuedAfter passed to listing=" +
+ $"{string.Join(",", p.IssuedAfterSeen.Select(d => d.HasValue ? d.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ") : "null"))}" +
+ $" => orderDateFrom={string.Join(",", p.IssuedAfterSeen.Select(d => d.HasValue ? DayOf(d.Value.ToUniversalTime()) : "none"))}");
+ W($" plugin trace for the order: {(proc == null ? "(none)" : TestOutputScrub.Scrub(proc.Text))}");
+ W($" separate listing probe around the pass: {(listedBefore == null ? "(not listed)" : listedBefore.CertificateStatusId + "/" + listedBefore.CertificateStatus)}");
+ W($" order row processed at {(proc == null ? "(not seen in listing)" : SinceOrder(proc.At) + " => age " + F((proc.At - (ca ?? proc.At)).TotalSeconds) + "s")}; " +
+ $"hold-back log={(hold == null ? "no" : "yes (AgeSeconds=" + holdAge + ")")}");
+ W($" emitted: total={p.Emitted.Count}, this order={(mine.Count == 0 ? "NO" : "YES x" + mine.Count)}" +
+ (mine.Count > 0 ? $" (status={mine[0].Status}, bodyBytes={mine[0].Certificate?.Length ?? 0})" : string.Empty));
+ W($" client calls for this order id during pass: [{string.Join(", ", calls)}]");
+ W($" HTTP paths during pass: {string.Join(", ", paths)}");
+ W($" summary: {TestOutputScrub.Scrub(SummaryLine(p)?.Text ?? "(none)")}");
+ if (p.Error != null) W($" SYNC ERROR: {p.Error}");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Cleanup
+ // ---------------------------------------------------------------------------
+
+ private async Task RejectOrderAsync(CERTInextClient client, string orderNumber)
+ {
+ var m = typeof(CERTInextClient).GetMethod("BuildMetaAsync", BindingFlags.NonPublic | BindingFlags.Instance)
+ ?? throw new InvalidOperationException("BuildMetaAsync not found");
+ var metaTask = m.Invoke(client, new object[] { CancellationToken.None }) as Task
+ ?? throw new InvalidOperationException("BuildMetaAsync returned no task");
+ var meta = await metaTask;
+ string body = JsonSerializer.Serialize(new
+ {
+ meta,
+ orderDetails = new { orderNumber, rejectRemarks = "sync hold-back live test cleanup" }
+ });
+ using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
+ using var resp = await http.PostAsync(_fixture.Config.ApiUrl.TrimEnd('/') + "/" + Constants.Api.RejectOrderPath,
+ new StringContent(body, Encoding.UTF8, "application/json"));
+ string content = await resp.Content.ReadAsStringAsync();
+ try
+ {
+ using var doc = JsonDocument.Parse(content);
+ var metaEl = doc.RootElement.GetProperty("meta");
+ string Get(string k) => metaEl.TryGetProperty(k, out var v) ? v.ToString() : "";
+ return $"HTTP {(int)resp.StatusCode} status={Get("status")} errorCode={Get("errorCode")} errorMessage={Get("errorMessage")}";
+ }
+ catch
+ {
+ return $"HTTP {(int)resp.StatusCode} (unparsed body, {content?.Length ?? 0} chars)";
+ }
+ }
+
+ /// Revokes each created order once it is issued (waits up to 90 s), else RejectOrder; then verifies.
+ private async Task CleanupAsync(CERTInextClient client, IReadOnlyList orders)
+ {
+ W("=== cleanup ===");
+ if (orders.Count == 0) { W("no orders created"); return; }
+ foreach (var id in orders)
+ {
+ try
+ {
+ int st = 0;
+ var deadline = DateTime.UtcNow.AddSeconds(90);
+ while (true)
+ {
+ var t = await client.TrackOrderAsync(id);
+ int.TryParse(t.OrderDetails?.CertificateStatusId, out st);
+ if (st == Constants.CertificateStatusId.CertificateGenerated
+ || st == Constants.CertificateStatusId.CertificateDownloaded
+ || st == Constants.CertificateStatusId.CertificateRevoked
+ || DateTime.UtcNow >= deadline) break;
+ await Task.Delay(TimeSpan.FromSeconds(4));
+ }
+
+ if (st == Constants.CertificateStatusId.CertificateRevoked)
+ W($"cleanup {id}: already revoked");
+ else if (st == Constants.CertificateStatusId.CertificateGenerated
+ || st == Constants.CertificateStatusId.CertificateDownloaded)
+ {
+ await client.RevokeCertificateAsync(id, new RevokeCertificateRequest
+ {
+ Reason = Constants.RevocationReason.CessationOfOperation,
+ Comment = "sync hold-back live test cleanup"
+ });
+ W($"cleanup {id}: revoke accepted (was certificateStatusId={st})");
+ }
+ else
+ {
+ W($"cleanup {id}: RejectOrder (was certificateStatusId={st}) -> {await RejectOrderAsync(client, id)}");
+ }
+ }
+ catch (Exception ex)
+ {
+ W($"cleanup {id}: FAILED -> {TestOutputScrub.Describe(ex)}");
+ }
+ await Task.Delay(TimeSpan.FromSeconds(2));
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(8));
+ foreach (var id in orders)
+ {
+ try
+ {
+ var after = await client.TrackOrderAsync(id);
+ W($"verify {id}: orderStatusId={after.OrderDetails?.OrderStatusId} ({after.OrderDetails?.OrderStatus}), " +
+ $"certificateStatusId={after.OrderDetails?.CertificateStatusId} ({after.OrderDetails?.CertificateStatus})");
+ }
+ catch (Exception ex)
+ {
+ W($"verify {id}: TrackOrder failed -> {TestOutputScrub.Describe(ex)}");
+ }
+ }
+ }
+
+ private void ReportSkew(Placed o)
+ {
+ if (!o.CaOrderDateUtc.HasValue) { W("skew: CA orderDate not available"); return; }
+ var ca = o.CaOrderDateUtc.Value;
+ // The CA stamps whole seconds (truncated) at some instant in [t0, tResp] on its clock; so
+ // caClock - localClock lies in [ca - tResp, ca + 1s - t0].
+ W($"skew: CA orderDate raw='{o.CaOrderDateRaw}' (read as UTC); local t0={o.T0Utc:HH:mm:ss.fff}Z, tResp={o.TRespUtc:HH:mm:ss.fff}Z " +
+ $"(placement took {F((o.TRespUtc - o.T0Utc).TotalSeconds)}s); caOrderDate - t0 = {F((ca - o.T0Utc).TotalSeconds)}s; " +
+ $"caOrderDate - tResp = {F((ca - o.TRespUtc).TotalSeconds)}s; CA-minus-local clock offset bounds " +
+ $"[{F((ca - o.TRespUtc).TotalSeconds)}s, {F((ca.AddSeconds(1) - o.T0Utc).TotalSeconds)}s]");
+ }
+
+ // ---------------------------------------------------------------------------
+ // 1. Control
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Control_GraceZero_ImmediateIncrementalSync_EmitsTheFreshOrder()
+ {
+ string baseDomain = BaseDomainOrSkip();
+ var client = _fixture.Client;
+ await PreflightAsync(client);
+
+ using var h = Harness.Create(client);
+ var plugin = new CERTInextCAPlugin(h.RecordingClient, BuildConfig(graceSeconds: 0));
+ string domain = $"shbc-{RunId()}.{baseDomain}".ToLowerInvariant();
+ var failures = new List();
+
+ try
+ {
+ var o = await PlaceOrderAsync(client, new Placed { Domain = domain });
+ h.CreatedOrders.Add(o.OrderNumber);
+ W($"control: order {o.OrderNumber} placed; grace=0");
+
+ var probe = await ProbeAsync(client, o.T0Utc, domain);
+ o.CaOrderDateRaw = probe.Mine?.OrderDate;
+ o.CaOrderDateUtc = ParseCaDate(o.CaOrderDateRaw);
+ ReportSkew(o);
+
+ var pass = await RunPassAsync("control", plugin, h, lastSyncUtc: o.T0Utc.AddSeconds(-60));
+ Describe(pass, o, probe.Mine);
+
+ var mine = pass.Emitted.Where(r => r.CARequestID == o.OrderNumber).ToList();
+ if (mine.Count != 1) failures.Add($"control: expected the fresh order to be emitted once with grace=0, got {mine.Count}");
+ if (HoldLine(pass, o.OrderNumber) != null) failures.Add("control: hold-back log appeared although grace=0");
+ if (SummaryInt(pass, "RecentOrderDeferred") != 0) failures.Add($"control: RecentOrderDeferred={SummaryInt(pass, "RecentOrderDeferred")}, expected 0");
+ if (pass.Error != null) failures.Add("control: sync error " + pass.Error);
+ }
+ finally
+ {
+ await CleanupAsync(client, h.CreatedOrders);
+ }
+
+ Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures));
+ }
+
+ // ---------------------------------------------------------------------------
+ // 2-5. Hold (pending), hold (issued), release on the next incremental pass
+ // ---------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task HoldBack_PendingThenIssuedAreHeld_AndNextIncrementalPassReleasesTheOrder()
+ {
+ string baseDomain = BaseDomainOrSkip();
+ var client = _fixture.Client;
+ await PreflightAsync(client);
+
+ using var h = Harness.Create(client);
+ var plugin = new CERTInextCAPlugin(h.RecordingClient, BuildConfig(graceSeconds: null)); // shipped default
+ string domain = $"shbh-{RunId()}.{baseDomain}".ToLowerInvariant();
+ var failures = new List();
+ var timeline = new List();
+
+ try
+ {
+ var o = new Placed { Domain = domain };
+ var lastSync0 = DateTime.UtcNow.AddSeconds(-60);
+
+ // ---- Pass A0: while GenerateOrderSSL is still in flight (the real enrollment race) ----
+ // Poll the listing by the unique domain; if the order shows up before the placement call
+ // returns and is not yet issued, run an incremental pass right then.
+ var placeTask = Task.Run(() => PlaceOrderAsync(client, o));
+ Pass passA0 = null;
+ OrderReportEntry seenInFlight = null;
+ while (!placeTask.IsCompleted)
+ {
+ var pr = await ProbeAsync(client, DateTime.UtcNow, domain);
+ if (pr.Mine != null && !string.IsNullOrWhiteSpace(pr.Mine.OrderNumber))
+ {
+ seenInFlight = pr.Mine;
+ break;
+ }
+ await Task.Delay(TimeSpan.FromMilliseconds(500));
+ }
+ if (seenInFlight != null && !placeTask.IsCompleted && !MapsToIssued(seenInFlight))
+ {
+ o.OrderNumber = seenInFlight.OrderNumber; // verified against the placement response afterwards
+ h.CreatedOrders.Add(o.OrderNumber);
+ passA0 = await RunPassAsync("A0 (pending, placement still in flight)", plugin, h, lastSync0);
+ }
+ await placeTask;
+ string id = o.OrderNumber;
+ if (!h.CreatedOrders.Contains(id)) h.CreatedOrders.Add(id);
+ W($"hold-back: order {id} placed; grace={Grace}s margin={Margin}s; " +
+ (seenInFlight == null ? "order was NOT visible in the listing before the placement response"
+ : $"order first visible in the listing before the response with status {seenInFlight.CertificateStatusId}/{seenInFlight.CertificateStatus}") +
+ (passA0 == null ? "; pass A0 not run" : "; pass A0 ran during placement"));
+
+ // ---- Pass A: first incremental pass, immediately after the placement response ----
+ // (before any other listing call, so the status is whatever the CA shows right now).
+ var passA = await RunPassAsync("A (first after response, young)", plugin, h, passA0?.EndUtc ?? lastSync0);
+
+ // Now fetch the CA's orderDate (skew + age math); Describe needs it.
+ Probe probe = null;
+ for (int i = 0; i < 20; i++)
+ {
+ probe = await ProbeAsync(client, o.T0Utc, domain);
+ if (probe.Mine != null) break;
+ await Task.Delay(TimeSpan.FromSeconds(1.5));
+ }
+ Assert.True(probe?.Mine != null, "order never appeared in the listing within 30 s of placement");
+ o.CaOrderDateRaw = probe.Mine.OrderDate;
+ o.CaOrderDateUtc = ParseCaDate(o.CaOrderDateRaw);
+ Assert.True(o.CaOrderDateUtc.HasValue, "could not parse the CA orderDate");
+ var ca = o.CaOrderDateUtc.Value;
+ ReportSkew(o);
+ if (passA0 != null)
+ {
+ Describe(passA0, o, probe.Mine);
+ if (passA0.Emitted.Any(r => r.CARequestID == id)) failures.Add("A0: young order was emitted while placement was in flight");
+ if (HoldLine(passA0, id) == null) failures.Add("A0: no 'holding back recent order' log line for the order");
+ if (SummaryInt(passA0, "RecentOrderDeferred") < 1) failures.Add("A0: sync summary does not count a deferred order");
+ if (passA0.Calls.Any(c => c.StringArg == id)) failures.Add("A0: client was called for the held order");
+ if (passA0.Error != null) failures.Add("A0: sync error " + passA0.Error);
+ var trace0 = ProcessingLine(passA0, id)?.Text ?? string.Empty;
+ W($" A0 status as the plugin saw it: {(trace0.Contains($"mappedStatus={(int)EndEntityStatus.EXTERNALVALIDATION},") ? "PENDING (EXTERNALVALIDATION)" : "not pending: " + TestOutputScrub.Scrub(trace0))}");
+ timeline.Add($"A0 pass start t+{F((passA0.StartUtc - ca).TotalSeconds)}s emitted={(passA0.Emitted.Any(r => r.CARequestID == id) ? "yes" : "no")} (placement response at t+{F((o.TRespUtc - ca).TotalSeconds)}s)");
+ }
+ Describe(passA, o, probe.Mine);
+
+ if (passA.Emitted.Any(r => r.CARequestID == id)) failures.Add("A: young order was emitted");
+ var holdA = HoldLine(passA, id);
+ if (holdA == null) failures.Add("A: no 'holding back recent order' log line for the order");
+ if (SummaryInt(passA, "RecentOrderDeferred") < 1) failures.Add("A: sync summary does not count a deferred order");
+ if (passA.Error != null) failures.Add("A: sync error " + passA.Error);
+ if ((passA.StartUtc - ca).TotalSeconds >= Grace + Margin) failures.Add("A: pass started too late to be a young-order test");
+ timeline.Add($"A pass start t+{F((passA.StartUtc - ca).TotalSeconds)}s emitted={(passA.Emitted.Any(r => r.CARequestID == id) ? "yes" : "no")}");
+
+ // ---- Pass B: issued, still young --------------------------------------------
+ Probe probeB = null;
+ var waitDeadline = ca.AddSeconds(110);
+ while (DateTime.UtcNow < waitDeadline)
+ {
+ probeB = await ProbeAsync(client, o.T0Utc, domain);
+ if (IsCertGenerated(probeB.Mine)) break;
+ await Task.Delay(TimeSpan.FromSeconds(3));
+ }
+ W($"before pass B: listing shows {probeB?.Mine?.CertificateStatusId}/{probeB?.Mine?.CertificateStatus} at t+{F((DateTime.UtcNow - ca).TotalSeconds)}s");
+ if (!MapsToIssued(probeB?.Mine))
+ failures.Add("B: order did not reach an issued status in the listing within 110 s; issued-hold not testable");
+
+ var passB = await RunPassAsync("B (issued)", plugin, h, lastSyncUtc: passA.EndUtc);
+ Describe(passB, o, probeB?.Mine);
+
+ if (passB.Emitted.Any(r => r.CARequestID == id)) failures.Add("B: young issued order was emitted");
+ if (HoldLine(passB, id) == null) failures.Add("B: no 'holding back recent order' log line for the order");
+ if (SummaryInt(passB, "RecentOrderDeferred") < 1) failures.Add("B: sync summary does not count a deferred order");
+ var downloadCallsB = passB.Calls.Where(c => c.StringArg == id).ToList();
+ if (downloadCallsB.Count > 0)
+ failures.Add("B: client was called for the held order: " + string.Join(",", downloadCallsB.Select(c => c.Method)));
+ if (passB.Error != null) failures.Add("B: sync error " + passB.Error);
+ if ((passB.StartUtc - ca).TotalSeconds >= Grace + Margin)
+ failures.Add($"B: pass started at age {F((passB.StartUtc - ca).TotalSeconds)}s, not under {Grace + Margin}s; hold not exercised");
+ timeline.Add($"B pass start t+{F((passB.StartUtc - ca).TotalSeconds)}s emitted={(passB.Emitted.Any(r => r.CARequestID == id) ? "yes" : "no")}");
+
+ // ---- Pass C: release on the next incremental pass ----------------------------
+ var releaseAt = ca.AddSeconds(135);
+ var wait = releaseAt - DateTime.UtcNow;
+ if (wait > TimeSpan.Zero) await Task.Delay(wait);
+ var probeC = await ProbeAsync(client, o.T0Utc, domain);
+ var passC = await RunPassAsync("C (release)", plugin, h, lastSyncUtc: passB.EndUtc);
+ Describe(passC, o, probeC.Mine);
+
+ var mineC = passC.Emitted.Where(r => r.CARequestID == id).ToList();
+ if (mineC.Count != 1) failures.Add($"C: expected the order to be emitted once on the next incremental pass, got {mineC.Count}");
+ else
+ {
+ if (mineC[0].Status != (int)EndEntityStatus.GENERATED)
+ failures.Add($"C: emitted with status {mineC[0].Status}, expected GENERATED ({(int)EndEntityStatus.GENERATED})");
+ if (string.IsNullOrWhiteSpace(mineC[0].Certificate))
+ failures.Add("C: emitted without a certificate body");
+ else
+ {
+ try
+ {
+ // The plugin hands the gateway whatever the CA returned: PEM-armored or bare base64 DER.
+ string body = mineC[0].Certificate;
+ bool armored = body.Contains("-----BEGIN");
+ Org.BouncyCastle.X509.X509Certificate cert = armored
+ ? new PemReader(new StringReader(body)).ReadObject() as Org.BouncyCastle.X509.X509Certificate
+ : new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(
+ Convert.FromBase64String(Regex.Replace(body, @"\s+", string.Empty)));
+ if (cert == null) failures.Add("C: certificate body is not a parseable X.509 certificate");
+ else W($" C: body parses as X.509 with BouncyCastle (armored={armored}, chars={body.Length}); serial={cert.SerialNumber.ToString(16)}");
+ }
+ catch (Exception ex)
+ {
+ failures.Add("C: certificate body did not parse: " + TestOutputScrub.Describe(ex));
+ }
+ }
+ }
+ if (HoldLine(passC, id) != null) failures.Add("C: order was still held back after the grace window");
+ if (!passC.Calls.Any(c => c.StringArg == id && c.Method == nameof(ICERTInextClient.GetCertificateAsync)))
+ failures.Add("C: no certificate fetch for the released issued order");
+ if (passC.Error != null) failures.Add("C: sync error " + passC.Error);
+ if (passC.LastSyncUtc != passB.EndUtc) failures.Add("C: lastSync was not the end of the previous pass");
+ timeline.Add($"C pass start t+{F((passC.StartUtc - ca).TotalSeconds)}s emitted={(mineC.Count > 0 ? "yes" : "no")}");
+
+ W("=== timeline (t = seconds since the CA orderDate) ===");
+ foreach (var line in timeline) W(line);
+ }
+ finally
+ {
+ await CleanupAsync(client, h.CreatedOrders);
+ }
+
+ Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures));
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/TESTING.md b/CERTInext.IntegrationTests/TESTING.md
index b961130..df5c1f7 100644
--- a/CERTInext.IntegrationTests/TESTING.md
+++ b/CERTInext.IntegrationTests/TESTING.md
@@ -94,6 +94,9 @@ The file is parsed line by line:
- Each line must be in `KEY=VALUE` format.
- Values are not quoted — do not surround values with `"` or `'`.
- Real environment variables override file values (useful for CI injection).
+- Opt-in flags that place real orders (`CERTINEXT_COMPLETE_PENDING`, `CERTINEXT_RUN_BULK_TEST`,
+ `CERTINEXT_ALGO_MATRIX`, `CERTINEXT_ALGO_MATRIX_DCV`, `CERTINEXT_SAN_PROBE`, `CERTINEXT_RENEWAL_E2E`) are **ignored** in this file;
+ they must be exported in the shell (see `IntegrationTestFixture.OptInOnlyFlags`).
---
@@ -190,6 +193,22 @@ account. These tests do not require any pre-existing account state.
|------|---------------|
| `Enroll_Synchronize_Revoke_FullLifecycle` | (1) Generates a fresh RSA-2048 CSR; (2) calls `Enroll` and asserts a non-empty `CARequestID` is returned; (3) runs a full sync and asserts the new order appears by `CARequestID`; (4) attempts revocation — skips gracefully if the order is not yet in an issued/approved state |
+### `RenewalLiveTests` (DCV build only, opt-in)
+
+Places 2 sandbox DV orders (needs Cloudflare DCV creds + `CERTINEXT_DCV_DOMAIN`); both are revoked afterwards.
+Build/run with `-p:DcvSupport=true` and `CERTINEXT_RENEWAL_E2E=1` exported in the shell.
+
+| Test | What it checks |
+|------|---------------|
+| `Enroll_RenewOrReissue_ThroughPlugin_PlacesRenewalOrder_WithTemplateProductCode` | `plugin.Enroll(New)` to issuance, then `plugin.Enroll(RenewOrReissue)` with a new CSR and `PriorCertSN` = the issued serial inside the renewal window: no EMS-918, new `CARequestID`, order exists with the template product code (not `DefaultProductCode`), CN/SAN match, renewal issues against the new key (skips if the sandbox never issues the first order) |
+| `CleanupLeftoverOrders` | Revokes orders listed in `CERTINEXT_RENEWAL_E2E_CLEANUP_ORDERS` (also needs `CERTINEXT_RENEWAL_E2E=1`) after an interrupted run |
+
+```sh
+set -a; . ~/.env_certinext; set +a
+CERTINEXT_RENEWAL_E2E=1 dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \
+ -p:DcvSupport=true --filter "FullyQualifiedName~RenewalLiveTests" --logger "console;verbosity=detailed"
+```
+
---
## Expected Outcomes by Account State
diff --git a/CERTInext.IntegrationTests/TestOutputScrub.cs b/CERTInext.IntegrationTests/TestOutputScrub.cs
new file mode 100644
index 0000000..fea50b1
--- /dev/null
+++ b/CERTInext.IntegrationTests/TestOutputScrub.cs
@@ -0,0 +1,56 @@
+// 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.RegularExpressions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Scrubs CA-originated text (exception messages, status messages, status strings) before it is
+ /// written to ITestOutputHelper, so a CA error that echoes the requestor or POC email from
+ /// ~/.env_certinext cannot land in test or CI output.
+ ///
+ internal static class TestOutputScrub
+ {
+ private const int MaxLength = 500;
+
+ private static readonly Regex EmailPattern =
+ new Regex(@"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", RegexOptions.Compiled);
+
+ /// Masks anything that looks like an email address, then truncates to 500 chars.
+ public static string Scrub(string s)
+ {
+ if (s == null) return null;
+ s = EmailPattern.Replace(s, "");
+ return s.Length > MaxLength ? s.Substring(0, MaxLength) : s;
+ }
+
+ ///
+ /// "TypeName: scrubbed message" for , followed by the same for each inner
+ /// exception (separated by " <- ").
+ ///
+ public static string Describe(Exception ex)
+ {
+ if (ex == null) return null;
+ var sb = new System.Text.StringBuilder();
+ for (var cur = ex; cur != null; cur = cur.InnerException)
+ {
+ if (sb.Length > 0) sb.Append(" <- ");
+ sb.Append(cur.GetType().Name).Append(": ").Append(Scrub(cur.Message));
+ }
+ return sb.ToString();
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/TestOutputScrubTests.cs b/CERTInext.IntegrationTests/TestOutputScrubTests.cs
new file mode 100644
index 0000000..340bfa3
--- /dev/null
+++ b/CERTInext.IntegrationTests/TestOutputScrubTests.cs
@@ -0,0 +1,68 @@
+// 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 FluentAssertions;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Pure unit tests (no live API) for , which keeps CA-echoed
+ /// email addresses out of ITestOutputHelper output.
+ ///
+ public class TestOutputScrubTests
+ {
+ [Fact]
+ public void Scrub_NullInput_ReturnsNull()
+ {
+ TestOutputScrub.Scrub(null).Should().BeNull();
+ }
+
+ [Theory]
+ [InlineData("Requestor jane.doe+test@example.com is invalid", "Requestor is invalid")]
+ [InlineData("a@b.io and c_d@e-f.org", " and ")]
+ [InlineData("no address here", "no address here")]
+ public void Scrub_MasksEmailAddresses(string input, string expected)
+ {
+ TestOutputScrub.Scrub(input).Should().Be(expected);
+ }
+
+ [Fact]
+ public void Scrub_TruncatesTo500Characters()
+ {
+ TestOutputScrub.Scrub(new string('x', 800)).Should().HaveLength(500);
+ }
+
+ [Fact]
+ public void Describe_ScrubsMessageAndInnerExceptionMessages()
+ {
+ var ex = new InvalidOperationException(
+ "CA rejected contact poc@example.com",
+ new ArgumentException("inner echoes requestor@example.org"));
+
+ string text = TestOutputScrub.Describe(ex);
+
+ text.Should().NotContain("@example");
+ text.Should().Contain("InvalidOperationException: CA rejected contact ");
+ text.Should().Contain("ArgumentException: inner echoes ");
+ }
+
+ [Fact]
+ public void Describe_NullException_ReturnsNull()
+ {
+ TestOutputScrub.Describe(null).Should().BeNull();
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/ValidityProbeTests.cs b/CERTInext.IntegrationTests/ValidityProbeTests.cs
new file mode 100644
index 0000000..b827ffb
--- /dev/null
+++ b/CERTInext.IntegrationTests/ValidityProbeTests.cs
@@ -0,0 +1,1031 @@
+// 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.
+
+// Probe: can the CERTInext V1 API express a certificate lifetime shorter than one year?
+//
+// Why: CA/B Forum is shrinking maximum TLS certificate lifetimes (200 days now, 100 days from
+// Mar 2027, 47 days from Mar 2029). Today the plugin sends only subscriptionDetails.validity
+// ("1"/"2"/"3" = subscription YEARS); a template ValidityDays is converted with ceil(days/365),
+// so a 45-day request becomes a 1-year subscription. The V1 spec documents no days/lifetime
+// field. This probe gathers live evidence.
+//
+// Three tests, all opt-in (CERTINEXT_PROBE_VALIDITY=1) because they place real orders:
+//
+// Probe_RawValidityVariants raw GenerateOrderSSL drafts (saveAndHold=1): validity
+// "1"/"0"/"45", undocumented lifetime-ish fields at several
+// levels, and a pure-nonsense field as the negative control
+// for "does the API reject unknown fields at all".
+// Probe_PluginPath_ValidityDays45 / (SUPPORTS_DCV builds) enroll through the plugin with a
+// Probe_PluginPath_ValidityYears1_ template ValidityDays=45, then ValidityYears=1 (control),
+// Control drive DCV to issuance, and report the wire validity and
+// the issued certificate's notBefore/notAfter.
+// Probe_UndocumentedFields_Issued / (SUPPORTS_DCV builds) issue ONE raw order carrying every
+// Probe_RawIssuedControl undocumented lifetime-ish field (and a field-free control
+// through the same harness); if the shotgun certificate comes
+// back shorter than the control the API honored one.
+//
+// Residue: Probe_RawValidityVariants places five saveAndHold drafts that the V1 API cannot cancel
+// (RejectOrder / SubmitCSR / TrackOrder all answer EMS-943 for a draft; they sit "On Hold / Setup
+// Pending" with no certificate). Probe_CleanupAudit_TodaysVprobeOrders lists them;
+// Probe_CleanupSweep_TodaysVprobeOrders retries the cancel. Every issued/pending order is revoked or
+// rejected by the test that created it.
+//
+// Acceptance of an unknown field proves nothing by itself — only the issued certificate's
+// notAfter shows whether a field was honored. Every order is cancelled / revoked at the end and
+// the final TrackOrder state is printed as the cleanup verification.
+//
+// set -a; . ~/.env_certinext; set +a
+// export CERTINEXT_PROBE_VALIDITY=1
+// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \
+// -p:DcvSupport=true --filter "FullyQualifiedName~ValidityProbeTests" \
+// --logger "console;verbosity=detailed" > /tmp/probe45-v1.log 2>&1
+//
+// (xUnit buffers ITestOutputHelper output until the test ends — read the report at the tail.)
+// Test output is passed through TestOutputScrub / key filtering so contact emails and other
+// personal data from order payloads never reach it.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Microsoft.Extensions.Logging;
+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;
+#if SUPPORTS_DCV
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.PKI.Enums.EJBCA;
+#endif
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ public class ValidityProbeTests : IClassFixture, IDisposable
+ {
+ private const string OptInFlag = "CERTINEXT_PROBE_VALIDITY";
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _out;
+ private readonly List _toDispose = new List();
+ private readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
+ private static readonly SecureRandom Rng = new SecureRandom();
+
+ /// Every order this class creates; swept by .
+ private readonly List _created = new List();
+ private List LastRawShape = new List();
+
+ public ValidityProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _out = output;
+ }
+
+ public void Dispose()
+ {
+ foreach (var d in _toDispose) d.Dispose();
+ _toDispose.Clear();
+ _http.Dispose();
+ }
+
+ private void RequireOptIn()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+ Skip.IfNot(
+ Environment.GetEnvironmentVariable(OptInFlag) == "1",
+ $"Set {OptInFlag}=1 to run this probe — it places real orders on the configured account.");
+ }
+
+ // -------------------------------------------------------------------------------
+ // Raw GenerateOrderSSL plumbing
+ // -------------------------------------------------------------------------------
+
+ private sealed class CreatedOrder
+ {
+ public string Label;
+ public string OrderNumber;
+ public string RequestNumber;
+ public bool Issued; // true when it may need RevokeOrder instead of RejectOrder
+ }
+
+ private sealed class RawResult
+ {
+ public string Label;
+ public int HttpStatus;
+ public bool Accepted;
+ public string ErrorCode;
+ public string ErrorMessage;
+ public string OrderNumber;
+ public string RequestNumber;
+ public JsonNode Body;
+ }
+
+ private static string NewCsrPem(string cn)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(Rng, 2048));
+ AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair();
+ var csr = new Pkcs10CertificationRequest(
+ "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, null, kp.Private);
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ /// meta block, authKey = SHA256(accessKey + ts + txn) computed with BouncyCastle.
+ private JsonObject BuildMeta()
+ {
+ string ts = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture);
+ long val = (Rng.NextLong() & long.MaxValue) % 1_000_000_000_000_000_000L + 1L;
+ string txn = val.ToString(CultureInfo.InvariantCulture);
+
+ byte[] input = Encoding.UTF8.GetBytes(_fixture.Config.ApiKey + ts + txn);
+ var digest = new Org.BouncyCastle.Crypto.Digests.Sha256Digest();
+ digest.BlockUpdate(input, 0, input.Length);
+ byte[] hash = new byte[digest.GetDigestSize()];
+ digest.DoFinal(hash, 0);
+
+ return new JsonObject
+ {
+ ["ver"] = "1.0",
+ ["ts"] = ts,
+ ["txn"] = txn,
+ ["accountNumber"] = _fixture.Config.AccountNumber,
+ ["authKey"] = Convert.ToHexString(hash).ToLowerInvariant()
+ };
+ }
+
+ private async Task<(int Http, JsonNode Body)> PostAsync(string path, JsonObject body)
+ {
+ string url = _fixture.Config.ApiUrl.TrimEnd('/') + "/" + path;
+ using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
+ using var resp = await _http.PostAsync(url, content);
+ string text = await resp.Content.ReadAsStringAsync();
+ JsonNode parsed = null;
+ try { parsed = JsonNode.Parse(text); } catch (Exception) { /* non-JSON body */ }
+ return ((int)resp.StatusCode, parsed);
+ }
+
+ ///
+ /// A DV SSL order body shaped like the plugin's (same fields, same locations), with
+ /// applied before it is sent so a variant can change or add fields.
+ ///
+ private JsonObject BuildOrderBody(string cn, string csr, bool saveAndHold, Action mutate)
+ {
+ var cfg = _fixture.Config;
+ string reqName = string.IsNullOrWhiteSpace(cfg.RequestorName) ? "Keyfactor Integration Test" : cfg.RequestorName;
+ string first = reqName.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? reqName;
+ string last = reqName.Trim().Contains(' ') ? reqName.Trim().Substring(reqName.Trim().IndexOf(' ')).Trim() : first;
+
+ var details = new JsonObject
+ {
+ ["productCode"] = cfg.DefaultProductCode,
+ ["accountingModel"] = "2",
+ ["saveAndHold"] = saveAndHold ? "1" : "0",
+ ["emailNotifications"] = "0",
+ ["autoSecureWWW"] = "0",
+ ["requestorInformation"] = new JsonObject
+ {
+ ["requestorName"] = reqName,
+ ["requestorIsdCode"] = "1",
+ ["requestorMobileNumber"] = cfg.RequestorMobileNumber,
+ ["requestorEmail"] = cfg.RequestorEmail
+ },
+ ["subscriptionDetails"] = new JsonObject
+ {
+ ["validity"] = "1",
+ ["autoRenew"] = "0",
+ ["renewCriteria"] = "30"
+ },
+ ["certificateInformation"] = new JsonObject { ["domainName"] = cn },
+ ["technicalPointOfContact"] = new JsonObject
+ {
+ ["pocFirstName"] = first,
+ ["pocLastName"] = last,
+ ["pocEmail"] = cfg.RequestorEmail,
+ ["pocIsdCode"] = "1",
+ ["pocMobileNumber"] = cfg.RequestorMobileNumber
+ },
+ ["agreementDetails"] = new JsonObject
+ {
+ ["acceptAgreement"] = "1",
+ ["signerName"] = reqName,
+ ["signerPlace"] = "Gateway",
+ ["signerIP"] = "127.0.0.1"
+ },
+ ["additionalInformation"] = new JsonObject { ["remarks"] = "validity probe" }
+ };
+ if (!string.IsNullOrWhiteSpace(cfg.GroupNumber)) details["groupNumber"] = cfg.GroupNumber;
+ if (!string.IsNullOrWhiteSpace(cfg.OrganizationNumber))
+ details["organizationDetails"] = new JsonObject
+ {
+ ["preVetting"] = "1",
+ ["organizationNumber"] = cfg.OrganizationNumber
+ };
+ if (!string.IsNullOrWhiteSpace(csr)) details["csr"] = csr;
+
+ var root = new JsonObject { ["meta"] = BuildMeta(), ["orderDetails"] = details };
+ mutate?.Invoke(root);
+ return root;
+ }
+
+ private async Task PlaceRawOrderAsync(
+ string label, bool saveAndHold, bool withCsr, Action mutate, bool issuedIntent = false)
+ {
+ string cn = $"vprobe-{DateTime.UtcNow:HHmmssfff}-{Rng.Next(1000, 9999)}.scrup.org";
+ string csr = withCsr ? NewCsrPem(cn) : null;
+
+ (int http, JsonNode body) = (0, null);
+ // The sandbox answers burst order placement with "Inactive Account User." — back off and
+ // retry with a fresh meta/txn, same as the plugin's client does.
+ for (int attempt = 1; attempt <= 4; attempt++)
+ {
+ var req = BuildOrderBody(cn, csr, saveAndHold, mutate);
+ LastRawShape = Shape(req);
+ (http, body) = await PostAsync("GenerateOrderSSL", req);
+ string msg = body?["meta"]?["errorMessage"]?.ToString() ?? "";
+ if (msg.IndexOf("Inactive Account User", StringComparison.OrdinalIgnoreCase) < 0) break;
+ await Task.Delay(TimeSpan.FromSeconds(10 * attempt));
+ }
+
+ var meta = body?["meta"];
+ string status = meta?["status"]?.ToString();
+ var result = new RawResult
+ {
+ Label = label,
+ HttpStatus = http,
+ Accepted = status == "1",
+ ErrorCode = meta?["errorCode"]?.ToString(),
+ ErrorMessage = TestOutputScrub.Scrub(meta?["errorMessage"]?.ToString()),
+ OrderNumber = body?["orderDetails"]?["orderNumber"]?.ToString(),
+ RequestNumber = body?["orderDetails"]?["requestNumber"]?.ToString(),
+ Body = body
+ };
+ if (result.Accepted)
+ _created.Add(new CreatedOrder
+ {
+ Label = label,
+ OrderNumber = result.OrderNumber,
+ RequestNumber = result.RequestNumber,
+ Issued = issuedIntent
+ });
+
+ await Task.Delay(2000); // sandbox rate-limits order bursts
+ return result;
+ }
+
+ private async Task RawTrackAsync(string orderNumber)
+ {
+ var body = new JsonObject
+ {
+ ["meta"] = BuildMeta(),
+ ["orderDetails"] = new JsonObject { ["orderNumber"] = orderNumber }
+ };
+ return (await PostAsync("TrackOrder", body)).Body;
+ }
+
+ // -------------------------------------------------------------------------------
+ // Output helpers (no personal data)
+ // -------------------------------------------------------------------------------
+
+ private static readonly Regex InterestingKey = new Regex(
+ "valid|subscr|expir|term|period|renew|days|before|after|issue|lifetime|status|date|dcv|caa",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ private static readonly Regex SensitiveKey = new Regex(
+ "mail|name|phone|mobile|contact|signer|csr|pem|chain|authkey|poc|requestor|certificate$|endentity",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ private static void Walk(JsonNode node, string path, List<(string Path, string Value)> leaves)
+ {
+ switch (node)
+ {
+ case JsonObject o:
+ foreach (var kv in o) Walk(kv.Value, path.Length == 0 ? kv.Key : path + "." + kv.Key, leaves);
+ break;
+ case JsonArray a:
+ for (int i = 0; i < a.Count; i++) Walk(a[i], $"{path}[{i}]", leaves);
+ break;
+ case null:
+ leaves.Add((path, "null"));
+ break;
+ default:
+ leaves.Add((path, node.ToString()));
+ break;
+ }
+ }
+
+ ///
+ /// Prints the lifetime/term-relevant leaves of a TrackOrder response (values) and, once,
+ /// the full key inventory (paths only) so undocumented fields can be spotted.
+ ///
+ private void DumpTrack(string label, JsonNode track, bool inventory)
+ {
+ var leaves = new List<(string Path, string Value)>();
+ Walk(track?["orderDetails"], "orderDetails", leaves);
+ _out.WriteLine($" [{label}] TrackOrder lifetime/term-relevant fields:");
+ foreach (var (path, value) in leaves)
+ {
+ string leafKey = path.Substring(path.LastIndexOf('.') + 1);
+ if (InterestingKey.IsMatch(leafKey) && !SensitiveKey.IsMatch(leafKey))
+ _out.WriteLine($" {path} = {TestOutputScrub.Scrub(value)}");
+ }
+ if (inventory)
+ {
+ _out.WriteLine($" [{label}] TrackOrder key inventory (paths only):");
+ _out.WriteLine(" " + string.Join(", ", leaves
+ .Select(l => Regex.Replace(l.Path, @"\[\d+\]", "[]"))
+ .Select(p => Regex.Replace(p, @"domainVerification\.[^.]+", "domainVerification."))
+ .Distinct()));
+ }
+ }
+
+ ///
+ /// Flattens an order body to "path=value" lines (values cut to 40 chars; csr and meta omitted) so the
+ /// plugin's real payload and the raw harness payload can be diffed field by field. The plugin payload
+ /// comes from the client's own redacted Trace log, so personal data is already masked.
+ ///
+ private static List Shape(JsonNode root)
+ {
+ var leaves = new List<(string Path, string Value)>();
+ Walk(root?["orderDetails"], "orderDetails", leaves);
+ return leaves
+ .Where(l => !l.Path.EndsWith(".csr", StringComparison.Ordinal))
+ .Select(l => $"{l.Path}={TestOutputScrub.Scrub(l.Value.Length > 40 ? l.Value.Substring(0, 40) + "..." : l.Value)}")
+ .OrderBy(x => x, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ private static string Evidence(RawResult r) =>
+ r.Accepted
+ ? $"orderNumber={r.OrderNumber ?? "-"} requestNumber={r.RequestNumber ?? "-"}"
+ : $"HTTP {r.HttpStatus} {r.ErrorCode} {r.ErrorMessage}";
+
+ // -------------------------------------------------------------------------------
+ // Cleanup
+ // -------------------------------------------------------------------------------
+
+ ///
+ /// Cancels (RejectOrder) every created order that is not yet issued and revokes those that
+ /// are, then prints each order's final TrackOrder state as the cleanup verification.
+ ///
+ private async Task CleanupAsync()
+ {
+ if (_created.Count == 0) return;
+ _out.WriteLine("");
+ _out.WriteLine("=== Cleanup ===");
+ foreach (var o in _created)
+ {
+ string id = o.OrderNumber;
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ // A saveAndHold draft has only a requestNumber. RejectOrder/RevokeOrder/TrackOrder all
+ // key on orderNumber and answer EMS-943 for a requestNumber (observed live), so the V1
+ // API offers no way to cancel a draft. It holds no certificate and consumes no credit.
+ _out.WriteLine($"[{o.Label}] draft requestNumber={o.RequestNumber}: no cancel endpoint accepts a requestNumber — left as a draft");
+ continue;
+ }
+ try
+ {
+ // Re-read state: an order that issued while we waited needs Revoke, not Reject.
+ var before = await RawTrackAsync(id);
+ string st = before?["orderDetails"]?["certificateStatusId"]?.ToString();
+ bool issued = o.Issued || st == "20" || st == "9";
+
+ // Preferred call first, the other as a fallback: the issued/not-issued guess can be
+ // wrong (an order may issue, or fail to issue, between our check and the call).
+ string[] sequence = issued ? new[] { "RevokeOrder", "RejectOrder" } : new[] { "RejectOrder", "RevokeOrder" };
+ foreach (string call in sequence)
+ {
+ JsonObject payload = call == "RevokeOrder"
+ ? new JsonObject
+ {
+ ["meta"] = BuildMeta(),
+ ["revocationDetails"] = new JsonObject
+ {
+ ["orderNumber"] = id,
+ ["requestorEmail"] = _fixture.Config.RequestorEmail,
+ ["revokeReasonId"] = "5",
+ ["revokeRemarks"] = "validity probe cleanup"
+ }
+ }
+ : new JsonObject
+ {
+ ["meta"] = BuildMeta(),
+ ["orderDetails"] = new JsonObject
+ {
+ ["orderNumber"] = id,
+ ["rejectRemarks"] = "validity probe cleanup"
+ }
+ };
+ var (h, b) = await PostAsync(call, payload);
+ string ok = b?["meta"]?["status"]?.ToString();
+ _out.WriteLine($"[{o.Label}] {id}: {call} -> HTTP {h} status={ok} " +
+ $"{b?["meta"]?["errorCode"]} {TestOutputScrub.Scrub(b?["meta"]?["errorMessage"]?.ToString())}");
+ if (ok == "1") break;
+ await Task.Delay(1500);
+ }
+ }
+ catch (Exception ex)
+ {
+ _out.WriteLine($"[{o.Label}] {id}: cleanup call threw {TestOutputScrub.Describe(ex)}");
+ }
+ await Task.Delay(1500);
+ }
+
+ _out.WriteLine("--- Cleanup verification (final TrackOrder state) ---");
+ var drafts = _created.Where(o => string.IsNullOrWhiteSpace(o.OrderNumber)).ToList();
+ if (drafts.Count > 0)
+ {
+ // Drafts are only visible through GetOrderReport (matched by requestNumber, today's orders).
+ var wanted = new HashSet(drafts.Select(d => d.RequestNumber));
+ var seen = new Dictionary();
+ try
+ {
+ await foreach (var e in _fixture.Client.ListOrdersAsync(
+ DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), 100))
+ if (e.RequestNumber != null && wanted.Contains(e.RequestNumber)) seen[e.RequestNumber] = e;
+ }
+ catch (Exception ex)
+ {
+ _out.WriteLine($" GetOrderReport lookup threw {TestOutputScrub.Describe(ex)}");
+ }
+ foreach (var d in drafts)
+ {
+ seen.TryGetValue(d.RequestNumber, out var e);
+ _out.WriteLine($"[{d.Label}] draft requestNumber={d.RequestNumber}: " + (e == null
+ ? "not found in GetOrderReport (today)"
+ : $"GetOrderReport orderStatusId={e.OrderStatusId} orderStatus={TestOutputScrub.Scrub(e.OrderStatus)} " +
+ $"certificateStatusId={e.CertificateStatusId} certificateStatus={TestOutputScrub.Scrub(e.CertificateStatus)} orderNumber='{e.OrderNumber}'"));
+ }
+ }
+ foreach (var o in _created)
+ {
+ string id = o.OrderNumber;
+ if (string.IsNullOrWhiteSpace(id)) continue;
+ try
+ {
+ var t = await RawTrackAsync(id);
+ var d = t?["orderDetails"];
+ _out.WriteLine($"[{o.Label}] {id}: trackStatus={t?["meta"]?["status"]} {t?["meta"]?["errorCode"]} " +
+ $"certificateStatusId={d?["certificateStatusId"]} certificateStatus={TestOutputScrub.Scrub(d?["certificateStatus"]?.ToString())} " +
+ $"orderStatus={TestOutputScrub.Scrub(d?["orderStatus"]?.ToString())}");
+ }
+ catch (Exception ex)
+ {
+ _out.WriteLine($"[{o.Label}] {id}: verify threw {TestOutputScrub.Describe(ex)}");
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------------
+ // Test 1: raw request variants (drafts)
+ // -------------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Probe_RawValidityVariants()
+ {
+ RequireOptIn();
+
+ var variants = new List<(string Label, Action Mutate)>
+ {
+ ("R0 control: validity=\"1\"", root => { }),
+ ("R1 validity=\"0\"", root => Sub(root)["validity"] = "0"),
+ ("R2 validity=\"45\"", root => Sub(root)["validity"] = "45"),
+ ("R3 validity=\"1\" + subscriptionDetails.{validityDays,certificateValidityDays,noOfDays,lifetimeDays}=45", root =>
+ {
+ var s = Sub(root);
+ s["validityDays"] = "45"; s["certificateValidityDays"] = "45";
+ s["noOfDays"] = "45"; s["lifetimeDays"] = "45";
+ }),
+ ("R4 validity=\"1\" + orderDetails.{validityDays,certificateValidityDays,noOfDays,lifetimeDays}=45", root =>
+ {
+ var d = Details(root);
+ d["validityDays"] = "45"; d["certificateValidityDays"] = "45";
+ d["noOfDays"] = "45"; d["lifetimeDays"] = "45";
+ }),
+ ("R5 validity=\"1\" + certificateInformation.{validityDays,noOfDays}=45", root =>
+ {
+ var c = CertInfo(root);
+ c["validityDays"] = "45"; c["noOfDays"] = "45";
+ }),
+ ("R6 negative control: validity=\"1\" + nonsense field orderDetails.zzProbeNonsense=\"x\"", root =>
+ Details(root)["zzProbeNonsense"] = "x"),
+ };
+
+ var results = new List();
+ try
+ {
+ foreach (var (label, mutate) in variants)
+ results.Add(await PlaceRawOrderAsync(label, saveAndHold: true, withCsr: false, mutate));
+
+ _out.WriteLine("=== Raw GenerateOrderSSL validity probe (saveAndHold=1 drafts) ===");
+ _out.WriteLine($"productCode in use: {_fixture.Config.DefaultProductCode}");
+ _out.WriteLine("");
+ foreach (var r in results)
+ {
+ _out.WriteLine($"[{(r.Accepted ? "ACCEPTED" : "REJECTED")}] {r.Label}");
+ _out.WriteLine($" {Evidence(r)}");
+ }
+
+ // Read back what CERTInext stored for each accepted draft. A draft holds no certificate,
+ // so a stored lifetime/term field (if any) is the only observable "was it honored" signal.
+ _out.WriteLine("");
+ _out.WriteLine("=== TrackOrder read-back of accepted drafts ===");
+ bool inventoryDone = false;
+ foreach (var r in results.Where(x => x.Accepted))
+ {
+ string id = r.OrderNumber;
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ _out.WriteLine($" {r.Label} -> draft (requestNumber only): TrackOrder keys on orderNumber, so no read-back is possible");
+ continue;
+ }
+ var track = await RawTrackAsync(id);
+ _out.WriteLine($" {r.Label} -> track meta.status={track?["meta"]?["status"]} {track?["meta"]?["errorCode"]}");
+ DumpTrack(r.Label.Substring(0, 2), track, inventory: !inventoryDone);
+ inventoryDone = true;
+ await Task.Delay(800);
+ }
+ }
+ finally
+ {
+ await CleanupAsync();
+ }
+
+ // The probe reports; the only hard requirement is that the control order was placed,
+ // otherwise nothing above is interpretable.
+ Assert.True(results[0].Accepted,
+ $"Control order (validity=\"1\", saveAndHold=1) was rejected, so the probe proved nothing: {Evidence(results[0])}");
+ }
+
+ private static JsonObject Details(JsonObject root) => root["orderDetails"]!.AsObject();
+ private static JsonObject CertInfo(JsonObject root) => Details(root)["certificateInformation"]!.AsObject();
+ private static JsonObject Sub(JsonObject root) => Details(root)["subscriptionDetails"]!.AsObject();
+
+ // -------------------------------------------------------------------------------
+ // Test: does GetProductDetails advertise any validity / lifetime / term attribute?
+ // -------------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Probe_ProductDetails_LifetimeFields()
+ {
+ RequireOptIn();
+
+ var body = new JsonObject
+ {
+ ["meta"] = BuildMeta(),
+ ["productDetails"] = new JsonObject
+ {
+ ["groupNumber"] = _fixture.Config.GroupNumber ?? "",
+ ["categoryID"] = "",
+ ["productCode"] = _fixture.Config.DefaultProductCode ?? ""
+ }
+ };
+ var (http, resp) = await PostAsync("GetProductDetails", body);
+ _out.WriteLine("=== GetProductDetails (product " + _fixture.Config.DefaultProductCode + ") ===");
+ _out.WriteLine($"HTTP {http} meta.status={resp?["meta"]?["status"]} {resp?["meta"]?["errorCode"]} {TestOutputScrub.Scrub(resp?["meta"]?["errorMessage"]?.ToString())}");
+
+ var leaves = new List<(string Path, string Value)>();
+ Walk(resp?["productDetails"], "productDetails", leaves);
+ _out.WriteLine("key inventory (paths only): " + string.Join(", ", leaves
+ .Select(l => Regex.Replace(l.Path, @"\[\d+\]", "[]")).Distinct()));
+ _out.WriteLine("lifetime/term-relevant values:");
+ foreach (var (path, value) in leaves)
+ {
+ string leafKey = path.Substring(path.LastIndexOf('.') + 1);
+ if (Regex.IsMatch(leafKey, "valid|lifetime|term|period|days|renew|subscr|duration|year", RegexOptions.IgnoreCase))
+ _out.WriteLine($" {path} = {TestOutputScrub.Scrub(value)}");
+ }
+ Assert.True(resp?["meta"]?["status"]?.ToString() == "1", "GetProductDetails failed");
+ }
+
+ // -------------------------------------------------------------------------------
+ // Cleanup audit: every order this probe family created has a "vprobe-" domain.
+ // -------------------------------------------------------------------------------
+
+ ///
+ /// Lists today's orders (bounded GetOrderReport window, not a history sync) and tallies the
+ /// "vprobe-*" ones by status, so a leaked live order is visible after a run.
+ ///
+ [SkippableFact]
+ public async Task Probe_CleanupAudit_TodaysVprobeOrders()
+ {
+ RequireOptIn();
+
+ var rows = new List();
+ await foreach (var e in _fixture.Client.ListOrdersAsync(
+ DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), 100))
+ if (e.DomainName != null && e.DomainName.StartsWith("vprobe-", StringComparison.OrdinalIgnoreCase))
+ rows.Add(e);
+
+ _out.WriteLine($"=== vprobe-* orders dated today: {rows.Count} ===");
+ foreach (var g in rows
+ .GroupBy(r => $"orderNumber={(string.IsNullOrWhiteSpace(r.OrderNumber) ? "(draft)" : "assigned")} | " +
+ $"orderStatus={TestOutputScrub.Scrub(r.OrderStatus)} | certificateStatus={TestOutputScrub.Scrub(r.CertificateStatus)}")
+ .OrderBy(g => g.Key))
+ _out.WriteLine($" {g.Count(),3} x {g.Key}");
+
+ // Live = has an order number and is neither cancelled/rejected nor revoked.
+ var live = rows.Where(r => !string.IsNullOrWhiteSpace(r.OrderNumber)
+ && !Regex.IsMatch((r.OrderStatus ?? "") + " " + (r.CertificateStatus ?? ""), "cancel|reject|revoked", RegexOptions.IgnoreCase))
+ .ToList();
+ _out.WriteLine($"live (non-draft, not cancelled/revoked): {live.Count}");
+ foreach (var r in live)
+ _out.WriteLine($" LIVE orderNumber={r.OrderNumber} orderStatus={TestOutputScrub.Scrub(r.OrderStatus)} certificateStatus={TestOutputScrub.Scrub(r.CertificateStatus)}");
+ }
+
+ ///
+ /// Sweeps leaked "vprobe-*" orders dated today: RejectOrder for each live (not cancelled / revoked)
+ /// one, then re-lists to show the result. Only touches orders whose domain this probe family generated.
+ ///
+ [SkippableFact]
+ public async Task Probe_CleanupSweep_TodaysVprobeOrders()
+ {
+ RequireOptIn();
+
+ async Task> LiveAsync()
+ {
+ var live = new List();
+ await foreach (var e in _fixture.Client.ListOrdersAsync(
+ DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), 100))
+ if (e.DomainName != null && e.DomainName.StartsWith("vprobe-", StringComparison.OrdinalIgnoreCase)
+ && !Regex.IsMatch((e.OrderStatus ?? "") + " " + (e.CertificateStatus ?? ""), "cancel|reject|revoked", RegexOptions.IgnoreCase))
+ live.Add(e);
+ return live;
+ }
+
+ var before = await LiveAsync();
+ _out.WriteLine($"live vprobe-* orders before sweep: {before.Count}");
+ foreach (var e in before)
+ {
+ string id = !string.IsNullOrWhiteSpace(e.OrderNumber) ? e.OrderNumber : e.RequestNumber;
+ var (h, b) = await PostAsync("RejectOrder", new JsonObject
+ {
+ ["meta"] = BuildMeta(),
+ ["orderDetails"] = new JsonObject { ["orderNumber"] = id, ["rejectRemarks"] = "validity probe cleanup" }
+ });
+ _out.WriteLine($" RejectOrder({id}) [{TestOutputScrub.Scrub(e.OrderStatus)} / {TestOutputScrub.Scrub(e.CertificateStatus)}] -> HTTP {h} " +
+ $"status={b?["meta"]?["status"]} {b?["meta"]?["errorCode"]} {TestOutputScrub.Scrub(b?["meta"]?["errorMessage"]?.ToString())}");
+
+ // A saveAndHold draft answers EMS-943 to RejectOrder. Promote it to a real order by attaching
+ // a CSR for its own domain (SubmitCSR), then cancel it.
+ if (b?["meta"]?["errorCode"]?.ToString() == "EMS-943")
+ {
+ await Task.Delay(1500);
+ var (h2, b2) = await PostAsync("SubmitCSR", new JsonObject
+ {
+ ["meta"] = BuildMeta(),
+ ["orderDetails"] = new JsonObject
+ {
+ ["requestorEmail"] = _fixture.Config.RequestorEmail,
+ ["orderNumber"] = id,
+ ["csr"] = NewCsrPem(e.DomainName)
+ }
+ });
+ _out.WriteLine($" SubmitCSR({id}) -> HTTP {h2} status={b2?["meta"]?["status"]} {b2?["meta"]?["errorCode"]} " +
+ $"{TestOutputScrub.Scrub(b2?["meta"]?["errorMessage"]?.ToString())}");
+ await Task.Delay(3000);
+ var (h3, b3) = await PostAsync("RejectOrder", new JsonObject
+ {
+ ["meta"] = BuildMeta(),
+ ["orderDetails"] = new JsonObject { ["orderNumber"] = id, ["rejectRemarks"] = "validity probe cleanup" }
+ });
+ _out.WriteLine($" RejectOrder({id}) after SubmitCSR -> HTTP {h3} status={b3?["meta"]?["status"]} {b3?["meta"]?["errorCode"]} " +
+ $"{TestOutputScrub.Scrub(b3?["meta"]?["errorMessage"]?.ToString())}");
+ }
+ await Task.Delay(1500);
+ }
+
+ await Task.Delay(3000);
+ var after = await LiveAsync();
+ _out.WriteLine($"live vprobe-* orders after sweep: {after.Count}");
+ foreach (var e in after)
+ _out.WriteLine($" STILL LIVE {(string.IsNullOrWhiteSpace(e.OrderNumber) ? e.RequestNumber : e.OrderNumber)} " +
+ $"[{TestOutputScrub.Scrub(e.OrderStatus)} / {TestOutputScrub.Scrub(e.CertificateStatus)}]");
+ }
+
+#if SUPPORTS_DCV
+ // -------------------------------------------------------------------------------
+ // DCV plumbing (mirrors DcvLifecycleTests)
+ // -------------------------------------------------------------------------------
+
+ private IDomainValidatorFactory BuildDnsFactory()
+ {
+ if (_fixture.IsCloudflareConfigured)
+ {
+ var factory = new CloudflareDomainValidatorFactory(
+ _fixture.CloudflareApiToken, _fixture.CloudflareZoneId);
+ _toDispose.Add(factory);
+ return factory;
+ }
+ return new StubDomainValidatorFactory();
+ }
+
+ private CERTInextCAPlugin BuildPlugin()
+ {
+ var config = new CERTInextConfig
+ {
+ ApiUrl = _fixture.Config.ApiUrl,
+ AuthMode = _fixture.Config.AuthMode,
+ ApiKey = _fixture.Config.ApiKey,
+ AccountNumber = _fixture.Config.AccountNumber,
+ GroupNumber = _fixture.Config.GroupNumber,
+ OrganizationNumber = _fixture.Config.OrganizationNumber,
+ RequestorName = _fixture.Config.RequestorName,
+ RequestorEmail = _fixture.Config.RequestorEmail,
+ RequestorIsdCode = _fixture.Config.RequestorIsdCode,
+ RequestorMobileNumber = _fixture.Config.RequestorMobileNumber,
+ SignerPlace = _fixture.Config.SignerPlace,
+ SignerIp = _fixture.Config.SignerIp,
+ DefaultProductCode = _fixture.Config.DefaultProductCode,
+ PageSize = _fixture.Config.PageSize,
+ DcvEnabled = true,
+ DcvPropagationDelaySeconds = 5,
+ DcvTimeoutMinutes = 3
+ };
+ return new CERTInextCAPlugin(_fixture.Client, BuildDnsFactory(), config);
+ }
+
+ /// Captures every formatted log message (all levels) so the wire payload can be read back.
+ private sealed class CapturingLogger : ILogger
+ {
+ public readonly List Messages = new List();
+ public IDisposable BeginScope(TState state) => null;
+ public bool IsEnabled(LogLevel logLevel) => true;
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception,
+ Func formatter)
+ {
+ lock (Messages) Messages.Add(formatter(state, exception));
+ }
+ }
+
+ ///
+ /// Extracts the "subscriptionDetails" block from the client's PlaceOrderAsync Trace payload —
+ /// the exact validity string that went on the wire. That block carries no personal data.
+ ///
+ private static string WireSubscriptionDetails(CapturingLogger log)
+ {
+ lock (log.Messages)
+ {
+ foreach (var m in log.Messages.Where(m => m.StartsWith("PlaceOrderAsync request payload:", StringComparison.Ordinal)))
+ {
+ var match = Regex.Match(m, "\"subscriptionDetails\"\\s*:\\s*\\{[^}]*\\}");
+ if (match.Success) return match.Value;
+ }
+ }
+ return "(PlaceOrderAsync payload not captured)";
+ }
+
+ ///
+ /// Parses the leaf certificate with BouncyCastle. Accepts PEM (with or without the BEGIN/END
+ /// markers, first certificate wins) or bare base64 DER.
+ ///
+ private static List PluginPayloadShape(CapturingLogger log)
+ {
+ lock (log.Messages)
+ {
+ foreach (var m in log.Messages.Where(m => m.StartsWith("PlaceOrderAsync request payload:", StringComparison.Ordinal)))
+ {
+ try { return Shape(JsonNode.Parse(m.Substring("PlaceOrderAsync request payload:".Length).Trim())); }
+ catch (Exception) { return new List { "(payload not parseable)" }; }
+ }
+ }
+ return new List { "(PlaceOrderAsync payload not captured)" };
+ }
+
+ private static (DateTime NotBefore, DateTime NotAfter)? ParseCertWindow(string pem)
+ {
+ if (string.IsNullOrWhiteSpace(pem)) return null;
+ try
+ {
+ string b64 = pem;
+ int begin = b64.IndexOf("-----BEGIN CERTIFICATE-----", StringComparison.Ordinal);
+ if (begin >= 0)
+ {
+ b64 = b64.Substring(begin + "-----BEGIN CERTIFICATE-----".Length);
+ int end = b64.IndexOf("-----END CERTIFICATE-----", StringComparison.Ordinal);
+ if (end >= 0) b64 = b64.Substring(0, end);
+ }
+ b64 = Regex.Replace(b64, @"\s+", "");
+ var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64));
+ return cert == null ? ((DateTime, DateTime)?)null : (cert.NotBefore.ToUniversalTime(), cert.NotAfter.ToUniversalTime());
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ /// Drives a pending DV order to issuance via GetSingleRecord's sync-DCV retry (bounded).
+ private async Task WaitForIssuanceAsync(CERTInextCAPlugin plugin, string id)
+ {
+ AnyCAPluginCertificate rec = null;
+ for (int pass = 1; pass <= 8; pass++)
+ {
+ rec = await plugin.GetSingleRecord(id);
+ if (rec != null && rec.Status == (int)EndEntityStatus.GENERATED
+ && !string.IsNullOrWhiteSpace(rec.Certificate))
+ return rec;
+ _out.WriteLine($" pass {pass}: status={rec?.Status} — waiting 30s");
+ await Task.Delay(TimeSpan.FromSeconds(30));
+ }
+ return rec;
+ }
+
+ private void ReportCert(string label, AnyCAPluginCertificate rec)
+ {
+ var win = ParseCertWindow(rec?.Certificate);
+ if (win == null)
+ {
+ string c = rec?.Certificate;
+ _out.WriteLine($" [{label}] could not parse certificate (status={rec?.Status}, length={c?.Length ?? 0}, " +
+ $"prefix='{(c == null ? "" : c.Substring(0, Math.Min(27, c.Length)))}')");
+ return;
+ }
+ var (nb, na) = win.Value;
+ double days = (na - nb).TotalDays;
+ _out.WriteLine($" [{label}] notBefore={nb:u} notAfter={na:u} lifetime={days:F2} days (~{Math.Round(days)})");
+ }
+
+ // -------------------------------------------------------------------------------
+ // Test 2: plugin path, ValidityDays=45 vs ValidityYears=1
+ // -------------------------------------------------------------------------------
+
+ [SkippableFact]
+ public Task Probe_PluginPath_ValidityDays45() =>
+ RunPluginPathAsync("P1 template ValidityDays=45",
+ new Dictionary { ["ValidityDays"] = "45" }, inventory: true);
+
+ [SkippableFact]
+ public Task Probe_PluginPath_ValidityYears1_Control() =>
+ RunPluginPathAsync("P2 control template ValidityYears=1",
+ new Dictionary { ["ValidityYears"] = "1" }, inventory: false);
+
+ private async Task RunPluginPathAsync(string label, Dictionary extra, bool inventory)
+ {
+ RequireOptIn();
+ Skip.If(!_fixture.IsCloudflareConfigured,
+ "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — issuance needs real DNS-01 DCV.");
+
+ var plugin = BuildPlugin();
+ _out.WriteLine("=== Plugin path (DV, DCV on, driven to issuance) ===");
+ try
+ {
+ string suffix = Guid.NewGuid().ToString("N").Substring(0, 8);
+ string cn = $"vprobe-{suffix}.scrup.org";
+ var pp = new Dictionary(extra)
+ {
+ ["ProfileId"] = _fixture.Config.DefaultProductCode
+ };
+ var info = new EnrollmentProductInfo
+ {
+ ProductID = _fixture.Config.DefaultProductCode,
+ ProductParameters = pp
+ };
+
+ var logger = new CapturingLogger();
+ EnrollmentResult res;
+ using (CERTInextClientLoggerSwap(logger))
+ {
+ res = await plugin.Enroll(
+ csr: NewCsrPem(cn),
+ subject: $"CN={cn}",
+ san: new Dictionary { ["dns"] = new[] { cn } },
+ productInfo: info,
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ }
+
+ _out.WriteLine($"[{label}] params=[{string.Join(", ", extra.Select(kv => kv.Key + "=" + kv.Value))}]");
+ _out.WriteLine($" wire: {WireSubscriptionDetails(logger)}");
+ _out.WriteLine(" plugin order payload shape: " + string.Join(" | ", PluginPayloadShape(logger)));
+ _out.WriteLine($" Enroll -> CARequestID={res?.CARequestID} Status={res?.Status}");
+ if (!string.IsNullOrWhiteSpace(res?.CARequestID))
+ _created.Add(new CreatedOrder { Label = label, OrderNumber = res.CARequestID, Issued = true });
+ Assert.False(string.IsNullOrWhiteSpace(res?.CARequestID), "Enroll returned no CARequestID");
+
+ var rec = await WaitForIssuanceAsync(plugin, res.CARequestID);
+ ReportCert(label, rec);
+ var track = await RawTrackAsync(res.CARequestID);
+ DumpTrack(label.Substring(0, 2), track, inventory);
+ }
+ finally
+ {
+ await CleanupAsync();
+ }
+ }
+
+ private static IDisposable CERTInextClientLoggerSwap(ILogger logger) =>
+ Keyfactor.Extensions.CAPlugin.CERTInext.Client.CERTInextClient.OverrideLoggerForTests(logger);
+
+ // -------------------------------------------------------------------------------
+ // Test 3: one issued order carrying every undocumented lifetime-ish field
+ // -------------------------------------------------------------------------------
+
+ [SkippableFact]
+ public Task Probe_UndocumentedFields_Issued() =>
+ RunRawIssuedAsync(
+ "U1 shotgun (validity=\"1\" + validityDays/certificateValidityDays/noOfDays/lifetimeDays=45 at orderDetails, subscriptionDetails, certificateInformation)",
+ root =>
+ {
+ foreach (var level in new[] { Details(root), Sub(root), CertInfo(root) })
+ {
+ level["validityDays"] = "45";
+ level["certificateValidityDays"] = "45";
+ level["noOfDays"] = "45";
+ level["lifetimeDays"] = "45";
+ }
+ });
+
+ /// Harness control for the shotgun test: the same raw issued-order path with no extra fields.
+ [SkippableFact]
+ public Task Probe_RawIssuedControl() =>
+ RunRawIssuedAsync("U0 raw control (validity=\"1\", no extra fields)", root => { });
+
+ /// Does the issued certificate lifetime track the subscription years? Same harness, validity="3".
+ [SkippableFact]
+ public Task Probe_RawIssued_Validity3() =>
+ RunRawIssuedAsync("U3 raw validity=\"3\"", root => Sub(root)["validity"] = "3");
+
+ private async Task RunRawIssuedAsync(string label, Action mutate)
+ {
+ RequireOptIn();
+ Skip.If(!_fixture.IsCloudflareConfigured,
+ "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — issuance needs real DNS-01 DCV.");
+
+ var plugin = BuildPlugin();
+ _out.WriteLine("=== Raw (non-plugin) order driven to issuance through the plugin's sync-DCV path ===");
+ try
+ {
+ var r = await PlaceRawOrderAsync(label, saveAndHold: false, withCsr: true, issuedIntent: true, mutate: mutate);
+ _out.WriteLine($"[{(r.Accepted ? "ACCEPTED" : "REJECTED")}] {r.Label}");
+ _out.WriteLine($" {Evidence(r)}");
+ _out.WriteLine(" raw order payload shape: " + string.Join(" | ", LastRawShape));
+ Skip.IfNot(r.Accepted, "Order rejected — see output; nothing to issue.");
+
+ string id = r.OrderNumber;
+ Skip.If(string.IsNullOrWhiteSpace(id), "Order accepted but no orderNumber returned (draft?) — cannot drive DCV/issuance.");
+
+ // Mirror what Enroll does for a plugin-placed order: poll TrackOrder until the DCV slot
+ // (domainVerification) is exposed and run DCV IMMEDIATELY. Observed live: a raw order whose
+ // DCV is attempted late (>=10s) sits in "Pending System RA" and GetDcv answers
+ // "Invalid Request for this API", so timing matters, not the request body.
+ var polls = 0;
+ for (; polls < 60; polls++)
+ {
+ var t = await RawTrackAsync(id);
+ if (t?["orderDetails"]?["domainVerification"] is JsonObject dv && dv.Any(kv => kv.Value is JsonObject)) break;
+ await Task.Delay(1000);
+ }
+ _out.WriteLine($" DCV slot exposed after {polls} TrackOrder polls (1s apart)");
+
+ var rec = await WaitForIssuanceAsync(plugin, id);
+ ReportCert(label.Substring(0, 2), rec);
+ DumpTrack(label.Substring(0, 2), await RawTrackAsync(id), inventory: false);
+ if (rec == null || rec.Status != (int)EndEntityStatus.GENERATED || string.IsNullOrWhiteSpace(rec.Certificate))
+ {
+ var tr = await RawTrackAsync(id);
+ foreach (var d in (tr?["orderDetails"]?["domainVerification"] as JsonObject)?
+ .Where(kv => kv.Value is JsonObject).Select(kv => kv.Key) ?? Enumerable.Empty())
+ {
+ try
+ {
+ var dcv = await _fixture.Client.GetDcvAsync(id, d, "1");
+ _out.WriteLine($" diag GetDcv({d}) ok, tokenPresent={!string.IsNullOrWhiteSpace(dcv?.DcvDetails?.Token)}");
+ }
+ catch (Exception ex)
+ {
+ _out.WriteLine($" diag GetDcv({d}) threw {TestOutputScrub.Describe(ex)}");
+ }
+ }
+ }
+ }
+ finally
+ {
+ await CleanupAsync();
+ }
+ }
+#endif
+ }
+}
diff --git a/CERTInext.Tests/BlankRequestorWireTests.cs b/CERTInext.Tests/BlankRequestorWireTests.cs
new file mode 100644
index 0000000..98dc412
--- /dev/null
+++ b/CERTInext.Tests/BlankRequestorWireTests.cs
@@ -0,0 +1,210 @@
+// Copyright 2026 Keyfactor
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Microsoft.Extensions.Logging;
+using Moq;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using WireMock.Server;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Pins the bytes actually POSTed to GenerateOrderSSL when the connector has
+ /// RequestorName="" and TechnicalContactName="" (the configuration the opt-in live
+ /// probe BlankRequestorLiveTests uses). Drives the real
+ /// enroll and renewal paths over a real against WireMock, so the
+ /// assertions are on the captured wire body, not on an intermediate object.
+ ///
+ /// Expected wire shape: requestorInformation.requestorName == "" (present, empty string —
+ /// the plugin does not substitute a default for a blank-but-non-null connector value),
+ /// no technicalPointOfContact key, and agreementDetails.signerName falls back to
+ /// "Keyfactor Gateway".
+ ///
+ [Collection(LoggingStateCollection.Name)]
+ public class BlankRequestorWireTests : IDisposable
+ {
+ private readonly WireMockServer _server;
+
+ public BlankRequestorWireTests()
+ {
+ _server = WireMockServer.Start();
+
+ _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost())
+ .RespondWith(Response.Create().WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(MockCertificateData.GenerateOrderSuccessJson(MockCertificateData.OrderNumber1)));
+ _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost())
+ .RespondWith(Response.Create().WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(MockCertificateData.TrackOrderIssuedJson(MockCertificateData.OrderNumber1)));
+ _server.Given(Request.Create().WithPath("/GetCertificate").UsingPost())
+ .RespondWith(Response.Create().WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(MockCertificateData.GetCertificateSuccessJson()));
+ }
+
+ public void Dispose() => _server.Stop();
+
+ private sealed class CapturingLogger : ILogger
+ {
+ public ConcurrentQueue Messages { get; } = new();
+ public IDisposable BeginScope(TState state) => null;
+ public bool IsEnabled(LogLevel logLevel) => true;
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception,
+ Func formatter) => Messages.Enqueue(formatter(state, exception));
+ }
+
+ private CERTInextClient BuildBlankRequestorClient(bool logSensitiveRequestData = false) => new CERTInextClient(new CERTInextConfig
+ {
+ LogSensitiveRequestData = logSensitiveRequestData,
+ ApiUrl = _server.Urls[0],
+ AuthMode = "AccessKey",
+ ApiKey = "test-key",
+ AccountNumber = "12345",
+ RequestorName = string.Empty,
+ TechnicalContactName = string.Empty,
+ RequestorEmail = "requestor@example.com",
+ RequestorIsdCode = "1",
+ RequestorMobileNumber = "5550000000",
+ SignerPlace = "Austin",
+ SignerIp = "203.0.113.10",
+ PageSize = 100
+ });
+
+ private static EnrollmentProductInfo MakeProductInfo(Dictionary extras = null)
+ {
+ var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ProfileId"] = "842"
+ };
+ if (extras != null)
+ foreach (var kv in extras)
+ parameters[kv.Key] = kv.Value;
+ return new EnrollmentProductInfo { ProductID = "842", ProductParameters = parameters };
+ }
+
+ private JsonElement CapturedRoot()
+ {
+ var posts = _server.LogEntries
+ .Where(e => e.RequestMessage.Path == "/GenerateOrderSSL")
+ .ToList();
+ posts.Should().HaveCount(1, "exactly one GenerateOrderSSL POST should have been emitted");
+ string body = posts[0].RequestMessage.Body;
+ body.Should().NotBeNullOrEmpty();
+ return JsonDocument.Parse(body!).RootElement;
+ }
+
+ private static void AssertBlankRequestorShape(JsonElement root)
+ {
+ var od = root.GetProperty("orderDetails");
+
+ od.TryGetProperty("requestorInformation", out var ri).Should().BeTrue();
+ ri.TryGetProperty("requestorName", out var name).Should().BeTrue(
+ "requestorName is serialized even when blank");
+ name.ValueKind.Should().Be(JsonValueKind.String);
+ name.GetString().Should().Be(string.Empty,
+ "a blank connector RequestorName reaches the wire as an empty string, not a substituted default");
+
+ od.TryGetProperty("technicalPointOfContact", out _).Should().BeFalse(
+ "no name resolves, so the technicalPointOfContact block is omitted entirely");
+
+ od.GetProperty("agreementDetails").GetProperty("signerName").GetString()
+ .Should().Be("Keyfactor Gateway", "blank requestor/signer name falls back to the built-in default");
+ }
+
+ [Fact]
+ public async Task Enroll_New_BlankRequestorAndTechContact_WireBodyMatchesExpectedShape()
+ {
+ var plugin = new CERTInextCAPlugin(BuildBlankRequestorClient(), new CERTInextConfig { PickupRetries = 0 });
+
+ await plugin.Enroll(
+ MockCertificateData.FakeCsrPem, "CN=test.example.com",
+ new Dictionary { ["dns"] = new[] { "test.example.com" } },
+ MakeProductInfo(), RequestFormat.PKCS10, EnrollmentType.New);
+
+ AssertBlankRequestorShape(CapturedRoot());
+ }
+
+ [Fact]
+ public async Task Enroll_RenewOrReissue_RenewalApi_BlankRequestorAndTechContact_WireBodyMatchesExpectedShape()
+ {
+ var reader = new Mock();
+ reader.Setup(r => r.GetRequestIDBySerialNumber(It.IsAny()))
+ .ReturnsAsync(MockCertificateData.CertId1);
+ reader.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1))
+ .Returns(DateTime.UtcNow.AddDays(30));
+
+ var plugin = new CERTInextCAPlugin(BuildBlankRequestorClient(), reader.Object);
+
+ await plugin.Enroll(
+ MockCertificateData.FakeCsrPem, "CN=test.example.com",
+ new Dictionary { ["dns"] = new[] { "test.example.com" } },
+ MakeProductInfo(new Dictionary
+ {
+ ["PriorCertSN"] = "AABB",
+ ["RenewalWindowDays"] = "90"
+ }),
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+
+ // Guard: the renewal API path (not the new-enroll fallback) must have produced the body.
+ reader.Verify(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1), Times.Once);
+ AssertBlankRequestorShape(CapturedRoot());
+ }
+
+ ///
+ /// Justifies the live test capturing the Trace PlaceOrderAsync request payload dump
+ /// (LogSensitiveRequestData=true) instead of the socket: apart from the redacted
+ /// meta.authKey, the dump's orderDetails is identical to the body WireMock
+ /// received.
+ ///
+ [Fact]
+ public async Task TraceDump_OrderDetails_EqualsWireBody_WhenSensitiveLoggingOn()
+ {
+ string marker = $"wire-{Guid.NewGuid():N}.example.com";
+ var plugin = new CERTInextCAPlugin(BuildBlankRequestorClient(logSensitiveRequestData: true),
+ new CERTInextConfig { PickupRetries = 0 });
+
+ var logger = new CapturingLogger();
+ using (CERTInextClient.OverrideLoggerForTests(logger))
+ {
+ await plugin.Enroll(
+ MockCertificateData.FakeCsrPem, $"CN={marker}",
+ new Dictionary { ["dns"] = new[] { marker } },
+ MakeProductInfo(), RequestFormat.PKCS10, EnrollmentType.New);
+ }
+
+ const string prefix = "PlaceOrderAsync request payload: ";
+ string dump = logger.Messages.Single(m => m.StartsWith(prefix, StringComparison.Ordinal) && m.Contains(marker));
+ string dumpJson = dump.Substring(prefix.Length);
+
+ dumpJson.Should().NotContain("test-key").And.Contain("***REDACTED***");
+ var logged = JsonNode.Parse(dumpJson)!["orderDetails"];
+ var wire = JsonNode.Parse(CapturedRoot().GetRawText())!["orderDetails"];
+ JsonNode.DeepEquals(logged, wire).Should().BeTrue("the Trace dump must mirror the POSTed orderDetails");
+ AssertBlankRequestorShape(JsonDocument.Parse(dumpJson).RootElement);
+ }
+ }
+}
diff --git a/CERTInext.Tests/CERTInext.Tests.csproj b/CERTInext.Tests/CERTInext.Tests.csproj
index 84ce7a6..a17ee62 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/CERTInextCAPluginAuditLoggingTests.cs b/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs
new file mode 100644
index 0000000..0083bd7
--- /dev/null
+++ b/CERTInext.Tests/CERTInextCAPluginAuditLoggingTests.cs
@@ -0,0 +1,218 @@
+// 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.Client;
+using Keyfactor.Logging;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Issue 0040: pins the on/off behavior of the "Enrollment attempt started" audit log line in
+ /// for the LogSensitiveRequestData connector
+ /// setting.
+ ///
+ /// CERTInextCAPlugin._logger is a per-instance field assigned from
+ /// LogHandler.GetClassLogger<CERTInextCAPlugin>() at construction time (unlike
+ /// Client.CERTInextClient.Logger, which is a static readonly field resolved once
+ /// per process — not swappable after the fact). Swapping
+ /// before constructing a fresh plugin instance is therefore a genuine, narrow capture seam for
+ /// this one log line. All tests in this class run in the shared
+ /// (non-parallel: no other test, in or out of the
+ /// collection, runs concurrently with them) and restore the original factory in a
+ /// finally block so the global static mutation can't outlive a single test.
+ ///
+ [Collection(LoggingStateCollection.Name)]
+ public class CERTInextCAPluginAuditLoggingTests
+ {
+ private sealed class CapturingLoggerProvider : ILoggerProvider
+ {
+ public ConcurrentQueue Messages { get; } = new();
+ public ILogger CreateLogger(string categoryName) => new CapturingLogger(Messages);
+ public void Dispose() { }
+
+ private sealed class CapturingLogger : ILogger
+ {
+ private readonly ConcurrentQueue _messages;
+ public CapturingLogger(ConcurrentQueue messages) => _messages = messages;
+ public IDisposable BeginScope(TState state) => null;
+ public bool IsEnabled(LogLevel logLevel) => true;
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception,
+ Func formatter)
+ => _messages.Enqueue(formatter(state, exception));
+ }
+ }
+
+ private static Mock NewHappyPathMock()
+ {
+ var mock = new Mock(MockBehavior.Loose);
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new API.EnrollCertificateResponse
+ {
+ Id = "ORD-AUDIT-001",
+ Status = "issued",
+ Certificate = MockCertificateData.FakePemCertificate
+ });
+ return mock;
+ }
+
+ ///
+ /// Runs once with a freshly-swapped capturing
+ /// logger factory in place — constructing the plugin only after the swap, so its
+ /// per-instance _logger field resolves through the capturing factory — and returns
+ /// every rendered log message the plugin emitted. RequesterName/RequesterEmail are driven
+ /// through the template parameters that EnrollmentParams.RequesterName/
+ /// RequesterEmail read ( /
+ /// RequesterEmail), matching what the "Enrollment attempt started" line logs.
+ ///
+ private static async Task<(ConcurrentQueue Messages, string SubjectMarker)> CaptureEnrollLogMessagesAsync(
+ bool logSensitiveRequestData, string requesterName, string requesterEmail)
+ {
+ var provider = new CapturingLoggerProvider();
+ var factory = LoggerFactory.Create(b => b.AddProvider(provider).SetMinimumLevel(LogLevel.Trace));
+
+ // LogHandler.Factory is a shared static — other test classes construct their own
+ // CERTInextCAPlugin instances concurrently (xUnit parallelizes across collections by
+ // default) and, purely by coincidence of timing, some of those may resolve their
+ // _logger through this same swapped factory while it's active, adding unrelated
+ // "Enrollment attempt started" lines to provider.Messages. A per-call unique subject
+ // is the only reliable way to pick this call's own line back out of that noise.
+ string subjectMarker = "audit-" + Guid.NewGuid().ToString("N");
+ try
+ {
+ LogHandler.Factory = factory;
+
+ var mock = NewHappyPathMock();
+ var config = new CERTInextConfig
+ {
+ PickupRetries = 0,
+ LogSensitiveRequestData = logSensitiveRequestData
+ };
+ // Constructed AFTER the factory swap so its _logger field resolves through it.
+ var plugin = new CERTInextCAPlugin(mock.Object, config);
+
+ var productInfo = new EnrollmentProductInfo
+ {
+ ProductID = "DV SSL",
+ ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ProductCode"] = "842",
+ [Constants.EnrollmentParam.RequesterName] = requesterName,
+ [Constants.EnrollmentParam.RequesterEmail] = requesterEmail
+ }
+ };
+
+ await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem,
+ subject: $"CN={subjectMarker}.example.com",
+ san: null,
+ productInfo: productInfo,
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ }
+ finally
+ {
+ // LogHandler.Factory is write-only (no getter to save/restore the prior value),
+ // so reset to the same NullLoggerFactory the class defaults to absent any host
+ // configuring a real one — matching every other test's ambient (unconfigured)
+ // logging state.
+ LogHandler.Factory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance;
+ factory.Dispose();
+ }
+
+ return (provider.Messages, subjectMarker);
+ }
+
+ private static string FindEnrollmentAttemptLine(ConcurrentQueue messages, string subjectMarker)
+ {
+ foreach (var m in messages)
+ {
+ if (m.Contains("Enrollment attempt started") && m.Contains(subjectMarker))
+ return m;
+ }
+ return null;
+ }
+
+ [Fact]
+ public async Task Enroll_LogSensitiveRequestDataFalse_AuditLineOmitsNameAndMasksEmail()
+ {
+ var (messages, marker) = await CaptureEnrollLogMessagesAsync(
+ logSensitiveRequestData: false, requesterName: "Jane Doe", requesterEmail: "jane.doe@example.com");
+
+ string line = FindEnrollmentAttemptLine(messages, marker);
+ line.Should().NotBeNull("the enrollment-attempt audit line must always be logged");
+ line.Should().NotContain("Jane Doe", "the requester name must be dropped entirely when the flag is off");
+ line.Should().NotContain("RequesterName=", "the RequesterName field itself must be absent from the line, not just blanked");
+ line.Should().Contain("j***@example.com", "the requester email must be masked but keep its domain");
+ line.Should().NotContain("jane.doe@example.com");
+ }
+
+ [Fact]
+ public async Task Enroll_LogSensitiveRequestDataTrue_AuditLineIncludesNameAndEmailInFull()
+ {
+ var (messages, marker) = await CaptureEnrollLogMessagesAsync(
+ logSensitiveRequestData: true, requesterName: "Jane Doe", requesterEmail: "jane.doe@example.com");
+
+ string line = FindEnrollmentAttemptLine(messages, marker);
+ line.Should().NotBeNull("the enrollment-attempt audit line must always be logged");
+ line.Should().Contain("Jane Doe", "the requester name is logged in full when the flag is on");
+ line.Should().Contain("jane.doe@example.com", "the requester email is logged in full when the flag is on");
+ }
+
+ [Fact]
+ public async Task Enroll_LogSensitiveRequestDataTrue_CrLfInRequesterValuesIsStripped()
+ {
+ // Regression: RequesterName/RequesterEmail were logged raw (log injection via CR/LF),
+ // unlike subject and SANs which already went through LogSanitizer.Strip.
+ var (messages, marker) = await CaptureEnrollLogMessagesAsync(
+ logSensitiveRequestData: true,
+ requesterName: "Jane\r\nFAKE-LOG-ENTRY name",
+ requesterEmail: "jane@example.com\r\nFAKE-LOG-ENTRY email");
+
+ string line = FindEnrollmentAttemptLine(messages, marker);
+ line.Should().NotBeNull("the enrollment-attempt audit line must always be logged");
+ line.Should().NotContain("\r").And.NotContain("\n",
+ "CR/LF in requester values must not be able to forge a new log line");
+ line.Should().Contain("Jane\\r\\nFAKE-LOG-ENTRY name");
+ line.Should().Contain("jane@example.com\\r\\nFAKE-LOG-ENTRY email");
+ }
+
+ [Fact]
+ public async Task Enroll_LogSensitiveRequestDataFalse_CrLfInRequesterValuesIsStripped()
+ {
+ // Flag off: name is dropped, email is MaskEmail(Strip(email)). The domain part survives
+ // masking, so CR/LF placed there must still be escaped.
+ var (messages, marker) = await CaptureEnrollLogMessagesAsync(
+ logSensitiveRequestData: false,
+ requesterName: "Jane\r\nFAKE-LOG-ENTRY name",
+ requesterEmail: "jane@example.com\r\nFAKE-LOG-ENTRY email");
+
+ string line = FindEnrollmentAttemptLine(messages, marker);
+ line.Should().NotBeNull("the enrollment-attempt audit line must always be logged");
+ line.Should().NotContain("\r").And.NotContain("\n",
+ "CR/LF in requester values must not be able to forge a new log line");
+ line.Should().NotContain("Jane").And.NotContain("FAKE-LOG-ENTRY name");
+ line.Should().Contain("j***@example.com\\r\\nFAKE-LOG-ENTRY email");
+ }
+ }
+}
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..f81c238 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);
}
@@ -584,9 +596,11 @@ public async Task Dcv_CleanupAlwaysCalled_EvenWhenVerifyDcvThrows()
var validator = new FakeDomainValidator();
var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
- Func act = () => Enroll(plugin);
-
- await act.Should().ThrowAsync().WithMessage("*DNS record not found*");
+ // The order is already placed, so the VerifyDcv failure no longer fails Enroll (issue
+ // 0077): it is logged and the pending result carrying the order number is returned.
+ var result = await Enroll(plugin);
+ result.CARequestID.Should().Be(MockCertificateData.DcvOrderId);
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
// Cleanup must run even when VerifyDcv throws
string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, MockCertificateData.DcvDomain);
@@ -594,7 +608,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 +625,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 +698,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 +722,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 +838,618 @@ 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");
+ // A domain was skipped, so the order cannot issue: the post-DCV issuance wait must not run.
+ mock.Verify(c => c.GetCertificateAsync(order, It.IsAny()), Times.Never);
+ result.Status.Should().NotBe((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");
+ // A domain was skipped, so the order cannot issue: the post-DCV issuance wait must not run.
+ mock.Verify(c => c.GetCertificateAsync(order, It.IsAny()), Times.Never);
+ result.Status.Should().NotBe((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));
+
+ // The cancellation still reaches the outer catch inside PerformDcvIfNeededAsync (not the
+ // per-domain "GetDcv failed" skip) — pinned by the no-VerifyDcv assertion below — but
+ // since the order is already placed, EnrollNewAsync now absorbs it and returns the
+ // pending result carrying the order number instead of failing Enroll (issue 0077).
+ var result = await Enroll(plugin);
+
+ result.CARequestID.Should().Be(MockCertificateData.DcvOrderId);
+ mock.Verify(c => c.VerifyDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ ///
+ /// 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));
+ // A domain was skipped, so the order cannot issue: the post-DCV issuance wait must not run.
+ mock.Verify(c => c.GetCertificateAsync(order, It.IsAny()), Times.Never);
+ result.Status.Should().NotBe((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));
+
+ // The mid-loop cancellation no longer fails Enroll (issue 0077: the order is already
+ // placed); the cleanup behavior asserted below is unchanged.
+ var result = await Enroll(plugin);
+ result.CARequestID.Should().Be(order);
+
+ 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 here by timing: three domains each with an artificial
+ /// cleanup delay must complete in close to ONE delay's worth of wall time, not three.
+ ///
+ [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));
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ await Enroll(plugin);
+ sw.Stop();
+
+ validator.CleanedUpKeys.Should().HaveCount(3, "all three staged domains must be cleaned up");
+
+ // This flow carries ~2s of fixed overhead unrelated to cleanup (DcvPropagationDelaySeconds
+ // and WaitForDcvVerificationAsync's poll interval both floor at 1s each — DcvConfig's
+ // propagationDelaySeconds default is deliberately 1, since 0 falls back to a 30s default
+ // in PerformDcvIfNeededAsync, not "no delay"). An 800ms-per-domain cleanup delay makes the
+ // concurrent-vs-sequential gap (≈800ms vs ≈2400ms of cleanup time) large relative to that
+ // fixed cost and to CI jitter. 4000ms sits well above "fixed overhead + one 800ms delay"
+ // and well below "fixed overhead + three 800ms delays run one after another".
+ sw.ElapsedMilliseconds.Should().BeLessThan(4000,
+ "cleanup for independent domains must run concurrently, not sequentially — " +
+ "3 domains x 800ms sequential would add roughly 3x this call's actual cleanup time");
+ }
+
+ ///
+ /// 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