diff --git a/CHANGELOG.md b/CHANGELOG.md index b8781cf..cab5c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,13 @@ v1.2.0 - Added support for CSC TrustedSecure EV, Multiple Names; CSC TrustedSecure OV Wildcard, Multiple Names; and CSC TrustedSecure DV Wildcard, Multiple Names certificate products - Renamed all certificate template product IDs to match CSC's current certificate type names (e.g. "CSC TrustedSecure Premium Certificate" is now "CSC TrustedSecure OV", "CSC TrustedSecure Domain Validated SSL" is now "CSC TrustedSecure DV"). Existing Certificate Templates in Command using the old names continue to work; new Templates should use the new names. -- Fixed the "Addtl Sans Comma Separated DCV Emails" enrollment field never actually being read during enrollment, due to a typo in the code looking up "DVC" instead of "DCV". Per-domain DCV emails for additional SANs on unrelated domains were silently ignored, falling back to the primary CN's DCV email - which does not have authority to validate a different domain. -- Fixed a case-sensitivity bug ("priorcertsn" vs "PriorCertSN") that prevented PriorCertSN from ever being read during Renew/Reissue enrollment. +- Removed the TemplateSync gateway registration setting. Certificate sync now always maps synced certificates back to their CSC certificate type/template. +- Added .NET 10 as a supported target framework, alongside .NET 6 and .NET 8. +- Enrollment/renewal/reissue failures now include the FlowLogger step-by-step summary ahead of the underlying error message in StatusMessage, instead of just a terse error. Also fixed enrollment failures reporting Status 30 (INPROCESS) instead of the correct FAILED status, and an exception thrown mid-enrollment no longer bubbles up as an unhandled error - it's now returned as a proper failed EnrollmentResult with full flow context. - Fixed a crash when CSC Global returns a null "price.total" (e.g. reissuing a certificate that is not in an active status) - Price.Total is now nullable instead of causing a JSON deserialization exception. -- Added an xUnit test suite covering certificate type/SAN/EV routing, legacy product name backward compatibility, and the fixes above. +- Fixed successful Renew and Reissue requests incorrectly reporting Status GENERATED (certificate issued) when CSC Global has only submitted the request and is still waiting on domain control validation. This caused the AnyGateway REST host to crash trying to parse a certificate that doesn't exist yet. Renew/Reissue now report EXTERNALVALIDATION, matching new enrollment, and surface the pending CNAME/email DCV instructions the same way. +- Fixed the "Addtl Sans Comma Separated DCV Emails" enrollment field never actually being read during enrollment, due to a typo in the code looking up "DVC" instead of "DCV". Per-domain DCV emails for additional SANs on unrelated domains were silently ignored, falling back to the primary CN's DCV email - which does not have authority to validate a different domain. +- On successful enrollment/renewal/reissue, the FlowLogger step summary is now attached to EnrollmentContext as one entry per step (rendered as separate bullets by Command's enrollment UI, alongside any DCV instructions) instead of one multi-line block, since that UI does not render embedded newlines. v.1.1.1 - Added Incremental Sync that goes back X Number of days diff --git a/README.md b/README.md index 3079352..0250f2c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

