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
18 changes: 17 additions & 1 deletion CERTInext.IntegrationTests/INTEGRATION_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,26 @@ test that "passes" with the order still pending tells you nothing.
| No `X-Product-Code`: two-call CSR submit returns 422 "PFX based certificate orders are not allowed" | CERTInext resolved the product to one that doesn't take a client CSR (on the sandbox, DV resolves to `917`, not `842`) | The create response's `resolvedProductCode` (logged as `ResolvedProductCode=`). Pin `ProductCode` on the template. |
| No `X-Product-Code`: `GET .../dcv` returns 422 `EMS-1164 Domain Name is not matching`, and `certificateState` is "System Error" | Same product resolution as the row above. It isn't the domain or its letter case. | Pin 842 on the same domain shape; the order issues. |
| Two-call, pinned: `PUT .../csr` returns 422 "Something went Wrong." | The domain is already validated, so the create came back `issued` with `csrSubmitted: false` and the order won't accept a CSR | Trace payloads show the create response. Use `V2InlineCsr=true`, the default since 2.0. |
| `V2 OAuth2 token acquisition failed ... HttpStatus=0` (enroll, sync, or config save returns 500 "could not connect to CERTInext") | A transient transport failure to the token endpoint. The plugin currently logs no transport detail and doesn't retry the token request; a fix is planned. | Retry once. From a gateway pod, test with `curl --cacert /etc/pki/tls/certs/ca-bundle.crt` (the lab pod's `SSL_CERT_FILE` trusts only the lab CA, so plain curl fails TLS even when .NET works). |
| `V2 OAuth2 token acquisition failed: no HTTP response after 3 attempt(s)` (enroll, sync, or config save returns 500 "could not connect to CERTInext") | A transport failure to the token endpoint that outlasted the plugin's two retries (1 s, 3 s). The line's `TransportDetail=` names the error, such as connection reset, TLS, or DNS. Builds before issue 0095 logged only `HttpStatus=0` and didn't retry. | Retry once. From a gateway pod, test with `curl --cacert /etc/pki/tls/certs/ca-bundle.crt` (the lab pod's `SSL_CERT_FILE` trusts only the lab CA, so plain curl fails TLS even when .NET works). |
| Unpinned (`V2Template`) rows fail while pinned rows pass | Expected on the sandbox until CERTInext changes how it resolves DV | See the 917 rows above. |

Before blaming the plugin, separate CA-side from plugin-side failures: run the same request **pinned**,
on a **fresh** name under the validated parent zone, and with the CSR **inline**. Change one variable
at a time. Record confirmed spec-vs-live discrepancies as dated Findings in the CERTInext
support-questions list kept with the maintainers.

## V2 spec follow-up probes (issues 0094/0095)

`V2SpecFollowupsLiveTests` holds one opt-in test per follow-up, each on top of `CERTINEXT_USE_V2_API=1`:

| Flag | Test | Touches |
|---|---|---|
| `CERTINEXT_V2_SSLCOM_DCV_PROBE=1` | `SslComDomainDcv_LookupAndShape_Probe` | Read-only |
| `CERTINEXT_V2_UNRESOLVED_STATUS_PROBE=1` | `Sync_UnresolvedReportStatuses_Probe` | Read-only |
| `CERTINEXT_V2_REVOKE_PRIVILEGE_WITHDRAWN=1` | `Revoke_PrivilegeWithdrawn_OwnOrder_ReportsCaAcceptance` | Places and revokes its own order |
| `CERTINEXT_V2_REVOKE_REASON_PROBE_ORDER_ID=<own order>` | `Revoke_ReasonAcceptance_OwnOrder_Probe` | Sends each revoke reason to that order |
| `CERTINEXT_V2_FULL_SYNC_PROBE=1` | `Sync_V2_FullSync_Summary_Probe` | Read-only (downloads issued certificates) |
| `CERTINEXT_V2_LIFECYCLE_REISSUE_ENDPOINT=1` + `CERTINEXT_V2_REISSUE_SOURCE_ORDER_ID=<issued order>` | `Reissue_V2_ReissueEndpoint_DerivedOrder_Report` | Creates a derived order, then cleans it up |

