Skip to content

Commit 6062e8e

Browse files
committed
fix(dcv): run post-DCV issuance wait when DCV is already validated
Closes the cached-DCV gap surfaced during live testing on the new sandbox: when CERTInext has cached a prior DCV validation for the parent domain (e.g. scrup.org used across many test runs), the plugin's TrackOrder check sees domainVerification already in a validated state and PerformDcvIfNeededAsync used to return false ("skipped"). That left WaitForIssuanceAfterDcvAsync unreachable, so Enroll() returned a pending result even though CERTInext was about to generate the cert within seconds — forcing the gateway to wait for the next sync cycle. Changes: - PerformDcvIfNeededAsync now returns true when DCV is functionally done, defined as either (a) the aggregate domainVerification.Status == "1" OR (b) every per-domain dcvStatus == "1" (the per-domain field has been observed flipping to validated slightly before the parent aggregate). - WaitForIssuanceAfterDcvAsync short-circuits when its budget <= 0, avoiding a wasted GetCertificate call and keeping Strict-mock unit tests that intentionally disable the wait passing without re-wiring mocks. - Rename dcvRan -> dcvDone at the three call sites (EnrollNewAsync, Synchronize, GetSingleRecord) to reflect the new "DCV is done, by us or CERTInext" semantics. Tests: - Rename Dcv_Skipped_WhenAllDomainsAlreadyValidated to Dcv_SkipsStaging_AndDoesNotIssuancePoll_WhenAllDomainsAlreadyValidated_AndIssuanceBudgetZero and assert GetCertificateAsync is never called in that path. - Add Dcv_RunsIssuanceWait_WhenDcvAlreadyValidated_AndIssuanceBudgetPositive pinning the new cached-DCV happy path (pending then issued via the issuance poll). - Bump issuance budget on Dcv_HappyPath_* and Dcv_WaitsForChallenge_WhenDomainVerificationAppearsLate so they continue to exercise the post-DCV fetch. 137/137 unit tests pass.
1 parent 6a2db50 commit 6062e8e

2 files changed

Lines changed: 103 additions & 13 deletions

File tree

CERTInext.Tests/CERTInextCAPluginDcvTests.cs

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,10 @@ private static Task<EnrollmentResult> Enroll(CERTInextCAPlugin plugin) =>
116116
public async Task Dcv_HappyPath_StagesVerifiesAndCleansUp()
117117
{
118118
var (mock, validator) = HappyPathMocks();
119-
var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator));
119+
// Issuance budget > 0 so the post-DCV GetCertificate poll runs and lifts the
120+
// issued cert out of the mock back into the EnrollmentResult.
121+
var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
122+
DcvConfig(dcvWaitForIssuanceSeconds: 10));
120123

121124
var result = await Enroll(plugin);
122125

@@ -144,7 +147,8 @@ public async Task Dcv_HappyPath_StagesVerifiesAndCleansUp()
144147
public async Task Dcv_HappyPath_UsesCustomTxtTemplate()
145148
{
146149
var (mock, validator) = HappyPathMocks();
147-
var config = DcvConfig();
150+
// Issuance budget > 0 so the post-DCV GetCertificate poll runs.
151+
var config = DcvConfig(dcvWaitForIssuanceSeconds: 10);
148152
config.DcvTxtRecordTemplate = "dcv-proof.{0}.acme-corp.com";
149153
var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), config);
150154

@@ -211,8 +215,11 @@ public async Task Dcv_Skipped_WhenNoDomainVerificationBlock()
211215
}
212216