-Integration Status: production +Integration Status: pilot Release Issues GitHub Downloads (all assets, all releases) @@ -37,7 +37,7 @@ This integration allows for the Synchronization, Enrollment, and Revocation of c ## Compatibility -The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.2.0 and later. +The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. ## Support The CSCGlobal CAPlugin AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. @@ -54,15 +54,17 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and 2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [CSCGlobal CAPlugin AnyCA Gateway REST plugin](https://github.com/Keyfactor/cscglobal-caplugin/releases/latest) from GitHub. -3. Copy the unzipped directory (usually called `net10.0`) to the Extensions directory: +3. Copy the unzipped directory (usually called `net6.0` or `net8.0` or `net10.0`) to the Extensions directory: ```shell Depending on your AnyCA Gateway REST version, copy the unzipped directory to one of the following locations: + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net6.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net10.0\Extensions ``` - > The directory containing the CSCGlobal CAPlugin AnyCA Gateway REST plugin DLLs (`net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. + > The directory containing the CSCGlobal CAPlugin AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0` or `net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. @@ -85,8 +87,6 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and * **BearerToken** - CSCGlobal Bearer Token * **DefaultPageSize** - Default page size for use with the API. Default is 100 * **SyncFilterDays** - Number of days from today to filter certificates by expiration date during incremental sync. - * **RenewalWindowDays** - Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30. - * **DcvPollTimeoutSeconds** - Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests. 2. PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION @@ -96,16 +96,16 @@ If a field value is specified as both an Enrollment Field in Command and in the CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Premium Certificate -Template Display Name | CSC TrustedSecure Premium Certificate -Friendly Name | CSC TrustedSecure Premium Certificate +Template Short Name | CSC TrustedSecure OV +Template Display Name | CSC TrustedSecure OV +Friendly Name | CSC TrustedSecure OV Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Premium Certificate - Enrollment Fields** +**CSC TrustedSecure OV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -120,20 +120,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -**CSC TrustedSecure EV Certificate - Details Tab** +**CSC TrustedSecure OV Wildcard - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure EV Certificate -Template Display Name | CSC TrustedSecure EV Certificate -Friendly Name | CSC TrustedSecure EV Certificate +Template Short Name | CSC TrustedSecure OV Wildcard +Template Display Name | CSC TrustedSecure OV Wildcard +Friendly Name | CSC TrustedSecure OV Wildcard Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure EV Certificate - Enrollment Fields** +**CSC TrustedSecure OV Wildcard - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -147,22 +147,21 @@ Organization Contact | Multiple Choice | Get From CSC Differs For Clients Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -Organization Country | String | N/A -**CSC TrustedSecure UC Certificate - Details Tab** +**CSC TrustedSecure OV, Multiple Names - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure UC Certificate -Template Display Name | CSC TrustedSecure UC Certificate -Friendly Name | CSC TrustedSecure UC Certificate +Template Short Name | CSC TrustedSecure OV, Multiple Names +Template Display Name | CSC TrustedSecure OV, Multiple Names +Friendly Name | CSC TrustedSecure OV, Multiple Names Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure UC Certificate - Enrollment Fields** +**CSC TrustedSecure OV, Multiple Names - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -177,22 +176,21 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A Addtl Sans Comma Separated DCV Emails | String | N/A - -**CSC TrustedSecure Premium Wildcard Certificate - Details Tab** +**CSC TrustedSecure EV - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Premium Wildcard Certificate -Template Display Name | CSC TrustedSecure Premium Wildcard Certificate -Friendly Name | CSC TrustedSecure Premium Wildcard Certificate +Template Short Name | CSC TrustedSecure EV +Template Display Name | CSC TrustedSecure EV +Friendly Name | CSC TrustedSecure EV Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Premium Wildcard Certificate - Enrollment Fields** +**CSC TrustedSecure EV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -206,21 +204,22 @@ Organization Contact | Multiple Choice | Get From CSC Differs For Clients Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A +Organization Country | String | N/A -**CSC TrustedSecure Domain Validated SSL - Details Tab** +**CSC TrustedSecure DV - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Domain Validated SSL -Template Display Name | CSC TrustedSecure Domain Validated SSL -Friendly Name | CSC TrustedSecure Domain Validated SSL +Template Short Name | CSC TrustedSecure DV +Template Display Name | CSC TrustedSecure DV +Friendly Name | CSC TrustedSecure DV Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True - **CSC TrustedSecure Domain Validated SSL - Enrollment Fields** +**CSC TrustedSecure DV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -235,20 +234,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -**CSC TrustedSecure Domain Validated Wildcard SSL - Details Tab** +**CSC TrustedSecure DV Wildcard - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Domain Validated Wildcard SSL -Template Display Name | CSC TrustedSecure Domain Validated Wildcard SSL -Friendly Name | CSC TrustedSecure Domain Validated Wildcard SSL +Template Short Name | CSC TrustedSecure DV Wildcard +Template Display Name | CSC TrustedSecure DV Wildcard +Friendly Name | CSC TrustedSecure DV Wildcard Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Domain Validated Wildcard SSL - Enrollment Fields** +**CSC TrustedSecure DV Wildcard - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -263,20 +262,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -**CSC TrustedSecure Domain Validated UC Certificate - Details Tab** +**CSC TrustedSecure DV, Multiple Names - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Domain Validated UC Certificate -Template Display Name | CSC TrustedSecure Domain Validated UC Certificate -Friendly Name | CSC TrustedSecure Domain Validated UC Certificate +Template Short Name | CSC TrustedSecure DV, Multiple Names +Template Display Name | CSC TrustedSecure DV, Multiple Names +Friendly Name | CSC TrustedSecure DV, Multiple Names Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Domain Validated UC Certificate - Enrollment Fields** +**CSC TrustedSecure DV, Multiple Names - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -292,131 +291,110 @@ Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A Addtl Sans Comma Separated DCV Emails | String | N/A -3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates. - -4. In Keyfactor Command (v12.3+), for each imported Certificate Template, follow the [official documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Configuring%20Template%20Options.htm) to define enrollment fields for each of the following parameters: - - * **Term** - OPTIONAL: Certificate term (e.g. 12 or 24 months) - * **Applicant First Name** - OPTIONAL: Applicant First Name - * **Applicant Last Name** - OPTIONAL: Applicant Last Name - * **Applicant Email Address** - OPTIONAL: Applicant Email Address - * **Applicant Phone** - OPTIONAL: Applicant Phone (+nn.nnnnnnnn) - * **Domain Control Validation Method** - OPTIONAL: Domain Control Validation Method (e.g. EMAIL) - * **Organization Contact** - OPTIONAL: Organization Contact (selected from CSC configuration) - * **Business Unit** - OPTIONAL: Business Unit (selected from CSC configuration) - * **Notification Email(s) Comma Separated** - OPTIONAL: Notification Email(s), comma separated - * **CN DCV Email** - OPTIONAL: CN DCV Email (e.g. admin@yourdomain.com) - * **Organization Country** - OPTIONAL: Organization Country - * **Addtl Sans Comma Separated DCV Emails** - OPTIONAL: Additional SANs DCV Emails, comma separated - -## CA Connection Configuration - -When defining the Certificate Authority in the AnyCA Gateway REST portal, configure the following fields on the **CA Connection** tab: - -CONFIG ELEMENT | DESCRIPTION | DEFAULT ----------------|-------------|-------- -Enabled | Flag to Enable or Disable gateway functionality. Set to `false` to allow creating the CA record before configuration information is available; the plugin then short-circuits Ping, Sync, Enroll, and Revoke with a warning until it is re-enabled. | `true` -CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) -ApiKey | Your CSCGlobal API key | (required) -BearerToken | Your CSCGlobal Bearer token for authentication | (required) -DefaultPageSize | Page size for API list requests | 100 -SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 -RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 -DcvPollTimeoutSeconds | Max seconds to synchronously poll CSC for certificate issuance after submitting an order. `0` disables polling (enrollment returns pending immediately; cert arrives on the next sync). When `>0`, fast-validating orders can return the issued cert directly in the enrollment response. See [Synchronous Issuance Polling](#synchronous-issuance-polling) below. | 0 - -> **Note:** DNS auto-publishing for CNAME DCV is handled by the AnyCA Gateway REST framework's Domain Validation system (gateway 3.3+). It's configured in the gateway UI under **Domain Validation Configurations**, not on the CA Connection tab. See [DNS Auto-Publishing (CNAME DCV)](#dns-auto-publishing-cname-dcv). - -## Renewal vs. Reissue Logic - -CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). - -The decision is based on the **RenewalWindowDays** setting and works as follows: - -1. The plugin fetches the original certificate from CSC and reads its `orderDate`. -2. It computes the **order expiry** as `orderDate + 1 year`. -3. It calculates **days remaining** until the order expires. -4. If `days remaining <= RenewalWindowDays`, the request is treated as a **Renewal** (new paid order). -5. If `days remaining > RenewalWindowDays`, the request is treated as a **Reissue** (free under the active order). - -**Example with default RenewalWindowDays = 30:** +**CSC TrustedSecure EV, Multiple Names - Details Tab** -``` -Order Date: 2025-04-08 -Order Expiry: 2026-04-08 -Today: 2026-03-15 -Days Left: 24 - -24 <= 30 --> RENEWAL (new paid order) -``` - -``` -Order Date: 2025-04-08 -Order Expiry: 2026-04-08 -Today: 2025-09-01 -Days Left: 219 - -219 > 30 --> REISSUE (free under active order) -``` - -**Fallback behavior:** If the plugin cannot retrieve the `orderDate` from CSC (e.g., API error or missing field), it falls back to checking the certificate's expiration date. If the certificate is already expired, it treats the request as a Renewal. - -**Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. - -## DNS Auto-Publishing (CNAME DCV) - -CSC supports two Domain Control Validation (DCV) methods: **EMAIL** and **CNAME**. With CNAME validation, CSC returns a CNAME record (name → target) that must exist in DNS before they will validate the order. - -By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, the plugin uses the **AnyCA Gateway REST framework's built-in DNS provider system** (available in framework 3.3 and later). The framework discovers DNS provider plugins deployed alongside the CA plugin and routes each CNAME to whichever provider claims the matching DNS zone. +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure EV, Multiple Names +Template Display Name | CSC TrustedSecure EV, Multiple Names +Friendly Name | CSC TrustedSecure EV, Multiple Names +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True -### Requirements -* AnyCA Gateway REST framework **3.3 or later** (the `IDomainValidatorFactory` interface ships in `Keyfactor.AnyGateway.IAnyCAPlugin` 3.3+). -* At least one DNS provider DLL (e.g. GoDaddy, Cloudflare, Route 53, Azure) deployed in the gateway `Extensions` folder. -* A Domain Validation Configuration registered in the gateway UI that maps your domain(s) to the deployed provider (for example, `*.example.com` → GoDaddy). +**CSC TrustedSecure EV, Multiple Names - Enrollment Fields** -### How It Works +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A +Organization Country | String | N/A +Addtl Sans Comma Separated DCV Emails | String | N/A -1. CSC returns the CNAME `name → target` details in the enrollment response. -2. For each CNAME entry, the plugin calls `IDomainValidatorFactory.ResolveDomainValidator(recordName, "cname")`. -3. The framework returns the `IDomainValidator` whose Domain Validation Configuration matches the record's zone (or `null` if no match). -4. The plugin calls `validator.StageValidation(recordName, cnameTarget, ct)` to publish the record. -5. CSC asynchronously validates the CNAME; the issued certificate appears on the next sync. +**CSC TrustedSecure OV Wildcard, Multiple Names - Details Tab** -### Behavior +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure OV Wildcard, Multiple Names +Template Display Name | CSC TrustedSecure OV Wildcard, Multiple Names +Friendly Name | CSC TrustedSecure OV Wildcard, Multiple Names +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True -* **Resolution is per record, not per CA.** One CA can drive multiple DNS providers (GoDaddy for some domains, Route 53 for others) with no per-CA configuration. -* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. -* **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. -* **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. -* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. CSC's DCV requires a **CNAME** record, which is different from ACME's `"dns-01"` challenge (a TXT record). A single DNS provider DLL can ship multiple validator classes — one advertising `"dns-01"` (publishes TXT, for ACME) and one advertising `"cname"` (publishes CNAME, for CSC). You must deploy and configure a validator that advertises `"cname"` or no provider will match. -* **Trailing dots normalized.** CSC returns FQDN-canonical names with a trailing dot (e.g. `_token.example.com.`). The plugin strips the trailing dot before resolution and publishing, because Domain Validation Configurations and DNS provider APIs expect names without it. -### Configuration in the Gateway UI +**CSC TrustedSecure OV Wildcard, Multiple Names - Enrollment Fields** -In the AnyCA Gateway REST portal, under **Domain Validation Configurations**: +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A +Addtl Sans Comma Separated DCV Emails | String | N/A -1. **Add** a new configuration. -2. Pick a **Domain Validator Type** that publishes **CNAME** records and advertises validation type `cname`. For GoDaddy this is `GoDaddyCnameDomainValidator` (the `GoDaddyDomainValidator` variant publishes TXT for ACME and will **not** work for CSC). -3. Add one or more **domain patterns** (e.g. `*.example.com`). -4. Fill out the provider-specific **Configuration Settings** (API keys, base URL, etc.). -5. Save. +**CSC TrustedSecure DV Wildcard, Multiple Names - Details Tab** -Once configured, any CSC enrollment for a domain matching one of those patterns will have its CNAME auto-published. +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure DV Wildcard, Multiple Names +Template Display Name | CSC TrustedSecure DV Wildcard, Multiple Names +Friendly Name | CSC TrustedSecure DV Wildcard, Multiple Names +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True -> **Common pitfall:** If you configure the TXT/`dns-01` validator (e.g. `GoDaddyDomainValidator`) for a CSC domain, the record will publish as a **TXT** and CSC's CNAME validation will never succeed. Make sure you select the **CNAME** validator variant. -## Synchronous Issuance Polling +**CSC TrustedSecure DV Wildcard, Multiple Names - Enrollment Fields** -CSC validates domain control asynchronously — after an order is submitted (and the CNAME DCV record published), CSC/Sectigo polls public DNS on its own schedule and issues the certificate once validation passes. By default this plugin returns a **pending** (`EXTERNALVALIDATION`) result immediately and the issued certificate is picked up on the next gateway **sync** cycle. +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A +Addtl Sans Comma Separated DCV Emails | String | N/A -For environments where DNS is published automatically (see [DNS Auto-Publishing](#dns-auto-publishing-cname-dcv)) and validation tends to complete quickly, you can have the plugin **poll CSC synchronously** at the end of enrollment and return the issued certificate directly — avoiding the wait for the next sync. +3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates. -* Set **`DcvPollTimeoutSeconds`** to the maximum number of seconds to poll (e.g. `60`). `0` (default) disables polling entirely. -* The plugin polls CSC every 10 seconds until the order is issued or the timeout is reached. -* If the certificate issues within the window, the enrollment returns it immediately with a success status. -* If the window expires, the plugin falls back to the **pending** result and the certificate arrives on the next sync — exactly as it would with polling disabled. +4. In Keyfactor Command (v12.3+), for each imported Certificate Template, follow the [official documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Configuring%20Template%20Options.htm) to define enrollment fields for each of the following parameters: -**Tradeoff:** Polling blocks the enrollment request for up to `DcvPollTimeoutSeconds`. CSC validation frequently takes minutes to hours, so most orders will still fall through to pending — keep the timeout small (30–90s) to catch only the fast cases without hanging callers. This applies to New enrollments, Renewals, and Reissues. + * **Term** - OPTIONAL: Certificate term (e.g. 12 or 24 months) + * **Applicant First Name** - OPTIONAL: Applicant First Name + * **Applicant Last Name** - OPTIONAL: Applicant Last Name + * **Applicant Email Address** - OPTIONAL: Applicant Email Address + * **Applicant Phone** - OPTIONAL: Applicant Phone (+nn.nnnnnnnn) + * **Domain Control Validation Method** - OPTIONAL: Domain Control Validation Method (e.g. EMAIL) + * **Organization Contact** - OPTIONAL: Organization Contact (selected from CSC configuration) + * **Business Unit** - OPTIONAL: Business Unit (selected from CSC configuration) + * **Notification Email(s) Comma Separated** - OPTIONAL: Notification Email(s), comma separated + * **CN DCV Email** - OPTIONAL: CN DCV Email (e.g. admin@yourdomain.com) + * **Organization Country** - OPTIONAL: Organization Country + * **Addtl Sans Comma Separated DCV Emails** - OPTIONAL: Additional SANs DCV Emails, comma separated ## License diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj index 823e49f..b65ca5b 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj @@ -1,25 +1,30 @@ - - net10.0 - enable - enable - false - true - CSCGlobalCAPlugin.Tests - CscGlobalCAPluginTests - + + net8.0 + enable + enable + false + true + CSCGlobalCAPlugin.Tests + - - - - - - - + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - - - + + + diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index a763f2e..16fd258 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -1,6 +1,7 @@ // Copyright 2021 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +using System.Collections.Concurrent; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; @@ -16,41 +17,36 @@ namespace CscGlobalCAPluginTests; public class CSCGlobalCAPluginTests { - private static EnrollmentProductInfo ProductInfo(string productId = "CSC TrustedSecure DV", - Dictionary? parameters = null) => new EnrollmentProductInfo + private sealed class FakeConfigProvider : IAnyCAPluginConfigProvider { - ProductID = productId, - ProductParameters = parameters ?? new Dictionary() - }; + public Dictionary CAConnectionData { get; set; } = new(); + } - private static Mock ConfigProviderMock(Dictionary? overrides = null) + private static Dictionary ValidConnectionData() => new() { - var data = new Dictionary - { - [Constants.CscGlobalApiKey] = "api-key", - [Constants.CscGlobalUrl] = "https://example.invalid/", - [Constants.BearerToken] = "bearer-token" - }; - if (overrides != null) - foreach (var kv in overrides) - data[kv.Key] = kv.Value; - - var mock = new Mock(); - mock.Setup(c => c.CAConnectionData).Returns(data); - return mock; - } + [Constants.CscGlobalUrl] = "https://api.csc.test", + [Constants.CscGlobalApiKey] = "test-api-key", + [Constants.BearerToken] = "test-bearer-token" + }; private static CSCGlobalCAPlugin MakePlugin(Mock? client = null, - Mock? certDataReader = null, Dictionary? configOverrides = null, - IDomainValidatorFactory? validatorFactory = null) + Mock? certDataReader = null, Dictionary? connectionData = null) { - var plugin = validatorFactory != null ? new CSCGlobalCAPlugin(validatorFactory) : new CSCGlobalCAPlugin(); - plugin.Initialize(ConfigProviderMock(configOverrides).Object, + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = connectionData ?? ValidConnectionData() }, (certDataReader ?? new Mock()).Object); - plugin.CscGlobalClient = (client ?? new Mock()).Object; + if (client != null) + plugin.ClientFactory = _ => client.Object; return plugin; } + private static EnrollmentProductInfo ProductInfo(string productId = "CSC TrustedSecure OV", + Dictionary? parameters = null) => new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = parameters ?? new Dictionary() + }; + private static (X509Certificate2 Cert, string Pem) MakeSelfSignedCert(string cn = "test.example.com", bool isCa = false) { using var rsa = RSA.Create(2048); @@ -63,6 +59,8 @@ private static (X509Certificate2 Cert, string Pem) MakeSelfSignedCert(string cn return (cert, pem); } + private static string ToApiBase64(string pemText) => Convert.ToBase64String(Encoding.UTF8.GetBytes(pemText)); + // --------------------------------------------------------------------- // Initialize // --------------------------------------------------------------------- @@ -75,143 +73,98 @@ public void Initialize_NullConfigProvider_Throws() } [Fact] - public void Initialize_NullCertificateDataReader_Throws() - { - var plugin = new CSCGlobalCAPlugin(); - Assert.Throws(() => plugin.Initialize(ConfigProviderMock().Object, null!)); - } - - [Fact] - public void Initialize_NullCAConnectionData_Throws() + public void Initialize_NullCertDataReader_Throws() { var plugin = new CSCGlobalCAPlugin(); - var mock = new Mock(); - mock.Setup(c => c.CAConnectionData).Returns((Dictionary)null!); - Assert.Throws(() => plugin.Initialize(mock.Object, Mock.Of())); + Assert.Throws(() => + plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, null!)); } [Fact] - public void Initialize_EnabledDefault_ConstructsRealClient() + public void Initialize_MissingEnabled_DefaultsToTrue() { var plugin = new CSCGlobalCAPlugin(); - plugin.Initialize(ConfigProviderMock().Object, Mock.Of()); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, Mock.Of()); Assert.True(plugin.Enabled); - Assert.NotNull(plugin.CscGlobalClient); } [Fact] - public void Initialize_ExplicitlyDisabled_SkipsClientCreation() + public void Initialize_ExplicitlyDisabled_ParsesFalse() { + var data = ValidConnectionData(); + data[Constants.Enabled] = "false"; var plugin = new CSCGlobalCAPlugin(); - plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = "false" }).Object, - Mock.Of()); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); Assert.False(plugin.Enabled); - Assert.Null(plugin.CscGlobalClient); } [Fact] public void Initialize_UnparsableEnabled_DefaultsToTrue() { + var data = ValidConnectionData(); + data[Constants.Enabled] = "not-a-bool"; var plugin = new CSCGlobalCAPlugin(); - plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = "not-a-bool" }).Object, - Mock.Of()); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); Assert.True(plugin.Enabled); } [Fact] - public void Initialize_EnabledButMissingApiKey_Throws() - { - var plugin = new CSCGlobalCAPlugin(); - var mock = new Mock(); - mock.Setup(c => c.CAConnectionData).Returns(new Dictionary()); - Assert.Throws(() => plugin.Initialize(mock.Object, Mock.Of())); - } - - [Theory] - [InlineData("10", 10)] - [InlineData("not-a-number", 0)] - public void Initialize_SyncFilterDays_ParsesOrDefaults(string raw, int expected) - { - var plugin = new CSCGlobalCAPlugin(); - plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.SyncFilterDays] = raw }).Object, - Mock.Of()); - Assert.Equal(expected, plugin.SyncFilterDays); - } - - [Theory] - [InlineData("45", 45)] - [InlineData("not-a-number", 30)] - [InlineData("-5", 30)] - public void Initialize_RenewalWindowDays_ParsesOrDefaults(string raw, int expected) + public void Initialize_ValidSyncFilterDays_ParsesValue() { + var data = ValidConnectionData(); + data[Constants.SyncFilterDays] = "10"; var plugin = new CSCGlobalCAPlugin(); - plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.RenewalWindowDays] = raw }).Object, - Mock.Of()); - Assert.Equal(expected, plugin.RenewalWindowDays); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + Assert.Equal(10, plugin.SyncFilterDays); } [Fact] - public void Initialize_RenewalWindowDaysNotConfigured_DefaultsTo30() + public void Initialize_UnparsableSyncFilterDays_LeavesDefault() { + var data = ValidConnectionData(); + data[Constants.SyncFilterDays] = "not-a-number"; var plugin = new CSCGlobalCAPlugin(); - plugin.Initialize(ConfigProviderMock().Object, Mock.Of()); - Assert.Equal(30, plugin.RenewalWindowDays); - } - - [Theory] - [InlineData("5", 5)] - [InlineData("not-a-number", 0)] - [InlineData("-1", 0)] - public void Initialize_DcvPollTimeoutSeconds_ParsesOrDefaults(string raw, int expected) - { - var plugin = new CSCGlobalCAPlugin(); - plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.DcvPollTimeoutSeconds] = raw }).Object, - Mock.Of()); - Assert.Equal(expected, plugin.DcvPollTimeoutSeconds); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + Assert.Equal(0, plugin.SyncFilterDays); } [Fact] - public void Initialize_EnabledKeyPresentButNullValue_DefaultsToTrue() + public void Initialize_MissingSyncFilterDays_LeavesDefault() { var plugin = new CSCGlobalCAPlugin(); - plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = null! }).Object, - Mock.Of()); - Assert.True(plugin.Enabled); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, Mock.Of()); + Assert.Equal(0, plugin.SyncFilterDays); } [Fact] - public void Initialize_SyncFilterDaysKeyPresentButNullValue_DefaultsToZero() + public void Initialize_ValidRenewalWindowDays_ParsesValue() { + var data = ValidConnectionData(); + data[Constants.RenewalWindowDays] = "45"; var plugin = new CSCGlobalCAPlugin(); - plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.SyncFilterDays] = null! }).Object, - Mock.Of()); - Assert.Equal(0, plugin.SyncFilterDays); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + Assert.Equal(45, plugin.RenewalWindowDays); } [Fact] - public void Initialize_RenewalWindowDaysKeyPresentButNullValue_DefaultsTo30() + public void Initialize_MissingRenewalWindowDays_DefaultsTo30() { var plugin = new CSCGlobalCAPlugin(); - plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.RenewalWindowDays] = null! }).Object, - Mock.Of()); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, Mock.Of()); Assert.Equal(30, plugin.RenewalWindowDays); } - [Fact] - public void Initialize_DcvPollTimeoutSecondsKeyPresentButNullValue_DefaultsToZero() + [Theory] + [InlineData("not-a-number")] + [InlineData("-5")] + [InlineData("0")] + public void Initialize_InvalidRenewalWindowDays_DefaultsTo30(string raw) { + var data = ValidConnectionData(); + data[Constants.RenewalWindowDays] = raw; var plugin = new CSCGlobalCAPlugin(); - plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.DcvPollTimeoutSeconds] = null! }).Object, - Mock.Of()); - Assert.Equal(0, plugin.DcvPollTimeoutSeconds); - } - - [Fact] - public void Initialize_WithValidatorFactory_DoesNotThrow() - { - var plugin = new CSCGlobalCAPlugin(Mock.Of()); - plugin.Initialize(ConfigProviderMock().Object, Mock.Of()); - Assert.True(plugin.Enabled); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + Assert.Equal(30, plugin.RenewalWindowDays); } // --------------------------------------------------------------------- @@ -219,31 +172,17 @@ public void Initialize_WithValidatorFactory_DoesNotThrow() // --------------------------------------------------------------------- [Fact] - public async Task GetSingleRecord_NullId_Throws() + public async Task GetSingleRecord_ShortCaRequestId_ThrowsWrappedException() { var plugin = MakePlugin(); - await Assert.ThrowsAsync(() => plugin.GetSingleRecord(null!)); + await Assert.ThrowsAsync(() => plugin.GetSingleRecord("too-short")); } [Fact] - public async Task GetSingleRecord_TooShortId_Throws() + public async Task GetSingleRecord_NullCaRequestId_ThrowsWrappedException() { var plugin = MakePlugin(); - await Assert.ThrowsAsync(() => plugin.GetSingleRecord("short-id")); - } - - [Fact] - public async Task GetSingleRecord_NullClientResponse_ReturnsFailedMappedStatus() - { - var uuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync((CertificateResponse)null!); - - var plugin = MakePlugin(mockClient); - var result = await plugin.GetSingleRecord(uuid); - - Assert.Equal(uuid, result.CARequestID); - Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + await Assert.ThrowsAsync(() => plugin.GetSingleRecord(null!)); } [Fact] @@ -266,23 +205,6 @@ public async Task GetSingleRecord_ValidId_ReturnsMappedCertificate() Assert.Equal(Convert.ToBase64String(cert.RawData), result.Certificate); } - [Fact] - public async Task GetSingleRecord_InvalidBase64Certificate_ReturnsEmptyCertificate() - { - var uuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse - { - Certificate = Convert.ToBase64String(Encoding.ASCII.GetBytes("not valid pem at all")), - Status = "ACTIVE" - }); - - var plugin = MakePlugin(mockClient); - var result = await plugin.GetSingleRecord(uuid); - - Assert.Equal(string.Empty, result.Certificate); - } - [Fact] public async Task GetSingleRecord_ClientThrows_WrapsException() { @@ -295,241 +217,215 @@ public async Task GetSingleRecord_ClientThrows_WrapsException() } // --------------------------------------------------------------------- - // Synchronize / SyncCertificates + // Synchronize // --------------------------------------------------------------------- [Fact] - public async Task Synchronize_NullBuffer_Throws() - { - var plugin = MakePlugin(); - await Assert.ThrowsAsync(() => plugin.Synchronize(null!, null, true, CancellationToken.None)); - } - - [Fact] - public async Task Synchronize_Disabled_CompletesImmediatelyWithoutCallingClient() + public async Task Synchronize_FullSync_QueuesActiveAndRevokedOnly() { - var mockClient = new Mock(); - var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.Enabled] = "false" }); - var buffer = new System.Collections.Concurrent.BlockingCollection(); - - await plugin.Synchronize(buffer, null, true, CancellationToken.None); - - Assert.True(buffer.IsAddingCompleted); - mockClient.Verify(c => c.SubmitCertificateListRequestAsync(It.IsAny()), Times.Never); - } + var (cert, pem) = MakeSelfSignedCert(); + var apiCert = ToApiBase64(pem); - [Fact] - public async Task Synchronize_FullSync_QueuesGeneratedAndRevokedOnly() - { var mockClient = new Mock(); mockClient.Setup(c => c.SubmitCertificateListRequestAsync(null)).ReturnsAsync(new CertificateListResponse { Results = new List { - new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = null, CertificateType = "4" }, - new CertificateResponse { Uuid = "u2", Status = "Pending", Certificate = null, CertificateType = "4" }, - null! + new CertificateResponse { Uuid = "cert-1", Status = "ACTIVE", CertificateType = "CSC TrustedSecure OV", Certificate = apiCert }, + new CertificateResponse { Uuid = "cert-2", Status = "REVOKED", CertificateType = "CSC TrustedSecure DV", Certificate = apiCert }, + new CertificateResponse { Uuid = "cert-3", Status = "Pending", CertificateType = "CSC TrustedSecure OV", Certificate = apiCert } } }); var plugin = MakePlugin(mockClient); - var buffer = new System.Collections.Concurrent.BlockingCollection(); + var buffer = new BlockingCollection(); await plugin.Synchronize(buffer, null, true, CancellationToken.None); - Assert.True(buffer.IsAddingCompleted); - // Neither item has actual certificate bytes, so both get skipped after status-eligibility - // check; this exercises the eligible-but-empty-content and null-item paths. - Assert.Empty(buffer); + var items = buffer.ToList(); + Assert.Equal(2, items.Count); + Assert.Contains(items, i => i.CARequestID == "cert-1" && i.ProductID == "CSC TrustedSecure OV"); + Assert.Contains(items, i => i.CARequestID == "cert-2" && i.ProductID == "CSC TrustedSecure DV"); } [Fact] - public async Task Synchronize_IncrementalSync_UsesFilterDate() + public async Task Synchronize_IncrementalSync_UsesConfiguredFilterDays() { var mockClient = new Mock(); - string? capturedFilter = "not-called"; - mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) - .Callback(f => capturedFilter = f) + string? capturedFilter = null; + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .Callback(f => capturedFilter = f) .ReturnsAsync(new CertificateListResponse { Results = new List() }); - var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.SyncFilterDays] = "10" }); - var buffer = new System.Collections.Concurrent.BlockingCollection(); + var data = ValidConnectionData(); + data[Constants.SyncFilterDays] = "10"; + var plugin = MakePlugin(mockClient, connectionData: data); + var buffer = new BlockingCollection(); - await plugin.Synchronize(buffer, null, false, CancellationToken.None); + await plugin.Synchronize(buffer, DateTime.UtcNow, false, CancellationToken.None); Assert.NotNull(capturedFilter); - Assert.NotEqual("not-called", capturedFilter); + Assert.Equal(DateTime.Today.Subtract(TimeSpan.FromDays(10)).ToString("yyyy/MM/dd"), capturedFilter); } [Fact] - public async Task Synchronize_IncrementalSync_SyncFilterDaysNotConfigured_DefaultsToFiveDays() + public async Task Synchronize_IncrementalSync_DefaultsToFiveDaysWhenUnset() { var mockClient = new Mock(); - string? capturedFilter = "not-called"; - mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) - .Callback(f => capturedFilter = f) + string? capturedFilter = null; + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .Callback(f => capturedFilter = f) .ReturnsAsync(new CertificateListResponse { Results = new List() }); var plugin = MakePlugin(mockClient); - var buffer = new System.Collections.Concurrent.BlockingCollection(); - - await plugin.Synchronize(buffer, null, false, CancellationToken.None); - - var expected = DateTime.Today.Subtract(TimeSpan.FromDays(5)).ToString("yyyy/MM/dd"); - Assert.Equal(expected, capturedFilter); - } - - [Fact] - public async Task Synchronize_NullResultsFromClient_CompletesWithoutError() - { - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) - .ReturnsAsync((CertificateListResponse)null!); - - var plugin = MakePlugin(mockClient); - var buffer = new System.Collections.Concurrent.BlockingCollection(); + var buffer = new BlockingCollection(); - await plugin.Synchronize(buffer, null, true, CancellationToken.None); + await plugin.Synchronize(buffer, DateTime.UtcNow, false, CancellationToken.None); - Assert.True(buffer.IsAddingCompleted); + Assert.Equal(DateTime.Today.Subtract(TimeSpan.FromDays(5)).ToString("yyyy/MM/dd"), capturedFilter); } [Fact] - public async Task Synchronize_NullResultsCollection_CompletesWithoutError() + public async Task Synchronize_NullResultsCollection_CompletesWithoutQueueing() { var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) .ReturnsAsync(new CertificateListResponse { Results = null }); var plugin = MakePlugin(mockClient); - var buffer = new System.Collections.Concurrent.BlockingCollection(); + var buffer = new BlockingCollection(); await plugin.Synchronize(buffer, null, true, CancellationToken.None); + Assert.Empty(buffer.ToList()); Assert.True(buffer.IsAddingCompleted); } [Fact] - public async Task Synchronize_ValidCertificateContent_AddsToBufferWithMappedProductId() + public async Task Synchronize_EmptyCertificateContent_SkipsRecord() { - var (_, pem) = MakeSelfSignedCert(); - var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(pem)); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse - { - Results = new List + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ReturnsAsync(new CertificateListResponse { - new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "CSC TrustedSecure DV" } - } - }); + Results = new List + { + new CertificateResponse { Uuid = "cert-empty", Status = "ACTIVE", Certificate = "" } + } + }); var plugin = MakePlugin(mockClient); - var buffer = new System.Collections.Concurrent.BlockingCollection(); + var buffer = new BlockingCollection(); await plugin.Synchronize(buffer, null, true, CancellationToken.None); - var items = buffer.ToArray(); - Assert.Single(items); - Assert.Equal("u1", items[0].CARequestID); - // CSC's list/sync API returns the certificate's current product name directly, so the - // synced ProductID must match it verbatim (and therefore match the canonical Certificate - // Profile name configured in Command) rather than going through a name-remapping table. - Assert.Equal("CSC TrustedSecure DV", items[0].ProductID); + Assert.Empty(buffer.ToList()); } [Fact] - public async Task Synchronize_MalformedBase64Certificate_SkipsItem() + public async Task Synchronize_UnparsableCertificateContent_SkipsRecord() { var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse - { - Results = new List + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ReturnsAsync(new CertificateListResponse { - new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = "not valid base64 at all!!", CertificateType = "4" } - } - }); + Results = new List + { + new CertificateResponse + { + Uuid = "cert-bad", + Status = "ACTIVE", + Certificate = ToApiBase64("not a valid pem block at all") + } + } + }); var plugin = MakePlugin(mockClient); - var buffer = new System.Collections.Concurrent.BlockingCollection(); + var buffer = new BlockingCollection(); await plugin.Synchronize(buffer, null, true, CancellationToken.None); - Assert.Empty(buffer); + Assert.Empty(buffer.ToList()); } [Fact] - public async Task Synchronize_ValidBase64ButNoPemCertificates_SkipsItem() + public async Task Synchronize_MissingProductIdFromCsc_LeavesProductIdNull() { - var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("this is not a PEM certificate")); + var (_, pem) = MakeSelfSignedCert(); var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse - { - Results = new List + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ReturnsAsync(new CertificateListResponse { - new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "4" } - } - }); + Results = new List + { + new CertificateResponse { Uuid = "cert-1", Status = "ACTIVE", CertificateType = null, Certificate = ToApiBase64(pem) } + } + }); var plugin = MakePlugin(mockClient); - var buffer = new System.Collections.Concurrent.BlockingCollection(); + var buffer = new BlockingCollection(); await plugin.Synchronize(buffer, null, true, CancellationToken.None); - Assert.Empty(buffer); + var item = Assert.Single(buffer.ToList()); + Assert.Null(item.ProductID); } [Fact] - public async Task Synchronize_RevokedStatus_AlsoQualifiesForSync() + public async Task Synchronize_CancellationRequested_ThrowsAndCompletesBuffer() { var (_, pem) = MakeSelfSignedCert(); - var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(pem)); var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse - { - Results = new List + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ReturnsAsync(new CertificateListResponse { - new CertificateResponse { Uuid = "u1", Status = "REVOKED", Certificate = apiBase64, CertificateType = "4" } - } - }); + Results = new List + { + new CertificateResponse { Uuid = "cert-1", Status = "ACTIVE", Certificate = ToApiBase64(pem) } + } + }); var plugin = MakePlugin(mockClient); - var buffer = new System.Collections.Concurrent.BlockingCollection(); + var buffer = new BlockingCollection(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); - await plugin.Synchronize(buffer, null, true, CancellationToken.None); + await Assert.ThrowsAsync(() => + plugin.Synchronize(buffer, null, true, cts.Token)); - Assert.Single(buffer); + Assert.True(buffer.IsAddingCompleted); } [Fact] - public async Task Synchronize_ClientThrows_PropagatesAndCompletesBuffer() + public async Task Synchronize_ClientThrows_CompletesBufferAndRethrows() { var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("boom")); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("network down")); var plugin = MakePlugin(mockClient); - var buffer = new System.Collections.Concurrent.BlockingCollection(); + var buffer = new BlockingCollection(); + + await Assert.ThrowsAsync(() => + plugin.Synchronize(buffer, null, true, CancellationToken.None)); - await Assert.ThrowsAsync(() => plugin.Synchronize(buffer, null, true, CancellationToken.None)); Assert.True(buffer.IsAddingCompleted); } [Fact] - public async Task Synchronize_Cancelled_ThrowsOperationCanceledAndCompletesBuffer() + public async Task Synchronize_Disabled_CompletesBufferWithoutCallingClient() { var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse - { - Results = new List { new CertificateResponse { Uuid = "u1", Status = "ACTIVE" } } - }); + var data = ValidConnectionData(); + data[Constants.Enabled] = "false"; + var plugin = MakePlugin(mockClient, connectionData: data); + var buffer = new BlockingCollection(); - var plugin = MakePlugin(mockClient); - var buffer = new System.Collections.Concurrent.BlockingCollection(); - using var cts = new CancellationTokenSource(); - cts.Cancel(); + await plugin.Synchronize(buffer, null, true, CancellationToken.None); - await Assert.ThrowsAsync(() => plugin.Synchronize(buffer, null, true, cts.Token)); Assert.True(buffer.IsAddingCompleted); + Assert.Empty(buffer); + mockClient.Verify(c => c.SubmitCertificateListRequestAsync(It.IsAny()), Times.Never); } // --------------------------------------------------------------------- @@ -537,1039 +433,793 @@ public async Task Synchronize_Cancelled_ThrowsOperationCanceledAndCompletesBuffe // --------------------------------------------------------------------- [Fact] - public async Task Revoke_Disabled_Throws() - { - var plugin = MakePlugin(configOverrides: new Dictionary { [Constants.Enabled] = "false" }); - await Assert.ThrowsAsync(() => - plugin.Revoke(new string('a', 36), "serial", 0)); - } - - [Fact] - public async Task Revoke_TooShortId_Throws() - { - var plugin = MakePlugin(); - await Assert.ThrowsAsync(() => plugin.Revoke("short", "serial", 0)); - } - - [Fact] - public async Task Revoke_NullId_Throws() + public async Task Revoke_ShortCaRequestId_ThrowsWrappedException() { var plugin = MakePlugin(); - await Assert.ThrowsAsync(() => plugin.Revoke(null!, "serial", 0)); + await Assert.ThrowsAsync(() => plugin.Revoke("short", "AB12", 0)); } [Fact] - public async Task Revoke_NullResponse_Throws() + public async Task Revoke_Success_ReturnsRevokedStatus() { var uuid = Guid.NewGuid().ToString(); var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync((RevokeResponse)null!); + mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse()); var plugin = MakePlugin(mockClient); - // Wrapped by the generic catch (Exception e) at the bottom of Revoke, since - // InvalidOperationException isn't AggregateException or HttpRequestException. - var ex = await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "serial", 0)); - Assert.IsType(ex.InnerException); + var status = await plugin.Revoke(uuid, "AB12", 0); + + Assert.Equal((int)EndEntityStatus.REVOKED, status); } [Fact] - public async Task Revoke_Success_ReturnsRevoked() + public async Task Revoke_ErrorWithDescription_ThrowsWrappedException() { var uuid = Guid.NewGuid().ToString(); var mockClient = new Mock(); mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse { - RevokeSuccess = new RevokeSuccessResponse { Status = "REVOKED" } + RegistrationError = new RegistrationError { Description = "already revoked" } }); var plugin = MakePlugin(mockClient); - var result = await plugin.Revoke(uuid, "serial", 0); - - Assert.Equal((int)EndEntityStatus.REVOKED, result); + await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "AB12", 0)); } [Fact] - public async Task Revoke_ErrorWithDescription_ThrowsHttpRequestException() + public async Task Revoke_FailedWithNoErrorDescription_ReturnsFailedStatus() { var uuid = Guid.NewGuid().ToString(); var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse - { - RegistrationError = new RegistrationError { Description = "already revoked" } - }); + mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync((RevokeResponse)null!); var plugin = MakePlugin(mockClient); - await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "serial", 0)); + var status = await plugin.Revoke(uuid, "AB12", 0); + + Assert.Equal((int)EndEntityStatus.FAILED, status); } [Fact] - public async Task Revoke_ClientThrows_WrapsException() + public async Task Revoke_Disabled_ThrowsInvalidOperationException() { - var uuid = Guid.NewGuid().ToString(); var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("boom")); + var data = ValidConnectionData(); + data[Constants.Enabled] = "false"; + var plugin = MakePlugin(mockClient, connectionData: data); - var plugin = MakePlugin(mockClient); - await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "serial", 0)); + await Assert.ThrowsAsync(() => plugin.Revoke(Guid.NewGuid().ToString(), "AB12", 0)); + mockClient.Verify(c => c.SubmitRevokeCertificateAsync(It.IsAny()), Times.Never); } // --------------------------------------------------------------------- - // Ping / ValidateCAConnectionInfo + // Enroll // --------------------------------------------------------------------- [Fact] - public async Task Ping_Enabled_DoesNotThrow() + public async Task Enroll_NullProductInfo_ThrowsArgumentNullException() { var plugin = MakePlugin(); - await plugin.Ping(); + await Assert.ThrowsAsync(() => + plugin.Enroll("csr", "subject", new Dictionary(), null!, RequestFormat.PKCS10, EnrollmentType.New)); } [Fact] - public async Task Ping_Disabled_DoesNotThrow() + public async Task Enroll_Disabled_ReturnsFailedWithoutCallingClient() { - var plugin = MakePlugin(configOverrides: new Dictionary { [Constants.Enabled] = "false" }); - await plugin.Ping(); - } + var mockClient = new Mock(); + var data = ValidConnectionData(); + data[Constants.Enabled] = "false"; + var plugin = MakePlugin(mockClient, connectionData: data); - [Fact] - public async Task ValidateCAConnectionInfo_NullConnectionInfo_Throws() - { - var plugin = MakePlugin(); - await Assert.ThrowsAsync(() => plugin.ValidateCAConnectionInfo(null!)); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); + Assert.Contains("Disabled", result.StatusMessage); + mockClient.Verify(c => c.SubmitGetCustomFields(), Times.Never); } [Fact] - public async Task ValidateCAConnectionInfo_Enabled_DoesNotThrow() + public async Task Enroll_New_Success_ReturnsExternalValidation() { - var plugin = MakePlugin(); - await plugin.ValidateCAConnectionInfo(new Dictionary()); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + { + Result = new Result { CommonName = "order-1", Status = new Status { Uuid = "uuid-1" } } + }); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); + Assert.Equal("uuid-1", result.CARequestID); } [Fact] - public async Task ValidateCAConnectionInfo_ExplicitlyDisabled_DoesNotThrow() + public async Task Enroll_New_CscReturnsError_ReturnsFailedStatus() { - var plugin = MakePlugin(); - await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "false" }); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + { + RegistrationError = new RegistrationError { Description = "duplicate order" } + }); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); + Assert.Contains("Flow: Enroll", result.StatusMessage); + Assert.Contains("duplicate order", result.StatusMessage); } [Fact] - public async Task ValidateCAConnectionInfo_UnparsableEnabledValue_TreatsAsEnabled() + public async Task Enroll_NewWithPriorCertSn_ReturnsFailureWithoutCallingClient() { - var plugin = MakePlugin(); - await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "not-a-bool" }); - } + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - // --------------------------------------------------------------------- - // ValidateProductInfo - // --------------------------------------------------------------------- + var plugin = MakePlugin(mockClient); + var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); - [Theory] - [InlineData("CSC TrustedSecure DV")] - [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names")] - public async Task ValidateProductInfo_CanonicalProductName_DoesNotThrow(string productId) - { - var plugin = MakePlugin(); - await plugin.ValidateProductInfo(ProductInfo(productId), new Dictionary()); - } + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.New); - [Theory] - [InlineData("CSC TrustedSecure UC Certificate")] - [InlineData("CSC TrustedSecure Domain Validated SSL")] - [InlineData("CSC Trusted Secure Domain Validated Wildcard SSL")] - public async Task ValidateProductInfo_LegacyProductName_DoesNotThrow(string legacyProductId) - { - var plugin = MakePlugin(); - await plugin.ValidateProductInfo(ProductInfo(legacyProductId), new Dictionary()); + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); + mockClient.Verify(c => c.SubmitRegistrationAsync(It.IsAny()), Times.Never); } [Fact] - public async Task ValidateProductInfo_NullProductInfo_Throws() + public async Task Enroll_RenewOrReissue_MissingPriorCertSn_ReturnsFailure() { - var plugin = MakePlugin(); - await Assert.ThrowsAsync(() => - plugin.ValidateProductInfo(null!, new Dictionary())); - } + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - [Fact] - public async Task ValidateProductInfo_EmptyProductId_Throws() - { - var plugin = MakePlugin(); - await Assert.ThrowsAsync(() => - plugin.ValidateProductInfo(ProductInfo(""), new Dictionary())); - } + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - [Fact] - public async Task ValidateProductInfo_UnknownProduct_Throws() - { - var plugin = MakePlugin(); - await Assert.ThrowsAsync(() => - plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), new Dictionary())); + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); + Assert.Contains("no prior certificate serial number", result.StatusMessage); } [Fact] - public async Task ValidateProductInfo_NullConnectionInfo_TreatsAsEnabled() + public async Task Enroll_RenewOrReissue_NoRequestIdFoundForSerial_ReturnsFailure() { - var plugin = MakePlugin(); - await Assert.ThrowsAsync(() => - plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), null!)); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(string.Empty); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); + Assert.Contains("no prior request found", result.StatusMessage); } [Fact] - public async Task ValidateProductInfo_DisabledConnector_SkipsValidationEvenForUnknownProduct() + public async Task Enroll_RenewOrReissue_NullExpirationDate_FallsBackToGetSingleRecordThenReissues() { - var plugin = MakePlugin(); - var connectionInfo = new Dictionary { [Constants.Enabled] = "false" }; - - // Should not throw even though the product is unknown - Enabled=false short-circuits - // validation entirely (pre-configuration workflow). - await plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), connectionInfo); - } - - [Fact] - public async Task ValidateProductInfo_UnparsableEnabledValue_TreatsAsEnabled() - { - var plugin = MakePlugin(); - var connectionInfo = new Dictionary { [Constants.Enabled] = "not-a-bool" }; - await Assert.ThrowsAsync(() => - plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), connectionInfo)); - } + var orderUuid = Guid.NewGuid().ToString(); + var (_, pem) = MakeSelfSignedCert(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + Certificate = Convert.ToBase64String(Encoding.ASCII.GetBytes(pem)), + Status = "ACTIVE" + }); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "reissued.example.com" } + }); - // --------------------------------------------------------------------- - // Annotations / product IDs - // --------------------------------------------------------------------- + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).Returns((DateTime?)null); - [Fact] - public void GetCAConnectorAnnotations_ReturnsExpectedKeys() - { - var plugin = MakePlugin(); - var annotations = plugin.GetCAConnectorAnnotations(); - Assert.Contains(Constants.Enabled, annotations.Keys); - Assert.Contains(Constants.CscGlobalUrl, annotations.Keys); - Assert.Contains(Constants.DcvPollTimeoutSeconds, annotations.Keys); - } + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); - [Fact] - public void GetTemplateParameterAnnotations_ReturnsExpectedKeys() - { - var plugin = MakePlugin(); - var annotations = plugin.GetTemplateParameterAnnotations(); - Assert.Contains(EnrollmentConfigConstants.CnDcvEmail, annotations.Keys); - Assert.Contains(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails, annotations.Keys); - } + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - [Fact] - public void GetProductIds_ReturnsCanonicalTenProducts() - { - var plugin = MakePlugin(); - Assert.Equal(10, plugin.GetProductIds().Count); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); + mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once); } - // --------------------------------------------------------------------- - // Enroll - validation and New enrollment - // --------------------------------------------------------------------- - [Fact] - public async Task Enroll_Disabled_ReturnsFailedWithoutCallingClient() + public async Task Enroll_Renewal_ExpiredCertWithApplicantLastName_Succeeds() { + var uuid = Guid.NewGuid().ToString(); var mockClient = new Mock(); - var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.Enabled] = "false" }); - - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), - RequestFormat.PKCS10, EnrollmentType.New); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse + { + Result = new Result { CommonName = "renewed.example.com" } + }); - Assert.Equal((int)EndEntityStatus.FAILED, result.Status); - mockClient.Verify(c => c.SubmitGetCustomFields(), Times.Never); - } + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(-1)); - [Fact] - public async Task Enroll_NullProductInfo_Throws() - { - var plugin = MakePlugin(); - await Assert.ThrowsAsync(() => - plugin.Enroll("csr", "CN=test", new Dictionary(), null!, RequestFormat.PKCS10, EnrollmentType.New)); - } + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); - [Fact] - public async Task Enroll_NullProductParameters_Throws() - { - var plugin = MakePlugin(); - var productInfo = new EnrollmentProductInfo { ProductID = "CSC TrustedSecure DV", ProductParameters = null! }; - await Assert.ThrowsAsync(() => - plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.New)); - } + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - [Fact] - public async Task Enroll_EmptyCsr_Throws() - { - var plugin = MakePlugin(); - await Assert.ThrowsAsync(() => - plugin.Enroll("", "CN=test", new Dictionary(), ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New)); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); } [Fact] - public async Task Enroll_New_PriorCertSnPresent_ReturnsFailure() + public async Task Enroll_Renewal_ExpiredCertMissingApplicantLastName_ReturnsFailure() { + var uuid = Guid.NewGuid().ToString(); var mockClient = new Mock(); mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - var plugin = MakePlugin(mockClient); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(-1)); + + var plugin = MakePlugin(mockClient, certDataReader); var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, - RequestFormat.PKCS10, EnrollmentType.New); + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - mockClient.Verify(c => c.SubmitRegistrationAsync(It.IsAny()), Times.Never); + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); + Assert.Contains("One click Renew Is Not Available", result.StatusMessage); } [Fact] - public async Task Enroll_New_Success_ReturnsExternalValidation() + public async Task Enroll_Reissue_ValidCertWithApplicantLastName_Succeeds() { + var uuid = Guid.NewGuid().ToString(); var mockClient = new Mock(); mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse { - Result = new Result { CommonName = "new.example.com", Status = new Status { Uuid = "uuid-new" } } + Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = uuid } } }); - var plugin = MakePlugin(mockClient); - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), - RequestFormat.PKCS10, EnrollmentType.New); - - Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - Assert.Equal("uuid-new", result.CARequestID); - // Command's enrollment UI doesn't surface StatusMessage on a successful/pending result - - // only EnrollmentContext is - so the flow summary must be attached there instead, one - // bullet per step so it renders readably rather than as a single run-on blob. - Assert.NotNull(result.EnrollmentContext); - Assert.True(result.EnrollmentContext.ContainsKey("Flow: Enroll-New")); - Assert.True(result.EnrollmentContext.Keys.Count(k => k.StartsWith("Flow Step ")) > 1); - } + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(30)); - [Fact] - public async Task Enroll_New_SuccessWithDcvDetails_KeepsDcvEntriesAlongsideFlowSummary() - { - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary { - Result = new Result - { - CommonName = "dcv.example.com", - Status = new Status { Uuid = "uuid-dcv" }, - DcvDetails = new List - { - new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } } - } - } + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" }); - var plugin = MakePlugin(mockClient); - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), - RequestFormat.PKCS10, EnrollmentType.New); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]); - Assert.True(result.EnrollmentContext.ContainsKey("Flow: Enroll-New")); - Assert.True(result.EnrollmentContext.Keys.Count(k => k.StartsWith("Flow Step ")) > 1); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); + Assert.NotNull(result.EnrollmentContext); + Assert.Contains(result.EnrollmentContext.Keys, k => k.StartsWith("Flow: Enroll")); + Assert.Contains(result.EnrollmentContext.Keys, k => k.Contains("SubmitReissue")); } + // --------------------------------------------------------------------- + // RenewOrReissue - order-expiry-window decision ("200 day" fix) + // + // CSC's order is a fixed 1-year paid subscription; a shorter-lived certificate (e.g. + // ~200 days) issued under it can still have plenty of runway left on the order itself. + // The decision must be based on the order's expiry (orderDate + 1 year, vs + // RenewalWindowDays), not the certificate's own expiration date. + // --------------------------------------------------------------------- + [Fact] - public async Task Enroll_New_RegistrationErrorFromCsc_PrependsFlowSummaryToStatusMessage() + public async Task Enroll_RenewOrReissue_OrderNearExpiryWithinWindow_TriggersRenewalEvenThoughCertNotExpired() { + var uuid = Guid.NewGuid().ToString(); var mockClient = new Mock(); mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + // Order was placed 350 days ago -> expires in 15 days, inside the default 30-day window. + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse { - RegistrationError = new RegistrationError { Description = "Open order in progress" } + OrderDate = DateTime.UtcNow.AddDays(-350).ToString("o") + }); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse + { + Result = new Result { CommonName = "renewed.example.com" } }); - var plugin = MakePlugin(mockClient); - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), - RequestFormat.PKCS10, EnrollmentType.New); - - Assert.Equal((int)EndEntityStatus.FAILED, result.Status); - Assert.Contains("Enroll-New", result.StatusMessage); - Assert.Contains("Open order in progress", result.StatusMessage); - } + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + // The certificate itself still has 60 days left - under the old cert-expiry-only + // logic this would incorrectly route to Reissue. + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(60)); - [Fact] - public async Task Enroll_New_NullClientResponse_ReturnsFailure() - { - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync((RegistrationResponse)null!); + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); - var plugin = MakePlugin(mockClient); - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), - RequestFormat.PKCS10, EnrollmentType.New); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); + mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Once); + mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Never); } [Fact] - public async Task Enroll_New_ClientThrows_ReturnsFailureInsteadOfThrowing() + public async Task Enroll_RenewOrReissue_OrderFarFromExpiry_TriggersReissueEvenThoughCertExpirationLooksExpired() { + var uuid = Guid.NewGuid().ToString(); var mockClient = new Mock(); mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("boom")); - - var plugin = MakePlugin(mockClient); - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), - RequestFormat.PKCS10, EnrollmentType.New); + // 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" } + }); - Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - Assert.Contains("boom", result.StatusMessage); - } + 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)); - [Fact] - public async Task Enroll_UnhandledEnrollmentType_ReturnsFailure() - { - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - var plugin = MakePlugin(mockClient); + 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.Renew); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); + mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once); + mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Never); } [Fact] - public async Task Enroll_New_WithPollingEnabledAndFastIssuance_ReturnsGeneratedCertDirectly() + public async Task Enroll_RenewOrReissue_LiveCertFetchThrows_FallsBackToCertExpiryCheck() { - var (_, pem) = MakeSelfSignedCert(); - var apiBase64 = Convert.ToBase64String(Encoding.ASCII.GetBytes(pem)); - var uuid = Guid.NewGuid().ToString(); // must be >= 36 chars - GetSingleRecord validates length - + var uuid = Guid.NewGuid().ToString(); var mockClient = new Mock(); mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("network error")); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse { - Result = new Result { CommonName = "fast.example.com", Status = new Status { Uuid = uuid } } + Result = new Result { CommonName = "reissued.example.com" } }); - mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse + + 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 { - Status = "ACTIVE", - Certificate = apiBase64 + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" }); - // DcvPollTimeoutSeconds < the 10s poll interval means exactly one poll attempt happens - // and the loop then breaks without ever calling Task.Delay - fast and deterministic. - var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" }); - - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), - RequestFormat.PKCS10, EnrollmentType.New); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); - Assert.Equal(uuid, result.CARequestID); - Assert.NotNull(result.Certificate); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); + mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once); } [Fact] - public async Task Enroll_New_PollingEnabledButNotIssued_FallsBackToPendingResult() + 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.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + 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 = "pending.example.com", Status = new Status { Uuid = uuid } } + Result = new Result { CommonName = "renewed.example.com" } }); - mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse { Status = "Pending" }); - var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" }); + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(-1)); - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), - RequestFormat.PKCS10, EnrollmentType.New); + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); - Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - Assert.Equal(uuid, result.CARequestID); + 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_New_WithDnsValidatorFactory_PublishesCnameRecord() + 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.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse { - Result = new Result - { - CommonName = "cname.example.com", - Status = new Status { Uuid = "uuid-cname" }, - DcvDetails = new List - { - new DcvDetail { CName = new CName { Name = "_dnsauth.example.com.", Value = "target.sectigo.com." } } - } - } + 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 mockValidator = new Mock(); - mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); - mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new DomainValidationResult { Success = true, Status = "staged" }); - - var mockFactory = new Mock(); - mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + 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, validatorFactory: mockFactory.Object); + var plugin = MakePlugin(mockClient, certDataReader); var productInfo = ProductInfo(parameters: new Dictionary { - [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" }); var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, - RequestFormat.PKCS10, EnrollmentType.New); + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - // Trailing dots must be stripped before resolution or no provider would match. - mockFactory.Verify(f => f.ResolveDomainValidator("_dnsauth.example.com", "cname"), Times.Once); - mockValidator.Verify(v => v.StageValidation("_dnsauth.example.com", "target.sectigo.com", It.IsAny()), Times.Once); + 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_WithDnsValidatorFactoryButEmailMethod_DoesNotAttemptPublish() + 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 = "email.example.com", - Status = new Status { Uuid = "uuid-email" }, + CommonName = "new.example.com", + Status = new Status { Uuid = "uuid-new" }, DcvDetails = new List { new DcvDetail { Email = "admin@example.com" } } } }); - var mockFactory = new Mock(); - var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); - var productInfo = ProductInfo(parameters: new Dictionary - { - [EnrollmentConfigConstants.DomainControlValidationMethod] = "EMAIL" - }); + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, - RequestFormat.PKCS10, EnrollmentType.New); - - mockFactory.Verify(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny()), Times.Never); - // The DcvAutoPublish step must explain *why* it was a no-op for a non-CNAME method, - // rather than showing a bare [OK] under a CNAME-sounding step name. - var publishStep = result.EnrollmentContext.Single(e => e.Key.Contains("DcvAutoPublish")); - Assert.Contains("not CNAME", publishStep.Value); + 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_New_DnsValidatorUnresolved_DoesNotThrow() + 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.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse - { - Result = new Result + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())) + .Callback(r => capturedRequest = r) + .ReturnsAsync(new ReissueResponse { - CommonName = "unresolved.example.com", - Status = new Status { Uuid = "uuid-unresolved" }, - DcvDetails = new List - { - new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } } - } - } - }); + Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = uuid } } + }); - var mockFactory = new Mock(); - mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns((IDomainValidator)null!); + 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, validatorFactory: mockFactory.Object); - var productInfo = ProductInfo(parameters: new Dictionary + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo("CSC TrustedSecure UC Certificate", new Dictionary { - [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" }); var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, - RequestFormat.PKCS10, EnrollmentType.New); + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); + Assert.Equal("2", capturedRequest.CertificateType); } [Fact] - public async Task Enroll_New_DnsValidatorStageValidationThrows_DoesNotThrow() + public async Task Enroll_Reissue_MissingApplicantLastName_ReturnsFailure() { + var uuid = Guid.NewGuid().ToString(); var mockClient = new Mock(); mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse - { - Result = new Result - { - CommonName = "err.example.com", - Status = new Status { Uuid = "uuid-err" }, - DcvDetails = new List - { - new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } } - } - } - }); - var mockValidator = new Mock(); - mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); - mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new InvalidOperationException("dns failure")); - - var mockFactory = new Mock(); - mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + 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, validatorFactory: mockFactory.Object); - var productInfo = ProductInfo(parameters: new Dictionary - { - [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" - }); + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, - RequestFormat.PKCS10, EnrollmentType.New); + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); + Assert.Contains("One click Reissue Is Not Available", result.StatusMessage); } [Fact] - public async Task Enroll_New_DnsValidatorReturnsFailure_DoesNotThrow() + public async Task Enroll_Reissue_RequestIdTooShort_ReturnsFailure() { 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 = "fail.example.com", - Status = new Status { Uuid = "uuid-fail" }, - DcvDetails = new List - { - new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } } - } - } - }); - - var mockValidator = new Mock(); - mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); - mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new DomainValidationResult { Success = false, Status = "error", ErrorMessage = "nope" }); - var mockFactory = new Mock(); - mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + 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, validatorFactory: mockFactory.Object); + var plugin = MakePlugin(mockClient, certDataReader); var productInfo = ProductInfo(parameters: new Dictionary { - [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" }); var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, - RequestFormat.PKCS10, EnrollmentType.New); + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); + Assert.Contains("no prior request found", result.StatusMessage); } [Fact] - public async Task Enroll_New_DnsValidatorReturnsNullResult_DoesNotThrow() + public async Task Enroll_UnhandledEnrollmentType_ReturnsNull() { 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 = "fail-null.example.com", - Status = new Status { Uuid = "uuid-fail-null" }, - DcvDetails = new List - { - new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } } - } - } - }); - - var mockValidator = new Mock(); - mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); - mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((DomainValidationResult)null!); - - var mockFactory = new Mock(); - mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); - - var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); - var productInfo = ProductInfo(parameters: new Dictionary - { - [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" - }); - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, - RequestFormat.PKCS10, EnrollmentType.New); + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.Renew); - Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Null(result); } [Fact] - public async Task Enroll_New_DnsFactoryButNoEnrollmentContext_SkipsPublish() + public async Task Enroll_ClientThrows_ReturnsFailureWithFlowSummaryAndErrorDetail() { 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 = "none.example.com", Status = new Status { Uuid = "uuid-none" } } - }); - - var mockFactory = new Mock(); - var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + mockClient.Setup(c => c.SubmitGetCustomFields()).ThrowsAsync(new InvalidOperationException("boom")); - await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), - RequestFormat.PKCS10, EnrollmentType.New); + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); - mockFactory.Verify(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny()), Times.Never); + 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 Enroll_New_PollingEnabledButNoCARequestId_SkipsPollingWithoutError() + public async Task Ping_DoesNotThrow() { - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse - { - // No Status/Uuid at all -> enrollResult.CARequestID is null -> TryPollForIssuedCertAsync - // must skip cleanly rather than throw. - Result = new Result { CommonName = "no-uuid.example.com" } - }); - - var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" }); - - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), - RequestFormat.PKCS10, EnrollmentType.New); + var plugin = MakePlugin(); + await plugin.Ping(); + } - Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - mockClient.Verify(c => c.SubmitGetCertificateAsync(It.IsAny()), Times.Never); + [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 Enroll_New_PollingThrowsOnFirstAttempt_FallsBackToPendingResult() + public async Task ValidateCAConnectionInfo_NullConnectionInfo_DoesNotThrow() { - var uuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse - { - Result = new Result { CommonName = "poll-error.example.com", Status = new Status { Uuid = uuid } } - }); - // GetSingleRecord (called internally by the poll loop) throws - must be caught and retried, - // not propagated, and the loop still falls back to the pending result once time is up. - mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("network blip")); + var plugin = MakePlugin(); + await plugin.ValidateCAConnectionInfo(null!); + } - var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" }); + [Fact] + public async Task ValidateCAConnectionInfo_WithConnectionInfo_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateCAConnectionInfo(new Dictionary { ["Key"] = "Value" }); + } - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), - RequestFormat.PKCS10, EnrollmentType.New); + [Fact] + public async Task ValidateCAConnectionInfo_ExplicitlyDisabled_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "false" }); + } - Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + [Fact] + public async Task ValidateProductInfo_KnownProduct_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateProductInfo(ProductInfo("CSC TrustedSecure OV"), new Dictionary()); } [Fact] - public async Task Enroll_New_CnameMethodWithMixedEmailEntry_SkipsEmailPassthroughEntry() + public async Task ValidateProductInfo_KnownProductDifferentCase_DoesNotThrow() { - 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 = "mixed.example.com", - Status = new Status { Uuid = "uuid-mixed" }, - DcvDetails = new List - { - new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } }, - // Key == value: GetEnrollmentResult's email passthrough shape, mixed into the - // same EnrollmentContext even though the product's DCV method is CNAME. - new DcvDetail { Email = "admin@example.com" } - } - } - }); + var plugin = MakePlugin(); + await plugin.ValidateProductInfo(ProductInfo("csc trustedsecure ov"), new Dictionary()); + } - var mockValidator = new Mock(); - mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); - mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new DomainValidationResult { Success = true }); + [Fact] + public async Task ValidateProductInfo_UnknownProduct_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), new Dictionary())); + } - var mockFactory = new Mock(); - mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + [Fact] + public async Task ValidateProductInfo_LegacyProductName_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateProductInfo(ProductInfo("CSC TrustedSecure UC Certificate"), new Dictionary()); + } - var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); - var productInfo = ProductInfo(parameters: new Dictionary - { - [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" - }); + [Fact] + public async Task ValidateProductInfo_DisabledConnector_SkipsValidationEvenForUnknownProduct() + { + var plugin = MakePlugin(); + var connectionInfo = new Dictionary { [Constants.Enabled] = "false" }; - await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, - RequestFormat.PKCS10, EnrollmentType.New); + // 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); + } - // Only the CNAME entry should have been resolved/staged; the email passthrough is skipped. - mockFactory.Verify(f => f.ResolveDomainValidator(It.IsAny(), "cname"), Times.Once); + [Fact] + public async Task ValidateProductInfo_NullConnectionInfo_TreatsAsEnabled() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), null!)); } // --------------------------------------------------------------------- - // Enroll - RenewOrReissue + // GetCAConnectorAnnotations / GetTemplateParameterAnnotations / GetProductIds // --------------------------------------------------------------------- [Fact] - public async Task Enroll_RenewOrReissue_MissingPriorCertSn_ReturnsFailure() + public void GetCAConnectorAnnotations_ContainsExpectedKeys() { - 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); + var plugin = MakePlugin(); + var annotations = plugin.GetCAConnectorAnnotations(); - Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - Assert.Contains("PriorCertSN", result.StatusMessage); + 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 async Task Enroll_RenewOrReissue_NoOrderIdFoundForSerial_ReturnsFailure() + public void GetTemplateParameterAnnotations_ContainsExpectedKeys() { - 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); + var plugin = MakePlugin(); + var annotations = plugin.GetTemplateParameterAnnotations(); - Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal(12, annotations.Count); + Assert.Contains(EnrollmentConfigConstants.Term, annotations.Keys); + Assert.Contains(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails, annotations.Keys); } [Fact] - public async Task Enroll_RenewOrReissue_OrderIdTooShort_ReturnsFailure() + public void GetProductIds_ReturnsFullList() { - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - var certDataReader = new Mock(); - certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync("short"); + var plugin = MakePlugin(); + var ids = plugin.GetProductIds(); - var plugin = MakePlugin(mockClient, certDataReader); - var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); + Assert.Equal(10, ids.Count); + Assert.Contains("CSC TrustedSecure OV", ids); + Assert.Contains("CSC TrustedSecure DV Wildcard, Multiple Names", ids); + } - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, - RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + // --------------------------------------------------------------------- + // GetEndEntityCertificate + // --------------------------------------------------------------------- - Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + [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 async Task Enroll_RenewOrReissue_RenewalWithApplicantLastName_Succeeds() + public void GetEndEntityCertificate_NoValidPemBlocks_ReturnsEmptyString() { - var orderUuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - // OrderDate 2 years ago -> well past the 1-year+RenewalWindowDays expiry -> renewal path. - mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse - { - OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") - }); - mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse - { - Result = new Result { CommonName = "renewed.example.com", Status = new Status { Uuid = orderUuid } } - }); - - var certDataReader = new Mock(); - certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + var plugin = MakePlugin(); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate("this is not a certificate")); + } - var plugin = MakePlugin(mockClient, certDataReader); - var productInfo = ProductInfo(parameters: new Dictionary - { - ["PriorCertSN"] = "ABC123", - ["Applicant Last Name"] = "Doe" - }); + [Fact] + public void GetEndEntityCertificate_SingleLeafCert_ReturnsBase64Der() + { + var (cert, pem) = MakeSelfSignedCert(); + var plugin = MakePlugin(); - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, - RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + var result = plugin.GetEndEntityCertificate(pem); - Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Once); + Assert.Equal(Convert.ToBase64String(cert.RawData), result); } [Fact] - public async Task Enroll_RenewOrReissue_RenewalMissingApplicantLastName_ReturnsFailure() + public void GetEndEntityCertificate_MalformedBase64Block_SkipsBlockReturnsEmpty() { - var orderUuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse - { - OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") - }); - - var certDataReader = new Mock(); - certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + var pem = "-----BEGIN CERTIFICATE-----\nNOT-VALID-BASE64!!!\n-----END CERTIFICATE-----\n"; + var plugin = MakePlugin(); - var plugin = MakePlugin(mockClient, certDataReader); - var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); + } - var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, - RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + [Fact] + public void GetEndEntityCertificate_EmptyBlockContent_Skipped() + { + var pem = "-----BEGIN CERTIFICATE-----\n\n-----END CERTIFICATE-----\n"; + var plugin = MakePlugin(); - Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Never); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); } [Fact] - public async Task Enroll_RenewOrReissue_ReissueWithApplicantLastName_Succeeds() + public void GetEndEntityCertificate_ValidBase64ButNotACertificate_SkipsBlockReturnsEmpty() { - var orderUuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - // OrderDate today -> well within the renewal window -> reissue (free) path. - mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse - { - OrderDate = DateTime.UtcNow.ToString("o") - }); - mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse - { - Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = orderUuid } } - }); - - var certDataReader = new Mock(); - certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); - - 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_ReissueMissingApplicantLastName_ReturnsFailure() - { - var orderUuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse - { - OrderDate = DateTime.UtcNow.ToString("o") - }); - - var certDataReader = new Mock(); - certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); - - 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Never); - } - - [Fact] - public async Task Enroll_RenewOrReissue_NoOrderDate_FallsBackToCertificateDataReaderExpiry() - { - var orderUuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - // No OrderDate at all -> falls back to expiry-based decision. - mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse { OrderDate = null }); - mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse - { - Result = new Result { CommonName = "expired.example.com", Status = new Status { Uuid = orderUuid } } - }); - - var certDataReader = new Mock(); - certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); - certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).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); - } - - // --------------------------------------------------------------------- - // GetEndEntityCertificate / ExtractCertificates / FindLeaf - // --------------------------------------------------------------------- - - [Fact] - public void GetEndEntityCertificate_EmptyInput_ReturnsEmpty() - { - 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_NoPemBlocks_ReturnsEmpty() - { - var plugin = MakePlugin(); - Assert.Equal(string.Empty, plugin.GetEndEntityCertificate("just some plain text, no PEM fences")); - } - - [Fact] - public void GetEndEntityCertificate_EmptyPemBlockContent_SkipsBlock() - { - var (cert, pem) = MakeSelfSignedCert(); - var emptyBlock = "-----BEGIN CERTIFICATE-----\n \n-----END CERTIFICATE-----\n"; - var plugin = MakePlugin(); - - var result = plugin.GetEndEntityCertificate(emptyBlock + pem); - - Assert.Equal(Convert.ToBase64String(cert.RawData), result); - } - - [Fact] - public void GetEndEntityCertificate_CertWithoutBasicConstraints_TreatedAsNonCa() - { - // A cert with no Basic Constraints extension at all exercises FindLeaf's IsCa "unknown -> - // treat as non-CA" fallback, distinct from an explicit CertificateAuthority=false. - using var rsa = RSA.Create(2048); - var req = new CertificateRequest("CN=no-constraints.example.com", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - 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"; - - var plugin = MakePlugin(); - var result = plugin.GetEndEntityCertificate(pem); - - Assert.Equal(Convert.ToBase64String(cert.RawData), result); - } - - [Fact] - public void GetEndEntityCertificate_MalformedBase64InBlock_SkipsAndReturnsEmpty() - { - var pem = "-----BEGIN CERTIFICATE-----\nNOT!!VALID==BASE64%%CHARS\n-----END CERTIFICATE-----\n"; - var plugin = MakePlugin(); - Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); - } - - [Fact] - public void GetEndEntityCertificate_ValidBase64ButNotACertificate_SkipsAndReturnsEmpty() - { - var notACert = Convert.ToBase64String(Encoding.UTF8.GetBytes("this decodes fine but is not DER-encoded")); + 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)); } @@ -1610,292 +1260,4 @@ string ToPemBlock(X509Certificate2 c) => "-----BEGIN CERTIFICATE-----\n" + Assert.Equal(Convert.ToBase64String(leafCert.RawData), result); } - - [Fact] - public void GetEndEntityCertificate_NoDeterminableLeaf_ReturnsEmpty() - { - // Two distinct CA certs that (deliberately) share the exact same Subject/Issuer DN - // string: FindLeaf's Issuer/Subject string-matching heuristic treats each as "issuing" - // the other, so neither ends up in nonIssuers nor anyNonCa (both are CA=true) - the - // "give up" path. - string ToPemBlock(X509Certificate2 c) => "-----BEGIN CERTIFICATE-----\n" + - Convert.ToBase64String(c.RawData, Base64FormattingOptions.InsertLineBreaks) + - "\n-----END CERTIFICATE-----\n"; - - X509Certificate2 MakeCaCert() - { - using var rsa = RSA.Create(2048); - var req = new CertificateRequest("CN=duplicate.example.com", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); - return req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); - } - - var pem = ToPemBlock(MakeCaCert()) + ToPemBlock(MakeCaCert()); - var plugin = MakePlugin(); - - Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); - } - - [Fact] - public async Task Enroll_New_CustomFieldsNull_UsesEmptyListInstead() - { - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync((List)null!); - mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse - { - Result = new Result { CommonName = "nullfields.example.com", Status = new Status { Uuid = "uuid-nf" } } - }); - - 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); - } - - [Fact] - public async Task Enroll_RenewOrReissue_FetchLiveCertThrowsAndFallbackAlsoFails_ReturnsFailure() - { - var orderUuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - // Both the primary live-cert fetch AND the fallback's GetSingleRecord call use the same - // client method, and both fail - forcing the innermost catch(fallbackEx) path. - mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ThrowsAsync(new InvalidOperationException("network error")); - - 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - Assert.Contains("unable to determine renewal status", result.StatusMessage); - } - - [Fact] - public async Task Enroll_RenewOrReissue_RenewalUuidLookupFails_ReturnsFailure() - { - var orderUuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse - { - OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") // renewal path - }); - - var certDataReader = new Mock(); - // First call resolves the top-level order_id; second (inside the renewal branch, for the - // same PriorCertSN) fails to resolve - exercises ValidateRenewalUUID's failure branch. - certDataReader.SetupSequence(r => r.GetRequestIDBySerialNumber("ABC123")) - .ReturnsAsync(orderUuid) - .ReturnsAsync(string.Empty); - - 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - Assert.Contains("could not resolve prior certificate serial number", result.StatusMessage); - } - - [Fact] - public async Task Enroll_RenewOrReissue_RenewalNullResponse_ReturnsFailure() - { - var orderUuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse - { - OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") - }); - mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync((RenewalResponse)null!); - - var certDataReader = new Mock(); - certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); - - 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - Assert.Contains("CSC API returned a null response", result.StatusMessage); - } - - [Fact] - public async Task Enroll_RenewOrReissue_ReissueRequestIdLookupEmpty_ReturnsFailure() - { - var orderUuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse - { - OrderDate = DateTime.UtcNow.ToString("o") // reissue path - }); - - var certDataReader = new Mock(); - certDataReader.SetupSequence(r => r.GetRequestIDBySerialNumber("ABC123")) - .ReturnsAsync(orderUuid) - .ReturnsAsync(string.Empty); - - 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - Assert.Contains("could not resolve prior certificate serial number", result.StatusMessage); - } - - [Fact] - public async Task Enroll_RenewOrReissue_ReissueRequestIdTooShort_ReturnsFailure() - { - var orderUuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse - { - OrderDate = DateTime.UtcNow.ToString("o") - }); - - var certDataReader = new Mock(); - certDataReader.SetupSequence(r => r.GetRequestIDBySerialNumber("ABC123")) - .ReturnsAsync(orderUuid) - .ReturnsAsync("too-short"); - - 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - Assert.Contains("too short to extract a UUID", result.StatusMessage); - } - - [Fact] - public async Task Enroll_RenewOrReissue_ReissueNullResponse_ReturnsFailure() - { - var orderUuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse - { - OrderDate = DateTime.UtcNow.ToString("o") - }); - mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync((ReissueResponse)null!); - - var certDataReader = new Mock(); - certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); - - 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); - Assert.Contains("CSC API returned a null response", result.StatusMessage); - } - - [Fact] - public async Task Enroll_RenewOrReissue_FetchLiveCertThrows_FallsBackToExpiryCheck() - { - var orderUuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - mockClient.SetupSequence(c => c.SubmitGetCertificateAsync(orderUuid)) - .ThrowsAsync(new InvalidOperationException("network error")); - mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse - { - Result = new Result { CommonName = "fallback.example.com", Status = new Status { Uuid = orderUuid } } - }); - - var certDataReader = new Mock(); - certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); - certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).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); - } - - [Fact] - public async Task Enroll_RenewOrReissue_NoOrderDateAndNoExpirationDateOnReader_FallsThroughToSingleRecordLookup() - { - var orderUuid = Guid.NewGuid().ToString(); - var mockClient = new Mock(); - mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); - // No OrderDate -> falls back to expiry check. GetExpirationDateByRequestId (below) returns - // null, so the fallback's "??" actually has to call GetSingleRecord for a second time to - // get a RevocationDate - which is never set by GetSingleRecord, so it stays null and the - // nullable "<" comparison evaluates to false (not a renewal). - mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse - { - OrderDate = null, - Status = "ACTIVE" - }); - mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse - { - Result = new Result { CommonName = "reissue.example.com", Status = new Status { Uuid = orderUuid } } - }); - - 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); - } } diff --git a/cscglobal-caplugin.Tests/CscGlobalClientTests.cs b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs index 9c7b0c1..2dbeeae 100644 --- a/cscglobal-caplugin.Tests/CscGlobalClientTests.cs +++ b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs @@ -2,50 +2,61 @@ // 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 Moq; 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 HttpRequestMessage? LastRequest { get; private set; } - public FakeHttpMessageHandler(Func responder) => _responder = 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 code, string json) => - new HttpResponseMessage(code) { Content = new StringContent(json) }; + private static HttpResponseMessage JsonResponse(HttpStatusCode status, string json) => + new HttpResponseMessage(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; - private static Mock ValidConfig() + private static IAnyCAPluginConfigProvider ValidConfig() => new FakeConfigProvider { - var mock = new Mock(); - mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + CAConnectionData = new Dictionary { - [Constants.CscGlobalApiKey] = "api-key", - [Constants.CscGlobalUrl] = "https://example.invalid/", - [Constants.BearerToken] = "bearer-token" - }); - return mock; - } + [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) + private static CscGlobalClient MakeClient(Func responder, + out FakeHttpMessageHandler handler, IAnyCAPluginConfigProvider? config = null) { handler = new FakeHttpMessageHandler(responder); - return new CscGlobalClient(ValidConfig().Object, handler); + return new CscGlobalClient(config ?? ValidConfig(), handler); } // --------------------------------------------------------------------- @@ -59,116 +70,21 @@ public void Constructor_NullConfig_Throws() } [Fact] - public void Constructor_NullCAConnectionData_Throws() - { - var mock = new Mock(); - mock.Setup(c => c.CAConnectionData).Returns((Dictionary)null!); - Assert.Throws(() => new CscGlobalClient(mock.Object)); - } - - [Fact] - public void Constructor_MissingApiKey_Throws() - { - var mock = new Mock(); - mock.Setup(c => c.CAConnectionData).Returns(new Dictionary()); - Assert.Throws(() => new CscGlobalClient(mock.Object)); - } - - [Fact] - public void Constructor_MissingUrl_Throws() + public async Task Constructor_NullConnectionData_DoesNotThrowButClientIsInert() { - var mock = new Mock(); - mock.Setup(c => c.CAConnectionData).Returns(new Dictionary - { - [Constants.CscGlobalApiKey] = "api-key" - }); - Assert.Throws(() => new CscGlobalClient(mock.Object)); - } + var config = new FakeConfigProvider { CAConnectionData = null! }; + var client = new CscGlobalClient(config); - [Fact] - public void Constructor_EmptyApiKeyValue_Throws() - { - var mock = new Mock(); - mock.Setup(c => c.CAConnectionData).Returns(new Dictionary - { - [Constants.CscGlobalApiKey] = "", - [Constants.CscGlobalUrl] = "https://example.invalid/" - }); - Assert.Throws(() => new CscGlobalClient(mock.Object)); + await Assert.ThrowsAsync(() => client.SubmitGetCustomFields()); } [Fact] - public void Constructor_NullApiKeyValue_Throws() + public async Task Constructor_MissingApiKeyEntry_DoesNotThrowButClientIsInert() { - // Key present but value is a null object (distinct from a missing key or an empty string - - // exercises the `?.ToString()` null-conditional rather than the ContainsKey check). - var mock = new Mock(); - mock.Setup(c => c.CAConnectionData).Returns(new Dictionary - { - [Constants.CscGlobalApiKey] = null!, - [Constants.CscGlobalUrl] = "https://example.invalid/" - }); - Assert.Throws(() => new CscGlobalClient(mock.Object)); - } + var config = new FakeConfigProvider { CAConnectionData = new Dictionary() }; + var client = new CscGlobalClient(config); - [Fact] - public void Constructor_NullUrlValue_Throws() - { - // Url key present but value is a null object (distinct from a missing key). - var mock = new Mock(); - mock.Setup(c => c.CAConnectionData).Returns(new Dictionary - { - [Constants.CscGlobalApiKey] = "api-key", - [Constants.CscGlobalUrl] = null! - }); - Assert.Throws(() => new CscGlobalClient(mock.Object)); - } - - [Fact] - public void Constructor_EmptyBearerTokenValue_Throws() - { - var mock = new Mock(); - mock.Setup(c => c.CAConnectionData).Returns(new Dictionary - { - [Constants.CscGlobalApiKey] = "api-key", - [Constants.CscGlobalUrl] = "https://example.invalid/", - [Constants.BearerToken] = "" - }); - Assert.Throws(() => new CscGlobalClient(mock.Object)); - } - - [Fact] - public void Constructor_NullBearerTokenValue_Throws() - { - // BearerToken key present but value is a null object (distinct from a missing key or an - // empty string - exercises the `?.ToString()` null-conditional rather than ContainsKey). - var mock = new Mock(); - mock.Setup(c => c.CAConnectionData).Returns(new Dictionary - { - [Constants.CscGlobalApiKey] = "api-key", - [Constants.CscGlobalUrl] = "https://example.invalid/", - [Constants.BearerToken] = null! - }); - Assert.Throws(() => new CscGlobalClient(mock.Object)); - } - - [Fact] - public void Constructor_MissingBearerToken_Throws() - { - var mock = new Mock(); - mock.Setup(c => c.CAConnectionData).Returns(new Dictionary - { - [Constants.CscGlobalApiKey] = "api-key", - [Constants.CscGlobalUrl] = "https://example.invalid/" - }); - Assert.Throws(() => new CscGlobalClient(mock.Object)); - } - - [Fact] - public void Constructor_ValidConfig_DoesNotThrow() - { - var client = new CscGlobalClient(ValidConfig().Object, new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, "{}"))); - Assert.NotNull(client); + await Assert.ThrowsAsync(() => client.SubmitGetCustomFields()); } // --------------------------------------------------------------------- @@ -179,27 +95,30 @@ public void Constructor_ValidConfig_DoesNotThrow() public async Task SubmitRegistrationAsync_Success_ReturnsParsedResponse() { var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, - "{\"result\":{\"commonName\":\"order-1\",\"price\":{\"currency\":\"USD\",\"total\":99.5}}}"), out var handler); + "{\"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_NullRequest_Throws() - { - var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); - await Assert.ThrowsAsync(() => client.SubmitRegistrationAsync(null!)); - } - [Fact] public async Task SubmitRegistrationAsync_BadRequest_ReturnsRegistrationError() { - var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"denied\"}"), out _); + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, + "{\"description\":\"invalid csr\"}"), out _); + var response = await client.SubmitRegistrationAsync(new RegistrationRequest()); - Assert.Equal("denied", response.RegistrationError.Description); + + Assert.NotNull(response.RegistrationError); + Assert.Equal("invalid csr", response.RegistrationError.Description); Assert.Null(response.Result); } @@ -207,9 +126,18 @@ public async Task SubmitRegistrationAsync_BadRequest_ReturnsRegistrationError() 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 // --------------------------------------------------------------------- @@ -217,34 +145,41 @@ public async Task SubmitRegistrationAsync_OtherError_Throws() [Fact] public async Task SubmitRenewalAsync_Success_ReturnsParsedResponse() { - var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"result\":{\"commonName\":\"renewed-1\"}}"), out var handler); + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"result\":{\"commonName\":\"renew-1\"}}"), out var handler); + var response = await client.SubmitRenewalAsync(new RenewalRequest()); - Assert.Equal("renewed-1", response.Result.CommonName); - Assert.Contains("/dbs/api/v2/tls/renewal", handler.LastRequest!.RequestUri!.ToString()); - } - [Fact] - public async Task SubmitRenewalAsync_NullRequest_Throws() - { - var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); - await Assert.ThrowsAsync(() => client.SubmitRenewalAsync(null!)); + 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\":\"denied\"}"), out _); + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"nope\"}"), out _); + var response = await client.SubmitRenewalAsync(new RenewalRequest()); - Assert.Equal("denied", response.RegistrationError.Description); + + 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 // --------------------------------------------------------------------- @@ -252,8 +187,11 @@ public async Task SubmitRenewalAsync_OtherError_Throws() [Fact] public async Task SubmitReissueAsync_Success_ReturnsParsedResponse() { - var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"result\":{\"commonName\":\"reissue-1\"}}"), out var handler); + 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()); } @@ -261,8 +199,10 @@ public async Task SubmitReissueAsync_Success_ReturnsParsedResponse() [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. + // 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 _); @@ -276,7 +216,9 @@ public async Task SubmitReissueAsync_NullPriceTotal_DoesNotThrow() 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); } @@ -284,9 +226,18 @@ public async Task SubmitReissueAsync_BadRequest_ReturnsRegistrationError() 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 // --------------------------------------------------------------------- @@ -294,24 +245,28 @@ public async Task SubmitReissueAsync_OtherError_Throws() [Fact] public async Task SubmitGetCertificateAsync_Success_ReturnsParsedResponse() { - var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"status\":\"ACTIVE\",\"certificate\":\"abc\"}"), out var handler); - var response = await client.SubmitGetCertificateAsync("uuid-1"); - Assert.Equal("ACTIVE", response.Status); - Assert.Contains("/dbs/api/v2/tls/certificate/uuid-1", handler.LastRequest!.RequestUri!.ToString()); + 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_NullId_Throws() + public async Task SubmitGetCertificateAsync_Failure_Throws() { - var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); - await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync(null!)); + var client = MakeClient(_ => JsonResponse(HttpStatusCode.NotFound, "not found"), out _); + + await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync("missing-uuid")); } [Fact] - public async Task SubmitGetCertificateAsync_ErrorStatus_Throws() + public async Task SubmitGetCertificateAsync_UnparsableSuccessBody_Throws() { - var client = MakeClient(_ => JsonResponse(HttpStatusCode.NotFound, "not found"), out _); - await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync("uuid-1")); + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _); + + await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync("cert-uuid")); } // --------------------------------------------------------------------- @@ -332,26 +287,29 @@ public async Task SubmitGetCustomFields_Success_ReturnsList() } [Fact] - public async Task SubmitGetCustomFields_NullCustomFieldsProperty_ReturnsEmptyList() + 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_NullResponseBody_ReturnsEmptyList() + public async Task SubmitGetCustomFields_Failure_Throws() { - var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _); - var fields = await client.SubmitGetCustomFields(); - Assert.Empty(fields); + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + + await Assert.ThrowsAsync(() => client.SubmitGetCustomFields()); } [Fact] - public async Task SubmitGetCustomFields_ErrorStatus_Throws() + public async Task SubmitGetCustomFields_UnparsableBody_Throws() { - var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); - await Assert.ThrowsAsync(() => client.SubmitGetCustomFields()); + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _); + + await Assert.ThrowsAsync(() => client.SubmitGetCustomFields()); } // --------------------------------------------------------------------- @@ -362,26 +320,23 @@ public async Task SubmitGetCustomFields_ErrorStatus_Throws() public async Task SubmitRevokeCertificateAsync_Success_ReturnsParsedResponse() { var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, - "{\"revokeSuccess\":{\"status\":\"REVOKED\"}}"), out var handler); + "{\"revokeSuccess\":{\"commonName\":\"revoked.example.com\",\"certificateType\":\"4\",\"status\":\"REVOKED\"}}"), out var handler); - var response = await client.SubmitRevokeCertificateAsync("uuid-1"); + 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/uuid-1", handler.LastRequest!.RequestUri!.ToString()); - } - - [Fact] - public async Task SubmitRevokeCertificateAsync_NullUuid_Throws() - { - var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); - await Assert.ThrowsAsync(() => client.SubmitRevokeCertificateAsync(null!)); + 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("uuid-1"); + + var response = await client.SubmitRevokeCertificateAsync("revoke-uuid"); + Assert.Equal("already revoked", response.RegistrationError.Description); } @@ -389,7 +344,16 @@ public async Task SubmitRevokeCertificateAsync_BadRequest_ReturnsRegistrationErr public async Task SubmitRevokeCertificateAsync_OtherError_Throws() { var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); - await Assert.ThrowsAsync(() => client.SubmitRevokeCertificateAsync("uuid-1")); + + 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")); } // --------------------------------------------------------------------- @@ -397,19 +361,20 @@ public async Task SubmitRevokeCertificateAsync_OtherError_Throws() // --------------------------------------------------------------------- [Fact] - public async Task SubmitCertificateListRequestAsync_NoDateFilter_ReturnsResults() + public async Task SubmitCertificateListRequestAsync_NoDateFilter_Success() { var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, - "{\"meta\":{\"numResults\":1},\"results\":[{\"uuid\":\"u1\"}]}"), out var handler); + "{\"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_AppendsFilterToQuery() + public async Task SubmitCertificateListRequestAsync_WithDateFilter_IncludesFilterInQuery() { var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"results\":[]}"), out var handler); @@ -419,21 +384,18 @@ public async Task SubmitCertificateListRequestAsync_WithDateFilter_AppendsFilter } [Fact] - public async Task SubmitCertificateListRequestAsync_NullBody_ReturnsEmptyResponse() + public async Task SubmitCertificateListRequestAsync_Failure_Throws() { - var client = MakeClient(_ => new HttpResponseMessage(HttpStatusCode.OK), out _); - var response = await client.SubmitCertificateListRequestAsync(); - Assert.NotNull(response); + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + + await Assert.ThrowsAsync(() => client.SubmitCertificateListRequestAsync()); } [Fact] - public async Task SubmitCertificateListRequestAsync_ErrorStatus_DoesNotThrow_ReturnsParsedBody() + public async Task SubmitCertificateListRequestAsync_UnparsableBody_Throws() { - // Unlike the other Submit* methods, this one only logs on non-success and still parses - // whatever body came back rather than throwing. - var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "{\"results\":[]}"), out _); - var response = await client.SubmitCertificateListRequestAsync(); - Assert.NotNull(response.Results); - Assert.Empty(response.Results); + 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 index a9834fd..6c71329 100644 --- a/cscglobal-caplugin.Tests/FlowLoggerTests.cs +++ b/cscglobal-caplugin.Tests/FlowLoggerTests.cs @@ -18,11 +18,23 @@ private static Mock NewLoggerMock() } [Fact] - public void Step_NoDetail_ChainableAndDoesNotThrow() + 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"); - var result = flow.Step("StepOne"); - Assert.Same(flow, result); + flow.Step("StepOne"); + Assert.False(flow.HasFailures); } [Fact] @@ -30,15 +42,17 @@ 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_RunsAction() + 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] @@ -47,6 +61,7 @@ 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] @@ -54,167 +69,153 @@ 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_Success_RunsAction() + public async Task StepAsync_NoReturnValue_Success() { using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); - var ran = false; - await flow.StepAsync("AsyncStep", () => - { - ran = true; - return Task.CompletedTask; - }); - Assert.True(ran); + await flow.StepAsync("AsyncStep", () => Task.CompletedTask); + Assert.False(flow.HasFailures); } [Fact] - public async Task StepAsync_Throws_RecordsFailureAndRethrows() + 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_WithDetail_Success() + public async Task StepAsync_WithReturnValue_Success_ReturnsResult() { using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); - await flow.StepAsync("AsyncStep", () => Task.CompletedTask, "detail"); + var result = await flow.StepAsync("AsyncStep", () => Task.FromResult(42)); + Assert.Equal(42, result); + Assert.False(flow.HasFailures); } [Fact] - public void Fail_RecordsFailure_DoesNotThrow() + public async Task StepAsync_WithReturnValue_Throws_RecordsFailureAndRethrows() { using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); - flow.Fail("StepOne"); - flow.Fail("StepTwo", "reason"); + await Assert.ThrowsAsync(() => + flow.StepAsync("AsyncStep", () => throw new InvalidOperationException("boom"))); + Assert.True(flow.HasFailures); } [Fact] - public void Skip_DoesNotThrow() + public void StepFunc_Success_ReturnsResult() { using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); - flow.Skip("StepOne"); - flow.Skip("StepTwo", "not applicable"); + var result = flow.Step("Func", () => 99); + Assert.Equal(99, result); + Assert.False(flow.HasFailures); } [Fact] - public void Branch_EndBranch_ChildStepsNestUnderBranch() + public void StepFunc_Throws_RecordsFailureAndRethrows() { using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); - flow.Branch("Inner"); - flow.Step("NestedStep"); - flow.EndBranch(); - flow.Step("TopLevelStep"); + Assert.Throws(() => + flow.Step("Func", () => throw new InvalidOperationException("boom"))); + Assert.True(flow.HasFailures); } [Fact] - public void EndBranch_WithoutBranch_DoesNotThrow() + public void Fail_RecordsFailure() { using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); - flow.EndBranch(); + flow.Fail("StepOne", "reason"); + Assert.True(flow.HasFailures); } [Fact] - public void GetSummaryEntries_OneEntryPerStepPlusHeader() + public void Skip_DoesNotRecordFailure() { - using var flow = new FlowLogger(NewLoggerMock().Object, "MyFlow"); - flow.Step("StepOne"); - flow.Fail("StepTwo", "boom"); - - var entries = flow.GetSummaryEntries(); - - Assert.True(entries.ContainsKey("Flow: MyFlow")); - Assert.Contains("FAILED", entries["Flow: MyFlow"]); - Assert.Equal(3, entries.Count); // header + 2 steps - Assert.Contains(entries, e => e.Key.Contains("StepOne") && e.Value.Contains("OK")); - Assert.Contains(entries, e => e.Key.Contains("StepTwo") && e.Value.Contains("boom")); - } - - [Fact] - public void GetSummaryEntries_AllStepsSucceed_HeaderReportsOk() - { - using var flow = new FlowLogger(NewLoggerMock().Object, "MyFlow"); - flow.Step("StepOne"); - flow.Step("StepTwo"); - - var entries = flow.GetSummaryEntries(); - - Assert.Contains("[OK]", entries["Flow: MyFlow"]); + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Skip("StepOne", "not applicable"); + Assert.False(flow.HasFailures); } [Fact] - public void GetSummaryEntries_BranchChildren_IncludedAsSeparateEntries() + public void Branch_EndBranch_RoundTrips() { - using var flow = new FlowLogger(NewLoggerMock().Object, "MyFlow"); + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); flow.Branch("Inner"); flow.Step("NestedStep"); - flow.Fail("NestedFail", "inner reason"); flow.EndBranch(); - flow.Step("TopLevelStep"); - - var entries = flow.GetSummaryEntries(); - - Assert.Contains(entries, e => e.Key.Contains("NestedStep")); - Assert.Contains(entries, e => e.Key.Contains("NestedFail") && e.Value.Contains("inner reason")); - Assert.Contains(entries, e => e.Key.Contains("TopLevelStep")); + Assert.False(flow.HasFailures); } [Fact] - public void Dispose_NoSteps_DoesNotThrow() + public void EndBranch_WithoutBranch_DoesNotThrow() { - var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); - flow.Dispose(); + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.EndBranch(); } [Fact] - public void Dispose_AllStepsSuccess_DoesNotThrow() + public void GetSummary_IncludesAllStepKindsAndCounts() { - var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); - flow.Step("Ok1"); - flow.Step("Ok2"); - flow.Dispose(); + 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 Dispose_LastStepFailed_DoesNotThrow() + public void GetSummary_NoFailures_ReportsOk() { - var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); - flow.Step("Ok1"); - flow.Fail("Failed1"); - flow.Dispose(); + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok"); + Assert.Contains("[OK]", flow.GetSummary()); + Assert.DoesNotContain("FAILED", flow.GetSummary()); } [Fact] - public void Dispose_MidStepFailedButLastSucceeded_PartialFailure_DoesNotThrow() + public void GetSummaryEntries_OneEntryPerStepPlusOverview() { - var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); - flow.Fail("Failed1"); - flow.Step("Ok1"); - flow.Dispose(); + 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 Dispose_WithBranchChildren_RendersChildrenWithoutThrowing() + public void GetSummaryEntries_NoSteps_ReturnsOnlyOverview() { - var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); - flow.Branch("Branch1"); - flow.Step("Child1"); - flow.Fail("Child2"); - flow.Skip("Child3"); - flow.EndBranch(); - flow.Step("AfterBranch"); - flow.Dispose(); + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var entries = flow.GetSummaryEntries(); + Assert.Single(entries); } [Fact] - public void Dispose_CalledTwice_IsIdempotent() + public void Dispose_DoesNotThrow() { var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); flow.Step("Ok"); flow.Dispose(); - flow.Dispose(); // should not throw or double-log } } diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs index 0762605..ad82f3d 100644 --- a/cscglobal-caplugin.Tests/RequestManagerTests.cs +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -4,7 +4,7 @@ using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.CSCGlobal; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; -using Newtonsoft.Json; +using Keyfactor.PKI.Enums.EJBCA; using Xunit; namespace CscGlobalCAPluginTests; @@ -23,306 +23,96 @@ private static EnrollmentProductInfo ProductInfo(string productId, Dictionary new RequestManager(); // --------------------------------------------------------------------- - // Certificate type routing - canonical (1.2.0+) names, all 10 products. + // GetRenewResponse // --------------------------------------------------------------------- - [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 Unrecognized Product", "-1", false, false)] - public void GetRegistrationRequest_CanonicalProductNames_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); - } - - // --------------------------------------------------------------------- - // Certificate type routing - pre-1.2.0 legacy names must resolve identically to their - // canonical replacement, so existing Certificate Templates in Command keep working. - // --------------------------------------------------------------------- - - [Theory] - [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 Trusted Secure Domain Validated Wildcard SSL", "5", false, false)] - [InlineData("CSC Trusted Secure Domain Validated UC Certificate", "6", true, false)] - public void GetRegistrationRequest_LegacyProductNames_ResolveToSameCertificateType( - string legacyProductId, string expectedType, bool expectSans, bool expectEv) - { - var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; - var productInfo = ProductInfo(legacyProductId, 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); - } - [Fact] - public void GetRegistrationRequest_LegacyAndCanonicalName_ProduceIdenticalCertificateType() - { - var legacy = ProductInfo("CSC TrustedSecure UC Certificate"); - var canonical = ProductInfo("CSC TrustedSecure OV, Multiple Names"); - - var legacyRequest = Manager.GetRegistrationRequest(legacy, SampleCsr, new Dictionary(), new List()); - var canonicalRequest = Manager.GetRegistrationRequest(canonical, SampleCsr, new Dictionary(), new List()); - - Assert.Equal(canonicalRequest.CertificateType, legacyRequest.CertificateType); - } - - // --------------------------------------------------------------------- - // IsKnownProductId - backs ValidateProductInfo. Must recognize both canonical and legacy - // names from the same source of truth GetCertificateType uses, so the two can't drift. - // --------------------------------------------------------------------- - - [Theory] - [InlineData("CSC TrustedSecure DV")] - [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names")] - [InlineData("CSC TrustedSecure Domain Validated SSL")] - [InlineData("csc trustedsecure dv")] - public void IsKnownProductId_RecognizedName_ReturnsTrue(string productId) - { - Assert.True(Manager.IsKnownProductId(productId)); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData("Not A Real Product")] - public void IsKnownProductId_UnrecognizedOrEmpty_ReturnsFalse(string? productId) - { - Assert.False(Manager.IsKnownProductId(productId!)); - } - - // --------------------------------------------------------------------- - // GetSubjectAlternativeNames (exercised via GetRegistrationRequest) - DCV email resolution. - // --------------------------------------------------------------------- - - [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 unrelated to any configured "Addtl Sans" email must fall back to the primary - // CN's DCV email rather than being left null. - var sans = new Dictionary { ["dnsname"] = new[] { "www.unrelated-domain.io" } }; - var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary - { - ["Domain Control Validation Method"] = "EMAIL", - [EnrollmentConfigConstants.CnDcvEmail] = "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() + public void GetRenewResponse_NullResponse_ReturnsFailed() { - 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); - Assert.NotNull(request.SubjectAlternativeNames[0].DomainControlValidation); - Assert.Equal("CNAME", request.SubjectAlternativeNames[0].DomainControlValidation.MethodType); + var result = Manager.GetRenewResponse(null); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Contains("no response", result.StatusMessage); } [Fact] - public void GetRegistrationRequest_WildcardMultiNameProduct_AcceptsUnrelatedDomainSans() + public void GetRenewResponse_RegistrationError_ReturnsFailedWithDescription() { - // Types 8/9 are wildcard + multi-name (the underlying Sectigo Multi-Domain Wildcard - // product) - additional SANs are not restricted to the CN's own base domain. - var sans = new Dictionary + var response = new RenewalResponse { - ["dnsname"] = new[] { "*.example2.com", "*.example3.com" } + RegistrationError = new RegistrationError { Description = "boom" }, + Result = new Result { Status = new Status { Uuid = "abc-123" } } }; - var productInfo = ProductInfo("CSC TrustedSecure DV Wildcard, Multiple Names", new Dictionary - { - ["Domain Control Validation Method"] = "CNAME" - }); - var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + var result = Manager.GetRenewResponse(response); - Assert.Equal(2, request.SubjectAlternativeNames.Count); - Assert.Equal("*.example2.com", request.SubjectAlternativeNames[0].DomainName); - Assert.Equal("*.example3.com", request.SubjectAlternativeNames[1].DomainName); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Equal("boom", result.StatusMessage); + Assert.Equal("abc-123", result.CARequestID); } - // --------------------------------------------------------------------- - // Price.Total nullability - CSC Global returns "price.total": null for orders that cannot - // be processed. Total must be nullable or Newtonsoft throws mid-deserialization, before the - // caller ever sees the RegistrationError/order status CSC was actually trying to report. - // --------------------------------------------------------------------- - [Fact] - public void RegistrationResponse_NullPriceTotal_DeserializesWithoutThrowing() + public void GetRenewResponse_NullResult_ReturnsFailed() { - const string json = "{\"result\":{\"commonName\":\"order-1\",\"price\":{\"currency\":\"\",\"total\":null}}}"; - - var response = JsonConvert.DeserializeObject(json); - - Assert.NotNull(response?.Result?.Price); - Assert.Null(response!.Result!.Price!.Total); + var response = new RenewalResponse { Result = null }; + var result = Manager.GetRenewResponse(response); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Contains("no result", result.StatusMessage); } - // --------------------------------------------------------------------- - // GetRenewResponse / GetReIssueResult - CSC never returns an issued certificate on these - // responses (only order/DCV status), so success must report EXTERNALVALIDATION, not - // GENERATED, or the gateway host will try to parse a certificate that doesn't exist. - // --------------------------------------------------------------------- - [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-1" } } + Result = new Result { CommonName = "renewed.example.com", Status = new Status { Uuid = "uuid-renew" } } }; - var result = Manager.GetRenewResponse(response); - - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); - Assert.Equal("uuid-1", result.CARequestID); - } - - [Fact] - public void GetReIssueResult_Success_ReturnsExternalValidation() - { - var response = new ReissueResponse - { - Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = "uuid-2" } } - }; - - var result = Manager.GetReIssueResult(response); - - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); - Assert.Equal("uuid-2", result.CARequestID); - } - - [Fact] - public void GetReIssueResult_NullResponse_ReturnsFailed() - { - var result = Manager.GetReIssueResult(null); - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); - } - - [Fact] - public void GetReIssueResult_RegistrationError_ReturnsFailedWithDescription() - { - var response = new ReissueResponse { RegistrationError = new RegistrationError { Description = "rejected" } }; - var result = Manager.GetReIssueResult(response); - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); - Assert.Equal("rejected", result.StatusMessage); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal("uuid-renew", result.CARequestID); + Assert.Contains("renewed.example.com", result.StatusMessage); } [Fact] - public void GetReIssueResult_NullResult_ReturnsFailed() - { - var response = new ReissueResponse { Result = null }; - var result = Manager.GetReIssueResult(response); - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); - } - - [Fact] - public void GetRenewResponse_NullResponse_ReturnsFailed() - { - var result = Manager.GetRenewResponse(null); - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); - } - - [Fact] - public void GetRenewResponse_RegistrationError_ReturnsFailedWithDescription() + public void GetRenewResponse_Success_WithDcvDetails_PopulatesEnrollmentContext() { var response = new RenewalResponse { - RegistrationError = new RegistrationError { Description = "boom" }, - Result = new Result { Status = new Status { Uuid = "abc-123" } } + 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.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); - Assert.Equal("abc-123", result.CARequestID); - Assert.Equal("boom", result.StatusMessage); + Assert.NotNull(result.EnrollmentContext); + Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]); } - [Fact] - public void GetRenewResponse_NullResult_StillReturnsExternalValidation() - { - // Unlike GetEnrollmentResult/GetReIssueResult, GetRenewResponse has no explicit - // Result==null guard - it just null-conditionals through to "(unknown)"/null. - var response = new RenewalResponse { Result = null }; - var result = Manager.GetRenewResponse(response); - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); - Assert.Contains("(unknown)", result.StatusMessage); - } + // --------------------------------------------------------------------- + // GetEnrollmentResult + // --------------------------------------------------------------------- [Fact] public void GetEnrollmentResult_NullResponse_ReturnsFailed() { var result = Manager.GetEnrollmentResult(null); - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); } [Fact] public void GetEnrollmentResult_RegistrationError_ReturnsFailed() { - var response = new RegistrationResponse { RegistrationError = new RegistrationError { Description = "denied" } }; + var response = new RegistrationResponse { RegistrationError = new RegistrationError { Description = "bad request" } }; var result = Manager.GetEnrollmentResult(response); - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); - Assert.Equal("denied", result.StatusMessage); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Equal("bad request", result.StatusMessage); } [Fact] @@ -330,7 +120,7 @@ public void GetEnrollmentResult_NullResult_ReturnsFailed() { var response = new RegistrationResponse { Result = null }; var result = Manager.GetEnrollmentResult(response); - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); } [Fact] @@ -340,8 +130,10 @@ public void GetEnrollmentResult_SuccessNoDcvDetails_ReturnsExternalValidationWit { Result = new Result { CommonName = "order-1", Status = new Status { Uuid = "uuid-1" } } }; + var result = Manager.GetEnrollmentResult(response); - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); Assert.Equal("uuid-1", result.CARequestID); Assert.Null(result.EnrollmentContext); } @@ -359,12 +151,10 @@ public void GetEnrollmentResult_WithCNameAndEmailDcvDetails_PopulatesEnrollmentC { new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } }, new DcvDetail { Email = "admin@example.com" }, - // Duplicate keys should not throw and should not be added twice. - new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } }, + // 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. Null entries are skipped. - new DcvDetail(), - null! + // Entry with neither CName nor Email contributes nothing. + new DcvDetail() } } }; @@ -372,9 +162,47 @@ public void GetEnrollmentResult_WithCNameAndEmailDcvDetails_PopulatesEnrollmentC var result = Manager.GetEnrollmentResult(response); Assert.NotNull(result.EnrollmentContext); - Assert.Equal(2, result.EnrollmentContext.Count); 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."]); } // --------------------------------------------------------------------- @@ -384,77 +212,105 @@ public void GetEnrollmentResult_WithCNameAndEmailDcvDetails_PopulatesEnrollmentC [Fact] public void GetRevokeResult_NullResponse_ReturnsFailed() { - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, Manager.GetRevokeResult(null)); + Assert.Equal((int)EndEntityStatus.FAILED, Manager.GetRevokeResult(null)); } [Fact] public void GetRevokeResult_RegistrationError_ReturnsFailed() { - var response = new RevokeResponse { RegistrationError = new RegistrationError { Description = "denied" } }; - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, Manager.GetRevokeResult(response)); + 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 { RevokeSuccess = new RevokeSuccessResponse { Status = "REVOKED" } }; - Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.REVOKED, Manager.GetRevokeResult(response)); + var response = new RevokeResponse(); + Assert.Equal((int)EndEntityStatus.REVOKED, Manager.GetRevokeResult(response)); } // --------------------------------------------------------------------- - // MapReturnStatus + // GetReIssueResult // --------------------------------------------------------------------- - [Theory] - [InlineData("ACTIVE", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.GENERATED)] - [InlineData("Initial", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.INITIALIZED)] - [InlineData("Pending", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.INPROCESS)] - [InlineData("REVOKED", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.REVOKED)] - [InlineData("SomethingUnexpected", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED)] - [InlineData(null, Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED)] - public void MapReturnStatus_MapsExpectedStatus(string? cscStatus, Keyfactor.PKI.Enums.EJBCA.EndEntityStatus expected) + [Fact] + public void GetReIssueResult_NullResponse_ReturnsFailed() { - Assert.Equal((int)expected, Manager.MapReturnStatus(cscStatus!)); + var result = Manager.GetReIssueResult(null); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); } - // --------------------------------------------------------------------- - // GetNotifications - // --------------------------------------------------------------------- - [Fact] - public void GetNotifications_NoEmailsConfigured_ReturnsEmptyList() + public void GetReIssueResult_RegistrationError_ReturnsFailed() { - var notifications = Manager.GetNotifications(ProductInfo("CSC TrustedSecure DV")); - Assert.True(notifications.Enabled); - Assert.Empty(notifications.AdditionalNotificationEmails); + 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 GetNotifications_EmailsConfigured_SplitsOnComma() + public void GetReIssueResult_NullResult_ReturnsFailed() { - var productInfo = ProductInfo("CSC TrustedSecure DV", - new Dictionary { ["Notification Email(s) Comma Separated"] = "a@example.com,b@example.com" }); + var response = new ReissueResponse { Result = null }; + var result = Manager.GetReIssueResult(response); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } - var notifications = Manager.GetNotifications(productInfo); + [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); + } - Assert.Equal(2, notifications.AdditionalNotificationEmails.Count); - Assert.Contains("a@example.com", notifications.AdditionalNotificationEmails); + [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 + // GetDomainControlValidation (email-list overload) // --------------------------------------------------------------------- [Fact] - public void GetDomainControlValidation_EmptyEmailArray_ReturnsNull() + public void GetDomainControlValidation_EmptyDomainName_ReturnsNull() { - Assert.Null(Manager.GetDomainControlValidation("EMAIL", Array.Empty(), "example.com")); + var result = Manager.GetDomainControlValidation("EMAIL", new[] { "admin@example.com" }, ""); + Assert.Null(result); } [Fact] public void GetDomainControlValidation_NullEmailArray_ReturnsNull() { - Assert.Null(Manager.GetDomainControlValidation("EMAIL", null!, "example.com")); + 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] @@ -469,11 +325,16 @@ public void GetDomainControlValidation_MatchingHostFound_ReturnsValidation() [Fact] public void GetDomainControlValidation_NoMatchingHost_ReturnsNull() { - Assert.Null(Manager.GetDomainControlValidation("EMAIL", new[] { "admin@other.com" }, "www.example.com")); + var result = Manager.GetDomainControlValidation("EMAIL", new[] { "admin@other.com" }, "www.example.com"); + Assert.Null(result); } + // --------------------------------------------------------------------- + // GetDomainControlValidation (single-email overload) + // --------------------------------------------------------------------- + [Fact] - public void GetDomainControlValidation_SingleEmailOverload_ReturnsValidationVerbatim() + public void GetDomainControlValidation_SingleEmail_ReturnsValidationVerbatim() { var result = Manager.GetDomainControlValidation("CNAME", "admin@example.com"); Assert.Equal("CNAME", result.MethodType); @@ -481,13 +342,83 @@ public void GetDomainControlValidation_SingleEmailOverload_ReturnsValidationVerb } // --------------------------------------------------------------------- - // GetCustomFields (exercised via GetRegistrationRequest) + // 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 DV"); + var productInfo = ProductInfo("CSC TrustedSecure OV"); var customFields = new List { new GetCustomField { Label = "Required Field", Mandatory = true } }; Assert.Throws(() => @@ -495,19 +426,30 @@ public void GetRegistrationRequest_MandatoryCustomFieldMissing_Throws() } [Fact] - public void GetRegistrationRequest_OptionalCustomFieldMissing_DoesNotThrow() + public void GetRegistrationRequest_NullCustomFields_ReturnsEmptyList() { - var productInfo = ProductInfo("CSC TrustedSecure DV"); + 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 DV", new Dictionary { ["Custom Field"] = "value" }); + 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); @@ -516,204 +458,305 @@ public void GetRegistrationRequest_CustomFieldPresent_IsMapped() Assert.Equal("value", request.CustomFields[0].Value); } - [Fact] - public void GetRegistrationRequest_NullCustomFieldsList_ReturnsEmptyCustomFields() - { - var request = Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), SampleCsr, - new Dictionary(), null!); - Assert.Empty(request.CustomFields); - } + // --------------------------------------------------------------------- + // GetSubjectAlternativeNames (exercised via GetRegistrationRequest) + // --------------------------------------------------------------------- [Fact] - public void GetRegistrationRequest_CustomFieldsWithNullEntryAndBlankLabel_SkipsBoth() + public void GetRegistrationRequest_MultiNameEmailMethod_MatchesAdditionalSanEmail() { - var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary { ["Custom Field"] = "value" }); - var customFields = new List + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary { - null!, - new GetCustomField { Label = "", Mandatory = false }, - new GetCustomField { Label = "Custom Field", Mandatory = false } - }; + ["Domain Control Validation Method"] = "EMAIL", + [EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails] = "admin@example.com,admin@other.com" + }); - var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), customFields); + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); - Assert.Single(request.CustomFields); - Assert.Equal("value", request.CustomFields[0].Value); + 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); } - // --------------------------------------------------------------------- - // GetRenewalRequest / GetReissueRequest - parity with GetRegistrationRequest - // --------------------------------------------------------------------- - [Fact] - public void GetRenewalRequest_EvProduct_PopulatesEvDetailsNoSans() + public void GetRegistrationRequest_MultiNameEmailMethodNoAddtlSanMatch_FallsBackToCommonNameDcvEmail() { - var productInfo = ProductInfo("CSC TrustedSecure EV", new Dictionary { ["Organization Country"] = "CA" }); - var request = Manager.GetRenewalRequest(productInfo, "uuid-456", SampleCsr, new Dictionary(), new List()); + // 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" + }); - Assert.Equal("3", request.CertificateType); - Assert.Null(request.SubjectAlternativeNames); - Assert.NotNull(request.EvCertificateDetails); - Assert.Equal("CA", request.EvCertificateDetails.Country); + 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 GetReissueRequest_EvMultiNameProduct_PopulatesBothSansAndEvDetails() + public void GetRegistrationRequest_MultiNameCnameMethod_MirrorsCommonNameDcv() { var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; - var productInfo = ProductInfo("CSC TrustedSecure EV, Multiple Names", new Dictionary + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary { - ["Domain Control Validation Method"] = "CNAME", - ["Organization Country"] = "GB" + ["Domain Control Validation Method"] = "CNAME" }); - var request = Manager.GetReissueRequest(productInfo, "uuid-000", SampleCsr, sans, new List()); + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); - Assert.Equal("7", request.CertificateType); Assert.Single(request.SubjectAlternativeNames); - Assert.NotNull(request.EvCertificateDetails); - Assert.Equal("GB", request.EvCertificateDetails.Country); + 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_AllOptionalParametersSupplied_MapsEachField() + public void GetRegistrationRequest_MultiNameProductWithNoDnsNameKey_ReturnsEmptySanList() { - var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary { - ["Term"] = "12", - ["Applicant First Name"] = "Jane", - ["Applicant Last Name"] = "Doe", - ["Applicant Email Address"] = "jane.doe@example.com", - ["Applicant Phone"] = "555-1234", - ["Organization Contact"] = "contact-1", - ["Business Unit"] = "IT" + ["Domain Control Validation Method"] = "CNAME" }); var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), new List()); - Assert.Equal("12", request.Term); - Assert.Equal("Jane", request.ApplicantFirstName); - Assert.Equal("Doe", request.ApplicantLastName); - Assert.Equal("jane.doe@example.com", request.ApplicantEmailAddress); - Assert.Equal("555-1234", request.ApplicantPhoneNumber); - Assert.Equal("contact-1", request.OrganizationContact); - Assert.Equal("IT", request.BusinessUnit); + Assert.NotNull(request.SubjectAlternativeNames); + Assert.Empty(request.SubjectAlternativeNames); } [Fact] - public void GetRenewalRequest_AllOptionalParametersSupplied_MapsEachField() + public void GetRegistrationRequest_MultiNameProductWithNullSans_ReturnsEmptySanList() { - var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary { - ["Term"] = "24", - ["Applicant First Name"] = "John", - ["Applicant Last Name"] = "Smith", - ["Applicant Email Address"] = "john.smith@example.com", - ["Applicant Phone"] = "555-5678", - ["Organization Contact"] = "contact-2", - ["Business Unit"] = "Legal" + ["Domain Control Validation Method"] = "CNAME" }); - var request = Manager.GetRenewalRequest(productInfo, "uuid-renewal", SampleCsr, new Dictionary(), new List()); + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, null!, new List()); - Assert.Equal("24", request.Term); - Assert.Equal("John", request.ApplicantFirstName); - Assert.Equal("Smith", request.ApplicantLastName); - Assert.Equal("john.smith@example.com", request.ApplicantEmailAddress); - Assert.Equal("555-5678", request.ApplicantPhoneNumber); - Assert.Equal("contact-2", request.OrganizationContact); - Assert.Equal("Legal", request.BusinessUnit); + Assert.NotNull(request.SubjectAlternativeNames); + Assert.Empty(request.SubjectAlternativeNames); } - [Fact] - public void GetReissueRequest_AllOptionalParametersSupplied_MapsEachField() + // --------------------------------------------------------------------- + // 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 productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary + var sans = sanDomains.Length > 0 + ? new Dictionary { ["dnsname"] = sanDomains } + : new Dictionary(); + var productInfo = ProductInfo(productId, new Dictionary { - ["Term"] = "36", - ["Applicant First Name"] = "Alex", - ["Applicant Last Name"] = "Nguyen", - ["Applicant Email Address"] = "alex.nguyen@example.com", - ["Applicant Phone"] = "555-9012", - ["Organization Contact"] = "contact-3", - ["Business Unit"] = "Finance" + ["Domain Control Validation Method"] = "CNAME", + ["Organization Country"] = "US" }); - var request = Manager.GetReissueRequest(productInfo, "uuid-reissue", SampleCsr, new Dictionary(), new List()); + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Equal(expectedCertificateType, request.CertificateType); + Assert.Equal(expectEv, request.EvCertificateDetails != null); - Assert.Equal("36", request.Term); - Assert.Equal("Alex", request.ApplicantFirstName); - Assert.Equal("Nguyen", request.ApplicantLastName); - Assert.Equal("alex.nguyen@example.com", request.ApplicantEmailAddress); - Assert.Equal("555-9012", request.ApplicantPhoneNumber); - Assert.Equal("contact-3", request.OrganizationContact); - Assert.Equal("Finance", request.BusinessUnit); + 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 GetRegistrationRequest_NullProductParameters_Throws() + public void GetNotifications_NoEmailsConfigured_ReturnsEmptyList() { - var productInfo = new EnrollmentProductInfo { ProductID = "CSC TrustedSecure DV", ProductParameters = null! }; - Assert.Throws(() => - Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), new List())); + var productInfo = ProductInfo("CSC TrustedSecure OV"); + var notifications = Manager.GetNotifications(productInfo); + Assert.True(notifications.Enabled); + Assert.Empty(notifications.AdditionalNotificationEmails); } [Fact] - public void GetRegistrationRequest_NullProductInfo_Throws() + public void GetNotifications_EmailsConfigured_SplitsOnComma() { - Assert.Throws(() => - Manager.GetRegistrationRequest(null!, SampleCsr, new Dictionary(), new List())); + 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 GetRegistrationRequest_NullCsr_Throws() + public void GetRenewalRequest_MultiNameProduct_PopulatesUuidAndSans() { - Assert.Throws(() => - Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), null!, new Dictionary(), new List())); + 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 GetRegistrationRequest_CsrLongerThan64Chars_WrapsWithPemify() + public void GetRenewalRequest_LegacyProductName_ResolvesCertificateTypeAndSans() { - var longCsr = new string('X', 130); - var request = Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), longCsr, new Dictionary(), new List()); - var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(request.Csr)); - Assert.Contains("\n", decoded); + // 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_NullUuid_Throws() + public void GetRenewalRequest_EvProduct_PopulatesEvDetailsNoSans() { - Assert.Throws(() => - Manager.GetRenewalRequest(ProductInfo("CSC TrustedSecure DV"), null!, SampleCsr, new Dictionary(), new List())); + 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 EvCertificateDetails_AllPropertiesSettable() + public void GetReissueRequest_MultiNameProduct_PopulatesUuidAndSans() { - var details = new EvCertificateDetails + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV Wildcard, Multiple Names", new Dictionary { - Country = "US", - City = "Independence", - State = "OH", - DateOfIncorporation = "2020-01-01", - DoingBusinessAs = "Keyfactor", - BusinessCategory = "Private Organization" - }; + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetReissueRequest(productInfo, "uuid-789", SampleCsr, sans, new List()); - Assert.Equal("US", details.Country); - Assert.Equal("Independence", details.City); - Assert.Equal("OH", details.State); - Assert.Equal("2020-01-01", details.DateOfIncorporation); - Assert.Equal("Keyfactor", details.DoingBusinessAs); - Assert.Equal("Private Organization", details.BusinessCategory); + Assert.Equal("uuid-789", request.Uuid); + Assert.Equal("8", request.CertificateType); + Assert.Single(request.SubjectAlternativeNames); } [Fact] - public void GetRegistrationRequest_EncodesCsrAsBase64() + public void GetReissueRequest_LegacyProductName_ResolvesCertificateType() { - var request = Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), "hello", new Dictionary(), new List()); - var decoded = Convert.FromBase64String(request.Csr); - Assert.Contains("hello", System.Text.Encoding.UTF8.GetString(decoded)); + 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 9594f70..1fc79be 100644 --- a/cscglobal-caplugin.sln +++ b/cscglobal-caplugin.sln @@ -1,10 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.0.11217.181 +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 @@ -12,68 +14,29 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution integration-manifest.json = integration-manifest.json EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cscglobal-caplugin.Tests", "cscglobal-caplugin.Tests", "{BE4C3E19-CFA0-7860-C455-A18FD2267928}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin.Tests", "cscglobal-caplugin.Tests\CSCGlobalCAPlugin.Tests.csproj", "{1FE36805-D1BD-4552-8B19-17358C5F19E3}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cscglobal-caplugin", "cscglobal-caplugin", "{9EDFC34F-9707-CEB2-9158-E7368508D81D}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 DebugAndPush|Any CPU = DebugAndPush|Any CPU - DebugAndPush|x64 = DebugAndPush|x64 - DebugAndPush|x86 = DebugAndPush|x86 Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x64.ActiveCfg = Debug|Any CPU - {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x64.Build.0 = Debug|Any CPU - {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x86.ActiveCfg = Debug|Any CPU - {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x86.Build.0 = Debug|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|Any CPU.ActiveCfg = DebugAndPush|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|Any CPU.Build.0 = DebugAndPush|Any CPU - {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x64.ActiveCfg = DebugAndPush|Any CPU - {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x64.Build.0 = DebugAndPush|Any CPU - {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x86.ActiveCfg = DebugAndPush|Any CPU - {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x86.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 - {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x64.ActiveCfg = Release|Any CPU - {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x64.Build.0 = Release|Any CPU - {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x86.ActiveCfg = Release|Any CPU - {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x86.Build.0 = Release|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x64.ActiveCfg = Debug|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x64.Build.0 = Debug|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x86.ActiveCfg = Debug|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x86.Build.0 = Debug|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|Any CPU.ActiveCfg = Debug|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|Any CPU.Build.0 = Debug|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x64.ActiveCfg = Debug|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x64.Build.0 = Debug|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x86.ActiveCfg = Debug|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x86.Build.0 = Debug|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|Any CPU.Build.0 = Release|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x64.ActiveCfg = Release|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x64.Build.0 = Release|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x86.ActiveCfg = Release|Any CPU - {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x86.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 EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {1FE36805-D1BD-4552-8B19-17358C5F19E3} = {BE4C3E19-CFA0-7860-C455-A18FD2267928} - EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8861D2F4-FEE8-4D70-8172-DF321704F12D} EndGlobalSection diff --git a/cscglobal-caplugin/AssemblyInfo.cs b/cscglobal-caplugin/AssemblyInfo.cs index bd280c9..dadad5f 100644 --- a/cscglobal-caplugin/AssemblyInfo.cs +++ b/cscglobal-caplugin/AssemblyInfo.cs @@ -1,5 +1,9 @@ // 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; diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 6480bb4..33d2306 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -23,302 +23,126 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; public class CSCGlobalCAPlugin : IAnyCAPlugin { - ///

