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
391 changes: 391 additions & 0 deletions CERTInext.IntegrationTests/SanSubmissionProbeTests.cs

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions CERTInext.Tests/CERTInextCAPluginCoverageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,60 @@ public async Task RenewOrReissue_CallsRenewApi_WhenCertWithinRenewalWindow()
It.IsAny<CancellationToken>()), Times.Never);
}

// ---------------------------------------------------------------------------
// A1d-2: renewal within window carries the template's product code onto the
// RenewCertificateRequest, not just the connector-level DefaultProductCode.
// Regression for issue #26 / local issues/0012.
// ---------------------------------------------------------------------------

[Fact]
public async Task RenewOrReissue_CallsRenewApi_UsesTemplateProductCode()
{
var clientMock = NewMock();
var readerMock = NewReaderMock();

// Expiry is 30 days in the future, renewal window is 90 days → within window
DateTime expiry = DateTime.UtcNow.AddDays(30);

readerMock
.Setup(r => r.GetRequestIDBySerialNumber(It.IsAny<string>()))
.ReturnsAsync(MockCertificateData.CertId1);

readerMock
.Setup(r => r.GetExpirationDateByRequestId(MockCertificateData.CertId1))
.Returns(expiry);

clientMock
.Setup(c => c.RenewCertificateAsync(
MockCertificateData.CertId1,
It.Is<RenewCertificateRequest>(r => r.ProfileId == MockCertificateData.ProfileIdClient),
It.IsAny<CancellationToken>()))
.ReturnsAsync(MockCertificateData.IssuedEnrollResponse("cert-renewed-002"));

var plugin = new CERTInextCAPlugin(clientMock.Object, readerMock.Object);

// ProfileId is a non-default value distinct from the connector's DefaultProductCode.
var productInfo = MakeProductInfo(profileId: MockCertificateData.ProfileIdClient, extras: new Dictionary<string, string>
{
["PriorCertSN"] = "AABBCCDDEEFF",
["RenewalWindowDays"] = "90"
});

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

result.Status.Should().Be((int)EndEntityStatus.GENERATED);
clientMock.Verify(c => c.RenewCertificateAsync(
MockCertificateData.CertId1,
It.Is<RenewCertificateRequest>(r => r.ProfileId == MockCertificateData.ProfileIdClient),
It.IsAny<CancellationToken>()), Times.Once);
}

// ---------------------------------------------------------------------------
// A1e: PriorCertSN present, cert already expired → new enroll
// Semantics: useRenewalApi = expiry > now && expiry <= now + window.
Expand Down
563 changes: 539 additions & 24 deletions CERTInext.Tests/CERTInextCAPluginDcvTests.cs

Large diffs are not rendered by default.

135 changes: 134 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,127 @@ public async Task Enroll_New_ReturnsPendingStatus_WhenCaReturnsPendingApproval()
result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
}

[Fact]
public async Task Enroll_New_ReturnsPendingStatus_WhenCaReportsIssuedButBodyMissing()
{
// CERTInext can report an "issued"/auto-approved certificateStatusId before the
// certificate bytes actually exist — the immediate GetCertificate download fails
// and the legacy client returns Status="issued" with Certificate=null. Reporting
// GENERATED with no PEM crashes the gateway framework's PEM parser downstream, so
// the plugin must demote this to pending rather than trust the raw status string.
var mock = NewMock();
mock.Setup(c => c.EnrollCertificateAsync(
It.IsAny<EnrollCertificateRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(MockCertificateData.AutoApprovedNoBodyEnrollResponse());

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

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

result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
result.Certificate.Should().BeNullOrEmpty();
}

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

[Fact]
public async Task Pickup_Disabled_WhenPickupRetriesZero_ReturnsPendingWithoutPolling()
{
var mock = NewMock();
mock.Setup(c => c.EnrollCertificateAsync(
It.IsAny<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
54 changes: 54 additions & 0 deletions CERTInext.Tests/CERTInextClientRequestShapeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -288,5 +288,59 @@ public async Task ValidityDays_OnRequest_OverridesConnectorDefault()
CapturedOrderBody().GetProperty("subscriptionDetails")
.GetProperty("validity").GetString().Should().Be("2");
}

// -----------------------------------------------------------------------
// RenewCertificateAsync — productCode resolution (issue #26 / local issues/0012)
// Renewals go out as a fresh GenerateOrderSSL order; the product code must
// come from the template (RenewCertificateRequest.ProfileId) when supplied,
// falling back to the connector's DefaultProductCode only when it is not.
// -----------------------------------------------------------------------

[Fact]
public async Task RenewCertificateAsync_ProfileIdSet_UsesTemplateProductCode()
{
StubHappyEnroll();
var cfg = MinimalConfig();
cfg.DefaultProductCode = "connector-default-code";

var renewReq = new RenewCertificateRequest
{
Csr = MockCertificateData.FakeCsrPem,
ProfileId = "template-product-code",
ValidityDays = 365,
Comment = "Renewal test"
};

await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq);

CapturedOrderBody().GetProperty("productCode").GetString()
.Should().Be("template-product-code",
"the template's own product code must win over the connector default");
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task RenewCertificateAsync_ProfileIdBlank_FallsBackToConnectorDefault(string blankProfileId)
{
StubHappyEnroll();
var cfg = MinimalConfig();
cfg.DefaultProductCode = "connector-default-code";

var renewReq = new RenewCertificateRequest
{
Csr = MockCertificateData.FakeCsrPem,
ProfileId = blankProfileId,
ValidityDays = 365,
Comment = "Renewal test"
};

await BuildClient(cfg).RenewCertificateAsync(MockCertificateData.OrderNumber1, renewReq);

CapturedOrderBody().GetProperty("productCode").GetString()
.Should().Be("connector-default-code",
"a blank ProfileId must fall back to the connector's DefaultProductCode, not an empty string");
}
}
}
36 changes: 36 additions & 0 deletions CERTInext.Tests/CERTInextClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,42 @@ await act.Should().ThrowAsync<Exception>()
.WithMessage("*GetDcv failed*");
}

/// <summary>
/// Regression: this client is built with ThrowOnAnyError=false, so RestSharp catches a
/// cancelled HttpClient.SendAsync internally and returns a non-throwing, unsuccessful
/// RestResponse instead of propagating OperationCanceledException. Before this fix,
/// ExecuteWithRetryAsync passed that response straight to DeserializeOrThrow, which wrapped
/// it in a plain Exception — indistinguishable from a genuine API failure. A caller such as
/// PerformDcvIfNeededAsync's per-domain "catch (OperationCanceledException) { throw; }" guard
/// (added specifically to stop a DCV timeout from being mislabeled as an ordinary per-domain
/// failure) could never actually see the real cancellation, because it never arrived as
/// OperationCanceledException in the first place — a gap a Moq-level test of the plugin alone
/// cannot expose, since a mock can be told to throw whatever type is asked for. This test
/// exercises the real client against a real (if local) HTTP call, which is the only way to
/// pin the actual failure mode.
/// </summary>
[Fact]
public async Task GetDcvAsync_ThrowsOperationCanceled_WhenCancellationTokenIsCancelled()
{
_server
.Given(Request.Create().WithPath("/GetDcv").UsingPost())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBody(MockCertificateData.GetDcvSuccessJson()));

var client = BuildClient();
using var cts = new CancellationTokenSource();
cts.Cancel();

Func<Task> act = () => client.GetDcvAsync(
MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt, cts.Token);

await act.Should().ThrowAsync<OperationCanceledException>(
"a cancelled token must surface as a genuine cancellation, not get wrapped into a " +
"plain Exception that a caller's cancellation-specific catch clause cannot recognize");
}

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