Skip to content

Commit 6a2db50

Browse files
committed
feat: complete SSL order body, sync DCV retry, and bounded Enroll waits
Closes several gaps uncovered while running live enrollments against the CERTInext sandbox under concurrent load. SSL order body (CERTInextClient.BuildOrderRequestFromLegacyEnrollRequest): - Populate organizationDetails (preVetting="1" + organizationNumber) so orders declare a pre-vetted org and skip CERTInext's manual vetting queue. Without this, fresh orders parked in "Pending System RA" for tens of hours on the sandbox. - Populate delegationInformation.groupNumber so orders route to the configured account group. - Populate technicalPointOfContact (defaults fall back to Requestor*). - Apply admin-configurable defaults for accountingModel, emailNotifications, subscriptionDetails.autoRenew/renewCriteria, certificateInformation.autoSecureWww. Connector configuration (CERTInextConfig + Constants + manifest + README): - Add OrganizationNumber, TechnicalContactName/Email/IsdCode/MobileNumber, AccountingModel, EmailNotifications, SubscriptionValidityYears, SubscriptionAutoRenew, SubscriptionRenewCriteriaDays, AutoSecureWww. - Each has a PropertyConfigInfo descriptor surfaced in the Keyfactor Command connector UI, plus a matching entry in integration-manifest.json and the README "CA Configuration" reference table. Sync-driven DCV retry (CERTInextCAPlugin.Synchronize + GetSingleRecord): - Add TryRunDcvDuringSyncAsync wrapper around PerformDcvIfNeededAsync with a per-order in-flight guard (_dcvInFlight ConcurrentDictionary) and bounded timeout. Called from Synchronize for every non-terminal order and from GetSingleRecord for single-record refreshes, so orders whose DCV challenge is only exposed after Enroll() returns get advanced on the next gateway sync cycle. - Also reserve the in-flight slot during the enroll-side DCV path so a concurrent sync can't double-stage TXT records for the same order. EMS-956 tolerance (PerformDcvIfNeededAsync): - CERTInext returns "EMS-956 Invalid Request for this API" from GetDcv when TrackOrder shows domainVerification populated but the GetDcv endpoint isn't yet accepting calls. Plugin now treats this narrowly as "not yet ready" (deferred to next sync) instead of throwing and failing the enrollment. Matching requires either an exact "EMS-956" code OR the phrase with no other EMS-NNN code present, so a 4xx whose body happens to contain the phrase isn't silently swallowed. Bounded Enroll() waits (DcvWaitForChallengeSeconds, DcvWaitForIssuanceSeconds): - Inside PerformDcvIfNeededAsync, poll TrackOrder up to DcvWaitForChallengeSeconds (default 60) waiting for domainVerification to materialize. Without this, high-concurrency enrollments race CERTInext and skip DCV during Enroll(). - After WaitForDcvVerificationAsync confirms DCV, poll GetCertificate up to DcvWaitForIssuanceSeconds (default 60) waiting for CERTInext's async issuance to complete. Without this, Enroll() returns a pending result and the cert is picked up on the next sync cycle. Both env-overridable via CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS and CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS. Tests: - CERTInextClientRequestShapeTests (9): pin the JSON body emitted by the new builder against connector config combinations (org set/blank, group set/blank, TPoC explicit/fallback, defaults vs custom). - CERTInextCAPluginDcvTests (5 new): EMS-956 tolerance (defer with code, defer with phrase-only, rethrow on unrelated error), challenge-wait polling (succeeds when slot appears late, gives up after budget), issuance-wait polling (returns issued, not first pending). - SmokeTests adds GetSingleRecord_ForAllOrders_AllSucceed which iterates the account's orders through the plugin's GetSingleRecord path. - DcvLifecycleTests adds GetSingleRecord_DrivesDcvForPendingOrder and BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks (opt-in via CERTINEXT_RUN_BULK_TEST=1) to exercise the deferred-DCV path and verify the sync iterator crosses the PageSize boundary under volume. 136/136 unit tests pass.
1 parent aea1ec0 commit 6a2db50

15 files changed

Lines changed: 1887 additions & 84 deletions

CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
</PackageReference>
2222
<PackageReference Include="FluentAssertions" Version="6.12.0" />
2323
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
24+
<PackageReference Include="BouncyCastle.Cryptography" Version="2.0.0" />
2425
<!-- Suppress TFM support build warnings for transitive dependencies -->
2526
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
2627
<PackageReference Include="System.IO.Pipelines" Version="8.0.0" />