On a sandbox that has stopped generating certificates, the cleanup of an order the test created can fail with "Certificate Request still being processed". Clean such orders up later with `CERTINEXT_V2_CLEANUP_ORDERS`.

591 changes: 591 additions & 0 deletions CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions CERTInext.Tests/CERTInext.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<ItemGroup Condition="!$(DefineConstants.Contains('SUPPORTS_DCV'))">
<Compile Remove="CERTInextCAPluginDcvTests.cs" />
<Compile Remove="CERTInextCAPluginV2DcvTests.cs" />
<Compile Remove="V2SslComDomainDcvPluginTests.cs" />
<Compile Remove="PostPlacementDcvFailureTests.cs" />
<Compile Remove="FakeDomainValidator.cs" />
</ItemGroup>
Expand Down
28 changes: 14 additions & 14 deletions CERTInext.Tests/CERTInextCAPluginV2DcvTests.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
// Copyright 2026 Keyfactor
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
// and limitations under the License.

using System;
using System.Collections.Concurrent;
Expand Down Expand Up @@ -41,8 +34,15 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
/// </summary>
public class CERTInextCAPluginV2DcvTests
{
private static Mock<ICERTInextClient> NewMock() =>
new Mock<ICERTInextClient>(MockBehavior.Strict);
private static Mock<ICERTInextClient> NewMock()
{
var mock = new Mock<ICERTInextClient>(MockBehavior.Strict);
// No account domain row: every domain here takes the emSign DCV path.
// V2SslComDomainDcvPluginTests covers SSL.com-validated domains.
mock.Setup(c => c.FindDomainV2Async(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((V2DomainRecord)null);
return mock;
}

private static CERTInextCAPlugin BuildV2DcvPlugin(
ICERTInextClient client, IDomainValidatorFactory factory, string dcvTxtRecordTemplate = null,
Expand Down
89 changes: 87 additions & 2 deletions CERTInext.Tests/CERTInextCAPluginV2Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
Expand Down Expand Up @@ -464,9 +465,13 @@ private static Mock<ICERTInextClient> SetupRevokeReasonRejectionMock(List<string
req.Reason == Constants.RevocationReasonV2.Unspecified ||
req.Reason == Constants.RevocationReasonV2.CACompromise ||
req.Reason == Constants.RevocationReasonV2.CertificateHold ||
req.Reason == Constants.RevocationReasonV2.AACompromise;
req.Reason == Constants.RevocationReasonV2.AACompromise ||
req.Reason == Constants.RevocationReasonV2.PrivilegeWithdrawn;
if (firstAttemptRejected && seenReasons.Count == 1)
throw new InvalidOperationException("V2 revoke rejected. Unprocessable Entity: Invalid Revoke Reason ID");
throw new InvalidOperationException(req.Reason == Constants.RevocationReasonV2.PrivilegeWithdrawn
// Live wording seen 2026-11-13; no EMS code.
? "V2 revoke rejected. Unprocessable Entity: Revocation reason privilege-withdrawn is not allowed."
: "V2 revoke rejected. Unprocessable Entity: Invalid Revoke Reason ID");
return Task.CompletedTask;
});
return mock;
Expand All @@ -478,6 +483,8 @@ private static Mock<ICERTInextClient> SetupRevokeReasonRejectionMock(List<string
[InlineData(2u, Constants.RevocationReasonV2.CACompromise, Constants.RevocationReasonV2.KeyCompromise)]
[InlineData(6u, Constants.RevocationReasonV2.CertificateHold, Constants.RevocationReasonV2.CessationOfOperation)]
[InlineData(10u, Constants.RevocationReasonV2.AACompromise, Constants.RevocationReasonV2.KeyCompromise)]
// Dropped from the 2.0.0 spec's SSL/TLS enum though accepted live: retried only if rejected.
[InlineData(9u, Constants.RevocationReasonV2.PrivilegeWithdrawn, Constants.RevocationReasonV2.CessationOfOperation)]
public async Task Revoke_V2Enabled_KnownRejectedReason_RetriesWithExpectedFallback(
uint crlReason, string expectedOriginal, string expectedFallback)
{
Expand All @@ -491,6 +498,40 @@ public async Task Revoke_V2Enabled_KnownRejectedReason_RetriesWithExpectedFallba
seenReasons.Should().Equal(expectedOriginal, expectedFallback);
}

[Theory]
[InlineData("V2 revoke rejected. Unprocessable Entity: Invalid Revoke Reason ID", true)]
[InlineData("V2 revoke rejected. Unprocessable Entity: Revocation reason unspecified is not allowed.", true)]
[InlineData("V2 revoke rejected. Unprocessable Entity: Certificate Request still being processed", false)]
[InlineData("V2 revoke rejected. Unprocessable Entity: Invalid Revoke Remarks", false)]
[InlineData(null, false)]
public void IsRevokeReasonRejection_MatchesBothLiveWordingsOnly(string message, bool expected) =>
CERTInextCAPlugin.IsRevokeReasonRejection(message).Should().Be(expected);

[Fact]
public async Task Revoke_V2Enabled_PrivilegeWithdrawnAccepted_SentOnceUnchanged()
{
var seen = new List<string>();
var mock = new Mock<ICERTInextClient>();
mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(
MockCertificateData.V2OrderId1, It.IsAny<CancellationToken>()))
.ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse
{
OrderId = MockCertificateData.V2OrderId1,
Status = "issued"
}));
mock.Setup(c => c.RevokeOrderV2Async(
Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1,
It.IsAny<V2RevokeRequest>(), It.IsAny<CancellationToken>()))
.Callback((string f, string o, V2RevokeRequest req, CancellationToken ct) => seen.Add(req.Reason))
.Returns(Task.CompletedTask);

var plugin = BuildV2Plugin(mock.Object);
var status = await plugin.Revoke(MockCertificateData.V2OrderId1, "AABB", 9u);

status.Should().Be((int)EndEntityStatus.REVOKED);
seen.Should().Equal(Constants.RevocationReasonV2.PrivilegeWithdrawn);
}

[Fact]
public async Task Revoke_V2Enabled_RejectedReasonNotInFallbackSet_DoesNotRetry()
{
Expand Down Expand Up @@ -640,6 +681,50 @@ public async Task Synchronize_V2Enabled_IssuedRow_DownloadsCertificateBody()
It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}

[Fact]
public async Task Synchronize_V2Enabled_OnHoldSetupPending_TrackNotFound_SkippedNotError_OthersStillSynced()
{
var mock = new Mock<ICERTInextClient>();
// Live shape: listed by the report as "On Hold | Setup Pending", 404 in every family.
var onHold = ReportRow("3549334354", "On Hold", "Setup Pending");
var issued = ReportRow("ord_v2sync_ok", "Order Fulfilled", "Certificate Downloaded");
mock.Setup(c => c.ListOrdersV2Async(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns(AsyncEnumerable(onHold, issued));
mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("3549334354", It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("V2 order '3549334354' not found in any product family."));
mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_ok", It.IsAny<CancellationToken>()))
.ReturnsAsync(new V2CertificateDownloadResponse
{
OrderId = "ord_v2sync_ok", SerialNumber = "AABB", CertificatePem = MockCertificateData.FakePemCertificate
});

var buffer = new BlockingCollection<AnyCAPluginCertificate>(100);
await BuildV2Plugin(mock.Object).Synchronize(buffer, null, true, CancellationToken.None);

buffer.ToArray().Select(r => r.CARequestID).Should().Equal("ord_v2sync_ok");
}

[Fact]
public async Task Synchronize_V2Enabled_ManyUntrackableRows_DoNotTripTheErrorRateAbort()
{
// Before: each 404 fallback counted as an error, so a pass made mostly of such rows threw
// "synchronization aborted ... >25% error rate".
var mock = new Mock<ICERTInextClient>();
var rows = Enumerable.Range(0, 60).Select(i => ReportRow($"onhold_{i}", "On Hold", "Setup Pending")).ToArray();
mock.Setup(c => c.ListOrdersV2Async(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns(AsyncEnumerable(rows));
mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("not found in any product family"));

var buffer = new BlockingCollection<AnyCAPluginCertificate>(100);
Func<Task> act = () => BuildV2Plugin(mock.Object).Synchronize(buffer, null, true, CancellationToken.None);

await act.Should().NotThrowAsync();
buffer.Should().BeEmpty();
}

[Fact]
public async Task Synchronize_V2Enabled_UnrecognizedStatus_FallsBackToLiveTrack()
{
Expand Down
91 changes: 91 additions & 0 deletions CERTInext.Tests/FlakyTcpProxy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright 2026 Keyfactor
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
// and limitations under the License.

using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;

namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
{
/// <summary>
/// Loopback TCP proxy in front of a WireMock server that resets chosen connections before any
/// HTTP response, so the client sees a real transport failure (RestSharp status 0). WireMock's own
/// faults still send a 200 status line. Connections are numbered from 1 in accept order.
/// </summary>
internal sealed class FlakyTcpProxy : IDisposable
{
private readonly TcpListener _listener = new TcpListener(IPAddress.Loopback, 0);
private readonly int _targetPort;
private readonly Func<int, bool> _drop;
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private int _connections;

public FlakyTcpProxy(string targetUrl, Func<int, bool> drop)
{
_targetPort = new Uri(targetUrl).Port;
_drop = drop;
_listener.Start();
_ = Task.Run(AcceptLoopAsync);
}

public string Url => $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}";

public int Connections => Volatile.Read(ref _connections);

private async Task AcceptLoopAsync()
{
while (!_cts.IsCancellationRequested)
{
TcpClient client;
try
{
client = await _listener.AcceptTcpClientAsync(_cts.Token);
}
catch
{
return;
}
int n = Interlocked.Increment(ref _connections);
if (_drop(n))
{
// Linger 0 sends RST: the request fails with no HTTP response at all.
client.Client.LingerState = new LingerOption(true, 0);
client.Close();
continue;
}
_ = Task.Run(() => PipeAsync(client));
}
}

private async Task PipeAsync(TcpClient client)
{
using (client)
using (var upstream = new TcpClient())
{
try
{
await upstream.ConnectAsync(IPAddress.Loopback, _targetPort, _cts.Token);
var a = client.GetStream();
var b = upstream.GetStream();
await Task.WhenAny(a.CopyToAsync(b, _cts.Token), b.CopyToAsync(a, _cts.Token));
}
catch
{
// A test ended or a side closed: nothing to do.
}
}
}

public void Dispose()
{
_cts.Cancel();
_listener.Stop();
}
}
}
32 changes: 18 additions & 14 deletions CERTInext.Tests/V2OrganizationEnrollmentTests.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
// Copyright 2026 Keyfactor
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
// and limitations under the License.

using System;
using System.Collections.Generic;
Expand Down Expand Up @@ -234,8 +227,19 @@ public void V2CreateSslOrderRequest_Serialization_IncludesOrganizationBlock_ForO
json.Should().Contain("\"organization\"");
json.Should().Contain("\"organizationNumber\":\"ORG-999\"");
json.Should().Contain("\"preVetted\":true");
// preVettingToken is optional and unset here — must be omitted, not sent as null.
json.Should().NotContain("preVettingToken");
// prevettingToken is optional and unset here — must be omitted, not sent as null.
json.Should().NotContainEquivalentOf("prevettingToken");
}

[Fact]
public void V2OrganizationParams_Serialization_UsesSpecSpellingPrevettingToken()
{
var org = new V2OrganizationParams { OrganizationNumber = "ORG-999", PreVettingToken = "tok-123" };

string json = JsonSerializer.Serialize(org);

json.Should().Be("{\"organizationNumber\":\"ORG-999\",\"preVetted\":true,\"prevettingToken\":\"tok-123\"}",
"the spec's SslOrganizationRef names the field prevettingToken (lower-case v)");
}

[Fact]
Expand Down
Loading
Loading