Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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.

541 changes: 524 additions & 17 deletions CERTInext.Tests/CERTInextCAPluginDcvTests.cs

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions CERTInext.Tests/CERTInextCAPluginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,34 @@ 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)
// ---------------------------------------------------------------------------
Expand Down
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
61 changes: 51 additions & 10 deletions CERTInext.Tests/FakeDomainValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
// At http://www.apache.org/licenses/LICENSE-2.0

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -21,29 +22,60 @@ internal sealed class FakeDomainValidator : IDomainValidator
/// <summary>All keys passed to <see cref="CleanupValidation"/>.</summary>
public List<string> CleanedUpKeys { get; } = new();

/// <summary>All CancellationTokens passed to <see cref="CleanupValidation"/>.</summary>
public List<CancellationToken> CleanupTokens { get; } = new();

/// <summary>When false, <see cref="StageValidation"/> returns a failure result.</summary>
public bool StageSucceeds { get; init; } = true;

/// <summary>Error message returned when <see cref="StageSucceeds"/> is false.</summary>
/// <summary>
/// When set, overrides <see cref="StageSucceeds"/> on a per-key basis — e.g.
/// <c>key => key.Contains("bad", StringComparison.OrdinalIgnoreCase)</c> to fail only a
/// specific hostname in a multi-domain test while the others still stage successfully.
/// </summary>
public Func<string, bool> ShouldFail { get; init; }

/// <summary>Error message returned when a StageValidation call fails.</summary>
public string StageError { get; init; } = "Stage failed (test stub)";

public void Initialize(IDomainValidatorConfigProvider configProvider) { }

public Task<DomainValidationResult> StageValidation(string key, string value, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
StagedRecords.Add((key, value));
bool fail = ShouldFail?.Invoke(key) ?? !StageSucceeds;
if (!fail)
StagedRecords.Add((key, value));

return Task.FromResult(new DomainValidationResult
{
Success = StageSucceeds,
ErrorMessage = StageSucceeds ? null : StageError
Success = !fail,
ErrorMessage = fail ? StageError : null
});
}

public Task<DomainValidationResult> CleanupValidation(string key, CancellationToken cancellationToken)
/// <summary>
/// Artificial delay applied inside <see cref="CleanupValidation"/> before completing — lets
/// tests distinguish "cleanup calls run concurrently" (wall time ~= one delay) from
/// "cleanup calls run sequentially" (wall time ~= N x delay).
/// </summary>
public TimeSpan CleanupDelay { get; init; } = TimeSpan.Zero;

// Cleanup calls can genuinely run concurrently (that's what CleanupDelay exists to prove),
// so the two List<T> fields below need a lock — unlike StagedRecords above, which only ever
// sees synchronously-completing calls in practice.
private readonly object _cleanupLock = new();

public async Task<DomainValidationResult> CleanupValidation(string key, CancellationToken cancellationToken)
{
CleanedUpKeys.Add(key);
return Task.FromResult(new DomainValidationResult { Success = true });
if (CleanupDelay > TimeSpan.Zero)
await Task.Delay(CleanupDelay, cancellationToken);
lock (_cleanupLock)
{
CleanedUpKeys.Add(key);
CleanupTokens.Add(cancellationToken);
}
return new DomainValidationResult { Success = true };
}

public Task ValidateConfiguration(Dictionary<string, object> configuration) => Task.CompletedTask;
Expand All @@ -53,15 +85,24 @@ public Task<DomainValidationResult> CleanupValidation(string key, CancellationTo

/// <summary>
/// Factory that returns a single pre-configured <see cref="IDomainValidator"/> for every
/// domain. Pass <c>null</c> as the validator to simulate "no DNS provider configured".
/// domain, or only for <paramref name="resolvableDomain"/> if set. Pass <c>null</c> as the
/// validator to simulate "no DNS provider configured".
/// </summary>
internal sealed class FakeDomainValidatorFactory : IDomainValidatorFactory
{
private readonly IDomainValidator _validator;
private readonly string _resolvableDomain;

public FakeDomainValidatorFactory(IDomainValidator validator = null) => _validator = validator;
public FakeDomainValidatorFactory(IDomainValidator validator = null, string resolvableDomain = null)
{
_validator = validator;
_resolvableDomain = resolvableDomain;
}

public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator;
public IDomainValidator ResolveDomainValidator(string domain, string validationType) =>
(_resolvableDomain == null || string.Equals(domain, _resolvableDomain, StringComparison.OrdinalIgnoreCase))
? _validator
: null;

/// <summary>The validator this factory returns; exposed for assertions in tests.</summary>
public IDomainValidator PrimaryValidator => _validator;
Expand Down
14 changes: 14 additions & 0 deletions CERTInext.Tests/MockCertificateData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,20 @@ public static EnrollCertificateResponse PendingEnrollResponse(string id = null)
Message = "Awaiting approval."
};

// Reproduces the CERTInext "auto-approved" race: TrackOrder reports a
// certificateStatusId the client legacy-maps to "issued", but the immediate
// GetCertificate download failed (cert bytes not generated yet), so no PEM
// ever arrived. See issue 0009.
public static EnrollCertificateResponse AutoApprovedNoBodyEnrollResponse(string id = null) =>
new EnrollCertificateResponse
{
Id = id ?? CertId1,
Status = "issued",
Certificate = null,
ProfileId = ProfileIdTls,
Message = "Order auto-approved."
};

// -----------------------------------------------------------------------
// GetCertificate response (object helpers — used by Moq-based plugin tests)
// These use the legacy inferred type (LegacyGetCertificateResponse).
Expand Down
Loading
Loading