CERTInext.IntegrationTests/DcvLifecycleTests.cs

Lines changed: 267 additions & 10 deletions
Large diffs are not rendered by default.

CERTInext.IntegrationTests/IntegrationTestFixture.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ public IntegrationTestFixture()
8383

8484
var env = LoadEnvFile(envPath);
8585

86+
// Promote env-file values into the process environment so that any code
87+
// calling System.Environment.GetEnvironmentVariable() picks them up.
88+
foreach (var kv in env)
89+
if (System.Environment.GetEnvironmentVariable(kv.Key) == null)
90+
System.Environment.SetEnvironmentVariable(kv.Key, kv.Value);
91+
8692
ApiUrl = GetEnvValue(env, "CERTINEXT_API_URL");
8793
AccessKey = GetEnvValue(env, "CERTINEXT_ACCESS_KEY");
8894
AccountNumber = GetEnvValue(env, "CERTINEXT_ACCOUNT_NUMBER");
@@ -109,6 +115,7 @@ public IntegrationTestFixture()
109115
ApiKey = AccessKey,
110116
AccountNumber = AccountNumber,
111117
GroupNumber = GroupNumber,
118+
OrganizationNumber = OrgNumber,
112119
RequestorName = string.IsNullOrWhiteSpace(RequestorName)
113120
? "Keyfactor Integration Test"
114121
: RequestorName,

CERTInext.IntegrationTests/LifecycleTests.cs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@
66
using System.Collections.Concurrent;
77
using System.Collections.Generic;
88
using System.Linq;
9-
using System.Security.Cryptography;
10-
using System.Security.Cryptography.X509Certificates;
9+
using Org.BouncyCastle.Asn1.X509;
10+
using Org.BouncyCastle.Crypto;
11+
using Org.BouncyCastle.Crypto.Generators;
12+
using Org.BouncyCastle.Pkcs;
13+
using Org.BouncyCastle.Security;
1114
using System.Threading;
1215
using System.Threading.Tasks;
1316
using FluentAssertions;
1417
using Keyfactor.AnyGateway.Extensions;
1518
using Keyfactor.PKI.Enums.EJBCA;
1619
using Xunit;
20+
using Xunit.Abstractions;
1721