213217
[Fact]
214-
public async Task Dcv_Skipped_WhenAllDomainsAlreadyValidated()
218+
public async Task Dcv_SkipsStaging_AndDoesNotIssuancePoll_WhenAllDomainsAlreadyValidated_AndIssuanceBudgetZero()
215219
{
220+
// With DcvWaitForIssuanceSeconds=0 (the test fixture's DcvConfig default), an
221+
// order with DCV already validated short-circuits: no TXT records staged AND
222+
// no post-DCV GetCertificate poll. Lets sync pick up the cert on its own.
216223
var mock = NewMock();
217224
mock.Setup(c => c.EnrollCertificateAsync(It.IsAny<EnrollCertificateRequest>(), It.IsAny<CancellationToken>()))
218225
.ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" });
@@ -239,6 +246,54 @@ public async Task Dcv_Skipped_WhenAllDomainsAlreadyValidated()
239246

240247
validator.StagedRecords.Should().BeEmpty();
241248
mock.Verify(c => c.GetDcvAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
249+
// Issuance budget = 0 means the post-DCV poll short-circuits and GetCertificate
250+
// is never called from this Enroll() path.
251+
mock.Verify(c => c.GetCertificateAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
252+
}
253+
254+
[Fact]
255+
public async Task Dcv_RunsIssuanceWait_WhenDcvAlreadyValidated_AndIssuanceBudgetPositive()
256+
{
257+
// The cached-DCV gap fix: when CERTInext shows DCV already validated (no work
258+
// for the plugin's DNS-TXT staging) AND the admin has set a positive issuance
259+
// budget, the plugin should poll GetCertificate until the cert is generated
260+
// and return the issued result directly from Enroll() — not leave it for sync.
261+
var mock = NewMock();
262+
mock.Setup(c => c.EnrollCertificateAsync(It.IsAny<EnrollCertificateRequest>(), It.IsAny<CancellationToken>()))
263+
.ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" });
264+
265+
mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny<CancellationToken>()))
266+
.ReturnsAsync(new TrackOrderResponse
267+
{
268+
OrderDetails = new TrackOrderResponseDetails
269+
{
270+
OrderStatusId = "1",
271+
CertificateStatusId = "1",
272+
DomainVerification = new TrackOrderDomainVerification
273+
{
274+
Status = Constants.Dcv.StatusValidated
275+
}
276+
}
277+
});
278+
279+
// First post-DCV fetch is still pending; second returns issued.
280+
mock.SetupSequence(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny<CancellationToken>()))
281+
.ReturnsAsync(MockCertificateData.PendingCertRecord(MockCertificateData.DcvOrderId))
282+
.ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId));
283+
284+
var validator = new FakeDomainValidator();
285+
var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
286+
DcvConfig(dcvWaitForIssuanceSeconds: 10));
287+
288+
var result = await Enroll(plugin);
289+
290+
result.Status.Should().Be((int)EndEntityStatus.GENERATED,
291+
"the issuance poll must lift the issued cert into the EnrollmentResult, " +
292+
"not let the order fall through to a pending-then-sync round-trip");
293+
validator.StagedRecords.Should().BeEmpty("no TXT staging is needed when DCV is already validated");
294+
mock.Verify(c => c.GetDcvAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
295+
mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny<CancellationToken>()),
296+
Times.AtLeast(2), "plugin should have polled at least twice to see the cert transition to issued");
242297
}
243298

244299
[Fact]
@@ -484,10 +539,11 @@ public async Task Dcv_WaitsForChallenge_WhenDomainVerificationAppearsLate()
484539
.ReturnsAsync(MockCertificateData.IssuedCertRecord(MockCertificateData.DcvOrderId));
485540

486541
var validator = new FakeDomainValidator();
542+
// Both budgets positive so the polling paths exercise end-to-end.
487543
var plugin = BuildPlugin(
488544
mock.Object,
489545
new FakeDomainValidatorFactory(validator),
490-
DcvConfig(dcvWaitForChallengeSeconds: 10, dcvWaitForIssuanceSeconds: 0));
546+
DcvConfig(dcvWaitForChallengeSeconds: 10, dcvWaitForIssuanceSeconds: 10));
491547

492548
var result = await Enroll(plugin);
493549

