diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8781cf..cab5c5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,13 @@
v1.2.0
- Added support for CSC TrustedSecure EV, Multiple Names; CSC TrustedSecure OV Wildcard, Multiple Names; and CSC TrustedSecure DV Wildcard, Multiple Names certificate products
- Renamed all certificate template product IDs to match CSC's current certificate type names (e.g. "CSC TrustedSecure Premium Certificate" is now "CSC TrustedSecure OV", "CSC TrustedSecure Domain Validated SSL" is now "CSC TrustedSecure DV"). Existing Certificate Templates in Command using the old names continue to work; new Templates should use the new names.
-- Fixed the "Addtl Sans Comma Separated DCV Emails" enrollment field never actually being read during enrollment, due to a typo in the code looking up "DVC" instead of "DCV". Per-domain DCV emails for additional SANs on unrelated domains were silently ignored, falling back to the primary CN's DCV email - which does not have authority to validate a different domain.
-- Fixed a case-sensitivity bug ("priorcertsn" vs "PriorCertSN") that prevented PriorCertSN from ever being read during Renew/Reissue enrollment.
+- Removed the TemplateSync gateway registration setting. Certificate sync now always maps synced certificates back to their CSC certificate type/template.
+- Added .NET 10 as a supported target framework, alongside .NET 6 and .NET 8.
+- Enrollment/renewal/reissue failures now include the FlowLogger step-by-step summary ahead of the underlying error message in StatusMessage, instead of just a terse error. Also fixed enrollment failures reporting Status 30 (INPROCESS) instead of the correct FAILED status, and an exception thrown mid-enrollment no longer bubbles up as an unhandled error - it's now returned as a proper failed EnrollmentResult with full flow context.
- Fixed a crash when CSC Global returns a null "price.total" (e.g. reissuing a certificate that is not in an active status) - Price.Total is now nullable instead of causing a JSON deserialization exception.
-- Added an xUnit test suite covering certificate type/SAN/EV routing, legacy product name backward compatibility, and the fixes above.
+- Fixed successful Renew and Reissue requests incorrectly reporting Status GENERATED (certificate issued) when CSC Global has only submitted the request and is still waiting on domain control validation. This caused the AnyGateway REST host to crash trying to parse a certificate that doesn't exist yet. Renew/Reissue now report EXTERNALVALIDATION, matching new enrollment, and surface the pending CNAME/email DCV instructions the same way.
+- Fixed the "Addtl Sans Comma Separated DCV Emails" enrollment field never actually being read during enrollment, due to a typo in the code looking up "DVC" instead of "DCV". Per-domain DCV emails for additional SANs on unrelated domains were silently ignored, falling back to the primary CN's DCV email - which does not have authority to validate a different domain.
+- On successful enrollment/renewal/reissue, the FlowLogger step summary is now attached to EnrollmentContext as one entry per step (rendered as separate bullets by Command's enrollment UI, alongside any DCV instructions) instead of one multi-line block, since that UI does not render embedded newlines.
v.1.1.1
- Added Incremental Sync that goes back X Number of days
diff --git a/README.md b/README.md
index 3079352..0250f2c 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-
+
@@ -37,7 +37,7 @@ This integration allows for the Synchronization, Enrollment, and Revocation of c
## Compatibility
-The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.2.0 and later.
+The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later.
## Support
The CSCGlobal CAPlugin AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com.
@@ -54,15 +54,17 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and
2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [CSCGlobal CAPlugin AnyCA Gateway REST plugin](https://github.com/Keyfactor/cscglobal-caplugin/releases/latest) from GitHub.
-3. Copy the unzipped directory (usually called `net10.0`) to the Extensions directory:
+3. Copy the unzipped directory (usually called `net6.0` or `net8.0` or `net10.0`) to the Extensions directory:
```shell
Depending on your AnyCA Gateway REST version, copy the unzipped directory to one of the following locations:
+ Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net6.0\Extensions
+ Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions
Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net10.0\Extensions
```
- > The directory containing the CSCGlobal CAPlugin AnyCA Gateway REST plugin DLLs (`net10.0`) can be named anything, as long as it is unique within the `Extensions` directory.
+ > The directory containing the CSCGlobal CAPlugin AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0` or `net10.0`) can be named anything, as long as it is unique within the `Extensions` directory.
4. Restart the AnyCA Gateway REST service.
@@ -85,8 +87,6 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and
* **BearerToken** - CSCGlobal Bearer Token
* **DefaultPageSize** - Default page size for use with the API. Default is 100
* **SyncFilterDays** - Number of days from today to filter certificates by expiration date during incremental sync.
- * **RenewalWindowDays** - Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30.
- * **DcvPollTimeoutSeconds** - Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests.
2. PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION
@@ -96,16 +96,16 @@ If a field value is specified as both an Enrollment Field in Command and in the
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure Premium Certificate
-Template Display Name | CSC TrustedSecure Premium Certificate
-Friendly Name | CSC TrustedSecure Premium Certificate
+Template Short Name | CSC TrustedSecure OV
+Template Display Name | CSC TrustedSecure OV
+Friendly Name | CSC TrustedSecure OV
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
-**CSC TrustedSecure Premium Certificate - Enrollment Fields**
+**CSC TrustedSecure OV - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
@@ -120,20 +120,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients
Notification Email(s) Comma Separated | String | N/A
CN DCV Email | String | N/A
-**CSC TrustedSecure EV Certificate - Details Tab**
+**CSC TrustedSecure OV Wildcard - Details Tab**
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure EV Certificate
-Template Display Name | CSC TrustedSecure EV Certificate
-Friendly Name | CSC TrustedSecure EV Certificate
+Template Short Name | CSC TrustedSecure OV Wildcard
+Template Display Name | CSC TrustedSecure OV Wildcard
+Friendly Name | CSC TrustedSecure OV Wildcard
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
-**CSC TrustedSecure EV Certificate - Enrollment Fields**
+**CSC TrustedSecure OV Wildcard - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
@@ -147,22 +147,21 @@ Organization Contact | Multiple Choice | Get From CSC Differs For Clients
Business Unit | Multiple Choice | Get From CSC Differs For Clients
Notification Email(s) Comma Separated | String | N/A
CN DCV Email | String | N/A
-Organization Country | String | N/A
-**CSC TrustedSecure UC Certificate - Details Tab**
+**CSC TrustedSecure OV, Multiple Names - Details Tab**
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure UC Certificate
-Template Display Name | CSC TrustedSecure UC Certificate
-Friendly Name | CSC TrustedSecure UC Certificate
+Template Short Name | CSC TrustedSecure OV, Multiple Names
+Template Display Name | CSC TrustedSecure OV, Multiple Names
+Friendly Name | CSC TrustedSecure OV, Multiple Names
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
-**CSC TrustedSecure UC Certificate - Enrollment Fields**
+**CSC TrustedSecure OV, Multiple Names - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
@@ -177,22 +176,21 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients
Notification Email(s) Comma Separated | String | N/A
CN DCV Email | String | N/A
Addtl Sans Comma Separated DCV Emails | String | N/A
-
-**CSC TrustedSecure Premium Wildcard Certificate - Details Tab**
+**CSC TrustedSecure EV - Details Tab**
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure Premium Wildcard Certificate
-Template Display Name | CSC TrustedSecure Premium Wildcard Certificate
-Friendly Name | CSC TrustedSecure Premium Wildcard Certificate
+Template Short Name | CSC TrustedSecure EV
+Template Display Name | CSC TrustedSecure EV
+Friendly Name | CSC TrustedSecure EV
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
-**CSC TrustedSecure Premium Wildcard Certificate - Enrollment Fields**
+**CSC TrustedSecure EV - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
@@ -206,21 +204,22 @@ Organization Contact | Multiple Choice | Get From CSC Differs For Clients
Business Unit | Multiple Choice | Get From CSC Differs For Clients
Notification Email(s) Comma Separated | String | N/A
CN DCV Email | String | N/A
+Organization Country | String | N/A
-**CSC TrustedSecure Domain Validated SSL - Details Tab**
+**CSC TrustedSecure DV - Details Tab**
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure Domain Validated SSL
-Template Display Name | CSC TrustedSecure Domain Validated SSL
-Friendly Name | CSC TrustedSecure Domain Validated SSL
+Template Short Name | CSC TrustedSecure DV
+Template Display Name | CSC TrustedSecure DV
+Friendly Name | CSC TrustedSecure DV
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
- **CSC TrustedSecure Domain Validated SSL - Enrollment Fields**
+**CSC TrustedSecure DV - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
@@ -235,20 +234,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients
Notification Email(s) Comma Separated | String | N/A
CN DCV Email | String | N/A
-**CSC TrustedSecure Domain Validated Wildcard SSL - Details Tab**
+**CSC TrustedSecure DV Wildcard - Details Tab**
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure Domain Validated Wildcard SSL
-Template Display Name | CSC TrustedSecure Domain Validated Wildcard SSL
-Friendly Name | CSC TrustedSecure Domain Validated Wildcard SSL
+Template Short Name | CSC TrustedSecure DV Wildcard
+Template Display Name | CSC TrustedSecure DV Wildcard
+Friendly Name | CSC TrustedSecure DV Wildcard
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
-**CSC TrustedSecure Domain Validated Wildcard SSL - Enrollment Fields**
+**CSC TrustedSecure DV Wildcard - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
@@ -263,20 +262,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients
Notification Email(s) Comma Separated | String | N/A
CN DCV Email | String | N/A
-**CSC TrustedSecure Domain Validated UC Certificate - Details Tab**
+**CSC TrustedSecure DV, Multiple Names - Details Tab**
CONFIG ELEMENT | DESCRIPTION
----------------------------|------------------
-Template Short Name | CSC TrustedSecure Domain Validated UC Certificate
-Template Display Name | CSC TrustedSecure Domain Validated UC Certificate
-Friendly Name | CSC TrustedSecure Domain Validated UC Certificate
+Template Short Name | CSC TrustedSecure DV, Multiple Names
+Template Display Name | CSC TrustedSecure DV, Multiple Names
+Friendly Name | CSC TrustedSecure DV, Multiple Names
Keys Size | 2048
Enforce RFC 2818 Compliance | True
CSR Enrollment | True
Pfx Enrollment | True
-**CSC TrustedSecure Domain Validated UC Certificate - Enrollment Fields**
+**CSC TrustedSecure DV, Multiple Names - Enrollment Fields**
NAME | DATA TYPE | VALUES
-----|--------------|-----------------
@@ -292,131 +291,110 @@ Notification Email(s) Comma Separated | String | N/A
CN DCV Email | String | N/A
Addtl Sans Comma Separated DCV Emails | String | N/A
-3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates.
-
-4. In Keyfactor Command (v12.3+), for each imported Certificate Template, follow the [official documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Configuring%20Template%20Options.htm) to define enrollment fields for each of the following parameters:
-
- * **Term** - OPTIONAL: Certificate term (e.g. 12 or 24 months)
- * **Applicant First Name** - OPTIONAL: Applicant First Name
- * **Applicant Last Name** - OPTIONAL: Applicant Last Name
- * **Applicant Email Address** - OPTIONAL: Applicant Email Address
- * **Applicant Phone** - OPTIONAL: Applicant Phone (+nn.nnnnnnnn)
- * **Domain Control Validation Method** - OPTIONAL: Domain Control Validation Method (e.g. EMAIL)
- * **Organization Contact** - OPTIONAL: Organization Contact (selected from CSC configuration)
- * **Business Unit** - OPTIONAL: Business Unit (selected from CSC configuration)
- * **Notification Email(s) Comma Separated** - OPTIONAL: Notification Email(s), comma separated
- * **CN DCV Email** - OPTIONAL: CN DCV Email (e.g. admin@yourdomain.com)
- * **Organization Country** - OPTIONAL: Organization Country
- * **Addtl Sans Comma Separated DCV Emails** - OPTIONAL: Additional SANs DCV Emails, comma separated
-
-## CA Connection Configuration
-
-When defining the Certificate Authority in the AnyCA Gateway REST portal, configure the following fields on the **CA Connection** tab:
-
-CONFIG ELEMENT | DESCRIPTION | DEFAULT
----------------|-------------|--------
-Enabled | Flag to Enable or Disable gateway functionality. Set to `false` to allow creating the CA record before configuration information is available; the plugin then short-circuits Ping, Sync, Enroll, and Revoke with a warning until it is re-enabled. | `true`
-CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required)
-ApiKey | Your CSCGlobal API key | (required)
-BearerToken | Your CSCGlobal Bearer token for authentication | (required)
-DefaultPageSize | Page size for API list requests | 100
-SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5
-RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30
-DcvPollTimeoutSeconds | Max seconds to synchronously poll CSC for certificate issuance after submitting an order. `0` disables polling (enrollment returns pending immediately; cert arrives on the next sync). When `>0`, fast-validating orders can return the issued cert directly in the enrollment response. See [Synchronous Issuance Polling](#synchronous-issuance-polling) below. | 0
-
-> **Note:** DNS auto-publishing for CNAME DCV is handled by the AnyCA Gateway REST framework's Domain Validation system (gateway 3.3+). It's configured in the gateway UI under **Domain Validation Configurations**, not on the CA Connection tab. See [DNS Auto-Publishing (CNAME DCV)](#dns-auto-publishing-cname-dcv).
-
-## Renewal vs. Reissue Logic
-
-CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order).
-
-The decision is based on the **RenewalWindowDays** setting and works as follows:
-
-1. The plugin fetches the original certificate from CSC and reads its `orderDate`.
-2. It computes the **order expiry** as `orderDate + 1 year`.
-3. It calculates **days remaining** until the order expires.
-4. If `days remaining <= RenewalWindowDays`, the request is treated as a **Renewal** (new paid order).
-5. If `days remaining > RenewalWindowDays`, the request is treated as a **Reissue** (free under the active order).
-
-**Example with default RenewalWindowDays = 30:**
+**CSC TrustedSecure EV, Multiple Names - Details Tab**
-```
-Order Date: 2025-04-08
-Order Expiry: 2026-04-08
-Today: 2026-03-15
-Days Left: 24
-
-24 <= 30 --> RENEWAL (new paid order)
-```
-
-```
-Order Date: 2025-04-08
-Order Expiry: 2026-04-08
-Today: 2025-09-01
-Days Left: 219
-
-219 > 30 --> REISSUE (free under active order)
-```
-
-**Fallback behavior:** If the plugin cannot retrieve the `orderDate` from CSC (e.g., API error or missing field), it falls back to checking the certificate's expiration date. If the certificate is already expired, it treats the request as a Renewal.
-
-**Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle.
-
-## DNS Auto-Publishing (CNAME DCV)
-
-CSC supports two Domain Control Validation (DCV) methods: **EMAIL** and **CNAME**. With CNAME validation, CSC returns a CNAME record (name → target) that must exist in DNS before they will validate the order.
-
-By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, the plugin uses the **AnyCA Gateway REST framework's built-in DNS provider system** (available in framework 3.3 and later). The framework discovers DNS provider plugins deployed alongside the CA plugin and routes each CNAME to whichever provider claims the matching DNS zone.
+CONFIG ELEMENT | DESCRIPTION
+----------------------------|------------------
+Template Short Name | CSC TrustedSecure EV, Multiple Names
+Template Display Name | CSC TrustedSecure EV, Multiple Names
+Friendly Name | CSC TrustedSecure EV, Multiple Names
+Keys Size | 2048
+Enforce RFC 2818 Compliance | True
+CSR Enrollment | True
+Pfx Enrollment | True
-### Requirements
-* AnyCA Gateway REST framework **3.3 or later** (the `IDomainValidatorFactory` interface ships in `Keyfactor.AnyGateway.IAnyCAPlugin` 3.3+).
-* At least one DNS provider DLL (e.g. GoDaddy, Cloudflare, Route 53, Azure) deployed in the gateway `Extensions` folder.
-* A Domain Validation Configuration registered in the gateway UI that maps your domain(s) to the deployed provider (for example, `*.example.com` → GoDaddy).
+**CSC TrustedSecure EV, Multiple Names - Enrollment Fields**
-### How It Works
+NAME | DATA TYPE | VALUES
+-----|--------------|-----------------
+Term | Multiple Choice | 12,24
+Applicant First Name | String | N/A
+Applicant Last Name | String | N/A
+Applicant Email Address | String | N/A
+Applicant Phone | String | N/A
+Domain Control Validation Method | Multiple Choice | EMAIL
+Organization Contact | Multiple Choice | Get From CSC Differs For Clients
+Business Unit | Multiple Choice | Get From CSC Differs For Clients
+Notification Email(s) Comma Separated | String | N/A
+CN DCV Email | String | N/A
+Organization Country | String | N/A
+Addtl Sans Comma Separated DCV Emails | String | N/A
-1. CSC returns the CNAME `name → target` details in the enrollment response.
-2. For each CNAME entry, the plugin calls `IDomainValidatorFactory.ResolveDomainValidator(recordName, "cname")`.
-3. The framework returns the `IDomainValidator` whose Domain Validation Configuration matches the record's zone (or `null` if no match).
-4. The plugin calls `validator.StageValidation(recordName, cnameTarget, ct)` to publish the record.
-5. CSC asynchronously validates the CNAME; the issued certificate appears on the next sync.
+**CSC TrustedSecure OV Wildcard, Multiple Names - Details Tab**
-### Behavior
+CONFIG ELEMENT | DESCRIPTION
+----------------------------|------------------
+Template Short Name | CSC TrustedSecure OV Wildcard, Multiple Names
+Template Display Name | CSC TrustedSecure OV Wildcard, Multiple Names
+Friendly Name | CSC TrustedSecure OV Wildcard, Multiple Names
+Keys Size | 2048
+Enforce RFC 2818 Compliance | True
+CSR Enrollment | True
+Pfx Enrollment | True
-* **Resolution is per record, not per CA.** One CA can drive multiple DNS providers (GoDaddy for some domains, Route 53 for others) with no per-CA configuration.
-* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs.
-* **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback.
-* **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level.
-* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. CSC's DCV requires a **CNAME** record, which is different from ACME's `"dns-01"` challenge (a TXT record). A single DNS provider DLL can ship multiple validator classes — one advertising `"dns-01"` (publishes TXT, for ACME) and one advertising `"cname"` (publishes CNAME, for CSC). You must deploy and configure a validator that advertises `"cname"` or no provider will match.
-* **Trailing dots normalized.** CSC returns FQDN-canonical names with a trailing dot (e.g. `_token.example.com.`). The plugin strips the trailing dot before resolution and publishing, because Domain Validation Configurations and DNS provider APIs expect names without it.
-### Configuration in the Gateway UI
+**CSC TrustedSecure OV Wildcard, Multiple Names - Enrollment Fields**
-In the AnyCA Gateway REST portal, under **Domain Validation Configurations**:
+NAME | DATA TYPE | VALUES
+-----|--------------|-----------------
+Term | Multiple Choice | 12,24
+Applicant First Name | String | N/A
+Applicant Last Name | String | N/A
+Applicant Email Address | String | N/A
+Applicant Phone | String | N/A
+Domain Control Validation Method | Multiple Choice | EMAIL
+Organization Contact | Multiple Choice | Get From CSC Differs For Clients
+Business Unit | Multiple Choice | Get From CSC Differs For Clients
+Notification Email(s) Comma Separated | String | N/A
+CN DCV Email | String | N/A
+Addtl Sans Comma Separated DCV Emails | String | N/A
-1. **Add** a new configuration.
-2. Pick a **Domain Validator Type** that publishes **CNAME** records and advertises validation type `cname`. For GoDaddy this is `GoDaddyCnameDomainValidator` (the `GoDaddyDomainValidator` variant publishes TXT for ACME and will **not** work for CSC).
-3. Add one or more **domain patterns** (e.g. `*.example.com`).
-4. Fill out the provider-specific **Configuration Settings** (API keys, base URL, etc.).
-5. Save.
+**CSC TrustedSecure DV Wildcard, Multiple Names - Details Tab**
-Once configured, any CSC enrollment for a domain matching one of those patterns will have its CNAME auto-published.
+CONFIG ELEMENT | DESCRIPTION
+----------------------------|------------------
+Template Short Name | CSC TrustedSecure DV Wildcard, Multiple Names
+Template Display Name | CSC TrustedSecure DV Wildcard, Multiple Names
+Friendly Name | CSC TrustedSecure DV Wildcard, Multiple Names
+Keys Size | 2048
+Enforce RFC 2818 Compliance | True
+CSR Enrollment | True
+Pfx Enrollment | True
-> **Common pitfall:** If you configure the TXT/`dns-01` validator (e.g. `GoDaddyDomainValidator`) for a CSC domain, the record will publish as a **TXT** and CSC's CNAME validation will never succeed. Make sure you select the **CNAME** validator variant.
-## Synchronous Issuance Polling
+**CSC TrustedSecure DV Wildcard, Multiple Names - Enrollment Fields**
-CSC validates domain control asynchronously — after an order is submitted (and the CNAME DCV record published), CSC/Sectigo polls public DNS on its own schedule and issues the certificate once validation passes. By default this plugin returns a **pending** (`EXTERNALVALIDATION`) result immediately and the issued certificate is picked up on the next gateway **sync** cycle.
+NAME | DATA TYPE | VALUES
+-----|--------------|-----------------
+Term | Multiple Choice | 12,24
+Applicant First Name | String | N/A
+Applicant Last Name | String | N/A
+Applicant Email Address | String | N/A
+Applicant Phone | String | N/A
+Domain Control Validation Method | Multiple Choice | EMAIL
+Organization Contact | Multiple Choice | Get From CSC Differs For Clients
+Business Unit | Multiple Choice | Get From CSC Differs For Clients
+Notification Email(s) Comma Separated | String | N/A
+CN DCV Email | String | N/A
+Addtl Sans Comma Separated DCV Emails | String | N/A
-For environments where DNS is published automatically (see [DNS Auto-Publishing](#dns-auto-publishing-cname-dcv)) and validation tends to complete quickly, you can have the plugin **poll CSC synchronously** at the end of enrollment and return the issued certificate directly — avoiding the wait for the next sync.
+3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates.
-* Set **`DcvPollTimeoutSeconds`** to the maximum number of seconds to poll (e.g. `60`). `0` (default) disables polling entirely.
-* The plugin polls CSC every 10 seconds until the order is issued or the timeout is reached.
-* If the certificate issues within the window, the enrollment returns it immediately with a success status.
-* If the window expires, the plugin falls back to the **pending** result and the certificate arrives on the next sync — exactly as it would with polling disabled.
+4. In Keyfactor Command (v12.3+), for each imported Certificate Template, follow the [official documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Configuring%20Template%20Options.htm) to define enrollment fields for each of the following parameters:
-**Tradeoff:** Polling blocks the enrollment request for up to `DcvPollTimeoutSeconds`. CSC validation frequently takes minutes to hours, so most orders will still fall through to pending — keep the timeout small (30–90s) to catch only the fast cases without hanging callers. This applies to New enrollments, Renewals, and Reissues.
+ * **Term** - OPTIONAL: Certificate term (e.g. 12 or 24 months)
+ * **Applicant First Name** - OPTIONAL: Applicant First Name
+ * **Applicant Last Name** - OPTIONAL: Applicant Last Name
+ * **Applicant Email Address** - OPTIONAL: Applicant Email Address
+ * **Applicant Phone** - OPTIONAL: Applicant Phone (+nn.nnnnnnnn)
+ * **Domain Control Validation Method** - OPTIONAL: Domain Control Validation Method (e.g. EMAIL)
+ * **Organization Contact** - OPTIONAL: Organization Contact (selected from CSC configuration)
+ * **Business Unit** - OPTIONAL: Business Unit (selected from CSC configuration)
+ * **Notification Email(s) Comma Separated** - OPTIONAL: Notification Email(s), comma separated
+ * **CN DCV Email** - OPTIONAL: CN DCV Email (e.g. admin@yourdomain.com)
+ * **Organization Country** - OPTIONAL: Organization Country
+ * **Addtl Sans Comma Separated DCV Emails** - OPTIONAL: Additional SANs DCV Emails, comma separated
## License
diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj
index 823e49f..b65ca5b 100644
--- a/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj
+++ b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj
@@ -1,25 +1,30 @@
-
- net10.0
- enable
- enable
- false
- true
- CSCGlobalCAPlugin.Tests
- CscGlobalCAPluginTests
-
+
+ net8.0
+ enable
+ enable
+ false
+ true
+ CSCGlobalCAPlugin.Tests
+
-
-
-
-
-
-
-
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
-
-
-
+
+
+
diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs
index a763f2e..16fd258 100644
--- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs
+++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs
@@ -1,6 +1,7 @@
// Copyright 2021 Keyfactor
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
@@ -16,41 +17,36 @@ namespace CscGlobalCAPluginTests;
public class CSCGlobalCAPluginTests
{
- private static EnrollmentProductInfo ProductInfo(string productId = "CSC TrustedSecure DV",
- Dictionary? parameters = null) => new EnrollmentProductInfo
+ private sealed class FakeConfigProvider : IAnyCAPluginConfigProvider
{
- ProductID = productId,
- ProductParameters = parameters ?? new Dictionary()
- };
+ public Dictionary CAConnectionData { get; set; } = new();
+ }
- private static Mock ConfigProviderMock(Dictionary? overrides = null)
+ private static Dictionary ValidConnectionData() => new()
{
- var data = new Dictionary
- {
- [Constants.CscGlobalApiKey] = "api-key",
- [Constants.CscGlobalUrl] = "https://example.invalid/",
- [Constants.BearerToken] = "bearer-token"
- };
- if (overrides != null)
- foreach (var kv in overrides)
- data[kv.Key] = kv.Value;
-
- var mock = new Mock();
- mock.Setup(c => c.CAConnectionData).Returns(data);
- return mock;
- }
+ [Constants.CscGlobalUrl] = "https://api.csc.test",
+ [Constants.CscGlobalApiKey] = "test-api-key",
+ [Constants.BearerToken] = "test-bearer-token"
+ };
private static CSCGlobalCAPlugin MakePlugin(Mock? client = null,
- Mock? certDataReader = null, Dictionary? configOverrides = null,
- IDomainValidatorFactory? validatorFactory = null)
+ Mock? certDataReader = null, Dictionary? connectionData = null)
{
- var plugin = validatorFactory != null ? new CSCGlobalCAPlugin(validatorFactory) : new CSCGlobalCAPlugin();
- plugin.Initialize(ConfigProviderMock(configOverrides).Object,
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = connectionData ?? ValidConnectionData() },
(certDataReader ?? new Mock()).Object);
- plugin.CscGlobalClient = (client ?? new Mock()).Object;
+ if (client != null)
+ plugin.ClientFactory = _ => client.Object;
return plugin;
}
+ private static EnrollmentProductInfo ProductInfo(string productId = "CSC TrustedSecure OV",
+ Dictionary? parameters = null) => new EnrollmentProductInfo
+ {
+ ProductID = productId,
+ ProductParameters = parameters ?? new Dictionary()
+ };
+
private static (X509Certificate2 Cert, string Pem) MakeSelfSignedCert(string cn = "test.example.com", bool isCa = false)
{
using var rsa = RSA.Create(2048);
@@ -63,6 +59,8 @@ private static (X509Certificate2 Cert, string Pem) MakeSelfSignedCert(string cn
return (cert, pem);
}
+ private static string ToApiBase64(string pemText) => Convert.ToBase64String(Encoding.UTF8.GetBytes(pemText));
+
// ---------------------------------------------------------------------
// Initialize
// ---------------------------------------------------------------------
@@ -75,143 +73,98 @@ public void Initialize_NullConfigProvider_Throws()
}
[Fact]
- public void Initialize_NullCertificateDataReader_Throws()
- {
- var plugin = new CSCGlobalCAPlugin();
- Assert.Throws(() => plugin.Initialize(ConfigProviderMock().Object, null!));
- }
-
- [Fact]
- public void Initialize_NullCAConnectionData_Throws()
+ public void Initialize_NullCertDataReader_Throws()
{
var plugin = new CSCGlobalCAPlugin();
- var mock = new Mock();
- mock.Setup(c => c.CAConnectionData).Returns((Dictionary)null!);
- Assert.Throws(() => plugin.Initialize(mock.Object, Mock.Of()));
+ Assert.Throws(() =>
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, null!));
}
[Fact]
- public void Initialize_EnabledDefault_ConstructsRealClient()
+ public void Initialize_MissingEnabled_DefaultsToTrue()
{
var plugin = new CSCGlobalCAPlugin();
- plugin.Initialize(ConfigProviderMock().Object, Mock.Of());
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, Mock.Of());
Assert.True(plugin.Enabled);
- Assert.NotNull(plugin.CscGlobalClient);
}
[Fact]
- public void Initialize_ExplicitlyDisabled_SkipsClientCreation()
+ public void Initialize_ExplicitlyDisabled_ParsesFalse()
{
+ var data = ValidConnectionData();
+ data[Constants.Enabled] = "false";
var plugin = new CSCGlobalCAPlugin();
- plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = "false" }).Object,
- Mock.Of());
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of());
Assert.False(plugin.Enabled);
- Assert.Null(plugin.CscGlobalClient);
}
[Fact]
public void Initialize_UnparsableEnabled_DefaultsToTrue()
{
+ var data = ValidConnectionData();
+ data[Constants.Enabled] = "not-a-bool";
var plugin = new CSCGlobalCAPlugin();
- plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = "not-a-bool" }).Object,
- Mock.Of());
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of());
Assert.True(plugin.Enabled);
}
[Fact]
- public void Initialize_EnabledButMissingApiKey_Throws()
- {
- var plugin = new CSCGlobalCAPlugin();
- var mock = new Mock();
- mock.Setup(c => c.CAConnectionData).Returns(new Dictionary());
- Assert.Throws(() => plugin.Initialize(mock.Object, Mock.Of()));
- }
-
- [Theory]
- [InlineData("10", 10)]
- [InlineData("not-a-number", 0)]
- public void Initialize_SyncFilterDays_ParsesOrDefaults(string raw, int expected)
- {
- var plugin = new CSCGlobalCAPlugin();
- plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.SyncFilterDays] = raw }).Object,
- Mock.Of());
- Assert.Equal(expected, plugin.SyncFilterDays);
- }
-
- [Theory]
- [InlineData("45", 45)]
- [InlineData("not-a-number", 30)]
- [InlineData("-5", 30)]
- public void Initialize_RenewalWindowDays_ParsesOrDefaults(string raw, int expected)
+ public void Initialize_ValidSyncFilterDays_ParsesValue()
{
+ var data = ValidConnectionData();
+ data[Constants.SyncFilterDays] = "10";
var plugin = new CSCGlobalCAPlugin();
- plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.RenewalWindowDays] = raw }).Object,
- Mock.Of());
- Assert.Equal(expected, plugin.RenewalWindowDays);
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of());
+ Assert.Equal(10, plugin.SyncFilterDays);
}
[Fact]
- public void Initialize_RenewalWindowDaysNotConfigured_DefaultsTo30()
+ public void Initialize_UnparsableSyncFilterDays_LeavesDefault()
{
+ var data = ValidConnectionData();
+ data[Constants.SyncFilterDays] = "not-a-number";
var plugin = new CSCGlobalCAPlugin();
- plugin.Initialize(ConfigProviderMock().Object, Mock.Of());
- Assert.Equal(30, plugin.RenewalWindowDays);
- }
-
- [Theory]
- [InlineData("5", 5)]
- [InlineData("not-a-number", 0)]
- [InlineData("-1", 0)]
- public void Initialize_DcvPollTimeoutSeconds_ParsesOrDefaults(string raw, int expected)
- {
- var plugin = new CSCGlobalCAPlugin();
- plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.DcvPollTimeoutSeconds] = raw }).Object,
- Mock.Of());
- Assert.Equal(expected, plugin.DcvPollTimeoutSeconds);
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of());
+ Assert.Equal(0, plugin.SyncFilterDays);
}
[Fact]
- public void Initialize_EnabledKeyPresentButNullValue_DefaultsToTrue()
+ public void Initialize_MissingSyncFilterDays_LeavesDefault()
{
var plugin = new CSCGlobalCAPlugin();
- plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = null! }).Object,
- Mock.Of());
- Assert.True(plugin.Enabled);
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, Mock.Of());
+ Assert.Equal(0, plugin.SyncFilterDays);
}
[Fact]
- public void Initialize_SyncFilterDaysKeyPresentButNullValue_DefaultsToZero()
+ public void Initialize_ValidRenewalWindowDays_ParsesValue()
{
+ var data = ValidConnectionData();
+ data[Constants.RenewalWindowDays] = "45";
var plugin = new CSCGlobalCAPlugin();
- plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.SyncFilterDays] = null! }).Object,
- Mock.Of());
- Assert.Equal(0, plugin.SyncFilterDays);
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of());
+ Assert.Equal(45, plugin.RenewalWindowDays);
}
[Fact]
- public void Initialize_RenewalWindowDaysKeyPresentButNullValue_DefaultsTo30()
+ public void Initialize_MissingRenewalWindowDays_DefaultsTo30()
{
var plugin = new CSCGlobalCAPlugin();
- plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.RenewalWindowDays] = null! }).Object,
- Mock.Of());
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, Mock.Of());
Assert.Equal(30, plugin.RenewalWindowDays);
}
- [Fact]
- public void Initialize_DcvPollTimeoutSecondsKeyPresentButNullValue_DefaultsToZero()
+ [Theory]
+ [InlineData("not-a-number")]
+ [InlineData("-5")]
+ [InlineData("0")]
+ public void Initialize_InvalidRenewalWindowDays_DefaultsTo30(string raw)
{
+ var data = ValidConnectionData();
+ data[Constants.RenewalWindowDays] = raw;
var plugin = new CSCGlobalCAPlugin();
- plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.DcvPollTimeoutSeconds] = null! }).Object,
- Mock.Of());
- Assert.Equal(0, plugin.DcvPollTimeoutSeconds);
- }
-
- [Fact]
- public void Initialize_WithValidatorFactory_DoesNotThrow()
- {
- var plugin = new CSCGlobalCAPlugin(Mock.Of());
- plugin.Initialize(ConfigProviderMock().Object, Mock.Of());
- Assert.True(plugin.Enabled);
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of());
+ Assert.Equal(30, plugin.RenewalWindowDays);
}
// ---------------------------------------------------------------------
@@ -219,31 +172,17 @@ public void Initialize_WithValidatorFactory_DoesNotThrow()
// ---------------------------------------------------------------------
[Fact]
- public async Task GetSingleRecord_NullId_Throws()
+ public async Task GetSingleRecord_ShortCaRequestId_ThrowsWrappedException()
{
var plugin = MakePlugin();
- await Assert.ThrowsAsync(() => plugin.GetSingleRecord(null!));
+ await Assert.ThrowsAsync(() => plugin.GetSingleRecord("too-short"));
}
[Fact]
- public async Task GetSingleRecord_TooShortId_Throws()
+ public async Task GetSingleRecord_NullCaRequestId_ThrowsWrappedException()
{
var plugin = MakePlugin();
- await Assert.ThrowsAsync(() => plugin.GetSingleRecord("short-id"));
- }
-
- [Fact]
- public async Task GetSingleRecord_NullClientResponse_ReturnsFailedMappedStatus()
- {
- var uuid = Guid.NewGuid().ToString();
- var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync((CertificateResponse)null!);
-
- var plugin = MakePlugin(mockClient);
- var result = await plugin.GetSingleRecord(uuid);
-
- Assert.Equal(uuid, result.CARequestID);
- Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
+ await Assert.ThrowsAsync(() => plugin.GetSingleRecord(null!));
}
[Fact]
@@ -266,23 +205,6 @@ public async Task GetSingleRecord_ValidId_ReturnsMappedCertificate()
Assert.Equal(Convert.ToBase64String(cert.RawData), result.Certificate);
}
- [Fact]
- public async Task GetSingleRecord_InvalidBase64Certificate_ReturnsEmptyCertificate()
- {
- var uuid = Guid.NewGuid().ToString();
- var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse
- {
- Certificate = Convert.ToBase64String(Encoding.ASCII.GetBytes("not valid pem at all")),
- Status = "ACTIVE"
- });
-
- var plugin = MakePlugin(mockClient);
- var result = await plugin.GetSingleRecord(uuid);
-
- Assert.Equal(string.Empty, result.Certificate);
- }
-
[Fact]
public async Task GetSingleRecord_ClientThrows_WrapsException()
{
@@ -295,241 +217,215 @@ public async Task GetSingleRecord_ClientThrows_WrapsException()
}
// ---------------------------------------------------------------------
- // Synchronize / SyncCertificates
+ // Synchronize
// ---------------------------------------------------------------------
[Fact]
- public async Task Synchronize_NullBuffer_Throws()
- {
- var plugin = MakePlugin();
- await Assert.ThrowsAsync(() => plugin.Synchronize(null!, null, true, CancellationToken.None));
- }
-
- [Fact]
- public async Task Synchronize_Disabled_CompletesImmediatelyWithoutCallingClient()
+ public async Task Synchronize_FullSync_QueuesActiveAndRevokedOnly()
{
- var mockClient = new Mock();
- var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.Enabled] = "false" });
- var buffer = new System.Collections.Concurrent.BlockingCollection();
-
- await plugin.Synchronize(buffer, null, true, CancellationToken.None);
-
- Assert.True(buffer.IsAddingCompleted);
- mockClient.Verify(c => c.SubmitCertificateListRequestAsync(It.IsAny()), Times.Never);
- }
+ var (cert, pem) = MakeSelfSignedCert();
+ var apiCert = ToApiBase64(pem);
- [Fact]
- public async Task Synchronize_FullSync_QueuesGeneratedAndRevokedOnly()
- {
var mockClient = new Mock();
mockClient.Setup(c => c.SubmitCertificateListRequestAsync(null)).ReturnsAsync(new CertificateListResponse
{
Results = new List
{
- new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = null, CertificateType = "4" },
- new CertificateResponse { Uuid = "u2", Status = "Pending", Certificate = null, CertificateType = "4" },
- null!
+ new CertificateResponse { Uuid = "cert-1", Status = "ACTIVE", CertificateType = "CSC TrustedSecure OV", Certificate = apiCert },
+ new CertificateResponse { Uuid = "cert-2", Status = "REVOKED", CertificateType = "CSC TrustedSecure DV", Certificate = apiCert },
+ new CertificateResponse { Uuid = "cert-3", Status = "Pending", CertificateType = "CSC TrustedSecure OV", Certificate = apiCert }
}
});
var plugin = MakePlugin(mockClient);
- var buffer = new System.Collections.Concurrent.BlockingCollection();
+ var buffer = new BlockingCollection();
await plugin.Synchronize(buffer, null, true, CancellationToken.None);
- Assert.True(buffer.IsAddingCompleted);
- // Neither item has actual certificate bytes, so both get skipped after status-eligibility
- // check; this exercises the eligible-but-empty-content and null-item paths.
- Assert.Empty(buffer);
+ var items = buffer.ToList();
+ Assert.Equal(2, items.Count);
+ Assert.Contains(items, i => i.CARequestID == "cert-1" && i.ProductID == "CSC TrustedSecure OV");
+ Assert.Contains(items, i => i.CARequestID == "cert-2" && i.ProductID == "CSC TrustedSecure DV");
}
[Fact]
- public async Task Synchronize_IncrementalSync_UsesFilterDate()
+ public async Task Synchronize_IncrementalSync_UsesConfiguredFilterDays()
{
var mockClient = new Mock();
- string? capturedFilter = "not-called";
- mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
- .Callback(f => capturedFilter = f)
+ string? capturedFilter = null;
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .Callback(f => capturedFilter = f)
.ReturnsAsync(new CertificateListResponse { Results = new List() });
- var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.SyncFilterDays] = "10" });
- var buffer = new System.Collections.Concurrent.BlockingCollection();
+ var data = ValidConnectionData();
+ data[Constants.SyncFilterDays] = "10";
+ var plugin = MakePlugin(mockClient, connectionData: data);
+ var buffer = new BlockingCollection();
- await plugin.Synchronize(buffer, null, false, CancellationToken.None);
+ await plugin.Synchronize(buffer, DateTime.UtcNow, false, CancellationToken.None);
Assert.NotNull(capturedFilter);
- Assert.NotEqual("not-called", capturedFilter);
+ Assert.Equal(DateTime.Today.Subtract(TimeSpan.FromDays(10)).ToString("yyyy/MM/dd"), capturedFilter);
}
[Fact]
- public async Task Synchronize_IncrementalSync_SyncFilterDaysNotConfigured_DefaultsToFiveDays()
+ public async Task Synchronize_IncrementalSync_DefaultsToFiveDaysWhenUnset()
{
var mockClient = new Mock();
- string? capturedFilter = "not-called";
- mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
- .Callback(f => capturedFilter = f)
+ string? capturedFilter = null;
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .Callback(f => capturedFilter = f)
.ReturnsAsync(new CertificateListResponse { Results = new List() });
var plugin = MakePlugin(mockClient);
- var buffer = new System.Collections.Concurrent.BlockingCollection();
-
- await plugin.Synchronize(buffer, null, false, CancellationToken.None);
-
- var expected = DateTime.Today.Subtract(TimeSpan.FromDays(5)).ToString("yyyy/MM/dd");
- Assert.Equal(expected, capturedFilter);
- }
-
- [Fact]
- public async Task Synchronize_NullResultsFromClient_CompletesWithoutError()
- {
- var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
- .ReturnsAsync((CertificateListResponse)null!);
-
- var plugin = MakePlugin(mockClient);
- var buffer = new System.Collections.Concurrent.BlockingCollection();
+ var buffer = new BlockingCollection();
- await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+ await plugin.Synchronize(buffer, DateTime.UtcNow, false, CancellationToken.None);
- Assert.True(buffer.IsAddingCompleted);
+ Assert.Equal(DateTime.Today.Subtract(TimeSpan.FromDays(5)).ToString("yyyy/MM/dd"), capturedFilter);
}
[Fact]
- public async Task Synchronize_NullResultsCollection_CompletesWithoutError()
+ public async Task Synchronize_NullResultsCollection_CompletesWithoutQueueing()
{
var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
.ReturnsAsync(new CertificateListResponse { Results = null });
var plugin = MakePlugin(mockClient);
- var buffer = new System.Collections.Concurrent.BlockingCollection();
+ var buffer = new BlockingCollection();
await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+ Assert.Empty(buffer.ToList());
Assert.True(buffer.IsAddingCompleted);
}
[Fact]
- public async Task Synchronize_ValidCertificateContent_AddsToBufferWithMappedProductId()
+ public async Task Synchronize_EmptyCertificateContent_SkipsRecord()
{
- var (_, pem) = MakeSelfSignedCert();
- var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(pem));
-
var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse
- {
- Results = new List
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .ReturnsAsync(new CertificateListResponse
{
- new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "CSC TrustedSecure DV" }
- }
- });
+ Results = new List
+ {
+ new CertificateResponse { Uuid = "cert-empty", Status = "ACTIVE", Certificate = "" }
+ }
+ });
var plugin = MakePlugin(mockClient);
- var buffer = new System.Collections.Concurrent.BlockingCollection();
+ var buffer = new BlockingCollection();
await plugin.Synchronize(buffer, null, true, CancellationToken.None);
- var items = buffer.ToArray();
- Assert.Single(items);
- Assert.Equal("u1", items[0].CARequestID);
- // CSC's list/sync API returns the certificate's current product name directly, so the
- // synced ProductID must match it verbatim (and therefore match the canonical Certificate
- // Profile name configured in Command) rather than going through a name-remapping table.
- Assert.Equal("CSC TrustedSecure DV", items[0].ProductID);
+ Assert.Empty(buffer.ToList());
}
[Fact]
- public async Task Synchronize_MalformedBase64Certificate_SkipsItem()
+ public async Task Synchronize_UnparsableCertificateContent_SkipsRecord()
{
var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse
- {
- Results = new List
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .ReturnsAsync(new CertificateListResponse
{
- new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = "not valid base64 at all!!", CertificateType = "4" }
- }
- });
+ Results = new List
+ {
+ new CertificateResponse
+ {
+ Uuid = "cert-bad",
+ Status = "ACTIVE",
+ Certificate = ToApiBase64("not a valid pem block at all")
+ }
+ }
+ });
var plugin = MakePlugin(mockClient);
- var buffer = new System.Collections.Concurrent.BlockingCollection();
+ var buffer = new BlockingCollection();
await plugin.Synchronize(buffer, null, true, CancellationToken.None);
- Assert.Empty(buffer);
+ Assert.Empty(buffer.ToList());
}
[Fact]
- public async Task Synchronize_ValidBase64ButNoPemCertificates_SkipsItem()
+ public async Task Synchronize_MissingProductIdFromCsc_LeavesProductIdNull()
{
- var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("this is not a PEM certificate"));
+ var (_, pem) = MakeSelfSignedCert();
var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse
- {
- Results = new List
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .ReturnsAsync(new CertificateListResponse
{
- new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "4" }
- }
- });
+ Results = new List
+ {
+ new CertificateResponse { Uuid = "cert-1", Status = "ACTIVE", CertificateType = null, Certificate = ToApiBase64(pem) }
+ }
+ });
var plugin = MakePlugin(mockClient);
- var buffer = new System.Collections.Concurrent.BlockingCollection();
+ var buffer = new BlockingCollection();
await plugin.Synchronize(buffer, null, true, CancellationToken.None);
- Assert.Empty(buffer);
+ var item = Assert.Single(buffer.ToList());
+ Assert.Null(item.ProductID);
}
[Fact]
- public async Task Synchronize_RevokedStatus_AlsoQualifiesForSync()
+ public async Task Synchronize_CancellationRequested_ThrowsAndCompletesBuffer()
{
var (_, pem) = MakeSelfSignedCert();
- var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(pem));
var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse
- {
- Results = new List
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .ReturnsAsync(new CertificateListResponse
{
- new CertificateResponse { Uuid = "u1", Status = "REVOKED", Certificate = apiBase64, CertificateType = "4" }
- }
- });
+ Results = new List
+ {
+ new CertificateResponse { Uuid = "cert-1", Status = "ACTIVE", Certificate = ToApiBase64(pem) }
+ }
+ });
var plugin = MakePlugin(mockClient);
- var buffer = new System.Collections.Concurrent.BlockingCollection();
+ var buffer = new BlockingCollection();
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
- await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+ await Assert.ThrowsAsync(() =>
+ plugin.Synchronize(buffer, null, true, cts.Token));
- Assert.Single(buffer);
+ Assert.True(buffer.IsAddingCompleted);
}
[Fact]
- public async Task Synchronize_ClientThrows_PropagatesAndCompletesBuffer()
+ public async Task Synchronize_ClientThrows_CompletesBufferAndRethrows()
{
var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("boom"));
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("network down"));
var plugin = MakePlugin(mockClient);
- var buffer = new System.Collections.Concurrent.BlockingCollection();
+ var buffer = new BlockingCollection();
+
+ await Assert.ThrowsAsync(() =>
+ plugin.Synchronize(buffer, null, true, CancellationToken.None));
- await Assert.ThrowsAsync(() => plugin.Synchronize(buffer, null, true, CancellationToken.None));
Assert.True(buffer.IsAddingCompleted);
}
[Fact]
- public async Task Synchronize_Cancelled_ThrowsOperationCanceledAndCompletesBuffer()
+ public async Task Synchronize_Disabled_CompletesBufferWithoutCallingClient()
{
var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse
- {
- Results = new List { new CertificateResponse { Uuid = "u1", Status = "ACTIVE" } }
- });
+ var data = ValidConnectionData();
+ data[Constants.Enabled] = "false";
+ var plugin = MakePlugin(mockClient, connectionData: data);
+ var buffer = new BlockingCollection();
- var plugin = MakePlugin(mockClient);
- var buffer = new System.Collections.Concurrent.BlockingCollection();
- using var cts = new CancellationTokenSource();
- cts.Cancel();
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
- await Assert.ThrowsAsync(() => plugin.Synchronize(buffer, null, true, cts.Token));
Assert.True(buffer.IsAddingCompleted);
+ Assert.Empty(buffer);
+ mockClient.Verify(c => c.SubmitCertificateListRequestAsync(It.IsAny()), Times.Never);
}
// ---------------------------------------------------------------------
@@ -537,1039 +433,793 @@ public async Task Synchronize_Cancelled_ThrowsOperationCanceledAndCompletesBuffe
// ---------------------------------------------------------------------
[Fact]
- public async Task Revoke_Disabled_Throws()
- {
- var plugin = MakePlugin(configOverrides: new Dictionary { [Constants.Enabled] = "false" });
- await Assert.ThrowsAsync(() =>
- plugin.Revoke(new string('a', 36), "serial", 0));
- }
-
- [Fact]
- public async Task Revoke_TooShortId_Throws()
- {
- var plugin = MakePlugin();
- await Assert.ThrowsAsync(() => plugin.Revoke("short", "serial", 0));
- }
-
- [Fact]
- public async Task Revoke_NullId_Throws()
+ public async Task Revoke_ShortCaRequestId_ThrowsWrappedException()
{
var plugin = MakePlugin();
- await Assert.ThrowsAsync(() => plugin.Revoke(null!, "serial", 0));
+ await Assert.ThrowsAsync(() => plugin.Revoke("short", "AB12", 0));
}
[Fact]
- public async Task Revoke_NullResponse_Throws()
+ public async Task Revoke_Success_ReturnsRevokedStatus()
{
var uuid = Guid.NewGuid().ToString();
var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync((RevokeResponse)null!);
+ mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse());
var plugin = MakePlugin(mockClient);
- // Wrapped by the generic catch (Exception e) at the bottom of Revoke, since
- // InvalidOperationException isn't AggregateException or HttpRequestException.
- var ex = await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "serial", 0));
- Assert.IsType(ex.InnerException);
+ var status = await plugin.Revoke(uuid, "AB12", 0);
+
+ Assert.Equal((int)EndEntityStatus.REVOKED, status);
}
[Fact]
- public async Task Revoke_Success_ReturnsRevoked()
+ public async Task Revoke_ErrorWithDescription_ThrowsWrappedException()
{
var uuid = Guid.NewGuid().ToString();
var mockClient = new Mock();
mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse
{
- RevokeSuccess = new RevokeSuccessResponse { Status = "REVOKED" }
+ RegistrationError = new RegistrationError { Description = "already revoked" }
});
var plugin = MakePlugin(mockClient);
- var result = await plugin.Revoke(uuid, "serial", 0);
-
- Assert.Equal((int)EndEntityStatus.REVOKED, result);
+ await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "AB12", 0));
}
[Fact]
- public async Task Revoke_ErrorWithDescription_ThrowsHttpRequestException()
+ public async Task Revoke_FailedWithNoErrorDescription_ReturnsFailedStatus()
{
var uuid = Guid.NewGuid().ToString();
var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse
- {
- RegistrationError = new RegistrationError { Description = "already revoked" }
- });
+ mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync((RevokeResponse)null!);
var plugin = MakePlugin(mockClient);
- await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "serial", 0));
+ var status = await plugin.Revoke(uuid, "AB12", 0);
+
+ Assert.Equal((int)EndEntityStatus.FAILED, status);
}
[Fact]
- public async Task Revoke_ClientThrows_WrapsException()
+ public async Task Revoke_Disabled_ThrowsInvalidOperationException()
{
- var uuid = Guid.NewGuid().ToString();
var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("boom"));
+ var data = ValidConnectionData();
+ data[Constants.Enabled] = "false";
+ var plugin = MakePlugin(mockClient, connectionData: data);
- var plugin = MakePlugin(mockClient);
- await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "serial", 0));
+ await Assert.ThrowsAsync(() => plugin.Revoke(Guid.NewGuid().ToString(), "AB12", 0));
+ mockClient.Verify(c => c.SubmitRevokeCertificateAsync(It.IsAny()), Times.Never);
}
// ---------------------------------------------------------------------
- // Ping / ValidateCAConnectionInfo
+ // Enroll
// ---------------------------------------------------------------------
[Fact]
- public async Task Ping_Enabled_DoesNotThrow()
+ public async Task Enroll_NullProductInfo_ThrowsArgumentNullException()
{
var plugin = MakePlugin();
- await plugin.Ping();
+ await Assert.ThrowsAsync(() =>
+ plugin.Enroll("csr", "subject", new Dictionary(), null!, RequestFormat.PKCS10, EnrollmentType.New));
}
[Fact]
- public async Task Ping_Disabled_DoesNotThrow()
+ public async Task Enroll_Disabled_ReturnsFailedWithoutCallingClient()
{
- var plugin = MakePlugin(configOverrides: new Dictionary { [Constants.Enabled] = "false" });
- await plugin.Ping();
- }
+ var mockClient = new Mock();
+ var data = ValidConnectionData();
+ data[Constants.Enabled] = "false";
+ var plugin = MakePlugin(mockClient, connectionData: data);
- [Fact]
- public async Task ValidateCAConnectionInfo_NullConnectionInfo_Throws()
- {
- var plugin = MakePlugin();
- await Assert.ThrowsAsync(() => plugin.ValidateCAConnectionInfo(null!));
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.FAILED, result!.Status);
+ Assert.Contains("Disabled", result.StatusMessage);
+ mockClient.Verify(c => c.SubmitGetCustomFields(), Times.Never);
}
[Fact]
- public async Task ValidateCAConnectionInfo_Enabled_DoesNotThrow()
+ public async Task Enroll_New_Success_ReturnsExternalValidation()
{
- var plugin = MakePlugin();
- await plugin.ValidateCAConnectionInfo(new Dictionary());
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ Result = new Result { CommonName = "order-1", Status = new Status { Uuid = "uuid-1" } }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status);
+ Assert.Equal("uuid-1", result.CARequestID);
}
[Fact]
- public async Task ValidateCAConnectionInfo_ExplicitlyDisabled_DoesNotThrow()
+ public async Task Enroll_New_CscReturnsError_ReturnsFailedStatus()
{
- var plugin = MakePlugin();
- await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "false" });
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ {
+ RegistrationError = new RegistrationError { Description = "duplicate order" }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.FAILED, result!.Status);
+ Assert.Contains("Flow: Enroll", result.StatusMessage);
+ Assert.Contains("duplicate order", result.StatusMessage);
}
[Fact]
- public async Task ValidateCAConnectionInfo_UnparsableEnabledValue_TreatsAsEnabled()
+ public async Task Enroll_NewWithPriorCertSn_ReturnsFailureWithoutCallingClient()
{
- var plugin = MakePlugin();
- await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "not-a-bool" });
- }
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
- // ---------------------------------------------------------------------
- // ValidateProductInfo
- // ---------------------------------------------------------------------
+ var plugin = MakePlugin(mockClient);
+ var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" });
- [Theory]
- [InlineData("CSC TrustedSecure DV")]
- [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names")]
- public async Task ValidateProductInfo_CanonicalProductName_DoesNotThrow(string productId)
- {
- var plugin = MakePlugin();
- await plugin.ValidateProductInfo(ProductInfo(productId), new Dictionary());
- }
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.New);
- [Theory]
- [InlineData("CSC TrustedSecure UC Certificate")]
- [InlineData("CSC TrustedSecure Domain Validated SSL")]
- [InlineData("CSC Trusted Secure Domain Validated Wildcard SSL")]
- public async Task ValidateProductInfo_LegacyProductName_DoesNotThrow(string legacyProductId)
- {
- var plugin = MakePlugin();
- await plugin.ValidateProductInfo(ProductInfo(legacyProductId), new Dictionary());
+ Assert.Equal((int)EndEntityStatus.FAILED, result!.Status);
+ mockClient.Verify(c => c.SubmitRegistrationAsync(It.IsAny()), Times.Never);
}
[Fact]
- public async Task ValidateProductInfo_NullProductInfo_Throws()
+ public async Task Enroll_RenewOrReissue_MissingPriorCertSn_ReturnsFailure()
{
- var plugin = MakePlugin();
- await Assert.ThrowsAsync(() =>
- plugin.ValidateProductInfo(null!, new Dictionary()));
- }
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
- [Fact]
- public async Task ValidateProductInfo_EmptyProductId_Throws()
- {
- var plugin = MakePlugin();
- await Assert.ThrowsAsync(() =>
- plugin.ValidateProductInfo(ProductInfo(""), new Dictionary()));
- }
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
- [Fact]
- public async Task ValidateProductInfo_UnknownProduct_Throws()
- {
- var plugin = MakePlugin();
- await Assert.ThrowsAsync(() =>
- plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), new Dictionary()));
+ Assert.Equal((int)EndEntityStatus.FAILED, result!.Status);
+ Assert.Contains("no prior certificate serial number", result.StatusMessage);
}
[Fact]
- public async Task ValidateProductInfo_NullConnectionInfo_TreatsAsEnabled()
+ public async Task Enroll_RenewOrReissue_NoRequestIdFoundForSerial_ReturnsFailure()
{
- var plugin = MakePlugin();
- await Assert.ThrowsAsync(() =>
- plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), null!));
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ var certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(string.Empty);
+
+ var plugin = MakePlugin(mockClient, certDataReader);
+ var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+
+ Assert.Equal((int)EndEntityStatus.FAILED, result!.Status);
+ Assert.Contains("no prior request found", result.StatusMessage);
}
[Fact]
- public async Task ValidateProductInfo_DisabledConnector_SkipsValidationEvenForUnknownProduct()
+ public async Task Enroll_RenewOrReissue_NullExpirationDate_FallsBackToGetSingleRecordThenReissues()
{
- var plugin = MakePlugin();
- var connectionInfo = new Dictionary { [Constants.Enabled] = "false" };
-
- // Should not throw even though the product is unknown - Enabled=false short-circuits
- // validation entirely (pre-configuration workflow).
- await plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), connectionInfo);
- }
-
- [Fact]
- public async Task ValidateProductInfo_UnparsableEnabledValue_TreatsAsEnabled()
- {
- var plugin = MakePlugin();
- var connectionInfo = new Dictionary { [Constants.Enabled] = "not-a-bool" };
- await Assert.ThrowsAsync(() =>
- plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), connectionInfo));
- }
+ var orderUuid = Guid.NewGuid().ToString();
+ var (_, pem) = MakeSelfSignedCert();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse
+ {
+ Certificate = Convert.ToBase64String(Encoding.ASCII.GetBytes(pem)),
+ Status = "ACTIVE"
+ });
+ mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse
+ {
+ Result = new Result { CommonName = "reissued.example.com" }
+ });
- // ---------------------------------------------------------------------
- // Annotations / product IDs
- // ---------------------------------------------------------------------
+ var certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid);
+ certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).Returns((DateTime?)null);
- [Fact]
- public void GetCAConnectorAnnotations_ReturnsExpectedKeys()
- {
- var plugin = MakePlugin();
- var annotations = plugin.GetCAConnectorAnnotations();
- Assert.Contains(Constants.Enabled, annotations.Keys);
- Assert.Contains(Constants.CscGlobalUrl, annotations.Keys);
- Assert.Contains(Constants.DcvPollTimeoutSeconds, annotations.Keys);
- }
+ var plugin = MakePlugin(mockClient, certDataReader);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ ["PriorCertSN"] = "ABC123",
+ ["Applicant Last Name"] = "Doe"
+ });
- [Fact]
- public void GetTemplateParameterAnnotations_ReturnsExpectedKeys()
- {
- var plugin = MakePlugin();
- var annotations = plugin.GetTemplateParameterAnnotations();
- Assert.Contains(EnrollmentConfigConstants.CnDcvEmail, annotations.Keys);
- Assert.Contains(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails, annotations.Keys);
- }
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
- [Fact]
- public void GetProductIds_ReturnsCanonicalTenProducts()
- {
- var plugin = MakePlugin();
- Assert.Equal(10, plugin.GetProductIds().Count);
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status);
+ mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once);
}
- // ---------------------------------------------------------------------
- // Enroll - validation and New enrollment
- // ---------------------------------------------------------------------
-
[Fact]
- public async Task Enroll_Disabled_ReturnsFailedWithoutCallingClient()
+ public async Task Enroll_Renewal_ExpiredCertWithApplicantLastName_Succeeds()
{
+ var uuid = Guid.NewGuid().ToString();
var mockClient = new Mock();
- var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.Enabled] = "false" });
-
- var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
- RequestFormat.PKCS10, EnrollmentType.New);
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse
+ {
+ Result = new Result { CommonName = "renewed.example.com" }
+ });
- Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
- mockClient.Verify(c => c.SubmitGetCustomFields(), Times.Never);
- }
+ var certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid);
+ certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(-1));
- [Fact]
- public async Task Enroll_NullProductInfo_Throws()
- {
- var plugin = MakePlugin();
- await Assert.ThrowsAsync(() =>
- plugin.Enroll("csr", "CN=test", new Dictionary(), null!, RequestFormat.PKCS10, EnrollmentType.New));
- }
+ var plugin = MakePlugin(mockClient, certDataReader);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ ["PriorCertSN"] = "ABC123",
+ ["Applicant Last Name"] = "Doe"
+ });
- [Fact]
- public async Task Enroll_NullProductParameters_Throws()
- {
- var plugin = MakePlugin();
- var productInfo = new EnrollmentProductInfo { ProductID = "CSC TrustedSecure DV", ProductParameters = null! };
- await Assert.ThrowsAsync(() =>
- plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.New));
- }
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
- [Fact]
- public async Task Enroll_EmptyCsr_Throws()
- {
- var plugin = MakePlugin();
- await Assert.ThrowsAsync(() =>
- plugin.Enroll("", "CN=test", new Dictionary(), ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New));
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status);
}
[Fact]
- public async Task Enroll_New_PriorCertSnPresent_ReturnsFailure()
+ public async Task Enroll_Renewal_ExpiredCertMissingApplicantLastName_ReturnsFailure()
{
+ var uuid = Guid.NewGuid().ToString();
var mockClient = new Mock();
mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
- var plugin = MakePlugin(mockClient);
+
+ var certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid);
+ certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(-1));
+
+ var plugin = MakePlugin(mockClient, certDataReader);
var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" });
var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
- RequestFormat.PKCS10, EnrollmentType.New);
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
- Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
- mockClient.Verify(c => c.SubmitRegistrationAsync(It.IsAny()), Times.Never);
+ Assert.Equal((int)EndEntityStatus.FAILED, result!.Status);
+ Assert.Contains("One click Renew Is Not Available", result.StatusMessage);
}
[Fact]
- public async Task Enroll_New_Success_ReturnsExternalValidation()
+ public async Task Enroll_Reissue_ValidCertWithApplicantLastName_Succeeds()
{
+ var uuid = Guid.NewGuid().ToString();
var mockClient = new Mock();
mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
- mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse
{
- Result = new Result { CommonName = "new.example.com", Status = new Status { Uuid = "uuid-new" } }
+ Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = uuid } }
});
- var plugin = MakePlugin(mockClient);
- var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
- RequestFormat.PKCS10, EnrollmentType.New);
-
- Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
- Assert.Equal("uuid-new", result.CARequestID);
- // Command's enrollment UI doesn't surface StatusMessage on a successful/pending result -
- // only EnrollmentContext is - so the flow summary must be attached there instead, one
- // bullet per step so it renders readably rather than as a single run-on blob.
- Assert.NotNull(result.EnrollmentContext);
- Assert.True(result.EnrollmentContext.ContainsKey("Flow: Enroll-New"));
- Assert.True(result.EnrollmentContext.Keys.Count(k => k.StartsWith("Flow Step ")) > 1);
- }
+ var certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid);
+ certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(30));
- [Fact]
- public async Task Enroll_New_SuccessWithDcvDetails_KeepsDcvEntriesAlongsideFlowSummary()
- {
- var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
- mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ var plugin = MakePlugin(mockClient, certDataReader);
+ var productInfo = ProductInfo(parameters: new Dictionary
{
- Result = new Result
- {
- CommonName = "dcv.example.com",
- Status = new Status { Uuid = "uuid-dcv" },
- DcvDetails = new List
- {
- new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } }
- }
- }
+ ["PriorCertSN"] = "ABC123",
+ ["Applicant Last Name"] = "Doe"
});
- var plugin = MakePlugin(mockClient);
- var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
- RequestFormat.PKCS10, EnrollmentType.New);
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
- Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]);
- Assert.True(result.EnrollmentContext.ContainsKey("Flow: Enroll-New"));
- Assert.True(result.EnrollmentContext.Keys.Count(k => k.StartsWith("Flow Step ")) > 1);
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status);
+ Assert.NotNull(result.EnrollmentContext);
+ Assert.Contains(result.EnrollmentContext.Keys, k => k.StartsWith("Flow: Enroll"));
+ Assert.Contains(result.EnrollmentContext.Keys, k => k.Contains("SubmitReissue"));
}
+ // ---------------------------------------------------------------------
+ // RenewOrReissue - order-expiry-window decision ("200 day" fix)
+ //
+ // CSC's order is a fixed 1-year paid subscription; a shorter-lived certificate (e.g.
+ // ~200 days) issued under it can still have plenty of runway left on the order itself.
+ // The decision must be based on the order's expiry (orderDate + 1 year, vs
+ // RenewalWindowDays), not the certificate's own expiration date.
+ // ---------------------------------------------------------------------
+
[Fact]
- public async Task Enroll_New_RegistrationErrorFromCsc_PrependsFlowSummaryToStatusMessage()
+ public async Task Enroll_RenewOrReissue_OrderNearExpiryWithinWindow_TriggersRenewalEvenThoughCertNotExpired()
{
+ var uuid = Guid.NewGuid().ToString();
var mockClient = new Mock();
mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
- mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse
+ // Order was placed 350 days ago -> expires in 15 days, inside the default 30-day window.
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse
{
- RegistrationError = new RegistrationError { Description = "Open order in progress" }
+ OrderDate = DateTime.UtcNow.AddDays(-350).ToString("o")
+ });
+ mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse
+ {
+ Result = new Result { CommonName = "renewed.example.com" }
});
- var plugin = MakePlugin(mockClient);
- var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
- RequestFormat.PKCS10, EnrollmentType.New);
-
- Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
- Assert.Contains("Enroll-New", result.StatusMessage);
- Assert.Contains("Open order in progress", result.StatusMessage);
- }
+ var certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid);
+ // The certificate itself still has 60 days left - under the old cert-expiry-only
+ // logic this would incorrectly route to Reissue.
+ certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(60));
- [Fact]
- public async Task Enroll_New_NullClientResponse_ReturnsFailure()
- {
- var mockClient = new Mock();
- mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
- mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync((RegistrationResponse)null!);
+ var plugin = MakePlugin(mockClient, certDataReader);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ ["PriorCertSN"] = "ABC123",
+ ["Applicant Last Name"] = "Doe"
+ });
- var plugin = MakePlugin(mockClient);
- var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
- RequestFormat.PKCS10, EnrollmentType.New);
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
- Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status);
+ mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Once);
+ mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny