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/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..f684f7d 100644
--- a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs
+++ b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs
@@ -772,7 +772,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 +811,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 +883,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..837ae8d
--- /dev/null
+++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs
@@ -0,0 +1,820 @@
+// 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) =>
+ 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
+ };
+
+ 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 so a wrong-path entry would manifest as a
+ // GetCertificate call we DON'T expect.
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
+ DcvConfig(dcvWaitForIssuanceSeconds: 10));
+
+ await Enroll(plugin);
+
+ mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()),
+ Times.Never,
+ "Enroll must not enter WaitForIssuanceAfterDcvAsync when the order is " +
+ "cancelled/rejected, even if DCV happens to be in a 'validated' state");
+ 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_Throws_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
+ var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator: null));
+
+ Func act = () => Enroll(plugin);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*No DNS provider plugin is configured*");
+ }
+
+ [Fact]
+ public async Task Dcv_Throws_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);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*Failed to stage DNS validation*DNS zone not writable*");
+
+ // No VerifyDcv call — failed before reaching that step
+ 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_Throws_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);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*GetDcv returned no token*");
+ }
+
+ // ---------------------------------------------------------------------------
+ // 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_Rethrows_When_GetDcv_FailsWithUnrelatedError()
+ {
+ // Tolerance is narrow: a genuine server error (5xx, transport, auth) must still
+ // bubble up so the gateway treats the enrollment as failed and the operator can
+ // diagnose. This guards against accidentally swallowing every GetDcv exception.
+ 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().ThrowAsync()
+ .WithMessage("*HTTP 500*");
+ }
+
+ // ---------------------------------------------------------------------------
+ // 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");
+ }
+ }
+}
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..3ec5df1 100644
--- a/CERTInext.Tests/CERTInextCAPluginTests.cs
+++ b/CERTInext.Tests/CERTInextCAPluginTests.cs
@@ -685,6 +685,110 @@ public async Task Synchronize_SkipsFailedCertificates()
results[0].CARequestID.Should().Be(MockCertificateData.CertId1);
}
+ // Regression for issue 0001 — Synchronize dropped issued certs because the
+ // order-report listing (ListCertificatesAsync) carries no PEM body, so the
+ // synced record had Certificate == null and Command couldn't store it.
+ [Fact]
+ public async Task Synchronize_IssuedCertMissingBody_RefetchesFullCertificate()
+ {
+ const string id = MockCertificateData.CertId1;
+
+ // Listing entry as the order report produces it: GENERATED status, NO body.
+ var listingEntry = new LegacyGetCertificateResponse
+ {
+ Id = id,
+ Status = "issued", // → EndEntityStatus.GENERATED
+ Certificate = null, // order report carries no PEM
+ ProfileId = MockCertificateData.ProfileIdTls
+ };
+
+ var mock = NewMock();
+ mock.Setup(c => c.ListCertificatesAsync(
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(AsyncEnum(new List { listingEntry }));
+ // Full fetch returns the PEM body (mirrors the real GetCertificateAsync).
+ mock.Setup(c => c.GetCertificateAsync(id, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.IssuedCertRecord(id));
+
+ var plugin = BuildPlugin(mock.Object);
+ var buffer = new BlockingCollection(10);
+
+ await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: CancellationToken.None);
+
+ var results = buffer.ToList();
+ results.Should().HaveCount(1);
+ results[0].CARequestID.Should().Be(id);
+ results[0].Certificate.Should().Be(MockCertificateData.FakePemCertificate,
+ "an issued cert must carry the PEM body fetched via GetCertificateAsync, not a null body");
+ mock.Verify(c => c.GetCertificateAsync(id, It.IsAny()), Times.Once);
+ }
+
+ // Guard the N+1 boundary: when the listing already includes a body, Synchronize
+ // must NOT refetch. The strict mock has no GetCertificateAsync setup, so any call
+ // would throw and fail this test.
+ [Fact]
+ public async Task Synchronize_IssuedCertWithBody_DoesNotRefetch()
+ {
+ var mock = NewMock();
+ mock.Setup(c => c.ListCertificatesAsync(
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(AsyncEnum(new List
+ {
+ MockCertificateData.IssuedCertRecord(MockCertificateData.CertId1) // already has a body
+ }));
+
+ var plugin = BuildPlugin(mock.Object);
+ var buffer = new BlockingCollection(10);
+
+ await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: CancellationToken.None);
+
+ var results = buffer.ToList();
+ results.Should().HaveCount(1);
+ results[0].Certificate.Should().Be(MockCertificateData.FakePemCertificate);
+ mock.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ // Regression for issue 0001 (revoked variant) — a cert reported "revoked" during
+ // sync also arrives from the order report with no body and no revocation detail.
+ // The refetch must populate the body AND the revocation date, not just the REVOKED
+ // status. (Complements Synchronize_MapsRevokedCertificates_Correctly, which feeds an
+ // already-populated entry that doesn't exercise the refetch.)
+ [Fact]
+ public async Task Synchronize_RevokedCertMissingBody_RefetchesWithRevocationMetadata()
+ {
+ const string id = MockCertificateData.CertId3;
+
+ var listingEntry = new LegacyGetCertificateResponse
+ {
+ Id = id,
+ Status = "revoked", // → EndEntityStatus.REVOKED
+ Certificate = null, // order report carries neither body nor revocation detail
+ RevokedAt = null,
+ ProfileId = MockCertificateData.ProfileIdTls
+ };
+
+ var mock = NewMock();
+ mock.Setup(c => c.ListCertificatesAsync(
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(AsyncEnum(new List { listingEntry }));
+ mock.Setup(c => c.GetCertificateAsync(id, It.IsAny()))
+ .ReturnsAsync(MockCertificateData.RevokedCertRecord(id)); // body + RevokedAt + reason
+
+ var plugin = BuildPlugin(mock.Object);
+ var buffer = new BlockingCollection(10);
+
+ await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: CancellationToken.None);
+
+ var results = buffer.ToList();
+ results.Should().HaveCount(1);
+ results[0].CARequestID.Should().Be(id);
+ results[0].Status.Should().Be((int)EndEntityStatus.REVOKED);
+ results[0].Certificate.Should().Be(MockCertificateData.FakePemCertificate);
+ results[0].RevocationDate.Should().NotBeNull(
+ "a revoked cert must carry its revocation date after the sync refetch, not just REVOKED status");
+ mock.Verify(c => c.GetCertificateAsync(id, It.IsAny()), Times.Once);
+ }
+
[Fact]
public async Task Synchronize_HonoursCancellation()
{
diff --git a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs
new file mode 100644
index 0000000..4e59495
--- /dev/null
+++ b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs
@@ -0,0 +1,292 @@
+// 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;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using WireMock.Server;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Verifies the JSON body emitted by BuildOrderRequestFromLegacyEnrollRequest
+ /// against the connector-level config fields that customers can set in the gateway
+ /// admin UI. Each test:
+ /// 1. Builds a with specific field combinations,
+ /// 2. Stubs GenerateOrderSSL + TrackOrder with a happy response,
+ /// 3. Invokes EnrollCertificateAsync ,
+ /// 4. Reads the captured POST body from WireMock and asserts the shape.
+ ///
+ /// These tests pin the behaviour of the configurables documented in README.md →
+ /// "CA Configuration"; if a future refactor accidentally omits one of them from
+ /// the SSL order body, the corresponding test fails loudly.
+ ///
+ public class CERTInextClientRequestShapeTests : IDisposable
+ {
+ private readonly WireMockServer _server;
+ private readonly string _baseUrl;
+
+ public CERTInextClientRequestShapeTests()
+ {
+ _server = WireMockServer.Start();
+ _baseUrl = _server.Urls[0];
+ }
+
+ public void Dispose() => _server.Stop();
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ private CERTInextClient BuildClient(CERTInextConfig config)
+ {
+ config.ApiUrl = _baseUrl;
+ return new CERTInextClient(config);
+ }
+
+ private static CERTInextConfig MinimalConfig() => new CERTInextConfig
+ {
+ AuthMode = "AccessKey",
+ ApiKey = "test-key",
+ AccountNumber = "12345",
+ RequestorName = "Default Requestor",
+ RequestorEmail = "default@example.com",
+ RequestorIsdCode = "1",
+ RequestorMobileNumber = "5550000000",
+ SignerPlace = "Austin",
+ SignerIp = "203.0.113.10",
+ PageSize = 100
+ };
+
+ private void StubHappyEnroll()
+ {
+ _server.Given(Request.Create().WithPath("/GenerateOrderSSL").UsingPost())
+ .RespondWith(Response.Create().WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(MockCertificateData.GenerateOrderSuccessJson(MockCertificateData.OrderNumber1)));
+
+ _server.Given(Request.Create().WithPath("/TrackOrder").UsingPost())
+ .RespondWith(Response.Create().WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(MockCertificateData.TrackOrderIssuedJson(MockCertificateData.OrderNumber1)));
+
+ _server.Given(Request.Create().WithPath("/GetCertificate").UsingPost())
+ .RespondWith(Response.Create().WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(MockCertificateData.GetCertificateSuccessJson()));
+ }
+
+ private JsonElement CapturedOrderBody()
+ {
+ var generateOrderRequests = _server.LogEntries
+ .Where(e => e.RequestMessage.Path == "/GenerateOrderSSL")
+ .ToList();
+ generateOrderRequests.Should().HaveCount(1,
+ "exactly one GenerateOrderSSL POST should have been emitted");
+ string body = generateOrderRequests[0].RequestMessage.Body;
+ body.Should().NotBeNullOrEmpty();
+ return JsonDocument.Parse(body!).RootElement.GetProperty("orderDetails");
+ }
+
+ private static EnrollCertificateRequest BasicEnrollRequest() => new EnrollCertificateRequest
+ {
+ ProfileId = "842",
+ Csr = MockCertificateData.FakeCsrPem,
+ Subject = "CN=test.example.com",
+ Comment = "Unit test"
+ };
+
+ // -----------------------------------------------------------------------
+ // OrganizationNumber → organizationDetails block
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public async Task OrganizationNumber_Set_EmitsPreVettedOrganizationDetails()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.OrganizationNumber = "9876543210";
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var orderDetails = CapturedOrderBody();
+ orderDetails.TryGetProperty("organizationDetails", out var orgDetails).Should().BeTrue(
+ "organizationDetails must be present when OrganizationNumber is configured");
+ orgDetails.GetProperty("preVetting").GetString().Should().Be("1",
+ "preVetting=1 declares the org as already vetted, bypassing the manual queue");
+ orgDetails.GetProperty("organizationNumber").GetString().Should().Be("9876543210");
+ }
+
+ [Fact]
+ public async Task OrganizationNumber_Blank_OmitsOrganizationDetailsBlock()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.OrganizationNumber = string.Empty;
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var orderDetails = CapturedOrderBody();
+ orderDetails.TryGetProperty("organizationDetails", out _).Should().BeFalse(
+ "organizationDetails must be omitted when OrganizationNumber is unset (preserves legacy behavior)");
+ }
+
+ // -----------------------------------------------------------------------
+ // GroupNumber → delegationInformation block
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public async Task GroupNumber_Set_EmitsDelegationInformation()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.GroupNumber = "2171775848";
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var orderDetails = CapturedOrderBody();
+ orderDetails.TryGetProperty("delegationInformation", out var delegation).Should().BeTrue();
+ delegation.GetProperty("groupNumber").GetString().Should().Be("2171775848");
+ }
+
+ [Fact]
+ public async Task GroupNumber_Blank_OmitsDelegationInformation()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.GroupNumber = string.Empty;
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var orderDetails = CapturedOrderBody();
+ orderDetails.TryGetProperty("delegationInformation", out _).Should().BeFalse();
+ }
+
+ // -----------------------------------------------------------------------
+ // technicalPointOfContact — overrides + requestor fallback
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public async Task TechnicalContact_AllSet_EmitsExplicitValues()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.TechnicalContactName = "Jane Smith";
+ cfg.TechnicalContactEmail = "tpc@example.com";
+ cfg.TechnicalContactIsdCode = "44";
+ cfg.TechnicalContactMobileNumber = "5559999999";
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var tpc = CapturedOrderBody().GetProperty("technicalPointOfContact");
+ tpc.GetProperty("tpcName").GetString().Should().Be("Jane Smith");
+ tpc.GetProperty("tpcEmail").GetString().Should().Be("tpc@example.com");
+ tpc.GetProperty("tpcIsdCode").GetString().Should().Be("44");
+ tpc.GetProperty("tpcMobileNumber").GetString().Should().Be("5559999999");
+ }
+
+ [Fact]
+ public async Task TechnicalContact_AllBlank_FallsBackToRequestorDefaults()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ // All TechnicalContact* unset → must fall back to Requestor*
+ cfg.TechnicalContactName = string.Empty;
+ cfg.TechnicalContactEmail = string.Empty;
+ cfg.TechnicalContactIsdCode = string.Empty;
+ cfg.TechnicalContactMobileNumber = string.Empty;
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var tpc = CapturedOrderBody().GetProperty("technicalPointOfContact");
+ tpc.GetProperty("tpcName").GetString().Should().Be(cfg.RequestorName);
+ tpc.GetProperty("tpcEmail").GetString().Should().Be(cfg.RequestorEmail);
+ tpc.GetProperty("tpcIsdCode").GetString().Should().Be(cfg.RequestorIsdCode);
+ tpc.GetProperty("tpcMobileNumber").GetString().Should().Be(cfg.RequestorMobileNumber);
+ }
+
+ // -----------------------------------------------------------------------
+ // SSL order body defaults — AccountingModel / EmailNotifications /
+ // SubscriptionAutoRenew / SubscriptionRenewCriteriaDays /
+ // SubscriptionValidityYears / AutoSecureWww
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public async Task SslBodyDefaults_AreEmitted_FromCustomConnectorValues()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.AccountingModel = "1";
+ cfg.EmailNotifications = "1";
+ cfg.SubscriptionValidityYears = "2";
+ cfg.SubscriptionAutoRenew = "1";
+ cfg.SubscriptionRenewCriteriaDays = "60";
+ cfg.AutoSecureWww = "1";
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var od = CapturedOrderBody();
+ od.GetProperty("accountingModel").GetString().Should().Be("1");
+ od.GetProperty("emailNotifications").GetString().Should().Be("1");
+
+ var sub = od.GetProperty("subscriptionDetails");
+ sub.GetProperty("validity").GetString().Should().Be("2");
+ sub.GetProperty("autoRenew").GetString().Should().Be("1");
+ sub.GetProperty("renewCriteria").GetString().Should().Be("60");
+
+ od.GetProperty("certificateInformation").GetProperty("autoSecureWWW").GetString().Should().Be("1");
+ }
+
+ [Fact]
+ public async Task SslBodyDefaults_AreSafeFallbacks_WhenConfigUntouched()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ // Leave new fields at their CERTInextConfig defaults
+
+ await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest());
+
+ var od = CapturedOrderBody();
+ od.GetProperty("accountingModel").GetString().Should().Be("2");
+ od.GetProperty("emailNotifications").GetString().Should().Be("0");
+
+ var sub = od.GetProperty("subscriptionDetails");
+ sub.GetProperty("validity").GetString().Should().Be("1");
+ sub.GetProperty("autoRenew").GetString().Should().Be("0");
+ sub.GetProperty("renewCriteria").GetString().Should().Be("30");
+
+ od.GetProperty("certificateInformation").GetProperty("autoSecureWWW").GetString().Should().Be("0");
+ }
+
+ // -----------------------------------------------------------------------
+ // ValidityDays request-parameter still overrides the connector default
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public async Task ValidityDays_OnRequest_OverridesConnectorDefault()
+ {
+ StubHappyEnroll();
+ var cfg = MinimalConfig();
+ cfg.SubscriptionValidityYears = "1"; // connector default = 1 year
+
+ var req = BasicEnrollRequest();
+ req.ValidityDays = 730; // 2 years
+
+ await BuildClient(cfg).EnrollCertificateAsync(req);
+
+ CapturedOrderBody().GetProperty("subscriptionDetails")
+ .GetProperty("validity").GetString().Should().Be("2");
+ }
+ }
+}
diff --git a/CERTInext.Tests/CERTInextClientTests.cs b/CERTInext.Tests/CERTInextClientTests.cs
index 243d801..e473e89 100644
--- a/CERTInext.Tests/CERTInextClientTests.cs
+++ b/CERTInext.Tests/CERTInextClientTests.cs
@@ -689,7 +689,7 @@ public async Task OAuth_InjectsBearerToken_InAuthorizationHeader()
pingRequest.RequestMessage.Headers.Should().ContainKey("Authorization",
"OAuth mode must inject the Authorization header on outgoing requests");
- var authHeader = pingRequest.RequestMessage.Headers["Authorization"].FirstOrDefault();
+ var authHeader = pingRequest.RequestMessage.Headers!["Authorization"].FirstOrDefault();
authHeader.Should().Be($"Bearer {expectedToken}",
"the injected token must match the one returned by the token endpoint");
}
@@ -713,7 +713,7 @@ public async Task OAuth_DoesNotInjectBearerToken_InAccessKeyMode()
.First(e => e.RequestMessage.Path == "/ValidateCredentials");
// Authorization header must be absent in AccessKey mode
- bool hasAuthHeader = pingRequest.RequestMessage.Headers.ContainsKey("Authorization");
+ bool hasAuthHeader = pingRequest.RequestMessage.Headers!.ContainsKey("Authorization");
hasAuthHeader.Should().BeFalse(
"AccessKey mode authenticates via the authKey field in the JSON body, not an HTTP header");
}
@@ -744,5 +744,145 @@ public async Task ExecuteWithRetry_MakesThreeAttempts_WhenServerAlwaysReturns500
pingCallCount.Should().Be(3,
"ExecuteWithRetryAsync makes 3 total attempts on persistent 5xx errors");
}
+
+ // ---------------------------------------------------------------------------
+ // GetDcvAsync — POST /GetDcv
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task GetDcvAsync_ReturnsToken_WhenServerRespondsOk()
+ {
+ const string token = "abc123token";
+ _server
+ .Given(Request.Create().WithPath("/GetDcv").UsingPost())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(MockCertificateData.GetDcvSuccessJson(token)));
+
+ var client = BuildClient();
+
+ var result = await client.GetDcvAsync(
+ MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
+
+ result.Should().NotBeNull();
+ result.DcvDetails.Should().NotBeNull();
+ result.DcvDetails.Token.Should().Be(token);
+ _server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/GetDcv");
+ }
+
+ [Fact]
+ public async Task GetDcvAsync_Throws_WhenMetaStatusIsFailure()
+ {
+ _server
+ .Given(Request.Create().WithPath("/GetDcv").UsingPost())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(MockCertificateData.GetDcvFailureJson("EMS-DCV-001", "DCV not available")));
+
+ var client = BuildClient();
+
+ Func act = () => client.GetDcvAsync(
+ MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*GetDcv failed*");
+ }
+
+ [Fact]
+ public async Task GetDcvAsync_Throws_WhenServerReturns401()
+ {
+ _server
+ .Given(Request.Create().WithPath("/GetDcv").UsingPost())
+ .RespondWith(Response.Create()
+ .WithStatusCode(401)
+ .WithBody(MockCertificateData.UnauthorizedJson()));
+
+ var client = BuildClient();
+
+ Func act = () => client.GetDcvAsync(
+ MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*Authentication failure*");
+ }
+
+ // ---------------------------------------------------------------------------
+ // VerifyDcvAsync — POST /VerifyDcv
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task VerifyDcvAsync_Succeeds_WhenServerRespondsOk()
+ {
+ _server
+ .Given(Request.Create().WithPath("/VerifyDcv").UsingPost())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(MockCertificateData.VerifyDcvSuccessJson()));
+
+ var client = BuildClient();
+
+ // Should not throw
+ await client.VerifyDcvAsync(
+ MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
+
+ _server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/VerifyDcv");
+ }
+
+ [Fact]
+ public async Task VerifyDcvAsync_Throws_WhenMetaStatusIsFailure()
+ {
+ _server
+ .Given(Request.Create().WithPath("/VerifyDcv").UsingPost())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(MockCertificateData.VerifyDcvFailureJson("EMS-DCV-002", "DNS record not found")));
+
+ var client = BuildClient();
+
+ Func act = () => client.VerifyDcvAsync(
+ MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*DNS record not found*");
+ }
+
+ [Fact]
+ public async Task VerifyDcvAsync_Throws_WhenServerReturns401()
+ {
+ _server
+ .Given(Request.Create().WithPath("/VerifyDcv").UsingPost())
+ .RespondWith(Response.Create()
+ .WithStatusCode(401)
+ .WithBody(MockCertificateData.UnauthorizedJson()));
+
+ var client = BuildClient();
+
+ Func act = () => client.VerifyDcvAsync(
+ MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*Authentication failure*");
+ }
+
+ [Fact]
+ public async Task VerifyDcvAsync_Throws_WhenServerReturns500()
+ {
+ _server
+ .Given(Request.Create().WithPath("/VerifyDcv").UsingPost())
+ .RespondWith(Response.Create()
+ .WithStatusCode(500)
+ .WithBody(MockCertificateData.ServerErrorJson()));
+
+ var client = BuildClient();
+
+ Func act = () => client.VerifyDcvAsync(
+ MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
+
+ await act.Should().ThrowAsync();
+ }
}
}
diff --git a/CERTInext.Tests/ExtractSerialFromPemTests.cs b/CERTInext.Tests/ExtractSerialFromPemTests.cs
new file mode 100644
index 0000000..f8064dd
--- /dev/null
+++ b/CERTInext.Tests/ExtractSerialFromPemTests.cs
@@ -0,0 +1,131 @@
+// 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;
+using System.Reflection;
+using FluentAssertions;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto.Operators;
+using Org.BouncyCastle.Math;
+using Org.BouncyCastle.Security;
+using Org.BouncyCastle.X509;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Regression tests for the private CERTInextCAPlugin.ExtractSerialFromPem
+ /// helper, which feeds the audit-log SerialNumber field. After the BouncyCastle
+ /// migration (replacing X509Certificate2.SerialNumber ) we need to pin the
+ /// format invariants — particularly the leading-zero-byte case where the old BCL
+ /// behaviour and a naive BigInteger.ToString(16) diverge.
+ ///
+ public class ExtractSerialFromPemTests
+ {
+ private static string InvokeExtractSerialFromPem(string pem)
+ {
+ var method = typeof(CERTInextCAPlugin)
+ .GetMethod("ExtractSerialFromPem", BindingFlags.NonPublic | BindingFlags.Static);
+ method.Should().NotBeNull("test pins the format produced by ExtractSerialFromPem");
+ return (string)method!.Invoke(null, new object[] { pem })!;
+ }
+
+ ///
+ /// Generates a self-signed PEM cert with the specified serial number. Uses
+ /// BouncyCastle throughout — no BCL crypto — per the project's crypto policy.
+ ///
+ private static string GeneratePemWithSerial(BigInteger serial)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ AsymmetricCipherKeyPair keyPair = keyGen.GenerateKeyPair();
+
+ var subject = new X509Name("CN=test-serial-parity");
+ var notBefore = DateTime.UtcNow.AddMinutes(-1);
+ var notAfter = notBefore.AddDays(1);
+
+ var builder = new X509V3CertificateGenerator();
+ builder.SetSerialNumber(serial);
+ builder.SetIssuerDN(subject);
+ builder.SetSubjectDN(subject);
+ builder.SetNotBefore(notBefore);
+ builder.SetNotAfter(notAfter);
+ builder.SetPublicKey(keyPair.Public);
+
+ var signerFactory = new Asn1SignatureFactory("SHA256withRSA", keyPair.Private);
+ X509Certificate cert = builder.Generate(signerFactory);
+
+ return "-----BEGIN CERTIFICATE-----\n"
+ + Convert.ToBase64String(cert.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE-----";
+ }
+
+ [Fact]
+ public void ExtractSerialFromPem_PreservesLeadingZeroByte()
+ {
+ // Serial bytes 0x00 0x0A 0xFF 0xFF as an unsigned big-endian integer = 720895
+ // X509Certificate2.SerialNumber would produce "0AFFFF" (sign byte stripped,
+ // remaining bytes hex-encoded, leading-zero NIBBLE preserved within byte boundary).
+ // A naive BigInteger.ToString(16) would produce "afff" (a 4-digit hex, dropping
+ // the leading zero nibble), which mis-correlates with Command's stored serial.
+ //
+ // Use a serial that has a leading-zero nibble in its first non-zero byte:
+ // 0x0A123456 → unsigned hex "0A123456" (8 nibbles). Anything that drops the
+ // leading zero produces "A123456" (7 nibbles).
+ var serial = new BigInteger("0A123456", 16);
+ string pem = GeneratePemWithSerial(serial);
+
+ string result = InvokeExtractSerialFromPem(pem);
+
+ result.Should().Be("0A123456",
+ "the serial must preserve the leading-zero nibble within its first byte " +
+ "so audit-log correlation against Command's stored serial succeeds");
+ }
+
+ [Fact]
+ public void ExtractSerialFromPem_NormalSerial_UppercaseHexNoLeadingZero()
+ {
+ // Plain mid-range serial; just confirms format is uppercase hex without separators.
+ var serial = new BigInteger("DEADBEEFCAFE", 16);
+ string pem = GeneratePemWithSerial(serial);
+
+ string result = InvokeExtractSerialFromPem(pem);
+
+ result.Should().Be("DEADBEEFCAFE");
+ }
+
+ [Fact]
+ public void ExtractSerialFromPem_LongSerial_AllBytesPreservedUppercase()
+ {
+ // 20-byte serial (the max CA/B Forum permits). Each byte must be uppercase
+ // hex, no separators, no leading-zero loss.
+ var serial = new BigInteger("01020304050607080910111213141516171819FA", 16);
+ string pem = GeneratePemWithSerial(serial);
+
+ string result = InvokeExtractSerialFromPem(pem);
+
+ result.Should().Be("01020304050607080910111213141516171819FA");
+ }
+
+ [Fact]
+ public void ExtractSerialFromPem_GarbageInput_ReturnsParseError()
+ {
+ // Robustness — audit-log path must never throw, only mark the failure.
+ InvokeExtractSerialFromPem("not a pem")
+ .Should().Be("(parse-error)");
+ }
+
+ [Fact]
+ public void ExtractSerialFromPem_EmptyBody_ReturnsEmptyPem()
+ {
+ InvokeExtractSerialFromPem("-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----")
+ .Should().Be("(empty-pem)");
+ }
+ }
+}
diff --git a/CERTInext.Tests/FakeDomainValidator.cs b/CERTInext.Tests/FakeDomainValidator.cs
new file mode 100644
index 0000000..6b42475
--- /dev/null
+++ b/CERTInext.Tests/FakeDomainValidator.cs
@@ -0,0 +1,69 @@
+// 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.Tests
+{
+ ///
+ /// In-memory stub that records staged and cleaned-up DNS TXT entries without
+ /// making real DNS calls. Configurable success/failure via init properties.
+ ///
+ internal sealed class FakeDomainValidator : IDomainValidator
+ {
+ /// All (key, value) pairs passed to .
+ public List<(string key, string value)> StagedRecords { get; } = new();
+
+ /// All keys passed to .
+ public List CleanedUpKeys { get; } = new();
+
+ /// When false, returns a failure result.
+ public bool StageSucceeds { get; init; } = true;
+
+ /// Error message returned when is false.
+ public string StageError { get; init; } = "Stage failed (test stub)";
+
+ public void Initialize(IDomainValidatorConfigProvider configProvider) { }
+
+ public Task StageValidation(string key, string value, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ StagedRecords.Add((key, value));
+ return Task.FromResult(new DomainValidationResult
+ {
+ Success = StageSucceeds,
+ ErrorMessage = StageSucceeds ? null : StageError
+ });
+ }
+
+ public Task CleanupValidation(string key, CancellationToken cancellationToken)
+ {
+ CleanedUpKeys.Add(key);
+ return Task.FromResult(new DomainValidationResult { Success = true });
+ }
+
+ public Task ValidateConfiguration(Dictionary configuration) => Task.CompletedTask;
+ public Dictionary GetDomainValidatorAnnotations() => new();
+ public string GetValidationType() => "dns-01";
+ }
+
+ ///
+ /// Factory that returns a single pre-configured for every
+ /// domain. Pass null as the validator to simulate "no DNS provider configured".
+ ///
+ internal sealed class FakeDomainValidatorFactory : IDomainValidatorFactory
+ {
+ private readonly IDomainValidator _validator;
+
+ public FakeDomainValidatorFactory(IDomainValidator validator = null) => _validator = validator;
+
+ public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator;
+
+ /// The validator this factory returns; exposed for assertions in tests.
+ public IDomainValidator PrimaryValidator => _validator;
+ }
+}
diff --git a/CERTInext.Tests/MockCertificateData.cs b/CERTInext.Tests/MockCertificateData.cs
index 04903b0..ee6644b 100644
--- a/CERTInext.Tests/MockCertificateData.cs
+++ b/CERTInext.Tests/MockCertificateData.cs
@@ -7,6 +7,7 @@
using System;
using System.Collections.Generic;
+using System.Text.Json;
using Keyfactor.Extensions.CAPlugin.CERTInext.API;
namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
@@ -312,6 +313,20 @@ public static LegacyGetCertificateResponse IssuedCertRecord(string id = null) =>
Csr = FakeCsrPem
};
+ ///
+ /// A LegacyGetCertificateResponse representing an order that is past DCV verification
+ /// but still has CERTInext-side issuance in progress. Status maps to
+ /// so post-DCV polling logic continues.
+ ///
+ public static LegacyGetCertificateResponse PendingCertRecord(string id = null) =>
+ new LegacyGetCertificateResponse
+ {
+ Id = id ?? CertId1,
+ Status = "pending_approval", // → EXTERNALVALIDATION via StatusMapper
+ Certificate = null,
+ SerialNumber = null
+ };
+
public static LegacyGetCertificateResponse RevokedCertRecord(string id = null) =>
new LegacyGetCertificateResponse
{
@@ -334,6 +349,125 @@ public static LegacyGetCertificateResponse RevokedCertRecord(string id = null) =
public static string OAuth2TokenJson(int expiresIn = 3600) =>
$@"{{""access_token"":""fake-bearer-token-abc123"",""token_type"":""Bearer"",""expires_in"":{expiresIn}}}";
+ // -----------------------------------------------------------------------
+ // DCV (domain control validation)
+ // -----------------------------------------------------------------------
+
+ public const string DcvOrderId = "ORD-DCV-001";
+ public const string DcvDomain = "example.com";
+ public const string DcvToken = "abc123dcvtoken";
+
+ ///
+ /// Returns a with one pending DNS-TXT domain entry,
+ /// ready for Moq setups that exercise the DCV orchestration path.
+ ///
+ public static TrackOrderResponse DcvPendingTrackResponse(
+ string orderNumber = DcvOrderId,
+ string domain = DcvDomain)
+ {
+ var detail = JsonSerializer.SerializeToElement(new DomainVerificationDetail
+ {
+ DcvMethod = Constants.Dcv.MethodDnsTxt,
+ DcvStatus = Constants.Dcv.StatusPending,
+ Status = "1"
+ });
+
+ return new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "1",
+ CertificateStatusId = "1",
+ DomainVerification = new TrackOrderDomainVerification
+ {
+ Status = Constants.Dcv.StatusPending,
+ RawDomainEntries = new Dictionary { [domain] = detail }
+ }
+ }
+ };
+ }
+
+ public static TrackOrderResponse DcvVerifiedTrackResponse(
+ string orderNumber = DcvOrderId,
+ string domain = DcvDomain)
+ {
+ var detail = JsonSerializer.SerializeToElement(new DomainVerificationDetail
+ {
+ DcvMethod = Constants.Dcv.MethodDnsTxt,
+ DcvStatus = Constants.Dcv.StatusValidated,
+ Status = "1"
+ });
+
+ return new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "2",
+ CertificateStatusId = "24",
+ DomainVerification = new TrackOrderDomainVerification
+ {
+ Status = Constants.Dcv.StatusValidated,
+ RawDomainEntries = new Dictionary { [domain] = detail }
+ }
+ }
+ };
+ }
+
+ ///
+ /// Returns a whose order is already in a terminal
+ /// issued state — DCV should be skipped entirely when this is returned.
+ ///
+ public static TrackOrderResponse AlreadyIssuedTrackResponse(string orderNumber = CertId1) =>
+ new TrackOrderResponse
+ {
+ OrderDetails = new TrackOrderResponseDetails
+ {
+ OrderStatusId = "4",
+ CertificateStatusId = "9", // CertificateGenerated — maps to GENERATED
+ }
+ };
+
+ ///
+ /// Returns a containing the TXT token for Moq setups.
+ ///
+ public static GetDcvResponse DcvTokenResponse(string token = DcvToken) =>
+ new GetDcvResponse
+ {
+ DcvDetails = new DcvResponseDetails { Token = token }
+ };
+
+ ///
+ /// POST /GetDcv — success response containing the TXT record token for DNS DCV.
+ ///
+ public static string GetDcvSuccessJson(string token = "abc123token") =>
+ $@"{{
+ ""meta"":{SuccessMetaJson()},
+ ""dcvDetails"":{{
+ ""token"":""{token}"",
+ ""fileName"":null,
+ ""fileContent"":null,
+ ""dcvEmails"":null
+ }}
+}}";
+
+ ///
+ /// POST /GetDcv — failure response (bad order or unsupported dcvMethod).
+ ///
+ public static string GetDcvFailureJson(string code = "EMS-DCV-001", string msg = "DCV not available for this order") =>
+ $@"{{""meta"":{FailureMetaJson(code, msg)}}}";
+
+ ///
+ /// POST /VerifyDcv — success response (meta only, no additional payload).
+ ///
+ public static string VerifyDcvSuccessJson() =>
+ $@"{{""meta"":{SuccessMetaJson()}}}";
+
+ ///
+ /// POST /VerifyDcv — failure response (TXT record not found).
+ ///
+ public static string VerifyDcvFailureJson(string code = "EMS-DCV-002", string msg = "DNS record not found") =>
+ $@"{{""meta"":{FailureMetaJson(code, msg)}}}";
+
// -----------------------------------------------------------------------
// Error responses
// -----------------------------------------------------------------------
diff --git a/CERTInext.Tests/RateLimitRetryTests.cs b/CERTInext.Tests/RateLimitRetryTests.cs
new file mode 100644
index 0000000..7750073
--- /dev/null
+++ b/CERTInext.Tests/RateLimitRetryTests.cs
@@ -0,0 +1,64 @@
+// 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 Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Pure unit tests for the rate-limit-retry helpers in .
+ /// Behavioral / end-to-end coverage of the retry loop itself lives in the WireMock
+ /// tests; here we pin the predicate and the backoff schedule.
+ ///
+ public class RateLimitRetryTests
+ {
+ [Theory]
+ [InlineData("Inactive Account User.", true)] // exact form from sandbox
+ [InlineData("inactive account user.", true)] // case-insensitive
+ [InlineData("INACTIVE ACCOUNT USER", true)] // case + missing period
+ [InlineData("Some preamble: Inactive Account User. Tail", true)] // embedded substring
+ [InlineData("Active account user.", false)] // wrong polarity
+ [InlineData("Account is inactive", false)] // similar phrase, wrong wording
+ [InlineData("EMS-956 Invalid Request for this API.", false)] // unrelated error
+ [InlineData("", false)]
+ [InlineData(null, false)]
+ public void IsRateLimitSurface_DetectsDocumentedPhraseOnly(string errorMessage, bool expected)
+ {
+ CERTInextClient.IsRateLimitSurface(errorMessage).Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData(1, 0.75, 1.25)] // base = 1s, jittered ±25% ⇒ [0.75, 1.25]
+ [InlineData(2, 1.5, 2.5)] // 2s × jitter
+ [InlineData(3, 3.0, 5.0)] // 4s × jitter
+ [InlineData(4, 6.0, 10.0)] // 8s × jitter
+ [InlineData(5, 12.0, 20.0)] // 16s × jitter
+ public void ComputeRateLimitBackoffSeconds_ProducesExpectedRange(int attempt, double min, double max)
+ {
+ // Run several samples so jitter is exercised; every sample must fall inside
+ // the documented exponential ± 25% jitter window.
+ for (int i = 0; i < 50; i++)
+ {
+ double waitSeconds = CERTInextClient.ComputeRateLimitBackoffSeconds(attempt);
+ waitSeconds.Should().BeInRange(min, max,
+ $"attempt {attempt} sample {i} must fall inside the documented backoff window");
+ }
+ }
+
+ [Fact]
+ public void ComputeRateLimitBackoffSeconds_ClampsAttemptsBelowOneToOne()
+ {
+ // Defensive: passing 0 or negative shouldn't produce zero / negative delay.
+ CERTInextClient.ComputeRateLimitBackoffSeconds(0)
+ .Should().BeInRange(0.75, 1.25);
+ CERTInextClient.ComputeRateLimitBackoffSeconds(-3)
+ .Should().BeInRange(0.75, 1.25);
+ }
+ }
+}
diff --git a/CERTInext.Tests/RedactCredentialsTests.cs b/CERTInext.Tests/RedactCredentialsTests.cs
new file mode 100644
index 0000000..fad3e46
--- /dev/null
+++ b/CERTInext.Tests/RedactCredentialsTests.cs
@@ -0,0 +1,108 @@
+// 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 Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Pins the credential-scrubbing pass that runs on
+ /// every response body before truncation. The CERTInext request meta block
+ /// includes an authKey SHA-256 digest that is itself a replayable
+ /// credential under SOX (anyone with one valid (ts, txn, authKey) triple
+ /// can replay until the timestamp window expires). These tests pin that the
+ /// scrubber catches both the documented-as-sent fields (authKey ) and
+ /// adjacent credential field names that *could* end up on the wire if a future
+ /// code path wires them in (client_secret , accessKey , password ).
+ /// See the audit report for commit aab1847.
+ ///
+ public class RedactCredentialsTests
+ {
+ [Theory]
+ [InlineData(
+ "{\"meta\":{\"authKey\":\"deadbeefdeadbeefdeadbeef\",\"ts\":\"2026\"}}",
+ "{\"meta\":{\"authKey\":\"***REDACTED***\",\"ts\":\"2026\"}}")]
+ [InlineData(
+ "{\"client_secret\":\"super-secret-12345\"}",
+ "{\"client_secret\":\"***REDACTED***\"}")]
+ [InlineData(
+ "{\"apiKey\":\"raw-access-key-value\",\"other\":\"keep\"}",
+ "{\"apiKey\":\"***REDACTED***\",\"other\":\"keep\"}")]
+ [InlineData(
+ "{\"accessKey\":\"xxx\",\"password\":\"yyy\"}",
+ "{\"accessKey\":\"***REDACTED***\",\"password\":\"***REDACTED***\"}")]
+ public void RedactCredentials_ScrubsJsonCredentialFields(string input, string expected)
+ {
+ CERTInextClient.RedactCredentials(input).Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData(
+ "grant_type=client_credentials&client_secret=super-secret-12345&client_id=public-id",
+ "grant_type=client_credentials&client_secret=***REDACTED***&client_id=public-id")]
+ [InlineData(
+ "authKey=abc123def456",
+ "authKey=***REDACTED***")]
+ public void RedactCredentials_ScrubsFormUrlEncodedCredentialFields(string input, string expected)
+ {
+ CERTInextClient.RedactCredentials(input).Should().Be(expected);
+ }
+
+ [Fact]
+ public void RedactCredentials_ScrubsAuthorizationHeaderLines()
+ {
+ string input =
+ "POST /token HTTP/1.1\r\n" +
+ "Host: example.com\r\n" +
+ "Authorization: Bearer ya29.abcdef-secret-token\r\n" +
+ "Content-Type: application/json\r\n";
+ string output = CERTInextClient.RedactCredentials(input);
+ output.Should().Contain("Authorization: ***REDACTED***");
+ output.Should().NotContain("ya29.abcdef-secret-token");
+ output.Should().Contain("Host: example.com");
+ output.Should().Contain("Content-Type: application/json");
+ }
+
+ [Fact]
+ public void RedactCredentials_PreservesNonCredentialFields()
+ {
+ string input = "{\"meta\":{\"ts\":\"2026-05-22\",\"txn\":\"12345\",\"errorMessage\":\"Inactive Account User.\"}}";
+ string output = CERTInextClient.RedactCredentials(input);
+ output.Should().Be(input, "non-credential fields must pass through unchanged");
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void RedactCredentials_HandlesNullAndEmpty(string input)
+ {
+ // Should not throw and should return the input unchanged (or empty for null).
+ // The current implementation returns the input as-is for these edge cases.
+ CERTInextClient.RedactCredentials(input).Should().Be(input);
+ }
+
+ [Fact]
+ public void RedactCredentials_CaseInsensitiveFieldNameMatch()
+ {
+ // CERTInext historically uses mixed casing (`AuthKey`, `apiKey`, etc.)
+ // depending on the endpoint. Make sure none slip past the scrubber.
+ string input = "{\"AuthKey\":\"abc\",\"APIKEY\":\"def\",\"ClientSecret\":\"xyz\"}";
+
+ string output = CERTInextClient.RedactCredentials(input);
+
+ // ClientSecret isn't currently in the redaction list (only client_secret is),
+ // and that's intentional — the JSON convention CERTInext uses is the
+ // snake_case form on the OAuth token endpoint. If we ever observe
+ // CamelCase variants on the wire, extend the regex. Documented here so
+ // a future regression review catches the gap.
+ output.Should().Contain("\"AuthKey\":\"***REDACTED***\"");
+ output.Should().Contain("\"APIKEY\":\"***REDACTED***\"");
+ }
+ }
+}
diff --git a/CERTInext.Tests/TESTING.md b/CERTInext.Tests/TESTING.md
index b703d61..e56c35a 100644
--- a/CERTInext.Tests/TESTING.md
+++ b/CERTInext.Tests/TESTING.md
@@ -1,27 +1,41 @@
-# CERTInext CA Plugin — Test Suite Reference
+# CERTInext CA Plugin — Unit Test Suite Reference
## Overview
-There are two test classes in this project, each targeting a different layer of the plugin:
+The `CERTInext.Tests` project contains unit and contract tests for the CERTInext AnyCA Gateway
+REST plugin. No external services are required — all HTTP I/O is handled in-process by WireMock.Net
+or replaced by Moq strict mocks.
-**`CERTInextClientTests`** tests the HTTP client (`CERTInextClient`) in isolation. It uses [WireMock.Net](https://github.com/WireMock-Net/WireMock.Net) to start a real in-process HTTP server on a random port, then directs the client at that server. RestSharp makes actual HTTP calls, so JSON serialization, request routing, header construction, OAuth2 token fetching, and pagination logic are all exercised end-to-end against real network I/O (loopback only).
+The project is split into several focused test classes:
-**`CERTInextCAPluginTests`** tests the `CERTInextCAPlugin` class — the Keyfactor `IAnyCAPlugin` implementation. It replaces `ICERTInextClient` with a [Moq](https://github.com/moq/moq4) strict mock so no network calls are made. The focus is on plugin-level logic: argument validation, status mapping, enrollment type routing, revocation reason translation, and synchronization behavior.
+| Class | Layer under test | Isolation technique |
+|---|---|---|
+| `CERTInextClientTests` | `CERTInextClient` HTTP transport | WireMock.Net (real loopback HTTP) |
+| `CERTInextClientRequestShapeTests` | `CERTInextClient` request body construction | WireMock.Net |
+| `CERTInextCAPluginTests` | `CERTInextCAPlugin` IAnyCAPlugin logic | Moq strict mock of `ICERTInextClient` |
+| `CERTInextCAPluginCoverageTests` | Additional plugin logic paths | Moq strict mock |
+| `CERTInextCAPluginPublicSurfaceTests` | Binary-compat / no-DCV surface contract | Reflection only |
+| `BoundedDcvSyncTests` | DCV sync age/cap filter logic | Pure unit (no I/O) |
+| `RateLimitRetryTests` | Rate-limit back-off helpers | Pure unit (no I/O) |
+| `ExtractSerialFromPemTests` | PEM serial-number extraction | Pure unit (no I/O) |
+| `RedactCredentialsTests` | Log credential-redaction helper | Pure unit (no I/O) |
-The split keeps concerns separate. If a test fails in `CERTInextClientTests`, the bug is in HTTP transport or serialization. If it fails in `CERTInextCAPluginTests`, the bug is in plugin logic.
+If a test fails in `CERTInextClientTests` or `CERTInextClientRequestShapeTests`, the bug is in
+HTTP transport or request serialisation. If it fails in `CERTInextCAPluginTests` or
+`CERTInextCAPluginCoverageTests`, the bug is in plugin logic.
---
## Running the Tests
**Prerequisites:**
-- .NET SDK 8.0 or later
+- .NET 8 or .NET 10 SDK
- NuGet packages restored (`dotnet restore`)
-- No external services required — WireMock runs in-process
+- No external services required
**Run all tests:**
```bash
-dotnet test
+dotnet test CERTInext.Tests/
```
**Run a single test class:**
@@ -35,259 +49,340 @@ dotnet test --filter "FullyQualifiedName~CERTInextCAPluginTests"
dotnet test --filter "DisplayName~OAuth2_TokenIsCached"
```
-Each `CERTInextClientTests` instance starts a fresh `WireMockServer` in its constructor and stops it in `Dispose()`, so tests are isolated and can run in parallel without port conflicts.
+Each `CERTInextClientTests` instance starts a fresh `WireMockServer` in its constructor and
+stops it in `Dispose()`, so tests are isolated and can run in parallel without port conflicts.
+
+---
+
+## Authentication model
+
+The real CERTInext API uses HTTP POST for **all** endpoints. There is no Authorization header
+for AccessKey mode. Instead, every request body includes a `meta` block containing:
+
+- `authKey` — `SHA256(accessKey + requestTs + requestTxnId)` (lowercase hex)
+- `ts` — ISO 8601 timestamp
+- `txn` — unique transaction UUID
+
+The raw access key is never transmitted — only the derived hash is sent.
+
+`AuthMode` accepted values:
+- `AccessKey` (primary) — HMAC signed body
+- `OAuth` (alternative) — bearer token via client credentials flow
+- `ApiKey`, `AccessKeyLegacy`, `OAuthLegacy` — legacy aliases accepted for backward compatibility
---
## CERTInextClientTests
-The test class implements `IDisposable`. A `WireMockServer` is started on a random available port in the constructor. All tests build a `CERTInextClient` pointed at `_server.Urls[0]`.
+The test class implements `IDisposable`. A `WireMockServer` is started on a random available port
+in the constructor. All tests build a `CERTInextClient` pointed at `_server.Urls[0]`.
Two helper methods build clients:
-- `BuildClient(authMode, apiKey)` — builds an ApiKey-authenticated client (default: `authMode="ApiKey"`, `apiKey="test-key"`)
-- `BuildOAuthClient(tokenUrl)` — builds an OAuth2 client with `client_id="my-client"` and `client_secret="my-secret"`
+- `BuildClient(authMode, apiKey)` — builds an AccessKey-authenticated client
+ (defaults: `authMode="AccessKey"`, `apiKey="test-key"`, `accountNumber="12345"`)
+- `BuildOAuthClient(tokenUrl)` — builds an OAuth client with `client_id="my-client"`,
+ `client_secret="my-secret"`
-### PingAsync
+### PingAsync — POST /ValidateCredentials
| Test | Stub | Assertion |
|------|------|-----------|
-| `PingAsync_ReturnsHealthy_WhenServerRespondsOk` | `GET /api/v1/health` → 200, `{"status":"ok","version":"2.1.0"}` | Does not throw; WireMock log contains a request to `/api/v1/health` |
-| `PingAsync_Throws_When500Returned` | `GET /api/v1/health` → 500, server error body | Throws an `Exception` with message containing `"health check failed"` |
+| `PingAsync_ReturnsHealthy_WhenServerRespondsOk` | `POST /ValidateCredentials` → 200, success meta | Does not throw; WireMock log contains a request to `/ValidateCredentials` |
+| `PingAsync_Throws_When500Returned` | `POST /ValidateCredentials` → 500, server error body | Throws `Exception` with message containing `"health check failed"` |
+| `PingAsync_Throws_WhenMetaStatusIsFailure` | `POST /ValidateCredentials` → 200, failure meta (`EMS-001`, `"Invalid credentials"`) | Throws `Exception` with message containing `"credential validation failed"` |
-### API Key Authentication
+### OAuth2 Token Fetch, Caching, and Injection
| Test | Stub | Assertion |
|------|------|-----------|
-| `PingAsync_SendsApiKeyHeader_WhenAuthModeIsApiKey` | `GET /api/v1/health` matched only when header `X-API-Key: super-secret-key` is present → 200 | WireMock records exactly one matched request, confirming the header was sent with the correct value |
-
-This test verifies the header matching at the WireMock level: if the client sends the wrong header name or value, WireMock finds no matching stub and the request fails.
+| `OAuth2_FetchesToken_BeforeFirstApiCall` | `POST /oauth/token` → token JSON; `POST /ValidateCredentials` → 200 | Log contains both `/oauth/token` and `/ValidateCredentials` |
+| `OAuth2_TokenIsCached_SecondCallDoesNotRefetch` | Same stubs | `PingAsync` called twice; `/oauth/token` appears exactly once; `/ValidateCredentials` appears twice |
+| `OAuth_InjectsBearerToken_InAuthorizationHeader` | Token endpoint → `fake-bearer-token-abc123`; `/ValidateCredentials` → 200 | WireMock log entry for `/ValidateCredentials` carries `Authorization: Bearer fake-bearer-token-abc123` |
+| `OAuth_DoesNotInjectBearerToken_InAccessKeyMode` | `/ValidateCredentials` → 200 | WireMock log entry has no `Authorization` header |
-### OAuth2 Token Fetch, Caching, and Header Injection
+### Retry logic
| Test | Stub | Assertion |
|------|------|-----------|
-| `OAuth2_FetchesToken_BeforeFirstApiCall` | `POST /oauth/token` → 200, token JSON; `POST /ValidateCredentials` → 200 | Log entries contain both `/oauth/token` and `/ValidateCredentials` |
-| `OAuth2_TokenIsCached_SecondCallDoesNotRefetch` | Same as above | `PingAsync` called twice; `/oauth/token` appears exactly once in log; `/ValidateCredentials` appears twice |
-| `OAuth_InjectsBearerToken_InAuthorizationHeader` | Token endpoint → `fake-bearer-token-abc123`; ValidateCredentials → 200 | WireMock log for `/ValidateCredentials` contains header `Authorization: Bearer fake-bearer-token-abc123` |
-| `OAuth_DoesNotInjectBearerToken_InAccessKeyMode` | `/ValidateCredentials` → 200 | WireMock log entry for `/ValidateCredentials` has no `Authorization` header |
+| `ExecuteWithRetry_MakesThreeAttempts_WhenServerAlwaysReturns500` | `/ValidateCredentials` always → 500 | `PingAsync` throws; WireMock log has exactly 3 requests (3 total attempts, 4xx are not retried) |
-The `OAuth_InjectsBearerToken_InAuthorizationHeader` test is the P1-A regression test. Before the fix, `CERTInextClient` stored the token in a `[ThreadStatic]` field that was never injected into actual requests. The fix replaces this with a `CERTInextOAuthAuthenticator : AuthenticatorBase` subclass that injects the header per-request via RestSharp's authenticator interface.
-
-### Retry Logic
+### EnrollCertificateAsync — POST /GenerateOrderSSL
| Test | Stub | Assertion |
|------|------|-----------|
-| `ExecuteWithRetry_MakesThreeAttempts_WhenServerAlwaysReturns500` | `/ValidateCredentials` always → 500 | `PingAsync` throws; WireMock log contains exactly 3 requests to `/ValidateCredentials` |
-
-`ExecuteWithRetryAsync` retries on HTTP 5xx (and network-level failures) for up to `maxAttempts=3` total attempts. 4xx responses are not retried.
+| `EnrollCertificateAsync_ReturnsCertificate_WhenServerIssues` | `POST /GenerateOrderSSL` → 200, success meta + `orderDetails.orderNumber="ORD-AAA-111"` | Result not null; `OrderNumber == "ORD-AAA-111"` |
+| `EnrollCertificateAsync_ReturnsPending_WhenServerReturnsPendingApproval` | `POST /GenerateOrderSSL` → 200, pending response | Status maps to pending |
+| `EnrollCertificateAsync_Throws_WhenGenerateOrderFails` | `POST /GenerateOrderSSL` → 200, failure meta (EMS-918) | Throws `Exception` containing the API error message |
+| `EnrollCertificateAsync_Throws_When5xxReturned` | `POST /GenerateOrderSSL` → 500 | Throws `Exception` |
+| `EnrollCertificateAsync_Throws_When401Returned` | `POST /GenerateOrderSSL` → 401 | Throws `Exception` |
-### EnrollCertificateAsync
+### GetCertificateAsync — POST /GetCertificate
| Test | Stub | Assertion |
|------|------|-----------|
-| `EnrollCertificateAsync_ReturnsCertificate_WhenServerIssues` | `POST /api/v1/certificates` → 200, enroll response with `status="issued"`, cert PEM, `id=CertId1` | Result is not null; `Id == CertId1`; `Status == "issued"`; `Certificate` contains `"BEGIN CERTIFICATE"` |
-| `EnrollCertificateAsync_ReturnsPending_WhenServerReturnsPendingApproval` | `POST /api/v1/certificates` → 200, `{"status":"pending_approval","certificate":null,...}` | `Status == "pending_approval"`; `Certificate` is null |
-| `EnrollCertificateAsync_Throws_When4xxReturned` | `POST /api/v1/certificates` → 400, `{"error":"BAD_REQUEST","message":"Invalid CSR."}` | Throws `Exception` with message containing `"Invalid CSR"` |
-| `EnrollCertificateAsync_Throws_When5xxReturned` | `POST /api/v1/certificates` → 500, server error body | Throws `Exception` (any type) |
-| `EnrollCertificateAsync_Throws_When401Returned` | `POST /api/v1/certificates` → 401, unauthorized body | Throws `Exception` (any type) |
+| `GetCertificateAsync_ReturnsCertificate_WhenFound` | `POST /GetCertificate` → 200, PEM in `certificateDetails.endEntityCertificate` | PEM contains `"BEGIN CERTIFICATE"`; serial `"0A1B2C3D4E5F"` |
+| `GetCertificateAsync_ThrowsKeyNotFound_WhenOrderNotFound` | `POST /GetCertificate` → 200, failure meta (EMS-not-found) | Throws `KeyNotFoundException` |
-### GetCertificateAsync
+### RevokeCertificateAsync — POST /RevokeOrder
| Test | Stub | Assertion |
|------|------|-----------|
-| `GetCertificateAsync_ReturnsCertificate_WhenFound` | `GET /api/v1/certificates/{CertId1}` → 200, full certificate JSON | Result is not null; `Id == CertId1`; `Status == "issued"`; `Certificate` contains `"BEGIN CERTIFICATE"` |
-| `GetCertificateAsync_ThrowsKeyNotFound_When404Returned` | `GET /api/v1/certificates/nonexistent-id` → 404, not-found error body | Throws `KeyNotFoundException` |
+| `RevokeCertificateAsync_Succeeds_When200Returned` | `POST /RevokeOrder` → 200, success meta | Does not throw |
+| `RevokeCertificateAsync_Throws_WhenServerReturnsFailure` | `POST /RevokeOrder` → 200, failure meta | Throws `Exception` |
-### RevokeCertificateAsync
+### RenewCertificateAsync — POST /GenerateOrderSSL
+
+CERTInext has no dedicated renewal endpoint. `RenewCertificateAsync` submits a new
+`GenerateOrderSSL` order. The test verifies that the correct endpoint and body are used.
| Test | Stub | Assertion |
|------|------|-----------|
-| `RevokeCertificateAsync_Succeeds_When200Returned` | `POST /api/v1/certificates/{CertId1}/revoke` → 200, `{"success":true,...}` | Does not throw |
-| `RevokeCertificateAsync_Throws_When4xxReturned` | `POST /api/v1/certificates/{CertId1}/revoke` → 409, `{"error":"ALREADY_REVOKED",...}` | Throws `Exception` with message containing `"revoke certificate"` |
+| `RenewCertificateAsync_ReturnsNewCertificate_OnSuccess` | `POST /GenerateOrderSSL` → 200, success with new order number | New order number returned |
+
+### ListCertificatesAsync — POST /GetOrderReport (paginated)
-### RenewCertificateAsync
+`ListCertificatesAsync` is an `IAsyncEnumerable` that paginates
+`GetOrderReport`. Pagination stops when the returned page is empty or all pages are fetched.
| Test | Stub | Assertion |
|------|------|-----------|
-| `RenewCertificateAsync_ReturnsNewCertificate_OnSuccess` | `POST /api/v1/certificates/{CertId1}/renew` → 200, renew response with `id="cert-renewed-001"` | `Id == "cert-renewed-001"`; `Status == "issued"`; `Certificate` contains `"BEGIN CERTIFICATE"` |
-
-### ListCertificatesAsync
+| `ListCertificatesAsync_ReturnsSinglePage_WhenOnlyOnePage` | `POST /GetOrderReport` → single-page with `ORD-AAA-111` | Enumeration yields exactly 1 item |
+| `ListCertificatesAsync_IteratesMultiplePages` | Two pages: page 1 (`ORD-AAA-111`), page 2 (`ORD-BBB-222`) | Enumeration yields 2 items; both order numbers present |
+| `ListCertificatesAsync_StopsWhenEmptyPageReturned` | `POST /GetOrderReport` → empty `ordersArray` | Enumeration yields 0 items |
+| `ListCertificatesAsync_RespectsIssuedAfterFilter` | Any request with `issuedAfter` parameter → single-page | Enumeration yields 1 item; `issuedAfter` key present in the request log |
-`ListCertificatesAsync` is an `IAsyncEnumerable` that pages through results using a `page` query parameter, stopping when the returned page is empty or the last page has been fetched.
+### GetProfilesAsync — POST /GetProductDetails
| Test | Stub | Assertion |
|------|------|-----------|
-| `ListCertificatesAsync_ReturnsSinglePage_WhenOnlyOnePage` | `GET /api/v1/certificates?page=1` → 200, single-page list with one cert (`CertId1`) | Enumeration yields exactly 1 item with `Id == CertId1` |
-| `ListCertificatesAsync_IteratesMultiplePages` | `GET /api/v1/certificates?page=1` → page 1 of 2 (`CertId1`); `GET /api/v1/certificates?page=2` → page 2 of 2 (`CertId2`) | Enumeration yields 2 items; both `CertId1` and `CertId2` are present |
-| `ListCertificatesAsync_StopsWhenEmptyPageReturned` | `GET /api/v1/certificates?page=1` → 200, `{"data":[],"pagination":{"total":0,...}}` | Enumeration yields 0 items |
-| `ListCertificatesAsync_RespectsIssuedAfterFilter` | Any `GET /api/v1/certificates` request that includes an `issuedAfter` query parameter → 200, single-page list | Enumeration yields 1 item; WireMock log entry for the first request has an `issuedAfter` key in its query string |
+| `GetProfilesAsync_ReturnsProfiles_WhenServerResponds` | `POST /GetProductDetails` → two products in nested category envelope | Result has 2 items; `ProfileIdTls` and `ProfileIdClient` present; all `Active == true` |
+| `GetProfilesAsync_ReturnsEmptyList_WhenNoProductsReturned` | `POST /GetProductDetails` → empty `productDetails` array | Result is empty |
-### GetProfilesAsync
+### DCV endpoints
| Test | Stub | Assertion |
|------|------|-----------|
-| `GetProfilesAsync_ReturnsProfiles_WhenServerResponds` | `GET /api/v1/profiles` → 200, two-profile JSON (`ProfileIdTls`, `ProfileIdClient`, both active) | Result has 2 items; both profile IDs present; all have `Active == true` |
-| `GetProfilesAsync_ReturnsEmptyList_WhenDataIsEmpty` | `GET /api/v1/profiles` → 200, `{"data":[]}` | Result is empty |
+| `GetDcvAsync_ReturnsToken_WhenServerRespondsOk` | `POST /GetDcv` → 200, `dcvDetails.token="abc123token"` | Returns token string |
+| `GetDcvAsync_Throws_WhenMetaStatusIsFailure` | `POST /GetDcv` → 200, failure meta | Throws `Exception` |
+| `GetDcvAsync_Throws_WhenServerReturns401` | `POST /GetDcv` → 401 | Throws `Exception` |
+| `VerifyDcvAsync_Succeeds_WhenServerRespondsOk` | `POST /VerifyDcv` → 200, success meta | Does not throw |
+| `VerifyDcvAsync_Throws_WhenMetaStatusIsFailure` | `POST /VerifyDcv` → 200, failure meta | Throws `Exception` |
+| `VerifyDcvAsync_Throws_WhenServerReturns401` | `POST /VerifyDcv` → 401 | Throws `Exception` |
+| `VerifyDcvAsync_Throws_WhenServerReturns500` | `POST /VerifyDcv` → 500 | Throws `Exception` |
+
+---
+
+## CERTInextClientRequestShapeTests
+
+Uses WireMock to verify that the `GenerateOrderSSL` request body includes or omits optional
+blocks depending on connector configuration.
+
+| Test | Assertion |
+|------|-----------|
+| `OrganizationNumber_Set_EmitsPreVettedOrganizationDetails` | Body includes `organizationDetails.preVetting="1"` and the configured `organizationNumber` |
+| `OrganizationNumber_Blank_OmitsOrganizationDetailsBlock` | Body omits `organizationDetails` entirely |
+| `GroupNumber_Set_EmitsDelegationInformation` | Body includes `delegationInformation.groupNumber` |
+| `GroupNumber_Blank_OmitsDelegationInformation` | Body omits `delegationInformation` |
+| `TechnicalContact_AllSet_EmitsExplicitValues` | Body includes `technicalPointOfContact` with the configured values |
+| `TechnicalContact_AllBlank_FallsBackToRequestorDefaults` | Body includes `technicalPointOfContact` fields derived from `RequestorName`/`RequestorEmail` |
+| `SslBodyDefaults_AreEmitted_FromCustomConnectorValues` | Custom connector-level defaults appear in the order body |
+| `SslBodyDefaults_AreSafeFallbacks_WhenConfigUntouched` | Default values are emitted without throwing when optional config fields are omitted |
+| `ValidityDays_OnRequest_OverridesConnectorDefault` | `ValidityDays` template parameter overrides the connector `SubscriptionValidityYears` |
---
## CERTInextCAPluginTests
-The plugin is constructed by passing an `ICERTInextClient` mock directly: `new CERTInextCAPlugin(client)`. Moq is configured with `MockBehavior.Strict`, so any call to a method that has no setup will throw, making unexpected client calls immediately visible.
+The plugin is constructed with `new CERTInextCAPlugin(client)` where `client` is a Moq strict
+mock of `ICERTInextClient`. Any call to an unset-up method throws immediately, making unexpected
+client calls visible.
-Two local helpers are used across tests:
-- `MakeProductInfo(profileId, extras)` — builds an `EnrollmentProductInfo` with `ProductID` and a `ProductParameters` dictionary containing `"ProfileId"`
-- `AsyncEnum(items)` — wraps a `List` as an `IAsyncEnumerable` for use in `ListCertificatesAsync` mock setups
+Two helpers are used across tests:
+- `MakeProductInfo(profileId, extras)` — builds an `EnrollmentProductInfo` with `ProfileId` in
+ `ProductParameters`
+- `AsyncEnum(items)` — wraps a list as `IAsyncEnumerable`
### Ping
| Test | Mock setup | Assertion |
|------|-----------|-----------|
| `Ping_Succeeds_WhenClientPingAsyncDoesNotThrow` | `PingAsync` returns `Task.CompletedTask` | Does not throw; `PingAsync` called exactly once |
-| `Ping_Rethrows_WhenClientPingThrows` | `PingAsync` throws `Exception("Connection refused")` | Throws `Exception` with message matching `"*CERTInext*Connection refused*"` — verifies the plugin wraps the error with context |
-| `Ping_SkipsConnectivityTest_WhenConnectorIsDisabled` | Strict mock with no setups; `CERTInextConfig.Enabled = false` | Does not throw; no client method is called (verified via `VerifyNoOtherCalls()`) |
+| `Ping_Rethrows_WhenClientPingThrows` | `PingAsync` throws `Exception("Connection refused")` | Throws `Exception` with message matching `"*CERTInext*Connection refused*"` |
+| `Ping_SkipsConnectivityTest_WhenConnectorIsDisabled` | Strict mock, no setups; `CERTInextConfig.Enabled = false` | Does not throw; no client method called (verified via `VerifyNoOtherCalls()`) |
### GetProductIds
| Test | Mock setup | Assertion |
|------|-----------|-----------|
-| `GetProductIds_ReturnsStaticProductList` | No mock calls expected (strict mock verifies this) | Returns 10 items; contains `DV SSL`, `OV SSL`, `EV SSL`; no client method is called |
-
-`GetProductIds()` returns a hardcoded static list rather than making a live API call. This is intentional: `IAnyCAPlugin.GetProductIds()` is synchronous (calling `GetAwaiter().GetResult()` risks deadlock), and the Keyfactor integration-manifest tooling requires a known list at reflection time. The `VerifyNoOtherCalls()` assertion on the strict mock confirms no API call is made.
+| `GetProductIds_ReturnsStaticProductList` | No mock calls expected | Returns 10 items including `DV SSL`, `OV SSL`, `EV SSL`; no client method called |
-### ValidateCAConnectionInfo
-
-The plugin validates the connection info dictionary before any API calls are made.
-
-| Test | Input | Assertion |
-|------|-------|-----------|
-| `ValidateCAConnectionInfo_Throws_WhenApiUrlMissing` | `AuthMode="ApiKey"`, `ApiKey` set, no `ApiUrl` | Throws `AnyCAValidationException` with message matching `"*ApiUrl*required*"` |
-| `ValidateCAConnectionInfo_Throws_WhenApiUrlIsNotUri` | `ApiUrl="not-a-url"` | Throws `AnyCAValidationException` with message matching `"*valid absolute URI*"` |
-| `ValidateCAConnectionInfo_Throws_WhenApiKeyMissingForApiKeyMode` | `ApiUrl` set, `AuthMode="ApiKey"`, no `ApiKey` | Throws `AnyCAValidationException` with message matching `"*ApiKey*required*"` |
-| `ValidateCAConnectionInfo_Throws_WhenBasicCredentialsMissing` | `ApiUrl` set, `AuthMode="Basic"`, no `Username` or `Password` | Throws `AnyCAValidationException` with message matching `"*Username*required*"` |
-| `ValidateCAConnectionInfo_Throws_WhenOAuth2FieldsMissing` | `ApiUrl` set, `AuthMode="OAuth2"`, no token URL, client ID, or secret | Throws `AnyCAValidationException` with message matching `"*OAuth2TokenUrl*required*"` |
-| `ValidateCAConnectionInfo_Throws_WhenAuthModeIsInvalid` | `ApiUrl` set, `AuthMode="CertificateBased"` | Throws `AnyCAValidationException` with message matching `"*AuthMode*must be one of*"` |
-| `ValidateCAConnectionInfo_SkipsValidation_WhenDisabled` | `Enabled=false`, nothing else set | Does not throw; no calls made to the mock client |
-
-### ValidateProductInfo
-
-| Test | Input | Assertion |
-|------|-------|-----------|
-| `ValidateProductInfo_Throws_WhenProfileIdMissing` | `ProductID = string.Empty`, valid connection info | Throws `AnyCAValidationException` with message matching `"*ProfileId*required*"` |
+`GetProductIds()` returns a hardcoded static list — no API call is made. The strict mock's
+`VerifyNoOtherCalls()` confirms this.
### Enroll
-The `Enroll` method accepts an `EnrollmentType` parameter. `New` and `Reissue` both route to `EnrollCertificateAsync`. `RenewOrReissue` routes to `RenewCertificateAsync` when `PriorCertSN` is present in `ProductParameters`, and falls back to `EnrollCertificateAsync` when it is not.
+The `Enroll` method selects a path based on `EnrollmentType`. Both `New` and `Reissue` submit a
+new `GenerateOrderSSL` order. `RenewOrReissue` also submits `GenerateOrderSSL` (CERTInext has
+no dedicated renewal endpoint) but applies the renewal-window check to determine how Command
+tracks the old→new certificate relationship.
| Test | EnrollmentType | Mock setup | Assertion |
|------|---------------|-----------|-----------|
-| `Enroll_New_CallsEnrollAsync_AndReturnsIssuedResult` | `New` | `EnrollCertificateAsync` (matching `ProfileId == ProfileIdTls`) returns `IssuedEnrollResponse()` | `CARequestID == CertId1`; `Status == EndEntityStatus.GENERATED`; `Certificate` contains `"BEGIN CERTIFICATE"`; client called once |
-| `Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval` | `New` | `EnrollCertificateAsync` returns `PendingEnrollResponse()` | `Status == EndEntityStatus.EXTERNALVALIDATION` |
-| `Enroll_New_Throws_WhenProfileIdNotSet` | `New` | No setup (strict mock — any unexpected call throws) | Throws `Exception` with message matching `"*ProfileId*required*"` before calling the client |
-| `Enroll_Reissue_AlsoCallsEnrollAsync` | `Reissue` | `EnrollCertificateAsync` returns `IssuedEnrollResponse()` | `Status == EndEntityStatus.GENERATED`; `EnrollCertificateAsync` called once |
-| `Enroll_Renew_FallsBackToNewEnroll_WhenNoPriorCertSn` | `RenewOrReissue` | `EnrollCertificateAsync` returns `IssuedEnrollResponse()` | `CARequestID == CertId1`; `EnrollCertificateAsync` called once; `RenewCertificateAsync` never called |
+| `Enroll_New_CallsEnrollAsync_AndReturnsIssuedResult` | `New` | `PlaceOrderAsync` returns `ORD-AAA-111` | `CARequestID == "ORD-AAA-111"`; `Status == GENERATED` |
+| `Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval` | `New` | `PlaceOrderAsync` → pending status | `Status == EXTERNALVALIDATION` |
+| `Enroll_New_Throws_WhenProfileIdNotSet` | `New` | Strict mock — no setups | Throws before calling the client |
+| `Enroll_Reissue_AlsoCallsEnrollAsync` | `Reissue` | `PlaceOrderAsync` returns issued | `Status == GENERATED`; called once |
+| `Enroll_Renew_FallsBackToNewEnroll_WhenNoPriorCertSn` | `RenewOrReissue` | `PlaceOrderAsync` returns issued | `CARequestID == "ORD-AAA-111"`; no dedicated renew call |
### GetSingleRecord
| Test | Mock setup | Assertion |
|------|-----------|-----------|
-| `GetSingleRecord_ReturnsMappedCertificate_ForIssuedCert` | `GetCertificateAsync(CertId1)` returns `IssuedCertRecord()` | `CARequestID == CertId1`; `Status == EndEntityStatus.GENERATED`; `Certificate` contains `"BEGIN CERTIFICATE"`; `ProductID == ProfileIdTls` |
-| `GetSingleRecord_ReturnsMappedCertificate_ForRevokedCert` | `GetCertificateAsync(CertId3)` returns `RevokedCertRecord()` | `Status == EndEntityStatus.REVOKED`; `RevocationDate` is not null; `RevocationReason == 1` (keyCompromise) |
-| `GetSingleRecord_Rethrows_WhenCertNotFound` | `GetCertificateAsync("no-such-id")` throws `KeyNotFoundException` | Rethrows `KeyNotFoundException` |
+| `GetSingleRecord_ReturnsMappedCertificate_ForIssuedCert` | `TrackOrderAsync("ORD-AAA-111")` returns issued track response; `GetCertificateAsync` returns PEM | `Status == GENERATED`; PEM present; `ProductID == ProfileIdTls` |
+| `GetSingleRecord_ReturnsMappedCertificate_ForRevokedCert` | `TrackOrderAsync("ORD-CCC-333")` returns revoked response | `Status == REVOKED`; `RevocationDate` non-null; `RevocationReason == 1` |
+| `GetSingleRecord_Rethrows_WhenCertNotFound` | Client throws `KeyNotFoundException` | Rethrows `KeyNotFoundException` |
### Revoke
-The plugin looks up the certificate first to check whether it is already revoked, then calls `RevokeCertificateAsync` only if it is not. CRL reason codes (integers) are mapped to string values expected by the CERTInext API.
+The plugin checks the current certificate status before calling `RevokeOrder`. CRL reason codes
+(integers) are mapped to CERTInext string values.
| Test | Mock setup | Assertion |
|------|-----------|-----------|
-| `Revoke_CallsRevokeCertificateAsync_AndReturnsRevokedStatus` | `GetCertificateAsync(CertId1)` returns issued cert; `RevokeCertificateAsync(CertId1, ...)` returns `Task.CompletedTask` | Returns `EndEntityStatus.REVOKED`; `RevokeCertificateAsync` called once with `Reason == "keyCompromise"` (CRL code 1) |
-| `Revoke_ReturnsAlreadyRevoked_WhenCertAlreadyRevoked` | `GetCertificateAsync(CertId3)` returns revoked cert | Returns `EndEntityStatus.REVOKED`; `RevokeCertificateAsync` never called |
-| `Revoke_MapsAllCrlReasonCodes` | For each reason code 0–5: `GetCertificateAsync` returns issued cert; `RevokeCertificateAsync` matched only when `Reason` equals the expected string | Verifies the complete mapping: `0→"unspecified"`, `1→"keyCompromise"`, `2→"caCompromise"`, `3→"affiliationChanged"`, `4→"superseded"`, `5→"cessationOfOperation"` |
+| `Revoke_CallsRevokeCertificateAsync_AndReturnsRevokedStatus` | `TrackOrderAsync` returns issued cert; `RevokeOrderAsync` returns `Task.CompletedTask` | Returns `REVOKED`; `RevokeOrderAsync` called once with correct reason string |
+| `Revoke_ReturnsAlreadyRevoked_WhenCertAlreadyRevoked` | `TrackOrderAsync` returns revoked cert | Returns `REVOKED`; `RevokeOrderAsync` never called |
+| `Revoke_MapsAllCrlReasonCodes` | Per reason code 0–5 and beyond | Verifies mapping: `0→"unspecified"`, `1→"keyCompromise"`, `2→"caCompromise"`, `3→"affiliationChanged"`, `4→"superseded"`, `5→"cessationOfOperation"`, extended codes also covered by `CERTInextCAPluginCoverageTests` |
### Synchronize
-`Synchronize` iterates `ListCertificatesAsync` and adds mapped `AnyCAPluginCertificate` objects to a `BlockingCollection`. A full sync passes `null` as `issuedAfter`; a delta sync passes the `lastSync` timestamp. Certificates with a status that cannot be mapped (e.g., `"failed"`) are skipped.
+`Synchronize` iterates `ListOrdersAsync` and posts mapped `AnyCAPluginCertificate` objects to a
+`BlockingCollection`. Full sync passes `null` as `issuedAfter`; delta sync passes `lastSync`.
| Test | Mock setup | Assertion |
|------|-----------|-----------|
-| `Synchronize_FullSync_AddsAllCertsToBuffer` | `ListCertificatesAsync(null, ...)` returns two issued certs (`CertId1`, `CertId2`) | Buffer contains 2 items; both IDs present |
-| `Synchronize_DeltaSync_PassesLastSyncFilter` | `ListCertificatesAsync` captures the `issuedAfter` argument and returns one cert | Captured `issuedAfter` equals the `lastSync` value passed to `Synchronize` |
-| `Synchronize_FullSync_PassesNullIssuedAfter` | `ListCertificatesAsync` captures `issuedAfter` and returns empty | Even when `lastSync` is non-null, `fullSync: true` causes `issuedAfter` to be passed as `null` |
-| `Synchronize_SkipsFailedCertificates` | `ListCertificatesAsync` returns one issued cert and one cert with `status="failed"` and `Certificate=null` | Buffer contains exactly 1 item (`CertId1`); the failed cert is dropped |
-| `Synchronize_HonoursCancellation` | Custom async enumerable that yields one cert, cancels the `CancellationTokenSource`, then calls `ct.ThrowIfCancellationRequested()` before yielding a second | Throws `OperationCanceledException` |
-| `Synchronize_MapsRevokedCertificates_Correctly` | `ListCertificatesAsync` returns one revoked cert (`CertId3`) | Buffer contains 1 item; `Status == EndEntityStatus.REVOKED`; `RevocationDate` is not null |
-| `Synchronize_CallsCompleteAdding_OnNormalExit` | `ListCertificatesAsync` returns empty | `buffer.IsAddingCompleted == true` after `Synchronize` returns normally |
-| `Synchronize_CallsCompleteAdding_OnCancellation` | Custom async enumerable that cancels mid-iteration | `buffer.IsAddingCompleted == true` even after `OperationCanceledException` is thrown |
+| `Synchronize_FullSync_AddsAllCertsToBuffer` | `ListOrdersAsync(null, ...)` returns two issued orders | Buffer contains 2 items; both order numbers present |
+| `Synchronize_DeltaSync_PassesLastSyncFilter` | `ListOrdersAsync` captures `issuedAfter` | Captured value equals `lastSync` |
+| `Synchronize_FullSync_PassesNullIssuedAfter` | `ListOrdersAsync` captures `issuedAfter` | Even when `lastSync` is non-null, `fullSync:true` forces `issuedAfter=null` |
+| `Synchronize_SkipsFailedCertificates` | Returns one issued + one with unknown/failed status | Buffer contains exactly 1 item |
+| `Synchronize_HonoursCancellation` | Async enumerable that cancels mid-iteration | Throws `OperationCanceledException` |
+| `Synchronize_MapsRevokedCertificates_Correctly` | Returns one revoked record | Buffer item `Status == REVOKED`; `RevocationDate` non-null |
+| `Synchronize_CallsCompleteAdding_OnNormalExit` | Returns empty | `buffer.IsAddingCompleted == true` |
+| `Synchronize_CallsCompleteAdding_OnCancellation` | Cancels mid-iteration | `buffer.IsAddingCompleted == true` even after `OperationCanceledException` |
+
+**Note on `CompleteAdding`:** `Synchronize` calls `blockingBuffer.CompleteAdding()` in a `finally`
+block. Tests must not call `buffer.CompleteAdding()` themselves — doing so after the plugin has
+already called it throws `InvalidOperationException`.
+
+---
-**Note on `CompleteAdding`:** `Synchronize` calls `blockingBuffer.CompleteAdding()` in a `finally` block. Tests must NOT call `buffer.CompleteAdding()` themselves — doing so after the plugin has already called it throws `InvalidOperationException`.
+## CERTInextCAPluginPublicSurfaceTests
-### RenewalWindowDays — P2-C semantic
+Reflection-based contract tests that verify the no-DCV build does not expose any public types,
+fields, methods, or constructors that reference `IDomainValidatorFactory` or other IAnyCAPlugin
+3.3-only types. These tests ensure the default build loads cleanly on AnyCA Gateway 25.5.x hosts.
-`RenewalWindowDays` controls whether a `RenewOrReissue` enrollment uses the CERTInext renew API or falls back to a fresh order. The semantics are "Option A — window before expiry":
+| Test | What it checks |
+|------|---------------|
+| `NoPublicConstructor_ReferencesV3Point3OnlyTypes` | No public constructor has a parameter typed as a 3.3-only interface |
+| `NoInstanceField_DeclaredTypeReferencesV3Point3OnlyTypes` | No public or private instance field is typed as a 3.3-only type |
+| `NoNestedType_ImplementsV3Point3OnlyInterface` | No nested type implements a 3.3-only interface |
+| `NoPublicMethod_SignatureReferencesV3Point3OnlyTypes` | No public method has a parameter or return type referencing 3.3-only types |
+| `ParameterlessConstructor_IsPublic` | The plugin has a public parameterless constructor (required by the gateway host for reflection-based instantiation) |
+| `SetDomainValidatorFactory_AcceptsObject_NotIDomainValidatorFactory` | The DCV injection method accepts `object`, not the 3.3-only `IDomainValidatorFactory`, so the method signature loads on 3.2 hosts |
+| `SetDomainValidatorFactory_NullArgument_LeavesDcvDisabled` | Passing `null` does not enable DCV |
+| `SetDomainValidatorFactory_NonFactoryArgument_IsIgnored` | Passing a non-factory object does not enable DCV |
-```
-useRenewalApi = expiry > DateTime.UtcNow && expiry <= DateTime.UtcNow.AddDays(RenewalWindowDays)
-```
+---
+
+## BoundedDcvSyncTests
-| Test | Expiry | Window | Expected path |
-|------|--------|--------|---------------|
-| `RenewOrReissue_UsesRenewApi_WhenCertExpiresWithinWindow` | now + 30 days | 90 days | Renewal API |
-| `RenewOrReissue_UsesNewEnroll_WhenCertExpiresOutsideWindow` | now + 120 days | 90 days | New enroll (too early) |
-| `RenewOrReissue_UsesNewEnroll_WhenCertAlreadyExpired` | now − 5 days | 90 days | New enroll (graceful degradation) |
+Pure unit tests for the age-window and per-pass cap logic in `TryRunDcvDuringSyncAsync`. No
+network I/O. Verifies that:
+- Orders within the configured age window are attempted
+- Orders older than the window are skipped (to avoid retrying abandoned orders indefinitely)
+- Orders at the exact age boundary are attempted
+- Orders with unknown dates are attempted (not starved)
+- Age window of 0 disables the filter
+- The per-pass cap skips orders once the cap is reached
+- Cap of 0 disables the cap
+- Age skip takes precedence over the cap check
+
+---
+
+## RateLimitRetryTests
+
+Pure unit tests for the `IsRateLimitSurface` and `ComputeRateLimitBackoffSeconds` helpers:
+- `IsRateLimitSurface` recognises the documented CERTInext rate-limit error phrase and rejects
+ unrelated strings
+- `ComputeRateLimitBackoffSeconds` produces a result within the expected jittered range for each
+ attempt number
+- Attempt values below 1 are clamped to 1
---
## MockCertificateData
-`MockCertificateData` is a static internal class shared by both test suites. It provides two types of output:
+`MockCertificateData` is a static internal class shared across test suites. It provides realistic
+fake CERTInext API response objects and JSON payloads.
-- **Object helpers** — return typed API response objects for use in Moq setups
-- **JSON helpers** — return raw JSON strings for use in WireMock stubs
+The real CERTInext API uses HTTP POST for all endpoints and wraps every response in a `meta`
+block with `status: "1"` (success) or `status: "0"` (failure).
### Constants
| Constant | Value | Used for |
|----------|-------|---------|
| `FakePemCertificate` | PEM block starting with `-----BEGIN CERTIFICATE-----` | Certificate body in all responses |
-| `FakeCsrPem` | PEM block starting with `-----BEGIN CERTIFICATE REQUEST-----` | CSR body in enroll and renew requests |
-| `CertId1` | `"cert-aaa-111"` | Default issued certificate ID |
-| `CertId2` | `"cert-bbb-222"` | Second certificate ID (pagination, delta sync) |
-| `CertId3` | `"cert-ccc-333"` | Default revoked certificate ID |
-| `ProfileIdTls` | `"tls-server"` | TLS server profile |
-| `ProfileIdClient` | `"client-auth"` | Client authentication profile |
-
-### Object helpers (Moq)
+| `FakeCsrPem` | PEM block starting with `-----BEGIN CERTIFICATE REQUEST-----` | CSR body in enroll requests |
+| `OrderNumber1` | `"ORD-AAA-111"` | Primary order number (also aliased as `CertId1`) |
+| `OrderNumber2` | `"ORD-BBB-222"` | Second order number (also aliased as `CertId2`) |
+| `OrderNumber3` | `"ORD-CCC-333"` | Revoked order number (also aliased as `CertId3`) |
+| `ProfileIdTls` | `"tls-server"` | TLS server product code placeholder |
+| `ProfileIdClient` | `"client-auth"` | Client auth product code placeholder |
+
+`CertId1/2/3` are backward-compatibility aliases for `OrderNumber1/2/3`.
+
+### JSON helpers (WireMock stubs)
+
+| Method | Endpoint | Notes |
+|--------|----------|-------|
+| `ValidateCredentialsSuccessJson()` | `POST /ValidateCredentials` | Success meta only |
+| `ValidateCredentialsFailureJson(code, msg)` | `POST /ValidateCredentials` | Failure meta |
+| `GenerateOrderSuccessJson(orderNumber)` | `POST /GenerateOrderSSL` | Includes `orderDetails.orderNumber` |
+| `TrackOrderIssuedJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="9"` (GENERATED) |
+| `TrackOrderPendingJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="1"` (SetupPending) |
+| `TrackOrderRevokedJson(orderNumber)` | `POST /TrackOrder` | `certificateStatusId="22"`, revocation details present |
+| `GetCertificateSuccessJson()` | `POST /GetCertificate` | PEM in `certificateDetails.endEntityCertificate`; serial `"0A1B2C3D4E5F"` |
+| `RevokeSuccessJson()` | `POST /RevokeOrder` | Success meta only |
+| `OrderReportSinglePageJson()` | `POST /GetOrderReport` | One entry, `ORD-AAA-111` |
+| `OrderReportPageJson(orderNumbers, total, pages, current)` | `POST /GetOrderReport` | Multi-entry paginated response |
+| `OrderReportEmptyJson()` | `POST /GetOrderReport` | Empty `ordersArray`, `noOfPages=0` |
+| `GetProductDetailsJson()` | `POST /GetProductDetails` | Nested category envelope with two products |
+| `GetProductDetailsEmptyJson()` | `POST /GetProductDetails` | Empty `productDetails` array |
+| `ApiFailureJson(code, msg)` | Any endpoint | Generic `meta.status="0"` failure |
+| `GetDcvSuccessJson(token)` | `POST /GetDcv` | `dcvDetails.token` |
+| `GetDcvFailureJson(code, msg)` | `POST /GetDcv` | Failure meta |
+| `VerifyDcvSuccessJson()` | `POST /VerifyDcv` | Success meta only |
+| `VerifyDcvFailureJson(code, msg)` | `POST /VerifyDcv` | Failure meta |
+| `OAuth2TokenJson(expiresIn)` | OAuth token endpoint | `access_token="fake-bearer-token-abc123"` |
+| `ServerErrorJson()` | Any | Generic 500 error body (not meta-wrapped) |
+| `UnauthorizedJson()` | Any | Generic 401 error body (not meta-wrapped) |
+
+### Object helpers (Moq setups)
| Method | Returns |
|--------|---------|
| `ActiveProfiles()` | Two `ProfileInfo` objects, both `Active=true`: `ProfileIdTls` and `ProfileIdClient` |
| `MixedProfiles()` | Three `ProfileInfo` objects: `ProfileIdTls` (active), `"legacy-profile"` (inactive), `ProfileIdClient` (active) |
-| `IssuedEnrollResponse(id)` | `EnrollCertificateResponse` with `Status="issued"`, `FakePemCertificate`, `SerialNumber="0A1B2C3D4E5F"` |
+| `IssuedEnrollResponse(id)` | `EnrollCertificateResponse` with `Status="issued"`, PEM, `SerialNumber="0A1B2C3D4E5F"` |
| `PendingEnrollResponse(id)` | `EnrollCertificateResponse` with `Status="pending_approval"`, `Certificate=null` |
-| `IssuedCertRecord(id)` | `GetCertificateResponse` with `Status="issued"`, `FakePemCertificate`, `ProfileId=ProfileIdTls`, issued 2024-06-01, expires 2025-06-01 |
-| `RevokedCertRecord(id)` | `GetCertificateResponse` with `Status="revoked"`, `RevokedAt=2024-03-15`, `RevocationReason="keyCompromise"` |
-
-### JSON helpers (WireMock)
-
-| Method | Returns |
-|--------|---------|
-| `EnrollResponseJson(id, status)` | Enroll response JSON with `status="issued"` and `FakePemCertificate` escaped for JSON |
-| `PendingEnrollResponseJson(id)` | Enroll response JSON with `status="pending_approval"` and `certificate:null` |
-| `GetCertificateJson(id, status)` | Single certificate JSON including SANs, subject, CSR, and revocation fields |
-| `RevokedCertificateJson(id)` | Certificate JSON with `status="revoked"` and revocation fields populated |
-| `SinglePageListJson(id)` | Paginated list JSON: one cert on page 1 of 1 |
-| `TwoPageListJson(page)` | Paginated list JSON: call with `page=1` or `page=2` to get the respective page of a two-page result set |
-| `RevokeSuccessJson()` | `{"success":true,"message":"Certificate revoked successfully."}` |
-| `RenewResponseJson(newId)` | Renew response JSON with a new certificate ID |
-| `HealthOkJson()` | `{"status":"ok","version":"2.1.0"}` |
-| `OAuth2TokenJson(expiresIn)` | OAuth2 token response with `access_token="fake-bearer-token-abc123"` |
-| `ProfilesJson(profiles)` | Profiles list JSON; defaults to `ActiveProfiles()` if no argument passed |
-| `NotFoundErrorJson(id)` | 404 error body with the given ID in the message |
-| `ServerErrorJson()` | Generic 500 error body |
-| `UnauthorizedJson()` | 401 error body |
-
-`EscapeForJson` is a private helper used internally to embed `FakePemCertificate` and `FakeCsrPem` (which contain newlines and no special JSON escaping) inside JSON string values.
+| `IssuedCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="issued"`, PEM, `ProfileId=ProfileIdTls` |
+| `PendingCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="pending_approval"`, no certificate — maps to `EXTERNALVALIDATION` |
+| `RevokedCertRecord(id)` | `LegacyGetCertificateResponse` with `Status="revoked"`, `RevokedAt`, `RevocationReason="keyCompromise"` |
+| `DcvPendingTrackResponse(orderNumber, domain)` | `TrackOrderResponse` with one DNS-TXT entry at `dcvStatus="0"` (pending) |
+| `DcvVerifiedTrackResponse(orderNumber, domain)` | `TrackOrderResponse` with DNS-TXT entry at `dcvStatus="1"` (validated) |
+| `AlreadyIssuedTrackResponse(orderNumber)` | `TrackOrderResponse` with `certificateStatusId="9"` (GENERATED) — DCV should be skipped |
+| `DcvTokenResponse(token)` | `GetDcvResponse` with `DcvDetails.Token` set |
---
@@ -295,20 +390,23 @@ useRenewalApi = expiry > DateTime.UtcNow && expiry <= DateTime.UtcNow.AddDays(Re
### Which suite to add to
-- **Add to `CERTInextClientTests`** when testing HTTP-level behavior: a new endpoint, a new error status code, authentication header details, query parameter serialization, or any behavior where the actual request sent over the wire matters.
-- **Add to `CERTInextCAPluginTests`** when testing plugin logic: a new enrollment type, a new validation rule, a new status mapping, or how the plugin responds to specific client return values or exceptions.
+- **`CERTInextClientTests`** — when testing HTTP-level behaviour: a new endpoint, error status
+ code, authentication header detail, body serialisation, or query parameter.
+- **`CERTInextClientRequestShapeTests`** — when verifying that the request body includes or omits
+ specific JSON blocks based on connector configuration.
+- **`CERTInextCAPluginTests` / `CERTInextCAPluginCoverageTests`** — when testing plugin logic: a
+ new enrollment type, validation rule, status mapping, or response to specific client return values.
### Adding a new WireMock stub
-1. Register a stub in the test body using the existing pattern:
+1. Register a stub in the test body:
```csharp
_server
- .Given(Request.Create().WithPath("/api/v1/your-endpoint").UsingGet())
+ .Given(Request.Create().WithPath("/YourEndpoint").UsingPost())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
- .WithBody(@"{""yourField"":""yourValue""}"));
+ .WithBody(MockCertificateData.YourResponseJson()));
```
-2. If the response shape is reused across tests, add a JSON helper to `MockCertificateData` following the same `string YourResponseJson(...)` convention.
-3. If you need a typed object for a Moq setup that mirrors the new JSON, add a corresponding object helper (e.g., `YourResponse()`) that returns a populated API response object.
-4. Verify request details (headers, query parameters, body) by inspecting `_server.LogEntries` after the call, following the pattern in `PingAsync_SendsApiKeyHeader_WhenAuthModeIsApiKey` and `ListCertificatesAsync_RespectsIssuedAfterFilter`.
+2. Add a `YourResponseJson(...)` JSON helper to `MockCertificateData` if the shape is reused.
+3. Verify request details by inspecting `_server.LogEntries` after the call.
diff --git a/CERTInext/API/CertificateRequest.cs b/CERTInext/API/CertificateRequest.cs
index 2db42c6..7f02df0 100644
--- a/CERTInext/API/CertificateRequest.cs
+++ b/CERTInext/API/CertificateRequest.cs
@@ -142,6 +142,14 @@ public class SslOrderDetails
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public OrganizationDetails OrganizationDetails { get; set; }
+ [JsonPropertyName("delegationInformation")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public DelegationInformation DelegationInformation { get; set; }
+
+ [JsonPropertyName("technicalPointOfContact")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public TechnicalPointOfContact TechnicalPointOfContact { get; set; }
+
[JsonPropertyName("additionalInformation")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public AdditionalInformation AdditionalInformation { get; set; }
@@ -224,6 +232,39 @@ public class OrganizationDetails
public string OrganizationNumber { get; set; }
}
+ ///
+ /// Routes the order to a specific account group within CERTInext. Required by many
+ /// accounts even though the V1 docs list it as optional — without it, orders may be
+ /// placed against the default group and queued for additional review.
+ ///
+ public class DelegationInformation
+ {
+ [JsonPropertyName("groupNumber")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string GroupNumber { get; set; }
+ }
+
+ ///
+ /// Technical point of contact metadata sent with SSL orders. CERTInext uses these
+ /// fields as the secondary contact for issuance-related notifications. When omitted,
+ /// some product configurations queue the order in Pending System RA waiting
+ /// for the field to be populated manually.
+ ///
+ public class TechnicalPointOfContact
+ {
+ [JsonPropertyName("tpcName")]
+ public string TpcName { get; set; }
+
+ [JsonPropertyName("tpcEmail")]
+ public string TpcEmail { get; set; }
+
+ [JsonPropertyName("tpcIsdCode")]
+ public string TpcIsdCode { get; set; } = "1";
+
+ [JsonPropertyName("tpcMobileNumber")]
+ public string TpcMobileNumber { get; set; }
+ }
+
public class AdditionalInformation
{
[JsonPropertyName("remarks")]
@@ -288,6 +329,87 @@ public class TrackOrderDetails
public string OrderNumber { get; set; }
}
+ // ---------------------------------------------------------------------------
+ // GetDcv — POST {baseURL}GetDcv
+ // Retrieves Domain Control Validation token / file content / approver emails
+ // for a given (orderNumber, domainName, dcvMethod) tuple.
+ //
+ // The CERTInext V1 spec defines this body as wrapped in a "dcvDetails" block.
+ // Note: the Postman example for GetDcv uses "orderDetails" instead — this is
+ // an example typo; the inline spec, the response body, and the VerifyDcv body
+ // all use "dcvDetails" consistently.
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Request body for POST {baseURL}GetDcv.
+ /// Returns DCV instructions (token / file / approver emails) for one domain
+ /// in the given order.
+ ///
+ public class GetDcvRequest
+ {
+ [JsonPropertyName("meta")]
+ public RequestMeta Meta { get; set; }
+
+ [JsonPropertyName("dcvDetails")]
+ public DcvRequestDetails DcvDetails { get; set; }
+ }
+
+ ///
+ /// Common request body for both GetDcv and VerifyDcv — both endpoints take the
+ /// same set of identification fields. is only set on
+ /// VerifyDcv requests when = email (3).
+ ///
+ public class DcvRequestDetails
+ {
+ /// Registered requestor email associated with the order.
+ [JsonPropertyName("requestorEmail")]
+ public string RequestorEmail { get; set; }
+
+ /// Order number returned by GenerateOrderSSL.
+ [JsonPropertyName("orderNumber")]
+ public string OrderNumber { get; set; }
+
+ /// Domain to retrieve / verify DCV for.
+ [JsonPropertyName("domainName")]
+ public string DomainName { get; set; }
+
+ ///
+ /// DCV method (numeric string per CERTInext V1 spec):
+ /// "1" = DNS TXT record, "2" = HTTP file, "3" = email approver.
+ /// See .
+ ///
+ [JsonPropertyName("dcvMethod")]
+ public string DcvMethod { get; set; }
+
+ ///
+ /// Approver email address. Required (and only used) on VerifyDcv when
+ /// is "3" (email). Must be one of the
+ /// dcvEmails returned by GetDcv.
+ ///
+ [JsonPropertyName("dcvEmail")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string DcvEmail { get; set; }
+ }
+
+ // ---------------------------------------------------------------------------
+ // VerifyDcv — POST {baseURL}VerifyDcv
+ // Triggers CERTInext to verify the DCV record placed by the customer.
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Request body for POST {baseURL}VerifyDcv.
+ /// Tells CERTInext to attempt domain verification using the previously
+ /// supplied DCV details. Reuses .
+ ///
+ public class VerifyDcvRequest
+ {
+ [JsonPropertyName("meta")]
+ public RequestMeta Meta { get; set; }
+
+ [JsonPropertyName("dcvDetails")]
+ public DcvRequestDetails DcvDetails { get; set; }
+ }
+
// ---------------------------------------------------------------------------
// GetCertificate — POST {baseURL}GetCertificate
// Downloads the issued certificate for a fulfilled order.
diff --git a/CERTInext/API/CertificateResponse.cs b/CERTInext/API/CertificateResponse.cs
index 0f3ca67..3b3103f 100644
--- a/CERTInext/API/CertificateResponse.cs
+++ b/CERTInext/API/CertificateResponse.cs
@@ -6,6 +6,7 @@
// and limitations under the License.
using System.Collections.Generic;
+using System.Text.Json;
using System.Text.Json.Serialization;
namespace Keyfactor.Extensions.CAPlugin.CERTInext.API
@@ -151,10 +152,100 @@ public class TrackOrderResponseDetails
[JsonPropertyName("revocationDetails")]
public TrackOrderRevocationDetails RevocationDetails { get; set; }
+ ///
+ /// Per-domain DCV state plus a top-level status field. The wire
+ /// shape mixes typed and dynamic keys: { "<Domain Name>": { ... },
+ /// "status": "..." } , so domain entries are surfaced via
+ /// .
+ ///
+ [JsonPropertyName("domainVerification")]
+ public TrackOrderDomainVerification DomainVerification { get; set; }
+
[JsonPropertyName("csr")]
public string Csr { get; set; }
}
+ ///
+ /// domainVerification block from TrackOrder. Wire shape is heterogeneous:
+ /// a known status field at the top level alongside one entry per domain
+ /// keyed by domain name. The per-domain entries are captured via
+ /// and exposed through
+ /// .
+ ///
+ public class TrackOrderDomainVerification
+ {
+ /// Block-level status. Documented values mirror .
+ [JsonPropertyName("status")]
+ public string Status { get; set; }
+
+ ///
+ /// Raw per-domain entries as parsed from the response. Keys are the domain
+ /// names exactly as returned by CERTInext. Use
+ /// for typed access.
+ ///
+ [JsonExtensionData]
+ public Dictionary RawDomainEntries { get; set; }
+
+ ///
+ /// Returns a typed dictionary of domain → ,
+ /// skipping entries that fail to deserialize (e.g. unexpected scalar values).
+ /// Returns an empty dictionary if no per-domain entries were present.
+ ///
+ public Dictionary GetDomainEntries()
+ {
+ var result = new Dictionary();
+ if (RawDomainEntries == null) return result;
+
+ foreach (var kv in RawDomainEntries)
+ {
+ if (kv.Value.ValueKind != JsonValueKind.Object) continue;
+ try
+ {
+ var detail = kv.Value.Deserialize();
+ if (detail != null) result[kv.Key] = detail;
+ }
+ catch (JsonException)
+ {
+ // ignore: entry shape unexpected, skip rather than failing the whole TrackOrder
+ }
+ }
+
+ return result;
+ }
+ }
+
+ ///
+ /// Per-domain DCV detail inside .
+ ///
+ public class DomainVerificationDetail
+ {
+ /// DCV method used / requested for this domain (typically the human label).
+ [JsonPropertyName("dcvMethod")]
+ public string DcvMethod { get; set; }
+
+ ///
+ /// DCV completion status: "0"=Pending, "1"=Validated, "2"=Rejected.
+ /// See .
+ ///
+ [JsonPropertyName("dcvStatus")]
+ public string DcvStatus { get; set; }
+
+ /// Domain status: "1"=Active, "2"=Inactive, "3"=Expired.
+ [JsonPropertyName("status")]
+ public string Status { get; set; }
+
+ /// Timestamp at which the domain was successfully verified (when applicable).
+ [JsonPropertyName("verifiedDate")]
+ public string VerifiedDate { get; set; }
+
+ ///
+ /// CAA check status: "1"=emSign authorized or no CAA present,
+ /// "2"=Authorization required, "3"=Authorization pending.
+ ///
+ [JsonPropertyName("caaStatus")]
+ public string CaaStatus { get; set; }
+ }
+
public class TrackOrderRequestorInfo
{
[JsonPropertyName("requestorName")]
@@ -189,6 +280,84 @@ public class TrackOrderRevocationDetails
public string RevokeRequestStatus { get; set; }
}
+ // ---------------------------------------------------------------------------
+ // GetDcv response — POST {baseURL}GetDcv
+ //
+ // Per the V1 spec the dcvDetails block contains different fields depending
+ // on the dcvMethod that was requested:
+ // dcvMethod=1 (DNS TXT) → token populated
+ // dcvMethod=2 (HTTP) → fileName + fileContent populated
+ // dcvMethod=3 (email) → dcvEmails populated
+ //
+ // The TXT record HOSTNAME for dcvMethod=1 is NOT returned by this endpoint.
+ // The CERTInext V1 documentation does not specify the convention. The plugin
+ // uses Constants.Dcv.DefaultTxtRecordTemplate ("_emsign-validation.{0}") by
+ // default, overridable via the DcvTxtRecordTemplate connector config field.
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Response from POST {baseURL}GetDcv.
+ ///
+ public class GetDcvResponse
+ {
+ [JsonPropertyName("meta")]
+ public ResponseMeta Meta { get; set; }
+
+ [JsonPropertyName("dcvDetails")]
+ public DcvResponseDetails DcvDetails { get; set; }
+ }
+
+ ///
+ /// DCV instructions returned by GetDcv. Field population depends on the
+ /// requested dcvMethod (see class-level remarks on ).
+ ///
+ public class DcvResponseDetails
+ {
+ ///
+ /// Token / target address value to publish for DNS TXT-based DCV
+ /// (dcvMethod = 1). Empty for other methods.
+ ///
+ [JsonPropertyName("token")]
+ public string Token { get; set; }
+
+ ///
+ /// File name to host under /.well-known/pki-validation/ for HTTP
+ /// DCV (dcvMethod = 2). Empty for other methods.
+ ///
+ [JsonPropertyName("fileName")]
+ public string FileName { get; set; }
+
+ ///
+ /// File body to serve at the well-known path for HTTP DCV (dcvMethod = 2).
+ /// Empty for other methods.
+ ///
+ [JsonPropertyName("fileContent")]
+ public string FileContent { get; set; }
+
+ ///
+ /// CA/B Forum approved approver email candidates for email DCV
+ /// (dcvMethod = 3). Empty for other methods.
+ ///
+ [JsonPropertyName("dcvEmails")]
+ public List DcvEmails { get; set; }
+ }
+
+ // ---------------------------------------------------------------------------
+ // VerifyDcv response — POST {baseURL}VerifyDcv
+ // Body contains only the meta block (success/failure status).
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Response from POST {baseURL}VerifyDcv. Body is meta-only; the actual
+ /// per-domain verification status is observed via subsequent TrackOrder
+ /// calls (see ).
+ ///
+ public class VerifyDcvResponse
+ {
+ [JsonPropertyName("meta")]
+ public ResponseMeta Meta { get; set; }
+ }
+
// ---------------------------------------------------------------------------
// GetCertificate response — POST {baseURL}GetCertificate
// ---------------------------------------------------------------------------
@@ -602,6 +771,14 @@ public class LegacyGetCertificateResponse
[JsonPropertyName("expiresAt")]
public System.DateTime? ExpiresAt { get; set; }
+ ///
+ /// Order placement date parsed from orderDate in the order report. Distinct from
+ /// (a pending order has no issuance date) — used to bound
+ /// DCV-during-sync to recently-placed orders (issue 0002).
+ ///
+ [JsonPropertyName("orderDate")]
+ public System.DateTime? OrderDate { get; set; }
+
/// Revocation date parsed from revokeProcessedDate in TrackOrder revocationDetails.
[JsonPropertyName("revokedAt")]
public System.DateTime? RevokedAt { get; set; }
diff --git a/CERTInext/CERTInext.csproj b/CERTInext/CERTInext.csproj
index b25bd55..f683ea8 100644
--- a/CERTInext/CERTInext.csproj
+++ b/CERTInext/CERTInext.csproj
@@ -1,25 +1,44 @@
- net8.0
+ net8.0;net10.0
Keyfactor.Extensions.CAPlugin.CERTInext
CERTInextCAPlugin
disable
warnings
12.0
+
+ false
+ $(DefineConstants);SUPPORTS_DCV
true
-
+
+
+
-
+
+
-
@@ -32,5 +51,8 @@
<_Parameter1>CERTInext.Tests
+
+ <_Parameter1>CERTInext.IntegrationTests
+
diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs
index 1ca2770..231f611 100644
--- a/CERTInext/CERTInextCAPlugin.cs
+++ b/CERTInext/CERTInextCAPlugin.cs
@@ -19,6 +19,9 @@
using Keyfactor.Logging;
using Keyfactor.PKI.Enums.EJBCA;
using Microsoft.Extensions.Logging;
+#if SUPPORTS_DCV
+using IDomainValidatorFactory = Keyfactor.AnyGateway.Extensions.IDomainValidatorFactory;
+#endif
namespace Keyfactor.Extensions.CAPlugin.CERTInext
{
@@ -34,25 +37,79 @@ public class CERTInextCAPlugin : IAnyCAPlugin, IDisposable
private CERTInextConfig _config;
private ICERTInextClient _client;
private ICertificateDataReader _certificateDataReader;
+ // Typed as `object` — NOT `IDomainValidatorFactory` — so the .NET JIT does not
+ // eagerly resolve the v3.3-only IDomainValidatorFactory type when it compiles
+ // any method on this class. Resolving an instance field's declared type is
+ // part of the JIT's per-class metadata load, distinct from constructor-signature
+ // reflection (which we already protected in the issue #7 first pass). On a
+ // gateway host whose IAnyCAPlugin assembly is v3.2.0.0 (no IDomainValidatorFactory),
+ // declaring the field with the missing type causes TypeLoadException the first
+ // time ANY instance method on the class is compiled — typically Initialize.
+ //
+ // Reads of this field perform an `as IDomainValidatorFactory` cast inside method
+ // bodies (see DomainValidatorFactory below). Casts in method bodies are JIT-lazy
+ // per-method, so the type is only resolved on hosts that actually have it.
+ //
+ // `volatile` because the field is written by SetDomainValidatorFactory and read
+ // by EnrollNewAsync / TryRunDcvDuringSyncAsync, which can run on different threads.
+ // See GitHub issue #7 for the full reasoning.
+ // On the no-DCV build (IAnyCAPlugin 3.2.0, SUPPORTS_DCV undefined) this field is
+ // intentionally never assigned — its assignment sites (the factory ctor and
+ // SetDomainValidatorFactory) are fenced out, so it stays null and the Initialize
+ // DCV-wiring check reports "not wired". Suppress CS0649 for that case; on the
+ // SUPPORTS_DCV build it is assigned normally and the pragma is a no-op.
+#pragma warning disable CS0649
+ private volatile object _domainValidatorFactory;
+#pragma warning restore CS0649
+
+ ///
+ /// Returns the injected when one is
+ /// available, or null when DCV is not wired up. The cast is inside this
+ /// property body (and therefore JIT-lazy) so the missing-type case on a v3.2
+ /// gateway host stays compileable and never triggers TypeLoadException
+ /// at runtime. All read sites in this class go through this property.
+ ///
+#if SUPPORTS_DCV
+ private IDomainValidatorFactory DomainValidatorFactory =>
+ _domainValidatorFactory as IDomainValidatorFactory;
+#endif
// True when the client was passed in via a test-injection constructor and therefore
// should not be disposed by this class (the test owns the mock's lifetime).
private bool _clientWasInjected;
+ // Guards against concurrent DCV attempts on the same order — two overlapping sync
+ // cycles, or a sync overlapping with a GetSingleRecord refresh, must not both try
+ // to stage TXT records for the same order. The value byte is unused; this is a set.
+ private readonly ConcurrentDictionary _dcvInFlight = new();
+
// ---------------------------------------------------------------------------
// Constructors
// ---------------------------------------------------------------------------
- /// Production constructor — called by the gateway framework via reflection.
+ ///
+ /// Production constructor — the only public constructor the gateway DI container
+ /// sees. Deliberately parameterless to ensure plugin load succeeds on gateway
+ /// versions whose Keyfactor.AnyGateway.IAnyCAPlugin assembly does not
+ /// contain (e.g. 25.4.0 ships v3.2.0.0).
+ ///
+ /// If the host gateway exposes an instance
+ /// it should be injected via after
+ /// construction. When no factory is provided, DCV silently no-ops and orders
+ /// are returned in their pending state for the gateway to advance on the next
+ /// sync cycle.
+ ///
+ /// See .
+ ///
public CERTInextCAPlugin() { }
///
- /// Test-injection constructor — pass a mock
- /// to avoid real network calls in unit tests. A default configuration is
- /// supplied so that methods that read _config do not null-fault when
- /// has not been called.
+ /// Internal constructor used by unit and integration tests to inject a mock
+ /// and bypass network I/O. A default
+ /// is supplied so callers that don't invoke
+ /// can still read _config .
///
- public CERTInextCAPlugin(ICERTInextClient client)
+ internal CERTInextCAPlugin(ICERTInextClient client)
{
_client = client;
_clientWasInjected = true;
@@ -60,11 +117,11 @@ public CERTInextCAPlugin(ICERTInextClient client)
}
///
- /// Test-injection constructor — pass both a mock
+ /// Internal test-injection constructor — pass a mock
/// and a mock for tests that exercise
/// RenewOrReissue logic that reads prior certificate data from Command's database.
///
- public CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certDataReader)
+ internal CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certDataReader)
{
_client = client;
_clientWasInjected = true;
@@ -73,17 +130,73 @@ public CERTInextCAPlugin(ICERTInextClient client, ICertificateDataReader certDat
}
///
- /// Test-injection constructor — pass both a mock
+ /// Internal test-injection constructor — pass a mock
/// and a specific for tests that need to override
/// configuration fields such as IgnoreExpired .
///
- public CERTInextCAPlugin(ICERTInextClient client, CERTInextConfig config)
+ internal CERTInextCAPlugin(ICERTInextClient client, CERTInextConfig config)
{
_client = client;
_clientWasInjected = true;
_config = config ?? new CERTInextConfig();
}
+ ///
+ /// Internal test-injection constructor — pass a mock client, a domain validator
+ /// factory, and an optional config for unit-testing the DCV orchestration path.
+ ///
+ /// This constructor is internal (rather than public ) because the
+ /// gateway DI container's constructor-discovery reflection on a v3.2 host would
+ /// trip 's missing-type load if this signature
+ /// were exposed publicly. Tests in CERTInext.Tests /
+ /// CERTInext.IntegrationTests can still reach it via
+ /// [InternalsVisibleTo] . See issue #7.
+ ///
+#if SUPPORTS_DCV
+ internal CERTInextCAPlugin(ICERTInextClient client, IDomainValidatorFactory domainValidatorFactory, CERTInextConfig config = null)
+ {
+ _client = client;
+ _clientWasInjected = true;
+ _domainValidatorFactory = domainValidatorFactory;
+ _config = config ?? new CERTInextConfig();
+ }
+#endif
+
+ ///
+ /// Injects an after construction. Intended
+ /// for gateway hosts that can resolve the factory from their own service container
+ /// and want DCV enabled — they should call this between new CERTInextCAPlugin()
+ /// and .
+ ///
+ /// Accepts rather than
+ /// so the public method signature does not pull the v3.3-only type into the type's
+ /// reflection surface on older gateways. When the supplied value is not an
+ /// , DCV is left disabled.
+ ///
+ public void SetDomainValidatorFactory(object factory)
+ {
+#if SUPPORTS_DCV
+ var typed = factory as IDomainValidatorFactory;
+ // SOX change-management / SOC2 CC6.1: log every factory injection so an auditor
+ // can confirm which DNS provider plugin is being used to publish TXT records.
+ // A bad-faith host could otherwise swap the factory mid-lifecycle with no trail.
+ // We deliberately do NOT log the factory instance itself — only its type — to
+ // avoid serialising any state it may carry.
+ _logger.LogInformation(
+ "Domain validator factory set on CERTInext plugin. " +
+ "OfferedType={OfferedType}, Accepted={Accepted}",
+ factory?.GetType().FullName ?? "(null)", typed != null);
+ _domainValidatorFactory = typed;
+#else
+ // DCV is not supported on this build (IAnyCAPlugin 3.2.0 — no IDomainValidatorFactory).
+ // Accept the call for host compatibility but leave DCV disabled. See issue 0003.
+ _logger.LogInformation(
+ "Domain validator factory offered but DCV is not supported on this build " +
+ "(IAnyCAPlugin 3.2.0). OfferedType={OfferedType}",
+ factory?.GetType().FullName ?? "(null)");
+#endif
+ }
+
// ---------------------------------------------------------------------------
// IDisposable
// ---------------------------------------------------------------------------
@@ -108,7 +221,7 @@ public void Dispose()
///
public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader)
{
- _logger.MethodEntry(LogLevel.Trace);
+ _logger.MethodEntry(LogLevel.Debug);
_certificateDataReader = certificateDataReader;
@@ -134,13 +247,31 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa
"ApiKeyPresent={ApiKeyPresent}, UsernamePresent={UsernamePresent}, " +
"PasswordPresent={PasswordPresent}, OAuth2ClientIdPresent={OAuth2ClientIdPresent}, " +
"OAuth2ClientSecretPresent={OAuth2ClientSecretPresent}, OAuth2TokenUrlPresent={OAuth2TokenUrlPresent}, " +
- "PageSize={PageSize}, IgnoreExpired={IgnoreExpired}",
+ "PageSize={PageSize}, IgnoreExpired={IgnoreExpired}, " +
+ "DcvEnabled={DcvEnabled}, DcvTxtRecordTemplate={DcvTxtRecordTemplate}, " +
+ "DomainValidatorFactoryInjected={FactoryInjected}",
_config.ApiUrl, _config.AuthMode, _config.Enabled,
hasApiKey, hasUsername,
hasPassword, hasClientId,
hasClientSecret, hasTokenUrl,
- _config.PageSize, _config.IgnoreExpired);
- _logger.MethodExit(LogLevel.Trace);
+ _config.PageSize, _config.IgnoreExpired,
+ _config.DcvEnabled, _config.DcvTxtRecordTemplate,
+ _domainValidatorFactory != null);
+
+ // SOC2 CC7.1: surface silent functional downgrades. If DCV is enabled in
+ // config but no factory was injected (e.g. v3.2 gateway host), DCV will be
+ // skipped at runtime. The operator should know that on every restart.
+ if (_config.DcvEnabled && _domainValidatorFactory == null)
+ {
+ _logger.LogWarning(
+ "DcvEnabled=true but no IDomainValidatorFactory has been injected — " +
+ "DCV will be silently skipped for every enrollment. This usually means the " +
+ "gateway host is on a release that does not provide IDomainValidatorFactory " +
+ "(see GitHub issue #7). Install a DNS provider plugin and upgrade to a " +
+ "gateway image that supplies the factory, or set DcvEnabled=false to clear " +
+ "this warning.");
+ }
+ _logger.MethodExit(LogLevel.Debug);
}
// ---------------------------------------------------------------------------
@@ -188,12 +319,12 @@ public List GetProductIds()
///
public async Task Ping()
{
- _logger.MethodEntry(LogLevel.Trace);
+ _logger.MethodEntry(LogLevel.Debug);
if (!_config.Enabled)
{
_logger.LogWarning("CERTInext connector is disabled — skipping connectivity test.");
- _logger.MethodExit(LogLevel.Trace);
+ _logger.MethodExit(LogLevel.Debug);
return;
}
@@ -211,14 +342,14 @@ public async Task Ping()
}
finally
{
- _logger.MethodExit(LogLevel.Trace);
+ _logger.MethodExit(LogLevel.Debug);
}
}
///
public async Task ValidateCAConnectionInfo(Dictionary connectionInfo)
{
- _logger.MethodEntry(LogLevel.Trace);
+ _logger.MethodEntry(LogLevel.Debug);
// SOX CC6.1 / SOC2 CC6.1: log the access attempt so that every configuration
// change event is traceable in the audit trail.
@@ -236,7 +367,7 @@ public async Task ValidateCAConnectionInfo(Dictionary connection
_logger.LogWarning(
"CA connection validation skipped — connector is disabled. ApiUrl={ApiUrl}",
attemptedApiUrl);
- _logger.MethodExit(LogLevel.Trace);
+ _logger.MethodExit(LogLevel.Debug);
return;
}
@@ -292,13 +423,15 @@ public async Task ValidateCAConnectionInfo(Dictionary connection
}
// Attempt a live connectivity test using the supplied credentials
+ CERTInextConfig tempConfig = null;
+ CERTInextClient tempClient = null;
try
{
// Build a transient config from the supplied connectionInfo so we don't
// rely on the already-initialized _client (which may hold stale creds)
string rawConfig = JsonSerializer.Serialize(connectionInfo);
- var tempConfig = JsonSerializer.Deserialize(rawConfig);
- var tempClient = new CERTInextClient(tempConfig);
+ tempConfig = JsonSerializer.Deserialize(rawConfig);
+ tempClient = new CERTInextClient(tempConfig);
await tempClient.PingAsync();
}
catch (Exception ex)
@@ -318,17 +451,31 @@ public async Task ValidateCAConnectionInfo(Dictionary connection
"Successfully parsed configuration, but could not connect to CERTInext. " +
"See gateway logs for details.");
}
+ finally
+ {
+ // SOC2 CC6.1 best-effort credential scrubbing: blank out the secret fields
+ // on the transient config so they aren't reachable from the still-rooted
+ // tempClient instance after this method returns. Not a hard guarantee
+ // (the .NET runtime may have already copied them elsewhere) but removes
+ // the most obvious post-validation reference chain.
+ if (tempConfig != null)
+ {
+ tempConfig.ApiKey = string.Empty;
+ tempConfig.OAuthClientSecret = string.Empty;
+ tempConfig.Password = string.Empty;
+ }
+ }
_logger.LogInformation(
"CA connection validation succeeded. ApiUrl={ApiUrl}, AuthMode={AuthMode}",
attemptedApiUrl, attemptedAuthMode);
- _logger.MethodExit(LogLevel.Trace);
+ _logger.MethodExit(LogLevel.Debug);
}
///
public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo)
{
- _logger.MethodEntry(LogLevel.Trace);
+ _logger.MethodEntry(LogLevel.Debug);
string rawConfig = JsonSerializer.Serialize(connectionInfo);
var tempConfig = JsonSerializer.Deserialize(rawConfig);
@@ -385,9 +532,19 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Diction
$"Unable to validate profile '{profileId}' against CERTInext. " +
"See gateway logs for details.");
}
+ finally
+ {
+ // SOC2 CC6.1 best-effort credential scrubbing (see ValidateCAConnectionInfo).
+ if (tempConfig != null)
+ {
+ tempConfig.ApiKey = string.Empty;
+ tempConfig.OAuthClientSecret = string.Empty;
+ tempConfig.Password = string.Empty;
+ }
+ }
_logger.LogInformation("Product/profile validation succeeded. ProfileId={ProfileId}", profileId);
- _logger.MethodExit(LogLevel.Trace);
+ _logger.MethodExit(LogLevel.Debug);
}
// ---------------------------------------------------------------------------
@@ -403,7 +560,7 @@ public async Task Enroll(
RequestFormat requestFormat,
EnrollmentType enrollmentType)
{
- _logger.MethodEntry(LogLevel.Trace);
+ _logger.MethodEntry(LogLevel.Debug);
var ep = new EnrollmentParams(productInfo);
@@ -461,7 +618,7 @@ public async Task Enroll(
enrollmentType, result.CARequestID, result.Status,
result.Certificate != null ? ExtractSerialFromPem(result.Certificate) : "(pending)",
subject, ep.ProfileId);
- _logger.MethodExit(LogLevel.Trace);
+ _logger.MethodExit(LogLevel.Debug);
return result;
}
@@ -472,12 +629,36 @@ public async Task Enroll(
///
public async Task GetSingleRecord(string caRequestID)
{
- _logger.MethodEntry(LogLevel.Trace);
+ _logger.MethodEntry(LogLevel.Debug);
_logger.LogInformation("GetSingleRecord started. CARequestID={Id}", caRequestID);
try
{
var cert = await _client.GetCertificateAsync(caRequestID);
+
+ // Mirror the deferred-DCV behavior of Synchronize: if the order is still in
+ // a pending state, try to advance it through DCV before returning. This lets
+ // a manual single-record refresh unstick an order whose DCV challenge was
+ // only exposed after enrollment returned.
+ int status = StatusMapper.ToRequestDisposition(cert.Status);
+ if (status == (int)EndEntityStatus.EXTERNALVALIDATION)
+ {
+ bool dcvDone = await TryRunDcvDuringSyncAsync(caRequestID, CancellationToken.None);
+ if (dcvDone)
+ {
+ try
+ {
+ cert = await _client.GetCertificateAsync(caRequestID);
+ }
+ catch (Exception refetchEx)
+ {
+ _logger.LogWarning(refetchEx,
+ "Single-record DCV completed but post-DCV refetch failed. CARequestID={Id}",
+ caRequestID);
+ }
+ }
+ }
+
var record = MapToAnyCAPluginCertificate(cert);
// SOC2 CC7.3: certificate retrieval is a security-relevant read operation;
@@ -485,7 +666,7 @@ public async Task GetSingleRecord(string caRequestID)
_logger.LogInformation(
"GetSingleRecord complete. CARequestID={Id}, Status={Status}, SerialNumber={Serial}",
caRequestID, cert.Status, cert.SerialNumber ?? "(none)");
- _logger.MethodExit(LogLevel.Trace);
+ _logger.MethodExit(LogLevel.Debug);
return record;
}
catch (KeyNotFoundException)
@@ -507,17 +688,22 @@ public async Task GetSingleRecord(string caRequestID)
///
public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason)
{
- _logger.MethodEntry(LogLevel.Trace);
+ _logger.MethodEntry(LogLevel.Debug);
string reasonString = StatusMapper.ToRevocationReason(revocationReason);
// SOX: log the revocation attempt before any state change so the intent is
- // recorded even if the API call subsequently fails.
+ // recorded even if the API call subsequently fails. Include ManagedThreadId
+ // so revoke events can be correlated against the gateway-supplied
+ // RequestingUser scope when the host enriches Keyfactor.Logging with it
+ // (segregation-of-duties evidence — SOX CC1.3 / SOC2 CC1.4).
_logger.LogInformation(
"Revocation attempt started. " +
"CARequestID={Id}, HexSerialNumber={Serial}, " +
- "ReasonCode={ReasonCode}, ReasonString={ReasonString}",
- caRequestID, hexSerialNumber, revocationReason, reasonString);
+ "ReasonCode={ReasonCode}, ReasonString={ReasonString}, " +
+ "ManagedThreadId={ThreadId}",
+ caRequestID, hexSerialNumber, revocationReason, reasonString,
+ System.Environment.CurrentManagedThreadId);
// Verify the certificate is in a revocable state before calling the API
LegacyGetCertificateResponse current;
@@ -572,7 +758,7 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r
"ReasonCode={ReasonCode}, ReasonString={ReasonString}",
caRequestID, hexSerialNumber, current.Subject,
revocationReason, reasonString);
- _logger.MethodExit(LogLevel.Trace);
+ _logger.MethodExit(LogLevel.Debug);
return (int)EndEntityStatus.REVOKED;
}
@@ -587,7 +773,7 @@ public async Task Synchronize(
bool fullSync,
CancellationToken cancelToken)
{
- _logger.MethodEntry(LogLevel.Trace);
+ _logger.MethodEntry(LogLevel.Debug);
DateTime? issuedAfter = fullSync ? (DateTime?)null : lastSync;
@@ -597,8 +783,23 @@ public async Task Synchronize(
int synced = 0;
int skipped = 0;
+ int skippedWithBody = 0; // skipped records that nonetheless carried a cert body (should be 0)
int errors = 0;
+#if SUPPORTS_DCV
+ // DCV-during-sync only actually runs when DCV is enabled AND a DNS provider factory was
+ // injected by the host. On a gateway that doesn't supply one (e.g. IAnyCAPlugin 3.2.0
+ // hosts), DCV cannot run even on a DCV-capable build — so don't run the gate or report
+ // attempt counts that would imply it did (issue 0003). Bounds apply only when operational.
+ bool dcvOperational = _config.DcvEnabled && _domainValidatorFactory != null;
+ int ageWindowHours = _config.DcvSyncMaxOrderAgeHours; // 0 = no age filter
+ int perPassCap = _config.DcvSyncMaxPerPass; // 0 = no cap
+ int dcvAttempted = 0, dcvSkippedAge = 0, dcvSkippedCap = 0;
+#endif
+
+ // Emit-side accounting (issue 0003): what the plugin hands to the gateway buffer.
+ int emittedGeneratedWithBody = 0, emittedGeneratedNoBody = 0, emittedRevoked = 0, emittedPending = 0;
+
try
{
await foreach (var cert in _client.ListCertificatesAsync(
@@ -606,32 +807,171 @@ public async Task Synchronize(
{
cancelToken.ThrowIfCancellationRequested();
+ // Local copy so we can replace it with a post-DCV refetch below
+ var current = cert;
+
try
{
// Skip expired certificates when IgnoreExpired is configured
if (_config.IgnoreExpired
- && cert.ExpiresAt.HasValue
- && cert.ExpiresAt.Value < DateTime.UtcNow)
+ && current.ExpiresAt.HasValue
+ && current.ExpiresAt.Value < DateTime.UtcNow)
{
_logger.LogTrace(
"Skipping expired certificate '{Id}' (expires {ExpiresAt:u}).",
- cert.Id, cert.ExpiresAt.Value);
+ current.Id, current.ExpiresAt.Value);
+ if (!string.IsNullOrWhiteSpace(current.Certificate)) skippedWithBody++;
skipped++;
continue;
}
+ int status = StatusMapper.ToRequestDisposition(current.Status);
+
+ // Per-record trace so a sync pass is fully reconstructable from logs
+ // (info-level only emits start/summary). Enable Trace on this category.
+ _logger.LogTrace(
+ "Sync: processing order Id={Id}, listedStatus='{Listed}', mappedStatus={Status}, " +
+ "orderDate={OrderDate}, bodyInListing={HasBody}",
+ current.Id, current.Status, status,
+ current.OrderDate?.ToString("o") ?? "(none)",
+ !string.IsNullOrWhiteSpace(current.Certificate));
+
+ // Deferred DCV: pending orders (EXTERNALVALIDATION) often need DCV driven
+ // forward during sync — CERTInext parks fresh orders and exposes the DCV
+ // challenge minutes after enrollment, and scans are the only place that gets
+ // picked back up. But attempting DCV for EVERY pending order on EVERY pass is
+ // O(pending) and pathologically slow with a large/abandoned backlog (issue
+ // 0002). Bound it: only recently-placed orders are eligible (age window), and
+ // at most N per pass (cap). Aged-out / over-cap orders are emitted as pending
+ // and revisited on a later pass (the per-minute incremental scan keeps recent
+ // orders moving). Unknown order age → treat as eligible so we never starve a
+ // legitimately-new order.
+#if SUPPORTS_DCV
+ if (dcvOperational && status == (int)EndEntityStatus.EXTERNALVALIDATION)
+ {
+ var decision = EvaluateDcvSyncEligibility(
+ current.OrderDate, DateTime.UtcNow, ageWindowHours, dcvAttempted, perPassCap);
+
+ _logger.LogTrace(
+ "Sync DCV gate: Id={Id}, decision={Decision}, orderDate={OrderDate}, " +
+ "ageWindowHours={Age}, attemptedSoFar={Attempted}, perPassCap={Cap}",
+ current.Id, decision, current.OrderDate?.ToString("o") ?? "(none)",
+ ageWindowHours, dcvAttempted, perPassCap);
+
+ if (decision == DcvSyncDecision.SkipByAge)
+ {
+ // Issue 0003 / SOC1 completeness: an order past the age window is no
+ // longer advanced by sync (it only ages further), so record its
+ // identity at Information — not just the aggregate count — so an
+ // auditor can see which orders were left parked, and when.
+ _logger.LogInformation(
+ "Sync: pending DV order aged out of the DCV-during-sync window and will " +
+ "not be advanced. CARequestID={Id}, OrderDate={OrderDate}, AgeWindowHours={Age}.",
+ current.Id, current.OrderDate?.ToString("o") ?? "(none)", ageWindowHours);
+ dcvSkippedAge++;
+ }
+ else if (decision == DcvSyncDecision.SkipByCap)
+ {
+ dcvSkippedCap++;
+ }
+ else
+ {
+ dcvAttempted++;
+ bool dcvDone = await TryRunDcvDuringSyncAsync(
+ current.Id, cancelToken, fastSync: true);
+ if (dcvDone)
+ {
+ try
+ {
+ current = await _client.GetCertificateAsync(current.Id, cancelToken);
+ status = StatusMapper.ToRequestDisposition(current.Status);
+ }
+ catch (Exception refetchEx)
+ {
+ _logger.LogWarning(refetchEx,
+ "Sync DCV completed but post-DCV refetch failed. Id={Id}", current.Id);
+ }
+ }
+ }
+ }
+#endif
+
// Skip failed/rejected/cancelled certificates — they have no cert body
- int status = StatusMapper.ToRequestDisposition(cert.Status);
if (status == (int)EndEntityStatus.FAILED)
{
_logger.LogTrace(
"Skipping certificate '{Id}' with terminal failure status '{Status}'.",
- cert.Id, cert.Status);
+ current.Id, current.Status);
+ if (!string.IsNullOrWhiteSpace(current.Certificate)) skippedWithBody++;
skipped++;
continue;
}
- var record = MapToAnyCAPluginCertificate(cert);
+ // The order-report listing (ListCertificatesAsync) does NOT include the
+ // certificate body, so an already-issued order arrives here with
+ // current.Certificate == null. Command cannot store a record without a
+ // body, so issued certs were being silently dropped from sync. Refetch the
+ // full certificate (PEM included) for issued/revoked orders whose body is
+ // missing — this mirrors GetSingleRecord and the DCV-completed branch above.
+ // Pending (EXTERNALVALIDATION) records legitimately have no body yet and are
+ // left as-is.
+ if (string.IsNullOrWhiteSpace(current.Certificate)
+ && (status == (int)EndEntityStatus.GENERATED
+ || status == (int)EndEntityStatus.REVOKED))
+ {
+ _logger.LogDebug(
+ "Sync: issued/revoked order Id={Id} has no body in the listing — refetching full certificate.",
+ current.Id);
+ // The order-report listing carries metadata (Subject/DomainName,
+ // ProfileId/ProductCode, OrderDate) that GetCertificateAsync (TrackOrder +
+ // DownloadCertificate) does NOT return. The refetch replaces `current`
+ // wholesale, so carry that listing metadata across or the emitted record
+ // loses its Subject and ProductID (ProductID feeds the Command template).
+ var listed = current;
+ try
+ {
+ current = await _client.GetCertificateAsync(current.Id, cancelToken);
+ current.Subject = string.IsNullOrWhiteSpace(current.Subject) ? listed.Subject : current.Subject;
+ current.ProfileId = string.IsNullOrWhiteSpace(current.ProfileId) ? listed.ProfileId : current.ProfileId;
+ current.OrderDate ??= listed.OrderDate;
+ status = StatusMapper.ToRequestDisposition(current.Status);
+ _logger.LogDebug(
+ "Sync: refetched order Id={Id} — status={Status}, certBytes={Bytes}, subject={Subject}.",
+ current.Id, status, current.Certificate?.Length ?? 0, current.Subject);
+ }
+ catch (Exception fetchEx)
+ {
+ _logger.LogWarning(fetchEx,
+ "Sync: failed to fetch certificate body for issued order '{Id}'; " +
+ "emitting metadata-only record.", current.Id);
+ }
+ }
+
+ var record = MapToAnyCAPluginCertificate(current);
+
+ // Emit-side observability (issue 0003): account for what the plugin hands to
+ // the gateway buffer, broken down by status and whether a cert body is present.
+ // This is the boundary the plugin owns — if these counts show issued records
+ // emitted WITH bodies but the gateway DB lacks them, the gap is gateway-side
+ // persistence, not the plugin. Per-record detail is at Debug; the aggregate is
+ // logged at Information in the completion summary below.
+ bool recordHasBody = !string.IsNullOrWhiteSpace(record.Certificate);
+ if (record.Status == (int)EndEntityStatus.GENERATED)
+ {
+ if (recordHasBody) emittedGeneratedWithBody++; else emittedGeneratedNoBody++;
+ }
+ else if (record.Status == (int)EndEntityStatus.REVOKED)
+ {
+ emittedRevoked++;
+ }
+ else if (record.Status == (int)EndEntityStatus.EXTERNALVALIDATION)
+ {
+ emittedPending++;
+ }
+ _logger.LogDebug(
+ "Sync emit: CARequestID={Id}, Status={Status}, CertBytes={CertBytes}, Subject={Subject}",
+ record.CARequestID, record.Status, record.Certificate?.Length ?? 0, current.Subject);
+
blockingBuffer.Add(record, cancelToken);
synced++;
}
@@ -649,12 +989,43 @@ public async Task Synchronize(
{
_logger.LogError(ex, "Error processing certificate '{Id}' during synchronization.", cert.Id);
errors++;
+
+ // SOC1 completeness/accuracy: a sync that hits an error-rate cliff
+ // must report a failure, not silently 'complete' with zero useful
+ // records. Abort if we have at least 50 records' worth of evidence
+ // AND more than 25% of all records seen so far are errors.
+ int totalSeen = synced + skipped + errors;
+ if (totalSeen >= 50 && errors > totalSeen / 4)
+ {
+ _logger.LogError(
+ "CERTInext synchronization aborted — error rate ({Errors}/{Total}) " +
+ "exceeded 25% threshold. Likely CA-side outage; will retry on next sync cycle.",
+ errors, totalSeen);
+ throw new Exception(
+ $"CERTInext synchronization aborted after {errors}/{totalSeen} records failed " +
+ "(>25% error rate). See gateway logs for the underlying CA errors.");
+ }
}
}
+ // Build the DCV-during-sync clause for the ACTUAL runtime state so the summary
+ // never implies DCV ran when it couldn't (issue 0003 / SOC2 CC7.3 accuracy).
+ string dcvClause;
+#if SUPPORTS_DCV
+ if (dcvOperational)
+ dcvClause = $"DCV-during-sync: Attempted={dcvAttempted}, SkippedByAge={dcvSkippedAge} (>{ageWindowHours}h), SkippedByCap={dcvSkippedCap} (cap={perPassCap}).";
+ else
+ dcvClause = $"DCV-during-sync: not active (DcvEnabled={_config.DcvEnabled}, DnsProviderInjected={_domainValidatorFactory != null}) — pending orders left as EXTERNALVALIDATION.";
+#else
+ dcvClause = "DCV-during-sync: not supported on this build (IAnyCAPlugin 3.2.0).";
+#endif
_logger.LogInformation(
- "CERTInext synchronization complete. Synced={Synced}, Skipped={Skipped}, Errors={Errors}",
- synced, skipped, errors);
+ "CERTInext synchronization complete. Synced={Synced}, Skipped={Skipped} (withBody={SkippedWithBody}), " +
+ "Errors={Errors}. Emitted to gateway buffer: GeneratedWithBody={GenWithBody}, " +
+ "GeneratedNoBody={GenNoBody}, Revoked={Revoked}, Pending={Pending}. {DcvClause}",
+ synced, skipped, skippedWithBody, errors,
+ emittedGeneratedWithBody, emittedGeneratedNoBody, emittedRevoked, emittedPending,
+ dcvClause);
}
catch (OperationCanceledException)
{
@@ -669,13 +1040,45 @@ public async Task Synchronize(
blockingBuffer.CompleteAdding();
}
- _logger.MethodExit(LogLevel.Trace);
+ _logger.MethodExit(LogLevel.Debug);
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
+ /// The DCV-during-sync gate outcome for a single pending order (issue 0002).
+ internal enum DcvSyncDecision { Attempt, SkipByAge, SkipByCap }
+
+ ///
+ /// Decides whether to attempt DCV completion for a pending order during a sync pass,
+ /// bounding the work so a large pending backlog can't make sync slow (issue 0002).
+ /// Pure/stateless so it is unit-testable without the DCV machinery.
+ ///
+ /// Rules (checked in order):
+ /// - Age: when > 0, only orders placed within that
+ /// window are eligible. A missing is treated as eligible
+ /// so a legitimately-new order is never starved by unknown age.
+ /// - Cap: when > 0, at most that many orders are attempted
+ /// per pass; once reaches it, the rest are deferred.
+ /// A value of 0 for either bound disables that bound.
+ ///
+ internal static DcvSyncDecision EvaluateDcvSyncEligibility(
+ DateTime? orderDateUtc, DateTime nowUtc, int ageWindowHours, int attemptedSoFar, int perPassCap)
+ {
+ bool eligibleByAge = ageWindowHours <= 0
+ || !orderDateUtc.HasValue
+ || (nowUtc - orderDateUtc.Value).TotalHours <= ageWindowHours;
+ if (!eligibleByAge)
+ return DcvSyncDecision.SkipByAge;
+
+ bool eligibleByCap = perPassCap <= 0 || attemptedSoFar < perPassCap;
+ if (!eligibleByCap)
+ return DcvSyncDecision.SkipByCap;
+
+ return DcvSyncDecision.Attempt;
+ }
+
///
/// Handles New and Reissue enrollment flows by submitting a fresh certificate
/// request to CERTInext.
@@ -686,6 +1089,7 @@ private async Task EnrollNewAsync(
Dictionary san,
EnrollmentParams ep)
{
+ _logger.MethodEntry(LogLevel.Debug);
var enrollReq = new EnrollCertificateRequest
{
ProfileId = ep.ProfileId,
@@ -701,6 +1105,72 @@ private async Task EnrollNewAsync(
var enrollResp = await _client.EnrollCertificateAsync(enrollReq);
+#if SUPPORTS_DCV
+ // DCV: run domain validation if enabled, the factory was injected, and the
+ // order was accepted (not immediately failed).
+ string orderNumber = enrollResp.Id;
+ if (_domainValidatorFactory != null && _config.DcvEnabled && !string.IsNullOrEmpty(orderNumber))
+ {
+ // SOX CC7.3: bound the entire DCV flow with a hard timeout so a stuck
+ // DNS provider or extreme propagation delay cannot hold a gateway worker
+ // thread indefinitely. Configurable via DcvTimeoutMinutes (config or
+ // CERTINEXT_DCV_TIMEOUT_MINUTES env var); defaults to 10 minutes.
+ // Log the resolved limit so an auditor can confirm the configured ceiling.
+ int dcvTimeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes();
+ _logger.LogInformation(
+ "Starting DCV for order {OrderNumber}. DcvTimeoutMinutes={Timeout}",
+ orderNumber, dcvTimeoutMinutes);
+ using var dcvCts = new CancellationTokenSource(TimeSpan.FromMinutes(dcvTimeoutMinutes));
+
+ // Reserve the in-flight slot before running DCV so that any concurrent
+ // Synchronize / GetSingleRecord cycle won't try to stage TXT records for the
+ // same order from the sync-driven retry path. If something else already has
+ // the slot (the only realistic case: a duplicate Enroll for the same order
+ // ID), skip our own attempt and fall through to the pending result — the
+ // other caller will produce the same outcome and we shouldn't double-stage.
+ bool reserved = _dcvInFlight.TryAdd(orderNumber, 0);
+ if (!reserved)
+ {
+ _logger.LogInformation(
+ "DCV is already in flight for order {OrderNumber}; Enroll will skip its own DCV attempt " +
+ "and return the pending enroll response. The other caller will drive issuance.",
+ orderNumber);
+ }
+ else
+ {
+ try
+ {
+ bool dcvDone = await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token);
+ if (dcvDone)
+ {
+ // Poll GetCertificate until CERTInext finishes generating the cert OR the
+ // issuance budget expires. CERTInext issuance is async — DCV may verify
+ // but the cert PEM isn't immediately available. Without this poll, Enroll
+ // returns a pending result and the cert is picked up on the next sync cycle,
+ // which is undesirable when the whole thing completes in under a minute.
+ var postDcv = await WaitForIssuanceAfterDcvAsync(orderNumber, dcvCts.Token);
+ if (postDcv != null)
+ {
+ return BuildEnrollmentResult(new EnrollCertificateResponse
+ {
+ Id = postDcv.Id,
+ Status = postDcv.Status,
+ Certificate = postDcv.Certificate,
+ SerialNumber = postDcv.SerialNumber,
+ Message = $"Post-DCV status: {postDcv.Status}."
+ }, ep.AutoApprove);
+ }
+ }
+ }
+ finally
+ {
+ _dcvInFlight.TryRemove(orderNumber, out _);
+ }
+ }
+ }
+#endif
+
+ _logger.MethodExit(LogLevel.Debug);
return BuildEnrollmentResult(enrollResp, ep.AutoApprove);
}
@@ -721,6 +1191,14 @@ private async Task RenewOrReissueAsync(
string priorCertSn = null;
productInfo.ProductParameters?.TryGetValue("PriorCertSN", out priorCertSn);
+ // SOC2 CC6.1: a renewal/reissue read against the gateway's certificate
+ // inventory is a logical-access event and must be logged at Information.
+ _logger.LogInformation(
+ "Renewal/reissue probe — read PriorCertSN from EnrollmentProductInfo. " +
+ "Subject={Subject}, PriorCertSN={PriorCertSN}, RenewalWindowDays={WindowDays}",
+ subject, string.IsNullOrWhiteSpace(priorCertSn) ? "(none)" : priorCertSn,
+ ep.RenewalWindowDays);
+
if (string.IsNullOrWhiteSpace(priorCertSn))
{
// SOC2 CC7.2: log policy-relevant decisions at Information so they survive
@@ -830,6 +1308,566 @@ private async Task RenewOrReissueAsync(
}
}
+ // ---------------------------------------------------------------------------
+ // DCV helpers
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// True when a GetDcv failure is the CERTInext-side "DCV slot is exposed in
+ /// TrackOrder but the endpoint won't accept calls yet" condition. Observed as the
+ /// API error EMS-956 "Invalid Request for this API" for several hours after
+ /// enrollment — see analysis/certinext-support-ticket-2026-05-12.md .
+ ///
+ /// Detection is intentionally narrow:
+ /// * If the message contains the literal code EMS-956 , treat it as the
+ /// known not-ready condition.
+ /// * Otherwise, only fall back to the human-readable phrase match when *no other*
+ /// EMS-NNN code is present. Without that guard, an upstream proxy or WAF
+ /// returning a 4xx whose body happens to contain "Invalid Request for this API …"
+ /// plus a different CERTInext code (e.g. EMS-401) would be silently deferred,
+ /// masking a real authentication or input-validation failure.
+ ///
+ private static bool IsDcvNotYetReady(Exception ex)
+ {
+ if (ex == null) return false;
+ string msg = ex.Message ?? string.Empty;
+ if (msg.IndexOf("EMS-956", StringComparison.OrdinalIgnoreCase) >= 0)
+ return true;
+ bool hasPhrase = msg.IndexOf("Invalid Request for this API", StringComparison.OrdinalIgnoreCase) >= 0;
+ bool hasOtherEmsCode = System.Text.RegularExpressions.Regex.IsMatch(msg, @"\bEMS-\d+\b");
+ return hasPhrase && !hasOtherEmsCode;
+ }
+
+ // (`DomainValidatorConfigProvider` nested helper removed — it declared an
+ // implementation of `Keyfactor.AnyGateway.Extensions.IDomainValidatorConfigProvider`,
+ // a v3.3-only interface, but the type was never instantiated anywhere in the
+ // plugin. Keeping a nested type whose base list references a missing assembly
+ // type is a hazard for CLR class-load on v3.2 hosts (see issue #7). Dead code
+ // that costs nothing to remove.)
+
+ ///
+ /// Best-effort DCV retry for an order that may still be pending validation.
+ ///
+ /// Called from Synchronize and GetSingleRecord so that orders which CERTInext placed
+ /// into "Pending for Approver"/"Pending System RA" between enrollment and the next
+ /// gateway cycle (when domainVerification was still null at enroll time) can be
+ /// driven forward through DCV. Wraps with:
+ /// * a per-order in-flight guard so overlapping sync cycles or a sync+single
+ /// refresh do not double-stage TXT records,
+ /// * a bounded DCV timeout linked to the caller's cancellation token,
+ /// * swallowing of non-cancellation exceptions so a single bad order does not
+ /// halt a 12-hour sync — the order will be retried on the next cycle.
+ ///
+ /// Uses a single-shot challenge check (waitForChallengeSeconds=0 ) by default
+ /// because sync runs periodically: if CERTInext hasn't yet exposed the DCV slot for
+ /// this order, the next sync cycle will pick it up. Waiting per-order during sync
+ /// scales poorly — a single pending order's 60s budget becomes minutes of wasted
+ /// gateway thread time across an account with many orders. See PR #2 discussion.
+ ///
+ /// Returns true when DCV actually executed (or DCV is already complete),
+ /// false when skipped.
+ ///
+ private async Task TryRunDcvDuringSyncAsync(string orderNumber, CancellationToken ct, bool fastSync = false)
+ {
+ _logger.MethodEntry(LogLevel.Debug);
+#if SUPPORTS_DCV
+ if (_domainValidatorFactory == null || !_config.DcvEnabled || string.IsNullOrEmpty(orderNumber))
+ return false;
+
+ if (!_dcvInFlight.TryAdd(orderNumber, 0))
+ {
+ // SOC2 CC7.2: concurrent DCV-attempt collisions are security-relevant
+ // (they indicate either a normal overlap of two sync cycles OR an attempt
+ // to interleave operations on the same order). Log at Information so the
+ // event appears in production logs without verbose-debug being enabled.
+ _logger.LogInformation(
+ "DCV already in flight for order {OrderNumber}; skipping concurrent attempt.",
+ orderNumber);
+ return false;
+ }
+
+ try
+ {
+ int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes();
+ using var dcvCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ dcvCts.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes));
+
+ _logger.LogInformation(
+ "Attempting deferred DCV during sync/refresh (single-shot challenge check). " +
+ "OrderNumber={OrderNumber}, DcvTimeoutMinutes={Timeout}",
+ orderNumber, timeoutMinutes);
+
+ return await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token,
+ waitForChallengeSecondsOverride: 0,
+ propagationDelaySecondsOverride: fastSync ? Constants.Dcv.SyncPropagationDelaySeconds : (int?)null);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Deferred DCV attempt failed for order {OrderNumber}. Order will be retried on the next sync cycle.",
+ orderNumber);
+ return false;
+ }
+ finally
+ {
+ _dcvInFlight.TryRemove(orderNumber, out _);
+ }
+#else
+ // DCV is not supported on this build (IAnyCAPlugin 3.2.0). No-op: pending orders
+ // are reported as EXTERNALVALIDATION and not advanced during sync. See issue 0003.
+ await Task.CompletedTask;
+ return false;
+#endif
+ }
+
+ ///
+ /// Runs DNS DCV for any domains on that are still pending
+ /// validation. Returns true when DCV steps were executed, false when
+ /// skipped (order already issued, no pending domains, or factory not available).
+ ///
+ /// Rule: if the order is already issued we never attempt DCV — it would be a no-op
+ /// at best and could confuse the CA at worst.
+ ///
+ /// lets the sync path force a
+ /// single-shot challenge check (pass 0 ) so a sync cycle doesn't spend up to
+ /// DcvWaitForChallengeSeconds per pending order waiting for CERTInext to
+ /// expose the DCV slot — sync runs periodically, so unexposed orders are picked up
+ /// on the next cycle instead. Enroll passes null to keep the full configured
+ /// budget (user-visible latency benefits from a one-shot end-to-end finish).
+ ///
+#if SUPPORTS_DCV
+ private async Task PerformDcvIfNeededAsync(
+ string orderNumber,
+ CancellationToken ct,
+ int? waitForChallengeSecondsOverride = null,
+ int? propagationDelaySecondsOverride = null)
+ {
+ // Poll TrackOrder until CERTInext exposes the DCV challenge (domainVerification
+ // populated) OR the cert reaches a terminal state OR the wait budget expires.
+ // Under concurrent enrollment load CERTInext sometimes takes a few seconds to
+ // materialize the slot after GenerateOrderSSL returns — without this wait a
+ // race-condition order skips DCV entirely and waits for the next sync cycle.
+ int waitBudgetSeconds = waitForChallengeSecondsOverride
+ ?? _config.GetEffectiveDcvWaitForChallengeSeconds();
+ // Challenge-wait poll interval is clamped to [1s, 5s] so it's responsive even
+ // when an admin has set DcvPropagationDelaySeconds high for slow zones (that
+ // setting governs how long we wait *after* publishing a TXT record, which is a
+ // different, slower concern than how often we re-check TrackOrder here).
+ int challengePollSeconds = Math.Max(1, Math.Min(5, _config.DcvPropagationDelaySeconds > 0 ? _config.DcvPropagationDelaySeconds : 5));
+ var waitDeadline = DateTime.UtcNow.AddSeconds(Math.Max(0, waitBudgetSeconds));
+
+ TrackOrderResponse track = null;
+ API.TrackOrderDomainVerification domainVerification = null;
+ int pollAttempts = 0;
+
+ while (true)
+ {
+ pollAttempts++;
+ ct.ThrowIfCancellationRequested();
+ track = await _client.TrackOrderAsync(orderNumber, ct);
+
+ // Skip DCV entirely if the certificate is already issued or revoked
+ if (track.OrderDetails != null
+ && int.TryParse(track.OrderDetails.CertificateStatusId, out int certStatusId))
+ {
+ int disposition = StatusMapper.CertificateStatusIdToRequestDisposition(certStatusId);
+ if (disposition == (int)EndEntityStatus.GENERATED || disposition == (int)EndEntityStatus.REVOKED)
+ {
+ _logger.LogDebug(
+ "DCV skipped — order {OrderNumber} is already in terminal state (certificateStatusId={Status}).",
+ orderNumber, certStatusId);
+ return false;
+ }
+ }
+
+ // Skip if the order itself reached a terminal failure state. Without this
+ // the cached-DCV path below could still return true on a cancelled order
+ // (domainVerification.Status = "1" survives the cancellation), sending the
+ // caller into a wasted DcvWaitForIssuanceSeconds-long GetCertificate poll
+ // that can never resolve. OrderStatusId 4 = cancelled, 5 = rejected.
+ if (track.OrderDetails?.OrderStatusId is "4" or "5")
+ {
+ _logger.LogDebug(
+ "DCV skipped — order {OrderNumber} is cancelled/rejected " +
+ "(orderStatusId={OrderStatus}).",
+ orderNumber, track.OrderDetails.OrderStatusId);
+ return false;
+ }
+
+ domainVerification = track.OrderDetails?.DomainVerification;
+ if (domainVerification != null)
+ break;
+
+ // domainVerification still null — sleep and retry if we have budget left.
+ if (waitBudgetSeconds <= 0 || DateTime.UtcNow >= waitDeadline)
+ {
+ _logger.LogInformation(
+ "DCV challenge not exposed by CERTInext within {Budget}s for order {OrderNumber} " +
+ "(attempted {Attempts} TrackOrder polls). Deferring to next sync cycle.",
+ waitBudgetSeconds, orderNumber, pollAttempts);
+ return false;
+ }
+
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(challengePollSeconds), ct);
+ }
+ catch (OperationCanceledException)
+ {
+ return false;
+ }
+ }
+
+ // If DCV is already validated CERTInext-side, the plugin has no DCV work to
+ // do — but CERTInext's certificate generation may still be in flight (this
+ // happens when CERTInext has cached a prior DCV validation for the parent
+ // domain). Return true so the caller can run the issuance poll and pick up
+ // the cert directly from Enroll() instead of leaving it for the next sync.
+ //
+ // Treat "DCV done" as EITHER the overall aggregate Status flipping to "1"
+ // OR every individual per-domain dcvStatus being "1" — observed in the wild
+ // that the per-domain field can flip before the parent aggregate.
+ var allDomainEntries = domainVerification.GetDomainEntries();
+ bool aggregateValidated = string.Equals(
+ domainVerification.Status, Constants.Dcv.StatusValidated, StringComparison.Ordinal);
+ bool everyDomainValidated = allDomainEntries.Count > 0
+ && allDomainEntries.All(kvp => string.Equals(
+ kvp.Value?.DcvStatus, Constants.Dcv.StatusValidated, StringComparison.Ordinal));
+ if (aggregateValidated || everyDomainValidated)
+ {
+ _logger.LogInformation(
+ "DCV is already validated for order {OrderNumber} " +
+ "(aggregateStatus={Aggregate}, perDomainAllValidated={PerDomain}). " +
+ "Skipping DNS-TXT staging; caller may run the issuance poll.",
+ orderNumber, aggregateValidated, everyDomainValidated);
+ return true;
+ }
+
+ // Include domains that are pending DCV and either have no method set yet,
+ // or are already assigned to DNS TXT (numeric "1" from API or label from TrackOrder).
+ // Domains assigned to HTTP or email DCV are excluded — we must not override them.
+ var pendingDomains = domainVerification.GetDomainEntries()
+ .Where(kvp =>
+ {
+ if (!string.Equals(kvp.Value?.DcvStatus, Constants.Dcv.StatusPending, StringComparison.Ordinal))
+ return false;
+ string method = kvp.Value?.DcvMethod ?? string.Empty;
+ return string.IsNullOrEmpty(method)
+ || string.Equals(method, Constants.Dcv.MethodDnsTxt, StringComparison.Ordinal)
+ || string.Equals(method, Constants.Dcv.MethodDnsTxtLabel, StringComparison.OrdinalIgnoreCase);
+ })
+ .ToList();
+
+ // SOX CC6.1: validate domain names before passing them to the DNS provider plugin
+ // or the CERTInext API. A malformed domain (empty, whitespace, or containing
+ // characters outside the FQDN alphabet) could cause log injection or unexpected
+ // DNS plugin behaviour. Invalid entries are rejected loudly rather than silently
+ // skipped so the condition is visible in the audit trail.
+ foreach (var (domain, _) in pendingDomains)
+ {
+ if (string.IsNullOrWhiteSpace(domain))
+ throw new InvalidOperationException(
+ $"TrackOrder returned a blank domain key in domainVerification for order '{orderNumber}'. " +
+ "Cannot proceed with DCV.");
+
+ // Allow standard FQDN characters plus wildcard prefix (*.example.com)
+ if (!System.Text.RegularExpressions.Regex.IsMatch(domain, @"^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$"))
+ {
+ _logger.LogError(
+ "DCV domain name failed validation and will not be processed. OrderNumber={OrderNumber}, Domain={Domain}",
+ orderNumber, domain);
+ throw new InvalidOperationException(
+ $"TrackOrder returned an invalid domain name '{domain}' in domainVerification for order '{orderNumber}'. " +
+ "Domain names must conform to FQDN syntax.");
+ }
+ }
+
+ if (pendingDomains.Count == 0)
+ return false;
+
+ _logger.LogInformation(
+ "DCV required for order {OrderNumber}. Pending DNS TXT domains: [{Domains}]",
+ orderNumber, string.Join(", ", pendingDomains.Select(x => x.Key)));
+
+ var stagedValidations = new List<(string domain, string hostname, Keyfactor.AnyGateway.Extensions.IDomainValidator validator)>();
+
+ // Stage DNS TXT records for all pending domains
+ foreach (var (domain, _) in pendingDomains)
+ {
+ GetDcvResponse dcvResp;
+ try
+ {
+ dcvResp = await _client.GetDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, ct);
+ }
+ catch (Exception ex) when (IsDcvNotYetReady(ex))
+ {
+ // CERTInext occasionally exposes the DCV slot in TrackOrder (so
+ // domainVerification is populated and dcvStatus="0") before the GetDcv
+ // endpoint will accept calls for that order — observed as EMS-956
+ // "Invalid Request for this API" for several hours after enrollment.
+ // Treat this as "DCV not ready yet": skip the DCV ceremony for now and
+ // let the sync-driven retry pick it up on a later cycle. We must NOT
+ // throw, because that would fail the entire Enroll call and prevent the
+ // gateway from recording the pending order at all.
+ _logger.LogInformation(
+ "GetDcv not yet accepting calls for order {OrderNumber} domain {Domain} ({Error}). " +
+ "Deferring DCV to the next sync cycle.",
+ orderNumber, domain, ex.Message);
+ return false;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "GetDcv failed for order {OrderNumber} domain {Domain}", orderNumber, domain);
+ throw;
+ }
+
+ string token = dcvResp.DcvDetails?.Token;
+ if (string.IsNullOrWhiteSpace(token))
+ throw new InvalidOperationException(
+ $"GetDcv returned no token for order '{orderNumber}' domain '{domain}'.");
+
+ string template = string.IsNullOrWhiteSpace(_config.DcvTxtRecordTemplate)
+ ? Constants.Dcv.DefaultTxtRecordTemplate
+ : _config.DcvTxtRecordTemplate;
+ string hostname = string.Format(template, domain);
+
+ var validator = DomainValidatorFactory.ResolveDomainValidator(domain, "dns-01");
+ if (validator == null)
+ throw new InvalidOperationException(
+ $"No DNS provider plugin is configured for domain '{domain}'. " +
+ "Ensure the appropriate DNS provider plugin is deployed and configured on the gateway.");
+
+ _logger.LogInformation(
+ "Staging DNS TXT record for DCV. OrderNumber={OrderNumber}, Domain={Domain}, Hostname={Hostname}",
+ orderNumber, domain, hostname);
+
+ var stageResult = await validator.StageValidation(hostname, token, ct);
+ if (!stageResult.Success)
+ throw new InvalidOperationException(
+ $"Failed to stage DNS validation for '{domain}': {stageResult.ErrorMessage}");
+
+ stagedValidations.Add((domain, hostname, validator));
+ }
+
+ if (stagedValidations.Count == 0)
+ return false;
+
+ try
+ {
+ // Allow DNS propagation before asking CERTInext to verify. The sync path passes
+ // a short override (issue 0002) so a bounded set of recent pending orders doesn't
+ // each burn the full configured delay; Enroll uses the full configured value.
+ int delaySeconds = propagationDelaySecondsOverride
+ ?? (_config.DcvPropagationDelaySeconds > 0 ? _config.DcvPropagationDelaySeconds : 30);
+ _logger.LogInformation(
+ "Waiting {Delay}s for DNS propagation before verifying DCV. OrderNumber={OrderNumber}",
+ delaySeconds, orderNumber);
+ await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct);
+
+ foreach (var (domain, hostname, _) in stagedValidations)
+ {
+ _logger.LogInformation(
+ "Triggering CERTInext DCV verification. OrderNumber={OrderNumber}, Domain={Domain}", orderNumber, domain);
+ await _client.VerifyDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, ct);
+ }
+
+ // Poll TrackOrder until CERTInext confirms all staged domains are verified
+ // before removing TXT records — VerifyDcv triggers an async DNS lookup on
+ // their side, so cleanup must wait for dcvStatus=1 on every domain.
+ await WaitForDcvVerificationAsync(orderNumber, stagedValidations.Select(s => s.domain).ToList(), ct);
+ }
+ finally
+ {
+ // Always clean up staged DNS records — even on failure
+ foreach (var (domain, hostname, validator) in stagedValidations)
+ {
+ try
+ {
+ await validator.CleanupValidation(hostname, ct);
+ _logger.LogInformation(
+ "DNS TXT record cleaned up. Domain={Domain}, Hostname={Hostname}", domain, hostname);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Failed to clean up DNS TXT record. Domain={Domain}, Hostname={Hostname}", domain, hostname);
+ }
+ }
+ }
+
+ return true;
+ }
+#endif
+
+ ///
+ /// Polls GetCertificateAsync until either (a) the certificate reaches a terminal
+ /// state (issued or rejected) or (b) the configured DcvWaitForIssuanceSeconds
+ /// budget expires. Returns the final response on success, or null if all polls
+ /// failed (so callers fall back to the pending result they already have).
+ ///
+ /// CERTInext's issuance pipeline is asynchronous on their side: after the plugin's
+ /// VerifyDcv triggers and the per-domain DCV is confirmed, the cert generation step
+ /// finishes a few seconds later. Without this poll the plugin would catch the cert
+ /// in pending state and return it that way, forcing the gateway to wait for the next
+ /// sync cycle.
+ ///
+ private async Task WaitForIssuanceAfterDcvAsync(
+ string orderNumber, CancellationToken ct)
+ {
+ int waitBudgetSeconds = _config.GetEffectiveDcvWaitForIssuanceSeconds();
+
+ // Fixed 3-second poll interval. CERTInext's post-DCV issuance step typically
+ // completes within 5–15s; polling more aggressively would just add API load,
+ // and polling more slowly would push the typical-case latency closer to the
+ // budget ceiling. Decoupled from DcvPropagationDelaySeconds (which is for DNS
+ // propagation, a different concern) so admins tuning DNS settings don't
+ // accidentally make post-DCV polling chunky.
+ int pollIntervalSeconds = 3;
+ DateTime deadline = DateTime.UtcNow.AddSeconds(Math.Max(0, waitBudgetSeconds));
+ LegacyGetCertificateResponse last = null;
+
+ // Admin opt-out: budget <= 0 means "don't wait, let sync pick the cert up".
+ // Short-circuit before any API call so the gateway doesn't pay a TrackOrder +
+ // optional DownloadCertificate round trip per Enroll when the admin has
+ // explicitly disabled the wait.
+ if (waitBudgetSeconds <= 0)
+ {
+ _logger.LogDebug(
+ "Post-DCV issuance wait disabled (DcvWaitForIssuanceSeconds<=0). " +
+ "Order {OrderNumber} will be picked up on the next sync cycle.",
+ orderNumber);
+ return null;
+ }
+
+ int attempt = 0;
+ while (true)
+ {
+ attempt++;
+ ct.ThrowIfCancellationRequested();
+ try
+ {
+ last = await _client.GetCertificateAsync(orderNumber, ct);
+ }
+ catch (Exception ex)
+ {
+ // Distinguish first-call failure (no result to return, sync must pick up)
+ // from later-poll failure (we have a prior pending result that the caller
+ // can use as a fallback). Without this distinction a repeated first-call
+ // failure would look identical to a working-but-always-pending enroll.
+ _logger.LogWarning(ex,
+ "Post-DCV GetCertificate failed for order {OrderNumber} (attempt {Attempt}). " +
+ "Returning {Outcome}; sync will pick up the cert later.",
+ orderNumber, attempt, last == null ? "pending fallback (no prior result)" : "prior pending result");
+ return last;
+ }
+
+ int disposition = StatusMapper.ToRequestDisposition(last.Status);
+ if (disposition == (int)EndEntityStatus.GENERATED
+ || disposition == (int)EndEntityStatus.REVOKED
+ || disposition == (int)EndEntityStatus.FAILED)
+ {
+ return last;
+ }
+
+ if (waitBudgetSeconds <= 0 || DateTime.UtcNow >= deadline)
+ {
+ _logger.LogInformation(
+ "Post-DCV issuance not complete within {Budget}s for order {OrderNumber}. " +
+ "Returning pending result; sync will pick up the cert later.",
+ waitBudgetSeconds, orderNumber);
+ return last;
+ }
+
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(pollIntervalSeconds), ct);
+ }
+ catch (OperationCanceledException)
+ {
+ return last;
+ }
+ }
+ }
+
+ ///
+ /// Polls until every domain in
+ /// reaches dcvStatus=1 (verified) or a terminal
+ /// failure state (rejected/cancelled), or is cancelled.
+ /// Called after VerifyDcvAsync to ensure CERTInext has completed its async
+ /// DNS lookup before TXT records are cleaned up.
+ ///
+ private async Task WaitForDcvVerificationAsync(string orderNumber, IReadOnlyList domains, CancellationToken ct)
+ {
+ if (domains.Count == 0) return;
+
+ var pending = new HashSet(domains, StringComparer.OrdinalIgnoreCase);
+ int pollSeconds = Math.Max(1, _config.DcvPropagationDelaySeconds);
+
+ // Defense-in-depth deadline: SOX CC7.3 requires every wait to be bounded.
+ // The caller passes a `ct` derived from a CancellationTokenSource that already
+ // cancels after `DcvTimeoutMinutes`, so this method is bounded via that path.
+ // We add an explicit internal deadline so a future refactor breaking the
+ // cancellation chain (e.g. accidentally passing CancellationToken.None) can't
+ // make this loop unbounded — it would still exit on the deadline below.
+ var verificationDeadline = DateTime.UtcNow.AddMinutes(_config.GetEffectiveDcvTimeoutMinutes());
+
+ while (pending.Count > 0 && !ct.IsCancellationRequested)
+ {
+ if (DateTime.UtcNow >= verificationDeadline)
+ {
+ _logger.LogWarning(
+ "DCV verification poll exceeded its internal deadline ({Minutes}min). " +
+ "OrderNumber={OrderNumber}, StillPendingDomains=[{Pending}]. " +
+ "Exiting and leaving TXT records for the caller's finally block to clean up.",
+ _config.GetEffectiveDcvTimeoutMinutes(), orderNumber, string.Join(",", pending));
+ return;
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(pollSeconds), ct);
+
+ TrackOrderResponse poll;
+ try { poll = await _client.TrackOrderAsync(orderNumber, ct); }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "TrackOrder polling failed during DCV wait. OrderNumber={OrderNumber}", orderNumber);
+ return;
+ }
+
+ var entries = poll.OrderDetails?.DomainVerification?.GetDomainEntries()
+ ?? new Dictionary();
+
+ // Check for order-level terminal failure (cancelled/rejected)
+ if (poll.OrderDetails?.OrderStatusId is "4" or "5")
+ {
+ _logger.LogWarning(
+ "Order {OrderNumber} reached terminal failure state (OrderStatusId={Status}) during DCV wait. TXT records will be cleaned up.",
+ orderNumber, poll.OrderDetails.OrderStatusId);
+ return;
+ }
+
+ foreach (var domain in domains)
+ {
+ if (!pending.Contains(domain)) continue;
+ if (!entries.TryGetValue(domain, out var detail)) continue;
+
+ if (string.Equals(detail.DcvStatus, Constants.Dcv.StatusValidated, StringComparison.Ordinal))
+ {
+ _logger.LogInformation("DCV verified by CERTInext. OrderNumber={OrderNumber}, Domain={Domain}", orderNumber, domain);
+ pending.Remove(domain);
+ }
+ else if (string.Equals(detail.DcvStatus, Constants.Dcv.StatusRejected, StringComparison.Ordinal))
+ {
+ _logger.LogWarning("DCV rejected by CERTInext. OrderNumber={OrderNumber}, Domain={Domain}", orderNumber, domain);
+ pending.Remove(domain);
+ }
+ }
+ }
+ }
+
///
/// Converts a CERTInext API enrollment/renewal response into the
/// expected by the AnyCA gateway.
@@ -972,6 +2010,9 @@ private static string GetStringValue(
/// Extracts the X.509 serial number from a PEM-encoded certificate for inclusion
/// in audit log entries. Returns "(parse-error)" rather than throwing, so that a
/// logging failure never suppresses an audit record.
+ ///
+ /// Implemented with BouncyCastle (per the project's crypto policy: all certificate
+ /// and key handling goes through BouncyCastle, never BCL System.Security.Cryptography).
///
private static string ExtractSerialFromPem(string pem)
{
@@ -989,11 +2030,25 @@ private static string ExtractSerialFromPem(string pem)
return "(empty-pem)";
byte[] der = Convert.FromBase64String(b64);
- using var cert = new System.Security.Cryptography.X509Certificates.X509Certificate2(der);
- return cert.SerialNumber;
+ var parser = new Org.BouncyCastle.X509.X509CertificateParser();
+ var cert = parser.ReadCertificate(der);
+ if (cert == null)
+ return "(parse-error)";
+ // Match X509Certificate2.SerialNumber's format precisely: uppercase hex,
+ // byte-per-byte, *preserving* leading-zero bytes (e.g. serial bytes
+ // 0A 12 34 56 → "0A123456", not "A123456"). BouncyCastle's
+ // BigInteger.ToString(16) drops the leading-zero nibble, which would
+ // break audit-log correlation against Command's stored serial. Convert
+ // the unsigned-magnitude byte array to hex directly instead.
+ byte[] serialBytes = cert.SerialNumber.ToByteArrayUnsigned();
+ return Convert.ToHexString(serialBytes).ToUpperInvariant();
}
- catch
+ catch (Exception ex)
{
+ // SOC2 CC7.2: never let audit-log generation throw, but log the suppression
+ // at Debug so an auditor diagnosing missing serial numbers can see the cause.
+ LogHandler.GetClassLogger(typeof(CERTInextCAPlugin))
+ .LogDebug(ex, "ExtractSerialFromPem suppressed parse failure");
return "(parse-error)";
}
}
diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs
index acd3f81..43d0537 100644
--- a/CERTInext/CERTInextCAPluginConfig.cs
+++ b/CERTInext/CERTInextCAPluginConfig.cs
@@ -46,10 +46,61 @@ public static Dictionary GetCAConnectorAnnotations()
[Constants.Config.GroupNumber] = new PropertyConfigInfo
{
Comments = "OPTIONAL: CERTInext group (delegation) number. " +
- "When set, it is included in GetProductDetails requests so the full " +
- "product list is returned. Some sandbox accounts require this to avoid " +
- "receiving an empty product list. Available in the CERTInext portal under " +
- "Delegation → Groups.",
+ "When set, it is included in GetProductDetails requests AND in the " +
+ "`delegationInformation.groupNumber` field of every SSL order so the order " +
+ "is routed to the correct account group. Some accounts will queue orders for " +
+ "additional review when this field is omitted. " +
+ "Available in the CERTInext portal under Delegation → Groups.",
+ Hidden = false,
+ DefaultValue = string.Empty,
+ Type = "String"
+ },
+ [Constants.Config.OrganizationNumber] = new PropertyConfigInfo
+ {
+ Comments = "STRONGLY RECOMMENDED for OV/EV and faster DV issuance: numeric " +
+ "CERTInext organization number for a pre-vetted organization (e.g. " +
+ "your company's pre-vetted entry). When set, every SSL order is submitted " +
+ "with `organizationDetails.preVetting=\"1\"` and the configured " +
+ "`organizationNumber`, telling CERTInext to skip the manual " +
+ "organization-vetting queue. Without this value, orders are placed without " +
+ "any organizationDetails block and CERTInext may park them in " +
+ "`Pending System RA` for extended manual review (observed: tens of hours). " +
+ "Available in the CERTInext portal under Organizations → " +
+ "Pre-vetted Organizations.",
+ Hidden = false,
+ DefaultValue = string.Empty,
+ Type = "String"
+ },
+ [Constants.Config.TechnicalContactName] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: Name sent in the `technicalPointOfContact.tpcName` field of every " +
+ "SSL order. Defaults to the configured RequestorName when blank. " +
+ "Some product configurations require a TPoC to be present; omitting it can " +
+ "cause CERTInext to park orders awaiting manual completion of the field.",
+ Hidden = false,
+ DefaultValue = string.Empty,
+ Type = "String"
+ },
+ [Constants.Config.TechnicalContactEmail] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: Email sent in the `technicalPointOfContact.tpcEmail` field of every " +
+ "SSL order. Defaults to the configured RequestorEmail when blank.",
+ Hidden = false,
+ DefaultValue = string.Empty,
+ Type = "String"
+ },
+ [Constants.Config.TechnicalContactIsdCode] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: International dialing code for the TPoC phone number. " +
+ "Defaults to the configured RequestorIsdCode when blank.",
+ Hidden = false,
+ DefaultValue = string.Empty,
+ Type = "String"
+ },
+ [Constants.Config.TechnicalContactMobileNumber] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: Mobile number for the TPoC (digits only). " +
+ "Defaults to the configured RequestorMobileNumber when blank.",
Hidden = false,
DefaultValue = string.Empty,
Type = "String"
@@ -147,6 +198,57 @@ public static Dictionary GetCAConnectorAnnotations()
DefaultValue = string.Empty,
Type = "String"
},
+ [Constants.Config.AccountingModel] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: CERTInext billing model sent in `orderDetails.accountingModel`. " +
+ "\"2\" = credit-based (most accounts, default). \"1\" = cash model.",
+ Hidden = false,
+ DefaultValue = "2",
+ Type = "String"
+ },
+ [Constants.Config.EmailNotifications] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: Whether CERTInext sends lifecycle-event emails to the requestor. " +
+ "\"1\" = enabled, \"0\" = silent (recommended for gateway-driven orders so end users " +
+ "aren't surprised by CA emails). Default: \"0\".",
+ Hidden = false,
+ DefaultValue = "0",
+ Type = "String"
+ },
+ [Constants.Config.SubscriptionValidityYears] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: Default validity in years for SSL orders. \"1\", \"2\", or \"3\". " +
+ "Override per template via the ValidityYears product parameter. Default: \"1\".",
+ Hidden = false,
+ DefaultValue = "1",
+ Type = "String"
+ },
+ [Constants.Config.SubscriptionAutoRenew] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: Whether CERTInext should auto-renew certificates issued through " +
+ "this connector. \"0\" = disabled (recommended — renewal is driven by Keyfactor " +
+ "Command), \"1\" = enabled. Default: \"0\".",
+ Hidden = false,
+ DefaultValue = "0",
+ Type = "String"
+ },
+ [Constants.Config.SubscriptionRenewCriteriaDays] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: Days before expiry at which CERTInext auto-renews (only honored when " +
+ "SubscriptionAutoRenew = \"1\"). Typical values: \"30\" or \"60\". Default: \"30\".",
+ Hidden = false,
+ DefaultValue = "30",
+ Type = "String"
+ },
+ [Constants.Config.AutoSecureWww] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: If \"1\", CERTInext automatically adds the `www.` variant of the " +
+ "primary domain as an additional SAN. \"0\" = use only the CN/SANs supplied " +
+ "with the CSR. Default: \"0\".",
+ Hidden = false,
+ DefaultValue = "0",
+ Type = "String"
+ },
[Constants.Config.IgnoreExpired] = new PropertyConfigInfo
{
Comments = "If true, expired certificates will be skipped during synchronization. Default: false.",
@@ -169,6 +271,93 @@ public static Dictionary GetCAConnectorAnnotations()
Hidden = false,
DefaultValue = true,
Type = "Boolean"
+ },
+ [Constants.Config.DcvEnabled] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: When true, the gateway will perform DNS-based Domain Control Validation (DCV) " +
+ "during enrollment for orders that require it, using the configured DNS provider plugin. " +
+ "Requires a DNS provider plugin (e.g. azure-azuredns-dnsplugin) to be deployed on the gateway. " +
+ "Default: false.",
+ Hidden = false,
+ DefaultValue = false,
+ Type = "Boolean"
+ },
+ [Constants.Config.DcvTxtRecordTemplate] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: Format string for the DNS TXT record hostname used during DCV. " +
+ "{0} is replaced with the domain name being validated. " +
+ $"Default: {Constants.Dcv.DefaultTxtRecordTemplate}",
+ Hidden = false,
+ DefaultValue = Constants.Dcv.DefaultTxtRecordTemplate,
+ Type = "String"
+ },
+ [Constants.Config.DcvPropagationDelaySeconds] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: Seconds to wait after publishing the DNS TXT record before asking CERTInext " +
+ "to verify it. Increase for zones with slow propagation. Default: 30.",
+ Hidden = false,
+ DefaultValue = 30,
+ Type = "Number"
+ },
+ [Constants.Config.DcvTimeoutMinutes] = new PropertyConfigInfo
+ {
+ Comments = $"OPTIONAL: Maximum minutes to wait for the entire DCV flow (DNS publish + propagation + verify) " +
+ $"before timing out the enrollment. Can also be set via the {Constants.Config.DcvTimeoutMinutesEnvVar} " +
+ $"environment variable; the env var takes precedence when both are set. Default: 10.",
+ Hidden = false,
+ DefaultValue = 10,
+ Type = "Number"
+ },
+ [Constants.Config.DcvWaitForChallengeSeconds] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: How long (seconds) the plugin will wait inside Enroll() for CERTInext to " +
+ "expose the DCV challenge (i.e. populate `domainVerification` in TrackOrder). Under " +
+ "concurrent load CERTInext sometimes takes a few seconds after GenerateOrderSSL " +
+ "before the slot appears. Without this wait, the plugin's initial TrackOrder check " +
+ "sees null and skips DCV — the order then has to wait for the next gateway sync " +
+ "cycle to be picked up. Setting to 0 disables the wait (single-check behaviour). " +
+ $"Can also be set via the {Constants.Config.DcvWaitForChallengeSecondsEnvVar} " +
+ "environment variable; the env var takes precedence when both are set. Default: 60.",
+ Hidden = false,
+ DefaultValue = 60,
+ Type = "Number"
+ },
+ [Constants.Config.DcvWaitForIssuanceSeconds] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: How long (seconds) the plugin will wait inside Enroll() after DCV " +
+ "verifies for CERTInext to finish generating the certificate. CERTInext issuance " +
+ "is async — DCV may be verified but the cert PEM isn't yet available for download. " +
+ "Without this wait, Enroll() returns a pending result and the issued cert is " +
+ "picked up by the next sync cycle. Setting to 0 disables the wait (single-fetch " +
+ "behaviour). " +
+ $"Can also be set via the {Constants.Config.DcvWaitForIssuanceSecondsEnvVar} " +
+ "environment variable; the env var takes precedence when both are set. Default: 60.",
+ Hidden = false,
+ DefaultValue = 60,
+ Type = "Number"
+ },
+ [Constants.Config.DcvSyncMaxOrderAgeHours] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: During synchronization, only pending DV orders younger than this many hours " +
+ "are eligible to be driven through DCV. This keeps a sync pass fast when there is a " +
+ "large backlog of old, never-completing pending orders (e.g. abandoned orders or domains " +
+ "outside the configured DNS provider's zone): they age out and are simply reported as " +
+ "pending rather than retried every pass. Recently-placed orders (the ones that legitimately " +
+ "deferred DCV) are always within the window and complete via the normal scan cadence. " +
+ $"Set to 0 to disable the age filter (attempt DCV for all pending). Default: {Constants.Dcv.DefaultSyncMaxOrderAgeHours}.",
+ Hidden = false,
+ DefaultValue = Constants.Dcv.DefaultSyncMaxOrderAgeHours,
+ Type = "Number"
+ },
+ [Constants.Config.DcvSyncMaxPerPass] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL: Maximum number of pending DV orders the plugin will attempt to drive through DCV " +
+ "in a single synchronization pass. Bounds the per-pass cost regardless of backlog size; " +
+ "remaining pending orders are reported as-is and picked up on a later pass (the per-minute " +
+ $"incremental scan keeps recent orders moving). Set to 0 to disable the cap. Default: {Constants.Dcv.DefaultSyncMaxPerPass}.",
+ Hidden = false,
+ DefaultValue = Constants.Dcv.DefaultSyncMaxPerPass,
+ Type = "Number"
}
};
}
@@ -318,12 +507,27 @@ public class CERTInextConfig
///
/// Optional CERTInext group (delegation) number. When set, it is passed in
/// the productDetails.groupNumber field of GetProductDetails
- /// requests so that the account's full product list is returned. Some sandbox
- /// accounts return an empty product list if this field is omitted.
+ /// requests AND in the delegationInformation.groupNumber field of every
+ /// SSL order body so the order is routed to the correct account group. Some
+ /// accounts queue orders for extra review when this field is omitted.
///
[JsonPropertyName("GroupNumber")]
public string GroupNumber { get; set; } = string.Empty;
+ ///
+ /// CERTInext organization number for a pre-vetted organization (e.g. the customer's
+ /// company). When set, every SSL order is submitted with
+ /// organizationDetails.preVetting="1" and the configured
+ /// organizationNumber , telling CERTInext to skip the manual organization
+ /// vetting queue. Strongly recommended for OV/EV products; significantly speeds
+ /// up DV issuance because CERTInext otherwise parks orders in Pending System RA
+ /// for extended manual review (observed tens of hours on the sandbox).
+ /// Empty by default — the plugin omits the organizationDetails block when
+ /// this is unset, preserving prior behavior.
+ ///
+ [JsonPropertyName("OrganizationNumber")]
+ public string OrganizationNumber { get; set; } = string.Empty;
+
// -----------------------------------------------------------------------
// Authentication
// -----------------------------------------------------------------------
@@ -405,6 +609,56 @@ public class CERTInextConfig
[JsonPropertyName("DefaultProductCode")]
public string DefaultProductCode { get; set; } = string.Empty;
+ // -----------------------------------------------------------------------
+ // Technical point-of-contact — populated into technicalPointOfContact on SSL orders.
+ // When any field is blank, the corresponding Requestor* default is used.
+ // -----------------------------------------------------------------------
+
+ /// Technical contact name. Defaults to when blank.
+ [JsonPropertyName("TechnicalContactName")]
+ public string TechnicalContactName { get; set; } = string.Empty;
+
+ /// Technical contact email. Defaults to when blank.
+ [JsonPropertyName("TechnicalContactEmail")]
+ public string TechnicalContactEmail { get; set; } = string.Empty;
+
+ /// Technical contact ISD code. Defaults to when blank.
+ [JsonPropertyName("TechnicalContactIsdCode")]
+ public string TechnicalContactIsdCode { get; set; } = string.Empty;
+
+ /// Technical contact mobile number. Defaults to when blank.
+ [JsonPropertyName("TechnicalContactMobileNumber")]
+ public string TechnicalContactMobileNumber { get; set; } = string.Empty;
+
+ // -----------------------------------------------------------------------
+ // SSL order body defaults — every value matches a CERTInext-documented field
+ // and is overridable per-connector via the gateway admin UI.
+ // -----------------------------------------------------------------------
+
+ /// CERTInext billing model ("2" credit, "1" cash). Default "2".
+ [JsonPropertyName("AccountingModel")]
+ public string AccountingModel { get; set; } = "2";
+
+ /// "1" = enable lifecycle emails to requestor, "0" = silent (default).
+ [JsonPropertyName("EmailNotifications")]
+ public string EmailNotifications { get; set; } = "0";
+
+ /// Default validity in years sent in subscriptionDetails. "1", "2", or "3". Default "1".
+ [JsonPropertyName("SubscriptionValidityYears")]
+ public string SubscriptionValidityYears { get; set; } = "1";
+
+ /// "0" = disable CERTInext-side auto-renew (recommended — renewal is driven by Command). "1" = enable.
+ [JsonPropertyName("SubscriptionAutoRenew")]
+ public string SubscriptionAutoRenew { get; set; } = "0";
+
+ /// Days before expiry at which CERTInext auto-renews (only honored when SubscriptionAutoRenew="1").
+ [JsonPropertyName("SubscriptionRenewCriteriaDays")]
+ public string SubscriptionRenewCriteriaDays { get; set; } = "30";
+
+ /// "1" = let CERTInext auto-add the www. variant, "0" = use only the supplied CN/SANs (default).
+ [JsonPropertyName("AutoSecureWww")]
+ public string AutoSecureWww { get; set; } = "0";
+
// -----------------------------------------------------------------------
// Sync / behaviour
// -----------------------------------------------------------------------
@@ -417,5 +671,116 @@ public class CERTInextConfig
[JsonPropertyName("Enabled")]
public bool Enabled { get; set; } = true;
+
+ // -----------------------------------------------------------------------
+ // DCV — domain control validation via DNS provider plugins
+ // -----------------------------------------------------------------------
+
+ ///
+ /// When true, the plugin will run DNS DCV for orders that require it during enrollment.
+ /// Requires IDomainValidatorFactory to be injected by the gateway (available from
+ /// IAnyCAPlugin 3.3.0-prerelease ). Default: false.
+ ///
+ [JsonPropertyName("DcvEnabled")]
+ public bool DcvEnabled { get; set; } = false;
+
+ ///
+ /// Format string for the TXT record hostname. {0} is replaced with the domain.
+ /// Default: _emsign-validation.{0} .
+ ///
+ [JsonPropertyName("DcvTxtRecordTemplate")]
+ public string DcvTxtRecordTemplate { get; set; } = Constants.Dcv.DefaultTxtRecordTemplate;
+
+ ///
+ /// Seconds to wait after publishing the DNS TXT record before calling VerifyDcv.
+ /// Default: 30.
+ ///
+ [JsonPropertyName("DcvPropagationDelaySeconds")]
+ public int DcvPropagationDelaySeconds { get; set; } = 30;
+
+ ///
+ /// Maximum minutes for the entire DCV flow before the enrollment is cancelled.
+ /// Overridden by the CERTINEXT_DCV_TIMEOUT_MINUTES environment variable when set.
+ /// Default: 10.
+ ///
+ [JsonPropertyName("DcvTimeoutMinutes")]
+ public int DcvTimeoutMinutes { get; set; } = 10;
+
+ ///
+ /// Seconds the plugin will poll inside Enroll() waiting for CERTInext to populate
+ /// domainVerification in TrackOrder . Under concurrent load the slot can
+ /// take a few seconds to appear after GenerateOrderSSL returns; without this
+ /// wait the plugin's initial single-shot check sees null and skips DCV.
+ /// Set to 0 to disable the wait (preserving the single-check behaviour).
+ /// Overridden by CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS when set. Default: 60.
+ ///
+ [JsonPropertyName("DcvWaitForChallengeSeconds")]
+ public int DcvWaitForChallengeSeconds { get; set; } = 60;
+
+ ///
+ /// Seconds the plugin will poll GetCertificate inside Enroll() after DCV
+ /// verifies, waiting for CERTInext to finish generating the certificate. CERTInext
+ /// issuance is async — DCV may be verified but the cert PEM isn't yet available.
+ /// Set to 0 to disable the wait (preserving the single-fetch behaviour, where
+ /// the cert is picked up on the next sync cycle). Overridden by
+ /// CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS when set. Default: 60.
+ ///
+ [JsonPropertyName("DcvWaitForIssuanceSeconds")]
+ public int DcvWaitForIssuanceSeconds { get; set; } = 60;
+
+ ///
+ /// During synchronization, only pending DV orders younger than this many hours are
+ /// eligible for DCV completion. Bounds a sync pass against a large backlog of old,
+ /// never-completing pending orders (issue 0002). 0 disables the age filter.
+ /// Default: 24.
+ ///
+ [JsonPropertyName("DcvSyncMaxOrderAgeHours")]
+ public int DcvSyncMaxOrderAgeHours { get; set; } = Constants.Dcv.DefaultSyncMaxOrderAgeHours;
+
+ ///
+ /// Maximum number of pending DV orders the plugin attempts to drive through DCV in a
+ /// single sync pass (issue 0002). Bounds per-pass cost regardless of backlog size; the
+ /// remainder are reported pending and revisited on a later pass. 0 disables the cap.
+ /// Default: 50.
+ ///
+ [JsonPropertyName("DcvSyncMaxPerPass")]
+ public int DcvSyncMaxPerPass { get; set; } = Constants.Dcv.DefaultSyncMaxPerPass;
+
+ ///
+ /// Returns the effective DCV timeout, preferring the environment variable over the
+ /// config field so operators can adjust the ceiling without a connector reconfiguration.
+ ///
+ public int GetEffectiveDcvTimeoutMinutes()
+ {
+ var env = System.Environment.GetEnvironmentVariable(Constants.Config.DcvTimeoutMinutesEnvVar);
+ if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && envVal > 0)
+ return envVal;
+ return DcvTimeoutMinutes > 0 ? DcvTimeoutMinutes : 10;
+ }
+
+ ///
+ /// Returns the effective wait for the DCV challenge to appear in TrackOrder, preferring
+ /// the env var so operators can tune without re-saving the connector. A value of 0
+ /// (either field or env var) disables the wait entirely.
+ ///
+ public int GetEffectiveDcvWaitForChallengeSeconds()
+ {
+ var env = System.Environment.GetEnvironmentVariable(Constants.Config.DcvWaitForChallengeSecondsEnvVar);
+ if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && envVal >= 0)
+ return envVal;
+ return DcvWaitForChallengeSeconds >= 0 ? DcvWaitForChallengeSeconds : 60;
+ }
+
+ ///
+ /// Returns the effective post-DCV wait for cert issuance, preferring the env var.
+ /// A value of 0 disables the wait.
+ ///
+ public int GetEffectiveDcvWaitForIssuanceSeconds()
+ {
+ var env = System.Environment.GetEnvironmentVariable(Constants.Config.DcvWaitForIssuanceSecondsEnvVar);
+ if (!string.IsNullOrEmpty(env) && int.TryParse(env, out int envVal) && envVal >= 0)
+ return envVal;
+ return DcvWaitForIssuanceSeconds >= 0 ? DcvWaitForIssuanceSeconds : 60;
+ }
}
}
diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs
index 70cfa5d..255b65a 100644
--- a/CERTInext/Client/CERTInextClient.cs
+++ b/CERTInext/Client/CERTInextClient.cs
@@ -9,7 +9,6 @@
using System.Collections.Generic;
using System.Net;
using System.Runtime.CompilerServices;
-using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
@@ -161,9 +160,12 @@ public async Task PingAsync(CancellationToken ct = default)
var result = DeserializeOrThrow(resp, "validate credentials");
if (result.Meta != null && !result.Meta.IsSuccess)
{
- Logger.LogError(
- "CERTInext ValidateCredentials returned failure. ErrorCode={ErrorCode}, ErrorMessage={ErrorMsg}",
- result.Meta.ErrorCode, result.Meta.ErrorMessage);
+ // Authentication-failure-shaped event: log at Error so SOX-required
+ // SIEM rules on authentication failures fire. Every other meta-failure
+ // call site logs at the LogApiFailure default (Warning).
+ LogApiFailure("ValidateCredentials", resp,
+ result.Meta.ErrorCode, result.Meta.ErrorMessage,
+ level: LogLevel.Error);
throw new Exception(
$"CERTInext credential validation failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " +
"See gateway logs for details.");
@@ -188,32 +190,92 @@ public async Task PlaceOrderAsync(
"Submitting order to CERTInext. ProductCode={ProductCode}",
request.OrderDetails?.ProductCode);
- var req = new RestRequest(Constants.Api.GenerateOrderSslPath, Method.Post);
- req.AddJsonBody(JsonSerializer.Serialize(request, GetJsonOptions()));
+ GenerateOrderResponse result = null;
+ RestResponse resp = null;
+ // Cumulative backoff time across all rate-limit retries this call. Emitted
+ // on the success branch so an operator scraping gateway logs for rate-limit
+ // pressure (SOC2 CC7.2 anomaly-detection) can correlate by single log line
+ // rather than threading per-attempt warnings by OrderNumber.
+ double totalRateLimitBackoffSeconds = 0.0;
+
+ // Issue #8 rate-limit retry: the sandbox returns "Inactive Account User."
+ // as a generic error string for several conditions, including burst-rate-limit
+ // rejection. Empirically this resolves within seconds; auto-retrying lets a
+ // transient burst limit hit transparently. After RateLimitMaxAttempts the
+ // original exception is propagated unchanged so a genuinely-inactive account
+ // surfaces as the same operator-facing failure today.
+ for (int attempt = 1; ; attempt++)
+ {
+ // Refresh the request body's meta block on every retry — txn must be
+ // unique per call (CERTInext rejects duplicate txns), and a fresh ts/txn
+ // gives the CA a clean canary for whether the limiter has cleared.
+ if (attempt > 1)
+ request.Meta = await BuildMetaAsync(ct);
- var sw = System.Diagnostics.Stopwatch.StartNew();
- var resp = await ExecuteWithRetryAsync(req, ct);
- sw.Stop();
+ var req = new RestRequest(Constants.Api.GenerateOrderSslPath, Method.Post);
+ req.AddJsonBody(JsonSerializer.Serialize(request, GetJsonOptions()));
- Logger.LogInformation(
- "CERTInext API call: Method=POST, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}, AuthMode={AuthMode}",
- Constants.Api.GenerateOrderSslPath, (int)resp.StatusCode, sw.ElapsedMilliseconds, _config.AuthMode);
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ resp = await ExecuteWithRetryAsync(req, ct);
+ sw.Stop();
- if (resp.StatusCode == HttpStatusCode.Unauthorized || resp.StatusCode == HttpStatusCode.Forbidden)
- {
- Logger.LogError(
- "PlaceOrder API authentication failure. HttpStatus={Status}, AuthMode={AuthMode}",
- (int)resp.StatusCode, _config.AuthMode);
- throw new Exception(
- $"Authentication failure during certificate order. HTTP {(int)resp.StatusCode}. See gateway logs for details.");
- }
+ Logger.LogInformation(
+ "CERTInext API call: Method=POST, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}, AuthMode={AuthMode}, RateLimitRetryAttempt={Attempt}",
+ Constants.Api.GenerateOrderSslPath, (int)resp.StatusCode, sw.ElapsedMilliseconds, _config.AuthMode, attempt);
+
+ if (resp.StatusCode == HttpStatusCode.Unauthorized || resp.StatusCode == HttpStatusCode.Forbidden)
+ {
+ Logger.LogError(
+ "PlaceOrder API authentication failure. HttpStatus={Status}, AuthMode={AuthMode}",
+ (int)resp.StatusCode, _config.AuthMode);
+ throw new Exception(
+ $"Authentication failure during certificate order. HTTP {(int)resp.StatusCode}. See gateway logs for details.");
+ }
- var result = DeserializeOrThrow(resp, "place order");
+ result = DeserializeOrThrow(resp, "place order");
- if (result.Meta != null && !result.Meta.IsSuccess)
- throw new Exception(
- $"CERTInext order failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " +
- "See gateway logs for details.");
+ if (result.Meta != null && !result.Meta.IsSuccess)
+ {
+ LogApiFailure(Constants.Api.GenerateOrderSslPath, resp,
+ result.Meta.ErrorCode, result.Meta.ErrorMessage);
+
+ // Auto-retry the documented rate-limit surface up to RateLimitMaxAttempts.
+ if (IsRateLimitSurface(result.Meta.ErrorMessage) && attempt < RateLimitMaxAttempts)
+ {
+ double waitSeconds = ComputeRateLimitBackoffSeconds(attempt);
+ totalRateLimitBackoffSeconds += waitSeconds;
+ Logger.LogWarning(
+ "PlaceOrder hit rate-limit-shaped error \"{ErrorMessage}\" (attempt {Attempt}/{Max}). " +
+ "Backing off {WaitSeconds:F1}s before retrying. See Troubleshooting in README for context.",
+ result.Meta.ErrorMessage, attempt, RateLimitMaxAttempts, waitSeconds);
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(waitSeconds), ct);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ continue; // retry
+ }
+
+ throw new Exception(
+ $"CERTInext order failed: {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}. " +
+ "See gateway logs for details.");
+ }
+
+ // Success — if we retried, emit a single summary line so the rate-limit
+ // pressure is correlatable per-call without joining the per-attempt
+ // warnings by OrderNumber. (SOC2 CC7.2 anomaly-detection enablement.)
+ if (attempt > 1)
+ {
+ Logger.LogInformation(
+ "PlaceOrder succeeded after rate-limit retries. OrderNumber={OrderNumber}, " +
+ "RateLimitRetryCount={RetryCount}, TotalBackoffSeconds={BackoffSeconds:F1}",
+ result.OrderDetails?.OrderNumber, attempt - 1, totalRateLimitBackoffSeconds);
+ }
+ break; // success
+ }
Logger.LogInformation(
"CERTInext order placed. OrderNumber={OrderNumber}, RequestNumber={RequestNumber}",
@@ -246,7 +308,10 @@ public async Task SubmitCsrAsync(SubmitCsrRequest request, CancellationToken ct
Constants.Api.SubmitCsrPath, (int)resp.StatusCode, sw.ElapsedMilliseconds, _config.AuthMode);
if (!resp.IsSuccessful)
+ {
+ LogApiFailure(Constants.Api.SubmitCsrPath, resp);
throw new Exception($"CERTInext SubmitCSR failed. HTTP {(int)resp.StatusCode}. See gateway logs for details.");
+ }
Logger.MethodExit(LogLevel.Trace);
}
@@ -293,6 +358,8 @@ public async Task TrackOrderAsync(string orderNumber, Cancel
// A meta status of "0" with errorCode EMS-913 or similar means the order was not found
if (result.Meta != null && !result.Meta.IsSuccess)
{
+ LogApiFailure($"{Constants.Api.TrackOrderPath} {orderNumber}", resp,
+ result.Meta.ErrorCode, result.Meta.ErrorMessage);
if (result.Meta.ErrorCode != null &&
(result.Meta.ErrorCode.StartsWith("EMS-9") || result.Meta.ErrorMessage?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true))
{
@@ -344,8 +411,12 @@ public async Task DownloadCertificateAsync(string orderN
var result = DeserializeOrThrow(resp, $"download certificate {orderNumber}");
if (result.Meta != null && !result.Meta.IsSuccess)
+ {
+ LogApiFailure($"{Constants.Api.GetCertificatePath} {orderNumber}", resp,
+ result.Meta.ErrorCode, result.Meta.ErrorMessage);
throw new Exception(
$"CERTInext GetCertificate failed for order '{orderNumber}': {result.Meta.ErrorMessage ?? result.Meta.ErrorCode}.");
+ }
Logger.MethodExit(LogLevel.Trace);
return result;
@@ -402,6 +473,9 @@ public async Task RevokeOrderAsync(RevokeOrderRequest request, CancellationToken
var revResp = JsonSerializer.Deserialize(resp.Content, GetJsonOptions());
if (revResp?.Meta != null && !revResp.Meta.IsSuccess)
{
+ LogApiFailure(
+ $"{Constants.Api.RevokeOrderPath} {request.RevocationDetails?.OrderNumber}",
+ resp, revResp.Meta.ErrorCode, revResp.Meta.ErrorMessage);
throw new Exception(
$"CERTInext RevokeOrder returned failure for order " +
$"'{request.RevocationDetails?.OrderNumber}': {revResp.Meta.ErrorMessage ?? revResp.Meta.ErrorCode}.");
@@ -840,6 +914,145 @@ public async Task> GetProfilesAsync(CancellationToken ct = def
return profiles;
}
+ // ---------------------------------------------------------------------------
+ // ICERTInextClient — DCV methods
+ // ---------------------------------------------------------------------------
+
+ ///