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
6 changes: 3 additions & 3 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@

<!-- How was this validated? Check all that apply. -->

- [ ] `make test` passes (unit tests)
- [ ] `make integration-test` passes (requires `~/.env_certinext`)
- [ ] `make coverage` shows no coverage regression
- [ ] `just test` passes (unit tests)
- [ ] `just integration-test` passes (requires `~/.env_certinext`)
- [ ] `just coverage` shows no coverage regression
- [ ] Terraform changes validated with `terraform plan`
- [ ] Tested only docs/config — no runtime changes

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@ terraform/terraform.tfvars
# Analysis / scratch — never commit
analysis/

# Local-only working notes — never commit
DCV_BUILD_SUPPORT.md

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>warnings</Nullable>
<LangVersion>12.0</LangVersion>
Expand Down
12 changes: 4 additions & 8 deletions CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>warnings</Nullable>
<LangVersion>12.0</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Mirror the main project's DcvSupport flag. Default false → DCV test files are excluded
(matches the GA no-DCV build); -p:DcvSupport=true compiles them in with SUPPORTS_DCV. -->
<DcvSupport Condition="'$(DcvSupport)' == ''">false</DcvSupport>
<!-- Mirror the main project's DcvSupport flag. Default true → DCV test files are compiled in
(matches the default DCV build); -p:DcvSupport=false excludes them (GA no-DCV build). -->
<DcvSupport Condition="'$(DcvSupport)' == ''">true</DcvSupport>
<DefineConstants Condition="'$(DcvSupport)' == 'true'">$(DefineConstants);SUPPORTS_DCV</DefineConstants>
</PropertyGroup>

Expand Down Expand Up @@ -36,9 +36,5 @@
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<!-- Suppress TFM support build warnings for transitive dependencies -->
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
<PackageReference Include="System.IO.Pipelines" Version="8.0.0" />
<PackageReference Include="System.Text.Encodings.Web" Version="8.0.0" />
</ItemGroup>
</Project>
148 changes: 148 additions & 0 deletions CERTInext.IntegrationTests/CnameResolverLiveDnsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// 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.
// At http://www.apache.org/licenses/LICENSE-2.0
//
// Live-DNS validation for issue 0006's CnameResolver. This deliberately does NOT go through
// CERTInext order placement (which is currently blocked by sandbox credit exhaustion, issue
// 0004) — it stages real CNAME records in the same Cloudflare zone used for DCV tests and
// exercises the production Dcv.CnameResolver directly against public DNS, to confirm the
// DnsClient.NET-backed resolution actually works end-to-end (as opposed to only the
// hop-walking algorithm, which the unit tests already cover via a fake single-hop delegate).

using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Keyfactor.Extensions.CAPlugin.CERTInext.Dcv;
using Xunit;

namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
{
public class CnameResolverLiveDnsTests : IClassFixture<IntegrationTestFixture>, IAsyncLifetime
{
private const string CfApiBase = "https://api.cloudflare.com/client/v4";

private readonly IntegrationTestFixture _fixture;
private HttpClient _http;
private string _hopAName;
private string _hopBName;
private string _hopCName;
private string _hopARecordId;
private string _hopBRecordId;

public CnameResolverLiveDnsTests(IntegrationTestFixture fixture)
{
_fixture = fixture;
}

public Task InitializeAsync() => Task.CompletedTask;

public async Task DisposeAsync()
{
if (_http == null)
return;

foreach (var recordId in new[] { _hopARecordId, _hopBRecordId })
{
if (string.IsNullOrEmpty(recordId))
continue;
try
{
await _http.DeleteAsync($"{CfApiBase}/zones/{_fixture.CloudflareZoneId}/dns_records/{recordId}");
}
catch
{
// best-effort cleanup
}
}

_http.Dispose();
}

private async Task<string> CreateCnameRecordAsync(string name, string target)
{
var payload = new { type = "CNAME", name, content = target, ttl = 60, proxied = false };
var response = await _http.PostAsJsonAsync($"{CfApiBase}/zones/{_fixture.CloudflareZoneId}/dns_records", payload);
string body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"Cloudflare CNAME creation failed for '{name}': {body}");

using var doc = JsonDocument.Parse(body);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), $"Cloudflare reported failure: {body}");
return doc.RootElement.GetProperty("result").GetProperty("id").GetString();
}