- /// Validation type string passed to . - /// CSC's Domain Control Validation publishes a CNAME record, so we resolve a DNS provider - /// that advertises the "cname" validation type (e.g. GoDaddy's GoDaddyCnameDomainValidator). - /// This is distinct from ACME's "dns-01" challenge, which publishes TXT records — a single - /// DNS provider DLL can ship separate validator classes for each type. - /// - private const string DNS_VALIDATION_TYPE = "cname"; - - /// Delay between CSC status polls while waiting for DCV to complete. - private static readonly TimeSpan DcvPollInterval = TimeSpan.FromSeconds(10); - private readonly RequestManager _requestManager; private readonly ILogger Logger; - private readonly IDomainValidatorFactory? _validatorFactory; private ICertificateDataReader _certificateDataReader; - /// - /// Parameterless constructor retained for compatibility with older gateway hosts that don't - /// perform DI. When constructed this way the plugin runs without DNS auto-publishing. - /// public CSCGlobalCAPlugin() { Logger = LogHandler.GetClassLogger(); _requestManager = new RequestManager(); - _validatorFactory = null; } - /// - /// DI constructor used by AnyCA Gateway 3.3+ which injects the framework's domain validator - /// factory. When non-null, CNAME DCV records returned by CSC are auto-published via the - /// framework's registered DNS providers (resolved per-domain). - /// - public CSCGlobalCAPlugin(IDomainValidatorFactory validatorFactory) - { - Logger = LogHandler.GetClassLogger(); - _requestManager = new RequestManager(); - _validatorFactory = validatorFactory; - } + internal Func ClientFactory { get; set; } + = config => new CscGlobalClient(config); - // internal (not private) purely so the test project can inject a mock via - // InternalsVisibleTo, instead of hitting the real CSC Global API in unit tests. - internal ICscGlobalClient CscGlobalClient { get; set; } + private IAnyCAPluginConfigProvider Config { get; set; } - /// - /// Whether the CA is enabled. When false, the plugin returns early from Ping, - /// ValidateCAConnectionInfo, ValidateProductInfo, Synchronize, Enroll, and Revoke without - /// calling CSC. Primarily used to allow creation of the CA record prior to configuration - /// information being available (standard field across Keyfactor CA plugins). Defaults to true - /// so existing deployments that don't set this key continue to function. - /// - public bool Enabled { get; set; } = true; + private ICscGlobalClient CscGlobalClient => ClientFactory(Config); public int SyncFilterDays { get; set; } - public int RenewalWindowDays { get; set; } - /// - /// Maximum seconds to synchronously poll CSC for certificate issuance after submitting an - /// order (and publishing CNAME DCV). 0 disables polling — the enrollment returns "pending" - /// immediately and the cert is picked up on the next sync. When > 0, fast-validating - /// orders can return the issued cert directly in the enrollment response. + /// 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 int DcvPollTimeoutSeconds { get; set; } + 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) { - using var flow = new FlowLogger(Logger, "Initialize"); Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("Initialize called. configProvider is {Null}, certificateDataReader is {Null2}", - configProvider == null ? "NULL" : "present", - certificateDataReader == null ? "NULL" : "present"); - - flow.Step("ValidateInputs", () => - { - if (configProvider == null) - throw new ArgumentNullException(nameof(configProvider), "configProvider cannot be null in Initialize"); - if (certificateDataReader == null) - throw new ArgumentNullException(nameof(certificateDataReader), "certificateDataReader cannot be null in Initialize"); - }); - - _certificateDataReader = certificateDataReader; + if (configProvider == null) throw new ArgumentNullException(nameof(configProvider)); + _certificateDataReader = certificateDataReader ?? throw new ArgumentNullException(nameof(certificateDataReader)); + Config = configProvider; - flow.Step("ValidateConnectionData", () => + Enabled = true; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.Enabled, out var enabledObj)) { - if (configProvider.CAConnectionData == null) - { - Logger.LogError("CAConnectionData is null. Cannot read configuration."); - throw new InvalidOperationException("CAConnectionData is null on configProvider."); - } - Logger.LogTrace("CAConnectionData keys: {Keys}", string.Join(", ", configProvider.CAConnectionData.Keys)); - }); - - flow.Step("ReadEnabled", () => - { - Enabled = true; // default - if (configProvider.CAConnectionData.TryGetValue(Constants.Enabled, out var enabledObj)) - { - Logger.LogTrace("Enabled raw value: '{Value}'", enabledObj?.ToString() ?? "(null)"); - if (bool.TryParse(enabledObj?.ToString(), out var parsed)) - Enabled = parsed; - else - Logger.LogWarning("Enabled value '{Value}' could not be parsed as bool, defaulting to true.", enabledObj); - } + if (bool.TryParse(enabledObj?.ToString(), out var parsed)) + Enabled = parsed; else - { - Logger.LogTrace("Enabled key not found in CAConnectionData, defaulting to true."); - } - Logger.LogInformation("CA is {State}.", Enabled ? "Enabled" : "Disabled"); - }, $"Enabled={Enabled}"); - - // Construct the CSC client only when enabled. When disabled we allow Initialize to complete - // without valid API credentials — this is the whole point of the Enabled toggle (so ops can - // create the CA record before credentials are available). - if (Enabled) - { - flow.Step("CreateCscGlobalClient", () => - { - Logger.LogTrace("Creating CscGlobalClient from configProvider..."); - CscGlobalClient = new CscGlobalClient(configProvider); - Logger.LogTrace("CscGlobalClient created successfully."); - }); - } - else - { - flow.Skip("CreateCscGlobalClient", "CA is Disabled"); + Logger.LogWarning($"Could not parse {Constants.Enabled} value '{enabledObj}' as a bool; defaulting to true"); } + Logger.LogInformation("CA is {State}.", Enabled ? "Enabled" : "Disabled"); - flow.Step("ReadSyncFilterDays", () => + if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) { - if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) + var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); + if (int.TryParse(syncFilterDaysStr, out var syncFilterDays)) { - var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); - Logger.LogTrace("SyncFilterDays raw value: '{Value}'", syncFilterDaysStr ?? "(null)"); - if (int.TryParse(syncFilterDaysStr, out var syncFilterDays)) - { - SyncFilterDays = syncFilterDays; - Logger.LogDebug("SyncFilterDays configured to {Days} days", SyncFilterDays); - } - else - { - Logger.LogWarning("SyncFilterDays value '{Value}' could not be parsed as int, using default 0.", syncFilterDaysStr); - } + SyncFilterDays = syncFilterDays; + Logger.LogDebug($"SyncFilterDays configured to {SyncFilterDays} days"); } else { - Logger.LogTrace("SyncFilterDays key not found in CAConnectionData, using default 0."); + Logger.LogWarning($"Could not parse {Constants.SyncFilterDays} value '{syncFilterDaysStr}' as an integer; using default"); } - }); - - flow.Step("ReadRenewalWindowDays", () => - { - RenewalWindowDays = 30; // default - if (configProvider.CAConnectionData.TryGetValue(Constants.RenewalWindowDays, out var renewalWindowObj)) - { - Logger.LogTrace("RenewalWindowDays raw value: '{Value}'", renewalWindowObj?.ToString() ?? "(null)"); - if (int.TryParse(renewalWindowObj?.ToString(), out var renewalWindowDays) && renewalWindowDays > 0) - RenewalWindowDays = renewalWindowDays; - else - Logger.LogWarning("RenewalWindowDays value '{Value}' could not be parsed or was <= 0, using default 30.", renewalWindowObj); - } - else - { - Logger.LogTrace("RenewalWindowDays key not found in CAConnectionData, using default 30."); - } - Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays); - }, $"RenewalWindowDays={RenewalWindowDays}"); - - flow.Step("ReadDcvPollTimeoutSeconds", () => - { - DcvPollTimeoutSeconds = 0; // default: disabled - if (configProvider.CAConnectionData.TryGetValue(Constants.DcvPollTimeoutSeconds, out var pollObj)) - { - Logger.LogTrace("DcvPollTimeoutSeconds raw value: '{Value}'", pollObj?.ToString() ?? "(null)"); - if (int.TryParse(pollObj?.ToString(), out var pollSeconds) && pollSeconds >= 0) - DcvPollTimeoutSeconds = pollSeconds; - else - Logger.LogWarning("DcvPollTimeoutSeconds value '{Value}' could not be parsed or was < 0, using default 0 (disabled).", pollObj); - } - else - { - Logger.LogTrace("DcvPollTimeoutSeconds key not found in CAConnectionData, using default 0 (disabled)."); - } - Logger.LogDebug("DcvPollTimeoutSeconds configured to {Seconds}s ({State})", - DcvPollTimeoutSeconds, DcvPollTimeoutSeconds > 0 ? "enabled" : "disabled"); - }); + } - flow.Step("CheckDnsValidatorFactory", () => + RenewalWindowDays = 30; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.RenewalWindowDays, out var renewalWindowObj)) { - if (_validatorFactory == null) - Logger.LogInformation( - "No IDomainValidatorFactory was injected by the gateway host. CNAME DCV records will require manual publishing."); + if (int.TryParse(renewalWindowObj?.ToString(), out var renewalWindowDays) && renewalWindowDays > 0) + RenewalWindowDays = renewalWindowDays; else - Logger.LogInformation( - "IDomainValidatorFactory available from gateway host. CNAME DCV records will be auto-published per-domain via the framework's registered DNS providers (validation type '{Type}').", - DNS_VALIDATION_TYPE); - }); + 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); } //done public async Task GetSingleRecord(string caRequestID) { - using var flow = new FlowLogger(Logger, $"GetSingleRecord({caRequestID ?? "null"})"); - Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("GetSingleRecord called with caRequestID='{CaRequestId}'", caRequestID ?? "(null)"); - - flow.Step("ValidateInput", () => - { - if (string.IsNullOrEmpty(caRequestID)) - throw new ArgumentNullException(nameof(caRequestID), "caRequestID cannot be null or empty."); - if (caRequestID.Length < 36) - throw new ArgumentException($"caRequestID '{caRequestID}' is too short to extract a UUID (need at least 36 chars).", nameof(caRequestID)); - }); - try { - var keyfactorCaId = caRequestID.Substring(0, 36); - flow.Step("ExtractUUID", $"keyfactorCaId={keyfactorCaId}"); + Logger.MethodEntry(LogLevel.Debug); + 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)); - CertificateResponse certificateResponse = null; - await flow.StepAsync("FetchCertFromCSC", async () => - { - certificateResponse = await CscGlobalClient.SubmitGetCertificateAsync(keyfactorCaId); - }); + 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)) + .Result; - if (certificateResponse == null) - { - flow.Fail("ParseResponse", "API returned null"); - Logger.LogWarning("GetSingleRecord: SubmitGetCertificateAsync returned null for keyfactorCaId='{KeyfactorCaId}'", keyfactorCaId); - return new AnyCAPluginCertificate - { - CARequestID = keyfactorCaId, - Certificate = string.Empty, - Status = _requestManager.MapReturnStatus(null) - }; - } + Logger.LogTrace($"Single Cert JSON: {JsonConvert.SerializeObject(certificateResponse)}"); - flow.Step("ParseResponse", $"Status={certificateResponse.Status ?? "(null)"}"); - Logger.LogTrace("Single Cert JSON: {Json}", JsonConvert.SerializeObject(certificateResponse)); + var fileContent = + Encoding.ASCII.GetString( + Convert.FromBase64String(certificateResponse?.Certificate ?? string.Empty)); - var rawCert = certificateResponse.Certificate ?? string.Empty; - string fileContent = string.Empty; - flow.Step("DecodeBase64", () => - { - try - { - fileContent = Encoding.ASCII.GetString(Convert.FromBase64String(rawCert)); - } - catch (FormatException fex) - { - Logger.LogError(fex, "GetSingleRecord: Failed to decode Base64 certificate content for keyfactorCaId='{KeyfactorCaId}'", keyfactorCaId); - fileContent = string.Empty; - } - }, $"length={rawCert.Length}"); - - var certData = fileContent.Replace("\r\n", string.Empty); + Logger.LogTrace($"File Content {fileContent}"); + var certData = fileContent?.Replace("\r\n", string.Empty); var certString = string.Empty; if (!string.IsNullOrEmpty(certData)) - { - flow.Step("ExtractLeafCert", () => - { - certString = GetEndEntityCertificate(certData); - }, $"inputLength={certData.Length}"); - } - else - { - flow.Skip("ExtractLeafCert", "certData empty after cleanup"); - } - - var mappedStatus = _requestManager.MapReturnStatus(certificateResponse.Status); - flow.Step("MapStatus", $"{certificateResponse.Status ?? "(null)"} -> {mappedStatus}"); + certString = GetEndEntityCertificate(certData); + Logger.LogTrace($"Cert String Content {certString}"); Logger.MethodExit(LogLevel.Debug); return new AnyCAPluginCertificate { CARequestID = keyfactorCaId, - Certificate = certString ?? string.Empty, - Status = mappedStatus + Certificate = certString, + Status = _requestManager.MapReturnStatus(certificateResponse?.Status) }; } - catch (AggregateException ae) - { - var inner = ae.Flatten().InnerException; - flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); - Logger.LogError(inner, "GetSingleRecord: AggregateException for caRequestID='{CaRequestId}': {Message}", caRequestID, inner?.Message ?? ae.Message); - throw new Exception($"Error Occurred getting single cert for '{caRequestID}': {inner?.Message ?? ae.Message}", inner ?? ae); - } catch (Exception e) { - flow.Fail("UNHANDLED", e.Message); - Logger.LogError(e, "GetSingleRecord: Exception for caRequestID='{CaRequestId}': {Message}", caRequestID, e.Message); - throw new Exception($"Error Occurred getting single cert for '{caRequestID}': {e.Message}", 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}"); } } @@ -326,15 +150,9 @@ await flow.StepAsync("FetchCertFromCSC", async () => public async Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, bool fullSync, CancellationToken cancelToken) { - var syncType = fullSync ? "Full" : "Incremental"; - using var flow = new FlowLogger(Logger, $"Synchronize-{syncType}"); + Logger.LogTrace($"Full Sync? {fullSync.ToString()}"); Logger.MethodEntry(); - Logger.LogTrace("Synchronize called. fullSync={FullSync}, lastSync={LastSync}, blockingBuffer is {Null}", - fullSync, lastSync?.ToString("o") ?? "(null)", - blockingBuffer == null ? "NULL" : "present"); - - if (blockingBuffer == null) - throw new ArgumentNullException(nameof(blockingBuffer), "blockingBuffer cannot be null in Synchronize"); + using var flow = new FlowLogger(Logger, "Synchronize"); if (!Enabled) { @@ -348,42 +166,36 @@ public async Task Synchronize(BlockingCollection blockin { if (fullSync) { - flow.Step("DetermineFilter", "Full sync - no date filter"); - await flow.StepAsync("FetchAndProcessCerts", async () => - { - 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"); - flow.Step("DetermineFilter", $"Incremental, filterDays={filterDays}, cutoff={dateFilter}"); - await flow.StepAsync("FetchAndProcessCerts", async () => - { - 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); } - flow.Step("CompleteAdding"); blockingBuffer.CompleteAdding(); + Logger.LogInformation("Csc Global Synchronize Task completed successfully"); } catch (OperationCanceledException) { - flow.Fail("Cancelled", "operation was cancelled"); - Logger.LogWarning("Synchronize: operation was cancelled."); - if (!blockingBuffer.IsAddingCompleted) - blockingBuffer.CompleteAdding(); + Logger.LogWarning("Csc Global Synchronize Task was cancelled"); + flow.Fail("Synchronize", "Task was cancelled"); + blockingBuffer.CompleteAdding(); throw; } catch (Exception e) { - flow.Fail("SyncError", e.Message); - Logger.LogError(e, "Csc Global Synchronize Task failed! {FlatException}", LogHandler.FlattenException(e)); - if (!blockingBuffer.IsAddingCompleted) - blockingBuffer.CompleteAdding(); + Logger.LogError(e, $"Csc Global Synchronize Task failed! {LogHandler.FlattenException(e)}"); + flow.Fail("Synchronize", e.Message); Logger.MethodExit(); + blockingBuffer.CompleteAdding(); throw; } @@ -391,144 +203,83 @@ await flow.StepAsync("FetchAndProcessCerts", async () => } private async Task SyncCertificates(BlockingCollection blockingBuffer, - CancellationToken cancelToken, string? dateFilter) + CancellationToken cancelToken, string? dateFilter, FlowLogger flow) { - Logger.LogTrace("SyncCertificates: calling SubmitCertificateListRequestAsync with dateFilter='{DateFilter}'", dateFilter ?? "(null)"); - var certs = await CscGlobalClient.SubmitCertificateListRequestAsync(dateFilter); + var certs = await flow.StepAsync("SubmitCertificateListRequest", + () => CscGlobalClient.SubmitCertificateListRequestAsync(dateFilter)); - if (certs == null) - { - Logger.LogWarning("SyncCertificates: SubmitCertificateListRequestAsync returned null."); - return; - } + Logger.LogInformation($"Retrieved {certs?.Results?.Count ?? 0} certificate(s) from CSC Global for sync"); - if (certs.Results == null) + if (certs?.Results == null) { - Logger.LogWarning("SyncCertificates: certificate list response Results collection is null."); + Logger.LogWarning("Certificate list request returned no results collection; nothing to sync"); + flow.Step("QueueCertificates", "No results collection returned; nothing to sync"); return; } - Logger.LogTrace("SyncCertificates: received {Count} certificate results.", certs.Results.Count); - var processedCount = 0; + var queuedCount = 0; var skippedCount = 0; - foreach (var currentResponseItem in certs.Results) { cancelToken.ThrowIfCancellationRequested(); + Logger.LogTrace($"Took Certificate ID {currentResponseItem?.Uuid} from Queue"); + var certStatus = _requestManager.MapReturnStatus(currentResponseItem?.Status); - if (currentResponseItem == null) - { - Logger.LogTrace("SyncCertificates: skipping null result item."); - skippedCount++; - continue; - } - - Logger.LogTrace("SyncCertificates: processing certificate UUID={Uuid}, Status='{Status}', CertificateType='{CertType}'", - currentResponseItem.Uuid ?? "(null)", - currentResponseItem.Status ?? "(null)", - currentResponseItem.CertificateType ?? "(null)"); - - var certStatus = _requestManager.MapReturnStatus(currentResponseItem.Status); - Logger.LogTrace("SyncCertificates: mapped status for UUID={Uuid}: {MappedStatus}", currentResponseItem.Uuid ?? "(null)", certStatus); - + //Keyfactor sync only seems to work when there is a valid cert and I can only get Active valid certs from Csc Global if (certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || certStatus == Convert.ToInt32(EndEntityStatus.REVOKED)) { - var productId = _requestManager.MapCertificateTypeToProductId(currentResponseItem.CertificateType); + var productId = currentResponseItem?.CertificateType; - Logger.LogTrace("SyncCertificates: UUID={Uuid} qualifies for sync. CertificateType='{CertType}' -> ProductId='{ProductId}'", - currentResponseItem.Uuid, currentResponseItem.CertificateType ?? "(null)", productId); - - string fileContent; - try - { - fileContent = PreparePemTextFromApi(currentResponseItem.Certificate ?? string.Empty); - } - catch (Exception ex) - { - Logger.LogError(ex, "SyncCertificates: PreparePemTextFromApi failed for UUID={Uuid}", currentResponseItem.Uuid); - skippedCount++; - continue; - } + var fileContent = + PreparePemTextFromApi( + currentResponseItem?.Certificate ?? string.Empty); if (fileContent.Length > 0) { - Logger.LogTrace("SyncCertificates: fileContent length={Length} for UUID={Uuid}", fileContent.Length, currentResponseItem.Uuid); + Logger.LogTrace($"File Content {fileContent}"); var certData = fileContent.Replace("\r\n", string.Empty); - string certString; - try - { - certString = GetEndEntityCertificate(certData); - } - catch (Exception ex) - { - Logger.LogError(ex, "SyncCertificates: GetEndEntityCertificate failed for UUID={Uuid}", currentResponseItem.Uuid); - skippedCount++; - continue; - } - - if (!string.IsNullOrEmpty(certString)) + 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}", + CARequestID = $"{currentResponseItem?.Uuid}", Certificate = certString, Status = certStatus, ProductID = productId }, cancelToken); - processedCount++; - Logger.LogTrace("SyncCertificates: added UUID={Uuid} to buffer.", currentResponseItem.Uuid); + queuedCount++; } else { - Logger.LogTrace("SyncCertificates: certString was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); + Logger.LogWarning($"Could not extract end-entity certificate for {currentResponseItem?.Uuid}; skipping sync of this record"); skippedCount++; } } else { - Logger.LogTrace("SyncCertificates: fileContent was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); + Logger.LogWarning($"No certificate content returned by CSC Global for {currentResponseItem?.Uuid}; skipping sync of this record"); skippedCount++; } - else - { - Logger.LogTrace("SyncCertificates: fileContent was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); - skippedCount++; - } - else - { - Logger.LogTrace("SyncCertificates: fileContent was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); - skippedCount++; - } - } - else - { - Logger.LogTrace("SyncCertificates: UUID={Uuid} status {Status} not eligible for sync, skipping.", currentResponseItem.Uuid, certStatus); - skippedCount++; - } - else - { - Logger.LogTrace("SyncCertificates: UUID={Uuid} status {Status} not eligible for sync, skipping.", currentResponseItem.Uuid, certStatus); - skippedCount++; } else { - Logger.LogTrace("SyncCertificates: UUID={Uuid} status {Status} not eligible for sync, skipping.", currentResponseItem.Uuid, certStatus); + Logger.LogTrace($"Skipping Certificate ID {currentResponseItem?.Uuid} - status {currentResponseItem?.Status} is not eligible for sync"); skippedCount++; } } - Logger.LogDebug("SyncCertificates: completed. Processed={Processed}, Skipped={Skipped}, Total={Total}", - processedCount, skippedCount, certs.Results.Count); + 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) { - using var flow = new FlowLogger(Logger, $"Revoke({caRequestID ?? "null"})"); Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("Revoke called with caRequestID='{CaRequestId}', hexSerialNumber='{SerialNumber}', revocationReason={Reason}", - caRequestID ?? "(null)", hexSerialNumber ?? "(null)", revocationReason); + using var flow = new FlowLogger(Logger, "Revoke"); if (!Enabled) { @@ -536,65 +287,44 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r throw new InvalidOperationException("The CSC Global CA is in the Disabled state. Enable it to perform revocations."); } - flow.Step("ValidateInput", () => - { - if (string.IsNullOrEmpty(caRequestID)) - throw new ArgumentNullException(nameof(caRequestID), "caRequestID cannot be null or empty for Revoke."); - if (caRequestID.Length < 36) - throw new ArgumentException($"caRequestID '{caRequestID}' is too short to extract a UUID.", nameof(caRequestID)); - }); - try { - var uuid = caRequestID.Substring(0, 36); - flow.Step("ExtractUUID", $"uuid={uuid}"); + 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)); - RevokeResponse revokeResponse = null; - await flow.StepAsync("SubmitRevokeToCSC", async () => - { - revokeResponse = await CscGlobalClient.SubmitRevokeCertificateAsync(uuid); - }); + var uuid = caRequestID.Substring(0, 36); //todo fix to use pipe delimiter - if (revokeResponse == null) - { - flow.Fail("ParseResponse", "API returned null"); - throw new InvalidOperationException($"Revoke received null response for UUID '{uuid}'."); - } + var revokeResponse = await flow.StepAsync("SubmitRevokeCertificate", + () => CscGlobalClient.SubmitRevokeCertificateAsync(uuid)); - Logger.LogTrace("Revoke Response JSON: {Json}", JsonConvert.SerializeObject(revokeResponse)); + Logger.LogTrace($"Revoke Response JSON: {JsonConvert.SerializeObject(revokeResponse)}"); var revokeResult = _requestManager.GetRevokeResult(revokeResponse); - flow.Step("MapResult", $"result={revokeResult}"); if (revokeResult == (int)EndEntityStatus.FAILED) { - var errorDesc = revokeResponse.RegistrationError?.Description; - flow.Fail("RevokeResult", errorDesc ?? "(no description)"); - Logger.LogError("Revoke: failed for UUID='{Uuid}'. Error description: '{ErrorDesc}'", - uuid, errorDesc ?? "(no description)"); - if (!string.IsNullOrEmpty(errorDesc)) - throw new HttpRequestException($"Revoke Failed with message {errorDesc}"); + 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 (AggregateException ae) - { - var inner = ae.Flatten().InnerException; - flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); - Logger.LogError(inner, "Revoke: AggregateException for caRequestID='{CaRequestId}': {Message}", caRequestID, inner?.Message ?? ae.Message); - throw new Exception($"Revoke Failed for '{caRequestID}' with message {inner?.Message ?? ae.Message}", inner ?? ae); - } - catch (HttpRequestException) - { - throw; // already logged in flow above - } catch (Exception e) { - flow.Fail("UNHANDLED", e.Message); - Logger.LogError(e, "Revoke: Exception for caRequestID='{CaRequestId}': {Message}", caRequestID, e.Message); - throw new Exception($"Revoke Failed for '{caRequestID}' with message {e.Message}", e); + Logger.LogError(e, $"Revoke Failed for CA request ID {caRequestID} with message {e?.Message}"); + throw new Exception($"Revoke Failed with message {e?.Message}"); } } @@ -602,416 +332,273 @@ await flow.StepAsync("SubmitRevokeToCSC", async () => public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) { - using var flow = new FlowLogger(Logger, $"Enroll-{enrollmentType}"); + if (productInfo == null) throw new ArgumentNullException(nameof(productInfo)); + Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("Enroll called. enrollmentType={EnrollmentType}, subject='{Subject}', productId='{ProductId}', requestFormat={RequestFormat}", - enrollmentType, subject ?? "(null)", - productInfo?.ProductID ?? "(null)", requestFormat); - Logger.LogTrace("Enroll: csr is {CsrStatus}, san has {SanCount} entries, productInfo is {PiStatus}", - string.IsNullOrEmpty(csr) ? "empty/null" : $"present ({csr.Length} chars)", - san?.Count ?? 0, - productInfo == null ? "NULL" : "present"); + Logger.LogInformation($"Starting Enroll for product {productInfo.ProductID}, enrollment type {enrollmentType}"); + using var flow = new FlowLogger(Logger, "Enroll"); if (!Enabled) { - flow.Fail("Disabled", "CA is Disabled"); 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 = "The CSC Global CA is in the Disabled state. Enable it to perform enrollments." + StatusMessage = $"{flow.GetSummary()}\n\nThe CSC Global CA is in the Disabled state. Enable it to perform enrollments." }; } - flow.Step("ValidateInputs", () => - { - if (productInfo == null) - throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null for Enroll."); - if (productInfo.ProductParameters == null) - throw new ArgumentNullException(nameof(productInfo), "productInfo.ProductParameters cannot be null for Enroll."); - if (string.IsNullOrEmpty(csr)) - throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty for Enroll."); - }); - - Logger.LogTrace("Enroll: ProductParameters keys: [{Keys}]", - string.Join(", ", productInfo.ProductParameters.Keys)); - - RegistrationRequest enrollmentRequest; - var priorSn = ""; - ReissueRequest reissueRequest; - RenewalRequest renewRequest; - - flow.Step("CheckPriorCertSN", () => + try { - if (productInfo.ProductParameters.ContainsKey("priorcertsn")) + RegistrationRequest enrollmentRequest; + var priorSn = ""; + ReissueRequest reissueRequest; + RenewalRequest renewRequest; + var productParameters = productInfo.ProductParameters ?? new Dictionary(); + if (productParameters.ContainsKey("PriorCertSN")) { - if (productInfo.ProductParameters.ContainsKey("PriorCertSN")) - { - priorSn = productInfo.ProductParameters["PriorCertSN"]; - Logger.LogDebug("Enroll: Prior cert SN: '{PriorSn}'", priorSn ?? "(null)"); - } - else - { - Logger.LogWarning("Enroll: 'priorcertsn' key exists but 'PriorCertSN' (case-sensitive) not found."); - } + productParameters.TryGetValue("PriorCertSN", out priorSn); + priorSn ??= ""; + Logger.LogDebug($"Prior cert sn: {priorSn}"); } - }, string.IsNullOrEmpty(priorSn) ? "none" : $"SN={priorSn}"); - string uUId; - List customFields = null; - await flow.StepAsync("FetchCustomFields", async () => - { - customFields = await CscGlobalClient.SubmitGetCustomFields(); - }, $"count={customFields?.Count ?? 0}"); + string uUId; + var customFields = await flow.StepAsync("SubmitGetCustomFields", () => CscGlobalClient.SubmitGetCustomFields()); - if (customFields == null) - { - Logger.LogWarning("Enroll: SubmitGetCustomFields returned null, using empty list."); - customFields = new List(); - } - - try - { switch (enrollmentType) { case EnrollmentType.New: - flow.Step("SelectPath", "New Enrollment"); + flow.Branch("New Enrollment"); + //If they renewed an expired cert it gets here and this will not be supported IRegistrationResponse enrollmentResponse; - if (!productInfo.ProductParameters.ContainsKey("PriorCertSN")) + if (!productParameters.ContainsKey("PriorCertSN")) { - enrollmentRequest = null; - flow.Step("BuildRegistrationRequest", () => - { - enrollmentRequest = _requestManager.GetRegistrationRequest(productInfo, csr, san, customFields); - }); - Logger.LogTrace("Enrollment Request JSON: {Json}", JsonConvert.SerializeObject(enrollmentRequest)); - - RegistrationResponse regResponse = null; - await flow.StepAsync("SubmitRegistrationToCSC", async () => - { - regResponse = await CscGlobalClient.SubmitRegistrationAsync(enrollmentRequest); - }); - enrollmentResponse = regResponse; - - if (enrollmentResponse == null) - { - flow.Fail("ParseResponse", "API returned null"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = "Enrollment failed: CSC API returned a null response." - }; - } - flow.Step("ParseResponse", $"error={enrollmentResponse.RegistrationError != null}"); - Logger.LogTrace("Enrollment Response JSON: {Json}", JsonConvert.SerializeObject(enrollmentResponse)); + 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 { - flow.Fail("RejectExpiredRenew", "PriorCertSN present on New enrollment"); + 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 = 30, - StatusMessage = "You cannot renew an expired cert please perform an new enrollment." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nYou cannot renew an expired cert please perform an new enrollment." }; } - var enrollResult = _requestManager.GetEnrollmentResult(enrollmentResponse); - flow.Step("MapResult", $"Status={enrollResult?.Status}, ID={enrollResult?.CARequestID ?? "(null)"}"); - - await flow.StepAsync("PublishCnameDcv", async () => - { - await TryPublishCnameDcvAsync(productInfo, enrollResult); - }); - - EnrollmentResult? newPolled = null; - await flow.StepAsync("PollForIssuance", async () => - { - newPolled = await TryPollForIssuedCertAsync(enrollResult?.CARequestID); - }); - if (newPolled != null) - { - flow.Step("PollResult", "issued during poll window"); - Logger.MethodExit(LogLevel.Debug); - return newPolled; - } - + flow.EndBranch(); + var newResult = _requestManager.GetEnrollmentResult(enrollmentResponse); + AttachFlowSummary(newResult, flow); + LogEnrollmentOutcome(newResult, "New Enrollment"); Logger.MethodExit(LogLevel.Debug); - return enrollResult; - + return newResult; case EnrollmentType.RenewOrReissue: - flow.Step("SelectPath", "RenewOrReissue"); - + flow.Branch("Renew Or Reissue"); if (string.IsNullOrEmpty(priorSn)) { - flow.Fail("ValidatePriorSN", "PriorCertSN is empty"); + 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 = 30, - StatusMessage = "RenewOrReissue failed: PriorCertSN is required but was not provided." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nCannot renew or reissue: no prior certificate serial number was supplied." }; } - string order_id = null; - await flow.StepAsync("LookupOrderId", async () => - { - order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); - }, $"orderId={order_id ?? "(null)"}"); - + var order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); if (string.IsNullOrEmpty(order_id)) { - flow.Fail("ValidateOrderId", $"no order found for SN={priorSn}"); + 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 = 30, - StatusMessage = $"RenewOrReissue failed: could not find order ID for serial number '{priorSn}'." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nCannot renew or reissue: no prior request found for serial number {priorSn}." }; } - if (order_id.Length < 36) - { - flow.Fail("ValidateOrderId", $"order_id too short ({order_id.Length} chars)"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = $"RenewOrReissue failed: order ID '{order_id}' is too short to extract a UUID." - }; - } - flow.Step("ValidateOrderId", $"orderId={order_id}"); - - // Determine renew vs reissue based on order expiry window. + // 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 { - CertificateResponse liveCert = null; - await flow.StepAsync("FetchLiveCertForDecision", async () => - { - liveCert = await CscGlobalClient.SubmitGetCertificateAsync(order_id[..36]); - }); + 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("ComputeRenewalDecision", - $"orderDate={liveCert.OrderDate}, expiry={orderExpiry:dd-MMM-yyyy}, daysLeft={(int)daysUntilOrderExpiry}, window={RenewalWindowDays}, isRenewal={renewal}"); + flow.Step("DetermineRenewOrReissue", + $"orderDate={liveCert.OrderDate}, orderExpiry={orderExpiry:dd-MMM-yyyy}, daysRemaining={(int)daysUntilOrderExpiry}, renewalWindow={RenewalWindowDays}, isRenewal={renewal}"); } else { - flow.Skip("ComputeRenewalDecision", "orderDate unavailable, falling back to cert expiry"); - var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) - ?? (await GetSingleRecord(order_id)).RevocationDate; - renewal = expirationDate < DateTime.Now; - flow.Step("FallbackExpiryCheck", $"expirationDate={expirationDate?.ToString("o") ?? "(null)"}, isRenewal={renewal}"); + 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: {ex.Message}"); - Logger.LogWarning(ex, "RenewOrReissue: failed to fetch live cert, falling back to cert expiry."); - try - { - var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) - ?? (await GetSingleRecord(order_id)).RevocationDate; - renewal = expirationDate < DateTime.Now; - flow.Step("FallbackExpiryCheck", $"isRenewal={renewal}"); - } - catch (Exception fallbackEx) - { - flow.Fail("FallbackExpiryCheck", fallbackEx.Message); - return new EnrollmentResult - { - Status = 30, - StatusMessage = $"RenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" - }; - } + 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}"); } - flow.Step("RenewalDecision", renewal ? "RENEWAL (paid order)" : "REISSUE (free under active order)"); - if (renewal) { - if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) + //One click won't work for this implementation b/c we are missing enrollment params + if (productParameters.ContainsKey("Applicant Last Name")) { - uUId = null; - await flow.StepAsync("LookupRenewalUUID", async () => - { - uUId = await _certificateDataReader.GetRequestIDBySerialNumber( - productInfo.ProductParameters["PriorCertSN"]); - }); - - if (string.IsNullOrEmpty(uUId)) - { - flow.Fail("ValidateRenewalUUID", "could not resolve PriorCertSN"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = "Renewal failed: could not resolve prior certificate serial number to a request ID." - }; - } - flow.Step("ValidateRenewalUUID", $"uuid={uUId}"); - - RenewalRequest builtRenewRequest = null; - flow.Step("BuildRenewalRequest", () => - { - builtRenewRequest = _requestManager.GetRenewalRequest(productInfo, uUId, csr, san, customFields); - }); - renewRequest = builtRenewRequest; - Logger.LogTrace("Renewal Request JSON: {Json}", JsonConvert.SerializeObject(renewRequest)); - - RenewalResponse renewResponse = null; - await flow.StepAsync("SubmitRenewalToCSC", async () => - { - renewResponse = await CscGlobalClient.SubmitRenewalAsync(renewRequest); - }); - - if (renewResponse == null) - { - flow.Fail("ParseRenewalResponse", "API returned null"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = "Renewal failed: CSC API returned a null response." - }; - } - - Logger.LogTrace("Renewal Response JSON: {Json}", JsonConvert.SerializeObject(renewResponse)); + //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); - flow.Step("MapRenewalResult", $"Status={renewResult?.Status}, Message={renewResult?.StatusMessage ?? "(null)"}"); - - EnrollmentResult? renewPolled = null; - await flow.StepAsync("PollForIssuance", async () => - { - renewPolled = await TryPollForIssuedCertAsync(renewResult?.CARequestID); - }); + AttachFlowSummary(renewResult, flow); + LogEnrollmentOutcome(renewResult, "Renewal"); Logger.MethodExit(LogLevel.Debug); - return renewPolled ?? renewResult; + return renewResult; } - flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click renew unavailable"); + 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 = 30, + 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 Renew Is Not Available for this Certificate Type. Use the configure button instead." }; } - // Reissue path - if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) + //One click won't work for this implementation b/c we are missing enrollment params + if (productParameters.ContainsKey("Applicant Last Name")) { - string requestid = null; - await flow.StepAsync("LookupReissueRequestId", async () => - { - requestid = await _certificateDataReader.GetRequestIDBySerialNumber( - productInfo.ProductParameters["PriorCertSN"]); - }); - - if (string.IsNullOrEmpty(requestid)) - { - flow.Fail("ValidateReissueRequestId", "could not resolve PriorCertSN"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = "Reissue failed: could not resolve prior certificate serial number to a request ID." - }; - } - - if (requestid.Length < 36) - { - flow.Fail("ValidateReissueRequestId", $"requestid too short ({requestid.Length} chars)"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = $"Reissue failed: request ID '{requestid}' is too short to extract a UUID." - }; - } - - uUId = requestid.Substring(0, 36); - flow.Step("ExtractReissueUUID", $"uuid={uUId}"); - - ReissueRequest builtReissueRequest = null; - flow.Step("BuildReissueRequest", () => + var requestid = await _certificateDataReader.GetRequestIDBySerialNumber( + productParameters.GetValueOrDefault("PriorCertSN", "")); + if (string.IsNullOrEmpty(requestid) || requestid.Length < 36) { - builtReissueRequest = _requestManager.GetReissueRequest(productInfo, uUId, csr, san, customFields); - }); - reissueRequest = builtReissueRequest; - Logger.LogTrace("Reissue JSON: {Json}", JsonConvert.SerializeObject(reissueRequest)); - - ReissueResponse reissueResponse = null; - await flow.StepAsync("SubmitReissueToCSC", async () => - { - reissueResponse = await CscGlobalClient.SubmitReissueAsync(reissueRequest); - }); - - if (reissueResponse == null) - { - flow.Fail("ParseReissueResponse", "API returned null"); + 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 = 30, - StatusMessage = "Reissue failed: CSC API returned a null response." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nCannot reissue: no prior request found for the supplied certificate serial number." }; } - Logger.LogTrace("Reissue Response JSON: {Json}", JsonConvert.SerializeObject(reissueResponse)); + 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); - flow.Step("MapReissueResult", $"Status={reissueResult?.Status}, Message={reissueResult?.StatusMessage ?? "(null)"}"); - - EnrollmentResult? reissuePolled = null; - await flow.StepAsync("PollForIssuance", async () => - { - reissuePolled = await TryPollForIssuedCertAsync(reissueResult?.CARequestID); - }); + AttachFlowSummary(reissueResult, flow); + LogEnrollmentOutcome(reissueResult, "Reissue"); Logger.MethodExit(LogLevel.Debug); - return reissuePolled ?? reissueResult; + return reissueResult; } - flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click reissue unavailable"); + 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, + Status = (int)EndEntityStatus.FAILED, StatusMessage = $"{flow.GetSummary()}\n\nOne click Reissue Is Not Available for this Certificate Type. Use the configure button instead." }; - - default: - flow.Fail("UnhandledType", $"enrollmentType={enrollmentType}"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = $"Enroll failed: unhandled enrollment type '{enrollmentType}'." - }; } + + Logger.LogWarning($"Unhandled enrollment type {enrollmentType} for product {productInfo.ProductID}"); + Logger.MethodExit(LogLevel.Debug); + return null; } - catch (AggregateException ae) + catch (Exception e) { - var inner = ae.Flatten().InnerException; - flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); - Logger.LogError(inner, "Enroll: AggregateException during {EnrollmentType}: {Message}", enrollmentType, inner?.Message ?? ae.Message); + // 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 = 30, - StatusMessage = $"Enrollment failed with error: {inner?.Message ?? ae.Message}" + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed: {e.Message}" }; } - catch (Exception ex) + } + + 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) { - flow.Fail("UNHANDLED", ex.Message); - Logger.LogError(ex, "Enroll: unhandled exception during {EnrollmentType}: {Message}", enrollmentType, ex.Message); - return new EnrollmentResult - { - Status = 30, - StatusMessage = $"Enrollment failed with error: {ex.Message}" - }; + result.StatusMessage = $"{flow.GetSummary()}\n\n{result.StatusMessage}"; + return; } + + result.EnrollmentContext ??= new Dictionary(); + foreach (var entry in flow.GetSummaryEntries()) + result.EnrollmentContext[entry.Key] = entry.Value; } //done public async Task Ping() { Logger.MethodEntry(); - Logger.LogTrace("Ping: Enabled={Enabled}, CscGlobalClient is {Null}", Enabled, CscGlobalClient == null ? "NULL" : "present"); if (!Enabled) { @@ -1026,7 +613,7 @@ public async Task Ping() } catch (Exception e) { - Logger.LogError(e, "There was an error contacting CSCGlobal: {Message}", e.Message); + Logger.LogError(e, $"There was an error contacting CSCGlobal: {e.Message}."); throw new Exception($"Error attempting to ping CSCGlobal: {e.Message}.", e); } @@ -1037,21 +624,14 @@ public async Task Ping() public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) { Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("ValidateCAConnectionInfo called. connectionInfo is {Null}, keys=[{Keys}]", - connectionInfo == null ? "NULL" : "present", - connectionInfo != null ? string.Join(", ", connectionInfo.Keys) : ""); - - if (connectionInfo == null) - { - Logger.LogError("ValidateCAConnectionInfo: connectionInfo is null."); - throw new ArgumentNullException(nameof(connectionInfo), "connectionInfo cannot be null."); - } + 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. + // 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.TryGetValue(Constants.Enabled, out var enabledObj) && + if (connectionInfo != null && + connectionInfo.TryGetValue(Constants.Enabled, out var enabledObj) && bool.TryParse(enabledObj?.ToString(), out var parsed)) incomingEnabled = parsed; @@ -1070,15 +650,6 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("ValidateProductInfo called. productInfo is {Null}, productId='{ProductId}'", - productInfo == null ? "NULL" : "present", - productInfo?.ProductID ?? "(null)"); - - if (productInfo == null) - { - Logger.LogError("ValidateProductInfo: productInfo is null."); - throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null."); - } // 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). @@ -1095,23 +666,19 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, return; } - if (string.IsNullOrEmpty(productInfo.ProductID)) - { - Logger.LogError("ValidateProductInfo: productInfo.ProductID is null or empty."); - throw new ArgumentException("ProductID cannot be null or empty.", nameof(productInfo)); - } - var certType = ProductIDs.productIds.Find(x => x.Equals(productInfo.ProductID, StringComparison.InvariantCultureIgnoreCase)); - if (certType == null) + var isLegacyProductId = certType == null && productInfo.ProductID != null && + ProductIDs.legacyProductIdAliases.ContainsKey(productInfo.ProductID); + + if (certType == null && !isLegacyProductId) { - Logger.LogError("ValidateProductInfo: cannot find product ID '{ProductId}'. Known IDs: [{KnownIds}]", - productInfo.ProductID, string.Join(", ", ProductIDs.productIds)); + 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 {CertType} configured for AnyGateway", certType); + Logger.LogInformation($"Validated product ID '{productInfo.ProductID}' configured for AnyGateway"); Logger.MethodExit(LogLevel.Debug); } @@ -1122,10 +689,10 @@ public Dictionary GetCAConnectorAnnotations() { [Constants.Enabled] = new() { - Comments = "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available.", + 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 = "Boolean" + DefaultValue = "true", + Type = "String" }, [Constants.CscGlobalUrl] = new() { @@ -1168,13 +735,6 @@ public Dictionary GetCAConnectorAnnotations() Hidden = false, DefaultValue = "30", Type = "Number" - }, - [Constants.DcvPollTimeoutSeconds] = new() - { - Comments = "Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests.", - Hidden = false, - DefaultValue = "0", - Type = "Number" } }; } @@ -1291,206 +851,6 @@ public List GetProductIds() #region PRIVATE - /// - /// Strip a single trailing dot from a DNS name. CSC returns FQDN-canonical names with - /// a trailing dot but the framework's Domain Validation Configurations are stored without - /// one, so the strings have to be normalized before lookup or the equality check fails. - /// - private static string StripTrailingDot(string? s) - { - if (string.IsNullOrEmpty(s)) return s ?? string.Empty; - return s.EndsWith('.') ? s[..^1] : s; - } - - /// - /// Synchronously poll CSC for issuance of the order identified by , - /// up to . Returns a GENERATED - /// carrying the issued leaf certificate if CSC issues within the window, or null if the - /// window expires (in which case the caller falls back to its pending/EXTERNALVALIDATION result). - /// No-op (returns null) when polling is disabled or the uuid is missing. - /// - private async Task TryPollForIssuedCertAsync(string? uuid) - { - if (DcvPollTimeoutSeconds <= 0) - { - Logger.LogTrace("TryPollForIssuedCertAsync: polling disabled (DcvPollTimeoutSeconds=0), skipping."); - return null; - } - - if (string.IsNullOrEmpty(uuid)) - { - Logger.LogWarning("TryPollForIssuedCertAsync: no UUID/CARequestID to poll, skipping."); - return null; - } - - var deadline = DateTime.UtcNow.AddSeconds(DcvPollTimeoutSeconds); - Logger.LogInformation("TryPollForIssuedCertAsync: polling CSC for issuance of '{Uuid}' for up to {Seconds}s (interval {Interval}s).", - uuid, DcvPollTimeoutSeconds, (int)DcvPollInterval.TotalSeconds); - - var attempt = 0; - while (DateTime.UtcNow < deadline) - { - attempt++; - AnyCAPluginCertificate record; - try - { - record = await GetSingleRecord(uuid); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "TryPollForIssuedCertAsync: poll attempt {Attempt} for '{Uuid}' threw, will retry. {Error}", - attempt, uuid, ex.Message); - record = null; - } - - if (record != null) - { - Logger.LogTrace("TryPollForIssuedCertAsync: attempt {Attempt} for '{Uuid}' — status={Status}, cert={CertState}.", - attempt, uuid, record.Status, string.IsNullOrEmpty(record.Certificate) ? "empty" : "present"); - - if (record.Status == (int)EndEntityStatus.GENERATED && !string.IsNullOrEmpty(record.Certificate)) - { - Logger.LogInformation("TryPollForIssuedCertAsync: '{Uuid}' issued after {Attempt} poll(s); returning cert directly.", uuid, attempt); - return new EnrollmentResult - { - Status = (int)EndEntityStatus.GENERATED, - CARequestID = uuid, - Certificate = record.Certificate, - StatusMessage = $"Certificate issued and retrieved for order {uuid}." - }; - } - } - - // Don't sleep past the deadline. - if (DateTime.UtcNow.Add(DcvPollInterval) >= deadline) - break; - - await Task.Delay(DcvPollInterval); - } - - Logger.LogInformation("TryPollForIssuedCertAsync: '{Uuid}' not issued within {Seconds}s after {Attempts} attempt(s); falling back to pending.", - uuid, DcvPollTimeoutSeconds, attempt); - return null; - } - - /// - /// Publishes CNAME DCV records via the gateway framework's . - /// Per-record resolution: each record is routed to whichever DNS provider plugin the framework - /// resolves for its domain. No-op if the factory wasn't injected, the cert isn't using CNAME - /// validation, or the response contains no CNAME details. Failures are logged but never thrown — - /// manual publishing remains a fallback so the enrollment result is still returned to Keyfactor. - /// - private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) - { - if (_validatorFactory == null) - { - Logger.LogTrace("TryPublishCnameDcvAsync: no IDomainValidatorFactory was injected, skipping auto-publish."); - return; - } - - if (enrollResult?.EnrollmentContext == null || enrollResult.EnrollmentContext.Count == 0) - { - Logger.LogTrace("TryPublishCnameDcvAsync: no CNAME entries in EnrollmentContext, skipping."); - return; - } - - var dcvMethod = productInfo?.ProductParameters != null - && productInfo.ProductParameters.TryGetValue(EnrollmentConfigConstants.DomainControlValidationMethod, out var m) - ? m - : null; - - if (string.IsNullOrEmpty(dcvMethod) || - !string.Equals(dcvMethod, "CNAME", StringComparison.OrdinalIgnoreCase)) - { - Logger.LogTrace("TryPublishCnameDcvAsync: DCV method '{Method}' is not CNAME, skipping auto-publish.", dcvMethod ?? "(null)"); - return; - } - - Logger.LogInformation( - "TryPublishCnameDcvAsync: attempting to publish {Count} CNAME record(s) via framework DNS providers (validation type '{Type}').", - enrollResult.EnrollmentContext.Count, DNS_VALIDATION_TYPE); - - var successCount = 0; - var failCount = 0; - var unresolvedCount = 0; - - foreach (var entry in enrollResult.EnrollmentContext) - { - var rawRecordName = entry.Key; - var rawCnameTarget = entry.Value; - - // CSC may also surface DCV email entries in this dictionary (key == value). Skip those. - if (string.Equals(rawRecordName, rawCnameTarget, StringComparison.OrdinalIgnoreCase)) - { - Logger.LogTrace("TryPublishCnameDcvAsync: skipping entry '{Key}' (looks like an email DCV passthrough, not a CNAME).", rawRecordName); - continue; - } - - // CSC returns FQDN-canonical names with trailing dots (e.g. "foo.example.com."). - // The framework's Domain Validation Configuration stores domain patterns without - // the trailing dot, so strip it before resolution and publishing or no provider - // will match (the framework will look up "*.example.com." which won't equal "*.example.com"). - var recordName = StripTrailingDot(rawRecordName); - var cnameTarget = StripTrailingDot(rawCnameTarget); - - if (recordName != rawRecordName) - Logger.LogTrace("TryPublishCnameDcvAsync: normalized record name '{Raw}' -> '{Normalized}'.", rawRecordName, recordName); - - IDomainValidator? validator; - try - { - validator = _validatorFactory.ResolveDomainValidator(recordName, DNS_VALIDATION_TYPE); - } - catch (Exception ex) - { - unresolvedCount++; - Logger.LogWarning(ex, "ResolveDomainValidator threw for '{Record}' (type '{Type}'): {Error}", - recordName, DNS_VALIDATION_TYPE, ex.Message); - continue; - } - - if (validator == null) - { - unresolvedCount++; - Logger.LogWarning( - "No DNS provider matched domain '{Record}' for validation type '{Type}'. Manual publish required for this record.", - recordName, DNS_VALIDATION_TYPE); - continue; - } - - try - { - Logger.LogTrace("StageValidation: '{Name}' -> '{Target}' via validator type '{ValType}'.", - recordName, cnameTarget, validator.GetValidationType()); - var result = await validator.StageValidation(recordName, cnameTarget, CancellationToken.None); - - if (result?.Success == true) - { - successCount++; - Logger.LogInformation("Published CNAME '{Name}' -> '{Target}' (status='{Status}').", - recordName, cnameTarget, result.Status ?? "(none)"); - } - else - { - failCount++; - Logger.LogWarning( - "StageValidation reported failure for CNAME '{Name}'. Status='{Status}', Error='{Error}'. Manual publish may be required.", - recordName, result?.Status ?? "(none)", result?.ErrorMessage ?? "(none)"); - } - } - catch (Exception ex) - { - failCount++; - Logger.LogError(ex, "StageValidation threw publishing CNAME '{Name}'. Manual publish may be required. {Error}", - recordName, ex.Message); - } - } - - Logger.LogInformation( - "TryPublishCnameDcvAsync: complete. Published={Published}, Failed={Failed}, Unresolved={Unresolved}", - successCount, failCount, unresolvedCount); - } - //Trying to fix leaf extraction private static readonly Regex PemBlock = new( "-----BEGIN CERTIFICATE-----\\s*(?[A-Za-z0-9+/=\\r\\n]+?)\\s*-----END CERTIFICATE-----", diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj index e5f5ff7..a8ab8fd 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj @@ -3,7 +3,7 @@ true - net10.0 + net6.0;net8.0;net10.0 Keyfactor.Extensions.CAPlugin.CSCGlobal true enable @@ -16,16 +16,24 @@ - - + + + + + + + + + + Always - + \ No newline at end of file diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index 6ce5c3e..43de107 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -23,58 +23,29 @@ public sealed class CscGlobalClient : ICscGlobalClient public CscGlobalClient(IAnyCAPluginConfigProvider config) : this(config, null) { - Logger = LogHandler.GetClassLogger(); - - if (config == null) - throw new ArgumentNullException(nameof(config), "config cannot be null in CscGlobalClient constructor."); + } + internal CscGlobalClient(IAnyCAPluginConfigProvider config, HttpMessageHandler? handler) + { + Logger = LogHandler.GetClassLogger(); + if (config == null) throw new ArgumentNullException(nameof(config)); if (config.CAConnectionData == null) - throw new InvalidOperationException("CAConnectionData is null on config provider."); - - Logger.LogTrace("CscGlobalClient: CAConnectionData keys=[{Keys}]", string.Join(", ", config.CAConnectionData.Keys)); + { + 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)) { - var rawUrl = config.CAConnectionData.ContainsKey(Constants.CscGlobalUrl) - ? config.CAConnectionData[Constants.CscGlobalUrl]?.ToString() - : null; - if (string.IsNullOrEmpty(rawUrl)) - { - Logger.LogError("CscGlobalClient: CscGlobalUrl is missing or empty in CAConnectionData."); - throw new InvalidOperationException("CscGlobalUrl is required but was not configured."); - } - - Logger.LogTrace("CscGlobalClient: BaseUrl='{BaseUrl}'", rawUrl); - BaseUrl = new Uri(rawUrl); - - ApiKey = config.CAConnectionData[Constants.CscGlobalApiKey]?.ToString(); - if (string.IsNullOrEmpty(ApiKey)) - { - Logger.LogError("CscGlobalClient: ApiKey is empty or null."); - throw new InvalidOperationException("ApiKey is required but was not configured."); - } - Logger.LogTrace("CscGlobalClient: ApiKey is present (length={Length}).", ApiKey.Length); - - if (!config.CAConnectionData.ContainsKey(Constants.BearerToken)) - { - Logger.LogError("CscGlobalClient: BearerToken key not found in CAConnectionData."); - throw new InvalidOperationException("BearerToken is required but was not configured."); - } - Authorization = config.CAConnectionData[Constants.BearerToken]?.ToString(); - if (string.IsNullOrEmpty(Authorization)) - { - Logger.LogError("CscGlobalClient: BearerToken is empty or null."); - throw new InvalidOperationException("BearerToken is required but was empty."); - } - Logger.LogTrace("CscGlobalClient: BearerToken is present (length={Length}).", Authorization.Length); - - RestClient = ConfigureRestClient(); - Logger.LogTrace("CscGlobalClient: RestClient configured successfully."); + BaseUrl = new Uri(config.CAConnectionData[Constants.CscGlobalUrl].ToString()); + ApiKey = config.CAConnectionData[Constants.CscGlobalApiKey].ToString(); + Authorization = config.CAConnectionData[Constants.BearerToken].ToString(); + RestClient = ConfigureRestClient(handler); + Logger.LogDebug($"CscGlobalClient configured for base URL {BaseUrl}"); } else { - Logger.LogError("CscGlobalClient: ApiKey key '{Key}' not found in CAConnectionData. Client will not be functional.", Constants.CscGlobalApiKey); - throw new InvalidOperationException($"Required key '{Constants.CscGlobalApiKey}' not found in CAConnectionData."); + Logger.LogError($"CA connection data is missing required key '{Constants.CscGlobalApiKey}'; client will not be able to call the CSC Global API"); } } @@ -86,26 +57,18 @@ public CscGlobalClient(IAnyCAPluginConfigProvider config) : this(config, null) public async Task SubmitRegistrationAsync( RegistrationRequest registerRequest) { - Logger.LogTrace("SubmitRegistrationAsync: sending registration request..."); - if (registerRequest == null) - throw new ArgumentNullException(nameof(registerRequest)); - - var requestJson = JsonConvert.SerializeObject(registerRequest); - Logger.LogTrace("SubmitRegistrationAsync: request JSON: {Json}", requestJson); - + Logger.MethodEntry(LogLevel.Debug); using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/registration", new StringContent( - requestJson, Encoding.ASCII, "application/json"))) + JsonConvert.SerializeObject(registerRequest), Encoding.ASCII, "application/json"))) { - var rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitRegistrationAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); - Logger.LogTrace("SubmitRegistrationAsync: response body: {Body}", rawBody ?? "(null)"); - + Logger.LogTrace(JsonConvert.SerializeObject(registerRequest)); var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) + if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response { - Logger.LogWarning("SubmitRegistrationAsync: received 400 BadRequest."); - var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); - Logger.LogTrace("SubmitRegistrationAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); + var rawErrorResponse = await resp.Content.ReadAsStringAsync(); + var errorResponse = + 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; @@ -114,14 +77,18 @@ public async Task SubmitRegistrationAsync( if (!resp.IsSuccessStatusCode) { - Logger.LogError("SubmitRegistrationAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); - throw new HttpRequestException($"SubmitRegistrationAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + 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(rawBody ?? "{}", settings); - Logger.LogTrace("SubmitRegistrationAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", - registrationResponse?.Result == null ? "null" : "present", - registrationResponse?.RegistrationError == null ? "null" : "present"); + 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; } } @@ -129,26 +96,21 @@ public async Task SubmitRegistrationAsync( public async Task SubmitRenewalAsync( RenewalRequest renewalRequest) { - Logger.LogTrace("SubmitRenewalAsync: sending renewal request..."); - if (renewalRequest == null) - throw new ArgumentNullException(nameof(renewalRequest)); - - var requestJson = JsonConvert.SerializeObject(renewalRequest); - Logger.LogTrace("SubmitRenewalAsync: request JSON: {Json}", requestJson); - using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/renewal", new StringContent( - requestJson, Encoding.ASCII, "application/json"))) + JsonConvert.SerializeObject(renewalRequest), Encoding.ASCII, "application/json"))) { - var rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitRenewalAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); - Logger.LogTrace("SubmitRenewalAsync: response body: {Body}", rawBody ?? "(null)"); + Logger.LogTrace(JsonConvert.SerializeObject(renewalRequest)); var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) + if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response { - Logger.LogWarning("SubmitRenewalAsync: received 400 BadRequest."); - var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); - Logger.LogTrace("SubmitRenewalAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); + var rawErrorResponse = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("Logging Error Response Raw"); + Logger.LogTrace(rawErrorResponse); + 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; @@ -157,14 +119,19 @@ public async Task SubmitRenewalAsync( if (!resp.IsSuccessStatusCode) { - Logger.LogError("SubmitRenewalAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); - throw new HttpRequestException($"SubmitRenewalAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + 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 renewalResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); - Logger.LogTrace("SubmitRenewalAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", - renewalResponse?.Result == null ? "null" : "present", - renewalResponse?.RegistrationError == null ? "null" : "present"); + 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; } } @@ -172,26 +139,18 @@ public async Task SubmitRenewalAsync( public async Task SubmitReissueAsync( ReissueRequest reissueRequest) { - Logger.LogTrace("SubmitReissueAsync: sending reissue request..."); - if (reissueRequest == null) - throw new ArgumentNullException(nameof(reissueRequest)); - - var requestJson = JsonConvert.SerializeObject(reissueRequest); - Logger.LogTrace("SubmitReissueAsync: request JSON: {Json}", requestJson); - using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/reissue", new StringContent( - requestJson, Encoding.ASCII, "application/json"))) + JsonConvert.SerializeObject(reissueRequest), Encoding.ASCII, "application/json"))) { - var rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitReissueAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); - Logger.LogTrace("SubmitReissueAsync: response body: {Body}", rawBody ?? "(null)"); + Logger.LogTrace(JsonConvert.SerializeObject(reissueRequest)); var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) + if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response { - Logger.LogWarning("SubmitReissueAsync: received 400 BadRequest."); - var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); - Logger.LogTrace("SubmitReissueAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); + var rawErrorResponse = await resp.Content.ReadAsStringAsync(); + var errorResponse = + 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; @@ -200,101 +159,79 @@ public async Task SubmitReissueAsync( if (!resp.IsSuccessStatusCode) { - Logger.LogError("SubmitReissueAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); - throw new HttpRequestException($"SubmitReissueAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + 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(rawBody ?? "{}"); - Logger.LogTrace("SubmitReissueAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", - reissueResponse?.Result == null ? "null" : "present", - reissueResponse?.RegistrationError == null ? "null" : "present"); + 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.LogTrace("SubmitGetCertificateAsync: fetching certificate for id='{CertificateId}'", certificateId ?? "(null)"); - - if (string.IsNullOrEmpty(certificateId)) - throw new ArgumentNullException(nameof(certificateId), "certificateId cannot be null or empty."); - + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace($"Getting certificate with ID {certificateId}"); using (var resp = await RestClient.GetAsync($"/dbs/api/v2/tls/certificate/{certificateId}")) { - var rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitGetCertificateAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); - if (!resp.IsSuccessStatusCode) { - Logger.LogError("SubmitGetCertificateAsync: HTTP {StatusCode} for certificateId='{CertificateId}': {Body}", - (int)resp.StatusCode, certificateId, rawBody); - resp.EnsureSuccessStatusCode(); // will throw + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Failed to get certificate {certificateId}. Status code {resp.StatusCode} | Message: {errorBody}"); } - Logger.LogTrace("SubmitGetCertificateAsync: response body: {Body}", rawBody ?? "(null)"); - var getCertificateResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); - Logger.LogTrace("SubmitGetCertificateAsync: deserialized. Status='{Status}', OrderDate='{OrderDate}', Certificate is {Null}", - getCertificateResponse?.Status ?? "(null)", - getCertificateResponse?.OrderDate ?? "(null)", - string.IsNullOrEmpty(getCertificateResponse?.Certificate) ? "empty/null" : "present"); + 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.LogTrace("SubmitGetCustomFields: fetching custom fields..."); - + Logger.MethodEntry(LogLevel.Debug); using (var resp = await RestClient.GetAsync("/dbs/api/v2/admin/customfields")) { - var rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitGetCustomFields: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); - if (!resp.IsSuccessStatusCode) { - Logger.LogError("SubmitGetCustomFields: HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); - resp.EnsureSuccessStatusCode(); // will throw + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Failed to get custom fields. Status code {resp.StatusCode} | Message: {errorBody}"); } - Logger.LogTrace("SubmitGetCustomFields: response body: {Body}", rawBody ?? "(null)"); - var getCustomFieldsResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); - + resp.EnsureSuccessStatusCode(); + var getCustomFieldsResponse = + JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); if (getCustomFieldsResponse == null) - { - Logger.LogWarning("SubmitGetCustomFields: deserialized response is null, returning empty list."); - return new List(); - } + throw new InvalidOperationException("Get custom fields request succeeded but the response body could not be parsed"); - if (getCustomFieldsResponse.CustomFields == null) - { - Logger.LogWarning("SubmitGetCustomFields: CustomFields property is null, returning empty list."); - return new List(); - } - - Logger.LogTrace("SubmitGetCustomFields: received {Count} custom fields.", getCustomFieldsResponse.CustomFields.Count); - return getCustomFieldsResponse.CustomFields; + 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.LogTrace("SubmitRevokeCertificateAsync: revoking certificate UUID='{Uuid}'", uuId ?? "(null)"); - - if (string.IsNullOrEmpty(uuId)) - throw new ArgumentNullException(nameof(uuId), "uuId cannot be null or empty."); - + 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 rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitRevokeCertificateAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); - Logger.LogTrace("SubmitRevokeCertificateAsync: response body: {Body}", rawBody ?? "(null)"); - var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) + if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response { - Logger.LogWarning("SubmitRevokeCertificateAsync: received 400 BadRequest for UUID='{Uuid}'.", uuId); - var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); - Logger.LogTrace("SubmitRevokeCertificateAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); + var rawErrorResponse = await resp.Content.ReadAsStringAsync(); + var errorResponse = + 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; @@ -303,14 +240,17 @@ public async Task SubmitRevokeCertificateAsync(string uuId) if (!resp.IsSuccessStatusCode) { - Logger.LogError("SubmitRevokeCertificateAsync: unexpected HTTP {StatusCode} for UUID='{Uuid}': {Body}", (int)resp.StatusCode, uuId, rawBody); - throw new HttpRequestException($"SubmitRevokeCertificateAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + 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(rawBody ?? "{}"); - Logger.LogTrace("SubmitRevokeCertificateAsync: deserialized. RevokeSuccess is {Null}, RegistrationError is {Null2}", - getRevokeResponse?.RevokeSuccess == null ? "null" : "present", - getRevokeResponse?.RegistrationError == null ? "null" : "present"); + 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; } } @@ -318,36 +258,27 @@ public async Task SubmitRevokeCertificateAsync(string uuId) public async Task SubmitCertificateListRequestAsync(string? dateFilter = null) { Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("SubmitCertificateListRequestAsync: dateFilter='{DateFilter}'", dateFilter ?? "(null)"); - var filterQuery = "filter=status=in=(ACTIVE,REVOKED)"; if (!string.IsNullOrEmpty(dateFilter)) { filterQuery += $";effectiveDate=ge={dateFilter}"; } - Logger.LogTrace("SubmitCertificateListRequestAsync: filter query: {FilterQuery}", filterQuery); - - var resp = RestClient.GetAsync($"/dbs/api/v2/tls/certificate?{filterQuery}").Result; - var rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitCertificateListRequestAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace($"Certificate list filter query: {filterQuery}"); + var resp = await RestClient.GetAsync($"/dbs/api/v2/tls/certificate?{filterQuery}"); if (!resp.IsSuccessStatusCode) { - Logger.LogError( - "SubmitCertificateListRequestAsync: failed request. StatusCode={StatusCode}, Body={Body}", - (int)resp.StatusCode, rawBody); + 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(rawBody ?? "{}"); - + var certificateListResponse = + JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); if (certificateListResponse == null) - { - Logger.LogWarning("SubmitCertificateListRequestAsync: deserialized response is null."); - return new CertificateListResponse(); - } + throw new InvalidOperationException("Certificate list request succeeded but the response body could not be parsed"); - Logger.LogTrace("SubmitCertificateListRequestAsync: Results count={Count}", - certificateListResponse.Results?.Count ?? 0); + Logger.LogInformation($"Certificate list request returned {certificateListResponse.Results?.Count ?? 0} result(s)"); Logger.MethodExit(LogLevel.Debug); return certificateListResponse; } diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index dc10866..f013902 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -16,21 +16,37 @@ public class Constants public static string DefaultPageSize = "DefaultPageSize"; public static string SyncFilterDays = "SyncFilterDays"; public static string RenewalWindowDays = "RenewalWindowDays"; - public static string DcvPollTimeoutSeconds = "DcvPollTimeoutSeconds"; } 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 Trusted Secure Domain Validated Wildcard SSL", - "CSC Trusted Secure 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 index 5696fcd..601115e 100644 --- a/cscglobal-caplugin/FlowLogger.cs +++ b/cscglobal-caplugin/FlowLogger.cs @@ -11,231 +11,296 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; -public enum FlowStepStatus -{ - Success, - Failed, - Skipped, - InProgress -} - -public class FlowStep -{ - public string Name { get; set; } - public FlowStepStatus Status { get; set; } - public string Detail { get; set; } - public long ElapsedMs { get; set; } - public List Children { get; } = new(); -} - /// -/// Tracks high-level operation flow and renders a visual step diagram to Trace logs. -/// Usage: -/// using var flow = new FlowLogger(logger, "Enroll-New"); -/// flow.Step("ParseCSR"); -/// flow.Step("ValidateCSR", () => { ... }); -/// flow.Fail("CreateOrder", "API returned 400"); -/// // flow renders automatically on Dispose +/// 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 sealed class FlowLogger : IDisposable +public class FlowLogger : IDisposable { private readonly ILogger _logger; private readonly string _flowName; - private readonly Stopwatch _totalTimer; + private readonly Stopwatch _overallStopwatch; private readonly List _steps = new(); - private FlowStep _currentParent; - private bool _disposed; + private readonly Stack _branchStack = new(); public FlowLogger(ILogger logger, string flowName) { - _logger = logger; - _flowName = flowName; - _totalTimer = Stopwatch.StartNew(); - _logger.LogTrace("===== FLOW START: {FlowName} =====", _flowName); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _flowName = flowName ?? throw new ArgumentNullException(nameof(flowName)); + _overallStopwatch = Stopwatch.StartNew(); + _logger.LogTrace("[FLOW:{FlowName}] === BEGIN ===", _flowName); } - /// Record a completed step. - public FlowLogger Step(string name, string detail = null) + public void Step(string name, string detail = null) { - var step = new FlowStep { Name = name, Status = FlowStepStatus.Success, Detail = detail }; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... OK{Detail}", - _flowName, name, detail != null ? $" ({detail})" : ""); - return this; + 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); } - /// Record a step that executes an action and times it. - public FlowLogger Step(string name, Action action, string detail = null) + public void Step(string name, Action action, string detail = null) { var sw = Stopwatch.StartNew(); var step = new FlowStep { Name = name, Detail = detail }; try { - _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); action(); sw.Stop(); - step.Status = FlowStepStatus.Success; + step.Status = StepStatus.Success; step.ElapsedMs = sw.ElapsedMilliseconds; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", - _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + _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 = FlowStepStatus.Failed; + step.Status = StepStatus.Failed; step.ElapsedMs = sw.ElapsedMilliseconds; - step.Detail = ex.Message; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", - _flowName, name, sw.ElapsedMilliseconds, ex.Message); + 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; } - return this; } - /// Record an async step that executes and times it. - public async Task StepAsync(string name, Func action, string detail = null) + public async Task StepAsync(string name, Func action, string detail = null) { var sw = Stopwatch.StartNew(); var step = new FlowStep { Name = name, Detail = detail }; try { - _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); await action(); sw.Stop(); - step.Status = FlowStepStatus.Success; + step.Status = StepStatus.Success; step.ElapsedMs = sw.ElapsedMilliseconds; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", - _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + _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 = FlowStepStatus.Failed; + step.Status = StepStatus.Failed; step.ElapsedMs = sw.ElapsedMilliseconds; - step.Detail = ex.Message; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", - _flowName, name, sw.ElapsedMilliseconds, ex.Message); + 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; } - return this; } - /// Record a failed step without throwing. - public FlowLogger Fail(string name, string reason = null) + public async Task StepAsync(string name, Func> action, string detail = null) { - var step = new FlowStep { Name = name, Status = FlowStepStatus.Failed, Detail = reason }; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED{Reason}", - _flowName, name, reason != null ? $": {reason}" : ""); - return this; + 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; + } } - /// Record a skipped step. - public FlowLogger Skip(string name, string reason = null) + public T Step(string name, Func action, string detail = null) { - var step = new FlowStep { Name = name, Status = FlowStepStatus.Skipped, Detail = reason }; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... SKIPPED{Reason}", - _flowName, name, reason != null ? $": {reason}" : ""); - return this; + 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; + } } - /// Start a branch (group of child steps). - public FlowLogger Branch(string name) + public void Fail(string name, string reason) { - var step = new FlowStep { Name = name, Status = FlowStepStatus.InProgress }; - AddStep(step); - _currentParent = step; - _logger.LogTrace(" [{FlowName}] >> Branch: {BranchName}", _flowName, name); - return this; + 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); } - /// End the current branch. - public FlowLogger EndBranch() + public void Skip(string name, string reason) { - _currentParent = null; - return this; + 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); } - private void AddStep(FlowStep step) + public void Branch(string name) { - if (_currentParent != null) - _currentParent.Children.Add(step); - else - _steps.Add(step); + _branchStack.Push(name); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}>> {BranchName}", _flowName, prefix, name); } - /// Render the visual flow diagram to Trace log. - private string RenderFlow() + public void EndBranch() { - var sb = new StringBuilder(); - sb.AppendLine(); - sb.AppendLine($" ===== FLOW: {_flowName} ({_totalTimer.ElapsedMilliseconds}ms total) ====="); - sb.AppendLine(); - - for (var i = 0; i < _steps.Count; i++) + if (_branchStack.Count > 0) { - var step = _steps[i]; - var icon = GetStatusIcon(step.Status); - var elapsed = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; - var detail = !string.IsNullOrEmpty(step.Detail) ? $" [{step.Detail}]" : ""; - - sb.AppendLine($" {icon} {step.Name}{elapsed}{detail}"); - - // Render children (branch) - if (step.Children.Count > 0) - { - for (var j = 0; j < step.Children.Count; j++) - { - var child = step.Children[j]; - var childIcon = GetStatusIcon(child.Status); - var childElapsed = child.ElapsedMs > 0 ? $" ({child.ElapsedMs}ms)" : ""; - var childDetail = !string.IsNullOrEmpty(child.Detail) ? $" [{child.Detail}]" : ""; - var connector = j < step.Children.Count - 1 ? "| " : " "; - sb.AppendLine($" |"); - sb.AppendLine($" +-- {childIcon} {child.Name}{childElapsed}{childDetail}"); - } - } - - // Connector between top-level steps - if (i < _steps.Count - 1) - { - sb.AppendLine(" |"); - sb.AppendLine(" v"); - } + var name = _branchStack.Pop(); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}<< {BranchName}", _flowName, prefix, name); } + } - sb.AppendLine(); + public bool HasFailures => _steps.Any(s => s.Status == StepStatus.Failed); - // Final status line - var finalStatus = _steps.Count > 0 && _steps.Last().Status == FlowStepStatus.Failed - ? "FAILED" : _steps.Any(s => s.Status == FlowStepStatus.Failed) ? "PARTIAL FAILURE" : "SUCCESS"; - sb.AppendLine($" ===== FLOW RESULT: {finalStatus} ====="); + 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(); } - private static string GetStatusIcon(FlowStepStatus status) + /// + /// 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() { - return status switch + 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 { - FlowStepStatus.Success => "[OK]", - FlowStepStatus.Failed => "[FAIL]", - FlowStepStatus.Skipped => "[SKIP]", - FlowStepStatus.InProgress => "[...]", - _ => "[?]" + [$"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() { - if (_disposed) return; - _disposed = true; - _totalTimer.Stop(); - _logger.LogTrace(RenderFlow()); + _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/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index f65d589..06f0b41 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -19,45 +19,28 @@ 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)); - // Certificate types that carry a list of additional SAN domains, vs. a single CN only. - private static readonly HashSet MultiNameCertificateTypes = new() { "2", "6", "7", "8", "9" }; - - // Certificate types that require EvCertificateDetails (Organization Country, etc.). - private static readonly HashSet EvCertificateTypes = new() { "3", "7" }; + 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) { - Logger.LogTrace("GetCustomFields: productInfo is {Null}, customFields count={Count}", - productInfo == null ? "NULL" : "present", - customFields?.Count ?? 0); - var customFieldList = new List(); - if (customFields == null || productInfo?.ProductParameters == null) + if (customFields == null) { - Logger.LogTrace("GetCustomFields: returning empty list (null customFields or ProductParameters)."); + Logger.LogTrace("No custom field definitions supplied; skipping custom field mapping"); return customFieldList; } foreach (var field in customFields) - { - if (field == null) - { - Logger.LogTrace("GetCustomFields: skipping null field entry."); - continue; - } - - Logger.LogTrace("GetCustomFields: checking field Label='{Label}', Mandatory={Mandatory}", - field.Label ?? "(null)", field.Mandatory); - - if (string.IsNullOrEmpty(field.Label)) - { - Logger.LogTrace("GetCustomFields: skipping field with null/empty label."); - continue; - } - if (productInfo.ProductParameters.ContainsKey(field.Label)) { var newField = new CustomField @@ -65,272 +48,232 @@ private List GetCustomFields(EnrollmentProductInfo productInfo, Lis Name = field.Label, Value = productInfo.ProductParameters[field.Label] }; - Logger.LogTrace("GetCustomFields: matched field '{Label}' = '{Value}'", field.Label, newField.Value ?? "(null)"); customFieldList.Add(newField); } else if (field.Mandatory) { - Logger.LogError("GetCustomFields: mandatory field '{Label}' was not supplied. Available keys: [{Keys}]", - field.Label, string.Join(", ", productInfo.ProductParameters.Keys)); + 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."); } - else - { - Logger.LogTrace("GetCustomFields: optional field '{Label}' not found in ProductParameters, skipping.", field.Label); - } - } - Logger.LogTrace("GetCustomFields: returning {Count} custom fields.", customFieldList.Count); + Logger.LogTrace($"Mapped {customFieldList.Count} custom field(s) for request"); return customFieldList; } public EnrollmentResult GetRenewResponse(RenewalResponse renewResponse) { - Logger.LogTrace("GetRenewResponse: renewResponse is {Null}", renewResponse == null ? "NULL" : "present"); - if (renewResponse == null) { - Logger.LogError("GetRenewResponse: renewResponse is null."); + Logger.LogError("Renewal failed: CSC Global returned no response"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = "Renewal failed: received null response from CSC." + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global returned no response for the renewal request" }; } if (renewResponse.RegistrationError != null) { - Logger.LogWarning("GetRenewResponse: RegistrationError present. Description='{Desc}'", - renewResponse.RegistrationError.Description ?? "(null)"); + Logger.LogError($"Renewal failed: {renewResponse.RegistrationError.Description}"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, + Status = (int)EndEntityStatus.FAILED, //failure CARequestID = renewResponse.Result?.Status?.Uuid, - StatusMessage = renewResponse.RegistrationError.Description ?? "Renewal failed with unknown error." + StatusMessage = renewResponse.RegistrationError.Description }; } - var commonName = renewResponse.Result?.CommonName ?? "(unknown)"; - var uuid = renewResponse.Result?.Status?.Uuid; - Logger.LogTrace("GetRenewResponse: renewal succeeded for CommonName='{CommonName}', UUID='{Uuid}'", commonName, uuid ?? "(null)"); + if (renewResponse.Result == null) + { + 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" + }; + } + + // 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, - CARequestID = uuid, - StatusMessage = $"Renewal Successfully Submitted For {commonName}. Certificate will be available after next sync." + 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) { - Logger.LogTrace("GetEnrollmentResult: registrationResponse is {Null}", registrationResponse == null ? "NULL" : "present"); - if (registrationResponse == null) { - Logger.LogError("GetEnrollmentResult: registrationResponse is null."); + Logger.LogError("Enrollment failed: CSC Global returned no response"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = "Enrollment failed: received null response from CSC." + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global returned no response for the registration request" }; } if (registrationResponse.RegistrationError != null) { - Logger.LogWarning("GetEnrollmentResult: RegistrationError present. Description='{Desc}'", - registrationResponse.RegistrationError.Description ?? "(null)"); + Logger.LogError($"Enrollment failed: {registrationResponse.RegistrationError.Description}"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = registrationResponse.RegistrationError.Description ?? "Enrollment failed with unknown error." + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = registrationResponse.RegistrationError.Description }; } if (registrationResponse.Result == null) { - Logger.LogError("GetEnrollmentResult: Result is null but no RegistrationError present."); + Logger.LogError("Enrollment failed: CSC Global reported success but returned no result"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = "Enrollment failed: response Result is null." + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global reported success but returned no result" }; } - var cnames = new Dictionary(); - if (registrationResponse.Result.DcvDetails != null && registrationResponse.Result.DcvDetails.Count > 0) - { - Logger.LogTrace("GetEnrollmentResult: processing {Count} DcvDetails.", registrationResponse.Result.DcvDetails.Count); - foreach (var dcv in registrationResponse.Result.DcvDetails) - { - if (dcv == null) - { - Logger.LogTrace("GetEnrollmentResult: skipping null DcvDetail."); - continue; - } - - if (dcv.CName != null && !string.IsNullOrEmpty(dcv.CName.Name) && !string.IsNullOrEmpty(dcv.CName.Value)) - { - if (!cnames.ContainsKey(dcv.CName.Name)) - { - Logger.LogTrace("GetEnrollmentResult: adding CName '{Name}'='{Value}'", dcv.CName.Name, dcv.CName.Value); - cnames.Add(dcv.CName.Name, dcv.CName.Value); - } - else - { - Logger.LogTrace("GetEnrollmentResult: duplicate CName key '{Name}', skipping.", dcv.CName.Name); - } - } - - if (!string.IsNullOrEmpty(dcv.Email) && !cnames.ContainsKey(dcv.Email)) - { - if (!cnames.ContainsKey(dcv.Email)) - { - Logger.LogTrace("GetEnrollmentResult: adding DCV email '{Email}'", dcv.Email); - cnames.Add(dcv.Email, dcv.Email); - } - else - { - Logger.LogTrace("GetEnrollmentResult: duplicate email key '{Email}', skipping.", dcv.Email); - } - } - } - } - else - { - Logger.LogTrace("GetEnrollmentResult: no DcvDetails to process."); - } - - var uuid = registrationResponse.Result.Status?.Uuid; - var commonName = registrationResponse.Result.CommonName ?? "(unknown)"; - Logger.LogTrace("GetEnrollmentResult: success. UUID='{Uuid}', CommonName='{CommonName}', cnames count={Count}", - uuid ?? "(null)", commonName, cnames.Count); - + Logger.LogInformation($"Order successfully created with order number {registrationResponse.Result.CommonName}"); return new EnrollmentResult { - Status = (int)EndEntityStatus.EXTERNALVALIDATION, - CARequestID = uuid, + Status = (int)EndEntityStatus.EXTERNALVALIDATION, //success + CARequestID = registrationResponse.Result.Status?.Uuid, StatusMessage = - $"Order Successfully Created With Order Number {commonName}", - EnrollmentContext = cnames.Count > 0 ? cnames : null + $"Order Successfully Created With Order Number {registrationResponse.Result.CommonName}", + EnrollmentContext = BuildDcvEnrollmentContext(registrationResponse.Result.DcvDetails) }; } public int GetRevokeResult(IRevokeResponse revokeResponse) { - Logger.LogTrace("GetRevokeResult: revokeResponse is {Null}", revokeResponse == null ? "NULL" : "present"); - if (revokeResponse == null) { - Logger.LogError("GetRevokeResult: revokeResponse is null, returning FAILED."); + Logger.LogError("Revoke failed: CSC Global returned no response"); return (int)EndEntityStatus.FAILED; } if (revokeResponse.RegistrationError != null) { - Logger.LogWarning("GetRevokeResult: RegistrationError present. Description='{Desc}'", - revokeResponse.RegistrationError.Description ?? "(null)"); + Logger.LogError($"Revoke failed: {revokeResponse.RegistrationError.Description}"); return (int)EndEntityStatus.FAILED; } - Logger.LogTrace("GetRevokeResult: returning REVOKED."); return (int)EndEntityStatus.REVOKED; } public EnrollmentResult GetReIssueResult(IReissueResponse reissueResponse) { - Logger.LogTrace("GetReIssueResult: reissueResponse is {Null}", reissueResponse == null ? "NULL" : "present"); - if (reissueResponse == null) { - Logger.LogError("GetReIssueResult: reissueResponse is null."); + Logger.LogError("Reissue failed: CSC Global returned no response"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = "Reissue failed: received null response from CSC." + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global returned no response for the reissue request" }; } if (reissueResponse.RegistrationError != null) { - Logger.LogWarning("GetReIssueResult: RegistrationError present. Description='{Desc}'", - reissueResponse.RegistrationError.Description ?? "(null)"); + Logger.LogError($"Reissue failed: {reissueResponse.RegistrationError.Description}"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = reissueResponse.RegistrationError.Description ?? "Reissue failed with unknown error." + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = reissueResponse.RegistrationError.Description }; } if (reissueResponse.Result == null) { - Logger.LogError("GetReIssueResult: Result is null but no RegistrationError present."); + Logger.LogError("Reissue failed: CSC Global reported success but returned no result"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = "Reissue failed: response Result is null." + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global reported success but returned no result" }; } - var uuid = reissueResponse.Result.Status?.Uuid; - var commonName = reissueResponse.Result.CommonName ?? "(unknown)"; - Logger.LogTrace("GetReIssueResult: success. UUID='{Uuid}', CommonName='{CommonName}'", uuid ?? "(null)", commonName); - + // 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.EXTERNALVALIDATION, - CARequestID = uuid, - StatusMessage = $"Reissue Successfully Submitted For {commonName}. Certificate will be available after next sync." + 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) { - Logger.LogTrace("GetDomainControlValidation(array): methodType='{MethodType}', domainName='{DomainName}', emailAddress count={Count}", - methodType ?? "(null)", domainName ?? "(null)", emailAddress?.Length ?? 0); - - if (emailAddress == null || emailAddress.Length == 0) + if (string.IsNullOrWhiteSpace(domainName)) { - Logger.LogTrace("GetDomainControlValidation(array): no email addresses provided, returning null."); + Logger.LogWarning("GetDomainControlValidation called with an empty domain name"); return null; } - foreach (var address in emailAddress) + foreach (var address in emailAddress ?? Array.Empty()) { - if (string.IsNullOrEmpty(address)) - { - Logger.LogTrace("GetDomainControlValidation(array): skipping null/empty email address."); - continue; - } + if (string.IsNullOrWhiteSpace(address)) continue; + MailAddress email; try { - var email = new MailAddress(address); - var hostPart = email.Host?.Split('.')[0] ?? ""; - Logger.LogTrace("GetDomainControlValidation(array): checking email='{Email}', hostPart='{HostPart}' against domain='{Domain}'", - address, hostPart, domainName); - - if (!string.IsNullOrEmpty(domainName) && domainName.Contains(hostPart)) - { - Logger.LogTrace("GetDomainControlValidation(array): matched! Returning email='{Email}'", email.ToString()); - return new DomainControlValidation - { - MethodType = methodType, - EmailAddress = email.ToString() - }; - } + email = new MailAddress(address.Trim()); } catch (FormatException fex) { - Logger.LogWarning("GetDomainControlValidation(array): invalid email address '{Address}': {Message}", address, fex.Message); + 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, + EmailAddress = email.ToString() + }; } - Logger.LogTrace("GetDomainControlValidation(array): no matching email found, returning null."); + Logger.LogWarning($"No matching DCV email address found for domain {domainName}"); return null; } @@ -346,190 +289,118 @@ public DomainControlValidation GetDomainControlValidation(string methodType, str public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productInfo, string csr, Dictionary sans, List customFields) { - Logger.LogTrace("GetRegistrationRequest: building registration request. ProductID='{ProductId}'", productInfo?.ProductID ?? "(null)"); + 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-----"; - if (productInfo?.ProductParameters == null) - throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); - if (string.IsNullOrEmpty(csr)) - throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); - var cert = Pemify(csr); var bytes = Encoding.UTF8.GetBytes(cert); var encodedString = Convert.ToBase64String(bytes); - Logger.LogTrace("GetRegistrationRequest: CSR encoded, length={Length}", encodedString.Length); - - var commonNameValidationEmail = productInfo.ProductParameters.ContainsKey("CN DCV Email") - ? productInfo.ProductParameters["CN DCV Email"] : null; - var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") - ? productInfo.ProductParameters["Domain Control Validation Method"] : null; + var commonNameValidationEmail = GetOptionalParam(productInfo, "CN DCV Email"); + var methodType = GetOptionalParam(productInfo, "Domain Control Validation Method"); var certificateType = GetCertificateType(productInfo.ProductID); - Logger.LogTrace("GetRegistrationRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", - commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); - return new RegistrationRequest { Csr = encodedString, - ServerSoftware = "-1", + ServerSoftware = "-1", //Just default to other, user does not need to fill this in CertificateType = certificateType, - Term = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, - ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, - ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, - ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, - ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, + 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.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, - BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, - ShowPrice = true, + 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 = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, EvCertificateDetails = EvCertificateTypes.Contains(certificateType) ? GetEvCertificateDetails(productInfo) : null }; } - // Maps Keyfactor product ID -> CSC API certificate type code (used for enrollment requests) - private static readonly Dictionary ProductIdToCodeMap = new(StringComparer.OrdinalIgnoreCase) - { - ["CSC TrustedSecure Premium Certificate"] = "0", - ["CSC TrustedSecure Premium Wildcard Certificate"] = "1", - ["CSC TrustedSecure UC Certificate"] = "2", - ["CSC TrustedSecure EV Certificate"] = "3", - ["CSC TrustedSecure Domain Validated SSL"] = "4", - ["CSC Trusted Secure Domain Validated SSL"] = "4", - ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "5", - ["CSC Trusted Secure Domain Validated UC Certificate"] = "6", - }; - - // Reverse map: CSC API certificateType string -> Keyfactor product ID (used during sync) - // Note: CSC naming is inconsistent — first 4 types use "TrustedSecure" (no space), - // DV Wildcard and DV UC use "Trusted Secure" (with space), - // but CSC API returns DV SSL as "CSC Trusted Secure Domain Validated SSL" (with space) - // while the product ID is "CSC TrustedSecure Domain Validated SSL" (no space). - private static readonly Dictionary CodeToProductIdMap = new(StringComparer.OrdinalIgnoreCase) - { - // Premium - ["0"] = "CSC TrustedSecure Premium Certificate", - ["CSC TrustedSecure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", - ["CSC Trusted Secure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", - // Premium Wildcard - ["1"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["CSC TrustedSecure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["CSC Trusted Secure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", - // UC - ["2"] = "CSC TrustedSecure UC Certificate", - ["CSC TrustedSecure UC Certificate"] = "CSC TrustedSecure UC Certificate", - ["CSC Trusted Secure UC Certificate"] = "CSC TrustedSecure UC Certificate", - // EV - ["3"] = "CSC TrustedSecure EV Certificate", - ["CSC TrustedSecure EV Certificate"] = "CSC TrustedSecure EV Certificate", - ["CSC Trusted Secure EV Certificate"] = "CSC TrustedSecure EV Certificate", - // DV SSL — product ID has no space, but CSC API returns with space - ["4"] = "CSC TrustedSecure Domain Validated SSL", - ["CSC TrustedSecure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", - ["CSC Trusted Secure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", - // DV Wildcard — product ID has space (matches CSC API) - ["5"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - // DV UC — product ID has space (matches CSC API) - ["6"] = "CSC Trusted Secure Domain Validated UC Certificate", - ["CSC Trusted Secure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", - ["CSC TrustedSecure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", - }; - private string GetCertificateType(string productId) { - Logger.LogTrace("GetCertificateType: productId='{ProductId}'", productId ?? "(null)"); - if (!string.IsNullOrEmpty(productId) && ProductIdToCodeMap.TryGetValue(productId, out var code)) + if (productId != null && ProductIDs.legacyProductIdAliases.TryGetValue(productId, out var canonicalProductId)) { - Logger.LogTrace("GetCertificateType: mapped '{ProductId}' -> '{Code}'", productId, code); - return code; + productId = canonicalProductId; } - Logger.LogWarning("GetCertificateType: no mapping found for '{ProductId}', returning -1.", productId); - return "-1"; - } - /// - /// Maps a CSC API certificateType value back to a Keyfactor product ID. - /// Handles numeric codes, descriptive strings, and passthrough of already-correct values. - /// - public string MapCertificateTypeToProductId(string cscCertificateType) - { - Logger.LogTrace("MapCertificateTypeToProductId: input='{CscCertType}'", cscCertificateType ?? "(null)"); - if (!string.IsNullOrEmpty(cscCertificateType) && CodeToProductIdMap.TryGetValue(cscCertificateType, out var productId)) + switch (productId) { - Logger.LogTrace("MapCertificateTypeToProductId: mapped '{CscCertType}' -> '{ProductId}'", cscCertificateType, productId); - return productId; + case "CSC TrustedSecure OV": + return "0"; + case "CSC TrustedSecure OV Wildcard": + return "1"; + case "CSC TrustedSecure OV, Multiple Names": + return "2"; + case "CSC TrustedSecure EV": + return "3"; + case "CSC TrustedSecure DV": + return "4"; + case "CSC TrustedSecure DV Wildcard": + return "5"; + 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("MapCertificateTypeToProductId: no mapping for '{CscCertType}', passing through as-is.", cscCertificateType); - return cscCertificateType ?? "CscGlobal"; + + 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) { - Logger.LogTrace("GetNotifications: building notifications."); - var emailsRaw = productInfo?.ProductParameters != null - && productInfo.ProductParameters.ContainsKey("Notification Email(s) Comma Separated") - ? productInfo.ProductParameters["Notification Email(s) Comma Separated"] - : null; - - Logger.LogTrace("GetNotifications: raw notification emails='{Emails}'", emailsRaw ?? "(null)"); - - var emailList = !string.IsNullOrEmpty(emailsRaw) - ? emailsRaw.Split(',').Where(e => !string.IsNullOrWhiteSpace(e)).ToList() - : new List(); - - Logger.LogTrace("GetNotifications: parsed {Count} notification emails.", emailList.Count); - + var notificationEmails = GetOptionalParam(productInfo, "Notification Email(s) Comma Separated"); return new Notifications { Enabled = true, - AdditionalNotificationEmails = emailList + AdditionalNotificationEmails = string.IsNullOrWhiteSpace(notificationEmails) + ? new List() + : notificationEmails.Split(',').ToList() }; } public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { - Logger.LogTrace("GetRenewalRequest: building renewal request. UUID='{Uuid}', ProductID='{ProductId}'", - uUId ?? "(null)", productInfo?.ProductID ?? "(null)"); - - if (productInfo?.ProductParameters == null) - throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); - if (string.IsNullOrEmpty(csr)) - throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); - if (string.IsNullOrEmpty(uUId)) - throw new ArgumentNullException(nameof(uUId), "uUId cannot be null or empty."); - + 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.ContainsKey("CN DCV Email") - ? productInfo.ProductParameters["CN DCV Email"] : null; - var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") - ? productInfo.ProductParameters["Domain Control Validation Method"] : null; + var commonNameValidationEmail = GetOptionalParam(productInfo, "CN DCV Email"); + var methodType = GetOptionalParam(productInfo, "Domain Control Validation Method"); var certificateType = GetCertificateType(productInfo.ProductID); - Logger.LogTrace("GetRenewalRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", - commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); - return new RenewalRequest { Uuid = uUId, Csr = encodedString, ServerSoftware = "-1", CertificateType = certificateType, - Term = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, - ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, - ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, - ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, - ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, + 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.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, - BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, + OrganizationContact = GetOptionalParam(productInfo, "Organization Contact"), + BusinessUnit = GetOptionalParam(productInfo, "Business Unit"), ShowPrice = true, SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), @@ -540,107 +411,67 @@ public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, strin private List GetSubjectAlternativeNames(EnrollmentProductInfo productInfo, Dictionary sans) { - Logger.LogTrace("GetSubjectAlternativeNames: building SANs."); var subjectNameList = new List(); + var methodType = GetOptionalParam(productInfo, "Domain Control Validation Method"); + var commonNameValidationEmail = GetOptionalParam(productInfo, "CN DCV Email"); - if (sans == null || !sans.ContainsKey("dnsname")) - { - Logger.LogTrace("GetSubjectAlternativeNames: no 'dnsname' key in SANs dictionary, returning empty list."); - return subjectNameList; - } - - var dnsNames = sans["dnsname"]; - if (dnsNames == null || dnsNames.Length == 0) + string[] dnsNames = null; + sans?.TryGetValue("dnsname", out dnsNames); + foreach (var v in dnsNames ?? Array.Empty()) { - Logger.LogTrace("GetSubjectAlternativeNames: 'dnsname' array is null or empty, returning empty list."); - return subjectNameList; - } - - var methodType = productInfo?.ProductParameters != null - && productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") - ? productInfo.ProductParameters["Domain Control Validation Method"] - : null; - - Logger.LogTrace("GetSubjectAlternativeNames: processing {Count} DNS names, methodType='{MethodType}'", - dnsNames.Length, methodType ?? "(null)"); - - foreach (var v in dnsNames) - { - if (string.IsNullOrEmpty(v)) - { - Logger.LogTrace("GetSubjectAlternativeNames: skipping null/empty DNS name."); - continue; - } - var domainName = v; var san = new SubjectAlternativeName(); san.DomainName = domainName; - Logger.LogTrace("GetSubjectAlternativeNames: processing domain='{Domain}'", domainName); - - if (!string.IsNullOrEmpty(methodType) && methodType.ToUpper() == "EMAIL") - { - var emailsRaw = productInfo.ProductParameters.ContainsKey("Addtl Sans Comma Separated DVC Emails") - ? productInfo.ProductParameters["Addtl Sans Comma Separated DVC Emails"] - : null; - var emailAddresses = !string.IsNullOrEmpty(emailsRaw) ? emailsRaw.Split(',') : Array.Empty(); - Logger.LogTrace("GetSubjectAlternativeNames: EMAIL validation, {Count} email addresses for domain='{Domain}'", - emailAddresses.Length, domainName); - san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName); - } - else + if (methodType.ToUpper() == "EMAIL") { - Logger.LogTrace("GetSubjectAlternativeNames: CNAME/other validation for domain='{Domain}'", domainName); - 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); } - Logger.LogTrace("GetSubjectAlternativeNames: returning {Count} SANs.", subjectNameList.Count); return subjectNameList; } public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { - Logger.LogTrace("GetReissueRequest: building reissue request. UUID='{Uuid}', ProductID='{ProductId}'", - uUId ?? "(null)", productInfo?.ProductID ?? "(null)"); - - if (productInfo?.ProductParameters == null) - throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); - if (string.IsNullOrEmpty(csr)) - throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); - if (string.IsNullOrEmpty(uUId)) - throw new ArgumentNullException(nameof(uUId), "uUId cannot be null or empty."); - + 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.ContainsKey("CN DCV Email") - ? productInfo.ProductParameters["CN DCV Email"] : null; - var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") - ? productInfo.ProductParameters["Domain Control Validation Method"] : null; + var commonNameValidationEmail = GetOptionalParam(productInfo, "CN DCV Email"); + var methodType = GetOptionalParam(productInfo, "Domain Control Validation Method"); var certificateType = GetCertificateType(productInfo.ProductID); - Logger.LogTrace("GetReissueRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", - commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); - return new ReissueRequest { Uuid = uUId, Csr = encodedString, ServerSoftware = "-1", CertificateType = certificateType, - Term = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, - ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, - ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, - ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, - ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, + 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.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, - BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, + OrganizationContact = GetOptionalParam(productInfo, "Organization Contact"), + BusinessUnit = GetOptionalParam(productInfo, "Business Unit"), ShowPrice = true, SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), @@ -650,28 +481,15 @@ public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, strin private EvCertificateDetails GetEvCertificateDetails(EnrollmentProductInfo productInfo) { - Logger.LogTrace("GetEvCertificateDetails: building EV details."); - var country = productInfo?.ProductParameters != null - && productInfo.ProductParameters.ContainsKey("Organization Country") - ? productInfo.ProductParameters["Organization Country"] - : null; - Logger.LogTrace("GetEvCertificateDetails: country='{Country}'", country ?? "(null)"); var evDetails = new EvCertificateDetails(); - evDetails.Country = country; + evDetails.Country = GetOptionalParam(productInfo, "Organization Country"); return evDetails; } public int MapReturnStatus(string cscGlobalStatus) { - Logger.LogTrace("MapReturnStatus: input status='{Status}'", cscGlobalStatus ?? "(null)"); - - if (string.IsNullOrEmpty(cscGlobalStatus)) - { - Logger.LogWarning("MapReturnStatus: status is null or empty, returning FAILED."); - return (int)EndEntityStatus.FAILED; - } - int returnStatus; + switch (cscGlobalStatus) { case "ACTIVE": @@ -687,12 +505,11 @@ public int MapReturnStatus(string cscGlobalStatus) returnStatus = (int)EndEntityStatus.REVOKED; break; default: - Logger.LogWarning("MapReturnStatus: unrecognized status '{Status}', returning FAILED.", cscGlobalStatus); + Logger.LogWarning($"Unrecognized CSC Global status '{cscGlobalStatus}'; mapping to FAILED"); returnStatus = (int)EndEntityStatus.FAILED; break; } - Logger.LogTrace("MapReturnStatus: mapped '{Status}' to {Result}", cscGlobalStatus, returnStatus); return returnStatus; } } \ No newline at end of file diff --git a/docsource/configuration.md b/docsource/configuration.md index 122477f..20053a4 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -10,115 +10,6 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and The Root certificates for installation on the Anygateway server machine should be obtained from CSC. -## CA Connection Configuration - -When defining the Certificate Authority in the AnyCA Gateway REST portal, configure the following fields on the **CA Connection** tab: - -CONFIG ELEMENT | DESCRIPTION | DEFAULT ----------------|-------------|-------- -Enabled | Flag to Enable or Disable gateway functionality. Set to `false` to allow creating the CA record before configuration information is available; the plugin then short-circuits Ping, Sync, Enroll, and Revoke with a warning until it is re-enabled. | `true` -CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) -ApiKey | Your CSCGlobal API key | (required) -BearerToken | Your CSCGlobal Bearer token for authentication | (required) -DefaultPageSize | Page size for API list requests | 100 -SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 -RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 -DcvPollTimeoutSeconds | Max seconds to synchronously poll CSC for certificate issuance after submitting an order. `0` disables polling (enrollment returns pending immediately; cert arrives on the next sync). When `>0`, fast-validating orders can return the issued cert directly in the enrollment response. See [Synchronous Issuance Polling](#synchronous-issuance-polling) below. | 0 - -> **Note:** DNS auto-publishing for CNAME DCV is handled by the AnyCA Gateway REST framework's Domain Validation system (gateway 3.3+). It's configured in the gateway UI under **Domain Validation Configurations**, not on the CA Connection tab. See [DNS Auto-Publishing (CNAME DCV)](#dns-auto-publishing-cname-dcv). - -## Renewal vs. Reissue Logic - -CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). - -The decision is based on the **RenewalWindowDays** setting and works as follows: - -1. The plugin fetches the original certificate from CSC and reads its `orderDate`. -2. It computes the **order expiry** as `orderDate + 1 year`. -3. It calculates **days remaining** until the order expires. -4. If `days remaining <= RenewalWindowDays`, the request is treated as a **Renewal** (new paid order). -5. If `days remaining > RenewalWindowDays`, the request is treated as a **Reissue** (free under the active order). - -**Example with default RenewalWindowDays = 30:** - -``` -Order Date: 2025-04-08 -Order Expiry: 2026-04-08 -Today: 2026-03-15 -Days Left: 24 - -24 <= 30 --> RENEWAL (new paid order) -``` - -``` -Order Date: 2025-04-08 -Order Expiry: 2026-04-08 -Today: 2025-09-01 -Days Left: 219 - -219 > 30 --> REISSUE (free under active order) -``` - -**Fallback behavior:** If the plugin cannot retrieve the `orderDate` from CSC (e.g., API error or missing field), it falls back to checking the certificate's expiration date. If the certificate is already expired, it treats the request as a Renewal. - -**Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. - -## DNS Auto-Publishing (CNAME DCV) - -CSC supports two Domain Control Validation (DCV) methods: **EMAIL** and **CNAME**. With CNAME validation, CSC returns a CNAME record (name → target) that must exist in DNS before they will validate the order. - -By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, the plugin uses the **AnyCA Gateway REST framework's built-in DNS provider system** (available in framework 3.3 and later). The framework discovers DNS provider plugins deployed alongside the CA plugin and routes each CNAME to whichever provider claims the matching DNS zone. - -### Requirements - -* AnyCA Gateway REST framework **3.3 or later** (the `IDomainValidatorFactory` interface ships in `Keyfactor.AnyGateway.IAnyCAPlugin` 3.3+). -* At least one DNS provider DLL (e.g. GoDaddy, Cloudflare, Route 53, Azure) deployed in the gateway `Extensions` folder. -* A Domain Validation Configuration registered in the gateway UI that maps your domain(s) to the deployed provider (for example, `*.example.com` → GoDaddy). - -### How It Works - -1. CSC returns the CNAME `name → target` details in the enrollment response. -2. For each CNAME entry, the plugin calls `IDomainValidatorFactory.ResolveDomainValidator(recordName, "cname")`. -3. The framework returns the `IDomainValidator` whose Domain Validation Configuration matches the record's zone (or `null` if no match). -4. The plugin calls `validator.StageValidation(recordName, cnameTarget, ct)` to publish the record. -5. CSC asynchronously validates the CNAME; the issued certificate appears on the next sync. - -### Behavior - -* **Resolution is per record, not per CA.** One CA can drive multiple DNS providers (GoDaddy for some domains, Route 53 for others) with no per-CA configuration. -* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. -* **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. -* **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. -* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. CSC's DCV requires a **CNAME** record, which is different from ACME's `"dns-01"` challenge (a TXT record). A single DNS provider DLL can ship multiple validator classes — one advertising `"dns-01"` (publishes TXT, for ACME) and one advertising `"cname"` (publishes CNAME, for CSC). You must deploy and configure a validator that advertises `"cname"` or no provider will match. -* **Trailing dots normalized.** CSC returns FQDN-canonical names with a trailing dot (e.g. `_token.example.com.`). The plugin strips the trailing dot before resolution and publishing, because Domain Validation Configurations and DNS provider APIs expect names without it. - -### Configuration in the Gateway UI - -In the AnyCA Gateway REST portal, under **Domain Validation Configurations**: - -1. **Add** a new configuration. -2. Pick a **Domain Validator Type** that publishes **CNAME** records and advertises validation type `cname`. For GoDaddy this is `GoDaddyCnameDomainValidator` (the `GoDaddyDomainValidator` variant publishes TXT for ACME and will **not** work for CSC). -3. Add one or more **domain patterns** (e.g. `*.example.com`). -4. Fill out the provider-specific **Configuration Settings** (API keys, base URL, etc.). -5. Save. - -Once configured, any CSC enrollment for a domain matching one of those patterns will have its CNAME auto-published. - -> **Common pitfall:** If you configure the TXT/`dns-01` validator (e.g. `GoDaddyDomainValidator`) for a CSC domain, the record will publish as a **TXT** and CSC's CNAME validation will never succeed. Make sure you select the **CNAME** validator variant. - -## Synchronous Issuance Polling - -CSC validates domain control asynchronously — after an order is submitted (and the CNAME DCV record published), CSC/Sectigo polls public DNS on its own schedule and issues the certificate once validation passes. By default this plugin returns a **pending** (`EXTERNALVALIDATION`) result immediately and the issued certificate is picked up on the next gateway **sync** cycle. - -For environments where DNS is published automatically (see [DNS Auto-Publishing](#dns-auto-publishing-cname-dcv)) and validation tends to complete quickly, you can have the plugin **poll CSC synchronously** at the end of enrollment and return the issued certificate directly — avoiding the wait for the next sync. - -* Set **`DcvPollTimeoutSeconds`** to the maximum number of seconds to poll (e.g. `60`). `0` (default) disables polling entirely. -* The plugin polls CSC every 10 seconds until the order is issued or the timeout is reached. -* If the certificate issues within the window, the enrollment returns it immediately with a success status. -* If the window expires, the plugin falls back to the **pending** result and the certificate arrives on the next sync — exactly as it would with polling disabled. - -**Tradeoff:** Polling blocks the enrollment request for up to `DcvPollTimeoutSeconds`. CSC validation frequently takes minutes to hours, so most orders will still fall through to pending — keep the timeout small (30–90s) to catch only the fast cases without hanging callers. This applies to New enrollments, Renewals, and Reissues. - ## Certificate Template Creation Step PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION @@ -153,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 - Details Tab** +**CSC TrustedSecure OV Wildcard - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure EV -Template Display Name | CSC TrustedSecure EV -Friendly Name | CSC TrustedSecure EV +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 - Enrollment Fields** +**CSC TrustedSecure OV Wildcard - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -180,7 +71,6 @@ 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 OV, Multiple Names - Details Tab** @@ -210,22 +100,21 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A Addtl Sans Comma Separated DCV Emails | String | N/A - -**CSC TrustedSecure OV Wildcard - Details Tab** +**CSC TrustedSecure EV - 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 +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 OV Wildcard - Enrollment Fields** +**CSC TrustedSecure EV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -239,6 +128,7 @@ 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** @@ -352,8 +242,8 @@ 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 Organization Country | String | N/A +Addtl Sans Comma Separated DCV Emails | String | N/A **CSC TrustedSecure OV Wildcard, Multiple Names - Details Tab** diff --git a/integration-manifest.json b/integration-manifest.json index e58e50f..9ae70c7 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -2,13 +2,12 @@ "$schema": "https://keyfactor.github.io/integration-manifest-schema.json", "integration_type": "anyca-plugin", "name": "CSCGlobal CAPlugin REST Gateway Plugin", - "status": "production", "support_level": "kf-supported", + "status": "pilot", "link_github": true, - "status": "production", "update_catalog": true, "description": "CSCGlobal CAPlugin for the AnyCA REST Gateway framework", - "gateway_framework": "26.2.0", + "gateway_framework": "24.2.0", "release_project": "cscglobal-caplugin/CSCGlobalCAPlugin.csproj", "release_dir": "cscglobal-caplugin/bin/Release", "about": { @@ -33,14 +32,6 @@ { "name": "SyncFilterDays", "description": "Number of days from today to filter certificates by expiration date during incremental sync." - }, - { - "name": "RenewalWindowDays", - "description": "Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30." - }, - { - "name": "DcvPollTimeoutSeconds", - "description": "Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests." } ], "enrollment_config": [ @@ -94,14 +85,17 @@ } ], "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 Trusted Secure Domain Validated Wildcard SSL", - "CSC Trusted Secure 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" ] } } -} \ No newline at end of file +}