Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions CERTInext.Tests/CERTInextCAPluginDcvTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ private static CERTInextConfig DcvConfig(
int propagationDelaySeconds = 1,
int timeoutMinutes = 1,
int dcvWaitForChallengeSeconds = 0,
int dcvWaitForIssuanceSeconds = 0) =>
int dcvWaitForIssuanceSeconds = 0,
int pickupRetries = 0) =>
new CERTInextConfig
{
DcvEnabled = enabled,
Expand All @@ -45,7 +46,12 @@ private static CERTInextConfig DcvConfig(
// behaviour and run fast. Tests that exercise the new wait paths can opt
// in with a positive value (see WaitsForChallenge_ToAppear / WaitsForIssuance).
DcvWaitForChallengeSeconds = dcvWaitForChallengeSeconds,
DcvWaitForIssuanceSeconds = dcvWaitForIssuanceSeconds
DcvWaitForIssuanceSeconds = dcvWaitForIssuanceSeconds,
// Disable the synchronous pickup poll by default (same reasoning as the wait
// budgets above): the DCV path owns issuance for these tests, and a DCV-disabled
// or no-factory case that ends on a pending result must not pay the real pickup
// Task.Delay loop. The dedicated pickup tests live in CERTInextCAPluginTests.
PickupRetries = pickupRetries
};

private static Mock<ICERTInextClient> NewMock() =>
Expand Down Expand Up @@ -437,17 +443,19 @@ public async Task Dcv_Skipped_WhenOrderStatusIdIsTerminal_EvenIfDcvValidated(str
});

var validator = new FakeDomainValidator();
// Issuance-wait budget > 0 so a wrong-path entry would manifest as a
// GetCertificate call we DON'T expect.
// Issuance-wait budget > 0 AND pickup ENABLED (pickupRetries > 0) so a wrong-path
// entry would manifest as a GetCertificate call we DON'T expect — this test must
// fail if either the DCV issuance-wait guard OR the synchronous-pickup gate
// (dcvIssuanceWaitRan) regresses and starts polling a cancelled/rejected order.
var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator),
DcvConfig(dcvWaitForIssuanceSeconds: 10));
DcvConfig(dcvWaitForIssuanceSeconds: 10, pickupRetries: 5));

await Enroll(plugin);

mock.Verify(c => c.GetCertificateAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never,
"Enroll must not enter WaitForIssuanceAfterDcvAsync when the order is " +
"cancelled/rejected, even if DCV happens to be in a 'validated' state");
"Enroll must not enter WaitForIssuanceAfterDcvAsync OR the synchronous pickup poll " +
"when the order is cancelled/rejected, even if DCV happens to be in a 'validated' state");
validator.StagedRecords.Should().BeEmpty(
"DCV staging must not run for a cancelled/rejected order");
}
Expand Down
107 changes: 106 additions & 1 deletion CERTInext.Tests/CERTInextCAPluginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,20 @@ public class CERTInextCAPluginTests
// Helpers
// ---------------------------------------------------------------------------

// Pickup is disabled by default in the broad fixture (PickupRetries=0) — mirroring how
// DcvConfig defaults its wait budgets to 0 — so tests that don't care about the
// synchronous pickup don't pay its real Task.Delay-based poll. Tests that DO exercise
// pickup opt in via BuildPluginWithPickup.
private static CERTInextCAPlugin BuildPlugin(ICERTInextClient client) =>
new CERTInextCAPlugin(client);
new CERTInextCAPlugin(client, new CERTInextConfig { PickupRetries = 0 });

// Pickup-enabled fixture for the synchronous-pickup tests. PickupDelay is clamped to a
// 1s floor and the loop adds a fixed 5s initial delay, so these tests are intentionally
// a few seconds each.
private static CERTInextCAPlugin BuildPluginWithPickup(
ICERTInextClient client, int retries, int delaySeconds = 1) =>
new CERTInextCAPlugin(client,
new CERTInextConfig { PickupRetries = retries, PickupDelayInSeconds = delaySeconds });

private static Mock<ICERTInextClient> NewMock() => new Mock<ICERTInextClient>(MockBehavior.Strict);

Expand Down Expand Up @@ -345,6 +357,99 @@ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval()
result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
}

// ---------------------------------------------------------------------------
// Synchronous certificate pickup (Sectigo parity)
// ---------------------------------------------------------------------------

[Fact]
public async Task Pickup_Disabled_WhenPickupRetriesZero_ReturnsPendingWithoutPolling()
{
var mock = NewMock();
mock.Setup(c => c.EnrollCertificateAsync(
It.IsAny<EnrollCertificateRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(MockCertificateData.PendingEnrollResponse());

var plugin = BuildPluginWithPickup(mock.Object, retries: 0);

var result = await plugin.Enroll(
csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
enrollmentType: EnrollmentType.New);

result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
mock.Verify(c => c.GetCertificateAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never, "PickupRetries=0 must disable the synchronous pickup poll");
}

[Fact]
public async Task Pickup_ReturnsIssuedCert_WhenOrderIssuesDuringPoll()
{
var mock = NewMock();
mock.Setup(c => c.EnrollCertificateAsync(
It.IsAny<EnrollCertificateRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(MockCertificateData.PendingEnrollResponse());
// The order finishes issuing by the time we poll: GetCertificate reports issued + PEM.
mock.Setup(c => c.GetCertificateAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(MockCertificateData.IssuedCertRecord());

var plugin = BuildPluginWithPickup(mock.Object, retries: 2);

var result = await plugin.Enroll(
csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
enrollmentType: EnrollmentType.New);

result.Status.Should().Be((int)EndEntityStatus.GENERATED);
result.Certificate.Should().NotBeNullOrEmpty("a synchronously-picked-up cert must carry its PEM");
mock.Verify(c => c.GetCertificateAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
}

[Fact]
public async Task Pickup_SurfacesTerminalStatus_WhenOrderRevokedDuringPoll()
{
var mock = NewMock();
mock.Setup(c => c.EnrollCertificateAsync(
It.IsAny<EnrollCertificateRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(MockCertificateData.PendingEnrollResponse());
mock.Setup(c => c.GetCertificateAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(MockCertificateData.RevokedCertRecord());

var plugin = BuildPluginWithPickup(mock.Object, retries: 3);

var result = await plugin.Enroll(
csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
enrollmentType: EnrollmentType.New);

result.Status.Should().Be((int)EndEntityStatus.REVOKED,
"a terminal status observed during pickup is surfaced immediately, not polled to exhaustion");
}

[Fact]
public async Task Pickup_ReturnsPending_WhenOrderNeverIssuesWithinBudget()
{
var mock = NewMock();
mock.Setup(c => c.EnrollCertificateAsync(
It.IsAny<EnrollCertificateRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(MockCertificateData.PendingEnrollResponse());
// Every poll still reports pending — the budget is exhausted and Enroll returns the
// pending result for a later sync to complete.
mock.Setup(c => c.GetCertificateAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(MockCertificateData.PendingCertRecord());

var plugin = BuildPluginWithPickup(mock.Object, retries: 1);

var result = await plugin.Enroll(
csr: MockCertificateData.FakeCsrPem, subject: "CN=test.example.com", san: null,
productInfo: MakeProductInfo(), requestFormat: RequestFormat.PKCS10,
enrollmentType: EnrollmentType.New);

result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
mock.Verify(c => c.GetCertificateAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.AtLeastOnce, "an enabled pickup must actually poll before giving up");
}

[Fact]
public async Task Enroll_New_Throws_WhenProfileIdNotSet()
{
Expand Down
Loading
Loading