Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
3e3d581
feat(enroll): synchronous certificate pickup (Sectigo parity) — v1.0.1
spbsoluble Jul 31, 2026
77fe123
chore(enroll): log RequestFormat on the enrollment-start line
spbsoluble Jul 31, 2026
49616cc
fix(client): don't retry non-idempotent order/CSR submits on a networ…
spbsoluble Jul 31, 2026
e8e4739
fix(client): enrich orphaned-order warnings + SubmitCSR transient gui…
spbsoluble Jul 31, 2026
6ae12b7
fix(enroll): harden synchronous pickup — DCV gating, wait ceiling, au…
spbsoluble Jul 31, 2026
f499f65
fix(enroll): UCC SANs never reached CERTInext — additionalDomains sen…
spbsoluble Aug 13, 2026
947ae68
docs: refresh docsource against current code
spbsoluble Aug 13, 2026
5d2d154
docs: auto-generate README and documentation [skip ci]
github-actions[bot] Aug 13, 2026
a890a7d
fix(enroll): renewal product code, AutoApprove UI text, config log vi…
spbsoluble Aug 13, 2026
00ccdbd
docs(changelog): add PR #28's renewal product-code, AutoApprove, and …
spbsoluble Aug 13, 2026
59aa2b2
fix(logging): add trace-level payload dumps for enrollment request/re…
spbsoluble Sep 14, 2026
1447a5c
feat(enroll): port ValidityYears from release-1.1 (PR #22)
spbsoluble Sep 14, 2026
be44ef7
feat(v2): add CERTInext V2 REST API support behind UseV2Api flag
spbsoluble Sep 21, 2026
f0eb37e
test(v2): fix V2 integration test env var names and assertions
spbsoluble Sep 21, 2026
8992fcb
feat(v2): add V2 DCV client methods, chainPem, issuedAt/expiresAt, op…
spbsoluble Sep 22, 2026
a4e03a4
docs(architecture): add V2 enrollment flow diagrams and update V1 pol…
spbsoluble Sep 22, 2026
2d3a833
test(v2): add integration tests for GetProductDetails, GetSingleRecor…
spbsoluble Sep 22, 2026
db6f33e
fix: address V2 code-review findings (resource leaks, DCV family slug…
spbsoluble Sep 22, 2026
70b83b2
fix: resource leak, V2 audit-trail gap, and trace-level authKey exposure
spbsoluble Sep 22, 2026
c870895
fix: RevokeOrderV2Async 404→KeyNotFoundException and ValidateProductI…
spbsoluble Sep 22, 2026
0d149d9
fix: V2 enrollment never submitted CSR; CRLF log injection in Request…
spbsoluble Sep 22, 2026
a030192
fix(enroll): add DelegationInformation/TechnicalPointOfContact to Ren…
spbsoluble Sep 22, 2026
35fac40
fix(enroll): re-throw OperationCanceledException in PickUpEnrolledCer…
spbsoluble Sep 22, 2026
8005ce9
fix(dcv): decouple post-verify poll cadence from DcvPropagationDelayS…
spbsoluble Sep 22, 2026
393efc2
fix(dcv): add _dcvInFlight guard to PerformDcvV2IfNeededAsync
spbsoluble Sep 22, 2026
90805f1
fix(enroll): guard TrackOrderV2Async post-CSR-submission against tran…
spbsoluble Sep 22, 2026
8c79d54
fix(enroll): reject multi-SAN V2 enrollments with clear FAILED result
spbsoluble Sep 22, 2026
be135ed
test(v2): fix OAuth URL priority and wire lifecycle order ID to revok…
spbsoluble Sep 23, 2026
bc6f33d
test(v2): gate CERTINEXT_V2_RUN_BULK_TEST as opt-in, exclude V2DcvLif…
spbsoluble Sep 23, 2026
c83e0d0
test(v2): close catalog/products and TrackOrder structural coverage gaps
spbsoluble Sep 23, 2026
2edeb08
test(v2): add plugin-level V2 lifecycle test coverage (gaps 1-4, 9-11)
spbsoluble Sep 23, 2026
5ca26a5
test(v2): add plugin-level V2 DCV lifecycle coverage (gaps 5-8, 14-15)
spbsoluble Sep 23, 2026
6923895
test(v2): add opt-in /reports/orders and /domains live probes (Phase 0)
spbsoluble Sep 23, 2026
d603701
test(v2): make V2 integration tests honest (G5-G7, narrowed revoke ca…
spbsoluble Sep 23, 2026
e624be7
fix(v2): correct OAuth 401/403 hints, surface RFC 7807 field errors; …
spbsoluble Sep 23, 2026
5650937
fix(v2): kebab-case revoke reasons, single-family revoke with clear 4…
spbsoluble Sep 23, 2026
42c039e
docs: auto-generate README and documentation [skip ci]
github-actions[bot] Sep 23, 2026
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
10 changes: 6 additions & 4 deletions CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@
<ProjectReference Include="..\CERTInext\CERTInext.csproj" />
</ItemGroup>

<!-- DCV integration tests + the DNS validator implementations use the v3.3-only
IDomainValidator / IDomainValidatorFactory and the factory constructor. On the
IAnyCAPlugin 3.2.0 (no-DCV) build those don't exist, so exclude these files unless
SUPPORTS_DCV is defined. See issue 0003. -->
<!-- DCV integration tests + the DNS validator implementations (real and recording/spy)
use the v3.3-only IDomainValidator / IDomainValidatorFactory and the factory
constructor. On the IAnyCAPlugin 3.2.0 (no-DCV) build those don't exist, so exclude
these files unless SUPPORTS_DCV is defined. See issue 0003. -->
<ItemGroup Condition="!$(DefineConstants.Contains('SUPPORTS_DCV'))">
<Compile Remove="DcvLifecycleTests.cs" />
<Compile Remove="V2DcvLifecycleTests.cs" />
<Compile Remove="CloudflareDomainValidator.cs" />
<Compile Remove="StubDomainValidator.cs" />
<Compile Remove="RecordingDomainValidator.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
10 changes: 7 additions & 3 deletions CERTInext.IntegrationTests/CloudflareDomainValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
/// Credentials are read from the <see cref="IntegrationTestFixture"/>:
/// <c>CERTINEXT_CF_API_TOKEN</c> and <c>CERTINEXT_CF_ZONE_ID</c>.
/// </summary>
internal sealed class CloudflareDomainValidator : IDomainValidator
internal sealed class CloudflareDomainValidator : IDomainValidator, IDisposable
{
private const string CfApiBase = "https://api.cloudflare.com/client/v4";

Expand Down Expand Up @@ -113,17 +113,21 @@ public async Task<DomainValidationResult> CleanupValidation(string key, Cancella
public Task ValidateConfiguration(Dictionary<string, object> configuration) => Task.CompletedTask;
public Dictionary<string, Keyfactor.AnyGateway.Extensions.PropertyConfigInfo> GetDomainValidatorAnnotations() => new();
public string GetValidationType() => "dns-01";

public void Dispose() => _http.Dispose();
}

internal sealed class CloudflareDomainValidatorFactory : IDomainValidatorFactory
internal sealed class CloudflareDomainValidatorFactory : IDomainValidatorFactory, IDisposable
{
private readonly IDomainValidator _validator;
private readonly CloudflareDomainValidator _validator;

public CloudflareDomainValidatorFactory(string apiToken, string zoneId)
{
_validator = new CloudflareDomainValidator(apiToken, zoneId);
}

public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator;

public void Dispose() => _validator.Dispose();
}
}
58 changes: 45 additions & 13 deletions CERTInext.IntegrationTests/DcvLifecycleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,25 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
/// CERTINEXT_DCV_DOMAIN=&lt;subdomain to use, e.g. dcv-test.example.com&gt;
/// </code>
/// </summary>
public class DcvLifecycleTests : IClassFixture<IntegrationTestFixture>
public class DcvLifecycleTests : IClassFixture<IntegrationTestFixture>, IDisposable
{
private readonly IntegrationTestFixture _fixture;
private readonly ITestOutputHelper _output;
private readonly List<IDisposable> _toDispose = new List<IDisposable>();

public DcvLifecycleTests(IntegrationTestFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
}

public void Dispose()
{
foreach (var d in _toDispose)
d.Dispose();
_toDispose.Clear();
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand All @@ -69,11 +77,17 @@ private static string GenerateCsrPem(string commonName)
+ "\n-----END CERTIFICATE REQUEST-----";
}

private IDomainValidatorFactory BuildDnsFactory() =>
_fixture.IsCloudflareConfigured
? (IDomainValidatorFactory)new CloudflareDomainValidatorFactory(
_fixture.CloudflareApiToken, _fixture.CloudflareZoneId)
: new StubDomainValidatorFactory();
private IDomainValidatorFactory BuildDnsFactory()
{
if (_fixture.IsCloudflareConfigured)
{
var factory = new CloudflareDomainValidatorFactory(
_fixture.CloudflareApiToken, _fixture.CloudflareZoneId);
_toDispose.Add(factory);
return factory;
}
return new StubDomainValidatorFactory();
}

/// <summary>
/// Runs <c>plugin.Synchronize</c> and returns every record that came out of the
Expand All @@ -88,6 +102,7 @@ private static async Task<List<AnyCAPluginCertificate>> RunSyncAsync(CERTInextCA
var syncTask = Task.Run(async () =>
{
await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: System.Threading.CancellationToken.None);
// Synchronize calls CompleteAdding() in its finally block; guard against double-call.
if (!buffer.IsAddingCompleted)
buffer.CompleteAdding();
});
Expand Down Expand Up @@ -655,7 +670,6 @@ public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks()
List<AnyCAPluginCertificate> synced = null;
System.Diagnostics.Stopwatch syncPhaseSw = System.Diagnostics.Stopwatch.StartNew();
int passesUsed = 0;
int finalNotIssued = -1;

for (int pass = 1; pass <= maxSyncPasses; pass++)
{
Expand All @@ -664,13 +678,27 @@ public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks()
synced = await RunSyncAsync(plugin);
passSw.Stop();

// Classify enrolled orders by their current status so that FAILED orders
// are not silently counted as still-pending, which would burn the full
// pass budget before producing a misleading "expected 0" assertion.
int generated = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.GENERATED);
int pending = enrolledIds.Count - generated;
finalNotIssued = pending;
int failed = synced.Count(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED);
int pending = enrolledIds.Count - generated - failed;

_output.WriteLine(
$"--- Sync pass #{pass}: returned {synced.Count} records, {generated}/{enrolledIds.Count} GENERATED, " +
$"{pending} still pending, elapsed={passSw.Elapsed:mm\\:ss} ---");
$"{failed} FAILED, {pending} still pending, elapsed={passSw.Elapsed:mm\\:ss} ---");

if (failed > 0)
{
var failedIds = synced
.Where(r => enrolledIds.Contains(r.CARequestID) && r.Status == (int)EndEntityStatus.FAILED)
.Select(r => r.CARequestID)
.Take(5);
Assert.Fail(
$"Pass #{pass}: {failed} order(s) reached FAILED status and will never issue: " +
string.Join(", ", failedIds));
}

if (pending == 0)
break;
Expand All @@ -696,10 +724,14 @@ public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks()
$"{string.Join(", ", missing.Take(5))}{(missing.Count > 5 ? ", ..." : "")}");

// Final assertion — every enrolled order must be GENERATED after the polling window.
var lookup = synced.ToDictionary(r => r.CARequestID, r => r);
// Filter null CARequestIDs before building the lookup (guards against any CA response
// that omits the ID, which would otherwise throw ArgumentNullException in ToDictionary).
var lookup = synced
.Where(r => r.CARequestID != null)
.ToDictionary(r => r.CARequestID, r => r);
var notIssued = enrolledIds
.Where(id => lookup.TryGetValue(id, out var rec) && rec.Status != (int)EndEntityStatus.GENERATED)
.Select(id => lookup[id])
.Where(r => r.Status != (int)EndEntityStatus.GENERATED)
.ToList();

if (notIssued.Count > 0)
Expand All @@ -711,7 +743,7 @@ public async Task BulkDvEnrollment_AllOrdersIssue_AndPaginationWorks()

notIssued.Should().BeEmpty(
$"every enrolled DV order should auto-issue on the new sandbox after {maxSyncPasses} sync passes; " +
$"{notIssued.Count} did not (last pass: {finalNotIssued} pending).");
$"{notIssued.Count} did not.");

_output.WriteLine($"--- SUCCESS: {count}/{count} DV orders enrolled, synced, and issued in {passesUsed} sync pass(es). " +
$"Enroll={sw.Elapsed:mm\\:ss} SyncPhase={syncPhaseSw.Elapsed:mm\\:ss} Total={(sw.Elapsed + syncPhaseSw.Elapsed):mm\\:ss} ---");
Expand Down
23 changes: 22 additions & 1 deletion CERTInext.IntegrationTests/IntegrationTestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
/// </summary>
public sealed class IntegrationTestFixture : IDisposable
{
// ---------------------------------------------------------------------------
// Opt-in guard
// ---------------------------------------------------------------------------

/// <summary>
/// Env-var keys that must be set explicitly in the shell and must NOT be
/// auto-promoted from the env file. These gate destructive or mutating tests
/// so a developer cannot accidentally arm them by leaving flags in ~/.env_certinext.
/// </summary>
private static readonly System.Collections.Generic.HashSet<string> _optInOnlyFlags =
new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"CERTINEXT_COMPLETE_PENDING",
"CERTINEXT_RUN_BULK_TEST",
"CERTINEXT_V2_RUN_BULK_TEST",
};

// ---------------------------------------------------------------------------
// Credential properties
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -85,8 +102,12 @@ public IntegrationTestFixture()

// Promote env-file values into the process environment so that any code
// calling System.Environment.GetEnvironmentVariable() picks them up.
// Opt-in destructive-test flags are deliberately excluded: they must be
// set explicitly in the shell so a developer who leaves them in the file
// does not accidentally arm bulk/mutating tests on every bare `dotnet test`.
foreach (var kv in env)
if (System.Environment.GetEnvironmentVariable(kv.Key) == null)
if (System.Environment.GetEnvironmentVariable(kv.Key) == null
&& !_optInOnlyFlags.Contains(kv.Key))
System.Environment.SetEnvironmentVariable(kv.Key, kv.Value);

ApiUrl = GetEnvValue(env, "CERTINEXT_API_URL");
Expand Down
94 changes: 94 additions & 0 deletions CERTInext.IntegrationTests/RecordingDomainValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// 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.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Keyfactor.AnyGateway.Extensions;

namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
{
/// <summary>
/// <see cref="IDomainValidator"/> spy that wraps a real (Cloudflare or stub) validator
/// and records every <c>StageValidation</c>/<c>CleanupValidation</c> call, including the
/// FQDN and staged value, so DCV-on tests can assert whether the plugin actually staged
/// a TXT record rather than just asserting that Enroll did not throw (gap G5, issues/0020).
/// </summary>
internal sealed class RecordingDomainValidator : IDomainValidator
{
private readonly IDomainValidator _inner;
private readonly ConcurrentQueue<(string Fqdn, string Value)> _staged = new();
private readonly ConcurrentQueue<string> _cleanedUp = new();

public RecordingDomainValidator(IDomainValidator inner)
{
_inner = inner;
}

public IReadOnlyList<(string Fqdn, string Value)> StagedCalls => _staged.ToList();
public IReadOnlyList<string> CleanedUpFqdns => _cleanedUp.ToList();

public void Initialize(IDomainValidatorConfigProvider configProvider) => _inner.Initialize(configProvider);

public async Task<DomainValidationResult> StageValidation(string key, string value, CancellationToken cancellationToken)
{
_staged.Enqueue((key, value));
return await _inner.StageValidation(key, value, cancellationToken);
}

public async Task<DomainValidationResult> CleanupValidation(string key, CancellationToken cancellationToken)
{
_cleanedUp.Enqueue(key);
return await _inner.CleanupValidation(key, cancellationToken);
}

public Task ValidateConfiguration(Dictionary<string, object> configuration) => _inner.ValidateConfiguration(configuration);
public Dictionary<string, PropertyConfigInfo> GetDomainValidatorAnnotations() => _inner.GetDomainValidatorAnnotations();
public string GetValidationType() => _inner.GetValidationType();
}

/// <summary>
/// <see cref="IDomainValidatorFactory"/> that wraps another factory and hands out
/// <see cref="RecordingDomainValidator"/> spies so tests can inspect what the plugin
/// actually did with the DNS provider, keyed by (domain, validationType). Does not own
/// disposal of the wrapped factory — callers that build a disposable inner factory
/// (e.g. <c>CloudflareDomainValidatorFactory</c>) remain responsible for disposing it.
/// </summary>
internal sealed class RecordingDomainValidatorFactory : IDomainValidatorFactory
{
private readonly IDomainValidatorFactory _inner;
private readonly ConcurrentDictionary<string, RecordingDomainValidator> _wrapped = new();

public RecordingDomainValidatorFactory(IDomainValidatorFactory inner)
{
_inner = inner;
}

public IDomainValidator ResolveDomainValidator(string domain, string validationType)
{
string cacheKey = $"{domain}|{validationType}";
return _wrapped.GetOrAdd(cacheKey, _ => new RecordingDomainValidator(_inner.ResolveDomainValidator(domain, validationType)));
}

/// <summary>All StageValidation calls recorded across every domain resolved so far.</summary>
public IReadOnlyList<(string Fqdn, string Value)> StagedCalls =>
_wrapped.Values.SelectMany(v => v.StagedCalls).ToList();

/// <summary>All CleanupValidation calls recorded across every domain resolved so far.</summary>
public IReadOnlyList<string> CleanedUpFqdns =>
_wrapped.Values.SelectMany(v => v.CleanedUpFqdns).ToList();
}
}
Loading