Skip to content

Commit aea1ec0

Browse files
committed
test(dcv): FakeDomainValidator, DCV unit tests, Cloudflare integration validator
1 parent 9e06a4c commit aea1ec0

8 files changed

Lines changed: 855 additions & 0 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright 2024 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
3+
// At http://www.apache.org/licenses/LICENSE-2.0
4+
5+
using System;
6+
using System.Collections.Concurrent;
7+
using System.Collections.Generic;
8+
using System.Net.Http;
9+
using System.Net.Http.Headers;
10+
using System.Net.Http.Json;
11+
using System.Text.Json;
12+
using System.Text.Json.Serialization;
13+
using System.Threading;
14+
using System.Threading.Tasks;
15+
using Keyfactor.AnyGateway.Extensions;
16+
17+
namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
18+
{
19+
/// <summary>
20+
/// <see cref="IDomainValidator"/> that publishes and removes DNS TXT records via
21+
/// the Cloudflare v4 API. Intended for integration tests against a real domain.
22+
///
23+
/// Credentials are read from the <see cref="IntegrationTestFixture"/>:
24+
/// <c>CERTINEXT_CF_API_TOKEN</c> and <c>CERTINEXT_CF_ZONE_ID</c>.
25+
/// </summary>
26+
internal sealed class CloudflareDomainValidator : IDomainValidator
27+
{
28+
private const string CfApiBase = "https://api.cloudflare.com/client/v4";
29+
30+
private readonly string _apiToken;
31+
private readonly string _zoneId;
32+
private readonly HttpClient _http;
33+
34+
// Maps staging hostname → Cloudflare record ID so CleanupValidation can delete it
35+
private readonly ConcurrentDictionary<string, string> _stagedRecordIds = new();
36+
37+
public CloudflareDomainValidator(string apiToken, string zoneId)
38+
{
39+
_apiToken = apiToken ?? throw new ArgumentNullException(nameof(apiToken));
40+
_zoneId = zoneId ?? throw new ArgumentNullException(nameof(zoneId));
41+
42+
_http = new HttpClient();
43+
_http.DefaultRequestHeaders.Authorization =
44+
new AuthenticationHeaderValue("Bearer", _apiToken);
45+
}
46+
47+
public void Initialize(IDomainValidatorConfigProvider configProvider) { }
48+
49+
public async Task<DomainValidationResult> StageValidation(string key, string value, CancellationToken cancellationToken)
50+
{
51+
var payload = new
52+
{
53+
type = "TXT",
54+
name = key,
55+
content = value,
56+
ttl = 60
57+
};
58+
59+
var response = await _http.PostAsJsonAsync(
60+
$"{CfApiBase}/zones/{_zoneId}/dns_records",
61+
payload,
62+
cancellationToken);
63+
64+
string body = await response.Content.ReadAsStringAsync(cancellationToken);
65+
66+
if (!response.IsSuccessStatusCode)
67+
return new DomainValidationResult
68+
{
69+
Success = false,
70+
ErrorMessage = $"Cloudflare API error {(int)response.StatusCode}: {body}"
71+
};
72+
73+
using var doc = JsonDocument.Parse(body);
74+
bool success = doc.RootElement.GetProperty("success").GetBoolean();
75+
string recordId = success
76+
? doc.RootElement.GetProperty("result").GetProperty("id").GetString()
77+
: null;
78+
79+
if (!success || string.IsNullOrEmpty(recordId))
80+
return new DomainValidationResult
81+
{
82+
Success = false,
83+
ErrorMessage = $"Cloudflare record creation failed: {body}"
84+
};
85+
86+
_stagedRecordIds[key] = recordId;
87+
88+
return new DomainValidationResult { Success = true };
89+
}
90+
91+
public async Task<DomainValidationResult> CleanupValidation(string key, CancellationToken cancellationToken)
92+
{
93+
if (!_stagedRecordIds.TryRemove(key, out string recordId))
94+
return new DomainValidationResult { Success = true }; // nothing to clean up
95+
96+
var response = await _http.DeleteAsync(
97+
$"{CfApiBase}/zones/{_zoneId}/dns_records/{recordId}",
98+
cancellationToken);
99+
100+
if (!response.IsSuccessStatusCode)
101+
{
102+
string body = await response.Content.ReadAsStringAsync(cancellationToken);
103+
return new DomainValidationResult
104+
{
105+
Success = false,
106+
ErrorMessage = $"Cloudflare delete error {(int)response.StatusCode}: {body}"
107+
};
108+
}
109+
110+
return new DomainValidationResult { Success = true };
111+
}
112+
113+
public Task ValidateConfiguration(Dictionary<string, object> configuration) => Task.CompletedTask;
114+
public Dictionary<string, Keyfactor.AnyGateway.Extensions.PropertyConfigInfo> GetDomainValidatorAnnotations() => new();
115+
public string GetValidationType() => "dns-01";
116+
}
117+
118+
internal sealed class CloudflareDomainValidatorFactory : IDomainValidatorFactory
119+
{
120+
private readonly IDomainValidator _validator;
121+
122+
public CloudflareDomainValidatorFactory(string apiToken, string zoneId)
123+
{
124+
_validator = new CloudflareDomainValidator(apiToken, zoneId);
125+
}
126+
127+
public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator;
128+
}
129+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright 2024 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
3+
// At http://www.apache.org/licenses/LICENSE-2.0
4+
5+
using System.Collections.Generic;
6+
using System.Threading.Tasks;
7+
using FluentAssertions;
8+
using Keyfactor.AnyGateway.Extensions;
9+
using Keyfactor.PKI.Enums.EJBCA;
10+
using Xunit;
11+
12+
namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
13+
{
14+
/// <summary>
15+
/// Integration tests for the DNS DCV enrollment path.
16+
///
17+
/// DNS validator selection:
18+
/// • When <c>CERTINEXT_CF_API_TOKEN</c> and <c>CERTINEXT_CF_ZONE_ID</c> are set in
19+
/// <c>~/.env_certinext</c>, a <see cref="CloudflareDomainValidator"/> is used and
20+
/// a real TXT record is published and cleaned up around the enrollment.
21+
/// • Otherwise a <see cref="StubDomainValidator"/> is used. The plugin still
22+
/// exercises the full DCV orchestration path (Stage → propagation wait → VerifyDcv
23+
/// → Cleanup), but no real DNS record is published. Whether CERTInext's VerifyDcv
24+
/// succeeds in this mode depends on the sandbox environment.
25+
///
26+
/// All tests skip when CERTInext credentials are absent (<see cref="IntegrationSkip"/>).
27+
/// Add the following to <c>~/.env_certinext</c> to run with real DNS:
28+
/// <code>
29+
/// CERTINEXT_CF_API_TOKEN=&lt;your Cloudflare API token with DNS:Edit&gt;
30+
/// CERTINEXT_CF_ZONE_ID=&lt;Cloudflare Zone ID for your test domain&gt;
31+
/// CERTINEXT_DCV_DOMAIN=&lt;subdomain to use, e.g. dcv-test.example.com&gt;
32+
/// </code>
33+
/// </summary>
34+
public class DcvLifecycleTests : IClassFixture<IntegrationTestFixture>
35+
{
36+
private readonly IntegrationTestFixture _fixture;
37+
38+
public DcvLifecycleTests(IntegrationTestFixture fixture)
39+
{
40+
_fixture = fixture;
41+
}
42+
43+
// ---------------------------------------------------------------------------
44+
// Helpers
45+
// ---------------------------------------------------------------------------
46+
47+
private IDomainValidatorFactory BuildDnsFactory() =>
48+
_fixture.IsCloudflareConfigured
49+
? (IDomainValidatorFactory)new CloudflareDomainValidatorFactory(
50+
_fixture.CloudflareApiToken, _fixture.CloudflareZoneId)
51+
: new StubDomainValidatorFactory();
52+
53+
private CERTInextCAPlugin BuildPlugin(bool dcvEnabled, int propagationDelaySeconds = 5)
54+
{
55+
var config = new CERTInextConfig
56+
{
57+
ApiUrl = _fixture.Config.ApiUrl,
58+
AuthMode = _fixture.Config.AuthMode,
59+
ApiKey = _fixture.Config.ApiKey,
60+
AccountNumber = _fixture.Config.AccountNumber,
61+
GroupNumber = _fixture.Config.GroupNumber,
62+
RequestorName = _fixture.Config.RequestorName,
63+
RequestorEmail = _fixture.Config.RequestorEmail,
64+
RequestorIsdCode = _fixture.Config.RequestorIsdCode,
65+
RequestorMobileNumber = _fixture.Config.RequestorMobileNumber,
66+
SignerPlace = _fixture.Config.SignerPlace,
67+
SignerIp = _fixture.Config.SignerIp,
68+
DefaultProductCode = _fixture.Config.DefaultProductCode,
69+
PageSize = _fixture.Config.PageSize,
70+
DcvEnabled = dcvEnabled,
71+
DcvPropagationDelaySeconds = propagationDelaySeconds,
72+
DcvTimeoutMinutes = 3
73+
};
74+
75+
return new CERTInextCAPlugin(_fixture.Client, BuildDnsFactory(), config);
76+
}
77+
78+
// ---------------------------------------------------------------------------
79+
// Tests
80+
// ---------------------------------------------------------------------------
81+
82+
/// <summary>
83+
/// Enroll with DCV enabled. Uses a real Cloudflare DNS record when CF credentials
84+
/// are configured, otherwise uses <see cref="StubDomainValidator"/>.
85+
///
86+
/// The test verifies that the plugin completes without throwing. The enrollment
87+
/// result status depends on whether the CERTInext sandbox auto-issues after DCV.
88+
/// </summary>
89+
[SkippableFact]
90+
public async Task DcvEnroll_CompletesWithoutThrowing()
91+
{
92+
IntegrationSkip.IfNotConfigured(_fixture);
93+
94+
var plugin = BuildPlugin(dcvEnabled: true);
95+
96+
var result = await plugin.Enroll(
97+
csr: IntegrationTestData.FakeCsrPem,
98+
subject: $"CN={IntegrationTestData.DcvTestDomain}",
99+
san: new Dictionary<string, string[]>
100+
{
101+
["dns"] = new[] { IntegrationTestData.DcvTestDomain }
102+
},
103+
productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode),
104+
requestFormat: RequestFormat.PKCS10,
105+
enrollmentType: EnrollmentType.New);
106+
107+
result.Should().NotBeNull();
108+
109+
if (_fixture.IsCloudflareConfigured)
110+
{
111+
// With real DNS, CERTInext should be able to verify — assert issuance or pending
112+
new[] { (int)EndEntityStatus.GENERATED, (int)EndEntityStatus.EXTERNALVALIDATION }
113+
.Should().Contain(result.Status,
114+
"enrollment with real DNS DCV should produce a valid terminal or pending status");
115+
}
116+
else
117+
{
118+
// Without real DNS the VerifyDcv may fail; we only assert no unhandled exception
119+
// was thrown (the Enroll method handles the error gracefully).
120+
result.Should().NotBeNull("enrollment should return a result even when stub DNS is used");
121+
}
122+
}
123+
124+
/// <summary>
125+
/// Enroll without DCV enabled — verifies the plugin skips the DCV path entirely
126+
/// and returns a result from the normal enrollment flow.
127+
/// </summary>
128+
[SkippableFact]
129+
public async Task EnrollWithoutDcv_DoesNotInvokeDnsProvider()
130+
{
131+
IntegrationSkip.IfNotConfigured(_fixture);
132+
133+
// Use a plugin backed by the real client but DcvEnabled=false
134+
var plugin = BuildPlugin(dcvEnabled: false);
135+
136+
var result = await plugin.Enroll(
137+
csr: IntegrationTestData.FakeCsrPem,
138+
subject: $"CN={IntegrationTestData.DcvTestDomain}",
139+
san: new Dictionary<string, string[]>
140+
{
141+
["dns"] = new[] { IntegrationTestData.DcvTestDomain }
142+
},
143+
productInfo: IntegrationTestData.DvSslProductInfo(_fixture.Config.DefaultProductCode),
144+
requestFormat: RequestFormat.PKCS10,
145+
enrollmentType: EnrollmentType.New);
146+
147+
result.Should().NotBeNull();
148+
}
149+
}
150+
151+
/// <summary>
152+
/// Shared test data for DCV integration tests.
153+
/// </summary>
154+
internal static class IntegrationTestData
155+
{
156+
/// <summary>
157+
/// Domain used for DCV tests. Override via <c>CERTINEXT_DCV_DOMAIN</c> in
158+
/// <c>~/.env_certinext</c>.
159+
/// </summary>
160+
public static string DcvTestDomain =>
161+
System.Environment.GetEnvironmentVariable("CERTINEXT_DCV_DOMAIN")
162+
?? "dcv-test.example.com";
163+
164+
public const string FakeCsrPem =
165+
"-----BEGIN CERTIFICATE REQUEST-----\n" +
166+
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2a2rwplBQLzHPZe5TNJF\n" +
167+
"-----END CERTIFICATE REQUEST-----";
168+
169+
public static EnrollmentProductInfo DvSslProductInfo(string productCode = null) =>
170+
new EnrollmentProductInfo
171+
{
172+
ProductID = productCode ?? Constants.Products.DvSsl,
173+
ProductParameters = new Dictionary<string, string>
174+
{
175+
["ProfileId"] = productCode ?? Constants.Products.DvSsl,
176+
["ValidityYears"] = "1"
177+
}
178+
};
179+
}
180+
}