1822
namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
1923
{
@@ -34,10 +38,12 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
3438
public class LifecycleTests : IClassFixture<IntegrationTestFixture>
3539
{
3640
private readonly IntegrationTestFixture _fixture;
41+
private readonly ITestOutputHelper _output;
3742

38-
public LifecycleTests(IntegrationTestFixture fixture)
43+
public LifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output)
3944
{
4045
_fixture = fixture;
46+
_output = output;
4147
}
4248

4349
// ---------------------------------------------------------------------------
@@ -60,22 +66,15 @@ private CERTInextCAPlugin BuildPlugin()
6066
/// </summary>
6167
private static string GenerateCsrPem(string commonName)
6268
{
63-
using var rsa = RSA.Create(2048);
69+
var keyGen = new RsaKeyPairGenerator();
70+
keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
71+
var keyPair = keyGen.GenerateKeyPair();
6472

65-
var certReq = new CertificateRequest(
66-
$"CN={commonName}",
67-
rsa,
68-
HashAlgorithmName.SHA256,
69-
RSASignaturePadding.Pkcs1);
70-
71-
var sanBuilder = new SubjectAlternativeNameBuilder();
72-
sanBuilder.AddDnsName(commonName);
73-
certReq.CertificateExtensions.Add(sanBuilder.Build());
74-
75-
byte[] csrDer = certReq.CreateSigningRequest();
73+
var subject = new X509Name($"CN={commonName}");
74+
var csr = new Pkcs10CertificationRequest("SHA256withRSA", subject, keyPair.Public, null, keyPair.Private);
7675

7776
return "-----BEGIN CERTIFICATE REQUEST-----\n"
78-
+ Convert.ToBase64String(csrDer, Base64FormattingOptions.InsertLineBreaks)
77+
+ Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
7978
+ "\n-----END CERTIFICATE REQUEST-----";
8079
}
8180

@@ -237,5 +236,6 @@ await revokeAct.Should().NotThrowAsync(
237236
(int)EndEntityStatus.REVOKED,
238237
"Revoke must return the REVOKED status code on success");
239238
}
239+
240240
}
241241
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright 2024 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
3+
// At http://www.apache.org/licenses/LICENSE-2.0
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Linq;
8+
using System.Threading.Tasks;
9+
using FluentAssertions;
10+
using Xunit;
11+
using Xunit.Abstractions;
12+
13+
namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
14+
{
15+
/// <summary>
16+
/// Basic smoke tests — one operation per test, no side effects.
17+
/// These verify the API is reachable and returning sensible data without
18+
/// creating or modifying any orders.
19+
///
20+
/// All tests skip when CERTInext credentials are absent (<see cref="IntegrationSkip"/>).
21+
/// </summary>
22+
public class SmokeTests : IClassFixture<IntegrationTestFixture>
23+
{
24+
private readonly IntegrationTestFixture _fixture;
25+
private readonly ITestOutputHelper _output;
26+
27+
public SmokeTests(IntegrationTestFixture fixture, ITestOutputHelper output)
28+
{
29+
_fixture = fixture;
30+
_output = output;
31+
}
32+
33+
[SkippableFact]
34+
public async Task Ping_Succeeds()
35+
{
36+
IntegrationSkip.IfNotConfigured(_fixture);
37+
38+
await _fixture.Client.Invoking(c => c.PingAsync())
39+
.Should().NotThrowAsync("credentials should be valid and API should be reachable");
40+
}
41+
42+
[SkippableFact]
43+
public async Task GetProductDetails_ReturnsProducts()
44+
{
45+
IntegrationSkip.IfNotConfigured(_fixture);
46+
47+
var products = await _fixture.Client.GetProductDetailsAsync();
48+
49+
products.Should().NotBeNullOrEmpty("account must have at least one product configured");
50+
51+
foreach (var p in products)
52+
_output.WriteLine($" ProductCode={p.ProductCode} Name={p.ProductName} Type={p.ProductType}");
53+
}
54+
55+
[SkippableFact]
56+
public async Task ListOrders_ReturnsFirstPage()
57+
{
58+
IntegrationSkip.IfNotConfigured(_fixture);
59+
60+
var orders = new List<API.OrderReportEntry>();
61+
62+
await foreach (var entry in _fixture.Client.ListOrdersAsync(pageSize: 10))
63+
{
64+
orders.Add(entry);
65+
if (orders.Count >= 10) break;
66+
}
67+
68+
orders.Should().NotBeEmpty("sandbox account should have at least one order");
69+
70+
_output.WriteLine($"Returned {orders.Count} orders (capped at 10):");
71+
foreach (var o in orders)
72+
_output.WriteLine($" OrderNumber={o.OrderNumber} Domain={o.DomainName} Status={o.CertificateStatus} Expiry={o.CertificateExpiryDate}");
73+
}
74+
75+
[SkippableFact]
76+
public async Task TrackOrder_ReturnsDetails()
77+
{
78+
IntegrationSkip.IfNotConfigured(_fixture);
79+
80+
string orderId = System.Environment.GetEnvironmentVariable("CERTINEXT_ORDER_ID");
81+
Skip.If(string.IsNullOrWhiteSpace(orderId),
82+
"Set CERTINEXT_ORDER_ID in ~/.env_certinext to run this test.");
83+
84+
var response = await _fixture.Client.TrackOrderAsync(orderId);
85+
86+
response.Should().NotBeNull();
87+
response.OrderDetails.Should().NotBeNull();
88+
89+
var od = response.OrderDetails;
90+
_output.WriteLine($"OrderNumber: {orderId}");
91+
_output.WriteLine($"OrderStatus: {od.OrderStatus} (id={od.OrderStatusId})");
92+
_output.WriteLine($"CertificateStatus: {od.CertificateStatus} (id={od.CertificateStatusId})");
93+
_output.WriteLine($"CertificateExpiry: {od.CertificateExpiryDate}");
94+
_output.WriteLine($"TrackingUrl: {od.TrackingUrl}");
95+
96+
if (od.DomainVerification != null)
97+
{
98+
foreach (var kv in od.DomainVerification.GetDomainEntries())
99+
_output.WriteLine($" Domain [{kv.Key}]: dcvMethod={kv.Value.DcvMethod} dcvStatus={kv.Value.DcvStatus} verifiedDate={kv.Value.VerifiedDate}");
100+
}
101+
}
102+
103+
[SkippableFact]
104+
public async Task GetSingleRecord_ReturnsRecord()
105+
{
106+
IntegrationSkip.IfNotConfigured(_fixture);
107+
108+
string orderId = System.Environment.GetEnvironmentVariable("CERTINEXT_ORDER_ID");
109+
Skip.If(string.IsNullOrWhiteSpace(orderId),
110+
"Set CERTINEXT_ORDER_ID in ~/.env_certinext to run this test.");
111+
112+
var plugin = new CERTInextCAPlugin(_fixture.Client, new StubDomainValidatorFactory(), _fixture.Config);
113+
var record = await plugin.GetSingleRecord(orderId);
114+
115+
record.Should().NotBeNull();
116+
117+
_output.WriteLine($"CARequestID: {record.CARequestID}");
118+
_output.WriteLine($"Status: {record.Status}");
119+
_output.WriteLine($"Certificate: {(string.IsNullOrWhiteSpace(record.Certificate) ? "(not yet issued)" : record.Certificate[..60] + "...")}");
120+
}
121+
122+
/// <summary>
123+
/// Exercises <see cref="CERTInextCAPlugin.GetSingleRecord"/> against every order
124+
/// returned by <c>ListOrdersAsync</c>. Validates that the per-order plugin
125+
/// code path (TrackOrder → GetCertificate → AnyCAPluginCertificate mapping)
126+
/// succeeds for every order on the account, regardless of certificate status.
127+
/// </summary>
128+
[SkippableFact]
129+
public async Task GetSingleRecord_ForAllOrders_AllSucceed()
130+
{
131+
IntegrationSkip.IfNotConfigured(_fixture);
132+
133+
var plugin = new CERTInextCAPlugin(_fixture.Client, new StubDomainValidatorFactory(), _fixture.Config);
134+
135+
var orderNumbers = new List<string>();
136+
await foreach (var entry in _fixture.Client.ListOrdersAsync())
137+
{
138+
if (!string.IsNullOrWhiteSpace(entry.OrderNumber))
139+
orderNumbers.Add(entry.OrderNumber);
140+
}
141+
142+
orderNumbers.Should().NotBeEmpty("sandbox account should have at least one order");
143+
_output.WriteLine($"Calling GetSingleRecord for {orderNumbers.Count} order(s):");
144+
145+
var failures = new List<(string Order, string Error)>();
146+
foreach (var orderId in orderNumbers)
147+
{
148+
try
149+
{
150+
var record = await plugin.GetSingleRecord(orderId);
151+
string certPreview = string.IsNullOrWhiteSpace(record.Certificate)
152+
? "(none)"
153+
: $"{record.Certificate.Length} chars";
154+
_output.WriteLine($" [OK] Order={orderId} Status={record.Status} Cert={certPreview}");
155+
}
156+
catch (Exception ex)
157+
{
158+
failures.Add((orderId, ex.Message));
159+
_output.WriteLine($" [FAIL] Order={orderId} Error={ex.Message}");
160+
}
161+
}
162+
163+
failures.Should().BeEmpty(
164+
$"every order's GetSingleRecord call should succeed; {failures.Count} failed: " +
165+
string.Join("; ", failures.Select(f => $"{f.Order}={f.Error}")));
166+
}
167+
168+
[SkippableFact]
169+
public async Task Synchronize_DumpsAllRecords()
170+
{
171+
IntegrationSkip.IfNotConfigured(_fixture);
172+
173+
var plugin = new CERTInextCAPlugin(_fixture.Client, new StubDomainValidatorFactory(), _fixture.Config);
174+
175+
var records = new List<Keyfactor.AnyGateway.Extensions.AnyCAPluginCertificate>();
176+
var blockingCollection = new System.Collections.Concurrent.BlockingCollection<Keyfactor.AnyGateway.Extensions.AnyCAPluginCertificate>();
177+
178+
var syncTask = plugin.Synchronize(blockingCollection, lastSync: null, fullSync: true, cancelToken: default);
179+
var collectTask = Task.Run(() =>
180+
{
181+
foreach (var r in blockingCollection.GetConsumingEnumerable())
182+
records.Add(r);
183+
});
184+
185+
await syncTask;
186+
blockingCollection.CompleteAdding();
187+
await collectTask;
188+
189+
records.Should().NotBeEmpty("sandbox account should have at least one order");
190+
191+
_output.WriteLine($"Synchronized {records.Count} records:");
192+
foreach (var r in records.Take(20))
193+
_output.WriteLine($" CARequestID={r.CARequestID} Status={r.Status}");
194+
195+
if (records.Count > 20)
196+
_output.WriteLine($" ... and {records.Count - 20} more");
197+
}
198+
}
199+
}

0 commit comments

Comments
 (0)