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..73f5c95 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`) are **ignored** in this file;
+ they must be exported in the shell (see `IntegrationTestFixture.OptInOnlyFlags`).
---
diff --git a/CERTInext.IntegrationTests/IntegrationTestFixture.cs b/CERTInext.IntegrationTests/IntegrationTestFixture.cs
index 8e4f637..58b5878 100644
--- a/CERTInext.IntegrationTests/IntegrationTestFixture.cs
+++ b/CERTInext.IntegrationTests/IntegrationTestFixture.cs
@@ -20,6 +20,26 @@ 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",
+ };
+
// ---------------------------------------------------------------------------
// Credential properties
// ---------------------------------------------------------------------------
@@ -83,11 +103,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 +154,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..97a262e 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,49 @@ 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_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/SanSubmissionProbeTests.cs b/CERTInext.IntegrationTests/SanSubmissionProbeTests.cs
new file mode 100644
index 0000000..23681d1
--- /dev/null
+++ b/CERTInext.IntegrationTests/SanSubmissionProbeTests.cs
@@ -0,0 +1,391 @@
+// Copyright 2026 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+// At http://www.apache.org/licenses/LICENSE-2.0
+//
+// Probe: establish empirically how CERTInext treats the SAN/domain fields on
+// GenerateOrderSSL. Written because the plugin's original behaviour encoded three
+// assumptions that were never measured:
+//
+// A. certificateInformation.additionalDomains is the field that puts extra names on
+// the certificate (so a UCC order that omits it yields a CN-only certificate).
+// B. additionalDomains accepts DNS names only, so a non-DNS SAN is rejected by the CA.
+// C. Repeating the primary domainName inside additionalDomains is harmful (duplicate
+// domain / consumes the UCC allowance), so it should be de-duplicated.
+//
+// None of these had a test. This probe answers them against the live API by placing one
+// order per variant and reading back the domain set CERTInext actually registered, via
+// TrackOrder's domainVerification block (keys are the domains on the order). That is
+// ground truth for "which names did the CA put on this order" without waiting for DCV
+// and issuance to complete.
+//
+// ---------------------------------------------------------------------------------------
+// MEASURED RESULTS — SANDBOX ONLY: sandbox-us, account 4951571271, product 844 (OV SSL UCC),
+// 2026-08-12. (Product 840 / DV UCC is not enabled on that account: "Invalid Product Code".)
+//
+// These are sandbox observations. Re-run against production before treating B or C as
+// settled there — point ~/.env_certinext at the production account and set
+// CERTINEXT_SAN_PROBE_PRODUCTS to a UCC code that account can actually order (product
+// numbering is per-account; the codes in Constants.Products are defaults, not guarantees).
+// Finding A and the CSR-SAN result below are separately corroborated by production: the
+// customer report that prompted this work was a production UCC order whose CSR carried the
+// SANs and whose issued certificate held only the CN.
+//
+// A. CONFIRMED. additionalDomains is what puts extra names on the order. Submitting
+// CN + extra1. registered BOTH domains.
+//
+// B. DISPROVEN. Non-DNS values are NOT rejected. An email address, an IPv4 literal and
+// an https:// URI were each accepted at placement AND registered as order domains
+// ("san-probe@example.com", "192.0.2.10", "https://san-probe.example.com/x" all came
+// back as domainVerification keys). So the CA does not validate the field's contents
+// at order time; such an order is created and then cannot pass DCV, rather than
+// failing cleanly up front.
+//
+// C. PARTLY DISPROVEN. Repeating the primary domainName inside additionalDomains is
+// accepted and CERTInext collapses it itself — the order came back with the CN
+// registered once. De-duplicating on our side is therefore belt-and-braces, not a
+// correctness requirement.
+//
+// Root cause of the customer-reported "UCC SANs not populating": CERTInext IGNORES the
+// subjectAltName extension in the CSR. A CSR carrying CN + extra2., submitted with
+// additionalDomains omitted, registered ONLY the CN. SANs must be sent in
+// additionalDomains or they do not reach the certificate, no matter what the CSR says.
+// ---------------------------------------------------------------------------------------
+//
+// Opt-in: this places real orders against whatever account ~/.env_certinext points at.
+//
+// set -a; . ~/.env_certinext; set +a
+// export CERTINEXT_SAN_PROBE=1
+// dotnet test CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj -c Release \
+// --filter "FullyQualifiedName~SanSubmissionProbeTests" \
+// --logger "console;verbosity=detailed" > /tmp/sanprobe.log 2>&1
+//
+// (xUnit buffers ITestOutputHelper output until the test ends — read the report at the tail.)
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Org.BouncyCastle.Asn1;
+using Org.BouncyCastle.Asn1.Pkcs;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ public class SanSubmissionProbeTests : IClassFixture
+ {
+ private const string OptInFlag = "CERTINEXT_SAN_PROBE";
+
+ ///
+ /// Comma-separated product codes to probe. Defaults to the Multi-Domain (UCC) codes,
+ /// because additional domains are only meaningful on a UCC product — a single-domain
+ /// product (e.g. 842 = OV SSL) registers the CN and nothing else no matter what
+ /// additionalDomains contains, which makes it useless as a probe target.
+ ///
+ private const string ProductCodesFlag = "CERTINEXT_SAN_PROBE_PRODUCTS";
+ private const string DefaultProductCodes = "840,844";
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _out;
+
+ public SanSubmissionProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _out = output;
+ }
+
+ // -------------------------------------------------------------------------
+ // CSR generation (BouncyCastle — project crypto policy)
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Generates a PKCS#10 CSR for , optionally carrying a
+ /// subjectAltName extension (via the PKCS#9 extensionRequest attribute) holding
+ /// . The SAN-bearing form is what lets this probe ask
+ /// whether CERTInext reads SANs out of the CSR at all.
+ ///
+ private static string GenerateCsrPem(string cn, params string[] dnsSans)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ AsymmetricCipherKeyPair kp = keyGen.GenerateKeyPair();
+
+ Asn1Set attributes = null;
+ if (dnsSans != null && dnsSans.Length > 0)
+ {
+ var names = new GeneralNames(
+ dnsSans.Select(d => new GeneralName(GeneralName.DnsName, d)).ToArray());
+
+ var extGen = new X509ExtensionsGenerator();
+ extGen.AddExtension(X509Extensions.SubjectAlternativeName, critical: false, extValue: names);
+
+ attributes = new DerSet(new AttributePkcs(
+ PkcsObjectIdentifiers.Pkcs9AtExtensionRequest,
+ new DerSet(extGen.Generate())));
+ }
+
+ var csr = new Pkcs10CertificationRequest(
+ "SHA256withRSA", new X509Name($"CN={cn}"), kp.Public, attributes, kp.Private);
+
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ // -------------------------------------------------------------------------
+ // One probe variant
+ // -------------------------------------------------------------------------
+
+ private sealed class ProbeOutcome
+ {
+ public string ProductCode;
+ public string Label;
+ public bool Accepted;
+ public string OrderNumber;
+ public string Detail;
+ /// Domains CERTInext registered on the order, per TrackOrder.
+ public List RegisteredDomains = new List();
+ /// Names we asked CERTInext to put on the order, for comparison.
+ public List RequestedDomains = new List();
+
+ ///
+ /// True when the rejection was "Invalid Product Code" — the product simply is not
+ /// enabled on this account, which is not a data point about SAN handling.
+ ///
+ public bool ProductUnavailable;
+ }
+
+ ///
+ /// Places one order and reads back the domain set CERTInext registered for it.
+ /// drives certificateInformation.additionalDomains;
+ /// drives the SAN extension inside the CSR. They are
+ /// varied independently on purpose — that separation is the whole point of the probe.
+ ///
+ private async Task ProbeAsync(
+ string productCode,
+ string label,
+ Func> sansFactory,
+ string[] csrSans)
+ {
+ var outcome = new ProbeOutcome { ProductCode = productCode, Label = label };
+
+ var client = new CERTInextClient(_fixture.Config);
+ string cn = $"sanprobe-{DateTime.UtcNow:yyyyMMddHHmmssfff}.{SafeLabel(label)}.example.com";
+
+ var sans = sansFactory?.Invoke(cn);
+ outcome.RequestedDomains = sans == null
+ ? new List()
+ : sans.Select(s => $"{s.Type}:{s.Value}").ToList();
+
+ var req = new EnrollCertificateRequest
+ {
+ Csr = GenerateCsrPem(cn, csrSans == null ? null : csrSans.Select(s => Format(s, cn)).ToArray()),
+ Subject = $"CN={cn}",
+ Sans = sans,
+ ProfileId = productCode,
+ RequesterName = _fixture.RequestorName,
+ RequesterEmail = _fixture.RequestorEmail
+ };
+
+ try
+ {
+ var resp = await client.EnrollCertificateAsync(req);
+ outcome.Accepted = true;
+ outcome.OrderNumber = resp?.Id;
+ outcome.Detail = $"OrderNumber={resp?.Id} Status={resp?.Status}";
+ }
+ catch (Exception ex)
+ {
+ outcome.Accepted = false;
+ outcome.Detail = ex.Message;
+ outcome.ProductUnavailable =
+ ex.Message.IndexOf("Invalid Product Code", StringComparison.OrdinalIgnoreCase) >= 0;
+ return outcome;
+ }
+
+ // Read back which domains the CA actually put on the order.
+ try
+ {
+ var track = await client.TrackOrderAsync(outcome.OrderNumber);
+ var entries = track.OrderDetails?.DomainVerification?.GetDomainEntries();
+ if (entries != null)
+ outcome.RegisteredDomains = entries.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList();
+ }
+ catch (Exception ex)
+ {
+ outcome.Detail += $" | TrackOrder failed: {ex.Message}";
+ }
+
+ return outcome;
+ }
+
+ /// Substitutes the generated CN into a variant's placeholder template.
+ private static string Format(string template, string cn) => template.Replace("{cn}", cn);
+
+ private static string SafeLabel(string label) =>
+ new string(label.ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray())
+ .Trim('-');
+
+ // -------------------------------------------------------------------------
+ // The probe
+ // -------------------------------------------------------------------------
+
+ [SkippableFact]
+ public async Task Probe_SanSubmissionBehaviour()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+ Skip.IfNot(
+ Environment.GetEnvironmentVariable(OptInFlag) == "1",
+ $"Set {OptInFlag}=1 to run this probe — it places real orders on the configured account.");
+
+ var variants = new List<(string Label, Func> Sans, string[] CsrSans)>
+ {
+ // 1. Assumption A, positive control: additionalDomains carries an extra DNS
+ // name. If the extra name comes back registered, additionalDomains works.
+ ("dns-extra-via-additionalDomains",
+ cn => new List
+ {
+ new SanEntry { Type = "dns", Value = cn },
+ new SanEntry { Type = "dns", Value = $"extra1.{cn}" }
+ },
+ new[] { "{cn}", "extra1.{cn}" }),
+
+ // 2. Assumption A, the actual bug: CSR carries both names, additionalDomains
+ // is omitted entirely. This is what v1.0.1 sent for every UCC enrollment.
+ // If only the CN comes back registered, the CA does NOT read CSR SANs and
+ // the diagnosis is confirmed.
+ ("csr-sans-only-no-additionalDomains",
+ _ => null,
+ new[] { "{cn}", "extra2.{cn}" }),
+
+ // 3. Assumption C: primary domainName repeated inside additionalDomains.
+ // Does the CA reject it, or silently collapse it?
+ ("cn-duplicated-in-additionalDomains",
+ cn => new List
+ {
+ new SanEntry { Type = "dns", Value = cn },
+ new SanEntry { Type = "dns", Value = cn }
+ },
+ new[] { "{cn}" }),
+
+ // 4-6. Assumption B: non-DNS values in additionalDomains. Rejected, ignored,
+ // or accepted? Each is submitted alongside a valid DNS name so a rejection
+ // is attributable to the non-DNS value rather than an empty domain set.
+ ("nondns-email-in-additionalDomains",
+ cn => new List
+ {
+ new SanEntry { Type = "dns", Value = cn },
+ new SanEntry { Type = "email", Value = "san-probe@example.com" }
+ },
+ new[] { "{cn}" }),
+
+ ("nondns-ip-in-additionalDomains",
+ cn => new List
+ {
+ new SanEntry { Type = "dns", Value = cn },
+ new SanEntry { Type = "ip", Value = "192.0.2.10" }
+ },
+ new[] { "{cn}" }),
+
+ ("nondns-uri-in-additionalDomains",
+ cn => new List
+ {
+ new SanEntry { Type = "dns", Value = cn },
+ new SanEntry { Type = "uri", Value = "https://san-probe.example.com/x" }
+ },
+ new[] { "{cn}" }),
+ };
+
+ string[] productCodes =
+ (Environment.GetEnvironmentVariable(ProductCodesFlag) ?? DefaultProductCodes)
+ .Split(',', StringSplitOptions.RemoveEmptyEntries)
+ .Select(p => p.Trim())
+ .Where(p => p.Length > 0)
+ .ToArray();
+
+ var results = new List();
+ foreach (string productCode in productCodes)
+ {
+ bool unavailable = false;
+ foreach (var (label, sans, csrSans) in variants)
+ {
+ var outcome = await ProbeAsync(productCode, label, sans, csrSans);
+ results.Add(outcome);
+
+ // Don't burn five more orders proving the same product code is not
+ // enabled on this account.
+ if (outcome.ProductUnavailable)
+ {
+ unavailable = true;
+ break;
+ }
+
+ // Throttle: the sandbox rate-limits order bursts (~16 orders / 10 s).
+ await Task.Delay(1500);
+ }
+
+ if (unavailable)
+ _out.WriteLine($"(product {productCode} is not enabled on this account — skipped)");
+ }
+
+ _out.WriteLine("=== CERTInext SAN submission probe ===");
+ _out.WriteLine($"ProductCodes probed : {string.Join(", ", productCodes)}");
+ _out.WriteLine($"(fixture default : {_fixture.ProductCode})");
+ _out.WriteLine("");
+
+ foreach (var group in results.GroupBy(r => r.ProductCode))
+ {
+ _out.WriteLine($"--- ProductCode {group.Key} ---");
+ foreach (var r in group)
+ {
+ _out.WriteLine($"[{(r.Accepted ? "ACCEPTED" : "REJECTED")}] {r.Label}");
+ _out.WriteLine($" requested (additionalDomains): {(r.RequestedDomains.Count > 0 ? string.Join(", ", r.RequestedDomains) : "(field omitted)")}");
+ _out.WriteLine($" detail : {r.Detail}");
+ _out.WriteLine($" registeredDomains (TrackOrder): {(r.RegisteredDomains.Count > 0 ? string.Join(", ", r.RegisteredDomains) : "(none reported)")}");
+ _out.WriteLine("");
+ }
+ }
+
+ _out.WriteLine("=== How to read this ===");
+ _out.WriteLine("registeredDomains is TrackOrder's domainVerification key set — the domains");
+ _out.WriteLine("CERTInext put on the order. Compare it against 'requested':");
+ _out.WriteLine("(1) vs (2): if (1) registers the extra name and (2) does not, then");
+ _out.WriteLine(" additionalDomains is required and CSR SANs alone are ignored.");
+ _out.WriteLine("(3) : whether repeating the CN is rejected or collapsed.");
+ _out.WriteLine("(4)-(6) : whether non-DNS values are rejected, ignored, or accepted");
+ _out.WriteLine(" AT PLACEMENT TIME. An order accepted here can still be");
+ _out.WriteLine(" rejected later during validation/approval.");
+
+ // The probe reports; it does not assert a specific CA behaviour, because its purpose
+ // is to discover what that behaviour is. What must hold is that at least one UCC
+ // product was actually exercised — otherwise the run proved nothing and should not
+ // read as a pass.
+ var usable = results
+ .Where(r => !r.ProductUnavailable)
+ .GroupBy(r => r.ProductCode)
+ .ToList();
+
+ Skip.If(
+ usable.Count == 0,
+ "None of the probed product codes are enabled on this account " +
+ $"({string.Join(", ", productCodes)}). Set {ProductCodesFlag} to a Multi-Domain (UCC) " +
+ "code this account can order.");
+
+ foreach (var group in usable)
+ {
+ var control = group.First(r => r.Label == "dns-extra-via-additionalDomains");
+ Assert.True(
+ control.Accepted,
+ $"Positive control failed on product {group.Key} — could not place even a " +
+ $"plain DNS UCC order: {control.Detail}");
+ }
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/TESTING.md b/CERTInext.IntegrationTests/TESTING.md
index b961130..ad6d9ce 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`) are **ignored** in this file;
+ they must be exported in the shell (see `IntegrationTestFixture.OptInOnlyFlags`).
---
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.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()))
+ .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad))
+ .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good));
+
+ mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-a"));
+ mock.Setup(c => c.GetDcvAsync(order, bad, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-b"));
+
+ mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ mock.Setup(c => c.GetCertificateAsync(order, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(order));
+
+ var validator = new FakeDomainValidator
+ {
+ ShouldFail = key => key.Contains(bad, StringComparison.OrdinalIgnoreCase)
+ };
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ Func act = () => Enroll(plugin);
+
+ // Regression: a StageValidation failure on one domain of a multi-domain order must not
+ // abort the whole order any more — it did before this fix, which both failed the
+ // enrollment with an orphaned CERTInext order AND (before an earlier round's fix)
+ // orphaned the 'good' domain's already-published TXT record. Now the bad domain is
+ // skipped (logged loudly) and the good domain proceeds through the normal DCV lifecycle.
+ await act.Should().NotThrowAsync();
+
+ string goodHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good);
+ string badHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, bad);
+
+ validator.StagedRecords.Should().ContainSingle(
+ "only the domain that did not fail to stage should ever have been staged")
+ .Which.key.Should().Be(goodHostname);
+ validator.CleanedUpKeys.Should().Contain(goodHostname,
+ "the good domain completes its normal verify-then-cleanup lifecycle");
+ validator.CleanedUpKeys.Should().NotContain(badHostname,
+ "the bad domain was never staged, so there is nothing to clean up for it");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Partial skip vs. all-staged: the post-DCV issuance wait
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Regression: when some pending domains are skipped (here an IP-literal SAN with no DNS
+ /// provider) the order cannot issue, so Enroll must not hold the worker in the post-DCV
+ /// issuance wait. The staged domain must still be verified and its TXT record cleaned up;
+ /// sync DCV completes the order later.
+ ///
+ [Fact]
+ public async Task Dcv_PartialSkip_VerifiesAndCleansUpStagedDomains_ButSkipsIssuanceWait()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ const string good = 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_dcv" });
+ mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, ip))
+ .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good));
+ 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);
+ // Configured so that a (wrong) issuance wait would succeed and be observable.
+ 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 goodHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good);
+ validator.StagedRecords.Should().ContainSingle().Which.key.Should().Be(goodHostname);
+ mock.Verify(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()), Times.Once);
+ validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(goodHostname);
+
+ mock.Verify(c => c.GetCertificateAsync(order, It.IsAny()), Times.Never,
+ "the order cannot issue while a domain is skipped, so the post-DCV issuance wait must not run");
+ result.CARequestID.Should().Be(order);
+ result.Status.Should().NotBe((int)EndEntityStatus.GENERATED);
+ }
+
+ ///
+ /// Counterpart: every pending domain staged and verified -> the issuance wait still runs and
+ /// the issued certificate is returned from Enroll.
+ ///
+ [Fact]
+ public async Task Dcv_AllDomainsStaged_StillRunsIssuanceWait()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ const string a = "a.example.com";
+ const string b = "b.example.com";
+
+ 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 { [a] = verifiedDetail, [b] = verifiedDetail };
+ mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, a, b))
+ .ReturnsAsync(new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "1",
+ CertificateStatusId = "1",
+ DomainVerification = new TrackOrderDomainVerification
+ {
+ Status = Constants.Dcv.StatusValidated,
+ RawDomainEntries = verifiedRaw
+ }
+ }
+ });
+ foreach (string d in new[] { a, b })
+ {
+ 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();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ var result = await Enroll(plugin);
+
+ validator.StagedRecords.Should().HaveCount(2);
+ validator.CleanedUpKeys.Should().HaveCount(2);
+ mock.Verify(c => c.GetCertificateAsync(order, It.IsAny()), Times.Once,
+ "every domain was staged, so the post-DCV issuance wait must still run");
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ }
}
}
diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs
index 3ec5df1..b9a5071 100644
--- a/CERTInext.Tests/CERTInextCAPluginTests.cs
+++ b/CERTInext.Tests/CERTInextCAPluginTests.cs
@@ -31,8 +31,20 @@ public class CERTInextCAPluginTests
// Helpers
// ---------------------------------------------------------------------------
+ // Pickup is disabled by default in the broad fixture (PickupRetries=0) — mirroring how
+ // DcvConfig defaults its wait budgets to 0 — so tests that don't care about the
+ // synchronous pickup don't pay its real Task.Delay-based poll. Tests that DO exercise
+ // pickup opt in via BuildPluginWithPickup.
private static CERTInextCAPlugin BuildPlugin(ICERTInextClient client) =>
- new CERTInextCAPlugin(client);
+ new CERTInextCAPlugin(client, new CERTInextConfig { PickupRetries = 0 });
+
+ // Pickup-enabled fixture for the synchronous-pickup tests. PickupDelay is clamped to a
+ // 1s floor and the loop adds a fixed 5s initial delay, so these tests are intentionally
+ // a few seconds each.
+ private static CERTInextCAPlugin BuildPluginWithPickup(
+ ICERTInextClient client, int retries, int delaySeconds = 1) =>
+ new CERTInextCAPlugin(client,
+ new CERTInextConfig { PickupRetries = retries, PickupDelayInSeconds = delaySeconds });
private static Mock NewMock() => new Mock(MockBehavior.Strict);
@@ -289,6 +301,37 @@ await act.Should().ThrowAsync()
.WithMessage("*ProfileId*required*");
}
+ [Fact]
+ public async Task ValidateProductInfo_ProfileIdMissing_ThrowsBeforeClientAllocation()
+ {
+ // Regression: the transient CERTInextClient was allocated before the ProfileId
+ // guard, so the early throw leaked a RestClient + SemaphoreSlim (it sat outside the
+ // try/finally that disposes it). A null ApiUrl makes the CERTInextClient constructor
+ // throw (NullReferenceException on ApiUrl.TrimEnd), so if the client is still built
+ // before the guard this test surfaces that exception instead of the validation error.
+ var mock = NewMock();
+ var plugin = BuildPlugin(mock.Object);
+
+ var productInfo = new EnrollmentProductInfo
+ {
+ ProductID = string.Empty,
+ ProductParameters = new Dictionary()
+ };
+
+ var connInfo = new Dictionary
+ {
+ ["ApiUrl"] = null,
+ ["AuthMode"] = "ApiKey",
+ ["ApiKey"] = "key"
+ };
+
+ Func act = () => plugin.ValidateProductInfo(productInfo, connInfo);
+
+ await act.Should().ThrowExactlyAsync()
+ .WithMessage("*ProfileId*required*");
+ mock.VerifyNoOtherCalls();
+ }
+
// ---------------------------------------------------------------------------
// Enroll — New
// ---------------------------------------------------------------------------
@@ -345,6 +388,152 @@ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval()
result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
}
+ [Fact]
+ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReportsIssuedButBodyMissing()
+ {
+ // CERTInext can report an "issued"/auto-approved certificateStatusId before the
+ // certificate bytes actually exist — the immediate GetCertificate download fails
+ // and the legacy client returns Status="issued" with Certificate=null. Reporting
+ // GENERATED with no PEM crashes the gateway framework's PEM parser downstream, so
+ // the plugin must demote this to pending rather than trust the raw status string.
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(MockCertificateData.AutoApprovedNoBodyEnrollResponse());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 0);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem,
+ subject: "CN=test.example.com",
+ san: null,
+ productInfo: MakeProductInfo(),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ result.Certificate.Should().BeNullOrEmpty();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Synchronous certificate pickup (Sectigo parity)
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Pickup_Disabled_WhenPickupRetriesZero_ReturnsPendingWithoutPolling()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingEnrollResponse());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 0);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
+ productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()),
+ Times.Never, "PickupRetries=0 must disable the synchronous pickup poll");
+ }
+
+ [Fact]
+ public async Task Pickup_ReturnsIssuedCert_WhenOrderIssuesDuringPoll()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingEnrollResponse());
+ // The order finishes issuing by the time we poll: GetCertificate reports issued + PEM.
+ mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 2);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
+ productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ result.Certificate.Should().NotBeNullOrEmpty("a synchronously-picked-up cert must carry its PEM");
+ mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()),
+ Times.AtLeastOnce);
+ }
+
+ [Fact]
+ public async Task Pickup_SurfacesTerminalStatus_WhenOrderRevokedDuringPoll()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingEnrollResponse());
+ mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.RevokedCertRecord());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 3);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
+ productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.REVOKED,
+ "a terminal status observed during pickup is surfaced immediately, not polled to exhaustion");
+ }
+
+ [Fact]
+ public async Task Pickup_ReturnsPending_WhenOrderNeverIssuesWithinBudget()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingEnrollResponse());
+ // Every poll still reports pending — the budget is exhausted and Enroll returns the
+ // pending result for a later sync to complete.
+ mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingCertRecord());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 1);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
+ productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()),
+ Times.AtLeastOnce, "an enabled pickup must actually poll before giving up");
+ }
+
+ [Fact]
+ public async Task Pickup_StopsPolling_WhenGetCertificateThrowsOperationCanceled()
+ {
+ // Regression: the per-attempt catch swallowed OperationCanceledException as a
+ // transient poll error and kept polling. It must escape the poll loop; the outer
+ // pickup guard still degrades it to the pending result for a later sync.
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingEnrollResponse());
+ mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new OperationCanceledException());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 3);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
+ productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()),
+ Times.Once, "cancellation must not be consumed as a retryable poll error");
+ }
+
[Fact]
public async Task Enroll_New_Throws_WhenProfileIdNotSet()
{
@@ -369,6 +558,56 @@ await act.Should().ThrowAsync()
.WithMessage("*ProfileId*required*");
}
+ [Fact]
+ public async Task Enroll_New_ThreadsTemplateSignerParamsOntoRequest()
+ {
+ var mock = NewMock();
+ EnrollCertificateRequest captured = null;
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .Callback((r, _) => captured = r)
+ .ReturnsAsync(MockCertificateData.IssuedEnrollResponse());
+
+ var plugin = BuildPlugin(mock.Object);
+ await plugin.Enroll(
+ MockCertificateData.FakeCsrPem, "CN=test.example.com", null,
+ MakeProductInfo(extras: new Dictionary
+ {
+ ["SignerName"] = "Template Signer",
+ ["SignerPlace"] = "Template Place",
+ ["SignerIp"] = "203.0.113.77"
+ }),
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ captured.Should().NotBeNull();
+ captured!.SignerName.Should().Be("Template Signer");
+ captured.SignerPlace.Should().Be("Template Place");
+ captured.SignerIp.Should().Be("203.0.113.77");
+ }
+
+ [Fact]
+ public async Task Enroll_New_BlankTemplateSignerParams_LeaveRequestSignerValuesNull()
+ {
+ var mock = NewMock();
+ EnrollCertificateRequest captured = null;
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .Callback((r, _) => captured = r)
+ .ReturnsAsync(MockCertificateData.IssuedEnrollResponse());
+
+ var plugin = BuildPlugin(mock.Object);
+ await plugin.Enroll(
+ MockCertificateData.FakeCsrPem, "CN=test.example.com", null,
+ MakeProductInfo(), RequestFormat.PKCS10, EnrollmentType.New);
+
+ captured.Should().NotBeNull();
+ captured!.SignerName.Should().BeNull();
+ captured.SignerPlace.Should().BeNull();
+ captured.SignerIp.Should().BeNull();
+ }
+
[Fact]
public async Task Enroll_Reissue_AlsoCallsEnrollAsync()
{
@@ -966,6 +1205,44 @@ public async Task RenewOrReissue_UsesRenewApi_WhenCertExpiresWithinWindow()
"cert expiring in 30 days should use the renewal API (within 90-day window)");
}
+ [Fact]
+ public async Task RenewOrReissue_Renewal_ThreadsTemplateSignerParamsOntoRequest()
+ {
+ var clientMock = new Mock(MockBehavior.Strict);
+ var readerMock = new Mock(MockBehavior.Strict);
+
+ readerMock.Setup(r => r.GetRequestIDBySerialNumber(It.IsAny()))
+ .ReturnsAsync(MockCertificateData.CertId1);
+ readerMock.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1))
+ .Returns(DateTime.UtcNow.AddDays(30));
+
+ RenewCertificateRequest captured = null;
+ clientMock.Setup(c => c.RenewCertificateAsync(
+ MockCertificateData.CertId1,
+ It.IsAny(),
+ It.IsAny()))
+ .Callback((_, r, _) => captured = r)
+ .ReturnsAsync(MockCertificateData.IssuedEnrollResponse("renewed-01"));
+
+ var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object);
+ await plugin.Enroll(
+ MockCertificateData.FakeCsrPem, "CN=test.example.com", null,
+ MakeProductInfo(extras: new Dictionary
+ {
+ ["PriorCertSN"] = "AABB",
+ ["RenewalWindowDays"] = "90",
+ ["SignerName"] = "Template Signer",
+ ["SignerPlace"] = "Template Place",
+ ["SignerIp"] = "203.0.113.77"
+ }),
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+
+ captured.Should().NotBeNull();
+ captured!.SignerName.Should().Be("Template Signer");
+ captured.SignerPlace.Should().Be("Template Place");
+ captured.SignerIp.Should().Be("203.0.113.77");
+ }
+
[Fact]
public async Task RenewOrReissue_UsesNewEnroll_WhenCertExpiresOutsideWindow()
{
diff --git a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs
index 4e59495..eb9f73c 100644
--- a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs
+++ b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs
@@ -143,57 +143,100 @@ public async Task OrganizationNumber_Blank_OmitsOrganizationDetailsBlock()
}
// -----------------------------------------------------------------------
- // GroupNumber → delegationInformation block
+ // Legacy (wrong) field placements must never reappear. CERTInext reads
+ // groupNumber / autoSecureWWW from orderDetails and the technical contact as
+ // poc* fields; the shapes below were ignored by the API.
+ // -----------------------------------------------------------------------
+
+ private static void AssertNoLegacyFieldShapes(JsonElement orderDetails)
+ {
+ orderDetails.TryGetProperty("delegationInformation", out _).Should().BeFalse(
+ "delegationInformation{groupNumber} is not read by CERTInext — groupNumber belongs on orderDetails");
+ orderDetails.GetProperty("certificateInformation").TryGetProperty("autoSecureWWW", out _).Should().BeFalse(
+ "autoSecureWWW is read from orderDetails, not certificateInformation");
+ CollectPropertyNames(orderDetails)
+ .Where(n => n.StartsWith("tpc", StringComparison.Ordinal))
+ .Should().BeEmpty("the technical contact uses poc* field names, not tpc*");
+ }
+
+ private static System.Collections.Generic.IEnumerable CollectPropertyNames(JsonElement element)
+ {
+ if (element.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var prop in element.EnumerateObject())
+ {
+ yield return prop.Name;
+ foreach (var nested in CollectPropertyNames(prop.Value))
+ yield return nested;
+ }
+ }
+ else if (element.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var item in element.EnumerateArray())
+ foreach (var nested in CollectPropertyNames(item))
+ yield return nested;
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // GroupNumber → orderDetails.groupNumber
// -----------------------------------------------------------------------
[Fact]
- public async Task GroupNumber_Set_EmitsDelegationInformation()
+ public async Task GroupNumber_Set_EmitsOrderDetailsGroupNumber()
{
StubHappyEnroll();
var cfg = MinimalConfig();
- cfg.GroupNumber = "2171775848";
+ cfg.GroupNumber = "1000000001";
await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
var orderDetails = CapturedOrderBody();
- orderDetails.TryGetProperty("delegationInformation", out var delegation).Should().BeTrue();
- delegation.GetProperty("groupNumber").GetString().Should().Be("2171775848");
+ orderDetails.GetProperty("groupNumber").GetString().Should().Be("1000000001");
+ AssertNoLegacyFieldShapes(orderDetails);
}
- [Fact]
- public async Task GroupNumber_Blank_OmitsDelegationInformation()
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task GroupNumber_Blank_OmitsGroupNumber(string blank)
{
StubHappyEnroll();
var cfg = MinimalConfig();
- cfg.GroupNumber = string.Empty;
+ cfg.GroupNumber = blank;
await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
var orderDetails = CapturedOrderBody();
- orderDetails.TryGetProperty("delegationInformation", out _).Should().BeFalse();
+ orderDetails.TryGetProperty("groupNumber", out _).Should().BeFalse();
+ AssertNoLegacyFieldShapes(orderDetails);
}
// -----------------------------------------------------------------------
- // technicalPointOfContact — overrides + requestor fallback
+ // technicalPointOfContact — poc* fields, name split, requestor fallback
// -----------------------------------------------------------------------
[Fact]
- public async Task TechnicalContact_AllSet_EmitsExplicitValues()
+ public async Task TechnicalContact_AllSet_EmitsPocFields()
{
StubHappyEnroll();
var cfg = MinimalConfig();
- cfg.TechnicalContactName = "Jane Smith";
- cfg.TechnicalContactEmail = "tpc@example.com";
+ cfg.TechnicalContactName = "Jane Q Smith";
+ cfg.TechnicalContactEmail = "poc@example.com";
cfg.TechnicalContactIsdCode = "44";
cfg.TechnicalContactMobileNumber = "5559999999";
await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
- var tpc = CapturedOrderBody().GetProperty("technicalPointOfContact");
- tpc.GetProperty("tpcName").GetString().Should().Be("Jane Smith");
- tpc.GetProperty("tpcEmail").GetString().Should().Be("tpc@example.com");
- tpc.GetProperty("tpcIsdCode").GetString().Should().Be("44");
- tpc.GetProperty("tpcMobileNumber").GetString().Should().Be("5559999999");
+ var od = CapturedOrderBody();
+ var poc = od.GetProperty("technicalPointOfContact");
+ poc.GetProperty("pocFirstName").GetString().Should().Be("Jane");
+ poc.GetProperty("pocLastName").GetString().Should().Be("Q Smith");
+ poc.GetProperty("pocEmail").GetString().Should().Be("poc@example.com");
+ poc.GetProperty("pocIsdCode").GetString().Should().Be("44");
+ poc.GetProperty("pocMobileNumber").GetString().Should().Be("5559999999");
+ AssertNoLegacyFieldShapes(od);
}
[Fact]
@@ -209,17 +252,141 @@ public async Task TechnicalContact_AllBlank_FallsBackToRequestorDefaults()
await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
- var tpc = CapturedOrderBody().GetProperty("technicalPointOfContact");
- tpc.GetProperty("tpcName").GetString().Should().Be(cfg.RequestorName);
- tpc.GetProperty("tpcEmail").GetString().Should().Be(cfg.RequestorEmail);
- tpc.GetProperty("tpcIsdCode").GetString().Should().Be(cfg.RequestorIsdCode);
- tpc.GetProperty("tpcMobileNumber").GetString().Should().Be(cfg.RequestorMobileNumber);
+ var poc = CapturedOrderBody().GetProperty("technicalPointOfContact");
+ // MinimalConfig RequestorName = "Default Requestor"
+ poc.GetProperty("pocFirstName").GetString().Should().Be("Default");
+ poc.GetProperty("pocLastName").GetString().Should().Be("Requestor");
+ poc.GetProperty("pocEmail").GetString().Should().Be(cfg.RequestorEmail);
+ poc.GetProperty("pocIsdCode").GetString().Should().Be(cfg.RequestorIsdCode);
+ poc.GetProperty("pocMobileNumber").GetString().Should().Be(cfg.RequestorMobileNumber);
+ }
+
+ [Fact]
+ public async Task TechnicalContact_SingleTokenName_FillsFirstAndLast()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.TechnicalContactName = " Operations ";
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var poc = CapturedOrderBody().GetProperty("technicalPointOfContact");
+ poc.GetProperty("pocFirstName").GetString().Should().Be("Operations");
+ poc.GetProperty("pocLastName").GetString().Should().Be("Operations");
+ }
+
+ [Fact]
+ public async Task TechnicalContact_PerFieldFallback_MixesOverridesAndRequestorValues()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.TechnicalContactName = string.Empty; // → requestor name
+ cfg.TechnicalContactEmail = "poc@example.com"; // override
+ cfg.TechnicalContactIsdCode = string.Empty; // → requestor ISD
+ cfg.TechnicalContactMobileNumber = "5551112222"; // override
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var poc = CapturedOrderBody().GetProperty("technicalPointOfContact");
+ poc.GetProperty("pocFirstName").GetString().Should().Be("Default");
+ poc.GetProperty("pocLastName").GetString().Should().Be("Requestor");
+ poc.GetProperty("pocEmail").GetString().Should().Be("poc@example.com");
+ poc.GetProperty("pocIsdCode").GetString().Should().Be(cfg.RequestorIsdCode);
+ poc.GetProperty("pocMobileNumber").GetString().Should().Be("5551112222");
+ }
+
+ [Fact]
+ public async Task TechnicalContact_NoEmailResolved_OmitsBlock()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.RequestorEmail = string.Empty;
+ cfg.TechnicalContactEmail = " ";
+ cfg.TechnicalContactName = "Jane Smith";
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var od = CapturedOrderBody();
+ od.TryGetProperty("technicalPointOfContact", out _).Should().BeFalse(
+ "a POC with no email would now be validated by CERTInext — omit it rather than reject the order");
+ AssertNoLegacyFieldShapes(od);
+ }
+
+ [Theory]
+ [InlineData("", "")]
+ [InlineData(" ", "")]
+ [InlineData("", " ")]
+ public async Task TechnicalContact_NoNameResolved_OmitsBlock_OnEnroll(string technicalName, string requestorName)
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.TechnicalContactName = technicalName;
+ cfg.RequestorName = requestorName;
+ // Email resolves fine — only the name is missing.
+ cfg.TechnicalContactEmail = "poc@example.com";
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var od = CapturedOrderBody();
+ od.TryGetProperty("technicalPointOfContact", out _).Should().BeFalse(
+ "CERTInext requires the POC name inside the block — omit it rather than send empty first/last names");
+ AssertNoLegacyFieldShapes(od);
+ }
+
+ [Fact]
+ public async Task TechnicalContact_NoNameResolved_OmitsBlock_OnRenewal()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.TechnicalContactName = string.Empty;
+ cfg.RequestorName = string.Empty;
+ cfg.TechnicalContactEmail = "poc@example.com";
+
+ var renewReq = new RenewCertificateRequest
+ {
+ Csr = MockCertificateData.FakeCsrPem,
+ ProfileId = "842",
+ ValidityDays = 365,
+ Comment = "Renewal test"
+ };
+
+ await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq);
+
+ var od = CapturedOrderBody();
+ od.TryGetProperty("technicalPointOfContact", out _).Should().BeFalse(
+ "renewal shares the enroll builder and must also omit a POC block with no name");
+ AssertNoLegacyFieldShapes(od);
+ }
+
+ [Fact]
+ public async Task TechnicalContact_RenewalWithRequesterName_EmitsBlockFromRequesterName()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.TechnicalContactName = string.Empty;
+ cfg.RequestorName = string.Empty; // config blank, but the renewal request supplies a name
+ cfg.TechnicalContactEmail = "poc@example.com";
+
+ var renewReq = new RenewCertificateRequest
+ {
+ Csr = MockCertificateData.FakeCsrPem,
+ ProfileId = "842",
+ ValidityDays = 365,
+ RequesterName = "Renew Requester",
+ Comment = "Renewal test"
+ };
+
+ await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq);
+
+ var poc = CapturedOrderBody().GetProperty("technicalPointOfContact");
+ poc.GetProperty("pocFirstName").GetString().Should().Be("Renew");
+ poc.GetProperty("pocLastName").GetString().Should().Be("Requester");
}
// -----------------------------------------------------------------------
// SSL order body defaults — AccountingModel / EmailNotifications /
// SubscriptionAutoRenew / SubscriptionRenewCriteriaDays /
- // SubscriptionValidityYears / AutoSecureWww
+ // SubscriptionValidityYears / AutoSecureWww (→ orderDetails.autoSecureWWW)
// -----------------------------------------------------------------------
[Fact]
@@ -245,7 +412,8 @@ public async Task SslBodyDefaults_AreEmitted_FromCustomConnectorValues()
sub.GetProperty("autoRenew").GetString().Should().Be("1");
sub.GetProperty("renewCriteria").GetString().Should().Be("60");
- od.GetProperty("certificateInformation").GetProperty("autoSecureWWW").GetString().Should().Be("1");
+ od.GetProperty("autoSecureWWW").GetString().Should().Be("1");
+ AssertNoLegacyFieldShapes(od);
}
[Fact]
@@ -266,9 +434,26 @@ public async Task SslBodyDefaults_AreSafeFallbacks_WhenConfigUntouched()
sub.GetProperty("autoRenew").GetString().Should().Be("0");
sub.GetProperty("renewCriteria").GetString().Should().Be("30");
- od.GetProperty("certificateInformation").GetProperty("autoSecureWWW").GetString().Should().Be("0");
+ od.GetProperty("autoSecureWWW").GetString().Should().Be("0",
+ "the documented AutoSecureWww default of 0 must actually be sent, or CERTInext applies its own default of 1");
+ AssertNoLegacyFieldShapes(od);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public async Task AutoSecureWww_Blank_SendsZero(string blank)
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.AutoSecureWww = blank;
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ CapturedOrderBody().GetProperty("autoSecureWWW").GetString().Should().Be("0");
}
+
// -----------------------------------------------------------------------
// ValidityDays request-parameter still overrides the connector default
// -----------------------------------------------------------------------
@@ -288,5 +473,450 @@ public async Task ValidityDays_OnRequest_OverridesConnectorDefault()
CapturedOrderBody().GetProperty("subscriptionDetails")
.GetProperty("validity").GetString().Should().Be("2");
}
+
+ // -----------------------------------------------------------------------
+ // New enrollment — blank-value fallbacks (local issues/0071). Config/template
+ // strings default to "", so null-coalesce fallbacks never fired.
+ // -----------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task Enroll_ProfileIdBlank_FallsBackToConnectorDefaultProductCode(string blankProfileId)
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.DefaultProductCode = "connector-default-code";
+ var req = BasicEnrollRequest();
+ req.ProfileId = blankProfileId;
+
+ await BuildClient(cfg).EnrollCertificateAsync(req);
+
+ CapturedOrderBody().GetProperty("productCode").GetString().Should().Be("connector-default-code",
+ "a blank template ProductCode must fall back to the connector's DefaultProductCode");
+ }
+
+ [Fact]
+ public async Task Enroll_ProfileIdSet_UsesTemplateProductCodeOverConnectorDefault()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.DefaultProductCode = "connector-default-code";
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ CapturedOrderBody().GetProperty("productCode").GetString().Should().Be("842");
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task Enroll_SignerNameAndPlaceBlank_FallBackToDefaults(string blank)
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.RequestorName = blank;
+ cfg.SignerPlace = blank;
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var agreement = CapturedOrderBody().GetProperty("agreementDetails");
+ agreement.GetProperty("signerName").GetString().Should().Be("Keyfactor Gateway");
+ agreement.GetProperty("signerPlace").GetString().Should().Be("Gateway");
+ }
+
+ [Fact]
+ public async Task Enroll_SignerNameAndPlaceConfigured_AreSentVerbatim()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig(); // RequestorName "Default Requestor", SignerPlace "Austin"
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var agreement = CapturedOrderBody().GetProperty("agreementDetails");
+ agreement.GetProperty("signerName").GetString().Should().Be("Default Requestor");
+ agreement.GetProperty("signerPlace").GetString().Should().Be("Austin");
+ }
+
+ // -----------------------------------------------------------------------
+ // agreementDetails precedence (issue 0072): template value -> connector value -> default.
+ // Blank (null/""/whitespace) at either level falls through.
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public async Task Enroll_TemplateSignerValues_WinOverConnectorValues()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig(); // connector: "Default Requestor" / "Austin" / 203.0.113.10
+ var req = BasicEnrollRequest();
+ req.SignerName = "Template Signer";
+ req.SignerPlace = "Template Place";
+ req.SignerIp = "198.51.100.7";
+
+ await BuildClient(cfg).EnrollCertificateAsync(req);
+
+ var agreement = CapturedOrderBody().GetProperty("agreementDetails");
+ agreement.GetProperty("signerName").GetString().Should().Be("Template Signer");
+ agreement.GetProperty("signerPlace").GetString().Should().Be("Template Place");
+ agreement.GetProperty("signerIP").GetString().Should().Be("198.51.100.7");
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task Enroll_BlankTemplateSignerValues_FallBackToConnectorValues(string blank)
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ var req = BasicEnrollRequest();
+ req.SignerName = blank;
+ req.SignerPlace = blank;
+ req.SignerIp = blank;
+
+ await BuildClient(cfg).EnrollCertificateAsync(req);
+
+ var agreement = CapturedOrderBody().GetProperty("agreementDetails");
+ agreement.GetProperty("signerName").GetString().Should().Be("Default Requestor");
+ agreement.GetProperty("signerPlace").GetString().Should().Be("Austin");
+ agreement.GetProperty("signerIP").GetString().Should().Be("203.0.113.10");
+ }
+
+ [Fact]
+ public async Task Enroll_TemplateAndConnectorSignerBlank_UseBuiltInDefaults()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.RequestorName = "";
+ cfg.SignerPlace = " ";
+ cfg.SignerIp = "";
+ var req = BasicEnrollRequest();
+ req.SignerName = " ";
+
+ await BuildClient(cfg).EnrollCertificateAsync(req);
+
+ var agreement = CapturedOrderBody().GetProperty("agreementDetails");
+ agreement.GetProperty("signerName").GetString().Should().Be("Keyfactor Gateway");
+ agreement.GetProperty("signerPlace").GetString().Should().Be("Gateway");
+ agreement.GetProperty("signerIP").GetString().Should().Be("127.0.0.1");
+ }
+
+ [Fact]
+ public async Task Renewal_TemplateSignerValues_WinOverConnectorValues()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ var renewReq = new RenewCertificateRequest
+ {
+ Csr = MockCertificateData.FakeCsrPem,
+ ProfileId = "842",
+ Comment = "Renewal test",
+ SignerName = "Template Signer",
+ SignerPlace = "Template Place",
+ SignerIp = "198.51.100.7"
+ };
+
+ await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq);
+
+ var agreement = CapturedOrderBody().GetProperty("agreementDetails");
+ agreement.GetProperty("signerName").GetString().Should().Be("Template Signer");
+ agreement.GetProperty("signerPlace").GetString().Should().Be("Template Place");
+ agreement.GetProperty("signerIP").GetString().Should().Be("198.51.100.7");
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task Renewal_BlankTemplateSignerValues_FallBackToConnectorThenDefaults(string blank)
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ var renewReq = new RenewCertificateRequest
+ {
+ Csr = MockCertificateData.FakeCsrPem,
+ ProfileId = "842",
+ Comment = "Renewal test",
+ SignerName = blank,
+ SignerPlace = blank,
+ SignerIp = blank
+ };
+
+ await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq);
+
+ var agreement = CapturedOrderBody().GetProperty("agreementDetails");
+ agreement.GetProperty("signerName").GetString().Should().Be("Default Requestor");
+ agreement.GetProperty("signerPlace").GetString().Should().Be("Austin");
+ agreement.GetProperty("signerIP").GetString().Should().Be("203.0.113.10");
+ }
+
+ [Fact]
+ public async Task Renewal_TemplateAndConnectorSignerBlank_UseBuiltInDefaults()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.RequestorName = "";
+ cfg.SignerPlace = "";
+ cfg.SignerIp = "";
+ var renewReq = new RenewCertificateRequest
+ {
+ Csr = MockCertificateData.FakeCsrPem,
+ ProfileId = "842",
+ Comment = "Renewal test"
+ };
+
+ await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq);
+
+ var agreement = CapturedOrderBody().GetProperty("agreementDetails");
+ agreement.GetProperty("signerName").GetString().Should().Be("Keyfactor Gateway");
+ agreement.GetProperty("signerPlace").GetString().Should().Be("Gateway");
+ agreement.GetProperty("signerIP").GetString().Should().Be("127.0.0.1");
+ }
+
+ // -----------------------------------------------------------------------
+ // RenewCertificateAsync — productCode resolution (issue #26 / local issues/0012)
+ // Renewals go out as a fresh GenerateOrderSSL order; the product code must
+ // come from the template (RenewCertificateRequest.ProfileId) when supplied,
+ // falling back to the connector's DefaultProductCode only when it is not.
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public async Task RenewCertificateAsync_ProfileIdSet_UsesTemplateProductCode()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.DefaultProductCode = "connector-default-code";
+
+ var renewReq = new RenewCertificateRequest
+ {
+ Csr = MockCertificateData.FakeCsrPem,
+ ProfileId = "template-product-code",
+ ValidityDays = 365,
+ Comment = "Renewal test"
+ };
+
+ await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq);
+
+ CapturedOrderBody().GetProperty("productCode").GetString()
+ .Should().Be("template-product-code",
+ "the template's own product code must win over the connector default");
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task RenewCertificateAsync_ProfileIdBlank_FallsBackToConnectorDefault(string blankProfileId)
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.DefaultProductCode = "connector-default-code";
+
+ var renewReq = new RenewCertificateRequest
+ {
+ Csr = MockCertificateData.FakeCsrPem,
+ ProfileId = blankProfileId,
+ ValidityDays = 365,
+ Comment = "Renewal test"
+ };
+
+ await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq);
+
+ CapturedOrderBody().GetProperty("productCode").GetString()
+ .Should().Be("connector-default-code",
+ "a blank ProfileId must fall back to the connector's DefaultProductCode, not an empty string");
+ }
+
+ // -----------------------------------------------------------------------
+ // RenewCertificateAsync — full order-details shape. Renewal shares the
+ // new-enrollment builder, so it must send every field a new order sends and
+ // follow the connector's validity / autoRenew / emailNotifications /
+ // accountingModel settings (previously hard-coded validity="1" and DTO
+ // defaults autoRenew="1" / emailNotifications="1", and omitted groupNumber,
+ // autoSecureWWW, technical contact, organizationDetails and remarks).
+ // -----------------------------------------------------------------------
+
+ private static CERTInextConfig FullyConfiguredConfig()
+ {
+ var cfg = MinimalConfig();
+ cfg.GroupNumber = "1000000001";
+ cfg.OrganizationNumber = "2000000002";
+ cfg.AccountingModel = "1";
+ cfg.EmailNotifications = "0";
+ cfg.SubscriptionValidityYears = "3";
+ cfg.SubscriptionAutoRenew = "0";
+ cfg.SubscriptionRenewCriteriaDays = "60";
+ cfg.AutoSecureWww = "0";
+ cfg.TechnicalContactName = "Pat Example";
+ cfg.TechnicalContactEmail = "poc@example.com";
+ cfg.TechnicalContactIsdCode = "44";
+ cfg.TechnicalContactMobileNumber = "5553334444";
+ return cfg;
+ }
+
+ [Fact]
+ public async Task RenewCertificateAsync_SendsFullOrderDetails_FromConnectorConfig()
+ {
+ StubHappyEnroll();
+ var cfg = FullyConfiguredConfig();
+
+ var renewReq = new RenewCertificateRequest
+ {
+ Csr = MockCertificateData.FakeCsrPem,
+ Subject = "CN=renew.example.com,O=Example",
+ ProfileId = "842",
+ Sans = new System.Collections.Generic.List
+ {
+ new SanEntry { Type = "dns", Value = "renew.example.com" },
+ new SanEntry { Type = "dns", Value = "alt.example.com" }
+ },
+ ValidityYears = 2,
+ RequesterName = "Renew Requester",
+ RequesterEmail = "renew@example.com",
+ Comment = "Renewed via Keyfactor Command. Prior ID: ORD-AAA-111."
+ };
+
+ await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq);
+
+ var od = CapturedOrderBody();
+ od.GetProperty("productCode").GetString().Should().Be("842");
+ od.GetProperty("accountingModel").GetString().Should().Be("1");
+ od.GetProperty("saveAndHold").GetString().Should().Be("0");
+ od.GetProperty("emailNotifications").GetString().Should().Be("0");
+ od.GetProperty("groupNumber").GetString().Should().Be("1000000001");
+ od.GetProperty("autoSecureWWW").GetString().Should().Be("0");
+
+ var org = od.GetProperty("organizationDetails");
+ org.GetProperty("preVetting").GetString().Should().Be("1");
+ org.GetProperty("organizationNumber").GetString().Should().Be("2000000002");
+
+ var requestor = od.GetProperty("requestorInformation");
+ requestor.GetProperty("requestorName").GetString().Should().Be("Renew Requester");
+ requestor.GetProperty("requestorEmail").GetString().Should().Be("renew@example.com");
+ requestor.GetProperty("requestorIsdCode").GetString().Should().Be(cfg.RequestorIsdCode);
+ requestor.GetProperty("requestorMobileNumber").GetString().Should().Be(cfg.RequestorMobileNumber);
+
+ var sub = od.GetProperty("subscriptionDetails");
+ sub.GetProperty("validity").GetString().Should().Be("2", "the plugin-supplied ValidityYears must win");
+ sub.GetProperty("autoRenew").GetString().Should().Be("0");
+ sub.GetProperty("renewCriteria").GetString().Should().Be("60");
+
+ var ci = od.GetProperty("certificateInformation");
+ ci.GetProperty("domainName").GetString().Should().Be("renew.example.com");
+ ci.GetProperty("additionalDomains").EnumerateArray().Select(e => e.GetString())
+ .Should().Equal("alt.example.com");
+
+ var poc = od.GetProperty("technicalPointOfContact");
+ poc.GetProperty("pocFirstName").GetString().Should().Be("Pat");
+ poc.GetProperty("pocLastName").GetString().Should().Be("Example");
+ poc.GetProperty("pocEmail").GetString().Should().Be("poc@example.com");
+ poc.GetProperty("pocIsdCode").GetString().Should().Be("44");
+ poc.GetProperty("pocMobileNumber").GetString().Should().Be("5553334444");
+
+ od.GetProperty("csr").GetString().Should().Be(MockCertificateData.FakeCsrPem);
+ od.GetProperty("agreementDetails").GetProperty("acceptAgreement").GetString().Should().Be("1");
+ od.GetProperty("additionalInformation").GetProperty("remarks").GetString()
+ .Should().Be("Renewed via Keyfactor Command. Prior ID: ORD-AAA-111.");
+
+ AssertNoLegacyFieldShapes(od);
+ }
+
+ [Fact]
+ public async Task RenewCertificateAsync_NoValidityOnRequest_UsesConnectorValidity()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.SubscriptionValidityYears = "3";
+
+ var renewReq = new RenewCertificateRequest
+ {
+ Csr = MockCertificateData.FakeCsrPem,
+ Subject = "CN=renew.example.com",
+ ProfileId = "842"
+ };
+
+ await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq);
+
+ CapturedOrderBody().GetProperty("subscriptionDetails").GetProperty("validity").GetString()
+ .Should().Be("3", "renewal must no longer hard-code validity=1");
+ }
+
+ [Fact]
+ public async Task RenewCertificateAsync_ValidityDays_ConvertsToYears()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.SubscriptionValidityYears = "1";
+
+ var renewReq = new RenewCertificateRequest
+ {
+ Csr = MockCertificateData.FakeCsrPem,
+ Subject = "CN=renew.example.com",
+ ProfileId = "842",
+ ValidityDays = 730
+ };
+
+ await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq);
+
+ CapturedOrderBody().GetProperty("subscriptionDetails").GetProperty("validity").GetString()
+ .Should().Be("2");
+ }
+
+ [Fact]
+ public async Task RenewCertificateAsync_ConfigUntouched_UsesConnectorDefaultsNotDtoDefaults()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+
+ var renewReq = new RenewCertificateRequest
+ {
+ Csr = MockCertificateData.FakeCsrPem,
+ Subject = "CN=renew.example.com",
+ ProfileId = "842"
+ };
+
+ await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq);
+
+ var od = CapturedOrderBody();
+ od.GetProperty("accountingModel").GetString().Should().Be("2");
+ od.GetProperty("emailNotifications").GetString().Should().Be("0",
+ "the connector default (0) must apply, not the DTO default (1)");
+ od.GetProperty("subscriptionDetails").GetProperty("autoRenew").GetString().Should().Be("0",
+ "the connector default (0) must apply, not the DTO default (1)");
+ od.GetProperty("subscriptionDetails").GetProperty("renewCriteria").GetString().Should().Be("30");
+ od.GetProperty("autoSecureWWW").GetString().Should().Be("0");
+ od.TryGetProperty("groupNumber", out _).Should().BeFalse();
+ od.TryGetProperty("organizationDetails", out _).Should().BeFalse();
+ od.GetProperty("technicalPointOfContact").GetProperty("pocEmail").GetString()
+ .Should().Be(cfg.RequestorEmail);
+ od.GetProperty("additionalInformation").GetProperty("remarks").GetString()
+ .Should().NotBeNullOrWhiteSpace();
+ AssertNoLegacyFieldShapes(od);
+ }
+
+ // -----------------------------------------------------------------------
+ // SplitContactName — TechnicalContactName → pocFirstName / pocLastName
+ // -----------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("Jane Smith", "Jane", "Smith")]
+ [InlineData("Jane Q Smith", "Jane", "Q Smith")]
+ [InlineData(" Jane Smith ", "Jane", "Smith")]
+ [InlineData("Jane\tSmith", "Jane", "Smith")]
+ [InlineData("Jane Q Smith", "Jane", "Q Smith")]
+ [InlineData("Operations", "Operations", "Operations")]
+ [InlineData(" Operations ", "Operations", "Operations")]
+ [InlineData("", "", "")]
+ [InlineData(" ", "", "")]
+ [InlineData(null, "", "")]
+ public void SplitContactName_SplitsOnFirstWhitespaceRun(string input, string expectedFirst, string expectedLast)
+ {
+ var (first, last) = CERTInextClient.SplitContactName(input);
+
+ first.Should().Be(expectedFirst);
+ last.Should().Be(expectedLast);
+ }
}
}
diff --git a/CERTInext.Tests/CERTInextClientTests.cs b/CERTInext.Tests/CERTInextClientTests.cs
index e473e89..a0ade72 100644
--- a/CERTInext.Tests/CERTInextClientTests.cs
+++ b/CERTInext.Tests/CERTInextClientTests.cs
@@ -790,6 +790,42 @@ await act.Should().ThrowAsync()
.WithMessage("*GetDcv failed*");
}
+ ///
+ /// Regression: this client is built with ThrowOnAnyError=false, so RestSharp catches a
+ /// cancelled HttpClient.SendAsync internally and returns a non-throwing, unsuccessful
+ /// RestResponse instead of propagating OperationCanceledException. Before this fix,
+ /// ExecuteWithRetryAsync passed that response straight to DeserializeOrThrow, which wrapped
+ /// it in a plain Exception — indistinguishable from a genuine API failure. A caller such as
+ /// PerformDcvIfNeededAsync's per-domain "catch (OperationCanceledException) { throw; }" guard
+ /// (added specifically to stop a DCV timeout from being mislabeled as an ordinary per-domain
+ /// failure) could never actually see the real cancellation, because it never arrived as
+ /// OperationCanceledException in the first place — a gap a Moq-level test of the plugin alone
+ /// cannot expose, since a mock can be told to throw whatever type is asked for. This test
+ /// exercises the real client against a real (if local) HTTP call, which is the only way to
+ /// pin the actual failure mode.
+ ///
+ [Fact]
+ public async Task GetDcvAsync_ThrowsOperationCanceled_WhenCancellationTokenIsCancelled()
+ {
+ _server
+ .Given(Request.Create().WithPath("/GetDcv").UsingPost())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(MockCertificateData.GetDcvSuccessJson()));
+
+ var client = BuildClient();
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ Func act = () => client.GetDcvAsync(
+ MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt, cts.Token);
+
+ await act.Should().ThrowAsync(
+ "a cancelled token must surface as a genuine cancellation, not get wrapped into a " +
+ "plain Exception that a caller's cancellation-specific catch clause cannot recognize");
+ }
+
[Fact]
public async Task GetDcvAsync_Throws_WhenServerReturns401()
{
diff --git a/CERTInext.Tests/CaErrorTextMaskingTests.cs b/CERTInext.Tests/CaErrorTextMaskingTests.cs
new file mode 100644
index 0000000..6d3eac9
--- /dev/null
+++ b/CERTInext.Tests/CaErrorTextMaskingTests.cs
@@ -0,0 +1,288 @@
+// Copyright 2026 Keyfactor
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Models;
+using Microsoft.Extensions.Logging;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using WireMock.Server;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// CA-supplied error text (meta.errorMessage / legacy message) may echo a request
+ /// value such as an email. With LogSensitiveRequestData off, email-shaped tokens are masked
+ /// (via MaskEmail) and CR/LF stripped in both the log line and the exception message that
+ /// Command stores; the rest of the text is preserved. With the flag on the text is verbatim.
+ /// All data is synthetic.
+ ///
+ [Collection(LoggingStateCollection.Name)]
+ public class CaErrorTextMaskingTests : IDisposable
+ {
+ private const string Email = "jane.doe@example.com";
+ private const string MaskedEmail = "j***@example.com";
+
+ private readonly WireMockServer _server = WireMockServer.Start();
+
+ public void Dispose() => _server.Stop();
+
+ // ---------------------------------------------------------------------------
+ // LogSanitizer.SanitizeCaText
+ // ---------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("Invalid requestorEmail jane.doe@example.com for order", "Invalid requestorEmail j***@example.com for order")]
+ [InlineData("Email 'jane.doe@example.com'.", "Email 'j***@example.com'.")]
+ [InlineData("Sent to jane.doe@example.com.", "Sent to j***@example.com.")]
+ [InlineData("a@b.example.org and c+tag@sub.example.co.uk differ", "a***@b.example.org and c***@sub.example.co.uk differ")]
+ [InlineData("EMS-956 Invalid Request for this API.", "EMS-956 Invalid Request for this API.")]
+ [InlineData("Inactive Account User.", "Inactive Account User.")]
+ [InlineData("no at-sign, handle@ only, @example.com alone", "no at-sign, handle@ only, @example.com alone")]
+ public void SanitizeCaText_FlagOff_MasksOnlyEmailTokens(string input, string expected)
+ {
+ LogSanitizer.SanitizeCaText(input, false).Should().Be(expected);
+ }
+
+ [Fact]
+ public void SanitizeCaText_FlagOff_StripsCrLfAndTab()
+ {
+ LogSanitizer.SanitizeCaText("first\r\nsecond\tthird", false)
+ .Should().Be("first\\r\\nsecond\\tthird");
+ }
+
+ [Fact]
+ public void SanitizeCaText_FlagOn_IsVerbatim()
+ {
+ string text = "Bad " + Email + "\r\nline two";
+ LogSanitizer.SanitizeCaText(text, true).Should().Be(text);
+ }
+
+ [Fact]
+ public void SanitizeCaText_NullAndEmpty_PassThrough()
+ {
+ LogSanitizer.SanitizeCaText(null, false).Should().BeNull();
+ LogSanitizer.SanitizeCaText(string.Empty, false).Should().BeEmpty();
+ }
+
+ [Fact]
+ public void SanitizeCaText_IsIdempotent()
+ {
+ string once = LogSanitizer.SanitizeCaText("Bad " + Email, false);
+ LogSanitizer.SanitizeCaText(once, false).Should().Be(once);
+ }
+
+ [Fact]
+ public void SanitizeCaText_LargeInputWithoutAtSign_CompletesQuickly()
+ {
+ string big = new string('a', 64 * 1024);
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ LogSanitizer.SanitizeCaText(big, false).Should().Be(big);
+ sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1));
+ }
+
+ // ---------------------------------------------------------------------------
+ // ExtractErrorMessage
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void ExtractErrorMessage_MetaBody_FlagOff_MasksEmailKeepsCodeAndStatus()
+ {
+ string body = "{\"meta\":{\"status\":\"0\",\"errorCode\":\"EMS-100\",\"errorMessage\":\"Invalid requestorEmail " + Email + " (field requestorEmail)\"}}";
+
+ CERTInextClient.ExtractErrorMessage(body, "op", 400)
+ .Should().Be($"CERTInext error during 'op' (HTTP 400): Invalid requestorEmail {MaskedEmail} (field requestorEmail) [EMS-100]");
+ }
+
+ [Fact]
+ public void ExtractErrorMessage_MetaBody_FlagOn_IsVerbatim()
+ {
+ string body = "{\"meta\":{\"status\":\"0\",\"errorCode\":\"EMS-100\",\"errorMessage\":\"Invalid requestorEmail " + Email + "\"}}";
+
+ CERTInextClient.ExtractErrorMessage(body, "op", 400, logSensitiveRequestData: true)
+ .Should().Be($"CERTInext error during 'op' (HTTP 400): Invalid requestorEmail {Email} [EMS-100]");
+ }
+
+ [Fact]
+ public void ExtractErrorMessage_MetaBody_FlagOff_StripsCrLf()
+ {
+ // JSON \r\n escapes decode to real CR/LF characters.
+ string body = "{\"meta\":{\"errorCode\":\"EMS-100\",\"errorMessage\":\"line one\\r\\nforged line\"}}";
+
+ string msg = CERTInextClient.ExtractErrorMessage(body, "op");
+
+ msg.Should().NotContain("\r").And.NotContain("\n");
+ msg.Should().Contain("line one\\r\\nforged line");
+ }
+
+ [Fact]
+ public void ExtractErrorMessage_LegacyBody_FlagOffMasksFlagOnVerbatim()
+ {
+ string body = "{\"message\":\"Unknown user " + Email + "\"}";
+
+ CERTInextClient.ExtractErrorMessage(body, "op", 503)
+ .Should().Be($"CERTInext error during 'op' (HTTP 503): Unknown user {MaskedEmail}");
+ CERTInextClient.ExtractErrorMessage(body, "op", 503, logSensitiveRequestData: true)
+ .Should().Be($"CERTInext error during 'op' (HTTP 503): Unknown user {Email}");
+ }
+
+ [Fact]
+ public void ExtractErrorMessage_OmittedFlag_FailsClosed()
+ {
+ string body = "{\"message\":\"Unknown user " + Email + "\"}";
+
+ CERTInextClient.ExtractErrorMessage(body, "op").Should().NotContain(Email);
+ }
+
+ // ---------------------------------------------------------------------------
+ // End to end through the client: log line and exception message
+ // ---------------------------------------------------------------------------
+
+ private sealed class CapturingLogger : ILogger
+ {
+ public ConcurrentQueue<(LogLevel Level, string Message)> Entries { 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)
+ => Entries.Enqueue((logLevel, formatter(state, exception)));
+ }
+
+ private CERTInextClient BuildClient(bool logSensitiveRequestData) => new CERTInextClient(new CERTInextConfig
+ {
+ ApiUrl = _server.Urls[0],
+ AuthMode = "AccessKey",
+ ApiKey = "synthetic-access-key",
+ AccountNumber = "9988776655",
+ LogSensitiveRequestData = logSensitiveRequestData
+ });
+
+ private async Task<(Exception Error, List Lines)> RunAsync(
+ bool logSensitiveRequestData, string path, int status, string body, string marker,
+ Func call)
+ {
+ _server.Reset();
+ _server.Given(Request.Create().WithPath("/" + path).UsingPost())
+ .RespondWith(Response.Create().WithStatusCode(status)
+ .WithHeader("Content-Type", "application/json").WithBody(body));
+
+ var client = BuildClient(logSensitiveRequestData);
+ var logger = new CapturingLogger();
+ Exception error = null;
+ using (CERTInextClient.OverrideLoggerForTests(logger))
+ {
+ try { await call(client); }
+ catch (Exception ex) { error = ex; }
+ }
+
+ // The client logger is process-wide; scope to lines carrying this call's marker.
+ var lines = logger.Entries.Select(e => e.Message)
+ .Where(m => m != null && m.Contains(marker)).ToList();
+ return (error, lines);
+ }
+
+ private static string MetaFailureBody(string marker, string errorCode = "EMS-100") =>
+ "{\"meta\":{\"status\":\"0\",\"errorCode\":\"" + errorCode + "\",\"errorMessage\":\"" + marker +
+ " Invalid requestorEmail " + Email + " for field requestorEmail\\r\\nforged\"}}";
+
+ // The ErrorMessage= field of the "CERTInext API non-success" line (the ResponseBody= field
+ // that follows is redacted separately by ApplyLoggingRedaction).
+ private static string ErrorMessageField(IEnumerable lines)
+ {
+ string failure = lines.Single(l => l.StartsWith("CERTInext API non-success"));
+ int start = failure.IndexOf("ErrorMessage=", StringComparison.Ordinal);
+ int end = failure.IndexOf(", ResponseBody=", StringComparison.Ordinal);
+ return failure.Substring(start, end - start);
+ }
+
+ [Fact]
+ public async Task TrackOrder_MetaFailure_FlagOff_MasksEmailInLogAndException()
+ {
+ string marker = "m-" + Guid.NewGuid().ToString("N");
+ var (error, lines) = await RunAsync(false, "TrackOrder", 200, MetaFailureBody(marker), marker,
+ c => c.TrackOrderAsync("ORD-1"));
+
+ error.Should().NotBeNull();
+ error.Message.Should().Contain("Invalid requestorEmail " + MaskedEmail + " for field requestorEmail")
+ .And.NotContain(Email).And.NotContain("\r").And.NotContain("\n");
+
+ string errField = ErrorMessageField(lines);
+ errField.Should().Contain("Invalid requestorEmail " + MaskedEmail + " for field requestorEmail")
+ .And.NotContain(Email).And.NotContain("\r").And.NotContain("\n").And.Contain("\\r\\nforged");
+ lines.Single(l => l.StartsWith("CERTInext API non-success")).Should().Contain("ErrorCode=EMS-100");
+ }
+
+ [Fact]
+ public async Task TrackOrder_MetaFailure_FlagOn_IsVerbatimInLogAndException()
+ {
+ string marker = "m-" + Guid.NewGuid().ToString("N");
+ var (error, lines) = await RunAsync(true, "TrackOrder", 200, MetaFailureBody(marker), marker,
+ c => c.TrackOrderAsync("ORD-1"));
+
+ error.Message.Should().Contain("Invalid requestorEmail " + Email + " for field requestorEmail");
+ ErrorMessageField(lines).Should().Contain("Invalid requestorEmail " + Email + " for field requestorEmail");
+ }
+
+ [Fact]
+ public async Task TrackOrder_NotFound_FlagOff_MasksEmailInKeyNotFoundMessage()
+ {
+ string marker = "m-" + Guid.NewGuid().ToString("N");
+ var (error, _) = await RunAsync(false, "TrackOrder", 200, MetaFailureBody(marker, "EMS-913"), marker,
+ c => c.TrackOrderAsync("ORD-1"));
+
+ error.Should().BeOfType();
+ error.Message.Should().Contain(MaskedEmail).And.NotContain(Email);
+ }
+
+ [Fact]
+ public async Task GetProductDetails_Non2xxMetaBody_FlagOff_MasksEmailInLogAndException()
+ {
+ string marker = "m-" + Guid.NewGuid().ToString("N");
+ var (error, lines) = await RunAsync(false, "GetProductDetails", 400, MetaFailureBody(marker), marker,
+ c => c.GetProductDetailsAsync());
+
+ error.Message.Should().Contain("(HTTP 400)").And.Contain("[EMS-100]")
+ .And.Contain("Invalid requestorEmail " + MaskedEmail + " for field requestorEmail")
+ .And.NotContain(Email);
+ ErrorMessageField(lines).Should().Contain(MaskedEmail).And.NotContain(Email);
+ }
+
+ [Fact]
+ public async Task GetProductDetails_Non2xxMetaBody_FlagOn_IsVerbatimInLogAndException()
+ {
+ string marker = "m-" + Guid.NewGuid().ToString("N");
+ var (error, lines) = await RunAsync(true, "GetProductDetails", 400, MetaFailureBody(marker), marker,
+ c => c.GetProductDetailsAsync());
+
+ error.Message.Should().Contain("Invalid requestorEmail " + Email + " for field requestorEmail");
+ ErrorMessageField(lines).Should().Contain("Invalid requestorEmail " + Email + " for field requestorEmail");
+ }
+
+ [Fact]
+ public void IsRateLimitSurface_StillMatchesRawTextContainingEmail()
+ {
+ CERTInextClient.IsRateLimitSurface("Inactive Account User. contact " + Email).Should().BeTrue();
+ // And the masked form keeps the phrase, so masking never hides a rate-limit diagnosis.
+ LogSanitizer.SanitizeCaText("Inactive Account User. contact " + Email, false)
+ .Should().Contain("Inactive Account User.");
+ }
+ }
+}
diff --git a/CERTInext.Tests/ClientPayloadLogRedactionTests.cs b/CERTInext.Tests/ClientPayloadLogRedactionTests.cs
new file mode 100644
index 0000000..1db5891
--- /dev/null
+++ b/CERTInext.Tests/ClientPayloadLogRedactionTests.cs
@@ -0,0 +1,300 @@
+// 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.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Microsoft.Extensions.Logging;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using WireMock.Server;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Issue 0040 regression coverage at the real log call sites in :
+ /// the PlaceOrderAsync Trace request dump (which used to write the replayable
+ /// meta.authKey digest and requestor PII verbatim), the TrackOrderAsync Trace
+ /// response dump, and the LogApiFailure response-body logger. Each test drives the client
+ /// against WireMock and captures what the client actually logged through
+ /// CERTInextClient.OverrideLoggerForTests.
+ ///
+ /// The client logger is process-wide, so other test classes running in parallel may log into
+ /// the capture while it is installed; every assertion is scoped to lines carrying this call's
+ /// unique marker. All data is synthetic.
+ ///
+ [Collection(LoggingStateCollection.Name)]
+ public class ClientPayloadLogRedactionTests : IDisposable
+ {
+ private const string RequestorName = "Jane Doe";
+ private const string RequestorEmail = "jane.doe@example.com";
+ private const string MaskedRequestorEmail = "j***@example.com";
+ private const string RequestorMobile = "5551234567";
+ private const string PocEmail = "tech.contact@example.com";
+ private const string MaskedPocEmail = "t***@example.com";
+ private const string PocFirstName = "Terry";
+ private const string PocLastName = "Techcontact";
+ private const string PocMobile = "5559876543";
+ private const string SignerName = "John Signer";
+ private const string EmailSan = "alice@example.com";
+ private const string MaskedEmailSan = "a***@example.com";
+
+ private readonly WireMockServer _server;
+
+ public ClientPayloadLogRedactionTests()
+ {
+ _server = WireMockServer.Start();
+ }
+
+ public void Dispose() => _server.Stop();
+
+ private sealed class CapturingLogger : ILogger
+ {
+ public ConcurrentQueue<(LogLevel Level, string Message)> Entries { 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)
+ => Entries.Enqueue((logLevel, formatter(state, exception)));
+ }
+
+ private CERTInextClient BuildClient(bool logSensitiveRequestData) => new CERTInextClient(new CERTInextConfig
+ {
+ ApiUrl = _server.Urls[0],
+ AuthMode = "AccessKey",
+ ApiKey = "synthetic-access-key",
+ AccountNumber = "9988776655",
+ LogSensitiveRequestData = logSensitiveRequestData
+ });
+
+ private static GenerateOrderSslRequest BuildOrder(string primaryDomain) => new GenerateOrderSslRequest
+ {
+ // Meta left null so PlaceOrderAsync computes a real authKey via BuildMetaAsync.
+ OrderDetails = new SslOrderDetails
+ {
+ ProductCode = "842",
+ RequestorInformation = new RequestorInformation
+ {
+ RequestorName = RequestorName,
+ RequestorMobileNumber = RequestorMobile,
+ RequestorEmail = RequestorEmail,
+ RequestorDesignation = "IT Administrator"
+ },
+ CertificateInformation = new CertificateInformation
+ {
+ DomainName = primaryDomain,
+ AdditionalDomains = new List { "www." + primaryDomain, EmailSan }
+ },
+ AgreementDetails = new AgreementDetails { SignerName = SignerName, SignerPlace = "Austin", SignerIp = "203.0.113.10" },
+ TechnicalPointOfContact = new TechnicalPointOfContact
+ {
+ PocFirstName = PocFirstName, PocLastName = PocLastName, PocEmail = PocEmail,
+ PocIsdCode = "44", PocMobileNumber = PocMobile
+ }
+ }
+ };
+
+ private void StubGenerateOrder(string body) =>
+ _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost())
+ .RespondWith(Response.Create().WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json").WithBody(body));
+
+ /// Returns the meta.authKey the client actually sent on the wire.
+ private string SentAuthKey()
+ {
+ var entry = _server.LogEntries.Last(e => e.RequestMessage.Path == "/GenerateOrderSSL");
+ using var doc = JsonDocument.Parse(entry.RequestMessage.Body ?? "{}");
+ string authKey = doc.RootElement.GetProperty("meta").GetProperty("authKey").GetString();
+ authKey.Should().NotBeNullOrEmpty("precondition: AccessKey mode sends a computed authKey");
+ return authKey;
+ }
+
+ private static async Task> CaptureAsync(string marker, Func act)
+ {
+ var logger = new CapturingLogger();
+ using (CERTInextClient.OverrideLoggerForTests(logger))
+ {
+ try { await act(); }
+ catch (Exception) { /* failure-path tests expect a throw; assertions are on the logs */ }
+ }
+ return logger.Entries.Where(e => e.Message != null && e.Message.Contains(marker)).ToList();
+ }
+
+ // ---------------------------------------------------------------------------
+ // PlaceOrderAsync Trace request dump
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task PlaceOrder_TracePayload_FlagOff_OmitsAuthKeyAndPii()
+ {
+ string domain = "po-" + Guid.NewGuid().ToString("N") + ".example.com";
+ StubGenerateOrder(MockCertificateData.GenerateOrderSuccessJson("ORD-REDACT-1"));
+ using var client = BuildClient(logSensitiveRequestData: false);
+
+ var lines = await CaptureAsync(domain, () => client.PlaceOrderAsync(BuildOrder(domain)));
+ string authKey = SentAuthKey();
+
+ var dump = lines.Where(l => l.Message.StartsWith("PlaceOrderAsync request payload")).ToList();
+ dump.Should().ContainSingle();
+ dump[0].Level.Should().Be(LogLevel.Trace);
+ string payload = dump[0].Message;
+
+ payload.Should().NotContain(authKey, "the replayable authKey digest must never be logged");
+ payload.Should().Contain("\"authKey\":\"***REDACTED***\"");
+ payload.Should().NotContain(RequestorName).And.NotContain(RequestorEmail).And.NotContain(RequestorMobile)
+ .And.NotContain(PocEmail).And.NotContain(SignerName).And.NotContain(EmailSan)
+ .And.NotContain(PocFirstName).And.NotContain(PocLastName).And.NotContain(PocMobile)
+ .And.NotContain("\"pocIsdCode\":\"44\"");
+ payload.Should().Contain(MaskedPocEmail).And.Contain("\"pocFirstName\":\"***REDACTED***\"")
+ .And.Contain("\"pocLastName\":\"***REDACTED***\"").And.Contain("\"pocIsdCode\":\"***REDACTED***\"")
+ .And.Contain("\"pocMobileNumber\":\"***REDACTED***\"");
+ payload.Should().Contain(MaskedRequestorEmail).And.Contain(MaskedEmailSan).And.Contain("www." + domain);
+
+ lines.Should().NotContain(l => l.Message.Contains(authKey) || l.Message.Contains(EmailSan) || l.Message.Contains(RequestorEmail),
+ "no client log line for this order may carry the authKey or unmasked email with the flag off");
+ }
+
+ [Fact]
+ public async Task PlaceOrder_TracePayload_FlagOn_IncludesPiiButStillRedactsAuthKey()
+ {
+ string domain = "po-" + Guid.NewGuid().ToString("N") + ".example.com";
+ StubGenerateOrder(MockCertificateData.GenerateOrderSuccessJson("ORD-REDACT-2"));
+ using var client = BuildClient(logSensitiveRequestData: true);
+
+ var lines = await CaptureAsync(domain, () => client.PlaceOrderAsync(BuildOrder(domain)));
+ string authKey = SentAuthKey();
+
+ string payload = lines.Single(l => l.Message.StartsWith("PlaceOrderAsync request payload")).Message;
+
+ payload.Should().NotContain(authKey, "credentials are redacted regardless of LogSensitiveRequestData");
+ payload.Should().Contain("\"authKey\":\"***REDACTED***\"");
+ payload.Should().Contain(RequestorName).And.Contain(RequestorEmail).And.Contain(RequestorMobile)
+ .And.Contain(PocEmail).And.Contain(SignerName).And.Contain(EmailSan)
+ .And.Contain(PocFirstName).And.Contain(PocLastName).And.Contain(PocMobile)
+ .And.Contain("\"pocIsdCode\":\"44\"");
+
+ lines.Should().NotContain(l => l.Message.Contains(authKey));
+ }
+
+ [Fact]
+ public async Task PlaceOrder_SubmittingOrderLine_MasksEmailSanUnlessFlagOn()
+ {
+ string domain = "po-" + Guid.NewGuid().ToString("N") + ".example.com";
+ StubGenerateOrder(MockCertificateData.GenerateOrderSuccessJson("ORD-REDACT-3"));
+
+ using (var off = BuildClient(logSensitiveRequestData: false))
+ {
+ var lines = await CaptureAsync(domain, () => off.PlaceOrderAsync(BuildOrder(domain)));
+ lines.Single(l => l.Message.StartsWith("Submitting order to CERTInext")).Message
+ .Should().Contain(MaskedEmailSan).And.NotContain(EmailSan);
+ }
+
+ using (var on = BuildClient(logSensitiveRequestData: true))
+ {
+ var lines = await CaptureAsync(domain, () => on.PlaceOrderAsync(BuildOrder(domain)));
+ lines.Single(l => l.Message.StartsWith("Submitting order to CERTInext")).Message
+ .Should().Contain(EmailSan);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // LogApiFailure — CA error body echoing request fields
+ // ---------------------------------------------------------------------------
+
+ private static string FailureBodyEchoingRequest(string domain) =>
+ "{\"meta\":{\"status\":\"0\",\"errorCode\":\"EMS-100\",\"errorMessage\":\"Validation failed\"," +
+ "\"authKey\":\"0f1e2d3c4b5a6978synthetic\"}," +
+ "\"orderDetails\":{\"requestorInformation\":{\"requestorName\":\"" + RequestorName + "\",\"requestorEmail\":\"" + RequestorEmail + "\"}," +
+ "\"certificateInformation\":{\"domainName\":\"" + domain + "\",\"additionalDomains\":[\"" + EmailSan + "\"]}}}";
+
+ [Fact]
+ public async Task PlaceOrder_ApiFailureBody_FlagOff_RedactsCredentialsAndPii()
+ {
+ string domain = "fail-" + Guid.NewGuid().ToString("N") + ".example.com";
+ StubGenerateOrder(FailureBodyEchoingRequest(domain));
+ using var client = BuildClient(logSensitiveRequestData: false);
+
+ var lines = await CaptureAsync(domain, () => client.PlaceOrderAsync(BuildOrder(domain)));
+
+ string failure = lines.Single(l => l.Message.StartsWith("CERTInext API non-success")).Message;
+ failure.Should().NotContain("0f1e2d3c4b5a6978synthetic")
+ .And.NotContain(RequestorName).And.NotContain(RequestorEmail).And.NotContain(EmailSan);
+ failure.Should().Contain(MaskedRequestorEmail).And.Contain(MaskedEmailSan).And.Contain("EMS-100");
+ }
+
+ [Fact]
+ public async Task PlaceOrder_ApiFailureBody_FlagOn_KeepsPiiButRedactsCredentials()
+ {
+ string domain = "fail-" + Guid.NewGuid().ToString("N") + ".example.com";
+ StubGenerateOrder(FailureBodyEchoingRequest(domain));
+ using var client = BuildClient(logSensitiveRequestData: true);
+
+ var lines = await CaptureAsync(domain, () => client.PlaceOrderAsync(BuildOrder(domain)));
+
+ string failure = lines.Single(l => l.Message.StartsWith("CERTInext API non-success")).Message;
+ failure.Should().NotContain("0f1e2d3c4b5a6978synthetic");
+ failure.Should().Contain(RequestorName).And.Contain(RequestorEmail).And.Contain(EmailSan);
+ }
+
+ // ---------------------------------------------------------------------------
+ // TrackOrderAsync Trace response dump
+ // ---------------------------------------------------------------------------
+
+ private void StubTrackOrder(string orderNumber) =>
+ _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost())
+ .RespondWith(Response.Create().WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(
+ "{\"meta\":{\"status\":\"1\"},\"orderDetails\":{\"orderNumber\":\"" + orderNumber + "\"," +
+ "\"orderStatusId\":\"1\",\"certificateStatusId\":\"1\"," +
+ "\"requestorInformation\":{\"requestorName\":\"" + RequestorName + "\",\"requestorEmail\":\"" + RequestorEmail + "\"}," +
+ "\"domainVerification\":{\"example.com\":{\"dcvStatus\":\"1\"},\"" + EmailSan + "\":{\"dcvStatus\":\"0\"},\"status\":\"0\"}}}"));
+
+ [Fact]
+ public async Task TrackOrder_TracePayload_FlagOff_OmitsPii()
+ {
+ string order = "ORD-" + Guid.NewGuid().ToString("N");
+ StubTrackOrder(order);
+ using var client = BuildClient(logSensitiveRequestData: false);
+
+ var lines = await CaptureAsync(order, () => client.TrackOrderAsync(order));
+
+ var dump = lines.Where(l => l.Message.StartsWith("TrackOrderAsync response payload")).ToList();
+ dump.Should().ContainSingle();
+ dump[0].Level.Should().Be(LogLevel.Trace);
+ dump[0].Message.Should().NotContain(RequestorName).And.NotContain(RequestorEmail).And.NotContain(EmailSan);
+ dump[0].Message.Should().Contain(MaskedRequestorEmail).And.Contain(MaskedEmailSan);
+ }
+
+ [Fact]
+ public async Task TrackOrder_TracePayload_FlagOn_IncludesPii()
+ {
+ string order = "ORD-" + Guid.NewGuid().ToString("N");
+ StubTrackOrder(order);
+ using var client = BuildClient(logSensitiveRequestData: true);
+
+ var lines = await CaptureAsync(order, () => client.TrackOrderAsync(order));
+
+ lines.Single(l => l.Message.StartsWith("TrackOrderAsync response payload")).Message
+ .Should().Contain(RequestorName).And.Contain(RequestorEmail).And.Contain(EmailSan);
+ }
+ }
+}
diff --git a/CERTInext.Tests/ExtractErrorMessageTests.cs b/CERTInext.Tests/ExtractErrorMessageTests.cs
new file mode 100644
index 0000000..d1477f1
--- /dev/null
+++ b/CERTInext.Tests/ExtractErrorMessageTests.cs
@@ -0,0 +1,81 @@
+// 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 FluentAssertions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///