/// <summary>
/// Two-hop live chain: hopA → hopB → hopC (hopC is never created, so it is terminal —
/// exactly like a delegated validation zone whose target has no further CNAME). Confirms
/// the production <see cref="CnameResolver"/> (real DnsClient.NET queries against the
/// OS-configured resolver) walks a real, publicly-resolvable CNAME chain correctly.
/// </summary>
[SkippableFact]
public async Task ResolveTerminalNameAsync_FollowsRealTwoHopCnameChain()
{
Skip.If(!_fixture.IsCloudflareConfigured,
"CERTINEXT_CF_API_TOKEN / CERTINEXT_CF_ZONE_ID / CERTINEXT_DCV_DOMAIN are required.");

string baseDomain = IntegrationTestData.DcvTestDomain;
string suffix = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
_hopAName = $"_cname-resolver-test-a-{suffix}.{baseDomain}";
_hopBName = $"_cname-resolver-test-b-{suffix}.{baseDomain}";
_hopCName = $"_cname-resolver-test-c-{suffix}.{baseDomain}";

_http = new HttpClient();
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _fixture.CloudflareApiToken);

_hopARecordId = await CreateCnameRecordAsync(_hopAName, _hopBName);
_hopBRecordId = await CreateCnameRecordAsync(_hopBName, _hopCName);

var resolver = new CnameResolver();

// Freshly-created records can take a few seconds to propagate across Cloudflare's
// edge / the resolver's negative-cache TTL, and a not-yet-propagated record reads as
// "no CNAME" (a clean terminal result, not an exception) rather than an error — so
// retry until the expected terminal name is reached, not just on thrown exceptions.
string terminal = null;
Exception lastError = null;
for (int attempt = 0; attempt < 8; attempt++)
{
await Task.Delay(TimeSpan.FromSeconds(3));
try
{
terminal = await resolver.ResolveTerminalNameAsync(_hopAName, CancellationToken.None);
if (string.Equals(terminal.TrimEnd('.'), _hopCName.TrimEnd('.'), StringComparison.OrdinalIgnoreCase))
break;
}
catch (Exception ex)
{
lastError = ex;
}
}

Assert.True(terminal != null, $"Failed to resolve after retries: {lastError}");
Assert.Equal(_hopCName.TrimEnd('.'), terminal.TrimEnd('.'), ignoreCase: true);
}

/// <summary>
/// A name with no CNAME record at all (the DCV domain apex, which is expected to carry
/// ordinary A/AAAA/TXT records for the existing DCV integration tests, not a CNAME) must
/// resolve to itself unchanged — confirms the "terminal on first hop" path against real
/// DNS, not just the fake-delegate unit tests.
/// </summary>
[SkippableFact]
public async Task ResolveTerminalNameAsync_NoCname_ReturnsInputUnchanged()
{
Skip.If(!_fixture.IsCloudflareConfigured,
"CERTINEXT_CF_API_TOKEN / CERTINEXT_CF_ZONE_ID / CERTINEXT_DCV_DOMAIN are required.");

string baseDomain = IntegrationTestData.DcvTestDomain;
var resolver = new CnameResolver();

string terminal = await resolver.ResolveTerminalNameAsync(baseDomain, CancellationToken.None);

Assert.Equal(baseDomain.TrimEnd('.'), terminal.TrimEnd('.'), ignoreCase: true);
}
}
}
6 changes: 3 additions & 3 deletions CERTInext.IntegrationTests/INTEGRATION_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ The file is parsed line by line:
dotnet test CERTInext.IntegrationTests/ --verbosity normal
```

### Using the Makefile
### Using the justfile

```sh
make integration-test
just integration-test
```

### From the solution root (all tests including unit tests)
Expand Down Expand Up @@ -160,4 +160,4 @@ never transmitted over the wire — only the derived `authKey` hash is sent.
| All tests skipped | Missing or empty `~/.env_certinext` | Create the file with required variables |
| `Ping` fails with 401 | Wrong `CERTINEXT_ACCESS_KEY` | Regenerate the key in the CERTInext portal |
| `Ping` fails with timeout | Wrong `CERTINEXT_API_URL` | Verify the URL matches your account region |
| `GetOrderReport` returns 0 orders | Account has no orders | Place a test order first (see `make generate-order` in the project Makefile) |
| `GetOrderReport` returns 0 orders | Account has no orders | Place a test order first (see `just generate-order` in the project justfile) |
14 changes: 7 additions & 7 deletions CERTInext.IntegrationTests/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Key findings verified against sandbox account `9374221333` in April 2026:
To discover the valid product codes for a new account, use:

```sh
make probe-products
just probe-products
```

This places `saveAndHold=1` draft orders for all known SSL/TLS product codes and reports
Expand Down Expand Up @@ -73,7 +73,7 @@ CERTINEXT_REQUESTOR_MOBILE=0000000000
| `CERTINEXT_ACCOUNT_NUMBER` | Yes | Your CERTInext account number (numeric string) |
| `CERTINEXT_GROUP_NUMBER` | No | Group number for order placement, filtering, and `GetProductDetails`. Required on some sandbox accounts for `GetProductDetails` to return a non-empty list. |
| `CERTINEXT_ORG_NUMBER` | No | Organization number for OV/EV order placement |
| `CERTINEXT_PRODUCT_CODE` | Yes | Numeric product code for the target account. **This is per-account** — obtain the correct code for your account by calling `GetProductDetails` (or `make probe-products`). Default shown is for sandbox account `9374221333`. |
| `CERTINEXT_PRODUCT_CODE` | Yes | Numeric product code for the target account. **This is per-account** — obtain the correct code for your account by calling `GetProductDetails` (or `just probe-products`). Default shown is for sandbox account `9374221333`. |
| `CERTINEXT_REQUESTOR_EMAIL` | Yes | Email submitted with test orders — must be registered in the account |
| `CERTINEXT_REQUESTOR_NAME` | Yes | Name submitted with test orders |
| `CERTINEXT_REQUESTOR_MOBILE` | No | Mobile number submitted with test orders |
Expand Down Expand Up @@ -258,16 +258,16 @@ never transmitted over the wire — only the derived `authKey` hash is sent.

When setting up a brand-new CERTInext sandbox account to run integration tests:

1. **Discover valid product codes** — run `make probe-products` from the repo root. This places
1. **Discover valid product codes** — run `just probe-products` from the repo root. This places
`saveAndHold=1` draft orders for all known SSL/TLS product codes and reports which ones your
account accepts. Use the first DV SSL code that returns a `requestNumber` as your
`CERTINEXT_PRODUCT_CODE`.

2. **Set `CERTINEXT_GROUP_NUMBER`** — if `make probe-products` or `GetProductDetails` returns no
2. **Set `CERTINEXT_GROUP_NUMBER`** — if `just probe-products` or `GetProductDetails` returns no
products, find your group number in the CERTInext portal under **Delegation → Groups** and add
it to `~/.env_certinext`. The `GetProductDetails` API requires it on some accounts.

3. **Run connectivity tests first** — `make integration-test` or
3. **Run connectivity tests first** — `just integration-test` or
`dotnet test CERTInext.IntegrationTests/ -v normal`. The `ConnectivityTests` class verifies
credentials. The `LifecycleTests` class places real orders — it can be run even before any
orders exist.
Expand All @@ -293,10 +293,10 @@ When setting up a brand-new CERTInext sandbox account to run integration tests:
| All tests skipped | Missing or empty `~/.env_certinext` | Create the file with `CERTINEXT_API_URL` and `CERTINEXT_ACCESS_KEY` |
| `Ping` fails with 401/403 | Wrong `CERTINEXT_ACCESS_KEY` | Regenerate the key in the CERTInext portal under Integrations → APIs |
| `Ping` fails with timeout or 404 | Wrong `CERTINEXT_API_URL` | Verify the URL matches your account region (see API URL table above) |
| `Enroll` fails with "Invalid Product Code" (EMS-1162) | Wrong `CERTINEXT_PRODUCT_CODE` | Run `make probe-products` to discover the codes provisioned for your account |
| `Enroll` fails with "Invalid Product Code" (EMS-1162) | Wrong `CERTINEXT_PRODUCT_CODE` | Run `just probe-products` to discover the codes provisioned for your account |
| `GetProductDetails` returns empty list | `CERTINEXT_GROUP_NUMBER` not set | Add your group number to `~/.env_certinext`; some accounts require it for `GetProductDetails` to return results |
| `Enroll` fails with "Additional Information cannot be empty" (EMS-918) | Old plugin version missing `additionalInformation.remarks` | Rebuild and redeploy the plugin — the `remarks` field is now populated automatically |
| `Enroll` fails with "Invalid Organization Number" (EMS-1073) | OV/EV product code selected with an unregistered org | Use a DV SSL product code for automated tests, or register and approve your org in CERTInext first |
| Revoke step skips with "not GENERATED" | Sandbox DV SSL order requires domain validation and RA approval | Expected behavior for public DV SSL in sandbox — log in to the CERTInext portal and approve the pending order, then re-run; or use a private PKI product that auto-approves |
| `OrderReportTests` all skip | Fresh account with no orders | Run `LifecycleTests` first to place at least one order |
| `ProductTests` asserts configured product code is not found | `CERTINEXT_PRODUCT_CODE` set to a code not provisioned for the account | Run `make probe-products` and update `CERTINEXT_PRODUCT_CODE` with a valid code |
| `ProductTests` asserts configured product code is not found | `CERTINEXT_PRODUCT_CODE` set to a code not provisioned for the account | Run `just probe-products` and update `CERTINEXT_PRODUCT_CODE` with a valid code |
22 changes: 13 additions & 9 deletions CERTInext.Tests/CERTInext.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>warnings</Nullable>
<LangVersion>12.0</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Mirror the main project's DcvSupport flag. Default false → DCV test files are excluded
(matches the GA no-DCV build); -p:DcvSupport=true compiles them in with SUPPORTS_DCV. -->
<DcvSupport Condition="'$(DcvSupport)' == ''">false</DcvSupport>
<!-- Mirror the main project's DcvSupport flag. Default true → DCV test files are compiled in
(matches the default DCV build); -p:DcvSupport=false excludes them (GA no-DCV build). -->
<DcvSupport Condition="'$(DcvSupport)' == ''">true</DcvSupport>
<DefineConstants Condition="'$(DcvSupport)' == 'true'">$(DefineConstants);SUPPORTS_DCV</DefineConstants>
</PropertyGroup>

Expand All @@ -33,11 +33,15 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="WireMock.Net" Version="1.6.2" />
<PackageReference Include="WireMock.Net" Version="1.25.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<!-- Suppress TFM support build warnings for transitive dependencies -->
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
<PackageReference Include="System.IO.Pipelines" Version="8.0.0" />
<PackageReference Include="System.Text.Encodings.Web" Version="8.0.0" />
<!-- Explicit override of transitive dependencies pulled in by WireMock.Net 1.25.0's
templating engine (Scriban.Signed, System.Linq.Dynamic.Core) and telemetry
support (OpenTelemetry.*). All carried known CVEs (NU1902-1904) at their
WireMock-resolved versions; pinned to patched versions here. -->
<PackageReference Include="Scriban.Signed" Version="7.2.5" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.7.3" />
<PackageReference Include="OpenTelemetry.Api" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
</ItemGroup>
</Project>
Loading