diff --git a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml
index 042ba5a..0f3d3ae 100644
--- a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml
+++ b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml
@@ -11,10 +11,17 @@ on:
jobs:
call-starter-workflow:
- uses: keyfactor/actions/.github/workflows/starter.yml@v3.1.2
+ uses: keyfactor/actions/.github/workflows/starter.yml@v5
+ with:
+ command_token_url: ${{ vars.COMMAND_TOKEN_URL }}
+ command_hostname: ${{ vars.COMMAND_HOSTNAME }}
+ command_base_api_path: ${{ vars.COMMAND_API_PATH }}
secrets:
token: ${{ secrets.V2BUILDTOKEN}}
- APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}}
gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }}
gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }}
scan_token: ${{ secrets.SAST_TOKEN }}
+ entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }}
+ entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }}
+ command_client_id: ${{ secrets.COMMAND_CLIENT_ID }}
+ command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c6124b..cab5c5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,14 @@
+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.
+- 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.
+- 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
- Fixed issue with parsing certain certificates that were in zip format
diff --git a/README.md b/README.md
index c68aac4..0250f2c 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
Support
-
+
ยท
Requirements
@@ -33,7 +33,6 @@
-
This integration allows for the Synchronization, Enrollment, and Revocation of certificates from the CSCGlobal. This is the AnyGateway REST version.
## Compatibility
@@ -41,7 +40,7 @@ This integration allows for the Synchronization, Enrollment, and Revocation of c
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 with your Keyfactor representative. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com.
+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.
> To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab.
@@ -55,16 +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 `net6.0` or `net8.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 (`net6.0` or `net8.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.
@@ -82,235 +82,319 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and
Populate using the configuration fields collected in the [requirements](#requirements) section.
- * **CscGlobalUrl** - CSCGlobal API URL
- * **ApiKey** - CSCGlobal API Key
- * **BearerToken** - CSCGlobal Bearer Token
- * **DefaultPageSize** - Default page size for use with the API. Default is 100
- * **TemplateSync** - Enable template sync.
- * **SyncFilterDays** - Number of days from today to filter certificates by expiration date during incremental sync.
+ * **CscGlobalUrl** - CSCGlobal API URL
+ * **ApiKey** - CSCGlobal API Key
+ * **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.
2. PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION
- The following certificate templates are supported. Please set up the key sizes accordingly in the Certificate Profile menu of Anygateway REST, then enter the remaining details
- and the Enrollment Fields for each Template accordingly using the Certificate Templates section in Command. If you would like to set up default values for enrollment parameters, you can do so the in the Certificate Template Menu of Anygateway REST.
- If a field value is specified as both an Enrollment Field in Command and in the Certificate Template Menu in the REST Gateway, the value in the Enrollment Field will take precedence.
-
- CONFIG ELEMENT | DESCRIPTION
- ----------------------------|------------------
- Template Short Name | CSC TrustedSecure Premium Certificate
- Template Display Name | CSC TrustedSecure Premium Certificate
- Friendly Name | CSC TrustedSecure Premium Certificate
- Keys Size | 2048
- Enforce RFC 2818 Compliance | True
- CSR Enrollment | True
- Pfx Enrollment | True
-
-
- **CSC TrustedSecure Premium Certificate - Enrollment Fields**
-
- 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
-
- **CSC TrustedSecure EV Certificate - 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
- Keys Size | 2048
- Enforce RFC 2818 Compliance | True
- CSR Enrollment | True
- Pfx Enrollment | True
-
-
- **CSC TrustedSecure EV Certificate - Enrollment Fields**
-
- 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
-
- **CSC TrustedSecure UC Certificate - 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
- Keys Size | 2048
- Enforce RFC 2818 Compliance | True
- CSR Enrollment | True
- Pfx Enrollment | True
-
-
- **CSC TrustedSecure UC Certificate - Enrollment Fields**
-
- 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
-
-
- **CSC TrustedSecure Premium Wildcard Certificate - 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
- Keys Size | 2048
- Enforce RFC 2818 Compliance | True
- CSR Enrollment | True
- Pfx Enrollment | True
-
-
- **CSC TrustedSecure Premium Wildcard Certificate - Enrollment Fields**
-
- 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
-
- **CSC TrustedSecure Domain Validated SSL - 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
- Keys Size | 2048
- Enforce RFC 2818 Compliance | True
- CSR Enrollment | True
- Pfx Enrollment | True
-
-
- **CSC TrustedSecure Domain Validated SSL - Enrollment Fields**
-
- 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
-
- **CSC TrustedSecure Domain Validated Wildcard SSL - 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
- Keys Size | 2048
- Enforce RFC 2818 Compliance | True
- CSR Enrollment | True
- Pfx Enrollment | True
-
-
- **CSC TrustedSecure Domain Validated Wildcard SSL - Enrollment Fields**
-
- 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
-
- **CSC TrustedSecure Domain Validated UC Certificate - 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
- Keys Size | 2048
- Enforce RFC 2818 Compliance | True
- CSR Enrollment | True
- Pfx Enrollment | True
-
-
- **CSC TrustedSecure Domain Validated UC Certificate - Enrollment Fields**
-
- 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
+The following certificate templates are supported. Please set up the key sizes accordingly in the Certificate Profile menu of Anygateway REST, then enter the remaining details
+and the Enrollment Fields for each Template accordingly using the Certificate Templates section in Command. If you would like to set up default values for enrollment parameters, you can do so the in the Certificate Template Menu of Anygateway REST.
+If a field value is specified as both an Enrollment Field in Command and in the Certificate Template Menu in the REST Gateway, the value in the Enrollment Field will take precedence.
+
+CONFIG ELEMENT | DESCRIPTION
+----------------------------|------------------
+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 OV - Enrollment Fields**
+
+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
+
+**CSC TrustedSecure OV Wildcard - Details Tab**
+
+CONFIG ELEMENT | DESCRIPTION
+----------------------------|------------------
+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 OV Wildcard - Enrollment Fields**
+
+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
+
+**CSC TrustedSecure OV, Multiple Names - Details Tab**
+
+CONFIG ELEMENT | DESCRIPTION
+----------------------------|------------------
+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 OV, Multiple Names - Enrollment Fields**
+
+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
+
+**CSC TrustedSecure EV - Details Tab**
+
+CONFIG ELEMENT | DESCRIPTION
+----------------------------|------------------
+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 EV - Enrollment Fields**
+
+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
+
+**CSC TrustedSecure DV - Details Tab**
+
+CONFIG ELEMENT | DESCRIPTION
+----------------------------|------------------
+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 DV - Enrollment Fields**
+
+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
+
+**CSC TrustedSecure DV Wildcard - Details Tab**
+
+CONFIG ELEMENT | DESCRIPTION
+----------------------------|------------------
+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 DV Wildcard - Enrollment Fields**
+
+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
+
+**CSC TrustedSecure DV, Multiple Names - Details Tab**
+
+CONFIG ELEMENT | DESCRIPTION
+----------------------------|------------------
+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 DV, Multiple Names - Enrollment Fields**
+
+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
+
+**CSC TrustedSecure EV, Multiple Names - Details Tab**
+
+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
+
+
+**CSC TrustedSecure EV, Multiple Names - Enrollment Fields**
+
+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
+
+**CSC TrustedSecure OV Wildcard, Multiple Names - Details Tab**
+
+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
+
+
+**CSC TrustedSecure OV Wildcard, Multiple Names - Enrollment Fields**
+
+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
+
+**CSC TrustedSecure DV Wildcard, Multiple Names - Details Tab**
+
+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
+
+
+**CSC TrustedSecure DV Wildcard, Multiple Names - Enrollment Fields**
+
+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
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
-
-
+ * **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
@@ -318,4 +402,4 @@ Apache License 2.0, see [LICENSE](LICENSE).
## Related Integrations
-See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway).
\ No newline at end of file
+See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway).
diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj
new file mode 100644
index 0000000..b65ca5b
--- /dev/null
+++ b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ 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
new file mode 100644
index 0000000..16fd258
--- /dev/null
+++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs
@@ -0,0 +1,1263 @@
+// 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;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces;
+using Keyfactor.PKI.Enums.EJBCA;
+using Moq;
+using Xunit;
+
+namespace CscGlobalCAPluginTests;
+
+public class CSCGlobalCAPluginTests
+{
+ private sealed class FakeConfigProvider : IAnyCAPluginConfigProvider
+ {
+ public Dictionary CAConnectionData { get; set; } = new();
+ }
+
+ private static Dictionary ValidConnectionData() => new()
+ {
+ [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? connectionData = null)
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = connectionData ?? ValidConnectionData() },
+ (certDataReader ?? 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);
+ var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ req.CertificateExtensions.Add(new X509BasicConstraintsExtension(isCa, false, 0, true));
+ var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365));
+ var pem = "-----BEGIN CERTIFICATE-----\n" +
+ Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks) +
+ "\n-----END CERTIFICATE-----\n";
+ return (cert, pem);
+ }
+
+ private static string ToApiBase64(string pemText) => Convert.ToBase64String(Encoding.UTF8.GetBytes(pemText));
+
+ // ---------------------------------------------------------------------
+ // Initialize
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void Initialize_NullConfigProvider_Throws()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ Assert.Throws(() => plugin.Initialize(null!, Mock.Of()));
+ }
+
+ [Fact]
+ public void Initialize_NullCertDataReader_Throws()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ Assert.Throws(() =>
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, null!));
+ }
+
+ [Fact]
+ public void Initialize_MissingEnabled_DefaultsToTrue()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, Mock.Of());
+ Assert.True(plugin.Enabled);
+ }
+
+ [Fact]
+ public void Initialize_ExplicitlyDisabled_ParsesFalse()
+ {
+ var data = ValidConnectionData();
+ data[Constants.Enabled] = "false";
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of());
+ Assert.False(plugin.Enabled);
+ }
+
+ [Fact]
+ public void Initialize_UnparsableEnabled_DefaultsToTrue()
+ {
+ var data = ValidConnectionData();
+ data[Constants.Enabled] = "not-a-bool";
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of());
+ Assert.True(plugin.Enabled);
+ }
+
+ [Fact]
+ public void Initialize_ValidSyncFilterDays_ParsesValue()
+ {
+ var data = ValidConnectionData();
+ data[Constants.SyncFilterDays] = "10";
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of());
+ Assert.Equal(10, plugin.SyncFilterDays);
+ }
+
+ [Fact]
+ public void Initialize_UnparsableSyncFilterDays_LeavesDefault()
+ {
+ var data = ValidConnectionData();
+ data[Constants.SyncFilterDays] = "not-a-number";
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of());
+ Assert.Equal(0, plugin.SyncFilterDays);
+ }
+
+ [Fact]
+ public void Initialize_MissingSyncFilterDays_LeavesDefault()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, Mock.Of());
+ Assert.Equal(0, plugin.SyncFilterDays);
+ }
+
+ [Fact]
+ public void Initialize_ValidRenewalWindowDays_ParsesValue()
+ {
+ var data = ValidConnectionData();
+ data[Constants.RenewalWindowDays] = "45";
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of());
+ Assert.Equal(45, plugin.RenewalWindowDays);
+ }
+
+ [Fact]
+ public void Initialize_MissingRenewalWindowDays_DefaultsTo30()
+ {
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, Mock.Of());
+ Assert.Equal(30, plugin.RenewalWindowDays);
+ }
+
+ [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(new FakeConfigProvider { CAConnectionData = data }, Mock.Of());
+ Assert.Equal(30, plugin.RenewalWindowDays);
+ }
+
+ // ---------------------------------------------------------------------
+ // GetSingleRecord
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task GetSingleRecord_ShortCaRequestId_ThrowsWrappedException()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() => plugin.GetSingleRecord("too-short"));
+ }
+
+ [Fact]
+ public async Task GetSingleRecord_NullCaRequestId_ThrowsWrappedException()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() => plugin.GetSingleRecord(null!));
+ }
+
+ [Fact]
+ public async Task GetSingleRecord_ValidId_ReturnsMappedCertificate()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var (cert, pem) = MakeSelfSignedCert();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse
+ {
+ Certificate = Convert.ToBase64String(Encoding.ASCII.GetBytes(pem)),
+ Status = "ACTIVE"
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.GetSingleRecord(uuid);
+
+ Assert.Equal(uuid, result.CARequestID);
+ Assert.Equal((int)EndEntityStatus.GENERATED, result.Status);
+ Assert.Equal(Convert.ToBase64String(cert.RawData), result.Certificate);
+ }
+
+ [Fact]
+ public async Task GetSingleRecord_ClientThrows_WrapsException()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("boom"));
+
+ var plugin = MakePlugin(mockClient);
+ await Assert.ThrowsAsync(() => plugin.GetSingleRecord(uuid));
+ }
+
+ // ---------------------------------------------------------------------
+ // Synchronize
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task Synchronize_FullSync_QueuesActiveAndRevokedOnly()
+ {
+ var (cert, pem) = MakeSelfSignedCert();
+ var apiCert = ToApiBase64(pem);
+
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(null)).ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List
+ {
+ 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 BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ 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_UsesConfiguredFilterDays()
+ {
+ var mockClient = new Mock();
+ string? capturedFilter = null;
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .Callback(f => capturedFilter = f)
+ .ReturnsAsync(new CertificateListResponse { Results = new List() });
+
+ var data = ValidConnectionData();
+ data[Constants.SyncFilterDays] = "10";
+ var plugin = MakePlugin(mockClient, connectionData: data);
+ var buffer = new BlockingCollection();
+
+ await plugin.Synchronize(buffer, DateTime.UtcNow, false, CancellationToken.None);
+
+ Assert.NotNull(capturedFilter);
+ Assert.Equal(DateTime.Today.Subtract(TimeSpan.FromDays(10)).ToString("yyyy/MM/dd"), capturedFilter);
+ }
+
+ [Fact]
+ public async Task Synchronize_IncrementalSync_DefaultsToFiveDaysWhenUnset()
+ {
+ var mockClient = new Mock();
+ 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 BlockingCollection();
+
+ await plugin.Synchronize(buffer, DateTime.UtcNow, false, CancellationToken.None);
+
+ Assert.Equal(DateTime.Today.Subtract(TimeSpan.FromDays(5)).ToString("yyyy/MM/dd"), capturedFilter);
+ }
+
+ [Fact]
+ public async Task Synchronize_NullResultsCollection_CompletesWithoutQueueing()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .ReturnsAsync(new CertificateListResponse { Results = null });
+
+ var plugin = MakePlugin(mockClient);
+ 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_EmptyCertificateContent_SkipsRecord()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List
+ {
+ new CertificateResponse { Uuid = "cert-empty", Status = "ACTIVE", Certificate = "" }
+ }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ Assert.Empty(buffer.ToList());
+ }
+
+ [Fact]
+ public async Task Synchronize_UnparsableCertificateContent_SkipsRecord()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .ReturnsAsync(new CertificateListResponse
+ {
+ 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 BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ Assert.Empty(buffer.ToList());
+ }
+
+ [Fact]
+ public async Task Synchronize_MissingProductIdFromCsc_LeavesProductIdNull()
+ {
+ var (_, pem) = MakeSelfSignedCert();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List
+ {
+ new CertificateResponse { Uuid = "cert-1", Status = "ACTIVE", CertificateType = null, Certificate = ToApiBase64(pem) }
+ }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ var item = Assert.Single(buffer.ToList());
+ Assert.Null(item.ProductID);
+ }
+
+ [Fact]
+ public async Task Synchronize_CancellationRequested_ThrowsAndCompletesBuffer()
+ {
+ var (_, pem) = MakeSelfSignedCert();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List
+ {
+ new CertificateResponse { Uuid = "cert-1", Status = "ACTIVE", Certificate = ToApiBase64(pem) }
+ }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new BlockingCollection();
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Assert.ThrowsAsync(() =>
+ plugin.Synchronize(buffer, null, true, cts.Token));
+
+ Assert.True(buffer.IsAddingCompleted);
+ }
+
+ [Fact]
+ public async Task Synchronize_ClientThrows_CompletesBufferAndRethrows()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("network down"));
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new BlockingCollection();
+
+ await Assert.ThrowsAsync(() =>
+ plugin.Synchronize(buffer, null, true, CancellationToken.None));
+
+ Assert.True(buffer.IsAddingCompleted);
+ }
+
+ [Fact]
+ public async Task Synchronize_Disabled_CompletesBufferWithoutCallingClient()
+ {
+ var mockClient = new Mock();
+ var data = ValidConnectionData();
+ data[Constants.Enabled] = "false";
+ var plugin = MakePlugin(mockClient, connectionData: data);
+ var buffer = new BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ Assert.True(buffer.IsAddingCompleted);
+ Assert.Empty(buffer);
+ mockClient.Verify(c => c.SubmitCertificateListRequestAsync(It.IsAny()), Times.Never);
+ }
+
+ // ---------------------------------------------------------------------
+ // Revoke
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task Revoke_ShortCaRequestId_ThrowsWrappedException()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() => plugin.Revoke("short", "AB12", 0));
+ }
+
+ [Fact]
+ public async Task Revoke_Success_ReturnsRevokedStatus()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse());
+
+ var plugin = MakePlugin(mockClient);
+ var status = await plugin.Revoke(uuid, "AB12", 0);
+
+ Assert.Equal((int)EndEntityStatus.REVOKED, status);
+ }
+
+ [Fact]
+ public async Task Revoke_ErrorWithDescription_ThrowsWrappedException()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse
+ {
+ RegistrationError = new RegistrationError { Description = "already revoked" }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "AB12", 0));
+ }
+
+ [Fact]
+ public async Task Revoke_FailedWithNoErrorDescription_ReturnsFailedStatus()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync((RevokeResponse)null!);
+
+ var plugin = MakePlugin(mockClient);
+ var status = await plugin.Revoke(uuid, "AB12", 0);
+
+ Assert.Equal((int)EndEntityStatus.FAILED, status);
+ }
+
+ [Fact]
+ public async Task Revoke_Disabled_ThrowsInvalidOperationException()
+ {
+ var mockClient = new Mock();
+ var data = ValidConnectionData();
+ data[Constants.Enabled] = "false";
+ var plugin = MakePlugin(mockClient, connectionData: data);
+
+ await Assert.ThrowsAsync(() => plugin.Revoke(Guid.NewGuid().ToString(), "AB12", 0));
+ mockClient.Verify(c => c.SubmitRevokeCertificateAsync(It.IsAny()), Times.Never);
+ }
+
+ // ---------------------------------------------------------------------
+ // Enroll
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task Enroll_NullProductInfo_ThrowsArgumentNullException()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() =>
+ plugin.Enroll("csr", "subject", new Dictionary(), null!, RequestFormat.PKCS10, EnrollmentType.New));
+ }
+
+ [Fact]
+ public async Task Enroll_Disabled_ReturnsFailedWithoutCallingClient()
+ {
+ var mockClient = new Mock();
+ var data = ValidConnectionData();
+ data[Constants.Enabled] = "false";
+ var plugin = MakePlugin(mockClient, connectionData: data);
+
+ 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 Enroll_New_Success_ReturnsExternalValidation()
+ {
+ 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 Enroll_New_CscReturnsError_ReturnsFailedStatus()
+ {
+ 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 Enroll_NewWithPriorCertSn_ReturnsFailureWithoutCallingClient()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+
+ var plugin = MakePlugin(mockClient);
+ var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.New);
+
+ Assert.Equal((int)EndEntityStatus.FAILED, result!.Status);
+ mockClient.Verify(c => c.SubmitRegistrationAsync(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Enroll_RenewOrReissue_MissingPriorCertSn_ReturnsFailure()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+
+ var plugin = MakePlugin(mockClient);
+ 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 certificate serial number", result.StatusMessage);
+ }
+
+ [Fact]
+ public async Task Enroll_RenewOrReissue_NoRequestIdFoundForSerial_ReturnsFailure()
+ {
+ 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 Enroll_RenewOrReissue_NullExpirationDate_FallsBackToGetSingleRecordThenReissues()
+ {
+ 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" }
+ });
+
+ var certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid);
+ certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).Returns((DateTime?)null);
+
+ var plugin = MakePlugin(mockClient, certDataReader);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ ["PriorCertSN"] = "ABC123",
+ ["Applicant Last Name"] = "Doe"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status);
+ mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Enroll_Renewal_ExpiredCertWithApplicantLastName_Succeeds()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ 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" }
+ });
+
+ 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",
+ ["Applicant Last Name"] = "Doe"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status);
+ }
+
+ [Fact]
+ 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 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.RenewOrReissue);
+
+ Assert.Equal((int)EndEntityStatus.FAILED, result!.Status);
+ Assert.Contains("One click Renew Is Not Available", result.StatusMessage);
+ }
+
+ [Fact]
+ 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.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse
+ {
+ Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = uuid } }
+ });
+
+ var certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid);
+ certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(30));
+
+ var plugin = MakePlugin(mockClient, certDataReader);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ ["PriorCertSN"] = "ABC123",
+ ["Applicant Last Name"] = "Doe"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+
+ 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_RenewOrReissue_OrderNearExpiryWithinWindow_TriggersRenewalEvenThoughCertNotExpired()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ // 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
+ {
+ 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 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));
+
+ var plugin = MakePlugin(mockClient, certDataReader);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ ["PriorCertSN"] = "ABC123",
+ ["Applicant Last Name"] = "Doe"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status);
+ mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Once);
+ mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Enroll_RenewOrReissue_OrderFarFromExpiry_TriggersReissueEvenThoughCertExpirationLooksExpired()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ // Order was placed 30 days ago -> expires in ~335 days, nowhere near the 30-day window.
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse
+ {
+ OrderDate = DateTime.UtcNow.AddDays(-30).ToString("o")
+ });
+ mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse
+ {
+ Result = new Result { CommonName = "reissued.example.com" }
+ });
+
+ var certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid);
+ // The locally-recorded cert expiration looks expired - under the old cert-expiry-only
+ // logic this would incorrectly route to a paid Renewal.
+ certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(-5));
+
+ var plugin = MakePlugin(mockClient, certDataReader);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ ["PriorCertSN"] = "ABC123",
+ ["Applicant Last Name"] = "Doe"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status);
+ mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once);
+ mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Enroll_RenewOrReissue_LiveCertFetchThrows_FallsBackToCertExpiryCheck()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("network error"));
+ mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse
+ {
+ Result = new Result { CommonName = "reissued.example.com" }
+ });
+
+ var certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid);
+ certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(30));
+
+ var plugin = MakePlugin(mockClient, certDataReader);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ ["PriorCertSN"] = "ABC123",
+ ["Applicant Last Name"] = "Doe"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status);
+ mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Enroll_RenewOrReissue_LiveCertOrderDateUnparsable_FallsBackToCertExpiryCheck()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse { OrderDate = null });
+ mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse
+ {
+ Result = new Result { CommonName = "renewed.example.com" }
+ });
+
+ 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",
+ ["Applicant Last Name"] = "Doe"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status);
+ mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Enroll_RenewOrReissue_FlowSummaryIncludesRenewalAnalysisDetail()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse
+ {
+ 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 certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid);
+ certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(60));
+
+ var plugin = MakePlugin(mockClient, certDataReader);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ ["PriorCertSN"] = "ABC123",
+ ["Applicant Last Name"] = "Doe"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+
+ Assert.NotNull(result.EnrollmentContext);
+ var decisionEntry = result.EnrollmentContext.Single(e => e.Key.Contains("DetermineRenewOrReissue"));
+ Assert.Contains("orderDate=", decisionEntry.Value);
+ Assert.Contains("isRenewal=True", decisionEntry.Value);
+ Assert.Contains(result.EnrollmentContext.Keys, k => k.Contains("FetchLiveCertForDecision"));
+ }
+
+ [Fact]
+ public async Task Enroll_New_Success_AttachesFlowSummaryAlongsideDcvContext()
+ {
+ // On success, StatusMessage isn't surfaced by Command's enrollment UI - only
+ // EnrollmentContext is - so the flow summary must ride alongside whatever DCV
+ // instructions came back, not replace them.
+ 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 = "new.example.com",
+ Status = new Status { Uuid = "uuid-new" },
+ DcvDetails = new List { new DcvDetail { Email = "admin@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.EXTERNALVALIDATION, result!.Status);
+ Assert.NotNull(result.EnrollmentContext);
+ Assert.Equal("admin@example.com", result.EnrollmentContext["admin@example.com"]);
+ Assert.Contains(result.EnrollmentContext.Keys, k => k.StartsWith("Flow: Enroll"));
+ Assert.Contains(result.EnrollmentContext.Keys, k => k.Contains("SubmitRegistration"));
+ }
+
+ [Fact]
+ public async Task Enroll_Reissue_LegacyProductName_SendsResolvedCertificateType()
+ {
+ // Full end-to-end proof that a Certificate Template still configured with a
+ // pre-1.2.0 product name reissues correctly against the current extension.
+ var uuid = Guid.NewGuid().ToString();
+ ReissueRequest capturedRequest = null!;
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+ mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny()))
+ .Callback(r => capturedRequest = r)
+ .ReturnsAsync(new ReissueResponse
+ {
+ Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = uuid } }
+ });
+
+ var certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid);
+ certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(30));
+
+ var plugin = MakePlugin(mockClient, certDataReader);
+ var productInfo = ProductInfo("CSC TrustedSecure UC Certificate", new Dictionary
+ {
+ ["PriorCertSN"] = "ABC123",
+ ["Applicant Last Name"] = "Doe"
+ });
+
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
+ RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status);
+ Assert.Equal("2", capturedRequest.CertificateType);
+ }
+
+ [Fact]
+ public async Task Enroll_Reissue_MissingApplicantLastName_ReturnsFailure()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+
+ var certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid);
+ certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(30));
+
+ 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("One click Reissue Is Not Available", result.StatusMessage);
+ }
+
+ [Fact]
+ public async Task Enroll_Reissue_RequestIdTooShort_ReturnsFailure()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+
+ var certDataReader = new Mock();
+ certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync("short-id");
+ certDataReader.Setup(r => r.GetExpirationDateByRequestId("short-id")).Returns(DateTime.Now.AddDays(30));
+
+ var plugin = MakePlugin(mockClient, certDataReader);
+ var productInfo = ProductInfo(parameters: new Dictionary
+ {
+ ["PriorCertSN"] = "ABC123",
+ ["Applicant Last Name"] = "Doe"
+ });
+
+ 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 Enroll_UnhandledEnrollmentType_ReturnsNull()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List());
+
+ var plugin = MakePlugin(mockClient);
+ var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(),
+ RequestFormat.PKCS10, EnrollmentType.Renew);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task Enroll_ClientThrows_ReturnsFailureWithFlowSummaryAndErrorDetail()
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitGetCustomFields()).ThrowsAsync(new InvalidOperationException("boom"));
+
+ 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("SubmitGetCustomFields", result.StatusMessage);
+ Assert.Contains("boom", result.StatusMessage);
+ }
+
+ // ---------------------------------------------------------------------
+ // Ping / ValidateCAConnectionInfo / ValidateProductInfo
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task Ping_DoesNotThrow()
+ {
+ var plugin = MakePlugin();
+ await plugin.Ping();
+ }
+
+ [Fact]
+ public async Task Ping_Disabled_DoesNotThrow()
+ {
+ var data = ValidConnectionData();
+ data[Constants.Enabled] = "false";
+ var plugin = MakePlugin(connectionData: data);
+ await plugin.Ping();
+ }
+
+ [Fact]
+ public async Task ValidateCAConnectionInfo_NullConnectionInfo_DoesNotThrow()
+ {
+ var plugin = MakePlugin();
+ await plugin.ValidateCAConnectionInfo(null!);
+ }
+
+ [Fact]
+ public async Task ValidateCAConnectionInfo_WithConnectionInfo_DoesNotThrow()
+ {
+ var plugin = MakePlugin();
+ await plugin.ValidateCAConnectionInfo(new Dictionary { ["Key"] = "Value" });
+ }
+
+ [Fact]
+ public async Task ValidateCAConnectionInfo_ExplicitlyDisabled_DoesNotThrow()
+ {
+ var plugin = MakePlugin();
+ await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "false" });
+ }
+
+ [Fact]
+ public async Task ValidateProductInfo_KnownProduct_DoesNotThrow()
+ {
+ var plugin = MakePlugin();
+ await plugin.ValidateProductInfo(ProductInfo("CSC TrustedSecure OV"), new Dictionary());
+ }
+
+ [Fact]
+ public async Task ValidateProductInfo_KnownProductDifferentCase_DoesNotThrow()
+ {
+ var plugin = MakePlugin();
+ await plugin.ValidateProductInfo(ProductInfo("csc trustedsecure ov"), new Dictionary());
+ }
+
+ [Fact]
+ public async Task ValidateProductInfo_UnknownProduct_Throws()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() =>
+ plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), new Dictionary()));
+ }
+
+ [Fact]
+ public async Task ValidateProductInfo_LegacyProductName_DoesNotThrow()
+ {
+ var plugin = MakePlugin();
+ await plugin.ValidateProductInfo(ProductInfo("CSC TrustedSecure UC Certificate"), new Dictionary());
+ }
+
+ [Fact]
+ public async Task ValidateProductInfo_DisabledConnector_SkipsValidationEvenForUnknownProduct()
+ {
+ 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_NullConnectionInfo_TreatsAsEnabled()
+ {
+ var plugin = MakePlugin();
+ await Assert.ThrowsAsync(() =>
+ plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), null!));
+ }
+
+ // ---------------------------------------------------------------------
+ // GetCAConnectorAnnotations / GetTemplateParameterAnnotations / GetProductIds
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void GetCAConnectorAnnotations_ContainsExpectedKeys()
+ {
+ var plugin = MakePlugin();
+ var annotations = plugin.GetCAConnectorAnnotations();
+
+ Assert.Equal(7, annotations.Count);
+ Assert.Contains(Constants.Enabled, annotations.Keys);
+ Assert.Contains(Constants.CscGlobalUrl, annotations.Keys);
+ Assert.Contains(Constants.CscGlobalApiKey, annotations.Keys);
+ Assert.Contains(Constants.BearerToken, annotations.Keys);
+ Assert.Contains(Constants.DefaultPageSize, annotations.Keys);
+ Assert.Contains(Constants.SyncFilterDays, annotations.Keys);
+ Assert.Contains(Constants.RenewalWindowDays, annotations.Keys);
+ Assert.True(annotations[Constants.CscGlobalApiKey].Hidden);
+ }
+
+ [Fact]
+ public void GetTemplateParameterAnnotations_ContainsExpectedKeys()
+ {
+ var plugin = MakePlugin();
+ var annotations = plugin.GetTemplateParameterAnnotations();
+
+ Assert.Equal(12, annotations.Count);
+ Assert.Contains(EnrollmentConfigConstants.Term, annotations.Keys);
+ Assert.Contains(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails, annotations.Keys);
+ }
+
+ [Fact]
+ public void GetProductIds_ReturnsFullList()
+ {
+ var plugin = MakePlugin();
+ var ids = plugin.GetProductIds();
+
+ Assert.Equal(10, ids.Count);
+ Assert.Contains("CSC TrustedSecure OV", ids);
+ Assert.Contains("CSC TrustedSecure DV Wildcard, Multiple Names", ids);
+ }
+
+ // ---------------------------------------------------------------------
+ // GetEndEntityCertificate
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void GetEndEntityCertificate_EmptyInput_ReturnsEmptyString()
+ {
+ var plugin = MakePlugin();
+ Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(""));
+ Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(" "));
+ Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(null!));
+ }
+
+ [Fact]
+ public void GetEndEntityCertificate_NoValidPemBlocks_ReturnsEmptyString()
+ {
+ var plugin = MakePlugin();
+ Assert.Equal(string.Empty, plugin.GetEndEntityCertificate("this is not a certificate"));
+ }
+
+ [Fact]
+ public void GetEndEntityCertificate_SingleLeafCert_ReturnsBase64Der()
+ {
+ var (cert, pem) = MakeSelfSignedCert();
+ var plugin = MakePlugin();
+
+ var result = plugin.GetEndEntityCertificate(pem);
+
+ Assert.Equal(Convert.ToBase64String(cert.RawData), result);
+ }
+
+ [Fact]
+ public void GetEndEntityCertificate_MalformedBase64Block_SkipsBlockReturnsEmpty()
+ {
+ var pem = "-----BEGIN CERTIFICATE-----\nNOT-VALID-BASE64!!!\n-----END CERTIFICATE-----\n";
+ var plugin = MakePlugin();
+
+ Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem));
+ }
+
+ [Fact]
+ public void GetEndEntityCertificate_EmptyBlockContent_Skipped()
+ {
+ var pem = "-----BEGIN CERTIFICATE-----\n\n-----END CERTIFICATE-----\n";
+ var plugin = MakePlugin();
+
+ Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem));
+ }
+
+ [Fact]
+ public void GetEndEntityCertificate_ValidBase64ButNotACertificate_SkipsBlockReturnsEmpty()
+ {
+ var notACert = Convert.ToBase64String(Encoding.UTF8.GetBytes("this is not a certificate, just text"));
+ var pem = $"-----BEGIN CERTIFICATE-----\n{notACert}\n-----END CERTIFICATE-----\n";
+ var plugin = MakePlugin();
+
+ Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem));
+ }
+
+ [Fact]
+ public void GetEndEntityCertificate_TwoIndependentLeafCerts_ReturnsOneOfThem()
+ {
+ var (certA, pemA) = MakeSelfSignedCert("a.example.com");
+ var (certB, pemB) = MakeSelfSignedCert("b.example.com");
+ var plugin = MakePlugin();
+
+ var result = plugin.GetEndEntityCertificate(pemA + pemB);
+
+ Assert.True(result == Convert.ToBase64String(certA.RawData) || result == Convert.ToBase64String(certB.RawData));
+ }
+
+ [Fact]
+ public void GetEndEntityCertificate_LeafAndCaChain_ReturnsLeafOnly()
+ {
+ using var rsaCa = RSA.Create(2048);
+ var caReq = new CertificateRequest("CN=Test CA", rsaCa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ caReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
+ var caCert = caReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365));
+
+ using var rsaLeaf = RSA.Create(2048);
+ var leafReq = new CertificateRequest("CN=leaf.example.com", rsaLeaf, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ leafReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
+ var leafCert = leafReq.Create(caCert, DateTimeOffset.UtcNow.AddDays(-1), caCert.NotAfter.AddDays(-1),
+ Guid.NewGuid().ToByteArray());
+
+ string ToPemBlock(X509Certificate2 c) => "-----BEGIN CERTIFICATE-----\n" +
+ Convert.ToBase64String(c.RawData, Base64FormattingOptions.InsertLineBreaks) +
+ "\n-----END CERTIFICATE-----\n";
+
+ var chainPem = ToPemBlock(caCert) + ToPemBlock(leafCert);
+ var plugin = MakePlugin();
+
+ var result = plugin.GetEndEntityCertificate(chainPem);
+
+ Assert.Equal(Convert.ToBase64String(leafCert.RawData), result);
+ }
+}
diff --git a/cscglobal-caplugin.Tests/CscGlobalClientTests.cs b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs
new file mode 100644
index 0000000..2dbeeae
--- /dev/null
+++ b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs
@@ -0,0 +1,401 @@
+// 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.Net;
+using System.Net.Http;
+using System.Text;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models;
+using Xunit;
+
+namespace CscGlobalCAPluginTests;
+
+public class CscGlobalClientTests
+{
+ private sealed class FakeConfigProvider : IAnyCAPluginConfigProvider
+ {
+ public Dictionary CAConnectionData { get; set; } = new();
+ }
+
+ private sealed class FakeHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly Func _responder;
+
+ public FakeHttpMessageHandler(Func responder)
+ {
+ _responder = responder;
+ }
+
+ public HttpRequestMessage? LastRequest { get; private set; }
+ public string? LastRequestBody { get; private set; }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ LastRequest = request;
+ LastRequestBody = request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult();
+ return Task.FromResult(_responder(request));
+ }
+ }
+
+ private static HttpResponseMessage JsonResponse(HttpStatusCode status, string json) =>
+ new HttpResponseMessage(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
+
+ private static IAnyCAPluginConfigProvider ValidConfig() => new FakeConfigProvider
+ {
+ CAConnectionData = new Dictionary
+ {
+ [Constants.CscGlobalUrl] = "https://api.csc.test",
+ [Constants.CscGlobalApiKey] = "test-api-key",
+ [Constants.BearerToken] = "test-bearer-token"
+ }
+ };
+
+ private static CscGlobalClient MakeClient(Func responder,
+ out FakeHttpMessageHandler handler, IAnyCAPluginConfigProvider? config = null)
+ {
+ handler = new FakeHttpMessageHandler(responder);
+ return new CscGlobalClient(config ?? ValidConfig(), handler);
+ }
+
+ // ---------------------------------------------------------------------
+ // Constructor validation
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void Constructor_NullConfig_Throws()
+ {
+ Assert.Throws(() => new CscGlobalClient(null!));
+ }
+
+ [Fact]
+ public async Task Constructor_NullConnectionData_DoesNotThrowButClientIsInert()
+ {
+ var config = new FakeConfigProvider { CAConnectionData = null! };
+ var client = new CscGlobalClient(config);
+
+ await Assert.ThrowsAsync(() => client.SubmitGetCustomFields());
+ }
+
+ [Fact]
+ public async Task Constructor_MissingApiKeyEntry_DoesNotThrowButClientIsInert()
+ {
+ var config = new FakeConfigProvider { CAConnectionData = new Dictionary() };
+ var client = new CscGlobalClient(config);
+
+ await Assert.ThrowsAsync(() => client.SubmitGetCustomFields());
+ }
+
+ // ---------------------------------------------------------------------
+ // SubmitRegistrationAsync
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task SubmitRegistrationAsync_Success_ReturnsParsedResponse()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK,
+ "{\"result\":{\"commonName\":\"order-1\",\"price\":{\"currency\":\"USD\",\"total\":99.5}," +
+ "\"dcvDetails\":[{\"domainName\":\"example.com\",\"actionNeeded\":\"N\"}]}}"), out var handler);
+
+ var response = await client.SubmitRegistrationAsync(new RegistrationRequest());
+
+ Assert.NotNull(response.Result);
+ Assert.Equal("order-1", response.Result.CommonName);
+ Assert.Equal("USD", response.Result.Price.Currency);
+ Assert.Equal(99.5m, response.Result.Price.Total!.Value);
+ Assert.Equal("example.com", response.Result.DcvDetails[0].DomainName);
+ Assert.Equal("N", response.Result.DcvDetails[0].ActionNeeded);
+ Assert.Contains("/dbs/api/v2/tls/registration", handler.LastRequest!.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task SubmitRegistrationAsync_BadRequest_ReturnsRegistrationError()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest,
+ "{\"description\":\"invalid csr\"}"), out _);
+
+ var response = await client.SubmitRegistrationAsync(new RegistrationRequest());
+
+ Assert.NotNull(response.RegistrationError);
+ Assert.Equal("invalid csr", response.RegistrationError.Description);
+ Assert.Null(response.Result);
+ }
+
+ [Fact]
+ public async Task SubmitRegistrationAsync_OtherError_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitRegistrationAsync(new RegistrationRequest()));
+ }
+
+ [Fact]
+ public async Task SubmitRegistrationAsync_UnparsableSuccessBody_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitRegistrationAsync(new RegistrationRequest()));
+ }
+
+ // ---------------------------------------------------------------------
+ // SubmitRenewalAsync
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task SubmitRenewalAsync_Success_ReturnsParsedResponse()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK,
+ "{\"result\":{\"commonName\":\"renew-1\"}}"), out var handler);
+
+ var response = await client.SubmitRenewalAsync(new RenewalRequest());
+
+ Assert.Equal("renew-1", response.Result.CommonName);
+ Assert.Contains("/dbs/api/v2/tls/renewal", handler.LastRequest!.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task SubmitRenewalAsync_BadRequest_ReturnsRegistrationError()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"nope\"}"), out _);
+
+ var response = await client.SubmitRenewalAsync(new RenewalRequest());
+
+ Assert.Equal("nope", response.RegistrationError.Description);
+ }
+
+ [Fact]
+ public async Task SubmitRenewalAsync_OtherError_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitRenewalAsync(new RenewalRequest()));
+ }
+
+ [Fact]
+ public async Task SubmitRenewalAsync_UnparsableSuccessBody_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitRenewalAsync(new RenewalRequest()));
+ }
+
+ // ---------------------------------------------------------------------
+ // SubmitReissueAsync
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task SubmitReissueAsync_Success_ReturnsParsedResponse()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK,
+ "{\"result\":{\"commonName\":\"reissue-1\"}}"), out var handler);
+
+ var response = await client.SubmitReissueAsync(new ReissueRequest());
+
+ Assert.Equal("reissue-1", response.Result.CommonName);
+ Assert.Contains("/dbs/api/v2/tls/reissue", handler.LastRequest!.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task SubmitReissueAsync_NullPriceTotal_DoesNotThrow()
+ {
+ // Real CSC Global response observed in production: "price.total" comes back null
+ // for a reissue where the certificate is not in a reissuable status. Price.Total
+ // must be nullable or this throws a JsonSerializationException instead of letting
+ // the caller see the RegistrationError/order status.
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK,
+ "{\"result\":{\"commonName\":\"reissue-2\",\"price\":{\"currency\":\"USD\",\"total\":null}}}"), out _);
+
+ var response = await client.SubmitReissueAsync(new ReissueRequest());
+
+ Assert.Equal("reissue-2", response.Result.CommonName);
+ Assert.Null(response.Result.Price.Total);
+ }
+
+ [Fact]
+ public async Task SubmitReissueAsync_BadRequest_ReturnsRegistrationError()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"denied\"}"), out _);
+
+ var response = await client.SubmitReissueAsync(new ReissueRequest());
+
+ Assert.Equal("denied", response.RegistrationError.Description);
+ }
+
+ [Fact]
+ public async Task SubmitReissueAsync_OtherError_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitReissueAsync(new ReissueRequest()));
+ }
+
+ [Fact]
+ public async Task SubmitReissueAsync_UnparsableSuccessBody_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitReissueAsync(new ReissueRequest()));
+ }
+
+ // ---------------------------------------------------------------------
+ // SubmitGetCertificateAsync
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task SubmitGetCertificateAsync_Success_ReturnsParsedResponse()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"certificate\":\"abc123\"}"), out var handler);
+
+ var response = await client.SubmitGetCertificateAsync("cert-uuid");
+
+ Assert.Equal("abc123", response.Certificate);
+ Assert.Contains("/dbs/api/v2/tls/certificate/cert-uuid", handler.LastRequest!.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task SubmitGetCertificateAsync_Failure_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.NotFound, "not found"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync("missing-uuid"));
+ }
+
+ [Fact]
+ public async Task SubmitGetCertificateAsync_UnparsableSuccessBody_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync("cert-uuid"));
+ }
+
+ // ---------------------------------------------------------------------
+ // SubmitGetCustomFields
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task SubmitGetCustomFields_Success_ReturnsList()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK,
+ "{\"customFields\":[{\"label\":\"Field1\",\"mandatory\":true}]}"), out var handler);
+
+ var fields = await client.SubmitGetCustomFields();
+
+ Assert.Single(fields);
+ Assert.Equal("Field1", fields[0].Label);
+ Assert.Contains("/dbs/api/v2/admin/customfields", handler.LastRequest!.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task SubmitGetCustomFields_NullCustomFieldsArray_ReturnsEmptyList()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _);
+
+ var fields = await client.SubmitGetCustomFields();
+
+ Assert.Empty(fields);
+ }
+
+ [Fact]
+ public async Task SubmitGetCustomFields_Failure_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitGetCustomFields());
+ }
+
+ [Fact]
+ public async Task SubmitGetCustomFields_UnparsableBody_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitGetCustomFields());
+ }
+
+ // ---------------------------------------------------------------------
+ // SubmitRevokeCertificateAsync
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task SubmitRevokeCertificateAsync_Success_ReturnsParsedResponse()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK,
+ "{\"revokeSuccess\":{\"commonName\":\"revoked.example.com\",\"certificateType\":\"4\",\"status\":\"REVOKED\"}}"), out var handler);
+
+ var response = await client.SubmitRevokeCertificateAsync("revoke-uuid");
+
+ Assert.Equal("revoked.example.com", response.RevokeSuccess.CommonName);
+ Assert.Equal("4", response.RevokeSuccess.CertificateType);
+ Assert.Equal("REVOKED", response.RevokeSuccess.Status);
+ Assert.Contains("/dbs/api/v2/tls/revoke/revoke-uuid", handler.LastRequest!.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task SubmitRevokeCertificateAsync_BadRequest_ReturnsRegistrationError()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"already revoked\"}"), out _);
+
+ var response = await client.SubmitRevokeCertificateAsync("revoke-uuid");
+
+ Assert.Equal("already revoked", response.RegistrationError.Description);
+ }
+
+ [Fact]
+ public async Task SubmitRevokeCertificateAsync_OtherError_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitRevokeCertificateAsync("revoke-uuid"));
+ }
+
+ [Fact]
+ public async Task SubmitRevokeCertificateAsync_UnparsableSuccessBody_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitRevokeCertificateAsync("revoke-uuid"));
+ }
+
+ // ---------------------------------------------------------------------
+ // SubmitCertificateListRequestAsync
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task SubmitCertificateListRequestAsync_NoDateFilter_Success()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK,
+ "{\"meta\":{\"numResults\":1},\"results\":[{\"uuid\":\"cert-1\"}]}"), out var handler);
+
+ var response = await client.SubmitCertificateListRequestAsync();
+
+ Assert.Single(response.Results);
+ Assert.Equal(1, response.Meta.NumResults);
+ Assert.DoesNotContain("effectiveDate", handler.LastRequest!.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task SubmitCertificateListRequestAsync_WithDateFilter_IncludesFilterInQuery()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"results\":[]}"), out var handler);
+
+ await client.SubmitCertificateListRequestAsync("2026/01/01");
+
+ Assert.Contains("effectiveDate=ge=2026/01/01", handler.LastRequest!.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task SubmitCertificateListRequestAsync_Failure_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitCertificateListRequestAsync());
+ }
+
+ [Fact]
+ public async Task SubmitCertificateListRequestAsync_UnparsableBody_Throws()
+ {
+ var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _);
+
+ await Assert.ThrowsAsync(() => client.SubmitCertificateListRequestAsync());
+ }
+}
diff --git a/cscglobal-caplugin.Tests/FlowLoggerTests.cs b/cscglobal-caplugin.Tests/FlowLoggerTests.cs
new file mode 100644
index 0000000..6c71329
--- /dev/null
+++ b/cscglobal-caplugin.Tests/FlowLoggerTests.cs
@@ -0,0 +1,221 @@
+// 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 Keyfactor.Extensions.CAPlugin.CSCGlobal;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace CscGlobalCAPluginTests;
+
+public class FlowLoggerTests
+{
+ private static Mock NewLoggerMock()
+ {
+ var mock = new Mock();
+ mock.Setup(l => l.IsEnabled(It.IsAny())).Returns(true);
+ return mock;
+ }
+
+ [Fact]
+ public void Constructor_NullLogger_Throws()
+ {
+ Assert.Throws(() => new FlowLogger(null!, "Flow"));
+ }
+
+ [Fact]
+ public void Constructor_NullFlowName_Throws()
+ {
+ Assert.Throws(() => new FlowLogger(NewLoggerMock().Object, null!));
+ }
+
+ [Fact]
+ public void Step_NoDetail_DoesNotThrowAndMarksNoFailure()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ flow.Step("StepOne");
+ Assert.False(flow.HasFailures);
+ }
+
+ [Fact]
+ public void Step_WithDetail_DoesNotThrow()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ flow.Step("StepOne", "some detail");
+ Assert.False(flow.HasFailures);
+ }
+
+ [Fact]
+ public void Step_Action_Success_RecordsSuccess()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ var ran = false;
+ flow.Step("Action", () => ran = true);
+ Assert.True(ran);
+ Assert.False(flow.HasFailures);
+ }
+
+ [Fact]
+ public void Step_Action_Throws_RecordsFailureAndRethrows()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ Assert.Throws(() =>
+ flow.Step("Action", () => throw new InvalidOperationException("boom")));
+ Assert.True(flow.HasFailures);
+ }
+
+ [Fact]
+ public void Step_ActionWithDetail_Success()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ flow.Step("Action", () => { }, "detail");
+ Assert.False(flow.HasFailures);
+ }
+
+ [Fact]
+ public async Task StepAsync_NoReturnValue_Success()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ await flow.StepAsync("AsyncStep", () => Task.CompletedTask);
+ Assert.False(flow.HasFailures);
+ }
+
+ [Fact]
+ public async Task StepAsync_NoReturnValue_Throws_RecordsFailureAndRethrows()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ await Assert.ThrowsAsync(() =>
+ flow.StepAsync("AsyncStep", () => throw new InvalidOperationException("boom")));
+ Assert.True(flow.HasFailures);
+ }
+
+ [Fact]
+ public async Task StepAsync_WithReturnValue_Success_ReturnsResult()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ var result = await flow.StepAsync("AsyncStep", () => Task.FromResult(42));
+ Assert.Equal(42, result);
+ Assert.False(flow.HasFailures);
+ }
+
+ [Fact]
+ public async Task StepAsync_WithReturnValue_Throws_RecordsFailureAndRethrows()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ await Assert.ThrowsAsync(() =>
+ flow.StepAsync("AsyncStep", () => throw new InvalidOperationException("boom")));
+ Assert.True(flow.HasFailures);
+ }
+
+ [Fact]
+ public void StepFunc_Success_ReturnsResult()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ var result = flow.Step("Func", () => 99);
+ Assert.Equal(99, result);
+ Assert.False(flow.HasFailures);
+ }
+
+ [Fact]
+ public void StepFunc_Throws_RecordsFailureAndRethrows()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ Assert.Throws(() =>
+ flow.Step("Func", () => throw new InvalidOperationException("boom")));
+ Assert.True(flow.HasFailures);
+ }
+
+ [Fact]
+ public void Fail_RecordsFailure()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ flow.Fail("StepOne", "reason");
+ Assert.True(flow.HasFailures);
+ }
+
+ [Fact]
+ public void Skip_DoesNotRecordFailure()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ flow.Skip("StepOne", "not applicable");
+ Assert.False(flow.HasFailures);
+ }
+
+ [Fact]
+ public void Branch_EndBranch_RoundTrips()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ flow.Branch("Inner");
+ flow.Step("NestedStep");
+ flow.EndBranch();
+ Assert.False(flow.HasFailures);
+ }
+
+ [Fact]
+ public void EndBranch_WithoutBranch_DoesNotThrow()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ flow.EndBranch();
+ }
+
+ [Fact]
+ public void GetSummary_IncludesAllStepKindsAndCounts()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ flow.Step("Ok");
+ flow.Skip("Skipped", "n/a");
+ flow.Fail("Failed", "bad");
+
+ var summary = flow.GetSummary();
+
+ Assert.Contains("FAILED", summary);
+ Assert.Contains("Steps: 3 total, 1 ok, 1 failed, 1 skipped", summary);
+ Assert.Contains("[OK]", summary);
+ Assert.Contains("[FAIL]", summary);
+ Assert.Contains("[SKIP]", summary);
+ }
+
+ [Fact]
+ public void GetSummary_NoFailures_ReportsOk()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ flow.Step("Ok");
+ Assert.Contains("[OK]", flow.GetSummary());
+ Assert.DoesNotContain("FAILED", flow.GetSummary());
+ }
+
+ [Fact]
+ public void GetSummaryEntries_OneEntryPerStepPlusOverview()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ flow.Step("Ok");
+ flow.Skip("Skipped", "n/a");
+ flow.Fail("Failed", "bad");
+
+ var entries = flow.GetSummaryEntries();
+
+ // 1 overview entry + 3 step entries.
+ Assert.Equal(4, entries.Count);
+ Assert.Contains(entries.Keys, k => k.StartsWith("Flow: Flow"));
+ Assert.Contains("FAILED", entries.Single(e => e.Key.StartsWith("Flow: Flow")).Value);
+ Assert.Contains(entries, e => e.Key.EndsWith(": Ok") && e.Value.StartsWith("[OK]"));
+ Assert.Contains(entries, e => e.Key.EndsWith(": Skipped") && e.Value.Contains("n/a"));
+ Assert.Contains(entries, e => e.Key.EndsWith(": Failed") && e.Value.Contains("bad"));
+ }
+
+ [Fact]
+ public void GetSummaryEntries_NoSteps_ReturnsOnlyOverview()
+ {
+ using var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ var entries = flow.GetSummaryEntries();
+ Assert.Single(entries);
+ }
+
+ [Fact]
+ public void Dispose_DoesNotThrow()
+ {
+ var flow = new FlowLogger(NewLoggerMock().Object, "Flow");
+ flow.Step("Ok");
+ flow.Dispose();
+ }
+}
diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs
new file mode 100644
index 0000000..ad82f3d
--- /dev/null
+++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs
@@ -0,0 +1,762 @@
+// 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 Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models;
+using Keyfactor.PKI.Enums.EJBCA;
+using Xunit;
+
+namespace CscGlobalCAPluginTests;
+
+public class RequestManagerTests
+{
+ private const string SampleCsr = "sample-csr-body";
+
+ private static EnrollmentProductInfo ProductInfo(string productId, Dictionary? parameters = null) =>
+ new EnrollmentProductInfo
+ {
+ ProductID = productId,
+ ProductParameters = parameters ?? new Dictionary()
+ };
+
+ private static RequestManager Manager => new RequestManager();
+
+ // ---------------------------------------------------------------------
+ // GetRenewResponse
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void GetRenewResponse_NullResponse_ReturnsFailed()
+ {
+ var result = Manager.GetRenewResponse(null);
+ Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
+ Assert.Contains("no response", result.StatusMessage);
+ }
+
+ [Fact]
+ public void GetRenewResponse_RegistrationError_ReturnsFailedWithDescription()
+ {
+ var response = new RenewalResponse
+ {
+ RegistrationError = new RegistrationError { Description = "boom" },
+ Result = new Result { Status = new Status { Uuid = "abc-123" } }
+ };
+
+ var result = Manager.GetRenewResponse(response);
+
+ Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
+ Assert.Equal("boom", result.StatusMessage);
+ Assert.Equal("abc-123", result.CARequestID);
+ }
+
+ [Fact]
+ public void GetRenewResponse_NullResult_ReturnsFailed()
+ {
+ var response = new RenewalResponse { Result = null };
+ var result = Manager.GetRenewResponse(response);
+ Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
+ Assert.Contains("no result", result.StatusMessage);
+ }
+
+ [Fact]
+ public void GetRenewResponse_Success_ReturnsExternalValidation()
+ {
+ // CSC Global never returns an issued certificate on the renewal response itself - it
+ // still needs DCV, so this must not claim GENERATED (Command would then try to parse
+ // a certificate off a result that doesn't have one).
+ var response = new RenewalResponse
+ {
+ Result = new Result { CommonName = "renewed.example.com", Status = new Status { Uuid = "uuid-renew" } }
+ };
+ var result = Manager.GetRenewResponse(response);
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ Assert.Equal("uuid-renew", result.CARequestID);
+ Assert.Contains("renewed.example.com", result.StatusMessage);
+ }
+
+ [Fact]
+ public void GetRenewResponse_Success_WithDcvDetails_PopulatesEnrollmentContext()
+ {
+ var response = new RenewalResponse
+ {
+ Result = new Result
+ {
+ CommonName = "renewed.example.com",
+ Status = new Status { Uuid = "uuid-renew" },
+ DcvDetails = new List
+ {
+ new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } }
+ }
+ }
+ };
+ var result = Manager.GetRenewResponse(response);
+ Assert.NotNull(result.EnrollmentContext);
+ Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]);
+ }
+
+ // ---------------------------------------------------------------------
+ // GetEnrollmentResult
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void GetEnrollmentResult_NullResponse_ReturnsFailed()
+ {
+ var result = Manager.GetEnrollmentResult(null);
+ Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
+ }
+
+ [Fact]
+ public void GetEnrollmentResult_RegistrationError_ReturnsFailed()
+ {
+ var response = new RegistrationResponse { RegistrationError = new RegistrationError { Description = "bad request" } };
+ var result = Manager.GetEnrollmentResult(response);
+ Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
+ Assert.Equal("bad request", result.StatusMessage);
+ }
+
+ [Fact]
+ public void GetEnrollmentResult_NullResult_ReturnsFailed()
+ {
+ var response = new RegistrationResponse { Result = null };
+ var result = Manager.GetEnrollmentResult(response);
+ Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
+ }
+
+ [Fact]
+ public void GetEnrollmentResult_SuccessNoDcvDetails_ReturnsExternalValidationWithNullContext()
+ {
+ var response = new RegistrationResponse
+ {
+ Result = new Result { CommonName = "order-1", Status = new Status { Uuid = "uuid-1" } }
+ };
+
+ var result = Manager.GetEnrollmentResult(response);
+
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ Assert.Equal("uuid-1", result.CARequestID);
+ Assert.Null(result.EnrollmentContext);
+ }
+
+ [Fact]
+ public void GetEnrollmentResult_WithCNameAndEmailDcvDetails_PopulatesEnrollmentContext()
+ {
+ var response = new RegistrationResponse
+ {
+ Result = new Result
+ {
+ CommonName = "order-2",
+ Status = new Status { Uuid = "uuid-2" },
+ DcvDetails = new List
+ {
+ new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } },
+ new DcvDetail { Email = "admin@example.com" },
+ // Duplicate email key should not throw and should not be added twice.
+ new DcvDetail { Email = "admin@example.com" },
+ // Entry with neither CName nor Email contributes nothing.
+ new DcvDetail()
+ }
+ }
+ };
+
+ var result = Manager.GetEnrollmentResult(response);
+
+ Assert.NotNull(result.EnrollmentContext);
+ Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]);
+ Assert.Equal("admin@example.com", result.EnrollmentContext["admin@example.com"]);
+ Assert.Equal(2, result.EnrollmentContext.Count);
+ }
+
+ [Fact]
+ public void GetEnrollmentResult_CnameMultiSanSharingBaseDomain_SingleDcvDetailCoversAllNames()
+ {
+ // Real CSC Global behavior: a wildcard CN plus two SAN entries all under the same
+ // registrable base domain ("boingy.com") only produces one dcvDetails entry for that
+ // base domain - CSC does not return one entry per requested SAN name.
+ var response = new RegistrationResponse
+ {
+ Result = new Result
+ {
+ CommonName = "*.boingy.com",
+ Status = new Status { Uuid = "b7094337-3fbb-4a3d-9a5c-3c2ea3394e92" },
+ DcvDetails = new List
+ {
+ new DcvDetail
+ {
+ DomainName = "boingy.com",
+ ActionNeeded = "Y",
+ Email = null,
+ CName = new CName
+ {
+ Name = "_489058F15A9A86BCDE6FAE1835C64424.boingy.com.",
+ Value = "87754be597cffd626f3fd036ba2d21c0.1f782fab3e3a78e17fb68b2efcf391f6.0189912763034de0b9bc.sectigo.com."
+ }
+ }
+ }
+ }
+ };
+
+ var result = Manager.GetEnrollmentResult(response);
+
+ Assert.NotNull(result.EnrollmentContext);
+ Assert.Single(result.EnrollmentContext);
+ Assert.Equal(
+ "87754be597cffd626f3fd036ba2d21c0.1f782fab3e3a78e17fb68b2efcf391f6.0189912763034de0b9bc.sectigo.com.",
+ result.EnrollmentContext["_489058F15A9A86BCDE6FAE1835C64424.boingy.com."]);
+ }
+
+ // ---------------------------------------------------------------------
+ // GetRevokeResult
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void GetRevokeResult_NullResponse_ReturnsFailed()
+ {
+ Assert.Equal((int)EndEntityStatus.FAILED, Manager.GetRevokeResult(null));
+ }
+
+ [Fact]
+ public void GetRevokeResult_RegistrationError_ReturnsFailed()
+ {
+ var response = new RevokeResponse { RegistrationError = new RegistrationError { Description = "nope" } };
+ Assert.Equal((int)EndEntityStatus.FAILED, Manager.GetRevokeResult(response));
+ }
+
+ [Fact]
+ public void GetRevokeResult_Success_ReturnsRevoked()
+ {
+ var response = new RevokeResponse();
+ Assert.Equal((int)EndEntityStatus.REVOKED, Manager.GetRevokeResult(response));
+ }
+
+ // ---------------------------------------------------------------------
+ // GetReIssueResult
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void GetReIssueResult_NullResponse_ReturnsFailed()
+ {
+ var result = Manager.GetReIssueResult(null);
+ Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
+ }
+
+ [Fact]
+ public void GetReIssueResult_RegistrationError_ReturnsFailed()
+ {
+ var response = new ReissueResponse { RegistrationError = new RegistrationError { Description = "rejected" } };
+ var result = Manager.GetReIssueResult(response);
+ Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
+ Assert.Equal("rejected", result.StatusMessage);
+ }
+
+ [Fact]
+ public void GetReIssueResult_NullResult_ReturnsFailed()
+ {
+ var response = new ReissueResponse { Result = null };
+ var result = Manager.GetReIssueResult(response);
+ Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
+ }
+
+ [Fact]
+ public void GetReIssueResult_Success_ReturnsExternalValidation()
+ {
+ // Same as renewal - CSC Global still requires DCV before actually issuing the
+ // certificate, so a reissue submission must not claim GENERATED either.
+ var response = new ReissueResponse
+ {
+ Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = "uuid-3" } }
+ };
+ var result = Manager.GetReIssueResult(response);
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status);
+ Assert.Equal("uuid-3", result.CARequestID);
+ }
+
+ [Fact]
+ public void GetReIssueResult_Success_WithDcvDetails_PopulatesEnrollmentContext()
+ {
+ var response = new ReissueResponse
+ {
+ Result = new Result
+ {
+ CommonName = "reissued.example.com",
+ Status = new Status { Uuid = "uuid-3" },
+ DcvDetails = new List { new DcvDetail { Email = "admin@example.com" } }
+ }
+ };
+ var result = Manager.GetReIssueResult(response);
+ Assert.NotNull(result.EnrollmentContext);
+ Assert.Equal("admin@example.com", result.EnrollmentContext["admin@example.com"]);
+ }
+
+ // ---------------------------------------------------------------------
+ // GetDomainControlValidation (email-list overload)
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void GetDomainControlValidation_EmptyDomainName_ReturnsNull()
+ {
+ var result = Manager.GetDomainControlValidation("EMAIL", new[] { "admin@example.com" }, "");
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void GetDomainControlValidation_NullEmailArray_ReturnsNull()
+ {
+ var result = Manager.GetDomainControlValidation("EMAIL", null!, "example.com");
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void GetDomainControlValidation_MalformedEmailSkipped_NoMatchReturnsNull()
+ {
+ var result = Manager.GetDomainControlValidation("EMAIL", new[] { "not-an-email", " " }, "example.com");
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void GetDomainControlValidation_MatchingHostFound_ReturnsValidation()
+ {
+ var result = Manager.GetDomainControlValidation("EMAIL", new[] { "not-an-email", "admin@example.com" }, "www.example.com");
+ Assert.NotNull(result);
+ Assert.Equal("EMAIL", result.MethodType);
+ Assert.Contains("admin@example.com", result.EmailAddress);
+ }
+
+ [Fact]
+ public void GetDomainControlValidation_NoMatchingHost_ReturnsNull()
+ {
+ var result = Manager.GetDomainControlValidation("EMAIL", new[] { "admin@other.com" }, "www.example.com");
+ Assert.Null(result);
+ }
+
+ // ---------------------------------------------------------------------
+ // GetDomainControlValidation (single-email overload)
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void GetDomainControlValidation_SingleEmail_ReturnsValidationVerbatim()
+ {
+ var result = Manager.GetDomainControlValidation("CNAME", "admin@example.com");
+ Assert.Equal("CNAME", result.MethodType);
+ Assert.Equal("admin@example.com", result.EmailAddress);
+ }
+
+ // ---------------------------------------------------------------------
+ // MapReturnStatus
+ // ---------------------------------------------------------------------
+
+ public static IEnumerable