diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 9f60d0c..a763f2e 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -420,7 +420,7 @@ public async Task Synchronize_ValidCertificateContent_AddsToBufferWithMappedProd { Results = new List { - new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "4" } + new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "CSC TrustedSecure DV" } } }); @@ -432,7 +432,10 @@ public async Task Synchronize_ValidCertificateContent_AddsToBufferWithMappedProd var items = buffer.ToArray(); Assert.Single(items); Assert.Equal("u1", items[0].CARequestID); - Assert.Equal("CSC TrustedSecure Domain Validated SSL", items[0].ProductID); + // 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); } [Fact] @@ -834,6 +837,58 @@ public async Task Enroll_New_Success_ReturnsExternalValidation() 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); + } + + [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 + { + 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" } } + } + } + }); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + 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); + } + + [Fact] + public async Task Enroll_New_RegistrationErrorFromCsc_PrependsFlowSummaryToStatusMessage() + { + 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 = "Open order in progress" } + }); + + 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); } [Fact] @@ -993,10 +1048,14 @@ public async Task Enroll_New_WithDnsValidatorFactoryButEmailMethod_DoesNotAttemp [EnrollmentConfigConstants.DomainControlValidationMethod] = "EMAIL" }); - await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + 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); } [Fact] diff --git a/cscglobal-caplugin.Tests/FlowLoggerTests.cs b/cscglobal-caplugin.Tests/FlowLoggerTests.cs index 2dea519..a9834fd 100644 --- a/cscglobal-caplugin.Tests/FlowLoggerTests.cs +++ b/cscglobal-caplugin.Tests/FlowLoggerTests.cs @@ -117,6 +117,51 @@ public void EndBranch_WithoutBranch_DoesNotThrow() flow.EndBranch(); } + [Fact] + public void GetSummaryEntries_OneEntryPerStepPlusHeader() + { + 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"]); + } + + [Fact] + public void GetSummaryEntries_BranchChildren_IncludedAsSeparateEntries() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "MyFlow"); + 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")); + } + [Fact] public void Dispose_NoSteps_DoesNotThrow() { diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs index 4bed6c6..0762605 100644 --- a/cscglobal-caplugin.Tests/RequestManagerTests.cs +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -402,7 +402,7 @@ public void GetRevokeResult_Success_ReturnsRevoked() } // --------------------------------------------------------------------- - // MapReturnStatus / MapCertificateTypeToProductId + // MapReturnStatus // --------------------------------------------------------------------- [Theory] @@ -417,28 +417,6 @@ public void MapReturnStatus_MapsExpectedStatus(string? cscStatus, Keyfactor.PKI. Assert.Equal((int)expected, Manager.MapReturnStatus(cscStatus!)); } - [Theory] - [InlineData("4", "CSC TrustedSecure Domain Validated SSL")] - [InlineData("CSC TrustedSecure Domain Validated SSL", "CSC TrustedSecure Domain Validated SSL")] - [InlineData("CSC Trusted Secure Domain Validated SSL", "CSC TrustedSecure Domain Validated SSL")] - [InlineData("9", "CSC TrustedSecure DV Wildcard, Multiple Names")] - public void MapCertificateTypeToProductId_KnownValue_MapsToProductId(string cscType, string expectedProductId) - { - Assert.Equal(expectedProductId, Manager.MapCertificateTypeToProductId(cscType)); - } - - [Fact] - public void MapCertificateTypeToProductId_UnknownValue_PassesThrough() - { - Assert.Equal("SomeUnknownType", Manager.MapCertificateTypeToProductId("SomeUnknownType")); - } - - [Fact] - public void MapCertificateTypeToProductId_Null_ReturnsFallback() - { - Assert.Equal("CscGlobal", Manager.MapCertificateTypeToProductId(null!)); - } - // --------------------------------------------------------------------- // GetNotifications // --------------------------------------------------------------------- diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index b5ae44f..e42fc86 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -434,7 +434,11 @@ private async Task SyncCertificates(BlockingCollection b if (certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || certStatus == Convert.ToInt32(EndEntityStatus.REVOKED)) { - var productId = _requestManager.MapCertificateTypeToProductId(currentResponseItem.CertificateType); + // CSC's list/sync API returns the certificate's current product name directly + // (e.g. "CSC TrustedSecure DV"), which already matches the canonical Product ID + // used for enrollment - no reverse lookup needed, same as the CSC-name-is-truth + // approach taken on feature/ev-ov-dv-multiname-certs. + var productId = currentResponseItem.CertificateType ?? "CscGlobal"; Logger.LogTrace("SyncCertificates: UUID={Uuid} qualifies for sync. CertificateType='{CertType}' -> ProductId='{ProductId}'", currentResponseItem.Uuid, currentResponseItem.CertificateType ?? "(null)", productId); @@ -490,6 +494,16 @@ private async Task SyncCertificates(BlockingCollection b 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 { @@ -674,8 +688,8 @@ await flow.StepAsync("SubmitRegistrationToCSC", async () => flow.Fail("ParseResponse", "API returned null"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Enrollment failed: CSC API returned a null response." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed: CSC API returned a null response." }; } flow.Step("ParseResponse", $"error={enrollmentResponse.RegistrationError != null}"); @@ -686,18 +700,15 @@ await flow.StepAsync("SubmitRegistrationToCSC", async () => flow.Fail("RejectExpiredRenew", "PriorCertSN present on New enrollment"); 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); - }); + await flow.StepAsync("DcvAutoPublish", () => TryPublishCnameDcvAsync(productInfo, enrollResult)); EnrollmentResult? newPolled = null; await flow.StepAsync("PollForIssuance", async () => @@ -707,10 +718,12 @@ await flow.StepAsync("PollForIssuance", async () => if (newPolled != null) { flow.Step("PollResult", "issued during poll window"); + AttachFlowSummary(newPolled, flow); Logger.MethodExit(LogLevel.Debug); return newPolled; } + AttachFlowSummary(enrollResult, flow); Logger.MethodExit(LogLevel.Debug); return enrollResult; @@ -722,8 +735,8 @@ await flow.StepAsync("PollForIssuance", async () => flow.Fail("ValidatePriorSN", "PriorCertSN is empty"); return new EnrollmentResult { - Status = 30, - StatusMessage = "RenewOrReissue failed: PriorCertSN is required but was not provided." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: PriorCertSN is required but was not provided." }; } @@ -738,8 +751,8 @@ await flow.StepAsync("LookupOrderId", async () => flow.Fail("ValidateOrderId", $"no order found for SN={priorSn}"); 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\nRenewOrReissue failed: could not find order ID for serial number '{priorSn}'." }; } @@ -748,8 +761,8 @@ await flow.StepAsync("LookupOrderId", async () => 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." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: order ID '{order_id}' is too short to extract a UUID." }; } flow.Step("ValidateOrderId", $"orderId={order_id}"); @@ -797,8 +810,8 @@ await flow.StepAsync("FetchLiveCertForDecision", async () => flow.Fail("FallbackExpiryCheck", fallbackEx.Message); return new EnrollmentResult { - Status = 30, - StatusMessage = $"RenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" }; } } @@ -821,8 +834,8 @@ await flow.StepAsync("LookupRenewalUUID", async () => 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." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewal failed: could not resolve prior certificate serial number to a request ID." }; } flow.Step("ValidateRenewalUUID", $"uuid={uUId}"); @@ -846,8 +859,8 @@ await flow.StepAsync("SubmitRenewalToCSC", async () => flow.Fail("ParseRenewalResponse", "API returned null"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Renewal failed: CSC API returned a null response." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewal failed: CSC API returned a null response." }; } @@ -860,6 +873,7 @@ await flow.StepAsync("PollForIssuance", async () => { renewPolled = await TryPollForIssuedCertAsync(renewResult?.CARequestID); }); + AttachFlowSummary(renewPolled ?? renewResult, flow); Logger.MethodExit(LogLevel.Debug); return renewPolled ?? renewResult; } @@ -867,9 +881,9 @@ await flow.StepAsync("PollForIssuance", async () => flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click renew unavailable"); 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." }; } @@ -888,8 +902,8 @@ await flow.StepAsync("LookupReissueRequestId", async () => 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." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nReissue failed: could not resolve prior certificate serial number to a request ID." }; } @@ -898,8 +912,8 @@ await flow.StepAsync("LookupReissueRequestId", async () => 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." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nReissue failed: request ID '{requestid}' is too short to extract a UUID." }; } @@ -925,8 +939,8 @@ await flow.StepAsync("SubmitReissueToCSC", async () => flow.Fail("ParseReissueResponse", "API returned null"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Reissue failed: CSC API returned a null response." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nReissue failed: CSC API returned a null response." }; } @@ -939,6 +953,7 @@ await flow.StepAsync("PollForIssuance", async () => { reissuePolled = await TryPollForIssuedCertAsync(reissueResult?.CARequestID); }); + AttachFlowSummary(reissuePolled ?? reissueResult, flow); Logger.MethodExit(LogLevel.Debug); return reissuePolled ?? reissueResult; } @@ -946,17 +961,17 @@ await flow.StepAsync("PollForIssuance", async () => flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click reissue unavailable"); 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 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}'." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnroll failed: unhandled enrollment type '{enrollmentType}'." }; } } @@ -967,8 +982,8 @@ await flow.StepAsync("PollForIssuance", async () => Logger.LogError(inner, "Enroll: AggregateException during {EnrollmentType}: {Message}", enrollmentType, inner?.Message ?? ae.Message); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Enrollment failed with error: {inner?.Message ?? ae.Message}" + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed with error: {inner?.Message ?? ae.Message}" }; } catch (Exception ex) @@ -977,12 +992,40 @@ await flow.StepAsync("PollForIssuance", async () => Logger.LogError(ex, "Enroll: unhandled exception during {EnrollmentType}: {Message}", enrollmentType, ex.Message); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Enrollment failed with error: {ex.Message}" + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed with error: {ex.Message}" }; } } + // 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. Command's enrollment + // UI does not surface StatusMessage on a successful/pending result at all - only + // EnrollmentContext is - so attach the summary there instead, as its own entry alongside + // whatever DCV instructions came back. Must be called after TryPublishCnameDcvAsync, which + // treats every EnrollmentContext entry as a candidate DNS record to publish - calling this + // first would make it try to publish "Flow Summary" as a CNAME. + private static void AttachFlowSummary(EnrollmentResult? result, FlowLogger flow) + { + if (result == null) + return; + + if (result.Status == (int)EndEntityStatus.FAILED) + { + result.StatusMessage = $"{flow.GetSummary()}\n\n{result.StatusMessage}"; + return; + } + + // One EnrollmentContext entry per step (rather than one entry holding the whole + // multi-line summary) so Command's bulleted rendering shows a readable line per step + // instead of a single run-on blob. + result.EnrollmentContext ??= new Dictionary(); + foreach (var entry in flow.GetSummaryEntries()) + result.EnrollmentContext[entry.Key] = entry.Value; + } + //done public async Task Ping() { @@ -1352,19 +1395,21 @@ record = null; /// 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. + /// Returns a short description of what happened (published/skipped/why), surfaced as the + /// flow step's detail so a no-op for non-CNAME methods doesn't look unexplained. /// - private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) + private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) { if (_validatorFactory == null) { Logger.LogTrace("TryPublishCnameDcvAsync: no IDomainValidatorFactory was injected, skipping auto-publish."); - return; + return "skipped - no DNS validator factory injected"; } if (enrollResult?.EnrollmentContext == null || enrollResult.EnrollmentContext.Count == 0) { Logger.LogTrace("TryPublishCnameDcvAsync: no CNAME entries in EnrollmentContext, skipping."); - return; + return "skipped - no DCV entries returned by CSC"; } var dcvMethod = productInfo?.ProductParameters != null @@ -1376,7 +1421,7 @@ private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, En !string.Equals(dcvMethod, "CNAME", StringComparison.OrdinalIgnoreCase)) { Logger.LogTrace("TryPublishCnameDcvAsync: DCV method '{Method}' is not CNAME, skipping auto-publish.", dcvMethod ?? "(null)"); - return; + return $"skipped - DCV method is '{dcvMethod ?? "(none)"}', not CNAME"; } Logger.LogInformation( @@ -1462,6 +1507,9 @@ private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, En Logger.LogInformation( "TryPublishCnameDcvAsync: complete. Published={Published}, Failed={Failed}, Unresolved={Unresolved}", successCount, failCount, unresolvedCount); + + return $"published {successCount}, failed {failCount}, unresolved {unresolvedCount} " + + $"of {enrollResult.EnrollmentContext.Count} CNAME record(s)"; } //Trying to fix leaf extraction diff --git a/cscglobal-caplugin/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs index 5696fcd..fb90961 100644 --- a/cscglobal-caplugin/FlowLogger.cs +++ b/cscglobal-caplugin/FlowLogger.cs @@ -124,6 +124,42 @@ public async Task StepAsync(string name, Func action, string d return this; } + /// + /// Record an async step whose own return value becomes the step's detail - unlike the + /// parameter on the other overload (which is evaluated before + /// the action runs and so can't reflect anything the action decided), this reflects what + /// actually happened during execution (e.g. why a conditional step was a no-op). + /// + public async Task StepAsync(string name, Func> action) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + var detail = await action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = detail; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", + _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = FlowStepStatus.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); + throw; + } + return this; + } + /// Record a failed step without throwing. public FlowLogger Fail(string name, string reason = null) { @@ -219,6 +255,86 @@ private string RenderFlow() return sb.ToString(); } + /// + /// Concise step-by-step summary suitable for surfacing in a user-facing failure message + /// (unlike 's ASCII-art tree, which is meant for Trace logs only). + /// + public string GetSummary() + { + var hasFailures = _steps.Any(s => s.Status == FlowStepStatus.Failed) || + _steps.SelectMany(s => s.Children).Any(c => c.Status == FlowStepStatus.Failed); + var overallStatus = hasFailures ? "FAILED" : "OK"; + + var sb = new StringBuilder(); + sb.AppendLine($"Flow: {_flowName} [{overallStatus}] Total: {_totalTimer.ElapsedMilliseconds}ms"); + sb.AppendLine("----------------------------------------"); + + foreach (var step in _steps) + { + AppendSummaryLine(sb, step, 0); + foreach (var child in step.Children) + AppendSummaryLine(sb, child, 1); + } + + return sb.ToString(); + } + + private static void AppendSummaryLine(StringBuilder sb, FlowStep step, int indentLevel) + { + var indent = new string(' ', indentLevel * 2); + var icon = GetStatusIcon(step.Status); + var elapsed = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" - {step.Detail}" : ""; + sb.AppendLine($"{indent}{icon} {step.Name}{elapsed}{detail}"); + } + + /// + /// Same information as , but as one entry per step instead of a + /// single multi-line block. Intended for callers (e.g. EnrollmentResult.EnrollmentContext) + /// whose rendering surface displays a dictionary as a bulleted list and doesn't respect + /// embedded newlines - each step becomes its own bullet instead of one run-on line. + /// + public Dictionary GetSummaryEntries() + { + var allSteps = _steps.Concat(_steps.SelectMany(s => s.Children)).ToList(); + var hasFailures = allSteps.Any(s => s.Status == FlowStepStatus.Failed); + var overallStatus = hasFailures ? "FAILED" : "OK"; + var succeeded = allSteps.Count(s => s.Status == FlowStepStatus.Success); + var failed = allSteps.Count(s => s.Status == FlowStepStatus.Failed); + var skipped = allSteps.Count(s => s.Status == FlowStepStatus.Skipped); + + var entries = new Dictionary + { + [$"Flow: {_flowName}"] = + $"[{overallStatus}] {_totalTimer.ElapsedMilliseconds}ms total - " + + $"{allSteps.Count} steps ({succeeded} ok, {failed} failed, {skipped} skipped)" + }; + + var stepNumber = 0; + foreach (var step in _steps) + { + stepNumber++; + AddSummaryEntry(entries, step, stepNumber, false); + + foreach (var child in step.Children) + { + stepNumber++; + AddSummaryEntry(entries, child, stepNumber, true); + } + } + + return entries; + } + + private static void AddSummaryEntry(Dictionary entries, FlowStep step, int stepNumber, bool indent) + { + var icon = GetStatusIcon(step.Status); + var time = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" - {step.Detail}" : ""; + var prefix = indent ? " " : ""; + entries[$"Flow Step {stepNumber:00}: {prefix}{step.Name}"] = $"{icon}{time}{detail}"; + } + private static string GetStatusIcon(FlowStepStatus status) { return status switch diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 00522b0..281e317 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -422,59 +422,6 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI public bool IsKnownProductId(string productId) => !string.IsNullOrEmpty(productId) && ProductIdToCodeMap.ContainsKey(productId); - // 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", - ["CSC TrustedSecure OV"] = "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", - ["CSC TrustedSecure OV Wildcard"] = "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", - ["CSC TrustedSecure OV, Multiple Names"] = "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", - ["CSC TrustedSecure EV"] = "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", - ["CSC TrustedSecure DV"] = "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", - ["CSC TrustedSecure DV Wildcard"] = "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", - ["CSC TrustedSecure DV, Multiple Names"] = "CSC Trusted Secure Domain Validated UC Certificate", - // EV, Multiple Names — new in 1.2.0, no legacy name - ["7"] = "CSC TrustedSecure EV, Multiple Names", - ["CSC TrustedSecure EV, Multiple Names"] = "CSC TrustedSecure EV, Multiple Names", - // OV Wildcard, Multiple Names — new in 1.2.0, no legacy name - ["8"] = "CSC TrustedSecure OV Wildcard, Multiple Names", - ["CSC TrustedSecure OV Wildcard, Multiple Names"] = "CSC TrustedSecure OV Wildcard, Multiple Names", - // DV Wildcard, Multiple Names — new in 1.2.0, no legacy name - ["9"] = "CSC TrustedSecure DV Wildcard, Multiple Names", - ["CSC TrustedSecure DV Wildcard, Multiple Names"] = "CSC TrustedSecure DV Wildcard, Multiple Names", - }; - private string GetCertificateType(string productId) { Logger.LogTrace("GetCertificateType: productId='{ProductId}'", productId ?? "(null)");