diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml
index 500c271..487d4c0 100644
--- a/.github/workflows/keyfactor-bootstrap-workflow.yml
+++ b/.github/workflows/keyfactor-bootstrap-workflow.yml
@@ -11,17 +11,9 @@ on:
jobs:
call-starter-workflow:
- uses: keyfactor/actions/.github/workflows/starter.yml@v4
- with:
- command_token_url: ${{ vars.COMMAND_TOKEN_URL }}
- command_hostname: ${{ vars.COMMAND_HOSTNAME }}
- command_base_api_path: ${{ vars.COMMAND_API_PATH }}
+ uses: keyfactor/actions/.github/workflows/starter.yml@v5
secrets:
token: ${{ secrets.V2BUILDTOKEN }}
gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }}
gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }}
scan_token: ${{ secrets.SAST_TOKEN }}
- entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }}
- entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }}
- command_client_id: ${{ secrets.COMMAND_CLIENT_ID }}
- command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }}
diff --git a/.gitignore b/.gitignore
index f920fa6..609bcd8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,7 @@ terraform/terraform.tfvars
# macOS
.DS_Store
+
+# Analysis / scratch — never commit
+analysis/
+
diff --git a/CERTInext.IntegrationTests/AlgorithmMatrixTests.cs b/CERTInext.IntegrationTests/AlgorithmMatrixTests.cs
new file mode 100644
index 0000000..2a8cb2b
--- /dev/null
+++ b/CERTInext.IntegrationTests/AlgorithmMatrixTests.cs
@@ -0,0 +1,181 @@
+// Copyright 2024 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+// At http://www.apache.org/licenses/LICENSE-2.0
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.Pkcs;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Key-algorithm coverage matrix: RSA 2048/3072/4096/6144/8192, ECDSA P-256/P-384/P-521,
+ /// Ed25519, and Ed448 (see ).
+ ///
+ /// Motivation: every other test in the suite hardcoded an RSA-2048 CSR, so only RSA-2048
+ /// certificates were ever exercised end-to-end (and that is all that showed up in Command).
+ /// The plugin takes the CSR as enrollment input and submits it verbatim, so the key
+ /// algorithm is entirely determined by the CSR.
+ ///
+ /// This file is the offline / submission-only layer (no DCV, no issuance):
+ /// 1. — deterministic, no API, always runs. Proves we
+ /// emit a structurally valid, self-consistent PKCS#10 CSR for each algorithm (the public key
+ /// type/size round-trips and the request signature verifies).
+ /// 2. — opt-in (creates real sandbox orders). Proves
+ /// whether CERTInext *accepts* each algorithm at order submission. A CA-side rejection is
+ /// reported as an explicit Skip carrying the CA's own message.
+ ///
+ /// The end-to-end "does CERTInext actually issue this algorithm" matrix (DCV on, one real
+ /// scrup.org cert per type) lives in DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm
+ /// and only exists on the DCV build.
+ ///
+ public class AlgorithmMatrixTests : IClassFixture
+ {
+ /// Set CERTINEXT_ALGO_MATRIX=1 to run the live submission theory (creates real orders).
+ private const string OptInFlag = "CERTINEXT_ALGO_MATRIX";
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ public AlgorithmMatrixTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ public static IEnumerable KeyTypes => KeyAlgorithms.AsMemberData;
+
+ // ---------------------------------------------------------------------------
+ // Layer 1 — deterministic CSR-validity round-trip (no API, always runs)
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Generates a CSR for the given key type, re-parses it, and asserts the public key
+ /// algorithm/size round-trips and the request signature verifies. Fully offline.
+ ///
+ /// Note: RSA-6144 and RSA-8192 key generation is intentionally slow (seconds to tens of
+ /// seconds) — that cost is inherent to large RSA keygen, not the test.
+ ///
+ [Theory]
+ [MemberData(nameof(KeyTypes))]
+ public void Csr_RoundTripsKeyAlgorithm(string tag)
+ {
+ var spec = KeyAlgorithms.For(tag);
+
+ string pem = KeyAlgorithms.GenerateCsrPem($"algo-{KeyAlgorithms.Slug(tag)}.example.com", spec);
+
+ var request = new Pkcs10CertificationRequest(KeyAlgorithms.DerFromPem(pem));
+
+ request.Verify().Should().BeTrue($"the {tag} CSR must be self-signed with a verifiable signature");
+
+ var pub = request.GetPublicKey();
+
+ switch (spec.Kind)
+ {
+ case KeyKind.Rsa:
+ pub.Should().BeOfType();
+ // BouncyCastle generates a modulus of exactly 'Strength' bits (top bit set).
+ ((RsaKeyParameters)pub).Modulus.BitLength.Should().Be(spec.Strength,
+ $"the RSA modulus must be {spec.Strength} bits");
+ break;
+
+ case KeyKind.Ecdsa:
+ pub.Should().BeOfType();
+ ((ECPublicKeyParameters)pub).Parameters.Curve.FieldSize.Should().Be(spec.Strength,
+ $"the EC field size must be {spec.Strength} bits");
+ break;
+
+ case KeyKind.Ed25519:
+ pub.Should().BeOfType();
+ break;
+
+ case KeyKind.Ed448:
+ pub.Should().BeOfType();
+ break;
+ }
+
+ _output.WriteLine($"[OK] {tag}: CSR generated ({pem.Length} chars PEM), signature verified, public key type confirmed.");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Layer 2 — live submission acceptance (opt-in; creates real sandbox orders)
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Submits a real order to CERTInext for each key type and asserts the order is accepted
+ /// (a CARequestID is returned). A CA-side rejection is reported as an explicit Skip carrying
+ /// the CA's own error message — so the suite documents which algorithms CERTInext accepts
+ /// rather than failing on a legitimate CA limitation.
+ ///
+ /// Opt-in: requires CERTINEXT_ALGO_MATRIX=1 because each run creates a real (pending,
+ /// non-issued) DV order on the sandbox account. No DCV is performed, so the orders park at
+ /// EXTERNALVALIDATION and are not cleaned up here. "Accepted at submission" is weaker than
+ /// "will issue" — see DcvLifecycleTests.EnrollWithDcvOn_IssuesPerKeyAlgorithm for the
+ /// end-to-end issuance matrix.
+ ///
+ [SkippableTheory]
+ [MemberData(nameof(KeyTypes))]
+ public async Task Enroll_AcceptsKeyAlgorithm(string tag)
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+ Skip.IfNot(
+ Environment.GetEnvironmentVariable(OptInFlag) == "1",
+ $"Set {OptInFlag}=1 to run the live algorithm-submission matrix (creates real sandbox orders).");
+
+ var spec = KeyAlgorithms.For(tag);
+ string cn = $"algo-{KeyAlgorithms.Slug(tag)}.example.com";
+ string csrPem = KeyAlgorithms.GenerateCsrPem(cn, spec);
+
+ var productInfo = new EnrollmentProductInfo
+ {
+ ProductID = _fixture.ProductCode,
+ ProductParameters = new Dictionary
+ {
+ [Constants.EnrollmentParam.ProfileId] = _fixture.ProductCode,
+ [Constants.EnrollmentParam.ProductCode] = _fixture.ProductCode,
+ [Constants.EnrollmentParam.RequesterName] = _fixture.RequestorName,
+ [Constants.EnrollmentParam.RequesterEmail] = _fixture.RequestorEmail,
+ }
+ };
+
+ var sanDict = new Dictionary { ["DNS"] = new[] { cn } };
+
+ var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config);
+
+ EnrollmentResult enrollResult = null;
+ try
+ {
+ enrollResult = await plugin.Enroll(
+ csrPem,
+ $"CN={cn}",
+ sanDict,
+ productInfo,
+ RequestFormat.PKCS10,
+ EnrollmentType.New);
+ }
+ catch (Exception ex)
+ {
+ // Per agreed scope: a CA-side rejection becomes an explicit Skip carrying the CA's
+ // message (classified so an unsupported algorithm isn't confused with a credit/
+ // account limitation), so the matrix documents real CERTInext support honestly.
+ string reason = KeyAlgorithms.ClassifyRejection(ex.Message);
+ _output.WriteLine($"[SKIP] {tag}: {reason} — {ex.Message}");
+ Skip.If(true, $"CERTInext did not accept a {tag} order: {reason}. CA message: {ex.Message}");
+ }
+
+ enrollResult.Should().NotBeNull($"{tag}: Enroll must return a non-null result when accepted");
+ if (enrollResult == null) return; // satisfies nullable analysis; assertion above already failed
+
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(
+ $"{tag}: a CARequestID must be returned when CERTInext accepts the order");
+
+ _output.WriteLine($"[OK] {tag}: CERTInext accepted the order. CARequestID={enrollResult.CARequestID}");
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
index 91e6472..bd3ec73 100644
--- a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
+++ b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
@@ -6,12 +6,26 @@
12.0
false
true
+
+ false
+ $(DefineConstants);SUPPORTS_DCV
+
+
+
+
+
+
+
@@ -21,6 +35,7 @@
+
diff --git a/CERTInext.IntegrationTests/CloudflareDomainValidator.cs b/CERTInext.IntegrationTests/CloudflareDomainValidator.cs
new file mode 100644
index 0000000..89c01eb
--- /dev/null
+++ b/CERTInext.IntegrationTests/CloudflareDomainValidator.cs
@@ -0,0 +1,129 @@
+// Copyright 2024 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+// At http://www.apache.org/licenses/LICENSE-2.0
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using Keyfactor.AnyGateway.Extensions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// that publishes and removes DNS TXT records via
+ /// the Cloudflare v4 API. Intended for integration tests against a real domain.
+ ///
+ /// Credentials are read from the :
+ /// CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID .
+ ///
+ internal sealed class CloudflareDomainValidator : IDomainValidator
+ {
+ private const string CfApiBase = "https://api.cloudflare.com/client/v4";
+
+ private readonly string _apiToken;
+ private readonly string _zoneId;
+ private readonly HttpClient _http;
+
+ // Maps staging hostname → Cloudflare record ID so CleanupValidation can delete it
+ private readonly ConcurrentDictionary _stagedRecordIds = new();
+
+ public CloudflareDomainValidator(string apiToken, string zoneId)
+ {
+ _apiToken = apiToken ?? throw new ArgumentNullException(nameof(apiToken));
+ _zoneId = zoneId ?? throw new ArgumentNullException(nameof(zoneId));
+
+ _http = new HttpClient();
+ _http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", _apiToken);
+ }
+
+ public void Initialize(IDomainValidatorConfigProvider configProvider) { }
+
+ public async Task StageValidation(string key, string value, CancellationToken cancellationToken)
+ {
+ var payload = new
+ {
+ type = "TXT",
+ name = key,
+ content = value,
+ ttl = 60
+ };
+
+ var response = await _http.PostAsJsonAsync(
+ $"{CfApiBase}/zones/{_zoneId}/dns_records",
+ payload,
+ cancellationToken);
+
+ string body = await response.Content.ReadAsStringAsync(cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ return new DomainValidationResult
+ {
+ Success = false,
+ ErrorMessage = $"Cloudflare API error {(int)response.StatusCode}: {body}"
+ };
+
+ using var doc = JsonDocument.Parse(body);
+ bool success = doc.RootElement.GetProperty("success").GetBoolean();
+ string recordId = success
+ ? doc.RootElement.GetProperty("result").GetProperty("id").GetString()
+ : null;
+
+ if (!success || string.IsNullOrEmpty(recordId))
+ return new DomainValidationResult
+ {
+ Success = false,
+ ErrorMessage = $"Cloudflare record creation failed: {body}"
+ };
+
+ _stagedRecordIds[key] = recordId;
+
+ return new DomainValidationResult { Success = true };
+ }
+
+ public async Task CleanupValidation(string key, CancellationToken cancellationToken)
+ {
+ if (!_stagedRecordIds.TryRemove(key, out string recordId))
+ return new DomainValidationResult { Success = true }; // nothing to clean up
+
+ var response = await _http.DeleteAsync(
+ $"{CfApiBase}/zones/{_zoneId}/dns_records/{recordId}",
+ cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ string body = await response.Content.ReadAsStringAsync(cancellationToken);
+ return new DomainValidationResult
+ {
+ Success = false,
+ ErrorMessage = $"Cloudflare delete error {(int)response.StatusCode}: {body}"
+ };
+ }
+
+ return new DomainValidationResult { Success = true };
+ }
+
+ public Task ValidateConfiguration(Dictionary configuration) => Task.CompletedTask;
+ public Dictionary GetDomainValidatorAnnotations() => new();
+ public string GetValidationType() => "dns-01";
+ }
+
+ internal sealed class CloudflareDomainValidatorFactory : IDomainValidatorFactory
+ {
+ private readonly IDomainValidator _validator;
+
+ public CloudflareDomainValidatorFactory(string apiToken, string zoneId)
+ {
+ _validator = new CloudflareDomainValidator(apiToken, zoneId);
+ }
+
+ public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator;
+ }
+}
diff --git a/CERTInext.IntegrationTests/DcvLifecycleTests.cs b/CERTInext.IntegrationTests/DcvLifecycleTests.cs
new file mode 100644
index 0000000..24ba0f1
--- /dev/null
+++ b/CERTInext.IntegrationTests/DcvLifecycleTests.cs
@@ -0,0 +1,871 @@
+// Copyright 2024 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+// At http://www.apache.org/licenses/LICENSE-2.0
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.PKI.Enums.EJBCA;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Integration tests for the DNS DCV enrollment path.
+ ///
+ /// DNS validator selection:
+ /// • When CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID are set in
+ /// ~/.env_certinext , a is used and
+ /// a real TXT record is published and cleaned up around the enrollment.
+ /// • Otherwise a is used. The plugin still
+ /// exercises the full DCV orchestration path (Stage → propagation wait → VerifyDcv
+ /// → Cleanup), but no real DNS record is published. Whether CERTInext's VerifyDcv
+ /// succeeds in this mode depends on the sandbox environment.
+ ///
+ /// All tests skip when CERTInext credentials are absent ( ).
+ /// Add the following to ~/.env_certinext to run with real DNS:
+ ///
+ /// CERTINEXT_CF_API_TOKEN=<your Cloudflare API token with DNS:Edit>
+ /// CERTINEXT_CF_ZONE_ID=<Cloudflare Zone ID for your test domain>
+ /// CERTINEXT_DCV_DOMAIN=<subdomain to use, e.g. dcv-test.example.com>
+ ///
+ ///
+ public class DcvLifecycleTests : IClassFixture
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ public DcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private static string GenerateCsrPem(string commonName)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
+
+ var subject = new X509Name($"CN={commonName}");
+ var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private);
+
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ private IDomainValidatorFactory BuildDnsFactory() =>
+ _fixture.IsCloudflareConfigured
+ ? (IDomainValidatorFactory)new CloudflareDomainValidatorFactory(
+ _fixture.CloudflareApiToken, _fixture.CloudflareZoneId)
+ : new StubDomainValidatorFactory();
+
+ ///
+ /// Runs plugin.Synchronize and returns every record that came out of the
+ /// blocking buffer. Mirrors the helper in LifecycleTests ; kept local so
+ /// the DCV bulk test isn't coupled to that file's private member.
+ ///
+ private static async Task> RunSyncAsync(CERTInextCAPlugin plugin)
+ {
+ var buffer = new System.Collections.Concurrent.BlockingCollection(boundedCapacity: 10_000);
+ var collected = new List();
+
+ var syncTask = Task.Run(async () =>
+ {
+ await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: System.Threading.CancellationToken.None);
+ if (!buffer.IsAddingCompleted)
+ buffer.CompleteAdding();
+ });
+
+ foreach (var record in buffer.GetConsumingEnumerable())
+ collected.Add(record);
+
+ await syncTask;
+ return collected;
+ }
+
+ private CERTInextCAPlugin BuildPlugin(bool dcvEnabled, int propagationDelaySeconds = 5, int? pageSize = null)
+ {
+ var config = new CERTInextConfig
+ {
+ ApiUrl = _fixture.Config.ApiUrl,
+ AuthMode = _fixture.Config.AuthMode,
+ ApiKey = _fixture.Config.ApiKey,
+ AccountNumber = _fixture.Config.AccountNumber,
+ GroupNumber = _fixture.Config.GroupNumber,
+ OrganizationNumber = _fixture.Config.OrganizationNumber,
+ RequestorName = _fixture.Config.RequestorName,
+ RequestorEmail = _fixture.Config.RequestorEmail,
+ RequestorIsdCode = _fixture.Config.RequestorIsdCode,
+ RequestorMobileNumber = _fixture.Config.RequestorMobileNumber,
+ SignerPlace = _fixture.Config.SignerPlace,
+ SignerIp = _fixture.Config.SignerIp,
+ DefaultProductCode = _fixture.Config.DefaultProductCode,
+ PageSize = pageSize ?? _fixture.Config.PageSize,
+ DcvEnabled = dcvEnabled,
+ DcvPropagationDelaySeconds = propagationDelaySeconds,
+ DcvTimeoutMinutes = 3
+ };
+
+ return new CERTInextCAPlugin(_fixture.Client, BuildDnsFactory(), config);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Tests
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Enroll with DCV enabled. Uses a real Cloudflare DNS record when CF credentials
+ /// are configured, otherwise uses .
+ ///
+ /// The test verifies that the plugin completes without throwing. The enrollment
+ /// result status depends on whether the CERTInext sandbox auto-issues after DCV.
+ ///
+ [SkippableFact]
+ public async Task DcvEnroll_CompletesWithoutThrowing()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ var plugin = BuildPlugin(dcvEnabled: true);
+
+ var result = await plugin.Enroll(
+ csr: GenerateCsrPem(IntegrationTestData.DcvTestDomain),
+ subject: $"CN={IntegrationTestData.DcvTestDomain}",
+ san: new Dictionary
+ {
+ ["dns"] = new[] { IntegrationTestData.DcvTestDomain }
+ },
+ productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Should().NotBeNull();
+ _output.WriteLine($"Domain: {IntegrationTestData.DcvTestDomain}");
+ _output.WriteLine($"CARequestID: {result.CARequestID}");
+ _output.WriteLine($"Status: {result.Status}");
+ _output.WriteLine($"Message: {result.StatusMessage}");
+
+ if (_fixture.IsCloudflareConfigured)
+ {
+ // With real DNS, CERTInext should be able to verify — assert issuance or pending
+ new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION }
+ .Should().Contain(result.Status,
+ "enrollment with real DNS DCV should produce a valid terminal or pending status");
+ }
+ else
+ {
+ // Without real DNS the VerifyDcv may fail; we only assert no unhandled exception
+ // was thrown (the Enroll method handles the error gracefully).
+ result.Should().NotBeNull("enrollment should return a result even when stub DNS is used");
+ }
+ }
+
+ ///
+ /// Enroll without DCV enabled — verifies the plugin skips the DCV path entirely
+ /// and returns a result from the normal enrollment flow.
+ ///
+ [SkippableFact]
+ public async Task EnrollWithoutDcv_DoesNotInvokeDnsProvider()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ // Use a plugin backed by the real client but DcvEnabled=false
+ var plugin = BuildPlugin(dcvEnabled: false);
+
+ var result = await plugin.Enroll(
+ csr: GenerateCsrPem(IntegrationTestData.DcvTestDomain),
+ subject: $"CN={IntegrationTestData.DcvTestDomain}",
+ san: new Dictionary
+ {
+ ["dns"] = new[] { IntegrationTestData.DcvTestDomain }
+ },
+ productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Should().NotBeNull();
+ }
+
+ ///
+ /// End-to-end "DCV mode off" scenario, mirroring how a v3.2 gateway host would
+ /// experience the plugin (no IDomainValidatorFactory available, so DCV silently
+ /// no-ops). Enrolls a fresh domain with DcvEnabled=false, then runs the plugin's
+ /// own Synchronize and asserts the order surfaces in pending-DCV state.
+ /// This is the live verification for GitHub issue #7.
+ ///
+ /// The CERTInext side may auto-issue some orders very quickly thanks to cached
+ /// DCV for previously-validated parent domains; this test uses a freshly random
+ /// subdomain to minimize that but tolerates either pending or issued in the
+ /// assertion (the real signal we want is "the plugin did not invoke DCV").
+ ///
+ [SkippableFact]
+ public async Task EnrollWithDcvOff_OrderAppearsInSync_PluginDidNotInvokeDcv()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ // Generate a unique CN so prior cached-DCV state on the parent zone doesn't
+ // bias the result.
+ string suffix = System.Guid.NewGuid().ToString("N").Substring(0, 8);
+ string cn = $"dcv-off-{suffix}.scrup.org";
+
+ // Plugin built with DCV disabled. BuildPlugin still wires a Cloudflare or stub
+ // factory but PerformDcvIfNeededAsync gates on _config.DcvEnabled so neither
+ // factory will be touched on this Enroll path.
+ var plugin = BuildPlugin(dcvEnabled: false);
+
+ // --- Enroll phase ---
+ var enrollSw = System.Diagnostics.Stopwatch.StartNew();
+ var enrollResult = await plugin.Enroll(
+ csr: GenerateCsrPem(cn),
+ subject: $"CN={cn}",
+ san: new Dictionary { ["dns"] = new[] { cn } },
+ productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ enrollSw.Stop();
+
+ enrollResult.Should().NotBeNull();
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace(
+ "the CA must accept the order even with DCV off — DCV-off ≠ no enrollment");
+
+ _output.WriteLine($"Enroll completed in {enrollSw.Elapsed:mm\\:ss\\.fff}");
+ _output.WriteLine($" CARequestID: {enrollResult.CARequestID}");
+ _output.WriteLine($" Status: {enrollResult.Status}");
+ _output.WriteLine($" Message: {enrollResult.StatusMessage}");
+
+ // The plugin's "DCV off" contract: with DcvEnabled=false the plugin does NOT
+ // wait for issuance. Even if CERTInext later auto-issues from cached DCV, the
+ // immediate Enroll response should be pending (no issuance polling ran).
+ // We allow GENERATED too because cached DCV on the parent zone could plausibly
+ // make CERTInext mark the order issued before its first reply — but the most
+ // common case is EXTERNALVALIDATION.
+ new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED }
+ .Should().Contain(enrollResult.Status,
+ $"DCV-off Enroll must return a recognizable terminal/pending state; got {enrollResult.Status}");
+
+ // --- Sync phase: pull the whole account, find our order ---
+ var syncSw = System.Diagnostics.Stopwatch.StartNew();
+ var synced = await RunSyncAsync(plugin);
+ syncSw.Stop();
+ _output.WriteLine($"Synchronize returned {synced.Count} records in {syncSw.Elapsed:mm\\:ss\\.fff}");
+
+ var record = synced.FirstOrDefault(r => r.CARequestID == enrollResult.CARequestID);
+ record.Should().NotBeNull(
+ $"the enrolled order ({enrollResult.CARequestID}) must appear in plugin.Synchronize results");
+ _output.WriteLine($" Sync record status: {record!.Status}");
+
+ // Final shape assertion: order is in the inventory, and its status is either
+ // pending (EXTERNALVALIDATION — typical when CERTInext hasn't moved it yet)
+ // or issued (GENERATED — if CERTInext autoissued from cached DCV). It must
+ // NOT be FAILED — DCV-off should not produce a failed cert.
+ new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED }
+ .Should().Contain(record.Status,
+ "the synced record must reflect either pending or issued — never FAILED with DCV off");
+
+ // Surface the human-readable summary so the live behavior is visible in the
+ // test output without needing to grep the gateway logs.
+ _output.WriteLine($"--- Verdict: DCV-off enroll for {cn} succeeded, plugin did not invoke DCV, " +
+ $"order {enrollResult.CARequestID} surfaced in sync with Status={record.Status}. ---");
+ }
+
+ ///
+ /// Symmetric counterpart to .
+ /// Drives a fresh enrollment with DCV ON end-to-end against the live sandbox and
+ /// asserts the issued cert flows through Synchronize. This is the v3.3+
+ /// production scenario — plugin places the order, runs DNS TXT staging via
+ /// Cloudflare, asks CERTInext to verify, waits for issuance, and the resulting
+ /// GENERATED record surfaces in the gateway's inventory.
+ ///
+ [SkippableFact]
+ public async Task EnrollWithDcvOn_OrderIssuedEndToEnd_AndAppearsInSync()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+ Skip.If(!_fixture.IsCloudflareConfigured,
+ "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — DCV-on test must publish real TXT records.");
+
+ string suffix = System.Guid.NewGuid().ToString("N").Substring(0, 8);
+ string cn = $"dcv-on-{suffix}.scrup.org";
+
+ var plugin = BuildPlugin(dcvEnabled: true);
+
+ // --- Enroll phase ---
+ var enrollSw = System.Diagnostics.Stopwatch.StartNew();
+ var enrollResult = await plugin.Enroll(
+ csr: GenerateCsrPem(cn),
+ subject: $"CN={cn}",
+ san: new Dictionary { ["dns"] = new[] { cn } },
+ productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ enrollSw.Stop();
+
+ enrollResult.Should().NotBeNull();
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace();
+ _output.WriteLine($"Enroll completed in {enrollSw.Elapsed:mm\\:ss\\.fff}");
+ _output.WriteLine($" CARequestID: {enrollResult.CARequestID}");
+ _output.WriteLine($" Status: {enrollResult.Status}");
+ _output.WriteLine($" Certificate: {(string.IsNullOrWhiteSpace(enrollResult.Certificate) ? "(not in Enroll response)" : enrollResult.Certificate[..60] + "...")}");
+
+ // Enroll must NOT be FAILED. GENERATED if the bounded issuance wait caught
+ // the cert before returning; EXTERNALVALIDATION if not — sync will catch it.
+ new[] { (int)EndEntityStatus.EXTERNALVALIDATION, (int)EndEntityStatus.GENERATED }
+ .Should().Contain(enrollResult.Status,
+ $"DCV-on Enroll must return pending or issued; got {enrollResult.Status}");
+
+ // --- Sync phase ---
+ var syncSw = System.Diagnostics.Stopwatch.StartNew();
+ var synced = await RunSyncAsync(plugin);
+ syncSw.Stop();
+ _output.WriteLine($"Synchronize returned {synced.Count} records in {syncSw.Elapsed:mm\\:ss\\.fff}");
+
+ var record = synced.FirstOrDefault(r => r.CARequestID == enrollResult.CARequestID);
+ record.Should().NotBeNull(
+ $"the enrolled order ({enrollResult.CARequestID}) must appear in plugin.Synchronize results");
+ _output.WriteLine($" Sync record status: {record!.Status}");
+ _output.WriteLine($" Cert PEM length: {(record.Certificate?.Length ?? 0)}");
+
+ // The plugin's sync-DCV-retry should have advanced any still-pending orders.
+ // With Cloudflare DCV available, every DCV-on enrollment should resolve to
+ // GENERATED by the time sync returns. If we see EXTERNALVALIDATION here it
+ // means CERTInext's async issuance window is still in flight after our sync —
+ // worth noting but not a hard failure (the next sync will pick it up).
+ record.Status.Should().BeOneOf((int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION);
+
+ // Issue 0001: Synchronize now materialises the PEM for issued certs.
+ // ListCertificatesAsync returns order-report metadata (no body), so the plugin
+ // refetches the full certificate for GENERATED/REVOKED records during sync.
+ if (record.Status == (int)EndEntityStatus.GENERATED)
+ {
+ record.Certificate.Should().NotBeNullOrWhiteSpace(
+ "Synchronize must populate the cert body for issued orders (issue 0001) — " +
+ "the order-report listing carries none, so the plugin refetches it.");
+
+ // GetSingleRecord is the same on-demand fetch the gateway uses for inventory.
+ var fetched = await plugin.GetSingleRecord(enrollResult.CARequestID);
+ fetched.Should().NotBeNull();
+ fetched.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ fetched.Certificate.Should().NotBeNullOrWhiteSpace(
+ "GetSingleRecord must populate the PEM for a GENERATED order.");
+ _output.WriteLine($" Sync cert PEM length: {record.Certificate!.Length}; " +
+ $"GetSingleRecord PEM length: {fetched.Certificate!.Length}");
+ }
+
+ _output.WriteLine($"--- Verdict: DCV-on enroll for {cn} drove DCV end-to-end via plugin, " +
+ $"order {enrollResult.CARequestID} surfaced in sync with Status={record.Status}. ---");
+ }
+
+ ///
+ /// End-to-end key-algorithm issuance matrix: RSA 2048/3072/4096/6144/8192, ECDSA
+ /// P-256/P-384/P-521, Ed25519, Ed448 (see ). For each type,
+ /// enroll a fresh scrup.org DV order with DCV ON, drive it to issuance via the plugin
+ /// (Cloudflare TXT publish → VerifyDcv → bounded sync passes), and assert the issued cert
+ /// carries a parseable body whose public key matches the requested algorithm.
+ ///
+ /// An algorithm CERTInext won't issue — rejected at submission, FAILED, or never reaching
+ /// GENERATED within the polling window — is reported as an explicit Skip carrying the
+ /// observed reason, so the matrix documents which algorithms CERTInext actually issues
+ /// without hard-failing on a legitimate CA limitation.
+ ///
+ /// Opt-in (issues a real cert per accepted algorithm): set CERTINEXT_ALGO_MATRIX_DCV=1 .
+ /// Requires Cloudflare DCV credentials.
+ ///
+ [SkippableTheory]
+ [MemberData(nameof(KeyAlgorithms.AsMemberData), MemberType = typeof(KeyAlgorithms))]
+ public async Task EnrollWithDcvOn_IssuesPerKeyAlgorithm(string tag)
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+ Skip.If(System.Environment.GetEnvironmentVariable("CERTINEXT_ALGO_MATRIX_DCV") != "1",
+ "Opt-in: set CERTINEXT_ALGO_MATRIX_DCV=1 to issue one real scrup.org cert per key algorithm.");
+ Skip.If(!_fixture.IsCloudflareConfigured,
+ "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — DCV issuance must publish real TXT records.");
+
+ var spec = KeyAlgorithms.For(tag);
+ string suffix = System.Guid.NewGuid().ToString("N").Substring(0, 8);
+ string cn = $"algo-{KeyAlgorithms.Slug(tag)}-{suffix}.scrup.org";
+ string csr = KeyAlgorithms.GenerateCsrPem(cn, spec);
+
+ var plugin = BuildPlugin(dcvEnabled: true);
+
+ // --- Enroll. A submission-time rejection (unsupported algorithm) → Skip with the CA's reason. ---
+ EnrollmentResult enrollResult;
+ try
+ {
+ enrollResult = await plugin.Enroll(
+ csr: csr,
+ subject: $"CN={cn}",
+ san: new Dictionary { ["dns"] = new[] { cn } },
+ productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ }
+ catch (Exception ex)
+ {
+ string reason = KeyAlgorithms.ClassifyRejection(ex.Message);
+ _output.WriteLine($"[SKIP] {tag}: {reason} — {ex.Message}");
+ Skip.If(true, $"CERTInext did not issue a {tag} cert: {reason}. CA message: {ex.Message}");
+ return; // unreachable — Skip throws
+ }
+
+ enrollResult.Should().NotBeNull();
+ enrollResult.CARequestID.Should().NotBeNullOrWhiteSpace($"{tag}: CA must return a CARequestID when it accepts the order");
+ _output.WriteLine($"[{tag}] enrolled cn={cn} id={enrollResult.CARequestID} status={enrollResult.Status}");
+
+ // --- Poll this one order to issuance via GetSingleRecord (targeted; avoids the
+ // full-account sync, which would also drive DCV on unrelated pending orders). ---
+ const int maxPolls = 6;
+ const int delaySeconds = 15;
+ AnyCAPluginCertificate record = null;
+ for (int poll = 1; poll <= maxPolls; poll++)
+ {
+ record = await plugin.GetSingleRecord(enrollResult.CARequestID);
+ int status = record?.Status ?? -1;
+ _output.WriteLine($"[{tag}] poll #{poll}: status={status} certLen={record?.Certificate?.Length ?? 0}");
+
+ // Wait for GENERATED *with a materialized body*. CERTInext flips status to
+ // GENERATED a beat before GetCertificate returns the PEM, so an order that
+ // issues quickly can report GENERATED with an empty body for a poll or two.
+ if (status == (int)EndEntityStatus.GENERATED && !string.IsNullOrWhiteSpace(record?.Certificate))
+ break;
+ if (status == (int)EndEntityStatus.FAILED)
+ {
+ _output.WriteLine($"[SKIP] {tag}: order {enrollResult.CARequestID} went FAILED — CERTInext will not issue this algorithm.");
+ Skip.If(true, $"CERTInext FAILED the {tag} order — algorithm not issuable on this account/profile.");
+ return;
+ }
+ if (poll < maxPolls)
+ await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
+ }
+
+ record.Should().NotBeNull($"{tag}: enrolled order {enrollResult.CARequestID} must be retrievable");
+
+ if (record!.Status != (int)EndEntityStatus.GENERATED)
+ {
+ // Accepted at submission but not issued within the window — document as Skip, not fail.
+ _output.WriteLine($"[SKIP] {tag}: order {enrollResult.CARequestID} still Status={record.Status} after {maxPolls} polls.");
+ Skip.If(true, $"CERTInext accepted the {tag} order but it did not reach GENERATED within the polling window " +
+ $"(Status={record.Status}) — possible unsupported algorithm or slow server-side validation.");
+ return;
+ }
+
+ record.Certificate.Should().NotBeNullOrWhiteSpace(
+ $"{tag}: issued cert must carry a PEM body (issue 0001)");
+
+ // Strong check: the issued cert's public key must match the algorithm we requested.
+ AssertIssuedCertMatchesAlgorithm(record.Certificate, spec, tag);
+
+ _output.WriteLine($"--- {tag}: DCV-on issuance OK — order {enrollResult.CARequestID} GENERATED, " +
+ $"cert public key confirmed as {tag}. ---");
+ }
+
+ ///
+ /// Parses an issued certificate PEM and asserts its public key matches the requested
+ /// algorithm/size — proves CERTInext issued the key type we submitted, not a substitute.
+ ///
+ private static void AssertIssuedCertMatchesAlgorithm(string certPem, KeyAlgorithmSpec spec, string tag)
+ {
+ var b64 = certPem
+ .Replace("-----BEGIN CERTIFICATE-----", string.Empty)
+ .Replace("-----END CERTIFICATE-----", string.Empty)
+ .Replace("\r", string.Empty).Replace("\n", string.Empty).Trim();
+
+ var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(Convert.FromBase64String(b64));
+ cert.Should().NotBeNull($"{tag}: issued cert PEM must parse");
+
+ var pub = cert.GetPublicKey();
+ switch (spec.Kind)
+ {
+ case KeyKind.Rsa:
+ pub.Should().BeOfType();
+ ((RsaKeyParameters)pub).Modulus.BitLength.Should().Be(spec.Strength,
+ $"{tag}: issued RSA cert must have a {spec.Strength}-bit modulus");
+ break;
+ case KeyKind.Ecdsa:
+ pub.Should().BeOfType();
+ ((ECPublicKeyParameters)pub).Parameters.Curve.FieldSize.Should().Be(spec.Strength,
+ $"{tag}: issued EC cert must use a {spec.Strength}-bit curve");
+ break;
+ case KeyKind.Ed25519:
+ pub.Should().BeOfType();
+ break;
+ case KeyKind.Ed448:
+ pub.Should().BeOfType();
+ break;
+ }
+ }
+
+ ///
+ /// Exercises the deferred-DCV retry path during single-record refresh against an
+ /// existing pending order. Reads CERTINEXT_PENDING_ORDER_ID from the
+ /// environment; the test is skipped if not set, since this scenario requires a
+ /// real order that CERTInext has parked at Pending System RA with
+ /// dcvStatus=0 after the initial enrollment.
+ ///
+ /// On success, GetSingleRecord drives DCV (Cloudflare TXT publish →
+ /// CERTInext VerifyDcv → wait for verification → cleanup) and returns either an
+ /// issued record ( ) or a still-pending
+ /// record if CERTInext has not finished server-side validation yet.
+ ///
+ [SkippableFact]
+ public async Task GetSingleRecord_DrivesDcvForPendingOrder()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ string orderId = System.Environment.GetEnvironmentVariable("CERTINEXT_PENDING_ORDER_ID");
+ Skip.If(string.IsNullOrWhiteSpace(orderId),
+ "Set CERTINEXT_PENDING_ORDER_ID to a real pending-DCV order to run this test.");
+
+ Skip.If(!_fixture.IsCloudflareConfigured,
+ "CERTINEXT_CF_API_TOKEN and CERTINEXT_CF_ZONE_ID must be set so the plugin " +
+ "can publish a real TXT record for CERTInext to verify.");
+
+ // DCV must be enabled and a real DNS provider must be wired up — otherwise the
+ // sync-retry helper short-circuits with no effect.
+ var plugin = BuildPlugin(dcvEnabled: true);
+
+ var record = await plugin.GetSingleRecord(orderId);
+
+ record.Should().NotBeNull();
+ _output.WriteLine($"CARequestID: {record.CARequestID}");
+ _output.WriteLine($"Status: {record.Status}");
+ _output.WriteLine($"Certificate: {(string.IsNullOrWhiteSpace(record.Certificate) ? "(not yet issued)" : record.Certificate[..60] + "...")}");
+
+ // We assert no unhandled exception was thrown and a record came back. The exact
+ // final status is environment-dependent (CERTInext may still be working through
+ // VerifyDcv even after the plugin returns), so we accept either GENERATED or
+ // a still-pending EXTERNALVALIDATION status here — the regression we're guarding
+ // against is the silent no-op the plugin used to do on this path.
+ new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION }
+ .Should().Contain(record.Status,
+ "deferred-DCV retry should leave the order in a valid pending or issued state");
+ }
+
+ ///
+ /// Volume / pagination smoke test — enrolls a configurable number of DV orders
+ /// concurrently (default 101) against fresh unique subdomains, then runs
+ /// plugin.Synchronize with the connector's PageSize=100 to verify
+ /// (a) every order issued, (b) every order shows up in sync, and (c) the sync
+ /// iterator correctly crosses the 100-record page boundary in
+ /// ListCertificatesAsync .
+ ///
+ /// This is an opt-in test because it places real CA orders and takes several
+ /// minutes. Set CERTINEXT_RUN_BULK_TEST=1 in the environment to run.
+ /// Override the count with CERTINEXT_BULK_TEST_COUNT (default 101) and
+ /// the concurrency cap with CERTINEXT_BULK_TEST_PARALLEL (default 5).
+ ///
+ [SkippableFact]
+ public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+ Skip.If(System.Environment.GetEnvironmentVariable("CERTINEXT_RUN_BULK_TEST") != "1",
+ "Opt-in: set CERTINEXT_RUN_BULK_TEST=1 to run the volume/pagination test.");
+ Skip.If(!_fixture.IsCloudflareConfigured,
+ "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — bulk test must publish real TXT records.");
+
+ int count = int.TryParse(System.Environment.GetEnvironmentVariable("CERTINEXT_BULK_TEST_COUNT"), out int c)
+ ? c : 101;
+ int parallel = int.TryParse(System.Environment.GetEnvironmentVariable("CERTINEXT_BULK_TEST_PARALLEL"), out int p)
+ ? p : 5;
+
+ // PageSize=100 ensures the 101st order forces a second page during Synchronize.
+ var plugin = BuildPlugin(dcvEnabled: true, propagationDelaySeconds: 5, pageSize: 100);
+
+ // --- Phase 1: bounded-parallel enrollments ---
+ var enrolled = new System.Collections.Concurrent.ConcurrentBag<(int idx, string cn, EnrollmentResult result)>();
+ var failures = new System.Collections.Concurrent.ConcurrentBag<(int idx, string cn, string error)>();
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+
+ using (var sem = new System.Threading.SemaphoreSlim(parallel, parallel))
+ {
+ var tasks = Enumerable.Range(0, count).Select(async i =>
+ {
+ await sem.WaitAsync();
+ try
+ {
+ // Unique CN per order — uses Guid hex prefix so reruns don't collide.
+ string suffix = Guid.NewGuid().ToString("N").Substring(0, 8);
+ string cn = $"bulk-{suffix}.scrup.org";
+ string csr = GenerateCsrPem(cn);
+
+ var result = await plugin.Enroll(
+ csr: csr,
+ subject: $"CN={cn}",
+ san: new Dictionary { ["dns"] = new[] { cn } },
+ productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ enrolled.Add((i, cn, result));
+ _output.WriteLine($"[{i:000}] OK cn={cn} id={result.CARequestID} status={result.Status}");
+ }
+ catch (Exception ex)
+ {
+ failures.Add((i, $"#{i}", ex.Message));
+ _output.WriteLine($"[{i:000}] FAIL {ex.GetType().Name}: {ex.Message}");
+ }
+ finally
+ {
+ sem.Release();
+ }
+ });
+ await Task.WhenAll(tasks);
+ }
+
+ sw.Stop();
+ _output.WriteLine($"--- Enroll phase: enrolled={enrolled.Count}, failed={failures.Count}, elapsed={sw.Elapsed:mm\\:ss} ---");
+
+ failures.Should().BeEmpty(
+ "every Enroll() call must succeed (the plugin's EMS-956 tolerance means even pending DCV returns gracefully); " +
+ $"got {failures.Count} hard failures.");
+ enrolled.Count.Should().Be(count, $"expected {count} successful Enroll() calls");
+
+ var enrolledIds = enrolled
+ .Where(e => !string.IsNullOrEmpty(e.result.CARequestID))
+ .Select(e => e.result.CARequestID)
+ .ToHashSet();
+ enrolledIds.Count.Should().Be(count, "every enrollment must return a CARequestID");
+
+ // --- Phase 2: Synchronize until every enrolled order reaches GENERATED ---
+ //
+ // CERTInext's pipeline is async: VerifyDcv triggers a server-side DNS-01 check
+ // and certificate generation that completes a few seconds *after* the plugin's
+ // Enroll() returns. A single Synchronize captures whatever state CERTInext has
+ // settled at that exact moment, so a chunk of orders typically remain at
+ // EXTERNALVALIDATION on the first pass. The sync-driven DCV retry in the plugin
+ // handles staggered completion across subsequent gateway sync cycles — so this
+ // test mimics that by running Synchronize repeatedly until either all 101 are
+ // GENERATED or a bounded number of attempts is exhausted.
+ const int maxSyncPasses = 8;
+ const int delayBetweenPassesSeconds = 30;
+
+ List synced = null;
+ System.Diagnostics.Stopwatch syncPhaseSw = System.Diagnostics.Stopwatch.StartNew();
+ int passesUsed = 0;
+ int finalNotIssued = -1;
+
+ for (int pass = 1; pass <= maxSyncPasses; pass++)
+ {
+ passesUsed = pass;
+ var passSw = System.Diagnostics.Stopwatch.StartNew();
+ synced = await RunSyncAsync(plugin);
+ passSw.Stop();
+
+ int generated = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.GENERATED);
+ int pending = enrolledIds.Count - generated;
+ finalNotIssued = pending;
+
+ _output.WriteLine(
+ $"--- Sync pass #{pass}: returned {synced.Count} records, {generated}/{enrolledIds.Count} GENERATED, " +
+ $"{pending} still pending, elapsed={passSw.Elapsed:mm\\:ss} ---");
+
+ if (pending == 0)
+ break;
+
+ if (pass < maxSyncPasses)
+ {
+ _output.WriteLine($" Waiting {delayBetweenPassesSeconds}s before next sync pass…");
+ await Task.Delay(TimeSpan.FromSeconds(delayBetweenPassesSeconds));
+ }
+ }
+ syncPhaseSw.Stop();
+
+ // Pagination check — sync must have returned strictly more than one page.
+ synced!.Count.Should().BeGreaterThan(100,
+ "with 101 freshly-enrolled orders + any pre-existing, sync must return >100 records " +
+ "to prove the ListCertificatesAsync paginator crossed PageSize=100.");
+
+ // Every enrolled CARequestID must show up.
+ var syncedIds = synced.Select(r => r.CARequestID).ToHashSet();
+ var missing = enrolledIds.Where(id => !syncedIds.Contains(id)).ToList();
+ missing.Should().BeEmpty(
+ $"{missing.Count} enrolled orders did not appear in sync results: " +
+ $"{string.Join(", ", missing.Take(5))}{(missing.Count > 5 ? ", ..." : "")}");
+
+ // Final assertion — every enrolled order must be GENERATED after the polling window.
+ var lookup = synced.ToDictionary(r => r.CARequestID, r => r);
+ var notIssued = enrolledIds
+ .Select(id => lookup[id])
+ .Where(r => r.Status != (int)EndEntityStatus.GENERATED)
+ .ToList();
+
+ if (notIssued.Count > 0)
+ {
+ _output.WriteLine($"--- After {passesUsed} sync passes, {notIssued.Count} order(s) still not GENERATED: ---");
+ foreach (var r in notIssued.Take(10))
+ _output.WriteLine($" {r.CARequestID} Status={r.Status}");
+ }
+
+ notIssued.Should().BeEmpty(
+ $"every enrolled DV order should auto-issue on the new sandbox after {maxSyncPasses} sync passes; " +
+ $"{notIssued.Count} did not (last pass: {finalNotIssued} pending).");
+
+ _output.WriteLine($"--- SUCCESS: {count}/{count} DV orders enrolled, synced, and issued in {passesUsed} sync pass(es). " +
+ $"Enroll={sw.Elapsed:mm\\:ss} SyncPhase={syncPhaseSw.Elapsed:mm\\:ss} Total={(sw.Elapsed + syncPhaseSw.Elapsed):mm\\:ss} ---");
+ }
+
+ ///
+ /// Operational task: drive every existing pending-DV order to completion.
+ ///
+ /// Unlike , this enrolls
+ /// nothing — it just runs the plugin's full Synchronize with DCV enabled, which
+ /// invokes TryRunDcvDuringSyncAsync for every order sitting at
+ /// (Cloudflare TXT publish → VerifyDcv →
+ /// wait → cleanup). It repeats the sync until no order remains pending or the pass budget
+ /// is exhausted, reporting which orders transitioned to .
+ ///
+ /// Opt-in (it mutates real CA orders and publishes real DNS records): set
+ /// CERTINEXT_COMPLETE_PENDING=1 . Requires Cloudflare DCV credentials.
+ ///
+ [SkippableFact]
+ public async Task CompleteAllPendingDvOrders()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+ Skip.If(System.Environment.GetEnvironmentVariable("CERTINEXT_COMPLETE_PENDING") != "1",
+ "Opt-in: set CERTINEXT_COMPLETE_PENDING=1 to drive all pending DV orders to completion.");
+ Skip.If(!_fixture.IsCloudflareConfigured,
+ "CERTINEXT_CF_API_TOKEN + CERTINEXT_CF_ZONE_ID required — completing DCV must publish real TXT records.");
+
+ var plugin = BuildPlugin(dcvEnabled: true);
+
+ const int maxSyncPasses = 8;
+ const int delayBetweenPassesSeconds = 30;
+
+ List synced = null;
+ int passesUsed = 0;
+ var phaseSw = System.Diagnostics.Stopwatch.StartNew();
+
+ for (int pass = 1; pass <= maxSyncPasses; pass++)
+ {
+ passesUsed = pass;
+ var passSw = System.Diagnostics.Stopwatch.StartNew();
+ synced = await RunSyncAsync(plugin);
+ passSw.Stop();
+
+ var pending = synced.Where(r => r.Status == (int)EndEntityStatus.EXTERNALVALIDATION).ToList();
+ int generated = synced.Count(r => r.Status == (int)EndEntityStatus.GENERATED);
+
+ _output.WriteLine(
+ $"--- Sync pass #{pass}: {synced.Count} records, {generated} GENERATED, " +
+ $"{pending.Count} still pending DV, elapsed={passSw.Elapsed:mm\\:ss} ---");
+ foreach (var r in pending.Take(20))
+ _output.WriteLine($" pending: {r.CARequestID}");
+
+ if (pending.Count == 0)
+ break;
+
+ if (pass < maxSyncPasses)
+ {
+ _output.WriteLine($" Waiting {delayBetweenPassesSeconds}s before next sync pass…");
+ await Task.Delay(TimeSpan.FromSeconds(delayBetweenPassesSeconds));
+ }
+ }
+ phaseSw.Stop();
+
+ synced.Should().NotBeNull("Synchronize must have run at least once");
+ var stillPending = synced!.Where(r => r.Status == (int)EndEntityStatus.EXTERNALVALIDATION).ToList();
+
+ _output.WriteLine(
+ $"--- Done after {passesUsed} pass(es) in {phaseSw.Elapsed:mm\\:ss}: " +
+ $"{synced!.Count(r => r.Status == (int)EndEntityStatus.GENERATED)} GENERATED, " +
+ $"{stillPending.Count} still pending DV. ---");
+
+ // Orders may legitimately remain pending if CERTInext is still working server-side or
+ // a domain isn't in the configured Cloudflare zone — surface that rather than failing.
+ stillPending.Should().BeEmpty(
+ $"all pending DV orders should reach GENERATED after {maxSyncPasses} passes; " +
+ $"{stillPending.Count} remain (e.g. {string.Join(", ", stillPending.Take(5).Select(r => r.CARequestID))}). " +
+ "These likely have domains outside the configured Cloudflare zone or are still validating server-side.");
+ }
+
+ // Regression for issue 0001 — a full Synchronize must return every issued cert WITH
+ // its PEM body. The order-report listing carries no body, so the plugin must refetch
+ // the full certificate; before the fix, issued certs synced with a null body and
+ // never appeared in Command. This is the end-to-end "issued certs fill in" check.
+ [SkippableFact]
+ public async Task FullSync_AllIssuedCerts_CarryParseableCertificateBody()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ var plugin = BuildPlugin(dcvEnabled: false);
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ var synced = await RunSyncAsync(plugin);
+ sw.Stop();
+
+ var issued = synced.Where(r => r.Status == (int)EndEntityStatus.GENERATED).ToList();
+ _output.WriteLine(
+ $"Synchronize returned {synced.Count} records in {sw.Elapsed:mm\\:ss} ({issued.Count} GENERATED).");
+
+ issued.Should().NotBeEmpty(
+ "the account has known issued certs (e.g. scrup.org) that a full sync must surface");
+
+ var parser = new Org.BouncyCastle.X509.X509CertificateParser();
+ var bad = new System.Collections.Generic.List();
+ foreach (var r in issued)
+ {
+ if (string.IsNullOrWhiteSpace(r.Certificate))
+ {
+ bad.Add($"{r.CARequestID} (empty body)");
+ continue;
+ }
+ try
+ {
+ var b64 = r.Certificate
+ .Replace("-----BEGIN CERTIFICATE-----", string.Empty)
+ .Replace("-----END CERTIFICATE-----", string.Empty)
+ .Replace("\r", string.Empty).Replace("\n", string.Empty).Trim();
+ if (parser.ReadCertificate(Convert.FromBase64String(b64)) == null)
+ bad.Add($"{r.CARequestID} (unparseable)");
+ }
+ catch (Exception ex)
+ {
+ bad.Add($"{r.CARequestID} ({ex.GetType().Name})");
+ }
+ }
+
+ bad.Should().BeEmpty(
+ "every issued cert must carry a parseable certificate body after sync; " +
+ $"offenders: {string.Join(", ", bad.Take(10))}");
+ _output.WriteLine($"--- Verdict: all {issued.Count} issued certs carry a valid certificate body. ---");
+ }
+ }
+
+ ///
+ /// Shared test data for DCV integration tests.
+ ///
+ internal static class IntegrationTestData
+ {
+ ///
+ /// Domain used for DCV tests. Override via CERTINEXT_DCV_DOMAIN in
+ /// ~/.env_certinext .
+ ///
+ public static string DcvTestDomain =>
+ System.Environment.GetEnvironmentVariable("CERTINEXT_DCV_DOMAIN")
+ ?? "dcv-test.example.com";
+
+ public static EnrollmentProductInfo DvSslProductInfo(string productCode = null) =>
+ new EnrollmentProductInfo
+ {
+ ProductID = productCode ?? Constants.Products.DvSsl,
+ ProductParameters = new Dictionary
+ {
+ ["ProfileId"] = productCode ?? Constants.Products.DvSsl,
+ ["ValidityYears"] = "1"
+ }
+ };
+ }
+}
diff --git a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
index c57d0f5..441f573 100644
--- a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
+++ b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
@@ -8,7 +8,7 @@ so the project is safe to include in CI pipelines that do not have API access.
## Prerequisites
-- .NET 8 SDK
+- .NET 8 or .NET 10 SDK
- Access to a CERTInext account (sandbox or production)
- An API Access Key generated in the CERTInext portal under **Integrations → APIs**
@@ -119,7 +119,6 @@ pipeline failure.
| Test | What it checks |
|------|---------------|
| `GetOrderReport_ReturnsOrders` | Fetches page 1; asserts at least one order is returned |
-| `GetOrderReport_ContainsKnownDraftOrder` | Fetches all pages; asserts requestNumber `4572531551` (DV SSL 838 draft) is present |
| `GetOrderReport_AllOrders_HaveRequiredFields` | For each order on page 1: `requestNumber`, `productCode`, and `orderDate` are non-empty |
### `PluginSmokeTests`
@@ -162,4 +161,3 @@ never transmitted over the wire — only the derived `authKey` hash is sent.
| `Ping` fails with 401 | Wrong `CERTINEXT_ACCESS_KEY` | Regenerate the key in the CERTInext portal |
| `Ping` fails with timeout | Wrong `CERTINEXT_API_URL` | Verify the URL matches your account region |
| `GetOrderReport` returns 0 orders | Account has no orders | Place a test order first (see `make generate-order` in the project Makefile) |
-| `ContainsKnownDraftOrder` fails | Draft order `4572531551` not on this account | Update `KnownDraftRequestNumber` in `OrderReportTests.cs` to a request number from your account |
diff --git a/CERTInext.IntegrationTests/IntegrationTestFixture.cs b/CERTInext.IntegrationTests/IntegrationTestFixture.cs
index 8147730..8e4f637 100644
--- a/CERTInext.IntegrationTests/IntegrationTestFixture.cs
+++ b/CERTInext.IntegrationTests/IntegrationTestFixture.cs
@@ -33,6 +33,22 @@ public sealed class IntegrationTestFixture : IDisposable
public string RequestorEmail { get; }
public string RequestorName { get; }
+ // ---------------------------------------------------------------------------
+ // Cloudflare DCV credentials (optional)
+ // ---------------------------------------------------------------------------
+
+ /// Cloudflare API token with DNS:Edit permission on .
+ public string CloudflareApiToken { get; }
+
+ /// Cloudflare Zone ID for the domain used in DCV integration tests.
+ public string CloudflareZoneId { get; }
+
+ ///
+ /// True when Cloudflare credentials are present, enabling real DNS DCV tests.
+ /// When false, DCV integration tests fall back to a .
+ ///
+ public bool IsCloudflareConfigured { get; }
+
///
/// True when at minimum ApiUrl and AccessKey are both non-empty,
/// indicating that live credential configuration is present.
@@ -67,6 +83,12 @@ public IntegrationTestFixture()
var env = LoadEnvFile(envPath);
+ // Promote env-file values into the process environment so that any code
+ // calling System.Environment.GetEnvironmentVariable() picks them up.
+ foreach (var kv in env)
+ if (System.Environment.GetEnvironmentVariable(kv.Key) == null)
+ System.Environment.SetEnvironmentVariable(kv.Key, kv.Value);
+
ApiUrl = GetEnvValue(env, "CERTINEXT_API_URL");
AccessKey = GetEnvValue(env, "CERTINEXT_ACCESS_KEY");
AccountNumber = GetEnvValue(env, "CERTINEXT_ACCOUNT_NUMBER");
@@ -76,6 +98,11 @@ public IntegrationTestFixture()
RequestorEmail = GetEnvValue(env, "CERTINEXT_REQUESTOR_EMAIL");
RequestorName = GetEnvValue(env, "CERTINEXT_REQUESTOR_NAME");
+ CloudflareApiToken = GetEnvValue(env, "CERTINEXT_CF_API_TOKEN");
+ CloudflareZoneId = GetEnvValue(env, "CERTINEXT_CF_ZONE_ID");
+ IsCloudflareConfigured = !string.IsNullOrWhiteSpace(CloudflareApiToken) &&
+ !string.IsNullOrWhiteSpace(CloudflareZoneId);
+
IsConfigured = !string.IsNullOrWhiteSpace(ApiUrl) &&
!string.IsNullOrWhiteSpace(AccessKey);
@@ -88,6 +115,7 @@ public IntegrationTestFixture()
ApiKey = AccessKey,
AccountNumber = AccountNumber,
GroupNumber = GroupNumber,
+ OrganizationNumber = OrgNumber,
RequestorName = string.IsNullOrWhiteSpace(RequestorName)
? "Keyfactor Integration Test"
: RequestorName,
@@ -131,7 +159,7 @@ private static Dictionary LoadEnvFile(string path)
continue;
string key = line.Substring(0, idx).Trim();
- string val = line.Substring(idx + 1).Trim();
+ string val = ParseEnvValue(line.Substring(idx + 1));
result[key] = val;
}
}
@@ -148,6 +176,28 @@ private static Dictionary LoadEnvFile(string path)
return result;
}
+ ///
+ /// Parses a raw value from a KEY=VALUE env-file line: trims surrounding
+ /// whitespace, then strips a single pair of matching surrounding double or single
+ /// quotes if present. Without quote stripping a line like
+ /// CERTINEXT_REQUESTOR_NAME="Keyfactor Plugin Test" would parse as the 24-char
+ /// literal "Keyfactor Plugin Test" (quotes included), diverging from any
+ /// other shell-style env consumer reading the same file. See GitHub issue #8.
+ /// Exposed internal for direct unit-testing.
+ ///
+ internal static string ParseEnvValue(string rawValue)
+ {
+ if (rawValue is null) return string.Empty;
+ string val = rawValue.Trim();
+ if (val.Length >= 2 &&
+ ((val[0] == '"' && val[val.Length - 1] == '"') ||
+ (val[0] == '\'' && val[val.Length - 1] == '\'')))
+ {
+ val = val.Substring(1, val.Length - 2);
+ }
+ return val;
+ }
+
private static string GetEnvValue(Dictionary env, string key)
{
return env.TryGetValue(key, out string val) ? val : string.Empty;
diff --git a/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs b/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs
new file mode 100644
index 0000000..1db8470
--- /dev/null
+++ b/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs
@@ -0,0 +1,53 @@
+// Copyright 2024 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+// 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 Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Pure unit tests (no live-API dependency) for the env-file parser used by
+ /// . See GitHub issue #8 — without quote
+ /// stripping, a shell-style quoted line was being parsed with the quote characters
+ /// included in the value.
+ ///
+ public class IntegrationTestFixtureTests
+ {
+ [Theory]
+ [InlineData("plain", "plain")]
+ [InlineData(" plain ", "plain")]
+ [InlineData("\"Keyfactor Plugin Test\"", "Keyfactor Plugin Test")]
+ [InlineData(" \"Keyfactor Plugin Test\" ", "Keyfactor Plugin Test")]
+ [InlineData("'single quoted'", "single quoted")]
+ [InlineData("\"\"", "")] // empty quoted string
+ [InlineData("''", "")] // empty single-quoted
+ [InlineData("\"un-paired'", "\"un-paired'")] // mismatched quotes — leave alone
+ [InlineData("\"", "\"")] // single naked quote, length<2 after trim — leave alone
+ [InlineData("", "")]
+ [InlineData(" ", "")]
+ public void ParseEnvValue_HandlesQuotingAndWhitespace(string input, string expected)
+ {
+ IntegrationTestFixture.ParseEnvValue(input).Should().Be(expected);
+ }
+
+ [Fact]
+ public void ParseEnvValue_NullInput_ReturnsEmptyString()
+ {
+ IntegrationTestFixture.ParseEnvValue(null).Should().Be(string.Empty);
+ }
+
+ [Fact]
+ public void ParseEnvValue_DoesNotStripEmbeddedQuotes()
+ {
+ // Quotes in the middle of the value must NOT be stripped; only matching
+ // outer wrappers count.
+ IntegrationTestFixture.ParseEnvValue("foo\"bar\"baz")
+ .Should().Be("foo\"bar\"baz");
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/KeyAlgorithms.cs b/CERTInext.IntegrationTests/KeyAlgorithms.cs
new file mode 100644
index 0000000..6f2489b
--- /dev/null
+++ b/CERTInext.IntegrationTests/KeyAlgorithms.cs
@@ -0,0 +1,137 @@
+// Copyright 2024 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+// At http://www.apache.org/licenses/LICENSE-2.0
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Org.BouncyCastle.Asn1;
+using Org.BouncyCastle.Asn1.Sec;
+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;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ internal enum KeyKind { Rsa, Ecdsa, Ed25519, Ed448 }
+
+ /// One row of the key-algorithm coverage matrix.
+ internal sealed class KeyAlgorithmSpec
+ {
+ public string Tag; // stable, human-readable id ("RSA-2048", "ECDSA-P256", ...)
+ public KeyKind Kind;
+ public int Strength; // RSA modulus bits, or EC field size in bits (informational for Ed)
+ public string SignatureAlgorithm; // BouncyCastle signature-algorithm name used to sign the CSR
+ public DerObjectIdentifier CurveOid; // EC named-curve OID (null for non-EC)
+ }
+
+ ///
+ /// Shared key-algorithm matrix + BouncyCastle CSR generation, used by both the offline
+ /// submission/round-trip tests (AlgorithmMatrixTests ) and the live DCV-issuance
+ /// theory (DcvLifecycleTests ). BouncyCastle only — never BCL crypto.
+ ///
+ /// Hash pairing follows the CA/Browser Forum Baseline Requirements: P-256→SHA256,
+ /// P-384→SHA384, P-521→SHA512.
+ ///
+ internal static class KeyAlgorithms
+ {
+ public static readonly KeyAlgorithmSpec[] All =
+ {
+ new() { Tag = "RSA-2048", Kind = KeyKind.Rsa, Strength = 2048, SignatureAlgorithm = "SHA256withRSA" },
+ new() { Tag = "RSA-3072", Kind = KeyKind.Rsa, Strength = 3072, SignatureAlgorithm = "SHA256withRSA" },
+ new() { Tag = "RSA-4096", Kind = KeyKind.Rsa, Strength = 4096, SignatureAlgorithm = "SHA256withRSA" },
+ new() { Tag = "RSA-6144", Kind = KeyKind.Rsa, Strength = 6144, SignatureAlgorithm = "SHA256withRSA" },
+ new() { Tag = "RSA-8192", Kind = KeyKind.Rsa, Strength = 8192, SignatureAlgorithm = "SHA256withRSA" },
+ new() { Tag = "ECDSA-P256", Kind = KeyKind.Ecdsa, Strength = 256, SignatureAlgorithm = "SHA256withECDSA", CurveOid = SecObjectIdentifiers.SecP256r1 },
+ new() { Tag = "ECDSA-P384", Kind = KeyKind.Ecdsa, Strength = 384, SignatureAlgorithm = "SHA384withECDSA", CurveOid = SecObjectIdentifiers.SecP384r1 },
+ new() { Tag = "ECDSA-P521", Kind = KeyKind.Ecdsa, Strength = 521, SignatureAlgorithm = "SHA512withECDSA", CurveOid = SecObjectIdentifiers.SecP521r1 },
+ new() { Tag = "Ed25519", Kind = KeyKind.Ed25519, Strength = 256, SignatureAlgorithm = "Ed25519" },
+ new() { Tag = "Ed448", Kind = KeyKind.Ed448, Strength = 448, SignatureAlgorithm = "Ed448" },
+ };
+
+ public static KeyAlgorithmSpec For(string tag) => All.Single(s => s.Tag == tag);
+
+ /// xUnit member-data source — one row per key type, keyed by its stable tag.
+ public static IEnumerable AsMemberData => All.Select(s => new object[] { s.Tag });
+
+ public static AsymmetricCipherKeyPair GenerateKeyPair(KeyAlgorithmSpec spec)
+ {
+ switch (spec.Kind)
+ {
+ case KeyKind.Rsa:
+ {
+ var gen = new RsaKeyPairGenerator();
+ gen.Init(new KeyGenerationParameters(new SecureRandom(), spec.Strength));
+ return gen.GenerateKeyPair();
+ }
+ case KeyKind.Ecdsa:
+ {
+ var gen = new ECKeyPairGenerator("ECDSA");
+ gen.Init(new ECKeyGenerationParameters(spec.CurveOid, new SecureRandom()));
+ return gen.GenerateKeyPair();
+ }
+ case KeyKind.Ed25519:
+ {
+ var gen = new Ed25519KeyPairGenerator();
+ gen.Init(new Ed25519KeyGenerationParameters(new SecureRandom()));
+ return gen.GenerateKeyPair();
+ }
+ case KeyKind.Ed448:
+ {
+ var gen = new Ed448KeyPairGenerator();
+ gen.Init(new Ed448KeyGenerationParameters(new SecureRandom()));
+ return gen.GenerateKeyPair();
+ }
+ default:
+ throw new ArgumentOutOfRangeException(nameof(spec), spec.Kind, "unhandled key kind");
+ }
+ }
+
+ public static string GenerateCsrPem(string commonName, KeyAlgorithmSpec spec)
+ {
+ var keyPair = GenerateKeyPair(spec);
+ var subject = new X509Name($"CN={commonName}");
+ var csr = new Pkcs10CertificationRequest(spec.SignatureAlgorithm, subject, keyPair.Public, null, keyPair.Private);
+
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ /// Strips PEM armor and returns the DER bytes of a CSR.
+ public static byte[] DerFromPem(string pem)
+ {
+ var b64 = pem
+ .Replace("-----BEGIN CERTIFICATE REQUEST-----", string.Empty)
+ .Replace("-----END CERTIFICATE REQUEST-----", string.Empty)
+ .Replace("\r", string.Empty)
+ .Replace("\n", string.Empty)
+ .Trim();
+ return Convert.FromBase64String(b64);
+ }
+
+ /// A filesystem/DNS-safe slug for a tag, e.g. "ECDSA-P256" → "ecdsap256".
+ public static string Slug(string tag) => tag.ToLowerInvariant().Replace("-", string.Empty);
+
+ ///
+ /// Classifies a CERTInext order-rejection message so the algorithm matrix doesn't
+ /// conflate "this key algorithm is unsupported" with "the account can't place orders
+ /// right now". CERTInext's live envelope (observed): RSA 2048/3072/4096 + ECC P-256/P-384
+ /// are accepted; larger RSA, P-521, and the Ed* curves return "Invalid key size" /
+ /// "Something went Wrong". A credit shortfall returns "Insufficient Credits" regardless
+ /// of algorithm.
+ ///
+ public static string ClassifyRejection(string caMessage)
+ {
+ caMessage ??= string.Empty;
+ if (caMessage.IndexOf("Invalid key size", StringComparison.OrdinalIgnoreCase) >= 0)
+ return "key algorithm/size not supported by CERTInext";
+ if (caMessage.IndexOf("Insufficient Credits", StringComparison.OrdinalIgnoreCase) >= 0)
+ return "CERTInext account is out of credits — algorithm support was not exercised";
+ return "rejected by CERTInext";
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/LifecycleTests.cs b/CERTInext.IntegrationTests/LifecycleTests.cs
index d58bade..185ff64 100644
--- a/CERTInext.IntegrationTests/LifecycleTests.cs
+++ b/CERTInext.IntegrationTests/LifecycleTests.cs
@@ -6,14 +6,18 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
-using System.Security.Cryptography;
-using System.Security.Cryptography.X509Certificates;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Keyfactor.AnyGateway.Extensions;
using Keyfactor.PKI.Enums.EJBCA;
using Xunit;
+using Xunit.Abstractions;
namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
{
@@ -34,10 +38,12 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
public class LifecycleTests : IClassFixture
{
private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
- public LifecycleTests(IntegrationTestFixture fixture)
+ public LifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
+ _output = output;
}
// ---------------------------------------------------------------------------
@@ -60,22 +66,15 @@ private CERTInextCAPlugin BuildPlugin()
///
private static string GenerateCsrPem(string commonName)
{
- using var rsa = RSA.Create(2048);
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
- var certReq = new CertificateRequest(
- $"CN={commonName}",
- rsa,
- HashAlgorithmName.SHA256,
- RSASignaturePadding.Pkcs1);
-
- var sanBuilder = new SubjectAlternativeNameBuilder();
- sanBuilder.AddDnsName(commonName);
- certReq.CertificateExtensions.Add(sanBuilder.Build());
-
- byte[] csrDer = certReq.CreateSigningRequest();
+ var subject = new X509Name($"CN={commonName}");
+ var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private);
return "-----BEGIN CERTIFICATE REQUEST-----\n"
- + Convert.ToBase64String(csrDer, Base64FormattingOptions.InsertLineBreaks)
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ "\n-----END CERTIFICATE REQUEST-----";
}
@@ -237,5 +236,6 @@ await revokeAct.Should().NotThrowAsync(
(int)EndEntityStatus.REVOKED,
"Revoke must return the REVOKED status code on success");
}
+
}
}
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/SmokeTests.cs b/CERTInext.IntegrationTests/SmokeTests.cs
new file mode 100644
index 0000000..8817413
--- /dev/null
+++ b/CERTInext.IntegrationTests/SmokeTests.cs
@@ -0,0 +1,199 @@
+// Copyright 2024 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+// At http://www.apache.org/licenses/LICENSE-2.0
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Basic smoke tests — one operation per test, no side effects.
+ /// These verify the API is reachable and returning sensible data without
+ /// creating or modifying any orders.
+ ///
+ /// All tests skip when CERTInext credentials are absent ( ).
+ ///
+ public class SmokeTests : IClassFixture
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ public SmokeTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ [SkippableFact]
+ public async Task Ping_Succeeds()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ await _fixture.Client.Invoking(c => c.PingAsync())
+ .Should().NotThrowAsync("credentials should be valid and API should be reachable");
+ }
+
+ [SkippableFact]
+ public async Task GetProductDetails_ReturnsProducts()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ var products = await _fixture.Client.GetProductDetailsAsync();
+
+ products.Should().NotBeNullOrEmpty("account must have at least one product configured");
+
+ foreach (var p in products)
+ _output.WriteLine($" ProductCode={p.ProductCode} Name={p.ProductName} Type={p.ProductType}");
+ }
+
+ [SkippableFact]
+ public async Task ListOrders_ReturnsFirstPage()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ var orders = new List();
+
+ await foreach (var entry in _fixture.Client.ListOrdersAsync(pageSize: 10))
+ {
+ orders.Add(entry);
+ if (orders.Count >= 10) break;
+ }
+
+ orders.Should().NotBeEmpty("sandbox account should have at least one order");
+
+ _output.WriteLine($"Returned {orders.Count} orders (capped at 10):");
+ foreach (var o in orders)
+ _output.WriteLine($" OrderNumber={o.OrderNumber} Domain={o.DomainName} Status={o.CertificateStatus} Expiry={o.CertificateExpiryDate}");
+ }
+
+ [SkippableFact]
+ public async Task TrackOrder_ReturnsDetails()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ string orderId = System.Environment.GetEnvironmentVariable("CERTINEXT_ORDER_ID");
+ Skip.If(string.IsNullOrWhiteSpace(orderId),
+ "Set CERTINEXT_ORDER_ID in ~/.env_certinext to run this test.");
+
+ var response = await _fixture.Client.TrackOrderAsync(orderId);
+
+ response.Should().NotBeNull();
+ response.OrderDetails.Should().NotBeNull();
+
+ var od = response.OrderDetails;
+ _output.WriteLine($"OrderNumber: {orderId}");
+ _output.WriteLine($"OrderStatus: {od.OrderStatus} (id={od.OrderStatusId})");
+ _output.WriteLine($"CertificateStatus: {od.CertificateStatus} (id={od.CertificateStatusId})");
+ _output.WriteLine($"CertificateExpiry: {od.CertificateExpiryDate}");
+ _output.WriteLine($"TrackingUrl: {od.TrackingUrl}");
+
+ if (od.DomainVerification != null)
+ {
+ foreach (var kv in od.DomainVerification.GetDomainEntries())
+ _output.WriteLine($" Domain [{kv.Key}]: dcvMethod={kv.Value.DcvMethod} dcvStatus={kv.Value.DcvStatus} verifiedDate={kv.Value.VerifiedDate}");
+ }
+ }
+
+ [SkippableFact]
+ public async Task GetSingleRecord_ReturnsRecord()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ string orderId = System.Environment.GetEnvironmentVariable("CERTINEXT_ORDER_ID");
+ Skip.If(string.IsNullOrWhiteSpace(orderId),
+ "Set CERTINEXT_ORDER_ID in ~/.env_certinext to run this test.");
+
+ var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config);
+ var record = await plugin.GetSingleRecord(orderId);
+
+ record.Should().NotBeNull();
+
+ _output.WriteLine($"CARequestID: {record.CARequestID}");
+ _output.WriteLine($"Status: {record.Status}");
+ _output.WriteLine($"Certificate: {(string.IsNullOrWhiteSpace(record.Certificate) ? "(not yet issued)" : record.Certificate[..60] + "...")}");
+ }
+
+ ///
+ /// Exercises against every order
+ /// returned by ListOrdersAsync . Validates that the per-order plugin
+ /// code path (TrackOrder → GetCertificate → AnyCAPluginCertificate mapping)
+ /// succeeds for every order on the account, regardless of certificate status.
+ ///
+ [SkippableFact]
+ public async Task GetSingleRecord_ForAllOrders_AllSucceed()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config);
+
+ var orderNumbers = new List();
+ await foreach (var entry in _fixture.Client.ListOrdersAsync())
+ {
+ if (!string.IsNullOrWhiteSpace(entry.OrderNumber))
+ orderNumbers.Add(entry.OrderNumber);
+ }
+
+ orderNumbers.Should().NotBeEmpty("sandbox account should have at least one order");
+ _output.WriteLine($"Calling GetSingleRecord for {orderNumbers.Count} order(s):");
+
+ var failures = new List<(string Order, string Error)>();
+ foreach (var orderId in orderNumbers)
+ {
+ try
+ {
+ var record = await plugin.GetSingleRecord(orderId);
+ string certPreview = string.IsNullOrWhiteSpace(record.Certificate)
+ ? "(none)"
+ : $"{record.Certificate.Length} chars";
+ _output.WriteLine($" [OK] Order={orderId} Status={record.Status} Cert={certPreview}");
+ }
+ catch (Exception ex)
+ {
+ failures.Add((orderId, ex.Message));
+ _output.WriteLine($" [FAIL] Order={orderId} Error={ex.Message}");
+ }
+ }
+
+ failures.Should().BeEmpty(
+ $"every order's GetSingleRecord call should succeed; {failures.Count} failed: " +
+ string.Join("; ", failures.Select(f => $"{f.Order}={f.Error}")));
+ }
+
+ [SkippableFact]
+ public async Task Synchronize_DumpsAllRecords()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+
+ var plugin = new CERTInextCAPlugin(_fixture.Client, _fixture.Config);
+
+ var records = new List();
+ var blockingCollection = new System.Collections.Concurrent.BlockingCollection();
+
+ var syncTask = plugin.Synchronize(blockingCollection, lastSync: null, fullSync: true, cancelToken: default);
+ var collectTask = Task.Run(() =>
+ {
+ foreach (var r in blockingCollection.GetConsumingEnumerable())
+ records.Add(r);
+ });
+
+ await syncTask;
+ blockingCollection.CompleteAdding();
+ await collectTask;
+
+ records.Should().NotBeEmpty("sandbox account should have at least one order");
+
+ _output.WriteLine($"Synchronized {records.Count} records:");
+ foreach (var r in records.Take(20))
+ _output.WriteLine($" CARequestID={r.CARequestID} Status={r.Status}");
+
+ if (records.Count > 20)
+ _output.WriteLine($" ... and {records.Count - 20} more");
+ }
+ }
+}
diff --git a/CERTInext.IntegrationTests/StubDomainValidator.cs b/CERTInext.IntegrationTests/StubDomainValidator.cs
new file mode 100644
index 0000000..2493021
--- /dev/null
+++ b/CERTInext.IntegrationTests/StubDomainValidator.cs
@@ -0,0 +1,37 @@
+// Copyright 2024 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+// At http://www.apache.org/licenses/LICENSE-2.0
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Keyfactor.AnyGateway.Extensions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// No-op DNS validator used when Cloudflare credentials are not available.
+ /// Records are not actually published; DCV verification by CERTInext may or may
+ /// not succeed depending on whether the sandbox enforces real DNS lookups.
+ ///
+ internal sealed class StubDomainValidator : IDomainValidator
+ {
+ public void Initialize(IDomainValidatorConfigProvider configProvider) { }
+
+ public Task StageValidation(string key, string value, CancellationToken cancellationToken) =>
+ Task.FromResult(new DomainValidationResult { Success = true });
+
+ public Task CleanupValidation(string key, CancellationToken cancellationToken) =>
+ Task.FromResult(new DomainValidationResult { Success = true });
+
+ public Task ValidateConfiguration(Dictionary configuration) => Task.CompletedTask;
+ public Dictionary GetDomainValidatorAnnotations() => new();
+ public string GetValidationType() => "dns-01";
+ }
+
+ internal sealed class StubDomainValidatorFactory : IDomainValidatorFactory
+ {
+ private readonly IDomainValidator _validator = new StubDomainValidator();
+ public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator;
+ }
+}
diff --git a/CERTInext.IntegrationTests/TESTING.md b/CERTInext.IntegrationTests/TESTING.md
index 21e4de1..b961130 100644
--- a/CERTInext.IntegrationTests/TESTING.md
+++ b/CERTInext.IntegrationTests/TESTING.md
@@ -41,7 +41,7 @@ which ones return a `requestNumber` (valid) vs. an error (invalid or not provisi
## Prerequisites
-- .NET 8 SDK
+- .NET 8 or .NET 10 SDK
- Access to a CERTInext sandbox or production account
- An API Access Key generated in the CERTInext portal under **Integrations → APIs**
diff --git a/CERTInext.Tests/BoundedDcvSyncTests.cs b/CERTInext.Tests/BoundedDcvSyncTests.cs
new file mode 100644
index 0000000..96b4e3b
--- /dev/null
+++ b/CERTInext.Tests/BoundedDcvSyncTests.cs
@@ -0,0 +1,124 @@
+using System;
+using FluentAssertions;
+using Xunit;
+using static Keyfactor.Extensions.CAPlugin.CERTInext.CERTInextCAPlugin;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Issue 0002 — unit tests for the DCV-during-sync gate (EvaluateDcvSyncEligibility).
+ /// Pure decision logic that bounds DCV work per sync pass so a large pending backlog
+ /// can't make a pass slow. No DCV machinery / network needed.
+ ///
+ public class BoundedDcvSyncTests
+ {
+ private static readonly DateTime Now = new DateTime(2026, 6, 10, 12, 0, 0, DateTimeKind.Utc);
+
+ // --- Age window ---------------------------------------------------------
+
+ [Fact]
+ public void RecentOrder_WithinAgeWindow_IsAttempted()
+ {
+ var orderDate = Now.AddHours(-1); // 1h old, window 24h
+ EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 24, attemptedSoFar: 0, perPassCap: 50)
+ .Should().Be(DcvSyncDecision.Attempt);
+ }
+
+ [Fact]
+ public void OldOrder_BeyondAgeWindow_IsSkippedByAge()
+ {
+ var orderDate = Now.AddHours(-48); // 48h old, window 24h
+ EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 24, attemptedSoFar: 0, perPassCap: 50)
+ .Should().Be(DcvSyncDecision.SkipByAge);
+ }
+
+ [Fact]
+ public void OrderExactlyAtAgeBoundary_IsAttempted()
+ {
+ var orderDate = Now.AddHours(-24); // exactly 24h, window 24h → still eligible (<=)
+ EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 24, attemptedSoFar: 0, perPassCap: 50)
+ .Should().Be(DcvSyncDecision.Attempt);
+ }
+
+ [Fact]
+ public void UnknownOrderDate_IsAttempted_NotStarved()
+ {
+ EvaluateDcvSyncEligibility(orderDateUtc: null, Now, ageWindowHours: 24, attemptedSoFar: 0, perPassCap: 50)
+ .Should().Be(DcvSyncDecision.Attempt);
+ }
+
+ [Fact]
+ public void AgeWindowDisabled_OldOrderStillAttempted()
+ {
+ var orderDate = Now.AddDays(-30);
+ EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 0, attemptedSoFar: 0, perPassCap: 50)
+ .Should().Be(DcvSyncDecision.Attempt);
+ }
+
+ // --- Per-pass cap -------------------------------------------------------
+
+ [Fact]
+ public void UnderCap_IsAttempted()
+ {
+ EvaluateDcvSyncEligibility(Now, Now, ageWindowHours: 24, attemptedSoFar: 4, perPassCap: 5)
+ .Should().Be(DcvSyncDecision.Attempt);
+ }
+
+ [Fact]
+ public void AtCap_IsSkippedByCap()
+ {
+ EvaluateDcvSyncEligibility(Now, Now, ageWindowHours: 24, attemptedSoFar: 5, perPassCap: 5)
+ .Should().Be(DcvSyncDecision.SkipByCap);
+ }
+
+ [Fact]
+ public void CapDisabled_AlwaysAttemptedRegardlessOfCount()
+ {
+ EvaluateDcvSyncEligibility(Now, Now, ageWindowHours: 24, attemptedSoFar: 10_000, perPassCap: 0)
+ .Should().Be(DcvSyncDecision.Attempt);
+ }
+
+ // --- Precedence ---------------------------------------------------------
+
+ [Fact]
+ public void AgeSkip_TakesPrecedenceOverCap()
+ {
+ // Old order AND at cap → reported as age skip (age checked first).
+ var orderDate = Now.AddHours(-48);
+ EvaluateDcvSyncEligibility(orderDate, Now, ageWindowHours: 24, attemptedSoFar: 5, perPassCap: 5)
+ .Should().Be(DcvSyncDecision.SkipByAge);
+ }
+
+ // --- Simulated pass: a backlog of old + a few recent, with a small cap ---
+
+ [Fact]
+ public void SimulatedPass_OnlyRecentOrdersAttempted_AndCapped()
+ {
+ // 100 old (out-of-window) + 10 recent; cap 5. Mirrors the Synchronize loop's
+ // use of the gate: only recent orders are eligible, and at most `cap` are attempted.
+ const int ageWindow = 24, cap = 5;
+ int attempted = 0, skippedAge = 0, skippedCap = 0;
+
+ for (int i = 0; i < 100; i++) // old backlog
+ Tally(EvaluateDcvSyncEligibility(Now.AddHours(-48), Now, ageWindow, attempted, cap),
+ ref attempted, ref skippedAge, ref skippedCap);
+ for (int i = 0; i < 10; i++) // recent
+ Tally(EvaluateDcvSyncEligibility(Now.AddMinutes(-5), Now, ageWindow, attempted, cap),
+ ref attempted, ref skippedAge, ref skippedCap);
+
+ attempted.Should().Be(5, "only up to the cap of recent orders are attempted");
+ skippedAge.Should().Be(100, "the entire old backlog is skipped by the age window");
+ skippedCap.Should().Be(5, "recent orders beyond the cap are deferred to a later pass");
+ }
+
+ private static void Tally(DcvSyncDecision d, ref int attempted, ref int skippedAge, ref int skippedCap)
+ {
+ switch (d)
+ {
+ case DcvSyncDecision.Attempt: attempted++; break;
+ case DcvSyncDecision.SkipByAge: skippedAge++; break;
+ case DcvSyncDecision.SkipByCap: skippedCap++; break;
+ }
+ }
+ }
+}
diff --git a/CERTInext.Tests/CERTInext.Tests.csproj b/CERTInext.Tests/CERTInext.Tests.csproj
index 39aed9d..84ce7a6 100644
--- a/CERTInext.Tests/CERTInext.Tests.csproj
+++ b/CERTInext.Tests/CERTInext.Tests.csproj
@@ -6,12 +6,24 @@
12.0
false
true
+
+ false
+ $(DefineConstants);SUPPORTS_DCV
+
+
+
+
+
+
diff --git a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs
index b949293..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.
@@ -772,7 +826,7 @@ await plugin.Enroll(
enrollmentType: EnrollmentType.New);
capturedRequest.Should().NotBeNull();
- capturedRequest.ValidityDays.Should().Be(365);
+ capturedRequest!.ValidityDays.Should().Be(365);
capturedRequest.RequesterName.Should().Be("Jane Smith");
capturedRequest.RequesterEmail.Should().Be("jane@example.com");
capturedRequest.KeyType.Should().Be("RSA2048");
@@ -811,7 +865,7 @@ await plugin.Enroll(
capturedRequest.Should().NotBeNull();
// ValidityDays == 0 when parse fails, so request should have null
- capturedRequest.ValidityDays.Should().BeNull(
+ capturedRequest!.ValidityDays.Should().BeNull(
"invalid ValidityDays should fall back to null (use profile default)");
}
@@ -883,7 +937,7 @@ await plugin.Enroll(
enrollmentType: EnrollmentType.New);
capturedRequest.Should().NotBeNull();
- capturedRequest.Sans.Should().NotBeNull();
+ capturedRequest!.Sans.Should().NotBeNull();
capturedRequest.Sans.Should().Contain(s => s.Type == "oid",
"unknown SAN type should be passed through as-is");
}
diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs
new file mode 100644
index 0000000..b45f0ac
--- /dev/null
+++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs
@@ -0,0 +1,1335 @@
+// Copyright 2024 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+// At http://www.apache.org/licenses/LICENSE-2.0
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Moq;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Unit tests for the DCV orchestration path inside
+ /// /
+ /// .
+ ///
+ /// All external dependencies (CERTInext client, DNS validator) are stubbed so
+ /// no network calls are made. Propagation delay is set to 0 so tests run fast.
+ ///
+ public class CERTInextCAPluginDcvTests
+ {
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private static CERTInextConfig DcvConfig(
+ bool enabled = true,
+ int propagationDelaySeconds = 1,
+ int timeoutMinutes = 1,
+ int dcvWaitForChallengeSeconds = 0,
+ int dcvWaitForIssuanceSeconds = 0,
+ int pickupRetries = 0) =>
+ new CERTInextConfig
+ {
+ DcvEnabled = enabled,
+ DcvPropagationDelaySeconds = propagationDelaySeconds,
+ DcvTimeoutMinutes = timeoutMinutes,
+ // Default to 0 so existing tests preserve the pre-polling single-check
+ // 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,
+ // 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() =>
+ new Mock(MockBehavior.Strict);
+
+ private static CERTInextCAPlugin BuildPlugin(
+ ICERTInextClient client,
+ IDomainValidatorFactory factory,
+ CERTInextConfig config = null) =>
+ new CERTInextCAPlugin(client, factory, config ?? DcvConfig());
+
+ private static EnrollmentProductInfo MakeProductInfo() =>
+ new EnrollmentProductInfo
+ {
+ ProductID = MockCertificateData.ProfileIdTls,
+ ProductParameters = new Dictionary { ["ProfileId"] = MockCertificateData.ProfileIdTls }
+ };
+
+ ///
+ /// Returns a mock client pre-wired for the full happy-path DCV flow:
+ /// Enroll → TrackOrder (DCV pending) → GetDcv → VerifyDcv → GetCertificate.
+ ///
+ private static (Mock mock, FakeDomainValidator validator) HappyPathMocks(
+ string orderNumber = MockCertificateData.DcvOrderId,
+ string domain = MockCertificateData.DcvDomain,
+ string token = MockCertificateData.DcvToken)
+ {
+ var mock = NewMock();
+
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = orderNumber, Status = "pending_dcv" });
+
+ // First call: pending (initial check in PerformDcvIfNeededAsync)
+ // Subsequent calls: verified (polling in WaitForDcvVerificationAsync)
+ mock.SetupSequence(c => c.TrackOrderAsync(orderNumber, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse(orderNumber, domain))
+ .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(orderNumber, domain));
+
+ mock.Setup(c => c.GetDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse(token));
+
+ mock.Setup(c => c.VerifyDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ mock.Setup(c => c.GetCertificateAsync(orderNumber, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(orderNumber));
+
+ var validator = new FakeDomainValidator();
+ return (mock, validator);
+ }
+
+ private static Task Enroll(CERTInextCAPlugin plugin) =>
+ plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem,
+ subject: $"CN={MockCertificateData.DcvDomain}",
+ san: new Dictionary { ["dns"] = new[] { MockCertificateData.DcvDomain } },
+ productInfo: MakeProductInfo(),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ // ---------------------------------------------------------------------------
+ // Happy path
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Dcv_HappyPath_StagesVerifiesAndCleansUp()
+ {
+ var (mock, validator) = HappyPathMocks();
+ // Issuance budget > 0 so the post-DCV GetCertificate poll runs and lifts the
+ // issued cert out of the mock back into the EnrollmentResult.
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ var result = await Enroll(plugin);
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ result.Certificate.Should().Contain("BEGIN CERTIFICATE");
+
+ // Verify Stage was called with the right hostname and token
+ string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, MockCertificateData.DcvDomain);
+ validator.StagedRecords.Should().ContainSingle()
+ .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken));
+
+ // Verify Cleanup was called (always, including on success)
+ validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(expectedHostname);
+
+ mock.Verify(c => c.VerifyDcvAsync(
+ MockCertificateData.DcvOrderId,
+ MockCertificateData.DcvDomain,
+ Constants.Dcv.MethodDnsTxt,
+ It.IsAny()), Times.Once);
+
+ mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Dcv_HappyPath_UsesCustomTxtTemplate()
+ {
+ var (mock, validator) = HappyPathMocks();
+ // Issuance budget > 0 so the post-DCV GetCertificate poll runs.
+ var config = DcvConfig(dcvWaitForIssuanceSeconds: 10);
+ config.DcvTxtRecordTemplate = "dcv-proof.{0}.acme-corp.com";
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config);
+
+ await Enroll(plugin);
+
+ string expectedHostname = $"dcv-proof.{MockCertificateData.DcvDomain}.acme-corp.com";
+ validator.StagedRecords.Should().ContainSingle().Which.key.Should().Be(expectedHostname);
+ validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(expectedHostname);
+ }
+
+ // ---------------------------------------------------------------------------
+ // DCV skipped conditions
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Dcv_Skipped_WhenOrderAlreadyIssued()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.CertId1, Status = "issued", Certificate = MockCertificateData.FakePemCertificate, SerialNumber = "0A1B2C" });
+
+ mock.Setup(c => c.TrackOrderAsync(MockCertificateData.CertId1, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.AlreadyIssuedTrackResponse());
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ var result = await Enroll(plugin);
+
+ // DCV skipped — order was already issued, result comes from EnrollCertificateAsync directly
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ validator.StagedRecords.Should().BeEmpty("DCV should be skipped for already-issued orders");
+ validator.CleanedUpKeys.Should().BeEmpty();
+
+ mock.Verify(c => c.GetDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Dcv_Skipped_WhenNoDomainVerificationBlock()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" });
+
+ mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()))
+ .ReturnsAsync(new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "1",
+ CertificateStatusId = "1",
+ DomainVerification = null
+ }
+ });
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ // PerformDcvIfNeeded returns false → plugin returns result from EnrollCertificateAsync
+ var result = await Enroll(plugin);
+
+ validator.StagedRecords.Should().BeEmpty();
+ mock.Verify(c => c.GetDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Dcv_SkipsStaging_AndDoesNotIssuancePoll_WhenAllDomainsAlreadyValidated_AndIssuanceBudgetZero()
+ {
+ // With DcvWaitForIssuanceSeconds=0 (the test fixture's DcvConfig default), an
+ // order with DCV already validated short-circuits: no TXT records staged AND
+ // no post-DCV GetCertificate poll. Lets sync pick up the cert on its own.
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" });
+
+ // domainVerification.status = "1" (Validated) — no pending work
+ mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()))
+ .ReturnsAsync(new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "1",
+ CertificateStatusId = "1",
+ DomainVerification = new TrackOrderDomainVerification
+ {
+ Status = Constants.Dcv.StatusValidated
+ }
+ }
+ });
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ await Enroll(plugin);
+
+ validator.StagedRecords.Should().BeEmpty();
+ mock.Verify(c => c.GetDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ // Issuance budget = 0 means the post-DCV poll short-circuits and GetCertificate
+ // is never called from this Enroll() path.
+ mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Dcv_RunsIssuanceWait_WhenDcvAlreadyValidated_AndIssuanceBudgetPositive()
+ {
+ // The cached-DCV gap fix: when CERTInext shows DCV already validated (no work
+ // for the plugin's DNS-TXT staging) AND the admin has set a positive issuance
+ // budget, the plugin should poll GetCertificate until the cert is generated
+ // and return the issued result directly from Enroll() — not leave it for sync.
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" });
+
+ mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()))
+ .ReturnsAsync(new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "1",
+ CertificateStatusId = "1",
+ DomainVerification = new TrackOrderDomainVerification
+ {
+ Status = Constants.Dcv.StatusValidated
+ }
+ }
+ });
+
+ // First post-DCV fetch is still pending; second returns issued.
+ mock.SetupSequence(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.DcvOrderId))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ var result = await Enroll(plugin);
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED,
+ "the issuance poll must lift the issued cert into the EnrollmentResult, " +
+ "not let the order fall through to a pending-then-sync round-trip");
+ validator.StagedRecords.Should().BeEmpty("no TXT staging is needed when DCV is already validated");
+ mock.Verify(c => c.GetDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()),
+ Times.AtLeast(2), "plugin should have polled at least twice to see the cert transition to issued");
+ }
+
+ [Fact]
+ public async Task Dcv_Skipped_WhenDcvEnabledFalse()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedEnrollResponse());
+
+ var validator = new FakeDomainValidator();
+ var config = DcvConfig(enabled: false);
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config);
+
+ await Enroll(plugin);
+
+ validator.StagedRecords.Should().BeEmpty("DCV should not run when DcvEnabled=false");
+ mock.Verify(c => c.TrackOrderAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Issue #7 — IDomainValidatorFactory is optional / injected post-construction
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Dcv_SilentlyNoOps_WhenNoFactoryInjected_AndDcvEnabledTrue()
+ {
+ // Simulates a v3.2 gateway host: plugin instantiated via the parameterless
+ // public production constructor, DcvEnabled=true in the connector config,
+ // but no IDomainValidatorFactory was injected via SetDomainValidatorFactory
+ // (because the host's IAnyCAPlugin assembly doesn't even have that interface).
+ // Enroll must:
+ // * NOT throw (no missing-type / null-factory exception),
+ // * NOT touch the CA's TrackOrder for DCV purposes,
+ // * return the enrollment result the CA gave us (here: pending).
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingEnrollResponse());
+
+ // Internal test ctor with factory = null AND DcvEnabled = true.
+ var plugin = new CERTInextCAPlugin(mock.Object, domainValidatorFactory: null, DcvConfig(enabled: true));
+
+ var result = await Enroll(plugin);
+
+ result.Should().NotBeNull();
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION,
+ "with no factory the CA's pending response must be passed through unchanged");
+ mock.Verify(c => c.TrackOrderAsync(It.IsAny(), It.IsAny()), Times.Never,
+ "EnrollNewAsync must short-circuit the DCV block when _domainValidatorFactory is null");
+ }
+
+ [Fact]
+ public async Task SetDomainValidatorFactory_AfterConstruction_WiresFactoryForSubsequentEnroll()
+ {
+ // The v3.3+ gateway path: host instantiates the plugin via the parameterless
+ // public constructor, resolves an IDomainValidatorFactory from its own
+ // service container, then calls SetDomainValidatorFactory(factory) before
+ // Initialize. Subsequent Enroll() calls must use the injected factory.
+ var (mock, validator) = HappyPathMocks();
+
+ // Plugin starts with NO factory — proves the setter does the wire-up, not
+ // some prior constructor parameter.
+ var plugin = new CERTInextCAPlugin(
+ mock.Object,
+ domainValidatorFactory: null,
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ plugin.SetDomainValidatorFactory(new FakeDomainValidatorFactory(validator));
+
+ var result = await Enroll(plugin);
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED,
+ "the factory injected via SetDomainValidatorFactory must drive DCV end-to-end");
+ validator.StagedRecords.Should().NotBeEmpty(
+ "SetDomainValidatorFactory must populate _domainValidatorFactory so DCV staging runs");
+ }
+
+ [Fact]
+ public async Task SetDomainValidatorFactory_SecondCall_OverridesFirst()
+ {
+ // Property-style setter semantics: the most recent SetDomainValidatorFactory
+ // call wins. Important for gateway hosts that may resolve a fresh factory
+ // per-initialize cycle. Tested behaviorally — drive Enroll() and assert
+ // the SECOND factory's validator received the TXT staging call (no reflection
+ // on internal fields).
+ var (mock, _) = HappyPathMocks();
+ var firstValidator = new FakeDomainValidator();
+ var secondValidator = new FakeDomainValidator();
+
+ var plugin = new CERTInextCAPlugin(
+ mock.Object,
+ domainValidatorFactory: null,
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ // First setter call is ignored by the override; only the second factory's
+ // validator should ever see traffic.
+ plugin.SetDomainValidatorFactory(new FakeDomainValidatorFactory(firstValidator));
+ plugin.SetDomainValidatorFactory(new FakeDomainValidatorFactory(secondValidator));
+
+ var result = await Enroll(plugin);
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ firstValidator.StagedRecords.Should().BeEmpty(
+ "the first factory must be replaced — its validator should never be called");
+ secondValidator.StagedRecords.Should().NotBeEmpty(
+ "the second SetDomainValidatorFactory call must replace the first; its validator drives DCV");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Cancelled/rejected orders short-circuit even with validated DCV state
+ // ---------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("4")] // OrderStatusId 4 = Order Cancelled
+ [InlineData("5")] // OrderStatusId 5 = Order Rejected
+ public async Task Dcv_Skipped_WhenOrderStatusIdIsTerminal_EvenIfDcvValidated(string terminalOrderStatusId)
+ {
+ // Regression guard for the cached-DCV path: a cancelled or rejected order
+ // can still have domainVerification.Status="1" carried over from a prior
+ // validated round. Without this guard the plugin would return true from
+ // PerformDcvIfNeededAsync and the caller would spend the full
+ // DcvWaitForIssuanceSeconds budget polling GetCertificate for a cert that
+ // is never going to issue. Per audit report B2 on PR #2.
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" });
+
+ mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()))
+ .ReturnsAsync(new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = terminalOrderStatusId,
+ CertificateStatusId = "1",
+ // Validated DCV state — without the OrderStatusId guard this would
+ // erroneously trigger the issuance-wait path.
+ DomainVerification = new TrackOrderDomainVerification
+ {
+ Status = Constants.Dcv.StatusValidated
+ }
+ }
+ });
+
+ var validator = new FakeDomainValidator();
+ // 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, pickupRetries: 5));
+
+ await Enroll(plugin);
+
+ mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()),
+ Times.Never,
+ "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");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Sync path is single-shot for the DCV challenge wait
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task SyncDcvRetry_DoesSingleShotTrackOrder_WhenChallengeNotReady()
+ {
+ // Sync MUST NOT poll the configured DcvWaitForChallengeSeconds budget per
+ // pending order — that would scale O(orders × 60s) per cycle and tie up
+ // gateway threads for minutes per sync. When TrackOrder returns null
+ // domainVerification, sync exits immediately and lets the next sync cycle
+ // pick the order up.
+ var mock = NewMock();
+
+ // High config budget — would normally drive 6+ polls × 5s waits. The sync
+ // override of 0 must prevent that.
+ var config = DcvConfig(dcvWaitForChallengeSeconds: 60);
+
+ mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()))
+ .ReturnsAsync(new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "1",
+ CertificateStatusId = "1",
+ DomainVerification = null
+ }
+ });
+
+ // GetSingleRecord calls GetCertificateAsync first to materialize the record;
+ // the sync-DCV-retry kicks in afterwards. The pending response keeps the
+ // retry path engaged so we exercise the override. The assertion below pins
+ // Times.Exactly(1) on TrackOrderAsync: with override=0, the polling loop
+ // takes one TrackOrder call, sees domainVerification null, and bails — no
+ // further polls inside the 60s budget the config nominally allows.
+ mock.Setup(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.DcvOrderId));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config);
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ // GetSingleRecord calls TryRunDcvDuringSyncAsync internally — which is the
+ // sync-style path with waitForChallengeSecondsOverride=0.
+ var record = await plugin.GetSingleRecord(MockCertificateData.DcvOrderId);
+ sw.Stop();
+
+ record.Should().NotBeNull();
+ // The 0-budget single shot must complete well under the 60s config budget.
+ // Use a generous 10s ceiling to tolerate slow CI hosts; the actual cost is
+ // ~1 TrackOrder. Without the override we'd be ≥60s.
+ sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(10),
+ "sync's DCV retry must be single-shot, not poll the configured challenge budget");
+
+ mock.Verify(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()),
+ Times.Exactly(1),
+ "PerformDcvIfNeededAsync's single-shot challenge check must make exactly ONE " +
+ "TrackOrder call when waitForChallengeSecondsOverride=0 and the slot is null. " +
+ "Without the override, the polling loop would issue many more calls within " +
+ "the 60s budget.");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Failure modes
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Dcv_SkipsAndDefers_WhenNoProviderForDomain()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" });
+
+ 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()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse());
+
+ // 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().NotThrowAsync();
+ }
+
+ [Fact]
+ public async Task Dcv_SkipsAndDefers_WhenStageValidationFails()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" });
+
+ 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()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse());
+
+ var validator = new FakeDomainValidator { StageSucceeds = false, StageError = "DNS zone not writable" };
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ Func act = () => Enroll(plugin);
+
+ // 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 — nothing was staged to verify
+ mock.Verify(c => c.VerifyDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Dcv_CleanupAlwaysCalled_EvenWhenVerifyDcvThrows()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" });
+
+ 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()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse());
+
+ mock.Setup(c => c.VerifyDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ThrowsAsync(new Exception("CERTInext DNS record not found"));
+
+ 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*");
+
+ // Cleanup must run even when VerifyDcv throws
+ string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, MockCertificateData.DcvDomain);
+ validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(expectedHostname);
+ }
+
+ [Fact]
+ public async Task Dcv_SkipsAndDefers_WhenGetDcvReturnsNoToken()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" });
+
+ 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()))
+ .ReturnsAsync(new GetDcvResponse { DcvDetails = new DcvResponseDetails { Token = null } });
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ Func act = () => Enroll(plugin);
+
+ // 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");
+ }
+
+ // ---------------------------------------------------------------------------
+ // EMS-956 tolerance — see analysis/certinext-support-ticket-2026-05-12.md
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Dcv_Defers_When_GetDcv_ReturnsEms956()
+ {
+ // Simulates the post-pre-vetted-org behaviour: TrackOrder shows a pending DCV
+ // slot, but CERTInext's GetDcv endpoint still rejects calls with EMS-956 for a
+ // window after enrollment. Plugin must NOT throw — it must return the pending
+ // result so the gateway records the order and the sync-retry can pick it up.
+ 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 Exception(
+ "CERTInext GetDcv failed for order '" + MockCertificateData.DcvOrderId + "': EMS-956 Invalid Request for this API."));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ // Should NOT throw — must return pending enrollment result so the gateway
+ // records the order and lets sync-retry recover later.
+ var result = await Enroll(plugin);
+ result.Should().NotBeNull();
+
+ // The DNS provider must not have been touched — staging a TXT record without a
+ // valid token would be wasted work and could collide with the future retry.
+ validator.StagedRecords.Should().BeEmpty();
+ validator.CleanedUpKeys.Should().BeEmpty();
+
+ // VerifyDcv must never be called either.
+ mock.Verify(c => c.VerifyDcvAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task Dcv_Defers_When_GetDcv_ReturnsInvalidRequestMessage_WithoutEms956Code()
+ {
+ // Tolerance must also match the human-readable phrase, not only the error code,
+ // because the CERTInext client wraps non-200 responses in a generic Exception
+ // whose Message is the upstream errorMessage field (sometimes without the code).
+ 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 Exception("Invalid Request for this API"));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ var result = await Enroll(plugin);
+ result.Should().NotBeNull();
+ validator.StagedRecords.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Dcv_SkipsAndDefers_WhenGetDcvFailsWithUnrelatedError()
+ {
+ // 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" });
+
+ 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 Exception("HTTP 500: Internal Server Error"));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ Func act = () => Enroll(plugin);
+ await act.Should().NotThrowAsync();
+ validator.StagedRecords.Should().BeEmpty("the only pending domain's GetDcv call failed, so nothing should have been staged");
+ }
+
+ // ---------------------------------------------------------------------------
+ // DcvWaitForChallengeSeconds — wait for domainVerification to appear
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Dcv_WaitsForChallenge_WhenDomainVerificationAppearsLate()
+ {
+ // First TrackOrder returns null domainVerification (CERTInext hasn't materialised
+ // the slot yet), second returns a populated pending slot. With a positive
+ // DcvWaitForChallengeSeconds the plugin must poll and proceed with DCV, NOT skip.
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending_dcv" });
+
+ // Sequence: 1st TrackOrder = no DCV slot, 2nd = pending, then verified for the wait poll.
+ mock.SetupSequence(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()))
+ .ReturnsAsync(new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "1",
+ CertificateStatusId = "1",
+ DomainVerification = null
+ }
+ })
+ .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse())
+ .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse());
+
+ mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse());
+ mock.Setup(c => c.VerifyDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ mock.Setup(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId));
+
+ var validator = new FakeDomainValidator();
+ // Both budgets positive so the polling paths exercise end-to-end.
+ var plugin = BuildPlugin(
+ mock.Object,
+ new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForChallengeSeconds: 10, dcvWaitForIssuanceSeconds: 10));
+
+ var result = await Enroll(plugin);
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ validator.StagedRecords.Should().NotBeEmpty("DCV must have run after polling found the slot");
+ }
+
+ [Fact]
+ public async Task Dcv_GivesUpWaitingForChallenge_AfterBudgetExpires()
+ {
+ // domainVerification stays null forever. With a short positive budget the plugin
+ // must poll for the budget and then return false (deferred to sync), NOT throw.
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" });
+
+ mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()))
+ .ReturnsAsync(new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "1",
+ CertificateStatusId = "1",
+ DomainVerification = null
+ }
+ });
+
+ var validator = new FakeDomainValidator();
+ // 5-second budget keeps the test fast but tolerates loaded CI hosts where a
+ // 2-second budget could overshoot to a single poll.
+ var plugin = BuildPlugin(
+ mock.Object,
+ new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForChallengeSeconds: 5));
+
+ var result = await Enroll(plugin);
+
+ result.Should().NotBeNull();
+ validator.StagedRecords.Should().BeEmpty("no DCV slot was ever exposed");
+ mock.Verify(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()),
+ Times.AtLeast(2), "plugin should have polled at least twice within the 5-second budget");
+ }
+
+ // ---------------------------------------------------------------------------
+ // DcvWaitForIssuanceSeconds — wait for cert PEM after DCV verifies
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Dcv_WaitsForIssuance_AfterDcvVerifies()
+ {
+ // First post-DCV GetCertificate returns pending; second returns issued. Plugin
+ // must poll and return the issued result to Enroll(), not the first pending one.
+ var (mock, validator) = HappyPathMocks();
+
+ // Override default GetCertificate setup: first pending, then issued.
+ mock.SetupSequence(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.DcvOrderId))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId));
+
+ var plugin = BuildPlugin(
+ mock.Object,
+ new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ var result = await Enroll(plugin);
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED,
+ "post-DCV polling must return the issued status, not the first pending fetch");
+ mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()),
+ Times.AtLeast(2), "plugin should have polled at least twice for issuance");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Undrainable pending domains must not strand the valid ones on the same order
+ // ---------------------------------------------------------------------------
+
+ /// Builds a DomainVerificationDetail JsonElement for the given dcvStatus.
+ private static System.Text.Json.JsonElement DcvDetail(string dcvStatus) =>
+ System.Text.Json.JsonSerializer.SerializeToElement(new DomainVerificationDetail
+ {
+ DcvMethod = Constants.Dcv.MethodDnsTxt,
+ DcvStatus = dcvStatus,
+ Status = "1"
+ });
+
+ ///
+ /// Builds a TrackOrder response whose domainVerification block lists several pending
+ /// domains, so tests can mix validatable and unvalidatable keys on one order.
+ ///
+ private static TrackOrderResponse DcvPendingTrackResponseMultiDomain(
+ string orderNumber, params string[] domains)
+ {
+ var detail = DcvDetail(Constants.Dcv.StatusPending);
+ var raw = new Dictionary();
+ foreach (string d in domains)
+ raw[d] = detail;
+
+ return new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "1",
+ CertificateStatusId = "1",
+ DomainVerification = new TrackOrderDomainVerification
+ {
+ Status = Constants.Dcv.StatusPending,
+ RawDomainEntries = raw
+ }
+ }
+ };
+ }
+
+ ///
+ /// Builds a TrackOrder response with one already-validated domain (dcvStatus=1) and one
+ /// still-pending, unresolvable domain (dcvStatus=0) — the shape CERTInext produces when it
+ /// has cached a prior DCV validation for the CN while a non-DNS SAN on the same order is
+ /// still outstanding.
+ ///
+ private static TrackOrderResponse DcvMixedStatusTrackResponse(
+ string validatedDomain, string pendingDomain)
+ {
+ var validated = DcvDetail(Constants.Dcv.StatusValidated);
+ var pending = DcvDetail(Constants.Dcv.StatusPending);
+
+ return new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "1",
+ CertificateStatusId = "1",
+ DomainVerification = new TrackOrderDomainVerification
+ {
+ // Aggregate stays pending because one domain still is — this must not take
+ // the early "already validated" return at the top of the method.
+ Status = Constants.Dcv.StatusPending,
+ RawDomainEntries = new Dictionary
+ {
+ [validatedDomain] = validated,
+ [pendingDomain] = pending
+ }
+ }
+ }
+ };
+ }
+
+ ///
+ /// Regression for the false invariant behind the round-1 fix's own misconfiguration check:
+ /// "the CN is always a pending domain too" is untrue whenever CERTInext has cached a prior
+ /// DCV validation for it (a case this same file's cached-validation branch documents), so a
+ /// non-DNS SAN sharing the order with an already-validated CN must not throw — it must defer
+ /// to the next sync cycle exactly like the single-domain case does.
+ ///
+ [Fact]
+ public async Task Dcv_CachedCnPlusUnresolvableSan_DefersWithoutThrowing()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ const string cn = MockCertificateData.DcvDomain;
+ const string ip = "192.0.2.10";
+
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending" });
+
+ mock.Setup(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvMixedStatusTrackResponse(validatedDomain: cn, pendingDomain: ip));
+
+ // The IP clears the FQDN regex and reaches GetDcv, per the sandbox-measured shape.
+ mock.Setup(c => c.GetDcvAsync(order, ip, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken));
+
+ var validator = new FakeDomainValidator();
+ // Resolves for the CN (a real, working DNS provider) but not for the IP literal — the
+ // scenario that must prove "a provider IS deployed" rather than "nothing is deployed".
+ var plugin = BuildPlugin(
+ mock.Object,
+ new FakeDomainValidatorFactory(validator, resolvableDomain: cn),
+ DcvConfig());
+
+ Func act = () => Enroll(plugin);
+
+ await act.Should().NotThrowAsync(
+ "an unresolvable non-DNS SAN must defer the order to the next sync cycle, not fail " +
+ "the enrollment — the CN having cached DCV proves a provider is deployed and working, " +
+ "so this is not the 'nothing is deployed' misconfiguration case");
+
+ validator.StagedRecords.Should().BeEmpty(
+ "the only pending domain is unresolvable, so nothing should have been staged");
+ }
+
+ ///
+ /// A non-FQDN pending domain must be skipped, not thrown on.
+ ///
+ /// Regression: non-DNS SANs are now submitted to CERTInext, which registers them verbatim
+ /// as order domains, so an email/URI SAN turns up as a domainVerification key that fails the
+ /// FQDN check. That check used to throw for the whole order — escaping Enroll (which has no
+ /// catch) after the order was already placed, so the enrollment failed with an orphaned
+ /// order and no TXT record was staged for the *valid* domains beside it. Every sync retry
+ /// re-threw and TryRunDcvDuringSyncAsync swallowed it, so the order could never progress.
+ ///
+ [Fact]
+ public async Task Dcv_NonFqdnPendingDomain_IsSkipped_AndValidDomainStillStaged()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ const string good = MockCertificateData.DcvDomain;
+ const string bad = "admin@example.com"; // what an rfc822 SAN comes back as
+
+ var mock = NewMock();
+
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" });
+
+ mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad))
+ .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good));
+
+ // Only the valid domain should ever reach GetDcv/VerifyDcv. MockBehavior.Strict means
+ // an unexpected call for `bad` fails the test on its own.
+ mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken));
+ mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ mock.Setup(c => c.GetCertificateAsync(order, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(order));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ // Must not throw — that is the regression.
+ var result = await Enroll(plugin);
+
+ string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good);
+ validator.StagedRecords.Should().ContainSingle(
+ "the valid DNS domain must still be staged even though a co-tenant domain is unusable")
+ .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken));
+
+ mock.Verify(c => c.GetDcvAsync(order, bad, It.IsAny(), It.IsAny()),
+ Times.Never, "a non-FQDN domain must never be sent to GetDcv");
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ }
+
+ ///
+ /// Regression: the FQDN validation regex used ^...$ , and in .NET's default (non-Multiline)
+ /// mode $ matches immediately before a single trailing '\n', not only at the true end of the
+ /// string. A domain value ending in '\n' therefore passed as "valid" and reached several log
+ /// sinks unsanitized further down this same method — a CWE-117 log-injection route into the
+ /// DCV audit trail, reachable via any order visible through Synchronize/GetSingleRecord (not
+ /// just ones this plugin's own Enroll call placed, since TrackOrder's domainVerification keys
+ /// for an externally-created order are never trimmed by this plugin). The regex now anchors
+ /// with \A/\z, which are absolute string-start/end regardless of trailing newlines.
+ ///
+ [Fact]
+ public async Task Dcv_DomainWithTrailingNewline_IsRejectedAsInvalid_AndValidDomainStillStaged()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ const string good = MockCertificateData.DcvDomain;
+ const string bad = "evil.example.com\n";
+
+ var mock = NewMock();
+
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" });
+
+ mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad))
+ .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good));
+
+ // MockBehavior.Strict: an unexpected GetDcv call for `bad` fails the test on its own —
+ // if the regex fix regressed, this domain would reach GetDcv instead of being rejected
+ // by the FQDN check before the staging loop even starts.
+ mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken));
+ mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ mock.Setup(c => c.GetCertificateAsync(order, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(order));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ var result = await Enroll(plugin);
+
+ string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good);
+ validator.StagedRecords.Should().ContainSingle(
+ "the valid domain must still be staged even though a co-tenant domain carries a " +
+ "trailing newline")
+ .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken));
+
+ mock.Verify(c => c.GetDcvAsync(order, bad, It.IsAny(), It.IsAny()),
+ Times.Never, "a domain with a trailing newline must never be sent to GetDcv");
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ }
+
+ ///
+ /// Regression: the generic per-domain catch blocks around GetDcvAsync and StageValidation
+ /// used to catch OperationCanceledException along with genuine GetDcv/DNS-provider failures,
+ /// logging and skipping the domain as an ordinary per-domain failure. A cancellation (the
+ /// shared DcvTimeoutMinutes-bound token expiring mid-loop) is not that — it must propagate to
+ /// the outer catch instead, which is the only place that logs it correctly and is the
+ /// intended timeout-handling path documented at the top of this method's DCV timeout setup.
+ ///
+ [Fact]
+ public async Task Dcv_CancellationDuringGetDcv_PropagatesRatherThanBeingSkippedAsPerDomainFailure()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending_dcv" });
+
+ mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse());
+
+ mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ThrowsAsync(new OperationCanceledException("DCV timeout budget exceeded"));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ Func act = () => Enroll(plugin);
+
+ // Must propagate as a cancellation, not be swallowed and reported as "GetDcv failed" in
+ // the skipped-domains summary while Enroll completes normally.
+ await act.Should().ThrowAsync();
+ }
+
+ ///
+ /// A pending domain that resolves no DNS provider (an IP-literal SAN passes the FQDN regex
+ /// but no zone can match it) must likewise be skipped rather than failing the whole order.
+ ///
+ [Fact]
+ public async Task Dcv_DomainWithNoResolvableValidator_IsSkipped_AndValidDomainStillStaged()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ const string good = MockCertificateData.DcvDomain;
+ const string ip = "192.0.2.10"; // what an iPAddress SAN comes back as
+
+ var mock = NewMock();
+
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" });
+
+ mock.SetupSequence(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, ip))
+ .ReturnsAsync(MockCertificateData.DcvVerifiedTrackResponse(order, good));
+
+ // The IP literal clears the FQDN filter, so GetDcv IS called for it; the dead end is
+ // that no validator resolves. Stub it so reaching that point is legitimate.
+ mock.Setup(c => c.GetDcvAsync(order, It.IsAny(), Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse(MockCertificateData.DcvToken));
+ mock.Setup(c => c.VerifyDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .Returns(Task.CompletedTask);
+ mock.Setup(c => c.GetCertificateAsync(order, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(order));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(
+ mock.Object,
+ new FakeDomainValidatorFactory(validator, resolvableDomain: good),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ var result = await Enroll(plugin);
+
+ string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, good);
+ validator.StagedRecords.Should().ContainSingle(
+ "only the domain with a resolvable provider should be staged, and it must still be staged")
+ .Which.Should().Be((expectedHostname, MockCertificateData.DcvToken));
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ }
+
+ ///
+ /// Regression: the compensating cleanup call after an early exit from staging (chiefly the
+ /// shared DcvTimeoutMinutes-bound token firing mid-loop, which is what this scenario
+ /// simulates via a domain whose GetDcv call raises OperationCanceledException) must not reuse
+ /// the same token the operation was cancelled by. A cooperative IDomainValidator that forwards
+ /// its token into its own HTTP calls (the reference CloudflareDomainValidator in this repo
+ /// does exactly that) would otherwise throw immediately on an already-cancelled token and
+ /// never even attempt the delete, silently leaving the TXT record published.
+ ///
+ /// CancellationToken.None would fix that but removes the cleanup call's timeout bound
+ /// entirely — a second, adversarially-found regression on top of the first — so the correct
+ /// fix is a fresh token with its OWN short timeout: not cancelled going in, but still bounded.
+ ///
+ [Fact]
+ public async Task Dcv_CleanupAfterCancellation_UsesAFreshBoundedToken_NotTheAmbientToken()
+ {
+ const string order = MockCertificateData.DcvOrderId;
+ const string good = "a.example.com";
+ const string bad = "b.example.com";
+
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new EnrollCertificateResponse { Id = order, Status = "pending_dcv" });
+
+ mock.Setup(c => c.TrackOrderAsync(order, It.IsAny()))
+ .ReturnsAsync(DcvPendingTrackResponseMultiDomain(order, good, bad));
+
+ mock.Setup(c => c.GetDcvAsync(order, good, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.DcvTokenResponse("token-a"));
+ // Domain 'good' is processed first (Dictionary enumeration order matches insertion order
+ // in practice for the small dictionaries this test builds); 'bad' then throws, driving the
+ // outer catch's cleanup of the already-staged 'good' entry.
+ mock.Setup(c => c.GetDcvAsync(order, bad, Constants.Dcv.MethodDnsTxt, It.IsAny()))
+ .ThrowsAsync(new OperationCanceledException("DCV timeout budget exceeded"));
+
+ var validator = new FakeDomainValidator();
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
+
+ Func act = () => Enroll(plugin);
+ await act.Should().ThrowAsync();
+
+ validator.StagedRecords.Should().ContainSingle(
+ "'good' must have staged before 'bad' threw, for this test to exercise cleanup at all");
+ var cleanupToken = validator.CleanupTokens.Should().ContainSingle(
+ "the staged entry must go through the cancellation cleanup path exactly once").Subject;
+
+ cleanupToken.IsCancellationRequested.Should().BeFalse(
+ "cleanup is a best-effort compensating action and must run with its own token, " +
+ "not the already-cancelled ambient one");
+ cleanupToken.CanBeCanceled.Should().BeTrue(
+ "the cleanup call must still be bounded by its own timeout, not unbounded " +
+ "(CancellationToken.None) — a hanging DNS-provider call must not block forever");
+ }
+
+ ///
+ /// Regression: the routine, always-runs finally-block cleanup used to iterate staged domains
+ /// sequentially. Each cleanup call already has its own independent
+ /// CleanupValidationTimeoutSeconds bound, but running them one after another meant that
+ /// bound was per-call, not in aggregate — a UCC order with N staged domains could hold the
+ /// calling request open for up to N x the per-call ceiling if the DNS provider was merely
+ /// slow (not even hung) on every delete, which can exceed DcvTimeoutMinutes itself for a
+ /// realistic multi-SAN count. Proven 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");
+ }
+ }
+}
diff --git a/CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs b/CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs
new file mode 100644
index 0000000..2fd1ad1
--- /dev/null
+++ b/CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs
@@ -0,0 +1,196 @@
+// Copyright 2024 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+// 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.Linq;
+using System.Reflection;
+using FluentAssertions;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Pins the gateway-DI-visible public surface of so that
+ /// regressions which would crash plugin load on older gateway hosts cannot land silently.
+ ///
+ /// Background: gateway image 25.4.0 ships
+ /// Keyfactor.AnyGateway.IAnyCAPlugin v3.2.0.0 , which does not define
+ /// Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory . If any public
+ /// constructor declares that type as a parameter, the gateway's DI container will fail
+ /// at RuntimeConstructorInfo.GetParameters() with TypeLoadException 0x80131509
+ /// before plugin load can complete (see GitHub issue #7).
+ ///
+ /// These tests assert via reflection that the only types reachable from the plugin's
+ /// public constructor parameter lists are ones present on v3.2 hosts (BCL +
+ /// pre-3.3 Keyfactor types).
+ ///
+ public class CERTInextCAPluginPublicSurfaceTests
+ {
+ private static readonly string[] V3Point3OnlyTypeNames =
+ {
+ "Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory",
+ "Keyfactor.AnyGateway.Extensions.IDomainValidator",
+ "Keyfactor.AnyGateway.Extensions.IDomainValidatorConfigProvider"
+ };
+
+ [Fact]
+ public void NoPublicConstructor_ReferencesV3Point3OnlyTypes()
+ {
+ var publicCtors = typeof(CERTInextCAPlugin)
+ .GetConstructors(BindingFlags.Public | BindingFlags.Instance);
+
+ publicCtors.Should().NotBeEmpty("plugin must have at least one public constructor for the gateway to instantiate");
+
+ foreach (var ctor in publicCtors)
+ {
+ foreach (var param in ctor.GetParameters())
+ {
+ string paramTypeName = param.ParameterType.FullName ?? param.ParameterType.Name;
+ V3Point3OnlyTypeNames.Should().NotContain(paramTypeName,
+ $"public constructor parameter '{param.Name}' (type {paramTypeName}) on " +
+ $"{ctor} would trip TypeLoadException on a gateway whose IAnyCAPlugin " +
+ $"assembly does not contain that type. Move the constructor to internal " +
+ $"or remove the parameter — see issue #7.");
+ }
+ }
+ }
+
+ [Fact]
+ public void NoInstanceField_DeclaredTypeReferencesV3Point3OnlyTypes()
+ {
+ // The .NET JIT eagerly resolves the declared types of all instance fields
+ // when it first compiles ANY method on a class. If an instance field is
+ // declared with a missing-type-on-this-host type, TypeLoadException fires
+ // the very first time Initialize / Enroll / Synchronize / anything is
+ // invoked — independent of whether the field is read on that code path.
+ //
+ // Issue #7's original fix patched constructor-signature reflection (the
+ // DI-container surface). The follow-up comment showed a separate failure
+ // path where Enroll trips on field-type loading. This test guards against
+ // a regression of either: field types must use only types the v3.2 host
+ // ships, with `object` as the typical neutral-typed storage and an `as`
+ // cast inside method bodies (JIT-lazy) for actual use.
+ // DeclaredOnly added for symmetry with the nested-type / method tests below
+ // and to make the "we only check this type, not its base classes" intent
+ // explicit in the reflection-query shape.
+ var fields = typeof(CERTInextCAPlugin)
+ .GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly);
+
+ foreach (var field in fields)
+ {
+ string fieldTypeName = field.FieldType.FullName ?? field.FieldType.Name;
+ V3Point3OnlyTypeNames.Should().NotContain(fieldTypeName,
+ $"instance field '{field.Name}' (declared type {fieldTypeName}) on " +
+ $"{field.DeclaringType?.FullName} would trigger TypeLoadException when the JIT " +
+ $"first compiles any method on the class on a v3.2 gateway host. " +
+ $"Re-type the field as `object` and cast to the v3.3 type inside method " +
+ $"bodies — see issue #7 follow-up.");
+ }
+ }
+
+ [Fact]
+ public void NoNestedType_ImplementsV3Point3OnlyInterface()
+ {
+ // Nested types declared with a base/interface reference to a v3.3-only
+ // interface put that interface in the containing class's nested-type
+ // metadata. CLR class-load behaviour around nested-type interface
+ // resolution is fragile across .NET versions, so we forbid it outright
+ // as a belt-and-braces measure.
+ var nestedTypes = typeof(CERTInextCAPlugin)
+ .GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic);
+
+ foreach (var nested in nestedTypes)
+ {
+ foreach (var iface in nested.GetInterfaces())
+ {
+ string ifaceName = iface.FullName ?? iface.Name;
+ V3Point3OnlyTypeNames.Should().NotContain(ifaceName,
+ $"nested type '{nested.FullName}' implements v3.3-only interface " +
+ $"'{ifaceName}', which would leak into the containing class's " +
+ $"reflection surface on a v3.2 host. Delete the nested type or " +
+ $"refactor it to not declare the v3.3 interface in its base list.");
+ }
+ }
+ }
+
+ [Fact]
+ public void NoPublicMethod_SignatureReferencesV3Point3OnlyTypes()
+ {
+ // Reflection-driven hosts (anything calling Type.GetMethods()) eagerly
+ // resolve return-type and parameter-type metadata on each method. Public
+ // method signatures must therefore avoid v3.3-only types the same way
+ // public constructors do. SetDomainValidatorFactory's `object` parameter
+ // is the safe pattern.
+ var publicInstanceMethods = typeof(CERTInextCAPlugin)
+ .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
+
+ foreach (var method in publicInstanceMethods)
+ {
+ // Property accessors get caught here too — that's intentional.
+ string returnTypeName = method.ReturnType.FullName ?? method.ReturnType.Name;
+ V3Point3OnlyTypeNames.Should().NotContain(returnTypeName,
+ $"public method '{method.Name}' returns v3.3-only type '{returnTypeName}'. " +
+ $"Change the return type to `object` and have callers cast at the use site.");
+
+ foreach (var param in method.GetParameters())
+ {
+ string paramTypeName = param.ParameterType.FullName ?? param.ParameterType.Name;
+ V3Point3OnlyTypeNames.Should().NotContain(paramTypeName,
+ $"public method '{method.Name}' parameter '{param.Name}' is " +
+ $"v3.3-only type '{paramTypeName}'. Change the parameter to `object` " +
+ $"and cast inside the method body — see SetDomainValidatorFactory.");
+ }
+ }
+ }
+
+ [Fact]
+ public void ParameterlessConstructor_IsPublic()
+ {
+ var parameterlessCtor = typeof(CERTInextCAPlugin)
+ .GetConstructor(BindingFlags.Public | BindingFlags.Instance, types: System.Type.EmptyTypes);
+
+ parameterlessCtor.Should().NotBeNull(
+ "older gateway hosts that don't pass any DI parameters need a public no-arg " +
+ "constructor to fall back to. See issue #7.");
+ }
+
+ [Fact]
+ public void SetDomainValidatorFactory_AcceptsObject_NotIDomainValidatorFactory()
+ {
+ // The public setter must declare `object` (not the v3.3-only interface) so the
+ // method's signature does not pull the missing type into the v3.2 host's
+ // reflection surface.
+ var method = typeof(CERTInextCAPlugin)
+ .GetMethod("SetDomainValidatorFactory", BindingFlags.Public | BindingFlags.Instance);
+
+ method.Should().NotBeNull("plugin must expose a public hook for v3.3+ hosts to inject the factory");
+ var parameters = method!.GetParameters();
+ parameters.Should().ContainSingle();
+ parameters[0].ParameterType.Should().Be(typeof(object),
+ "the parameter must be `object` so SetDomainValidatorFactory's signature is " +
+ "safe to reflect on a v3.2 host. The body casts to IDomainValidatorFactory " +
+ "lazily, which only resolves the type if the method is actually called.");
+ }
+
+ [Fact]
+ public void SetDomainValidatorFactory_NullArgument_LeavesDcvDisabled()
+ {
+ var plugin = new CERTInextCAPlugin();
+ plugin.SetDomainValidatorFactory(null);
+ // No exception, no state change — the plugin behaves as if no factory were available.
+ }
+
+ [Fact]
+ public void SetDomainValidatorFactory_NonFactoryArgument_IsIgnored()
+ {
+ // Pass something that doesn't implement IDomainValidatorFactory. The `as` cast
+ // in the setter yields null and the field stays null — no throw.
+ var plugin = new CERTInextCAPlugin();
+ plugin.SetDomainValidatorFactory("not a factory");
+ // No assertion needed beyond not throwing.
+ }
+ }
+}
diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs
index 9b85a66..5154146 100644
--- a/CERTInext.Tests/CERTInextCAPluginTests.cs
+++ b/CERTInext.Tests/CERTInextCAPluginTests.cs
@@ -31,8 +31,20 @@ public class CERTInextCAPluginTests
// Helpers
// ---------------------------------------------------------------------------
+ // Pickup is disabled by default in the broad fixture (PickupRetries=0) — mirroring how
+ // DcvConfig defaults its wait budgets to 0 — so tests that don't care about the
+ // synchronous pickup don't pay its real Task.Delay-based poll. Tests that DO exercise
+ // pickup opt in via BuildPluginWithPickup.
private static CERTInextCAPlugin BuildPlugin(ICERTInextClient client) =>
- new CERTInextCAPlugin(client);
+ new CERTInextCAPlugin(client, new CERTInextConfig { PickupRetries = 0 });
+
+ // Pickup-enabled fixture for the synchronous-pickup tests. PickupDelay is clamped to a
+ // 1s floor and the loop adds a fixed 5s initial delay, so these tests are intentionally
+ // a few seconds each.
+ private static CERTInextCAPlugin BuildPluginWithPickup(
+ ICERTInextClient client, int retries, int delaySeconds = 1) =>
+ new CERTInextCAPlugin(client,
+ new CERTInextConfig { PickupRetries = retries, PickupDelayInSeconds = delaySeconds });
private static Mock NewMock() => new Mock(MockBehavior.Strict);
@@ -345,6 +357,127 @@ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval()
result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
}
+ [Fact]
+ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReportsIssuedButBodyMissing()
+ {
+ // CERTInext can report an "issued"/auto-approved certificateStatusId before the
+ // certificate bytes actually exist — the immediate GetCertificate download fails
+ // and the legacy client returns Status="issued" with Certificate=null. Reporting
+ // GENERATED with no PEM crashes the gateway framework's PEM parser downstream, so
+ // the plugin must demote this to pending rather than trust the raw status string.
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(MockCertificateData.AutoApprovedNoBodyEnrollResponse());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 0);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem,
+ subject: "CN=test.example.com",
+ san: null,
+ productInfo: MakeProductInfo(),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ result.Certificate.Should().BeNullOrEmpty();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Synchronous certificate pickup (Sectigo parity)
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Pickup_Disabled_WhenPickupRetriesZero_ReturnsPendingWithoutPolling()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingEnrollResponse());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 0);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
+ productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()),
+ Times.Never, "PickupRetries=0 must disable the synchronous pickup poll");
+ }
+
+ [Fact]
+ public async Task Pickup_ReturnsIssuedCert_WhenOrderIssuesDuringPoll()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.EnrollCertificateAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.PendingEnrollResponse());
+ // The order finishes issuing by the time we poll: GetCertificate reports issued + PEM.
+ mock.Setup(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord());
+
+ var plugin = BuildPluginWithPickup(mock.Object, retries: 2);
+
+ var result = await plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
+ productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ result.Certificate.Should().NotBeNullOrEmpty("a synchronously-picked-up cert must carry its PEM");
+ mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny