Skip to content
Merged
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
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
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");
}
}
}
9 changes: 9 additions & 0 deletions CERTInext/API/CertificateRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,15 @@ public class RenewCertificateRequest
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string Subject { get; set; }

/// <summary>
/// Template/enrollment product code to submit the renewal order under. Without it, the
/// renewal falls back to the connector-level default product code, which is often unset —
/// leaving renewals to go out under an empty product code regardless of the template used.
/// </summary>
[JsonPropertyName("profileId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string ProfileId { get; set; }

/// <summary>
/// SANs to carry onto the renewal order. Renewals previously submitted none, so a
/// renewed UCC certificate came back holding only its primary domain.
Expand Down
8 changes: 8 additions & 0 deletions CERTInext/CERTInextCAPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -240,20 +240,27 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa
bool hasClientId = !string.IsNullOrWhiteSpace(_config.OAuth2ClientId);
bool hasClientSecret= !string.IsNullOrWhiteSpace(_config.OAuth2ClientSecret);
bool hasTokenUrl = !string.IsNullOrWhiteSpace(_config.OAuth2TokenUrl);
bool hasOrganizationNumber = !string.IsNullOrWhiteSpace(_config.OrganizationNumber);
bool hasDefaultProductCode = !string.IsNullOrWhiteSpace(_config.DefaultProductCode);
bool hasGroupNumber = !string.IsNullOrWhiteSpace(_config.GroupNumber);

_logger.LogInformation(
"CERTInext plugin initialized. " +
"ApiUrl={ApiUrl}, AuthMode={AuthMode}, Enabled={Enabled}, " +
"ApiKeyPresent={ApiKeyPresent}, UsernamePresent={UsernamePresent}, " +
"PasswordPresent={PasswordPresent}, OAuth2ClientIdPresent={OAuth2ClientIdPresent}, " +
"OAuth2ClientSecretPresent={OAuth2ClientSecretPresent}, OAuth2TokenUrlPresent={OAuth2TokenUrlPresent}, " +
"OrganizationNumberPresent={OrganizationNumberPresent}, DefaultProductCodePresent={DefaultProductCodePresent}, " +
"GroupNumberPresent={GroupNumberPresent}, " +
"PageSize={PageSize}, IgnoreExpired={IgnoreExpired}, SubmitNonDnsSans={SubmitNonDnsSans}, " +
"DcvEnabled={DcvEnabled}, DcvTxtRecordTemplate={DcvTxtRecordTemplate}, " +
"DomainValidatorFactoryInjected={FactoryInjected}",
_config.ApiUrl, _config.AuthMode, _config.Enabled,
hasApiKey, hasUsername,
hasPassword, hasClientId,
hasClientSecret, hasTokenUrl,
hasOrganizationNumber, hasDefaultProductCode,
hasGroupNumber,
_config.PageSize, _config.IgnoreExpired, _config.SubmitNonDnsSans,
_config.DcvEnabled, _config.DcvTxtRecordTemplate,
_domainValidatorFactory != null);
Expand Down Expand Up @@ -1320,6 +1327,7 @@ private async Task<EnrollmentResult> RenewOrReissueAsync(
// holding only its primary domain.
Subject = subject,
Sans = BuildSanList(san, csr, subject),
ProfileId = ep.ProductCode,
ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null,
RequesterName = string.IsNullOrWhiteSpace(ep.RequesterName) ? null : ep.RequesterName,
RequesterEmail = string.IsNullOrWhiteSpace(ep.RequesterEmail) ? null : ep.RequesterEmail,
Expand Down
4 changes: 2 additions & 2 deletions CERTInext/CERTInextCAPluginConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -444,8 +444,8 @@ public static Dictionary<string, PropertyConfigInfo> GetTemplateParameterAnnotat
},
[Constants.EnrollmentParam.AutoApprove] = new PropertyConfigInfo
{
Comments = "OPTIONAL: If true, the gateway will attempt automatic approval of certificates " +
"that are returned in a pending-approval state. Default: false.",
Comments = "Currently has no effect — reserved for future use. The plugin does not call " +
"any approval endpoint against CERTInext regardless of this setting.",
Hidden = false,
DefaultValue = false,
Type = "Boolean"
Expand Down
12 changes: 9 additions & 3 deletions CERTInext/Client/CERTInextClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -810,14 +810,20 @@ public async Task<EnrollCertificateResponse> RenewCertificateAsync(
certificateId, LogSanitizer.Strip(renewalDomainName));
}

// We don't have the product code from TrackOrder — build an order using
// the config defaults and the CSR from the renewal request.
// Prefer the template's own product code (threaded through via request.ProfileId);
// only fall back to the connector-level default when the caller didn't supply one.
// EnrollmentParams.ProductCode never returns null (it returns string.Empty when it
// can't resolve a code), so this must be a blank check, not a null-coalesce — a
// null-coalesce here would make the DefaultProductCode fallback unreachable, the
// same dead-fallback bug that made DefaultProductCode a no-op for new enrollments.
var orderReq = new GenerateOrderSslRequest
{
Meta = await BuildMetaAsync(ct),
OrderDetails = new SslOrderDetails
{
ProductCode = _config.DefaultProductCode ?? string.Empty,
ProductCode = string.IsNullOrWhiteSpace(request.ProfileId)
? (_config.DefaultProductCode ?? string.Empty)
: request.ProfileId,
SaveAndHold = "0",
RequestorInformation = new RequestorInformation
{
Expand Down
Loading