CERTInext.IntegrationTests/IntegrationTestFixture.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ public sealed class IntegrationTestFixture : IDisposable
3333
public string RequestorEmail { get; }
3434
public string RequestorName { get; }
3535

36+
// ---------------------------------------------------------------------------
37+
// Cloudflare DCV credentials (optional)
38+
// ---------------------------------------------------------------------------
39+
40+
/// <summary>Cloudflare API token with DNS:Edit permission on <see cref="CloudflareZoneId"/>.</summary>
41+
public string CloudflareApiToken { get; }
42+
43+
/// <summary>Cloudflare Zone ID for the domain used in DCV integration tests.</summary>
44+
public string CloudflareZoneId { get; }
45+
46+
/// <summary>
47+
/// True when Cloudflare credentials are present, enabling real DNS DCV tests.
48+
/// When false, DCV integration tests fall back to a <see cref="StubDomainValidator"/>.
49+
/// </summary>
50+
public bool IsCloudflareConfigured { get; }
51+
3652
/// <summary>
3753
/// True when at minimum ApiUrl and AccessKey are both non-empty,
3854
/// indicating that live credential configuration is present.
@@ -76,6 +92,11 @@ public IntegrationTestFixture()
7692
RequestorEmail = GetEnvValue(env, "CERTINEXT_REQUESTOR_EMAIL");
7793
RequestorName = GetEnvValue(env, "CERTINEXT_REQUESTOR_NAME");
7894

