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 MapReturnStatusCases() + { + yield return new object?[] { "ACTIVE", (int)EndEntityStatus.GENERATED }; + yield return new object?[] { "Initial", (int)EndEntityStatus.INITIALIZED }; + yield return new object?[] { "Pending", (int)EndEntityStatus.INPROCESS }; + yield return new object?[] { "REVOKED", (int)EndEntityStatus.REVOKED }; + yield return new object?[] { "SOMETHING_ELSE", (int)EndEntityStatus.FAILED }; + yield return new object?[] { null, (int)EndEntityStatus.FAILED }; + } + + [Theory] + [MemberData(nameof(MapReturnStatusCases))] + public void MapReturnStatus_MapsExpectedStatus(string? cscStatus, int expected) + { + Assert.Equal(expected, Manager.MapReturnStatus(cscStatus!)); + } + + // --------------------------------------------------------------------- + // GetRegistrationRequest - certificate type routing + // --------------------------------------------------------------------- + + [Theory] + [InlineData("CSC TrustedSecure OV", "0", false, false)] + [InlineData("CSC TrustedSecure OV Wildcard", "1", false, false)] + [InlineData("CSC TrustedSecure OV, Multiple Names", "2", true, false)] + [InlineData("CSC TrustedSecure EV", "3", false, true)] + [InlineData("CSC TrustedSecure DV", "4", false, false)] + [InlineData("CSC TrustedSecure DV Wildcard", "5", false, false)] + [InlineData("CSC TrustedSecure DV, Multiple Names", "6", true, false)] + [InlineData("CSC TrustedSecure EV, Multiple Names", "7", true, true)] + [InlineData("CSC TrustedSecure OV Wildcard, Multiple Names", "8", true, false)] + [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names", "9", true, false)] + [InlineData("Some Unknown Product", "-1", false, false)] + // Legacy (pre-1.2.0) product names, kept as aliases for backward compatibility. + [InlineData("CSC TrustedSecure Premium Certificate", "0", false, false)] + [InlineData("CSC TrustedSecure Premium Wildcard Certificate", "1", false, false)] + [InlineData("CSC TrustedSecure UC Certificate", "2", true, false)] + [InlineData("CSC TrustedSecure EV Certificate", "3", false, true)] + [InlineData("CSC TrustedSecure Domain Validated SSL", "4", false, false)] + [InlineData("CSC TrustedSecure Domain Validated Wildcard SSL", "5", false, false)] + [InlineData("CSC TrustedSecure Domain Validated UC Certificate", "6", true, false)] + public void GetRegistrationRequest_RoutesCertificateTypeAndOptionalSections( + string productId, string expectedType, bool expectSans, bool expectEv) + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo(productId, new Dictionary + { + ["Domain Control Validation Method"] = "CNAME", + ["Organization Country"] = "US" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Equal(expectedType, request.CertificateType); + Assert.Equal(expectSans, request.SubjectAlternativeNames != null); + Assert.Equal(expectEv, request.EvCertificateDetails != null); + if (expectEv) + Assert.Equal("US", request.EvCertificateDetails.Country); + } + + [Fact] + public void GetRegistrationRequest_EncodesCsrAsBase64() + { + var productInfo = ProductInfo("CSC TrustedSecure OV"); + var request = Manager.GetRegistrationRequest(productInfo, "hello", new Dictionary(), new List()); + + var decoded = Convert.FromBase64String(request.Csr); + Assert.Equal("hello", System.Text.Encoding.UTF8.GetString(decoded)); + } + + [Fact] + public void GetRegistrationRequest_MandatoryCustomFieldMissing_Throws() + { + var productInfo = ProductInfo("CSC TrustedSecure OV"); + var customFields = new List { new GetCustomField { Label = "Required Field", Mandatory = true } }; + + Assert.Throws(() => + Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), customFields)); + } + + [Fact] + public void GetRegistrationRequest_NullCustomFields_ReturnsEmptyList() + { + var productInfo = ProductInfo("CSC TrustedSecure OV"); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), null!); + + Assert.Empty(request.CustomFields); + } + + [Fact] + public void GetRegistrationRequest_OptionalCustomFieldMissing_Skipped() + { + var productInfo = ProductInfo("CSC TrustedSecure OV"); + var customFields = new List { new GetCustomField { Label = "Optional Field", Mandatory = false } }; + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), customFields); + + Assert.Empty(request.CustomFields); + } + + [Fact] + public void GetRegistrationRequest_CustomFieldPresent_IsMapped() + { + var productInfo = ProductInfo("CSC TrustedSecure OV", new Dictionary { ["Custom Field"] = "value" }); + var customFields = new List { new GetCustomField { Label = "Custom Field", Mandatory = false } }; + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), customFields); + + Assert.Single(request.CustomFields); + Assert.Equal("value", request.CustomFields[0].Value); + } + + // --------------------------------------------------------------------- + // GetSubjectAlternativeNames (exercised via GetRegistrationRequest) + // --------------------------------------------------------------------- + + [Fact] + public void GetRegistrationRequest_MultiNameEmailMethod_MatchesAdditionalSanEmail() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "EMAIL", + [EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails] = "admin@example.com,admin@other.com" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Single(request.SubjectAlternativeNames); + var san = request.SubjectAlternativeNames[0]; + Assert.Equal("www.example.com", san.DomainName); + Assert.NotNull(san.DomainControlValidation); + Assert.Equal("admin@example.com", san.DomainControlValidation.EmailAddress); + } + + [Fact] + public void GetRegistrationRequest_MultiNameEmailMethodNoAddtlSanMatch_FallsBackToCommonNameDcvEmail() + { + // CSC Global rejects the request if a SAN entry has no domainControlValidation, so a SAN + // domain with no matching "Addtl Sans" email must fall back to the primary CN's DCV email. + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "EMAIL", + ["CN DCV Email"] = "cn@example.com" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Single(request.SubjectAlternativeNames); + var san = request.SubjectAlternativeNames[0]; + Assert.NotNull(san.DomainControlValidation); + Assert.Equal("cn@example.com", san.DomainControlValidation.EmailAddress); + } + + [Fact] + public void GetRegistrationRequest_MultiNameCnameMethod_MirrorsCommonNameDcv() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Single(request.SubjectAlternativeNames); + var san = request.SubjectAlternativeNames[0]; + Assert.NotNull(san.DomainControlValidation); + Assert.Equal("CNAME", san.DomainControlValidation.MethodType); + Assert.Equal(string.Empty, san.DomainControlValidation.EmailAddress); + } + + [Fact] + public void GetRegistrationRequest_MultiNameProductWithNoDnsNameKey_ReturnsEmptySanList() + { + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), new List()); + + Assert.NotNull(request.SubjectAlternativeNames); + Assert.Empty(request.SubjectAlternativeNames); + } + + [Fact] + public void GetRegistrationRequest_MultiNameProductWithNullSans_ReturnsEmptySanList() + { + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, null!, new List()); + + Assert.NotNull(request.SubjectAlternativeNames); + Assert.Empty(request.SubjectAlternativeNames); + } + + // --------------------------------------------------------------------- + // Per-product-type domain scenarios (based on the underlying Sectigo Multi-Domain/UCC + // and Multi-Domain Wildcard products CSC resells - see sectigo.com/ssl-certificates-tls/ + // multi-domain-san-ucc and sectigostore.com/ssl-types/multi-domain-wildcard). SANs on the + // Multiple Names products are not restricted to the CN's own base domain; unrelated domains, + // and wildcards for unrelated domains, are valid. Note the CN itself lives inside the CSR + // blob and isn't observable at this layer, so these only exercise SAN/EV/type routing. + // --------------------------------------------------------------------- + + public static IEnumerable ProductTypeDomainScenarios() + { + // productId, sanDomains, expectedCertificateType, expectSans, expectEv + yield return new object[] { "CSC TrustedSecure OV", Array.Empty(), "0", false, false }; + yield return new object[] { "CSC TrustedSecure OV Wildcard", Array.Empty(), "1", false, false }; + yield return new object[] + { + "CSC TrustedSecure OV, Multiple Names", + new[] { "www.example.com", "shop.example.net", "portal.othercompany.org" }, + "2", true, false + }; + yield return new object[] { "CSC TrustedSecure EV", Array.Empty(), "3", false, true }; + yield return new object[] { "CSC TrustedSecure DV", Array.Empty(), "4", false, false }; + yield return new object[] { "CSC TrustedSecure DV Wildcard", Array.Empty(), "5", false, false }; + yield return new object[] + { + "CSC TrustedSecure DV, Multiple Names", + new[] { "mail.example.com", "app.example.com", "www.unrelated-domain.io" }, + "6", true, false + }; + yield return new object[] + { + "CSC TrustedSecure EV, Multiple Names", + new[] { "www.example.com", "www.example-partner.com" }, + "7", true, true + }; + yield return new object[] + { + // Wildcard multi-name: additional wildcard SANs for entirely unrelated domains, + // per Sectigo's own "*.example1.com, *.example2.com, *.example3.com" example. + "CSC TrustedSecure OV Wildcard, Multiple Names", + new[] { "*.example2.com", "*.example3.com" }, + "8", true, false + }; + yield return new object[] + { + // Base domain + wildcard for the same domain as two separate SAN entries - + // Sectigo requires both explicitly since a wildcard SAN alone does not cover + // the bare base domain. + "CSC TrustedSecure DV Wildcard, Multiple Names", + new[] { "example.com", "*.example.com" }, + "9", true, false + }; + } + + [Theory] + [MemberData(nameof(ProductTypeDomainScenarios))] + public void GetRegistrationRequest_ProductTypeDomainScenarios_BuildsExpectedRequest( + string productId, string[] sanDomains, string expectedCertificateType, bool expectSans, bool expectEv) + { + var sans = sanDomains.Length > 0 + ? new Dictionary { ["dnsname"] = sanDomains } + : new Dictionary(); + var productInfo = ProductInfo(productId, new Dictionary + { + ["Domain Control Validation Method"] = "CNAME", + ["Organization Country"] = "US" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Equal(expectedCertificateType, request.CertificateType); + Assert.Equal(expectEv, request.EvCertificateDetails != null); + + if (!expectSans) + { + Assert.Null(request.SubjectAlternativeNames); + return; + } + + Assert.NotNull(request.SubjectAlternativeNames); + Assert.Equal(sanDomains.Length, request.SubjectAlternativeNames.Count); + Assert.Equal(sanDomains, request.SubjectAlternativeNames.Select(s => s.DomainName)); + Assert.All(request.SubjectAlternativeNames, s => Assert.NotNull(s.DomainControlValidation)); + } + + // --------------------------------------------------------------------- + // GetNotifications + // --------------------------------------------------------------------- + + [Fact] + public void GetNotifications_NoEmailsConfigured_ReturnsEmptyList() + { + var productInfo = ProductInfo("CSC TrustedSecure OV"); + var notifications = Manager.GetNotifications(productInfo); + Assert.True(notifications.Enabled); + Assert.Empty(notifications.AdditionalNotificationEmails); + } + + [Fact] + public void GetNotifications_EmailsConfigured_SplitsOnComma() + { + var productInfo = ProductInfo("CSC TrustedSecure OV", + new Dictionary { ["Notification Email(s) Comma Separated"] = "a@example.com,b@example.com" }); + + var notifications = Manager.GetNotifications(productInfo); + + Assert.Equal(2, notifications.AdditionalNotificationEmails.Count); + Assert.Contains("a@example.com", notifications.AdditionalNotificationEmails); + } + + // --------------------------------------------------------------------- + // GetRenewalRequest / GetReissueRequest - basic parity with GetRegistrationRequest + // --------------------------------------------------------------------- + + [Fact] + public void GetRenewalRequest_MultiNameProduct_PopulatesUuidAndSans() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure DV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRenewalRequest(productInfo, "uuid-123", SampleCsr, sans, new List()); + + Assert.Equal("uuid-123", request.Uuid); + Assert.Equal("6", request.CertificateType); + Assert.Single(request.SubjectAlternativeNames); + Assert.Null(request.EvCertificateDetails); + } + + [Fact] + public void GetRenewalRequest_LegacyProductName_ResolvesCertificateTypeAndSans() + { + // A Certificate Template in Command created before the 1.2.0 rename still carries the + // old product name; renewals against it must resolve the same as the canonical name. + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure Domain Validated UC Certificate", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRenewalRequest(productInfo, "uuid-legacy", SampleCsr, sans, new List()); + + Assert.Equal("6", request.CertificateType); + Assert.Single(request.SubjectAlternativeNames); + } + + [Fact] + public void GetRenewalRequest_EvProduct_PopulatesEvDetailsNoSans() + { + var productInfo = ProductInfo("CSC TrustedSecure EV", new Dictionary + { + ["Organization Country"] = "CA" + }); + + var request = Manager.GetRenewalRequest(productInfo, "uuid-456", SampleCsr, new Dictionary(), new List()); + + Assert.Equal("3", request.CertificateType); + Assert.Null(request.SubjectAlternativeNames); + Assert.NotNull(request.EvCertificateDetails); + Assert.Equal("CA", request.EvCertificateDetails.Country); + } + + [Fact] + public void GetReissueRequest_MultiNameProduct_PopulatesUuidAndSans() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV Wildcard, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetReissueRequest(productInfo, "uuid-789", SampleCsr, sans, new List()); + + Assert.Equal("uuid-789", request.Uuid); + Assert.Equal("8", request.CertificateType); + Assert.Single(request.SubjectAlternativeNames); + } + + [Fact] + public void GetReissueRequest_LegacyProductName_ResolvesCertificateType() + { + var productInfo = ProductInfo("CSC TrustedSecure EV Certificate", new Dictionary + { + ["Organization Country"] = "US" + }); + + var request = Manager.GetReissueRequest(productInfo, "uuid-legacy-2", SampleCsr, + new Dictionary(), new List()); + + Assert.Equal("3", request.CertificateType); + Assert.NotNull(request.EvCertificateDetails); + } + + [Fact] + public void GetReissueRequest_EvMultiNameProduct_PopulatesBothSansAndEvDetails() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure EV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME", + ["Organization Country"] = "GB" + }); + + var request = Manager.GetReissueRequest(productInfo, "uuid-000", SampleCsr, sans, new List()); + + Assert.Equal("7", request.CertificateType); + Assert.Single(request.SubjectAlternativeNames); + Assert.NotNull(request.EvCertificateDetails); + Assert.Equal("GB", request.EvCertificateDetails.Country); + } +} diff --git a/cscglobal-caplugin.Tests/coverlet.runsettings b/cscglobal-caplugin.Tests/coverlet.runsettings new file mode 100644 index 0000000..f1d0827 --- /dev/null +++ b/cscglobal-caplugin.Tests/coverlet.runsettings @@ -0,0 +1,12 @@ + + + + + + + cobertura + + + + + diff --git a/cscglobal-caplugin.sln b/cscglobal-caplugin.sln index 220a2cd..1fc79be 100644 --- a/cscglobal-caplugin.sln +++ b/cscglobal-caplugin.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 18.0.11217.181 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin", "cscglobal-caplugin\CSCGlobalCAPlugin.csproj", "{01DDFD6F-275D-46E7-B522-E0C965D1BF9C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin.Tests", "cscglobal-caplugin.Tests\CSCGlobalCAPlugin.Tests.csproj", "{7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" ProjectSection(SolutionItems) = preProject CHANGELOG.md = CHANGELOG.md @@ -25,6 +27,12 @@ Global {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|Any CPU.Build.0 = DebugAndPush|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|Any CPU.ActiveCfg = Release|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|Any CPU.Build.0 = Release|Any CPU + {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.DebugAndPush|Any CPU.ActiveCfg = Debug|Any CPU + {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.DebugAndPush|Any CPU.Build.0 = Debug|Any CPU + {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/cscglobal-caplugin/AssemblyInfo.cs b/cscglobal-caplugin/AssemblyInfo.cs new file mode 100644 index 0000000..dadad5f --- /dev/null +++ b/cscglobal-caplugin/AssemblyInfo.cs @@ -0,0 +1,10 @@ +// 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. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CSCGlobalCAPlugin.Tests")] diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index e1af2f0..33d2306 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -33,20 +33,46 @@ public CSCGlobalCAPlugin() _requestManager = new RequestManager(); } - private ICscGlobalClient CscGlobalClient { get; set; } + internal Func ClientFactory { get; set; } + = config => new CscGlobalClient(config); - public bool EnableTemplateSync { get; set; } + private IAnyCAPluginConfigProvider Config { get; set; } + + private ICscGlobalClient CscGlobalClient => ClientFactory(Config); public int SyncFilterDays { get; set; } + /// + /// Whether the CA is enabled. When false, operations (Enroll, Revoke, Synchronize, Ping) + /// reject or skip cleanly instead of calling the CSC Global API, so a CA record can be + /// created and saved before valid API credentials are available (or intentionally + /// disabled later without deleting the CA connection). Defaults to true. + /// + public bool Enabled { get; set; } = true; + + // CSC's order is a fixed 1-year paid subscription that a single cert renewal doesn't reset, + // so a shorter-lived cert (e.g. ~200 days) can come up for renewal well before its order + // actually expires. RenewOrReissue uses this window (days before order expiry) to decide + // whether to submit a paid Renewal or a free Reissue under the still-active order. + public int RenewalWindowDays { get; set; } + //done public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { Logger.MethodEntry(LogLevel.Debug); - _certificateDataReader = certificateDataReader; - CscGlobalClient = new CscGlobalClient(configProvider); - var templateSync = configProvider.CAConnectionData["TemplateSync"].ToString(); - if (templateSync.ToUpper() == "ON") EnableTemplateSync = true; + if (configProvider == null) throw new ArgumentNullException(nameof(configProvider)); + _certificateDataReader = certificateDataReader ?? throw new ArgumentNullException(nameof(certificateDataReader)); + Config = configProvider; + + Enabled = true; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.Enabled, out var enabledObj)) + { + if (bool.TryParse(enabledObj?.ToString(), out var parsed)) + Enabled = parsed; + else + Logger.LogWarning($"Could not parse {Constants.Enabled} value '{enabledObj}' as a bool; defaulting to true"); + } + Logger.LogInformation("CA is {State}.", Enabled ? "Enabled" : "Disabled"); if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) { @@ -56,7 +82,23 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa SyncFilterDays = syncFilterDays; Logger.LogDebug($"SyncFilterDays configured to {SyncFilterDays} days"); } + else + { + Logger.LogWarning($"Could not parse {Constants.SyncFilterDays} value '{syncFilterDaysStr}' as an integer; using default"); + } + } + + RenewalWindowDays = 30; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.RenewalWindowDays, out var renewalWindowObj)) + { + if (int.TryParse(renewalWindowObj?.ToString(), out var renewalWindowDays) && renewalWindowDays > 0) + RenewalWindowDays = renewalWindowDays; + else + Logger.LogWarning($"Could not parse {Constants.RenewalWindowDays} value '{renewalWindowObj}' as a positive integer; using default of {RenewalWindowDays} days"); } + Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays); + + Logger.LogInformation("CSCGlobalCAPlugin initialized successfully"); Logger.MethodExit(LogLevel.Debug); } @@ -66,7 +108,10 @@ public async Task GetSingleRecord(string caRequestID) try { Logger.MethodEntry(LogLevel.Debug); - var keyfactorCaId = caRequestID?.Substring(0, 36); //todo fix to use pipe delimiter + if (string.IsNullOrEmpty(caRequestID) || caRequestID.Length < 36) + throw new ArgumentException($"CA request ID '{caRequestID}' is missing or too short to contain a valid UUID", nameof(caRequestID)); + + var keyfactorCaId = caRequestID.Substring(0, 36); //todo fix to use pipe delimiter Logger.LogTrace($"Keyfactor Ca Id: {keyfactorCaId}"); var certificateResponse = Task.Run(async () => await CscGlobalClient.SubmitGetCertificateAsync(keyfactorCaId)) @@ -96,6 +141,7 @@ public async Task GetSingleRecord(string caRequestID) } catch (Exception e) { + Logger.LogError(e, "Error occurred getting single cert for CA request ID {CaRequestID}: {Message}", caRequestID, e.Message); throw new Exception($"Error Occurred getting single cert {e.Message}"); } } @@ -106,27 +152,48 @@ public async Task Synchronize(BlockingCollection blockin { Logger.LogTrace($"Full Sync? {fullSync.ToString()}"); Logger.MethodEntry(); + using var flow = new FlowLogger(Logger, "Synchronize"); + + if (!Enabled) + { + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping Synchronize."); + blockingBuffer.CompleteAdding(); + Logger.MethodExit(LogLevel.Debug); + return; + } + try { if (fullSync) { - Logger.LogDebug("Performing full sync - no date filter applied"); - await SyncCertificates(blockingBuffer, cancelToken, null); + Logger.LogInformation("Performing full sync - no date filter applied"); + flow.Step("DetermineSyncMode", "Full sync - no date filter applied"); + await SyncCertificates(blockingBuffer, cancelToken, null, flow); } else { var filterDays = SyncFilterDays > 0 ? SyncFilterDays : 5; var filterDate = DateTime.Today.Subtract(TimeSpan.FromDays(filterDays)); var dateFilter = filterDate.ToString("yyyy/MM/dd"); - Logger.LogDebug($"Performing incremental sync with expiration date filter: {dateFilter}"); - await SyncCertificates(blockingBuffer, cancelToken, dateFilter); + Logger.LogInformation($"Performing incremental sync with expiration date filter: {dateFilter}"); + flow.Step("DetermineSyncMode", $"Incremental sync with expiration date filter: {dateFilter}"); + await SyncCertificates(blockingBuffer, cancelToken, dateFilter, flow); } blockingBuffer.CompleteAdding(); + Logger.LogInformation("Csc Global Synchronize Task completed successfully"); + } + catch (OperationCanceledException) + { + Logger.LogWarning("Csc Global Synchronize Task was cancelled"); + flow.Fail("Synchronize", "Task was cancelled"); + blockingBuffer.CompleteAdding(); + throw; } catch (Exception e) { - Logger.LogError($"Csc Global Synchronize Task failed! {LogHandler.FlattenException(e)}"); + Logger.LogError(e, $"Csc Global Synchronize Task failed! {LogHandler.FlattenException(e)}"); + flow.Fail("Synchronize", e.Message); Logger.MethodExit(); blockingBuffer.CompleteAdding(); throw; @@ -136,10 +203,22 @@ public async Task Synchronize(BlockingCollection blockin } private async Task SyncCertificates(BlockingCollection blockingBuffer, - CancellationToken cancelToken, string? dateFilter) + CancellationToken cancelToken, string? dateFilter, FlowLogger flow) { - var certs = await CscGlobalClient.SubmitCertificateListRequestAsync(dateFilter); + var certs = await flow.StepAsync("SubmitCertificateListRequest", + () => CscGlobalClient.SubmitCertificateListRequestAsync(dateFilter)); + + Logger.LogInformation($"Retrieved {certs?.Results?.Count ?? 0} certificate(s) from CSC Global for sync"); + + if (certs?.Results == null) + { + Logger.LogWarning("Certificate list request returned no results collection; nothing to sync"); + flow.Step("QueueCertificates", "No results collection returned; nothing to sync"); + return; + } + var queuedCount = 0; + var skippedCount = 0; foreach (var currentResponseItem in certs.Results) { cancelToken.ThrowIfCancellationRequested(); @@ -150,9 +229,7 @@ private async Task SyncCertificates(BlockingCollection b if (certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || certStatus == Convert.ToInt32(EndEntityStatus.REVOKED)) { - //One click renewal/reissue won't work for this implementation so there is an option to disable it by not syncing back template - var productId = "CscGlobal"; - if (EnableTemplateSync) productId = currentResponseItem?.CertificateType; + var productId = currentResponseItem?.CertificateType; var fileContent = PreparePemTextFromApi( @@ -164,6 +241,8 @@ private async Task SyncCertificates(BlockingCollection b var certData = fileContent.Replace("\r\n", string.Empty); var certString = GetEndEntityCertificate(certData); if (certString.Length > 0) + { + Logger.LogTrace($"Queueing Certificate ID {currentResponseItem?.Uuid} with ProductID '{productId ?? "(null)"}'"); blockingBuffer.Add(new AnyCAPluginCertificate { CARequestID = $"{currentResponseItem?.Uuid}", @@ -171,36 +250,80 @@ private async Task SyncCertificates(BlockingCollection b Status = certStatus, ProductID = productId }, cancelToken); + queuedCount++; + } + else + { + Logger.LogWarning($"Could not extract end-entity certificate for {currentResponseItem?.Uuid}; skipping sync of this record"); + skippedCount++; + } + } + else + { + Logger.LogWarning($"No certificate content returned by CSC Global for {currentResponseItem?.Uuid}; skipping sync of this record"); + skippedCount++; } } + else + { + Logger.LogTrace($"Skipping Certificate ID {currentResponseItem?.Uuid} - status {currentResponseItem?.Status} is not eligible for sync"); + skippedCount++; + } } + + flow.Step("QueueCertificates", $"Queued {queuedCount}, skipped {skippedCount}"); + Logger.LogInformation($"Sync queued {queuedCount} certificate(s), skipped {skippedCount}"); } //done public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) { + Logger.MethodEntry(LogLevel.Debug); + using var flow = new FlowLogger(Logger, "Revoke"); + + if (!Enabled) + { + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Rejecting Revoke."); + throw new InvalidOperationException("The CSC Global CA is in the Disabled state. Enable it to perform revocations."); + } + try { - Logger.LogTrace("Staring Revoke Method"); - var revokeResponse = - Task.Run(async () => - await CscGlobalClient.SubmitRevokeCertificateAsync(caRequestID.Substring(0, 36))).Result - ; //todo fix to use pipe delimiter + Logger.LogInformation($"Starting Revoke for CA request ID {caRequestID}, reason {revocationReason}"); + if (string.IsNullOrEmpty(caRequestID) || caRequestID.Length < 36) + throw new ArgumentException($"CA request ID '{caRequestID}' is missing or too short to contain a valid UUID", nameof(caRequestID)); + + var uuid = caRequestID.Substring(0, 36); //todo fix to use pipe delimiter + + var revokeResponse = await flow.StepAsync("SubmitRevokeCertificate", + () => CscGlobalClient.SubmitRevokeCertificateAsync(uuid)); Logger.LogTrace($"Revoke Response JSON: {JsonConvert.SerializeObject(revokeResponse)}"); - Logger.MethodExit(LogLevel.Debug); var revokeResult = _requestManager.GetRevokeResult(revokeResponse); if (revokeResult == (int)EndEntityStatus.FAILED) + { if (!string.IsNullOrEmpty(revokeResponse?.RegistrationError?.Description)) + { + flow.Fail("SubmitRevokeCertificate", revokeResponse?.RegistrationError?.Description ?? "Unknown error"); throw new HttpRequestException( $"Revoke Failed with message {revokeResponse?.RegistrationError?.Description}"); + } + + Logger.LogWarning($"Revoke returned a failed status for CA request ID {caRequestID} with no error description"); + } + else + { + Logger.LogInformation($"Revoke succeeded for CA request ID {caRequestID}"); + } + Logger.MethodExit(LogLevel.Debug); return revokeResult; } catch (Exception e) { + Logger.LogError(e, $"Revoke Failed for CA request ID {caRequestID} with message {e?.Message}"); throw new Exception($"Revoke Failed with message {e?.Message}"); } } @@ -209,128 +332,288 @@ await CscGlobalClient.SubmitRevokeCertificateAsync(caRequestID.Substring(0, 36)) public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) { + if (productInfo == null) throw new ArgumentNullException(nameof(productInfo)); + Logger.MethodEntry(LogLevel.Debug); + Logger.LogInformation($"Starting Enroll for product {productInfo.ProductID}, enrollment type {enrollmentType}"); + using var flow = new FlowLogger(Logger, "Enroll"); - RegistrationRequest enrollmentRequest; - var priorSn = ""; - ReissueRequest reissueRequest; - RenewalRequest renewRequest; - if (productInfo.ProductParameters.ContainsKey("priorcertsn")) + if (!Enabled) { - priorSn = productInfo.ProductParameters["PriorCertSN"]; - Logger.LogDebug($"Prior cert sn: {priorSn}"); + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Rejecting Enroll."); + flow.Fail("Enroll", "CA is Disabled"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nThe CSC Global CA is in the Disabled state. Enable it to perform enrollments." + }; } - string uUId; - var customFields = await CscGlobalClient.SubmitGetCustomFields(); - - switch (enrollmentType) + try { - case EnrollmentType.New: - Logger.LogTrace("Entering New Enrollment"); - //If they renewed an expired cert it gets here and this will not be supported - IRegistrationResponse enrollmentResponse; - if (!productInfo.ProductParameters.ContainsKey("PriorCertSN")) - { - enrollmentRequest = _requestManager.GetRegistrationRequest(productInfo, csr, san, customFields); - Logger.LogTrace($"Enrollment Request JSON: {JsonConvert.SerializeObject(enrollmentRequest)}"); - enrollmentResponse = - Task.Run(async () => await CscGlobalClient.SubmitRegistrationAsync(enrollmentRequest)) - .Result; - Logger.LogTrace($"Enrollment Response JSON: {JsonConvert.SerializeObject(enrollmentResponse)}"); - } - else - { - return new EnrollmentResult + RegistrationRequest enrollmentRequest; + var priorSn = ""; + ReissueRequest reissueRequest; + RenewalRequest renewRequest; + var productParameters = productInfo.ProductParameters ?? new Dictionary(); + if (productParameters.ContainsKey("PriorCertSN")) + { + productParameters.TryGetValue("PriorCertSN", out priorSn); + priorSn ??= ""; + Logger.LogDebug($"Prior cert sn: {priorSn}"); + } + + string uUId; + var customFields = await flow.StepAsync("SubmitGetCustomFields", () => CscGlobalClient.SubmitGetCustomFields()); + + switch (enrollmentType) + { + case EnrollmentType.New: + flow.Branch("New Enrollment"); + //If they renewed an expired cert it gets here and this will not be supported + IRegistrationResponse enrollmentResponse; + if (!productParameters.ContainsKey("PriorCertSN")) { - Status = 30, //failure - StatusMessage = "You cannot renew an expired cert please perform an new enrollment." - }; - } + enrollmentRequest = _requestManager.GetRegistrationRequest(productInfo, csr, san, customFields); + Logger.LogTrace($"Enrollment Request JSON: {JsonConvert.SerializeObject(enrollmentRequest)}"); + enrollmentResponse = await flow.StepAsync("SubmitRegistration", + () => CscGlobalClient.SubmitRegistrationAsync(enrollmentRequest)); + Logger.LogTrace($"Enrollment Response JSON: {JsonConvert.SerializeObject(enrollmentResponse)}"); + } + else + { + Logger.LogWarning("Cannot renew an expired cert via new enrollment; a new enrollment must be performed instead"); + flow.Fail("New Enrollment", "Attempted to renew an expired cert via new enrollment"); + flow.EndBranch(); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nYou cannot renew an expired cert please perform an new enrollment." + }; + } - Logger.MethodExit(LogLevel.Debug); - return _requestManager.GetEnrollmentResult(enrollmentResponse); - case EnrollmentType.RenewOrReissue: - Logger.LogTrace("Entering Renew Enrollment"); - //Logic to determine renew vs reissue - var renewal = false; - var order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); - var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id); - if (expirationDate == null) - { - var localcert = await GetSingleRecord(order_id); - expirationDate = localcert.RevocationDate; - } + flow.EndBranch(); + var newResult = _requestManager.GetEnrollmentResult(enrollmentResponse); + AttachFlowSummary(newResult, flow); + LogEnrollmentOutcome(newResult, "New Enrollment"); + Logger.MethodExit(LogLevel.Debug); + return newResult; + case EnrollmentType.RenewOrReissue: + flow.Branch("Renew Or Reissue"); + if (string.IsNullOrEmpty(priorSn)) + { + Logger.LogWarning($"Renew/Reissue requested for product {productInfo.ProductID} but no prior certificate serial number was supplied"); + flow.Fail("Renew Or Reissue", "Missing prior certificate serial number"); + flow.EndBranch(); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nCannot renew or reissue: no prior certificate serial number was supplied." + }; + } + + var order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); + if (string.IsNullOrEmpty(order_id)) + { + Logger.LogWarning($"Could not find a Keyfactor request ID for prior certificate serial number {priorSn}"); + flow.Fail("Renew Or Reissue", $"No request ID found for prior certificate serial number {priorSn}"); + flow.EndBranch(); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nCannot renew or reissue: no prior request found for serial number {priorSn}." + }; + } + + // Determine renew vs reissue based on the CSC order's actual expiry window, + // not the certificate's own validity period - CSC's order is a fixed 1-year + // paid subscription that can cover multiple shorter-lived certs (e.g. ~200 + // days), so basing this on the cert's own expiration would trigger a paid + // Renewal months before the order itself is actually due to expire. + var renewal = false; + try + { + var liveCert = await flow.StepAsync("FetchLiveCertForDecision", + () => CscGlobalClient.SubmitGetCertificateAsync(order_id[..36])); + + if (liveCert != null && DateTime.TryParse(liveCert.OrderDate, out var orderDate)) + { + var orderExpiry = orderDate.AddYears(1); + var daysUntilOrderExpiry = (orderExpiry - DateTime.UtcNow).TotalDays; + renewal = daysUntilOrderExpiry <= RenewalWindowDays; + flow.Step("DetermineRenewOrReissue", + $"orderDate={liveCert.OrderDate}, orderExpiry={orderExpiry:dd-MMM-yyyy}, daysRemaining={(int)daysUntilOrderExpiry}, renewalWindow={RenewalWindowDays}, isRenewal={renewal}"); + } + else + { + flow.Skip("FetchLiveCertForDecision", "orderDate unavailable, falling back to cert expiry"); + var fallbackExpirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id))?.RevocationDate; + renewal = fallbackExpirationDate < DateTime.Now; + flow.Step("DetermineRenewOrReissue", + $"fallback expiry check: expirationDate={fallbackExpirationDate?.ToString("o") ?? "(null)"}, isRenewal={renewal}"); + } + } + catch (Exception ex) + { + flow.Fail("FetchLiveCertForDecision", $"falling back to cert expiry: {ex.Message}"); + Logger.LogWarning(ex, "RenewOrReissue: failed to fetch live cert for order decision, falling back to cert expiry."); + var fallbackExpirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id))?.RevocationDate; + renewal = fallbackExpirationDate < DateTime.Now; + flow.Step("DetermineRenewOrReissue", + $"fallback expiry check: expirationDate={fallbackExpirationDate?.ToString("o") ?? "(null)"}, isRenewal={renewal}"); + } + + if (renewal) + { + //One click won't work for this implementation b/c we are missing enrollment params + if (productParameters.ContainsKey("Applicant Last Name")) + { + //priorCert = _certificateDataReader.get( + //DataConversion.HexToBytes(productInfo.ProductParameters["PriorCertSN"])); + //uUId = priorCert.CARequestID.Substring(0, 36); //uUId is a GUID + uUId = await _certificateDataReader.GetRequestIDBySerialNumber( + productParameters.GetValueOrDefault("PriorCertSN", "")); + Logger.LogTrace($"Renew uUId: {uUId}"); + renewRequest = _requestManager.GetRenewalRequest(productInfo, uUId, csr, san, customFields); + Logger.LogTrace($"Renewal Request JSON: {JsonConvert.SerializeObject(renewRequest)}"); + var renewResponse = await flow.StepAsync("SubmitRenewal", + () => CscGlobalClient.SubmitRenewalAsync(renewRequest)); + Logger.LogTrace($"Renewal Response JSON: {JsonConvert.SerializeObject(renewResponse)}"); + flow.EndBranch(); + var renewResult = _requestManager.GetRenewResponse(renewResponse); + AttachFlowSummary(renewResult, flow); + LogEnrollmentOutcome(renewResult, "Renewal"); + Logger.MethodExit(LogLevel.Debug); + return renewResult; + } + + Logger.LogWarning($"One click renew is not available for product {productInfo.ProductID}; missing required enrollment parameters"); + flow.Fail("Renewal", "One click renew is not available; missing Applicant Last Name"); + flow.EndBranch(); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = + $"{flow.GetSummary()}\n\nOne click Renew Is Not Available for this Certificate Type. Use the configure button instead." + }; + } - if (expirationDate < DateTime.Now) renewal = true; - if (renewal) - { //One click won't work for this implementation b/c we are missing enrollment params - if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) + if (productParameters.ContainsKey("Applicant Last Name")) { - //priorCert = _certificateDataReader.get( - //DataConversion.HexToBytes(productInfo.ProductParameters["PriorCertSN"])); - //uUId = priorCert.CARequestID.Substring(0, 36); //uUId is a GUID - uUId = await _certificateDataReader.GetRequestIDBySerialNumber( - productInfo.ProductParameters["PriorCertSN"]); - Logger.LogTrace($"Renew uUId: {uUId}"); - renewRequest = _requestManager.GetRenewalRequest(productInfo, uUId, csr, san, customFields); - Logger.LogTrace($"Renewal Request JSON: {JsonConvert.SerializeObject(renewRequest)}"); - var renewResponse = Task.Run(async () => await CscGlobalClient.SubmitRenewalAsync(renewRequest)) - .Result; - Logger.LogTrace($"Renewal Response JSON: {JsonConvert.SerializeObject(renewResponse)}"); + var requestid = await _certificateDataReader.GetRequestIDBySerialNumber( + productParameters.GetValueOrDefault("PriorCertSN", "")); + if (string.IsNullOrEmpty(requestid) || requestid.Length < 36) + { + Logger.LogWarning($"Could not find a valid Keyfactor request ID for prior certificate serial number for product {productInfo.ProductID}"); + flow.Fail("Reissue", "No valid request ID found for prior certificate serial number"); + flow.EndBranch(); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nCannot reissue: no prior request found for the supplied certificate serial number." + }; + } + + uUId = requestid.Substring(0, 36); //uUId is a GUID + Logger.LogTrace($"Reissue uUId: {uUId}"); + reissueRequest = _requestManager.GetReissueRequest(productInfo, uUId, csr, san, customFields); + Logger.LogTrace($"Reissue JSON: {JsonConvert.SerializeObject(reissueRequest)}"); + var reissueResponse = await flow.StepAsync("SubmitReissue", + () => CscGlobalClient.SubmitReissueAsync(reissueRequest)); + Logger.LogTrace($"Reissue Response JSON: {JsonConvert.SerializeObject(reissueResponse)}"); + flow.EndBranch(); + var reissueResult = _requestManager.GetReIssueResult(reissueResponse); + AttachFlowSummary(reissueResult, flow); + LogEnrollmentOutcome(reissueResult, "Reissue"); Logger.MethodExit(LogLevel.Debug); - return _requestManager.GetRenewResponse(renewResponse); + return reissueResult; } + Logger.LogWarning($"One click reissue is not available for product {productInfo.ProductID}; missing required enrollment parameters"); + flow.Fail("Reissue", "One click reissue is not available; missing Applicant Last Name"); + flow.EndBranch(); return new EnrollmentResult { - Status = 30, //failure + Status = (int)EndEntityStatus.FAILED, StatusMessage = - "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." + $"{flow.GetSummary()}\n\nOne click Reissue Is Not Available for this Certificate Type. Use the configure button instead." }; - } + } - Logger.LogTrace("Entering Reissue Enrollment"); - //One click won't work for this implementation b/c we are missing enrollment params - if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) - { - var requestid = await _certificateDataReader.GetRequestIDBySerialNumber( - productInfo.ProductParameters["PriorCertSN"]); - uUId = requestid.Substring(0, 36); //uUId is a GUID - Logger.LogTrace($"Reissue uUId: {uUId}"); - reissueRequest = _requestManager.GetReissueRequest(productInfo, uUId, csr, san, customFields); - Logger.LogTrace($"Reissue JSON: {JsonConvert.SerializeObject(reissueRequest)}"); - var reissueResponse = Task.Run(async () => await CscGlobalClient.SubmitReissueAsync(reissueRequest)) - .Result; - Logger.LogTrace($"Reissue Response JSON: {JsonConvert.SerializeObject(reissueResponse)}"); - Logger.MethodExit(LogLevel.Debug); - return _requestManager.GetReIssueResult(reissueResponse); - } + Logger.LogWarning($"Unhandled enrollment type {enrollmentType} for product {productInfo.ProductID}"); + Logger.MethodExit(LogLevel.Debug); + return null; + } + catch (Exception e) + { + // Log the full exception (with stack trace) for diagnostics, but keep the + // user-facing StatusMessage to just the exception's message - the flow summary + // already shows which step failed, so the stack trace would only add noise. + Logger.LogError(e, $"Enroll failed for product {productInfo.ProductID}: {LogHandler.FlattenException(e)}"); + flow.Fail("Enroll", e.Message); + Logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed: {e.Message}" + }; + } + } - return new EnrollmentResult - { - Status = 30, //failure - StatusMessage = - "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." - }; + private void LogEnrollmentOutcome(EnrollmentResult result, string operationName) + { + if (result == null) return; + if (result.Status == (int)EndEntityStatus.FAILED) + Logger.LogError($"{operationName} failed: {result.StatusMessage}"); + else + Logger.LogInformation($"{operationName} succeeded: {result.StatusMessage}"); + } + + // CSC Global business-level failures (e.g. "Open order in progress") come back from + // RequestManager as a terse StatusMessage with no context on what the plugin actually did + // before hitting that error. Prepend the flow's step-by-step summary so the message shown + // to the requester in Command explains what ran, not just how it ended. On success, the + // requester-facing StatusMessage isn't surfaced by Command's enrollment UI at all - only + // EnrollmentContext is, rendered as a bulleted list - so attach one bullet per flow step + // there instead (rather than one embedded-newline blob, which the list doesn't render as + // separate lines), alongside whatever DCV instructions came back. + private static void AttachFlowSummary(EnrollmentResult result, FlowLogger flow) + { + if (result == null) return; + + if (result.Status == (int)EndEntityStatus.FAILED) + { + result.StatusMessage = $"{flow.GetSummary()}\n\n{result.StatusMessage}"; + return; } - Logger.MethodExit(LogLevel.Debug); - return null; + result.EnrollmentContext ??= new Dictionary(); + foreach (var entry in flow.GetSummaryEntries()) + result.EnrollmentContext[entry.Key] = entry.Value; } //done public async Task Ping() { Logger.MethodEntry(); + + if (!Enabled) + { + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping Ping."); + Logger.MethodExit(); + return; + } + try { Logger.LogInformation("Ping request received"); } catch (Exception e) { - Logger.LogError($"There was an error contacting CSCGlobal: {e.Message}."); + Logger.LogError(e, $"There was an error contacting CSCGlobal: {e.Message}."); throw new Exception($"Error attempting to ping CSCGlobal: {e.Message}.", e); } @@ -340,19 +623,63 @@ public async Task Ping() //do public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogDebug($"Validating CA connection info with {connectionInfo?.Count ?? 0} entries"); + + // Honor the Enabled flag from the incoming connectionInfo (which may differ from + // Initialize's snapshot when the operator is currently editing the CA). If disabled, + // skip validation so the CA can be saved without valid credentials. + var incomingEnabled = true; + if (connectionInfo != null && + connectionInfo.TryGetValue(Constants.Enabled, out var enabledObj) && + bool.TryParse(enabledObj?.ToString(), out var parsed)) + incomingEnabled = parsed; + + if (!incomingEnabled) + { + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping ValidateCAConnectionInfo."); + Logger.MethodExit(LogLevel.Debug); + return; + } + + Logger.MethodExit(LogLevel.Debug); } //do public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { + Logger.MethodEntry(LogLevel.Debug); + + // Honor the Enabled flag from the incoming connectionInfo. If the CA is disabled, skip + // validation so a template can be saved on a disabled CA (pre-configuration workflow). + var incomingEnabled = true; + if (connectionInfo != null && + connectionInfo.TryGetValue(Constants.Enabled, out var enabledObj) && + bool.TryParse(enabledObj?.ToString(), out var parsed)) + incomingEnabled = parsed; + + if (!incomingEnabled) + { + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping ValidateProductInfo."); + Logger.MethodExit(LogLevel.Debug); + return; + } + var certType = ProductIDs.productIds.Find(x => x.Equals(productInfo.ProductID, StringComparison.InvariantCultureIgnoreCase)); - if (certType == null) throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); + var isLegacyProductId = certType == null && productInfo.ProductID != null && + ProductIDs.legacyProductIdAliases.ContainsKey(productInfo.ProductID); - Logger.LogInformation($"Validated {certType} ({certType})configured for AnyGateway"); + if (certType == null && !isLegacyProductId) + { + Logger.LogError($"Cannot find product ID {productInfo.ProductID} in the list of supported CSC Global products"); + throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); + } + Logger.LogInformation($"Validated product ID '{productInfo.ProductID}' configured for AnyGateway"); + Logger.MethodExit(LogLevel.Debug); } //done @@ -360,6 +687,13 @@ public Dictionary GetCAConnectorAnnotations() { return new Dictionary { + [Constants.Enabled] = new() + { + Comments = "Whether the CA is enabled. When false, Enroll/Revoke/Synchronize/Ping reject or skip cleanly instead of calling the CSC Global API. Default is true.", + Hidden = false, + DefaultValue = "true", + Type = "String" + }, [Constants.CscGlobalUrl] = new() { Comments = "CSCGlobal API URL", @@ -388,19 +722,19 @@ public Dictionary GetCAConnectorAnnotations() DefaultValue = "100", Type = "String" }, - [Constants.TemplateSync] = new() - { - Comments = "Enable template sync.", - Hidden = false, - DefaultValue = "false", - Type = "Bool" - }, [Constants.SyncFilterDays] = new() { Comments = "Number of days from today to filter certificates by expiration date during incremental sync.", Hidden = false, DefaultValue = "5", Type = "Number" + }, + [Constants.RenewalWindowDays] = new() + { + Comments = "Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30.", + Hidden = false, + DefaultValue = "30", + Type = "Number" } }; } diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj index 5118677..a8ab8fd 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj @@ -3,7 +3,7 @@ true - net6.0;net8.0 + net6.0;net8.0;net10.0 Keyfactor.Extensions.CAPlugin.CSCGlobal true enable @@ -17,7 +17,7 @@ - + diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index 0a5c7c5..43de107 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -21,15 +21,31 @@ public sealed class CscGlobalClient : ICscGlobalClient { private readonly ILogger Logger; - public CscGlobalClient(IAnyCAPluginConfigProvider config) + public CscGlobalClient(IAnyCAPluginConfigProvider config) : this(config, null) { - Logger = LogHandler.GetClassLogger(); + } + + internal CscGlobalClient(IAnyCAPluginConfigProvider config, HttpMessageHandler? handler) + { + Logger = LogHandler.GetClassLogger(); + if (config == null) throw new ArgumentNullException(nameof(config)); + if (config.CAConnectionData == null) + { + Logger.LogError("CA connection data is null; client will not be able to call the CSC Global API"); + return; + } + if (config.CAConnectionData.ContainsKey(Constants.CscGlobalApiKey)) { BaseUrl = new Uri(config.CAConnectionData[Constants.CscGlobalUrl].ToString()); ApiKey = config.CAConnectionData[Constants.CscGlobalApiKey].ToString(); Authorization = config.CAConnectionData[Constants.BearerToken].ToString(); - RestClient = ConfigureRestClient(); + RestClient = ConfigureRestClient(handler); + Logger.LogDebug($"CscGlobalClient configured for base URL {BaseUrl}"); + } + else + { + Logger.LogError($"CA connection data is missing required key '{Constants.CscGlobalApiKey}'; client will not be able to call the CSC Global API"); } } @@ -41,6 +57,7 @@ public CscGlobalClient(IAnyCAPluginConfigProvider config) public async Task SubmitRegistrationAsync( RegistrationRequest registerRequest) { + Logger.MethodEntry(LogLevel.Debug); using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/registration", new StringContent( JsonConvert.SerializeObject(registerRequest), Encoding.ASCII, "application/json"))) { @@ -48,18 +65,30 @@ public async Task SubmitRegistrationAsync( var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response { + var rawErrorResponse = await resp.Content.ReadAsStringAsync(); var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + JsonConvert.DeserializeObject(rawErrorResponse, settings); + Logger.LogWarning($"Registration request rejected by CSC Global: {errorResponse?.Description ?? rawErrorResponse}"); var response = new RegistrationResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } + if (!resp.IsSuccessStatusCode) + { + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Registration request failed with status code {resp.StatusCode} | Message: {errorBody}"); + throw new HttpRequestException($"Registration request failed with status code {resp.StatusCode}: {errorBody}"); + } + var registrationResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), settings); + if (registrationResponse == null) + throw new InvalidOperationException("Registration request succeeded but the response body could not be parsed"); + + Logger.MethodExit(LogLevel.Debug); return registrationResponse; } } @@ -81,17 +110,28 @@ public async Task SubmitRenewalAsync( var errorResponse = JsonConvert.DeserializeObject(rawErrorResponse, settings); + Logger.LogWarning($"Renewal request rejected by CSC Global: {errorResponse?.Description ?? rawErrorResponse}"); var response = new RenewalResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } + if (!resp.IsSuccessStatusCode) + { + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Renewal request failed with status code {resp.StatusCode} | Message: {errorBody}"); + throw new HttpRequestException($"Renewal request failed with status code {resp.StatusCode}: {errorBody}"); + } + var rawRenewResponse = await resp.Content.ReadAsStringAsync(); Logger.LogTrace("Logging Success Response Raw"); Logger.LogTrace(rawRenewResponse); var renewalResponse = JsonConvert.DeserializeObject(rawRenewResponse); + if (renewalResponse == null) + throw new InvalidOperationException("Renewal request succeeded but the response body could not be parsed"); + return renewalResponse; } } @@ -107,61 +147,110 @@ public async Task SubmitReissueAsync( var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response { + var rawErrorResponse = await resp.Content.ReadAsStringAsync(); var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + JsonConvert.DeserializeObject(rawErrorResponse, settings); + Logger.LogWarning($"Reissue request rejected by CSC Global: {errorResponse?.Description ?? rawErrorResponse}"); var response = new ReissueResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } + if (!resp.IsSuccessStatusCode) + { + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Reissue request failed with status code {resp.StatusCode} | Message: {errorBody}"); + throw new HttpRequestException($"Reissue request failed with status code {resp.StatusCode}: {errorBody}"); + } + var reissueResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (reissueResponse == null) + throw new InvalidOperationException("Reissue request succeeded but the response body could not be parsed"); + return reissueResponse; } } public async Task SubmitGetCertificateAsync(string certificateId) { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace($"Getting certificate with ID {certificateId}"); using (var resp = await RestClient.GetAsync($"/dbs/api/v2/tls/certificate/{certificateId}")) { + if (!resp.IsSuccessStatusCode) + { + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Failed to get certificate {certificateId}. Status code {resp.StatusCode} | Message: {errorBody}"); + } + resp.EnsureSuccessStatusCode(); var getCertificateResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (getCertificateResponse == null) + throw new InvalidOperationException($"Get certificate request for {certificateId} succeeded but the response body could not be parsed"); + + Logger.MethodExit(LogLevel.Debug); return getCertificateResponse; } } public async Task> SubmitGetCustomFields() { + Logger.MethodEntry(LogLevel.Debug); using (var resp = await RestClient.GetAsync("/dbs/api/v2/admin/customfields")) { + if (!resp.IsSuccessStatusCode) + { + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Failed to get custom fields. Status code {resp.StatusCode} | Message: {errorBody}"); + } + resp.EnsureSuccessStatusCode(); var getCustomFieldsResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); - return getCustomFieldsResponse.CustomFields; + if (getCustomFieldsResponse == null) + throw new InvalidOperationException("Get custom fields request succeeded but the response body could not be parsed"); + + Logger.LogTrace($"Retrieved {getCustomFieldsResponse.CustomFields?.Count ?? 0} custom field(s)"); + Logger.MethodExit(LogLevel.Debug); + return getCustomFieldsResponse.CustomFields ?? new List(); } } public async Task SubmitRevokeCertificateAsync(string uuId) { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace($"Revoking certificate with UUID {uuId}"); using (var resp = await RestClient.PutAsync($"/dbs/api/v2/tls/revoke/{uuId}", new StringContent(""))) { var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response { + var rawErrorResponse = await resp.Content.ReadAsStringAsync(); var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + JsonConvert.DeserializeObject(rawErrorResponse, settings); + Logger.LogWarning($"Revoke request rejected by CSC Global for UUID {uuId}: {errorResponse?.Description ?? rawErrorResponse}"); var response = new RevokeResponse(); response.RegistrationError = errorResponse; response.RevokeSuccess = null; return response; } + if (!resp.IsSuccessStatusCode) + { + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Revoke request for UUID {uuId} failed with status code {resp.StatusCode} | Message: {errorBody}"); + throw new HttpRequestException($"Revoke request failed with status code {resp.StatusCode}: {errorBody}"); + } + var getRevokeResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (getRevokeResponse == null) + throw new InvalidOperationException("Revoke request succeeded but the response body could not be parsed"); + + Logger.MethodExit(LogLevel.Debug); return getRevokeResponse; } } @@ -175,24 +264,28 @@ public async Task SubmitCertificateListRequestAsync(str filterQuery += $";effectiveDate=ge={dateFilter}"; } Logger.LogTrace($"Certificate list filter query: {filterQuery}"); - var resp = RestClient.GetAsync($"/dbs/api/v2/tls/certificate?{filterQuery}").Result; + var resp = await RestClient.GetAsync($"/dbs/api/v2/tls/certificate?{filterQuery}"); if (!resp.IsSuccessStatusCode) { - var responseMessage = resp.Content.ReadAsStringAsync().Result; - Logger.LogError( - $"Failed Request to Keyfactor. Retrying request. Status Code {resp.StatusCode} | Message: {responseMessage}"); + var responseMessage = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Certificate list request failed. Status Code {resp.StatusCode} | Message: {responseMessage}"); + throw new HttpRequestException($"Certificate list request failed with status code {resp.StatusCode}: {responseMessage}"); } var certificateListResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (certificateListResponse == null) + throw new InvalidOperationException("Certificate list request succeeded but the response body could not be parsed"); + + Logger.LogInformation($"Certificate list request returned {certificateListResponse.Results?.Count ?? 0} result(s)"); + Logger.MethodExit(LogLevel.Debug); return certificateListResponse; } - private HttpClient ConfigureRestClient() + private HttpClient ConfigureRestClient(HttpMessageHandler? handler = null) { - var clientHandler = new HttpClientHandler(); - var returnClient = new HttpClient(clientHandler, true) + var returnClient = new HttpClient(handler ?? new HttpClientHandler(), true) { BaseAddress = BaseUrl }; diff --git a/cscglobal-caplugin/Client/Models/Price.cs b/cscglobal-caplugin/Client/Models/Price.cs index ad66ea3..6c71b3e 100644 --- a/cscglobal-caplugin/Client/Models/Price.cs +++ b/cscglobal-caplugin/Client/Models/Price.cs @@ -13,5 +13,5 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; public class Price : IPrice { [JsonProperty("currency")] public string Currency { get; set; } - [JsonProperty("total")] public decimal Total { get; set; } + [JsonProperty("total")] public decimal? Total { get; set; } } \ No newline at end of file diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index 4d6b4da..f013902 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -9,26 +9,44 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; public class Constants { + public static string Enabled = "Enabled"; public static string CscGlobalUrl = "CscGlobalUrl"; public static string CscGlobalApiKey = "ApiKey"; public static string BearerToken = "BearerToken"; public static string DefaultPageSize = "DefaultPageSize"; - public static string TemplateSync = "TemplateSync"; public static string SyncFilterDays = "SyncFilterDays"; + public static string RenewalWindowDays = "RenewalWindowDays"; } public class ProductIDs { public static List productIds = new List() { - "CSC TrustedSecure Premium Certificate", - "CSC TrustedSecure EV Certificate", - "CSC TrustedSecure UC Certificate", - "CSC TrustedSecure Premium Wildcard Certificate", - "CSC TrustedSecure Domain Validated SSL", - "CSC TrustedSecure Domain Validated Wildcard SSL", - "CSC TrustedSecure Domain Validated UC Certificate" + "CSC TrustedSecure OV", + "CSC TrustedSecure OV Wildcard", + "CSC TrustedSecure OV, Multiple Names", + "CSC TrustedSecure EV", + "CSC TrustedSecure DV", + "CSC TrustedSecure DV Wildcard", + "CSC TrustedSecure DV, Multiple Names", + "CSC TrustedSecure EV, Multiple Names", + "CSC TrustedSecure OV Wildcard, Multiple Names", + "CSC TrustedSecure DV Wildcard, Multiple Names" }; + + // Pre-1.2.0 template names. Existing Certificate Templates in Command may still + // reference these, so they're accepted as aliases for their canonical replacement. + public static Dictionary legacyProductIdAliases = + new(StringComparer.InvariantCultureIgnoreCase) + { + ["CSC TrustedSecure Premium Certificate"] = "CSC TrustedSecure OV", + ["CSC TrustedSecure Premium Wildcard Certificate"] = "CSC TrustedSecure OV Wildcard", + ["CSC TrustedSecure UC Certificate"] = "CSC TrustedSecure OV, Multiple Names", + ["CSC TrustedSecure EV Certificate"] = "CSC TrustedSecure EV", + ["CSC TrustedSecure Domain Validated SSL"] = "CSC TrustedSecure DV", + ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "CSC TrustedSecure DV Wildcard", + ["CSC TrustedSecure Domain Validated UC Certificate"] = "CSC TrustedSecure DV, Multiple Names" + }; } public class EnrollmentConfigConstants diff --git a/cscglobal-caplugin/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs new file mode 100644 index 0000000..601115e --- /dev/null +++ b/cscglobal-caplugin/FlowLogger.cs @@ -0,0 +1,306 @@ +// 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. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System.Diagnostics; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; + +/// +/// Step-oriented trace logger for CA plugin pipelines. Wraps a sequence of named steps with +/// timings, branches, and outcomes into a single appended summary block that is attached +/// to on both success and failure, giving +/// operators a single scannable breadcrumb trail per plugin operation (Enroll, Revoke, Sync). +/// +public class FlowLogger : IDisposable +{ + private readonly ILogger _logger; + private readonly string _flowName; + private readonly Stopwatch _overallStopwatch; + private readonly List _steps = new(); + private readonly Stack _branchStack = new(); + + public FlowLogger(ILogger logger, string flowName) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _flowName = flowName ?? throw new ArgumentNullException(nameof(flowName)); + _overallStopwatch = Stopwatch.StartNew(); + _logger.LogTrace("[FLOW:{FlowName}] === BEGIN ===", _flowName); + } + + public void Step(string name, string detail = null) + { + var step = new FlowStep { Name = name, Detail = detail, Status = StepStatus.Success }; + _steps.Add(step); + var prefix = GetPrefix(); + if (detail != null) + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} - {Detail}", _flowName, prefix, name, detail); + else + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName}", _flowName, prefix, name); + } + + public void Step(string name, Action action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public async Task StepAsync(string name, Func action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + await action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public async Task StepAsync(string name, Func> action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + var result = await action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + return result; + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public T Step(string name, Func action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + var result = action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + return result; + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public void Fail(string name, string reason) + { + var step = new FlowStep { Name = name, Status = StepStatus.Failed, ErrorMessage = reason }; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} - {Reason}", _flowName, prefix, name, reason); + } + + public void Skip(string name, string reason) + { + var step = new FlowStep { Name = name, Status = StepStatus.Skipped, Detail = reason }; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[SKIP] {StepName} - {Reason}", _flowName, prefix, name, reason); + } + + public void Branch(string name) + { + _branchStack.Push(name); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}>> {BranchName}", _flowName, prefix, name); + } + + public void EndBranch() + { + if (_branchStack.Count > 0) + { + var name = _branchStack.Pop(); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}<< {BranchName}", _flowName, prefix, name); + } + } + + public bool HasFailures => _steps.Any(s => s.Status == StepStatus.Failed); + + public string GetSummary() + { + var hasFailures = HasFailures; + var overallStatus = hasFailures ? "FAILED" : "OK"; + var total = _steps.Count; + var succeeded = _steps.Count(s => s.Status == StepStatus.Success); + var failed = _steps.Count(s => s.Status == StepStatus.Failed); + var skipped = _steps.Count(s => s.Status == StepStatus.Skipped); + var elapsed = _overallStopwatch.ElapsedMilliseconds; + + var sb = new StringBuilder(); + sb.AppendLine($"Flow: {_flowName} [{overallStatus}] Total: {elapsed}ms"); + sb.AppendLine($"Steps: {total} total, {succeeded} ok, {failed} failed, {skipped} skipped"); + sb.AppendLine("----------------------------------------"); + foreach (var step in _steps) + { + var icon = step.Status == StepStatus.Success ? "[OK] " + : step.Status == StepStatus.Failed ? "[FAIL]" + : step.Status == StepStatus.Skipped ? "[SKIP]" + : "[...]"; + var time = step.ElapsedMs.HasValue ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.ErrorMessage) + ? $" - {step.ErrorMessage}" + : !string.IsNullOrEmpty(step.Detail) + ? $" - {step.Detail}" + : ""; + sb.AppendLine($" {icon} {step.Name}{time}{detail}"); + } + sb.Append("----------------------------------------"); + + return sb.ToString(); + } + + /// + /// Same information as , but as one entry per step instead of a + /// single multi-line block. Intended for callers (e.g. EnrollmentResult.EnrollmentContext) + /// whose rendering surface displays a dictionary as a bulleted list and doesn't respect + /// embedded newlines - each step becomes its own bullet instead of one run-on line. + /// + public Dictionary GetSummaryEntries() + { + var overallStatus = HasFailures ? "FAILED" : "OK"; + var succeeded = _steps.Count(s => s.Status == StepStatus.Success); + var failed = _steps.Count(s => s.Status == StepStatus.Failed); + var skipped = _steps.Count(s => s.Status == StepStatus.Skipped); + + var entries = new Dictionary + { + [$"Flow: {_flowName}"] = + $"[{overallStatus}] {_overallStopwatch.ElapsedMilliseconds}ms total - " + + $"{_steps.Count} steps ({succeeded} ok, {failed} failed, {skipped} skipped)" + }; + + for (var i = 0; i < _steps.Count; i++) + { + var step = _steps[i]; + var icon = step.Status == StepStatus.Success ? "OK" + : step.Status == StepStatus.Failed ? "FAIL" + : step.Status == StepStatus.Skipped ? "SKIP" + : "..."; + var time = step.ElapsedMs.HasValue ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.ErrorMessage) ? step.ErrorMessage + : !string.IsNullOrEmpty(step.Detail) ? step.Detail + : null; + + entries[$"Flow Step {i + 1:00}: {step.Name}"] = + detail == null ? $"[{icon}]{time}" : $"[{icon}]{time} - {detail}"; + } + + return entries; + } + + public void Dispose() + { + _overallStopwatch.Stop(); + var summary = GetSummary(); + _logger.LogTrace("[FLOW:{FlowName}] === END ===\n{Summary}", _flowName, summary); + } + + private string GetPrefix() + { + if (_branchStack.Count == 0) return ""; + return new string(' ', _branchStack.Count * 2) + "| "; + } + + private static string FormatDetail(string detail) + { + return string.IsNullOrEmpty(detail) ? "" : $" - {detail}"; + } + + private enum StepStatus + { + Success, + Failed, + Skipped, + InProgress + } + + private class FlowStep + { + public string Name { get; set; } + public string Detail { get; set; } + public StepStatus Status { get; set; } = StepStatus.InProgress; + public long? ElapsedMs { get; set; } + public string ErrorMessage { get; set; } + } +} diff --git a/cscglobal-caplugin/Interfaces/IPrice.cs b/cscglobal-caplugin/Interfaces/IPrice.cs index d4bab37..47eb1fb 100644 --- a/cscglobal-caplugin/Interfaces/IPrice.cs +++ b/cscglobal-caplugin/Interfaces/IPrice.cs @@ -10,5 +10,5 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces; public interface IPrice { string Currency { get; set; } - decimal Total { get; set; } + decimal? Total { get; set; } } \ No newline at end of file diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 776902c..06f0b41 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -10,18 +10,36 @@ using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces; +using Keyfactor.Logging; using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; public class RequestManager { + private readonly ILogger Logger = LogHandler.GetClassLogger(); + public static Func Pemify = ss => ss.Length <= 64 ? ss : ss.Substring(0, 64) + "\n" + Pemify(ss.Substring(64)); + private static string GetOptionalParam(EnrollmentProductInfo productInfo, string key) + { + return productInfo.ProductParameters != null && + productInfo.ProductParameters.TryGetValue(key, out var value) + ? value + : string.Empty; + } + private List GetCustomFields(EnrollmentProductInfo productInfo, List customFields) { var customFieldList = new List(); + if (customFields == null) + { + Logger.LogTrace("No custom field definitions supplied; skipping custom field mapping"); + return customFieldList; + } + foreach (var field in customFields) if (productInfo.ProductParameters.ContainsKey(field.Label)) { @@ -34,100 +52,220 @@ private List GetCustomFields(EnrollmentProductInfo productInfo, Lis } else if (field.Mandatory) { + Logger.LogError($"Custom field {field.Label} is marked as mandatory, but was not supplied in the request."); throw new Exception( $"Custom field {field.Label} is marked as mandatory, but was not supplied in the request."); } + Logger.LogTrace($"Mapped {customFieldList.Count} custom field(s) for request"); return customFieldList; } public EnrollmentResult GetRenewResponse(RenewalResponse renewResponse) { + if (renewResponse == null) + { + Logger.LogError("Renewal failed: CSC Global returned no response"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global returned no response for the renewal request" + }; + } + if (renewResponse.RegistrationError != null) + { + Logger.LogError($"Renewal failed: {renewResponse.RegistrationError.Description}"); return new EnrollmentResult { Status = (int)EndEntityStatus.FAILED, //failure - CARequestID = renewResponse?.Result?.Status?.Uuid, + CARequestID = renewResponse.Result?.Status?.Uuid, StatusMessage = renewResponse.RegistrationError.Description }; + } - return new EnrollmentResult + if (renewResponse.Result == null) { - Status = (int)EndEntityStatus.GENERATED, //success + Logger.LogError("Renewal failed: CSC Global reported success but returned no result"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global reported success but returned no result" + }; + } - StatusMessage = $"Renewal Successfully Completed For {renewResponse.Result.CommonName}" + // CSC Global never returns an issued certificate on the renewal response itself - the + // renewal is only submitted here and still needs domain control validation (email + // confirmation or a CNAME record) before CSC actually issues the certificate. The real + // certificate is picked up later via Synchronize. Reporting GENERATED here tells Command + // a certificate is ready to be parsed off this result when there isn't one. + Logger.LogInformation($"Renewal successfully submitted for {renewResponse.Result.CommonName}"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.EXTERNALVALIDATION, //success - pending DCV, not yet issued + CARequestID = renewResponse.Result.Status?.Uuid, + StatusMessage = $"Renewal Successfully Submitted For {renewResponse.Result.CommonName}", + EnrollmentContext = BuildDcvEnrollmentContext(renewResponse.Result.DcvDetails) }; } + // Builds the CNAME/email DCV instructions CSC Global returns on a submitted order into the + // key/value context Command surfaces to the requester, so they know what action is needed + // (create a CNAME record, or check their email) before the certificate will actually issue. + private static Dictionary BuildDcvEnrollmentContext(List dcvDetails) + { + var cnames = new Dictionary(); + if (dcvDetails == null) return null; + + foreach (var dcv in dcvDetails) + { + if (dcv.CName != null && !string.IsNullOrEmpty(dcv.CName.Name) && !string.IsNullOrEmpty(dcv.CName.Value)) + { + cnames.Add(dcv.CName.Name, dcv.CName.Value); + } + + if (!string.IsNullOrEmpty(dcv.Email) && !cnames.ContainsKey(dcv.Email)) + { + cnames.Add(dcv.Email, dcv.Email); + } + } + + return cnames.Count > 0 ? cnames : null; + } + public EnrollmentResult GetEnrollmentResult( IRegistrationResponse registrationResponse) { + if (registrationResponse == null) + { + Logger.LogError("Enrollment failed: CSC Global returned no response"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global returned no response for the registration request" + }; + } + if (registrationResponse.RegistrationError != null) + { + Logger.LogError($"Enrollment failed: {registrationResponse.RegistrationError.Description}"); return new EnrollmentResult { Status = (int)EndEntityStatus.FAILED, //failure StatusMessage = registrationResponse.RegistrationError.Description }; + } - var cnames = new Dictionary(); - if (registrationResponse.Result.DcvDetails != null && registrationResponse.Result.DcvDetails.Count > 0) - foreach (var dcv in registrationResponse.Result.DcvDetails) + if (registrationResponse.Result == null) + { + Logger.LogError("Enrollment failed: CSC Global reported success but returned no result"); + return new EnrollmentResult { - if (dcv.CName != null && !string.IsNullOrEmpty(dcv.CName.Name) && !string.IsNullOrEmpty(dcv.CName.Value)) - { - cnames.Add(dcv.CName.Name, dcv.CName.Value); - } + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global reported success but returned no result" + }; + } - if (!string.IsNullOrEmpty(dcv.Email) && !cnames.ContainsKey(dcv.Email)) - { - cnames.Add(dcv.Email, dcv.Email); - } - } - + Logger.LogInformation($"Order successfully created with order number {registrationResponse.Result.CommonName}"); return new EnrollmentResult { Status = (int)EndEntityStatus.EXTERNALVALIDATION, //success - CARequestID = registrationResponse.Result.Status.Uuid, + CARequestID = registrationResponse.Result.Status?.Uuid, StatusMessage = $"Order Successfully Created With Order Number {registrationResponse.Result.CommonName}", - EnrollmentContext = cnames.Count > 0 ? cnames : null + EnrollmentContext = BuildDcvEnrollmentContext(registrationResponse.Result.DcvDetails) }; } public int GetRevokeResult(IRevokeResponse revokeResponse) { + if (revokeResponse == null) + { + Logger.LogError("Revoke failed: CSC Global returned no response"); + return (int)EndEntityStatus.FAILED; + } + if (revokeResponse.RegistrationError != null) + { + Logger.LogError($"Revoke failed: {revokeResponse.RegistrationError.Description}"); return (int)EndEntityStatus.FAILED; + } return (int)EndEntityStatus.REVOKED; } public EnrollmentResult GetReIssueResult(IReissueResponse reissueResponse) { + if (reissueResponse == null) + { + Logger.LogError("Reissue failed: CSC Global returned no response"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global returned no response for the reissue request" + }; + } + if (reissueResponse.RegistrationError != null) + { + Logger.LogError($"Reissue failed: {reissueResponse.RegistrationError.Description}"); return new EnrollmentResult { Status = (int)EndEntityStatus.FAILED, //failure StatusMessage = reissueResponse.RegistrationError.Description }; + } + + if (reissueResponse.Result == null) + { + Logger.LogError("Reissue failed: CSC Global reported success but returned no result"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global reported success but returned no result" + }; + } + // Same as renewal: CSC Global submits the reissue but still requires DCV before the + // certificate is actually issued, so this must not claim GENERATED here. + Logger.LogInformation($"Reissue successfully submitted for {reissueResponse.Result.CommonName}"); return new EnrollmentResult { - Status = (int)EndEntityStatus.GENERATED, //success - CARequestID = reissueResponse.Result.Status.Uuid, - StatusMessage = $"Reissue Successfully Completed For {reissueResponse.Result.CommonName}" + Status = (int)EndEntityStatus.EXTERNALVALIDATION, //success - pending DCV, not yet issued + CARequestID = reissueResponse.Result.Status?.Uuid, + StatusMessage = $"Reissue Successfully Submitted For {reissueResponse.Result.CommonName}", + EnrollmentContext = BuildDcvEnrollmentContext(reissueResponse.Result.DcvDetails) }; } public DomainControlValidation GetDomainControlValidation(string methodType, string[] emailAddress, string domainName) { - foreach (var address in emailAddress) + if (string.IsNullOrWhiteSpace(domainName)) { - var email = new MailAddress(address); - if (domainName.Contains(email.Host.Split('.')[0])) + Logger.LogWarning("GetDomainControlValidation called with an empty domain name"); + return null; + } + + foreach (var address in emailAddress ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(address)) continue; + + MailAddress email; + try + { + email = new MailAddress(address.Trim()); + } + catch (FormatException fex) + { + Logger.LogWarning(fex, $"Skipping malformed DCV email address '{address}'"); + continue; + } + + var hostLabels = email.Host.Split('.'); + if (hostLabels.Length > 0 && domainName.Contains(hostLabels[0])) return new DomainControlValidation { MethodType = methodType, @@ -135,6 +273,7 @@ public DomainControlValidation GetDomainControlValidation(string methodType, str }; } + Logger.LogWarning($"No matching DCV email address found for domain {domainName}"); return null; } @@ -150,6 +289,7 @@ public DomainControlValidation GetDomainControlValidation(string methodType, str public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productInfo, string csr, Dictionary sans, List customFields) { + Logger.LogTrace($"Building registration request for product {productInfo.ProductID}"); //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; var cert = Pemify(csr); //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; @@ -157,8 +297,8 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI var bytes = Encoding.UTF8.GetBytes(cert); var encodedString = Convert.ToBase64String(bytes); - var commonNameValidationEmail = productInfo.ProductParameters["CN DCV Email"]; - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; + var commonNameValidationEmail = GetOptionalParam(productInfo, "CN DCV Email"); + var methodType = GetOptionalParam(productInfo, "Domain Control Validation Method"); var certificateType = GetCertificateType(productInfo.ProductID); return new RegistrationRequest @@ -166,72 +306,84 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI Csr = encodedString, ServerSoftware = "-1", //Just default to other, user does not need to fill this in CertificateType = certificateType, - Term = productInfo.ProductParameters["Term"], - ApplicantFirstName = productInfo.ProductParameters["Applicant First Name"], - ApplicantLastName = productInfo.ProductParameters["Applicant Last Name"], - ApplicantEmailAddress = productInfo.ProductParameters["Applicant Email Address"], - ApplicantPhoneNumber = productInfo.ProductParameters["Applicant Phone"], + Term = GetOptionalParam(productInfo, "Term"), + ApplicantFirstName = GetOptionalParam(productInfo, "Applicant First Name"), + ApplicantLastName = GetOptionalParam(productInfo, "Applicant Last Name"), + ApplicantEmailAddress = GetOptionalParam(productInfo, "Applicant Email Address"), + ApplicantPhoneNumber = GetOptionalParam(productInfo, "Applicant Phone"), DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = GetOptionalParam(productInfo, "Organization Contact"), + BusinessUnit = GetOptionalParam(productInfo, "Business Unit"), ShowPrice = true, //User should not have to fill this out CustomFields = GetCustomFields(productInfo, customFields), - SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, - EvCertificateDetails = certificateType == "3" ? GetEvCertificateDetails(productInfo) : null + SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, + EvCertificateDetails = EvCertificateTypes.Contains(certificateType) ? GetEvCertificateDetails(productInfo) : null }; } private string GetCertificateType(string productId) { + if (productId != null && ProductIDs.legacyProductIdAliases.TryGetValue(productId, out var canonicalProductId)) + { + productId = canonicalProductId; + } + switch (productId) { - case "CSC TrustedSecure Premium Certificate": + case "CSC TrustedSecure OV": return "0"; - case "CSC TrustedSecure EV Certificate": - return "3"; - case "CSC TrustedSecure UC Certificate": - return "2"; - case "CSC TrustedSecure Premium Wildcard Certificate": + case "CSC TrustedSecure OV Wildcard": return "1"; - case "CSC Trusted Secure Domain Validated SSL": - return "4"; - case "CSC Trusted Secure Domain Validated Wildcard SSL": - return "5"; - case "CSC Trusted Secure Domain Validated UC Certificate": - return "6"; - case "CSC TrustedSecure Domain Validated SSL": + case "CSC TrustedSecure OV, Multiple Names": + return "2"; + case "CSC TrustedSecure EV": + return "3"; + case "CSC TrustedSecure DV": return "4"; - case "CSC TrustedSecure Domain Validated Wildcard SSL": + case "CSC TrustedSecure DV Wildcard": return "5"; - case "CSC TrustedSecure Domain Validated UC Certificate": + case "CSC TrustedSecure DV, Multiple Names": return "6"; + case "CSC TrustedSecure EV, Multiple Names": + return "7"; + case "CSC TrustedSecure OV Wildcard, Multiple Names": + return "8"; + case "CSC TrustedSecure DV Wildcard, Multiple Names": + return "9"; } + Logger.LogWarning($"Unrecognized product ID '{productId}'; defaulting certificate type to -1"); return "-1"; } + private static readonly HashSet MultiNameCertificateTypes = new() { "2", "6", "7", "8", "9" }; + private static readonly HashSet EvCertificateTypes = new() { "3", "7" }; + public Notifications GetNotifications(EnrollmentProductInfo productInfo) { + var notificationEmails = GetOptionalParam(productInfo, "Notification Email(s) Comma Separated"); return new Notifications { Enabled = true, - AdditionalNotificationEmails = productInfo.ProductParameters["Notification Email(s) Comma Separated"] - .Split(',').ToList() + AdditionalNotificationEmails = string.IsNullOrWhiteSpace(notificationEmails) + ? new List() + : notificationEmails.Split(',').ToList() }; } public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { + Logger.LogTrace($"Building renewal request for product {productInfo.ProductID}, UUID {uUId}"); //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; var cert = Pemify(csr); //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; var bytes = Encoding.UTF8.GetBytes(cert); var encodedString = Convert.ToBase64String(bytes); - var commonNameValidationEmail = productInfo.ProductParameters["CN DCV Email"]; - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; + var commonNameValidationEmail = GetOptionalParam(productInfo, "CN DCV Email"); + var methodType = GetOptionalParam(productInfo, "Domain Control Validation Method"); var certificateType = GetCertificateType(productInfo.ProductID); return new RenewalRequest @@ -240,19 +392,19 @@ public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, strin Csr = encodedString, ServerSoftware = "-1", CertificateType = certificateType, - Term = productInfo.ProductParameters["Term"], - ApplicantFirstName = productInfo.ProductParameters["Applicant First Name"], - ApplicantLastName = productInfo.ProductParameters["Applicant Last Name"], - ApplicantEmailAddress = productInfo.ProductParameters["Applicant Email Address"], - ApplicantPhoneNumber = productInfo.ProductParameters["Applicant Phone"], + Term = GetOptionalParam(productInfo, "Term"), + ApplicantFirstName = GetOptionalParam(productInfo, "Applicant First Name"), + ApplicantLastName = GetOptionalParam(productInfo, "Applicant Last Name"), + ApplicantEmailAddress = GetOptionalParam(productInfo, "Applicant Email Address"), + ApplicantPhoneNumber = GetOptionalParam(productInfo, "Applicant Phone"), DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = GetOptionalParam(productInfo, "Organization Contact"), + BusinessUnit = GetOptionalParam(productInfo, "Business Unit"), ShowPrice = true, - SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, + SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), - EvCertificateDetails = certificateType == "3" ? GetEvCertificateDetails(productInfo) : null + EvCertificateDetails = EvCertificateTypes.Contains(certificateType) ? GetEvCertificateDetails(productInfo) : null }; } @@ -260,18 +412,30 @@ private List GetSubjectAlternativeNames(EnrollmentProduc Dictionary sans) { var subjectNameList = new List(); - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; + var methodType = GetOptionalParam(productInfo, "Domain Control Validation Method"); + var commonNameValidationEmail = GetOptionalParam(productInfo, "CN DCV Email"); - foreach (var v in sans["dnsname"]) + string[] dnsNames = null; + sans?.TryGetValue("dnsname", out dnsNames); + foreach (var v in dnsNames ?? Array.Empty()) { var domainName = v; var san = new SubjectAlternativeName(); san.DomainName = domainName; - var emailAddresses = productInfo.ProductParameters["Addtl Sans Comma Separated DVC Emails"].Split(','); if (methodType.ToUpper() == "EMAIL") - san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName); - else //it is a CNAME validation so no email is needed - san.DomainControlValidation = GetDomainControlValidation(methodType, ""); + { + productInfo.ProductParameters.TryGetValue(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails, out var addtlSansEmails); + var emailAddresses = string.IsNullOrWhiteSpace(addtlSansEmails) + ? Array.Empty() + : addtlSansEmails.Split(','); + + // Fall back to the primary CN's DCV email when no per-domain override matches; + // CSC Global rejects the request if a SAN entry is missing domainControlValidation. + san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName) + ?? GetDomainControlValidation(methodType, commonNameValidationEmail); + } + else //it is a CNAME validation - mirror the primary CN's DCV, no email is needed + san.DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail); subjectNameList.Add(san); } @@ -282,14 +446,15 @@ private List GetSubjectAlternativeNames(EnrollmentProduc public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { + Logger.LogTrace($"Building reissue request for product {productInfo.ProductID}, UUID {uUId}"); //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; var cert = Pemify(csr); //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; var bytes = Encoding.UTF8.GetBytes(cert); var encodedString = Convert.ToBase64String(bytes); - var commonNameValidationEmail = productInfo.ProductParameters["CN DCV Email"]; - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; + var commonNameValidationEmail = GetOptionalParam(productInfo, "CN DCV Email"); + var methodType = GetOptionalParam(productInfo, "Domain Control Validation Method"); var certificateType = GetCertificateType(productInfo.ProductID); return new ReissueRequest @@ -297,33 +462,33 @@ public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, strin Uuid = uUId, Csr = encodedString, ServerSoftware = "-1", - CertificateType = GetCertificateType(productInfo.ProductID), - Term = productInfo.ProductParameters["Term"], - ApplicantFirstName = productInfo.ProductParameters["Applicant First Name"], - ApplicantLastName = productInfo.ProductParameters["Applicant Last Name"], - ApplicantEmailAddress = productInfo.ProductParameters["Applicant Email Address"], - ApplicantPhoneNumber = productInfo.ProductParameters["Applicant Phone"], + CertificateType = certificateType, + Term = GetOptionalParam(productInfo, "Term"), + ApplicantFirstName = GetOptionalParam(productInfo, "Applicant First Name"), + ApplicantLastName = GetOptionalParam(productInfo, "Applicant Last Name"), + ApplicantEmailAddress = GetOptionalParam(productInfo, "Applicant Email Address"), + ApplicantPhoneNumber = GetOptionalParam(productInfo, "Applicant Phone"), DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = GetOptionalParam(productInfo, "Organization Contact"), + BusinessUnit = GetOptionalParam(productInfo, "Business Unit"), ShowPrice = true, - SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, + SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), - EvCertificateDetails = certificateType == "3" ? GetEvCertificateDetails(productInfo) : null + EvCertificateDetails = EvCertificateTypes.Contains(certificateType) ? GetEvCertificateDetails(productInfo) : null }; } private EvCertificateDetails GetEvCertificateDetails(EnrollmentProductInfo productInfo) { var evDetails = new EvCertificateDetails(); - evDetails.Country = productInfo.ProductParameters["Organization Country"]; + evDetails.Country = GetOptionalParam(productInfo, "Organization Country"); return evDetails; } public int MapReturnStatus(string cscGlobalStatus) { - var returnStatus = 0; + int returnStatus; switch (cscGlobalStatus) { @@ -340,6 +505,7 @@ public int MapReturnStatus(string cscGlobalStatus) returnStatus = (int)EndEntityStatus.REVOKED; break; default: + Logger.LogWarning($"Unrecognized CSC Global status '{cscGlobalStatus}'; mapping to FAILED"); returnStatus = (int)EndEntityStatus.FAILED; break; } diff --git a/docsource/configuration.md b/docsource/configuration.md index d8c196e..20053a4 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -20,16 +20,16 @@ If a field value is specified as both an Enrollment Field in Command and in the CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Premium Certificate -Template Display Name | CSC TrustedSecure Premium Certificate -Friendly Name | CSC TrustedSecure Premium Certificate +Template Short Name | CSC TrustedSecure OV +Template Display Name | CSC TrustedSecure OV +Friendly Name | CSC TrustedSecure OV Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Premium Certificate - Enrollment Fields** +**CSC TrustedSecure OV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -44,20 +44,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -**CSC TrustedSecure EV Certificate - Details Tab** +**CSC TrustedSecure OV Wildcard - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure EV Certificate -Template Display Name | CSC TrustedSecure EV Certificate -Friendly Name | CSC TrustedSecure EV Certificate +Template Short Name | CSC TrustedSecure OV Wildcard +Template Display Name | CSC TrustedSecure OV Wildcard +Friendly Name | CSC TrustedSecure OV Wildcard Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure EV Certificate - Enrollment Fields** +**CSC TrustedSecure OV Wildcard - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -71,22 +71,21 @@ Organization Contact | Multiple Choice | Get From CSC Differs For Clients Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -Organization Country | String | N/A -**CSC TrustedSecure UC Certificate - Details Tab** +**CSC TrustedSecure OV, Multiple Names - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure UC Certificate -Template Display Name | CSC TrustedSecure UC Certificate -Friendly Name | CSC TrustedSecure UC Certificate +Template Short Name | CSC TrustedSecure OV, Multiple Names +Template Display Name | CSC TrustedSecure OV, Multiple Names +Friendly Name | CSC TrustedSecure OV, Multiple Names Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure UC Certificate - Enrollment Fields** +**CSC TrustedSecure OV, Multiple Names - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -101,22 +100,50 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A Addtl Sans Comma Separated DCV Emails | String | N/A - -**CSC TrustedSecure Premium Wildcard Certificate - Details Tab** +**CSC TrustedSecure EV - Details Tab** + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure 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 Premium Wildcard Certificate -Template Display Name | CSC TrustedSecure Premium Wildcard Certificate -Friendly Name | CSC TrustedSecure Premium Wildcard Certificate +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 Premium Wildcard Certificate - Enrollment Fields** +**CSC TrustedSecure DV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -131,20 +158,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -**CSC TrustedSecure Domain Validated SSL - Details Tab** +**CSC TrustedSecure DV Wildcard - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Domain Validated SSL -Template Display Name | CSC TrustedSecure Domain Validated SSL -Friendly Name | CSC TrustedSecure Domain Validated SSL +Template Short Name | CSC TrustedSecure DV Wildcard +Template Display Name | CSC TrustedSecure DV Wildcard +Friendly Name | CSC TrustedSecure DV Wildcard Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Domain Validated SSL - Enrollment Fields** +**CSC TrustedSecure DV Wildcard - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -159,20 +186,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -**CSC TrustedSecure Domain Validated Wildcard SSL - Details Tab** +**CSC TrustedSecure DV, Multiple Names - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Domain Validated Wildcard SSL -Template Display Name | CSC TrustedSecure Domain Validated Wildcard SSL -Friendly Name | CSC TrustedSecure Domain Validated Wildcard SSL +Template Short Name | CSC TrustedSecure DV, Multiple Names +Template Display Name | CSC TrustedSecure DV, Multiple Names +Friendly Name | CSC TrustedSecure DV, Multiple Names Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Domain Validated Wildcard SSL - Enrollment Fields** +**CSC TrustedSecure DV, Multiple Names - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -186,21 +213,81 @@ 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 Domain Validated UC Certificate - Details Tab** +**CSC TrustedSecure DV Wildcard, Multiple Names - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Domain Validated UC Certificate -Template Display Name | CSC TrustedSecure Domain Validated UC Certificate -Friendly Name | CSC TrustedSecure Domain Validated UC Certificate +Template Short Name | CSC TrustedSecure DV 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 Domain Validated UC Certificate - Enrollment Fields** +**CSC TrustedSecure DV Wildcard, Multiple Names - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- diff --git a/integration-manifest.json b/integration-manifest.json index 2b4b8c4..17315d8 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -29,10 +29,6 @@ "name": "DefaultPageSize", "description": "Default page size for use with the API. Default is 100" }, - { - "name": "TemplateSync", - "description": "Enable template sync." - }, { "name": "SyncFilterDays", "description": "Number of days from today to filter certificates by expiration date during incremental sync." @@ -89,13 +85,16 @@ } ], "product_ids": [ - "CSC TrustedSecure Premium Certificate", - "CSC TrustedSecure EV Certificate", - "CSC TrustedSecure UC Certificate", - "CSC TrustedSecure Premium Wildcard Certificate", - "CSC TrustedSecure Domain Validated SSL", - "CSC TrustedSecure Domain Validated Wildcard SSL", - "CSC TrustedSecure Domain Validated UC Certificate" + "CSC TrustedSecure OV", + "CSC TrustedSecure OV Wildcard", + "CSC TrustedSecure OV, Multiple Names", + "CSC TrustedSecure EV", + "CSC TrustedSecure DV", + "CSC TrustedSecure DV Wildcard", + "CSC TrustedSecure DV, Multiple Names", + "CSC TrustedSecure EV, Multiple Names", + "CSC TrustedSecure OV Wildcard, Multiple Names", + "CSC TrustedSecure DV Wildcard, Multiple Names" ] } }