Skip to content

Commit fd6c432

Browse files
authored
feat(dcv): DNS domain control validation via IDomainValidatorFactory (#5)
* fix: P1-P3 improvements — OAuth auth, sync CompleteAdding, Ping enabled check, renewal window, retry logic, IDisposable, GroupNumber config, nested product response model * test: add unit tests for P1-P3 fixes; update MockCertificateData to nested product response format * test: rewrite integration tests — remove stale hardcoded-order tests, add lifecycle test, make empty-account resilient * docs: add GroupNumber field, per-account product code note, AgreementAcceptance and DCV findings * chore: refactor Makefile — extract all API targets into scripts/; add generate-order-149-fresh, probe-endpoints, get-field-details targets * docs: add cross-plugin analysis, certinext improvement plan, and API findings from sandbox exploration * chore: add V2 API Makefile targets and scripts; ignore analysis/ directory Adds 21 make targets covering every CERTInext V2 operation (ssl-certificates, private-pki-certificates, catalog, groups, orgs, domains, reports). Each target delegates to a corresponding script under scripts/v2/ which sources the new scripts/lib/certinext-v2-auth.sh for CERTInext-native SHA256 token exchange. Adds analysis/ to .gitignore so scratch docs and support emails are never committed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(constants): add Dcv constants and Config DCV key names Add Constants.Dcv subclass with dcvMethod codes (1=DNS TXT, 2=HTTP, 3=Email), dcvStatus values (0=Pending, 1=Validated, 2=Rejected), and the default TXT record hostname template. Add DcvEnabled, DcvTxtRecordTemplate, and DcvPropagationDelaySeconds to Constants.Config. * feat(api): add GetDcv and VerifyDcv request/response DTOs Add GetDcvRequest, DcvRequestDetails, and VerifyDcvRequest for the GetDcv/VerifyDcv endpoints. Add GetDcvResponse, DcvResponseDetails, VerifyDcvResponse, TrackOrderDomainVerification (with JsonExtensionData for heterogeneous per-domain entries), and DomainVerificationDetail. Wire DomainVerification onto TrackOrderResponseDetails. * feat(client): add GetDcvAsync and VerifyDcvAsync GetDcvAsync posts to GetDcv and returns the token (and file/email fields) for a domain on an existing order. VerifyDcvAsync posts to VerifyDcv to ask CERTInext to check the published DNS TXT record. Both methods follow the existing pattern: BuildMetaAsync, retry, auth-failure detection, DeserializeOrThrow, meta.status check, structured logging with OrderNumber and Domain context. * feat(config): add DcvEnabled, DcvTxtRecordTemplate, DcvPropagationDelaySeconds Add three DCV-related fields to CERTInextConfig with documented defaults (false, _emsign-validation.{0}, 30 s) and corresponding UI annotations in GetCAConnectorAnnotations. Guards the DNS DCV path so operators must explicitly opt in before any DNS plugin interaction occurs. * feat(enroll): inject IDomainValidatorFactory; add DNS DCV orchestration Bump IAnyCAPlugin to 3.3.0-PRERELEASE-78770-979f582005 to gain access to IDomainValidatorFactory, IDomainValidator, and IDomainValidatorConfigProvider. Add a primary constructor accepting IDomainValidatorFactory (gateway injects this at startup) alongside the existing parameterless fallback. Add DomainValidatorConfigProvider inner class. Add PerformDcvIfNeededAsync: reads pending-DCV domains from TrackOrder, skips if the order is already issued, validates domain FQDNs, calls GetDcvAsync per domain, resolves the DNS plugin via ResolveDomainValidator(domain, 'dns-01'), stages the TXT record, waits for propagation, triggers VerifyDcv, then cleans up in a finally block. EnrollNewAsync calls this when DcvEnabled=true and the factory is present, then re-fetches the post-DCV certificate status before returning. * test(client): add WireMock unit tests for GetDcvAsync and VerifyDcvAsync Add GetDcvSuccessJson, GetDcvFailureJson, VerifyDcvSuccessJson, and VerifyDcvFailureJson helpers to MockCertificateData. Add seven tests covering: successful token retrieval, meta-failure response, 401 authentication failure, successful verification, meta-failure on verify, 401 on verify, and 500 on verify. * chore(scripts): add get-dcv/verify-dcv probe scripts and Makefile targets Add scripts/get-dcv.sh and scripts/verify-dcv.sh mirroring the track-order.sh pattern. Both scripts source ~/.env_certinext and certinext-auth.sh, accept ORDER_NUMBER, DOMAIN_NAME, and optional DCV_METHOD (default 1=DNS TXT), and use jq --arg for safe JSON construction to prevent injection via user-supplied values. Add get-dcv and verify-dcv Makefile targets with DCV_METHOD variable and register both in .PHONY.
1 parent 05395cd commit fd6c432

36 files changed

Lines changed: 1857 additions & 5 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ terraform/terraform.tfvars
3333

3434
# macOS
3535
.DS_Store
36+
37+
# Analysis / scratch — never commit
38+
analysis/
39+

CERTInext.Tests/CERTInextClientTests.cs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,5 +744,145 @@ public async Task ExecuteWithRetry_MakesThreeAttempts_WhenServerAlwaysReturns500
744744
pingCallCount.Should().Be(3,
745745
"ExecuteWithRetryAsync makes 3 total attempts on persistent 5xx errors");
746746
}
747+
748+
// ---------------------------------------------------------------------------
749+
// GetDcvAsync — POST /GetDcv
750+
// ---------------------------------------------------------------------------
751+
752+
[Fact]
753+
public async Task GetDcvAsync_ReturnsToken_WhenServerRespondsOk()
754+
{
755+
const string token = "abc123token";
756+
_server
757+
.Given(Request.Create().WithPath("/GetDcv").UsingPost())
758+
.RespondWith(Response.Create()
759+
.WithStatusCode(200)
760+
.WithHeader("Content-Type", "application/json")
761+
.WithBody(MockCertificateData.GetDcvSuccessJson(token)));
762+
763+
var client = BuildClient();
764+
765+
var result = await client.GetDcvAsync(
766+
MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
767+
768+
result.Should().NotBeNull();
769+
result.DcvDetails.Should().NotBeNull();
770+
result.DcvDetails.Token.Should().Be(token);
771+
_server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/GetDcv");
772+
}
773+
774+
[Fact]
775+
public async Task GetDcvAsync_Throws_WhenMetaStatusIsFailure()
776+
{
777+
_server
778+
.Given(Request.Create().WithPath("/GetDcv").UsingPost())
779+
.RespondWith(Response.Create()
780+
.WithStatusCode(200)
781+
.WithHeader("Content-Type", "application/json")
782+
.WithBody(MockCertificateData.GetDcvFailureJson("EMS-DCV-001", "DCV not available")));
783+
784+
var client = BuildClient();
785+
786+
Func<Task> act = () => client.GetDcvAsync(
787+
MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
788+
789+
await act.Should().ThrowAsync<Exception>()
790+
.WithMessage("*GetDcv failed*");
791+
}
792+
793+
[Fact]
794+
public async Task GetDcvAsync_Throws_WhenServerReturns401()
795+
{
796+
_server
797+
.Given(Request.Create().WithPath("/GetDcv").UsingPost())
798+
.RespondWith(Response.Create()
799+
.WithStatusCode(401)
800+
.WithBody(MockCertificateData.UnauthorizedJson()));
801+
802+
var client = BuildClient();
803+
804+
Func<Task> act = () => client.GetDcvAsync(
805+
MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
806+
807+
await act.Should().ThrowAsync<Exception>()
808+
.WithMessage("*Authentication failure*");
809+
}
810+
811+
// ---------------------------------------------------------------------------
812+
// VerifyDcvAsync — POST /VerifyDcv
813+
// ---------------------------------------------------------------------------
814+
815+
[Fact]
816+
public async Task VerifyDcvAsync_Succeeds_WhenServerRespondsOk()
817+
{
818+
_server
819+
.Given(Request.Create().WithPath("/VerifyDcv").UsingPost())
820+
.RespondWith(Response.Create()
821+
.WithStatusCode(200)
822+
.WithHeader("Content-Type", "application/json")
823+
.WithBody(MockCertificateData.VerifyDcvSuccessJson()));
824+
825+
var client = BuildClient();
826+
827+
// Should not throw
828+
await client.VerifyDcvAsync(
829+
MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
830+
831+
_server.LogEntries.Should().Contain(e => e.RequestMessage.Path == "/VerifyDcv");
832+
}
833+
834+
[Fact]
835+
public async Task VerifyDcvAsync_Throws_WhenMetaStatusIsFailure()
836+
{
837+
_server
838+
.Given(Request.Create().WithPath("/VerifyDcv").UsingPost())
839+
.RespondWith(Response.Create()
840+
.WithStatusCode(200)
841+
.WithHeader("Content-Type", "application/json")
842+
.WithBody(MockCertificateData.VerifyDcvFailureJson("EMS-DCV-002", "DNS record not found")));
843+
844+
var client = BuildClient();
845+
846+
Func<Task> act = () => client.VerifyDcvAsync(
847+
MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
848+
849+
await act.Should().ThrowAsync<Exception>()
850+
.WithMessage("*DNS record not found*");
851+
}
852+
853+
[Fact]
854+
public async Task VerifyDcvAsync_Throws_WhenServerReturns401()
855+
{
856+
_server
857+
.Given(Request.Create().WithPath("/VerifyDcv").UsingPost())
858+
.RespondWith(Response.Create()
859+
.WithStatusCode(401)
860+
.WithBody(MockCertificateData.UnauthorizedJson()));
861+
862+
var client = BuildClient();
863+
864+
Func<Task> act = () => client.VerifyDcvAsync(
865+
MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
866+
867+
await act.Should().ThrowAsync<Exception>()
868+
.WithMessage("*Authentication failure*");
869+
}
870+
871+
[Fact]
872+
public async Task VerifyDcvAsync_Throws_WhenServerReturns500()
873+
{
874+
_server
875+
.Given(Request.Create().WithPath("/VerifyDcv").UsingPost())
876+
.RespondWith(Response.Create()
877+
.WithStatusCode(500)
878+
.WithBody(MockCertificateData.ServerErrorJson()));
879+
880+
var client = BuildClient();
881+
882+
Func<Task> act = () => client.VerifyDcvAsync(
883+
MockCertificateData.OrderNumber1, "example.com", Constants.Dcv.MethodDnsTxt);
884+
885+
await act.Should().ThrowAsync<Exception>();
886+
}
747887
}
748888
}

CERTInext.Tests/MockCertificateData.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,42 @@ public static LegacyGetCertificateResponse RevokedCertRecord(string id = null) =
334334
public static string OAuth2TokenJson(int expiresIn = 3600) =>
335335
$@"{{""access_token"":""fake-bearer-token-abc123"",""token_type"":""Bearer"",""expires_in"":{expiresIn}}}";
336336

337+
// -----------------------------------------------------------------------
338+
// DCV (domain control validation)
339+
// -----------------------------------------------------------------------
340+
341+
/// <summary>
342+
/// POST /GetDcv — success response containing the TXT record token for DNS DCV.
343+
/// </summary>
344+
public static string GetDcvSuccessJson(string token = "abc123token") =>
345+
$@"{{
346+
""meta"":{SuccessMetaJson()},
347+
""dcvDetails"":{{
348+
""token"":""{token}"",
349+
""fileName"":null,
350+
""fileContent"":null,
351+
""dcvEmails"":null
352+
}}
353+
}}";
354+
355+
/// <summary>
356+
/// POST /GetDcv — failure response (bad order or unsupported dcvMethod).
357+
/// </summary>
358+
public static string GetDcvFailureJson(string code = "EMS-DCV-001", string msg = "DCV not available for this order") =>
359+
$@"{{""meta"":{FailureMetaJson(code, msg)}}}";
360+
361+
/// <summary>
362+
/// POST /VerifyDcv — success response (meta only, no additional payload).
363+
/// </summary>
364+
public static string VerifyDcvSuccessJson() =>
365+
$@"{{""meta"":{SuccessMetaJson()}}}";
366+
367+
/// <summary>
368+
/// POST /VerifyDcv — failure response (TXT record not found).
369+
/// </summary>
370+
public static string VerifyDcvFailureJson(string code = "EMS-DCV-002", string msg = "DNS record not found") =>
371+
$@"{{""meta"":{FailureMetaJson(code, msg)}}}";
372+
337373
// -----------------------------------------------------------------------
338374
// Error responses
339375
// -----------------------------------------------------------------------

CERTInext/API/CertificateRequest.cs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,87 @@ public class TrackOrderDetails
288288
public string OrderNumber { get; set; }
289289
}
290290

291+
// ---------------------------------------------------------------------------
292+
// GetDcv — POST {baseURL}GetDcv
293+
// Retrieves Domain Control Validation token / file content / approver emails
294+
// for a given (orderNumber, domainName, dcvMethod) tuple.
295+
//
296+
// The CERTInext V1 spec defines this body as wrapped in a "dcvDetails" block.
297+
// Note: the Postman example for GetDcv uses "orderDetails" instead — this is
298+
// an example typo; the inline spec, the response body, and the VerifyDcv body
299+
// all use "dcvDetails" consistently.
300+
// ---------------------------------------------------------------------------
301+
302+
/// <summary>
303+
/// Request body for POST {baseURL}GetDcv.
304+
/// Returns DCV instructions (token / file / approver emails) for one domain
305+
/// in the given order.
306+
/// </summary>
307+
public class GetDcvRequest
308+
{
309+
[JsonPropertyName("meta")]
310+
public RequestMeta Meta { get; set; }
311+
312+
[JsonPropertyName("dcvDetails")]
313+
public DcvRequestDetails DcvDetails { get; set; }
314+
}
315+
316+
/// <summary>
317+
/// Common request body for both GetDcv and VerifyDcv — both endpoints take the
318+
/// same set of identification fields. <see cref="DcvEmail"/> is only set on
319+
/// VerifyDcv requests when <see cref="DcvMethod"/> = email (3).
320+
/// </summary>
321+
public class DcvRequestDetails
322+
{
323+
/// <summary>Registered requestor email associated with the order.</summary>
324+
[JsonPropertyName("requestorEmail")]
325+
public string RequestorEmail { get; set; }
326+
327+
/// <summary>Order number returned by GenerateOrderSSL.</summary>
328+
[JsonPropertyName("orderNumber")]
329+
public string OrderNumber { get; set; }
330+
331+
/// <summary>Domain to retrieve / verify DCV for.</summary>
332+
[JsonPropertyName("domainName")]
333+
public string DomainName { get; set; }
334+
335+
/// <summary>
336+
/// DCV method (numeric string per CERTInext V1 spec):
337+
/// "1" = DNS TXT record, "2" = HTTP file, "3" = email approver.
338+
/// See <see cref="Constants.Dcv"/>.
339+
/// </summary>
340+
[JsonPropertyName("dcvMethod")]
341+
public string DcvMethod { get; set; }
342+
343+
/// <summary>
344+
/// Approver email address. Required (and only used) on VerifyDcv when
345+
/// <see cref="DcvMethod"/> is "3" (email). Must be one of the
346+
/// <c>dcvEmails</c> returned by GetDcv.
347+
/// </summary>
348+
[JsonPropertyName("dcvEmail")]
349+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
350+
public string DcvEmail { get; set; }
351+
}
352+
353+
// ---------------------------------------------------------------------------
354+
// VerifyDcv — POST {baseURL}VerifyDcv
355+
// Triggers CERTInext to verify the DCV record placed by the customer.
356+
// ---------------------------------------------------------------------------
357+
358+
/// <summary>
359+
/// Request body for POST {baseURL}VerifyDcv.
360+
/// Tells CERTInext to attempt domain verification using the previously
361+
/// supplied DCV details. Reuses <see cref="DcvRequestDetails"/>.
362+
/// </summary>
363+
public class VerifyDcvRequest
364+
{
365+
[JsonPropertyName("meta")]
366+
public RequestMeta Meta { get; set; }
367+
368+
[JsonPropertyName("dcvDetails")]
369+
public DcvRequestDetails DcvDetails { get; set; }
370+
}
371+
291372
// ---------------------------------------------------------------------------
292373
// GetCertificate — POST {baseURL}GetCertificate
293374
// Downloads the issued certificate for a fulfilled order.

0 commit comments

Comments
 (0)