95+
CloudflareApiToken = GetEnvValue(env, "CERTINEXT_CF_API_TOKEN");
96+
CloudflareZoneId = GetEnvValue(env, "CERTINEXT_CF_ZONE_ID");
97+
IsCloudflareConfigured = !string.IsNullOrWhiteSpace(CloudflareApiToken) &&
98+
!string.IsNullOrWhiteSpace(CloudflareZoneId);
99+
79100
IsConfigured = !string.IsNullOrWhiteSpace(ApiUrl) &&
80101
!string.IsNullOrWhiteSpace(AccessKey);
81102

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright 2024 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
3+
// At http://www.apache.org/licenses/LICENSE-2.0
4+
5+
using System.Collections.Generic;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Keyfactor.AnyGateway.Extensions;
9+
10+
namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
11+
{
12+
/// <summary>
13+
/// No-op DNS validator used when Cloudflare credentials are not available.
14+
/// Records are not actually published; DCV verification by CERTInext may or may
15+
/// not succeed depending on whether the sandbox enforces real DNS lookups.
16+
/// </summary>
17+
internal sealed class StubDomainValidator : IDomainValidator
18+
{
19+
public void Initialize(IDomainValidatorConfigProvider configProvider) { }
20+
21+
public Task<DomainValidationResult> StageValidation(string key, string value, CancellationToken cancellationToken) =>
22+
Task.FromResult(new DomainValidationResult { Success = true });
23+
24+
public Task<DomainValidationResult> CleanupValidation(string key, CancellationToken cancellationToken) =>
25+
Task.FromResult(new DomainValidationResult { Success = true });
26+
27+
public Task ValidateConfiguration(Dictionary<string, object> configuration) => Task.CompletedTask;
28+
public Dictionary<string, Keyfactor.AnyGateway.Extensions.PropertyConfigInfo> GetDomainValidatorAnnotations() => new();
29+
public string GetValidationType() => "dns-01";
30+
}
31+
32+
internal sealed class StubDomainValidatorFactory : IDomainValidatorFactory
33+
{
34+
private readonly IDomainValidator _validator = new StubDomainValidator();
35+
public IDomainValidator ResolveDomainValidator(string domain, string validationType) => _validator;
36+
}
37+
}

0 commit comments

Comments
 (0)