CERTInext/CERTInextCAPlugin.cs

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -520,8 +520,8 @@ public async Task<AnyCAPluginCertificate> GetSingleRecord(string caRequestID)
520520
int status = StatusMapper.ToRequestDisposition(cert.Status);
521521
if (status == (int)EndEntityStatus.EXTERNALVALIDATION)
522522
{
523-
bool dcvRan = await TryRunDcvDuringSyncAsync(caRequestID, CancellationToken.None);
524-
if (dcvRan)
523+
bool dcvDone = await TryRunDcvDuringSyncAsync(caRequestID, CancellationToken.None);
524+
if (dcvDone)
525525
{
526526
try
527527
{
@@ -693,8 +693,8 @@ public async Task Synchronize(
693693
// already done or not yet exposed.
694694
if (status == (int)EndEntityStatus.EXTERNALVALIDATION)
695695
{
696-
bool dcvRan = await TryRunDcvDuringSyncAsync(current.Id, cancelToken);
697-
if (dcvRan)
696+
bool dcvDone = await TryRunDcvDuringSyncAsync(current.Id, cancelToken);
697+
if (dcvDone)
698698
{
699699
try
700700
{
@@ -823,8 +823,8 @@ private async Task<EnrollmentResult> EnrollNewAsync(
823823
{
824824
try
825825
{
826-
bool dcvRan = await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token);
827-
if (dcvRan)
826+
bool dcvDone = await PerformDcvIfNeededAsync(orderNumber, dcvCts.Token);
827+
if (dcvDone)
828828
{
829829
// Poll GetCertificate until CERTInext finishes generating the cert OR the
830830
// issuance budget expires. CERTInext issuance is async — DCV may verify
@@ -1149,9 +1149,30 @@ private async Task<bool> PerformDcvIfNeededAsync(string orderNumber, Cancellatio
11491149
}
11501150
}
11511151

1152-
// If the overall DCV status is already validated, nothing to do
1153-
if (string.Equals(domainVerification.Status, Constants.Dcv.StatusValidated, StringComparison.Ordinal))
1154-
return false;
1152+
// If DCV is already validated CERTInext-side, the plugin has no DCV work to
1153+
// do — but CERTInext's certificate generation may still be in flight (this
1154+
// happens when CERTInext has cached a prior DCV validation for the parent
1155+
// domain). Return true so the caller can run the issuance poll and pick up
1156+
// the cert directly from Enroll() instead of leaving it for the next sync.
1157+
//
1158+
// Treat "DCV done" as EITHER the overall aggregate Status flipping to "1"
1159+
// OR every individual per-domain dcvStatus being "1" — observed in the wild
1160+
// that the per-domain field can flip before the parent aggregate.
1161+
var allDomainEntries = domainVerification.GetDomainEntries();
1162+
bool aggregateValidated = string.Equals(
1163+
domainVerification.Status, Constants.Dcv.StatusValidated, StringComparison.Ordinal);
1164+
bool everyDomainValidated = allDomainEntries.Count > 0
1165+
&& allDomainEntries.All(kvp => string.Equals(
1166+
kvp.Value?.DcvStatus, Constants.Dcv.StatusValidated, StringComparison.Ordinal));
1167+
if (aggregateValidated || everyDomainValidated)
1168+
{
1169+
_logger.LogInformation(
1170+
"DCV is already validated for order {OrderNumber} " +
1171+
"(aggregateStatus={Aggregate}, perDomainAllValidated={PerDomain}). " +
1172+
"Skipping DNS-TXT staging; caller may run the issuance poll.",
1173+
orderNumber, aggregateValidated, everyDomainValidated);
1174+
return true;
1175+
}
11551176

11561177
// Include domains that are pending DCV and either have no method set yet,
11571178
// or are already assigned to DNS TXT (numeric "1" from API or label from TrackOrder).
@@ -1332,6 +1353,19 @@ private async Task<LegacyGetCertificateResponse> WaitForIssuanceAfterDcvAsync(
13321353
DateTime deadline = DateTime.UtcNow.AddSeconds(Math.Max(0, waitBudgetSeconds));
13331354
LegacyGetCertificateResponse last = null;
13341355

1356+
// Admin opt-out: budget <= 0 means "don't wait, let sync pick the cert up".
1357+
// Short-circuit before any API call so the gateway doesn't pay a TrackOrder +
1358+
// optional DownloadCertificate round trip per Enroll when the admin has
1359+
// explicitly disabled the wait.
1360+
if (waitBudgetSeconds <= 0)
1361+
{
1362+
_logger.LogDebug(
1363+
"Post-DCV issuance wait disabled (DcvWaitForIssuanceSeconds<=0). " +
1364+
"Order {OrderNumber} will be picked up on the next sync cycle.",
1365+
orderNumber);
1366+
return null;
1367+
}
1368+
13351369
int attempt = 0;
13361370
while (true)
13371371
{

0 commit comments

Comments
 (0)