From 7981892838b4e8ece254da359d1c2597f55efc71 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 19 Jun 2026 21:03:10 +0200 Subject: [PATCH] Add opt-out for publish-on-validation-error failure (3.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This forward-ports the FailOnValidationErrors publish toggle to the 3.8 mainline. It is purely additive and changes no default behavior: publishing a workflow definition still fails when validation errors are present (FailOnValidationErrors defaults to true, i.e. strict = opt-out). Setting FailOnValidationErrors to false — via WorkflowManagementFeature.UseFailOnValidationErrors(false) or the ManagementOptions.FailOnValidationErrors option — allows publication to succeed while surfacing the validation errors as warnings on the publish result. This enables publishing workflows that intentionally leave required properties blank (for example, an empty Cron expression used to disable a trigger). This is the 3.8 counterpart to #7740, which introduces the same toggle as opt-in (default false) on the 3.6.x line. References #7738. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Features/WorkflowManagementFeature.cs | 13 +++++++ .../Options/ManagementOptions.cs | 10 +++++ .../Services/WorkflowDefinitionPublisher.cs | 7 +++- .../ImportAndPublishCronTests.cs | 30 +++++++++++++- .../ImportAndPublishHttpEndpointsTests.cs | 39 +++++++++++++++++++ 5 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs index 0a26da083..c544636ee 100644 --- a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs @@ -61,6 +61,7 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module) private string CompressionAlgorithm { get; set; } = nameof(None); private LogPersistenceMode LogPersistenceMode { get; set; } = LogPersistenceMode.Include; private bool IsReadOnlyMode { get; set; } + private bool FailOnValidationErrors { get; set; } = true; /// /// A set of activity types to make available to the system. @@ -219,6 +220,17 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module) return this; } + /// + /// Enables or disables failing workflow publication when the workflow has validation errors. + /// Defaults to true as of 3.8.0 (publication fails when validation errors are present). + /// Set to false to allow publication to succeed, returning validation errors as warnings. + /// + public WorkflowManagementFeature UseFailOnValidationErrors(bool enabled = true) + { + FailOnValidationErrors = enabled; + return this; + } + public WorkflowManagementFeature UseWorkflowDefinitionPublisher(Func workflowDefinitionPublisher) { _workflowDefinitionPublisher = workflowDefinitionPublisher; @@ -313,6 +325,7 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module) options.CompressionAlgorithm = CompressionAlgorithm; options.LogPersistenceMode = LogPersistenceMode; options.IsReadOnlyMode = IsReadOnlyMode; + options.FailOnValidationErrors = FailOnValidationErrors; }); Services.Configure(options => options.RegisterTypeAlias(typeof(ClrWorkflowMaterializerContext), nameof(ClrWorkflowMaterializerContext))); diff --git a/src/modules/Elsa.Workflows.Management/Options/ManagementOptions.cs b/src/modules/Elsa.Workflows.Management/Options/ManagementOptions.cs index aca77eb19..53e2f4751 100644 --- a/src/modules/Elsa.Workflows.Management/Options/ManagementOptions.cs +++ b/src/modules/Elsa.Workflows.Management/Options/ManagementOptions.cs @@ -32,4 +32,14 @@ public class ManagementOptions /// A mode that does not allow editing workflows. /// public bool IsReadOnlyMode { get; set; } + + /// + /// Determines whether publishing a workflow definition fails when the workflow has validation errors. + /// When true (the default as of 3.8.0), publishing fails if any validation errors are present. + /// When false, publishing is allowed to succeed and any validation errors are returned as warnings + /// on the publish result. This allows publishing workflows that intentionally leave required properties + /// blank (for example, an empty Cron expression used to disable a trigger). In 3.6.x and 3.7.x this + /// defaulted to false (opt-in); as of 3.8.0 it defaults to true (opt-out). + /// + public bool FailOnValidationErrors { get; set; } = true; } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs index 007fc855c..30afc6d7a 100644 --- a/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs +++ b/src/modules/Elsa.Workflows.Management/Services/WorkflowDefinitionPublisher.cs @@ -8,7 +8,9 @@ using Elsa.Workflows.Management.Filters; using Elsa.Workflows.Management.Materializers; using Elsa.Workflows.Management.Models; using Elsa.Workflows.Management.Notifications; +using Elsa.Workflows.Management.Options; using Elsa.Workflows.Models; +using Microsoft.Extensions.Options; namespace Elsa.Workflows.Management.Services; @@ -20,7 +22,8 @@ public class WorkflowDefinitionPublisher( IIdentityGenerator identityGenerator, IActivitySerializer activitySerializer, IMediator mediator, - ISystemClock systemClock) + ISystemClock systemClock, + IOptions options) : IWorkflowDefinitionPublisher { /// @@ -87,7 +90,7 @@ public class WorkflowDefinitionPublisher( var workflowGraph = await workflowDefinitionService.MaterializeWorkflowAsync(definition, cancellationToken); var validationErrors = (await workflowValidator.ValidateAsync(workflowGraph.Workflow, cancellationToken)).ToList(); - if (validationErrors.Any()) + if (validationErrors.Any() && options.Value.FailOnValidationErrors) return new(false, validationErrors, new([])); await mediator.SendAsync(new WorkflowDefinitionPublishing(definition), cancellationToken); diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImportAndPublish/ImportAndPublishCronTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImportAndPublish/ImportAndPublishCronTests.cs index e61fa08b1..2d41f788e 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImportAndPublish/ImportAndPublishCronTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImportAndPublish/ImportAndPublishCronTests.cs @@ -1,4 +1,5 @@ -using Elsa.Testing.Shared; +using Elsa.Extensions; +using Elsa.Testing.Shared; using Elsa.Workflows.Management; using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; @@ -8,10 +9,12 @@ namespace Elsa.Workflows.IntegrationTests.Scenarios.ImportAndPublish; public class ImportAndPublishCronTests { private readonly CapturingTextWriter _capturingTextWriter = new(); + private readonly ITestOutputHelper _testOutputHelper; private readonly IServiceProvider _services; public ImportAndPublishCronTests(ITestOutputHelper testOutputHelper) { + _testOutputHelper = testOutputHelper; _services = new TestApplicationBuilder(testOutputHelper) .WithCapturingTextWriter(_capturingTextWriter) .Build(); @@ -53,4 +56,29 @@ public class ImportAndPublishCronTests Assert.Single(result.ValidationErrors); Assert.Equal("Error when parsing cron expression: The given cron expression has an invalid format. Seconds: Value must be a number between 0 and 59 (all inclusive).", result.ValidationErrors.Single().Message); } + + [Fact(DisplayName = "Cron workflow with bad cron expression should publish successfully when FailOnValidationErrors is disabled.")] + public async Task ImportAndPublish_ShouldSucceed_WithBadCronExpression_WhenFailOnValidationErrorsDisabled() + { + // Opt out of strict publishing. + var services = new TestApplicationBuilder(_testOutputHelper) + .WithCapturingTextWriter(_capturingTextWriter) + .ConfigureElsa(elsa => elsa.UseWorkflowManagement(management => management.UseFailOnValidationErrors(false))) + .Build(); + + // Populate registries. + await services.PopulateRegistriesAsync(); + + // Import workflow. + var workflowDefinition = await services.ImportWorkflowDefinitionAsync($"Scenarios/ImportAndPublish/Workflows/bad-cron-expression.json"); + + // Publish. + IWorkflowDefinitionPublisher workflowDefinitionPublisher = services.GetRequiredService(); + var result = await workflowDefinitionPublisher.PublishAsync(workflowDefinition); + + // Assert: publishing succeeds while the validation error is surfaced as a warning. + Assert.True(result.Succeeded); + Assert.Single(result.ValidationErrors); + Assert.Equal("Error when parsing cron expression: The given cron expression has an invalid format. Seconds: Value must be a number between 0 and 59 (all inclusive).", result.ValidationErrors.Single().Message); + } } \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImportAndPublish/ImportAndPublishHttpEndpointsTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImportAndPublish/ImportAndPublishHttpEndpointsTests.cs index d1b5a322e..29d127f92 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImportAndPublish/ImportAndPublishHttpEndpointsTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/ImportAndPublish/ImportAndPublishHttpEndpointsTests.cs @@ -9,10 +9,12 @@ namespace Elsa.Workflows.IntegrationTests.Scenarios.ImportAndPublish; public class ImportAndPublishHttpEndpointsTests { private readonly CapturingTextWriter _capturingTextWriter = new(); + private readonly ITestOutputHelper _testOutputHelper; private readonly IServiceProvider _services; public ImportAndPublishHttpEndpointsTests(ITestOutputHelper testOutputHelper) { + _testOutputHelper = testOutputHelper; _services = new TestApplicationBuilder(testOutputHelper) .WithCapturingTextWriter(_capturingTextWriter) .ConfigureElsa(configure => configure.UseHttp()) @@ -65,4 +67,41 @@ public class ImportAndPublishHttpEndpointsTests Assert.Single(result.ValidationErrors); Assert.Equal("The /test path and get method are already in use by another workflow!", result.ValidationErrors.Single().Message); } + + [Fact(DisplayName = "Http endpoint workflow with duplicate path and method should publish successfully when FailOnValidationErrors is disabled.")] + public async Task ImportAndPublish_ShouldSucceed_WithTwoHttpEndpointSamePathMethod_WhenFailOnValidationErrorsDisabled() + { + // Opt out of strict publishing. + var services = new TestApplicationBuilder(_testOutputHelper) + .WithCapturingTextWriter(_capturingTextWriter) + .ConfigureElsa(configure => configure + .UseHttp() + .UseWorkflowManagement(management => management.UseFailOnValidationErrors(false))) + .Build(); + + // Populate registries. + await services.PopulateRegistriesAsync(); + + // Import first workflow. + var workflowDefinition = await services.ImportWorkflowDefinitionAsync($"Scenarios/ImportAndPublish/Workflows/http-workflow.json"); + + // Publish first workflow. + IWorkflowDefinitionPublisher workflowDefinitionPublisher = services.GetRequiredService(); + var result = await workflowDefinitionPublisher.PublishAsync(workflowDefinition); + + // Assert first workflow. + Assert.True(result.Succeeded); + Assert.Empty(result.ValidationErrors); + + // Import second workflow. + workflowDefinition = await services.ImportWorkflowDefinitionAsync($"Scenarios/ImportAndPublish/Workflows/http-workflow.json"); + + // Publish second workflow. + result = await workflowDefinitionPublisher.PublishAsync(workflowDefinition); + + // Assert: publishing succeeds while the validation error is surfaced as a warning. + Assert.True(result.Succeeded); + Assert.Single(result.ValidationErrors); + Assert.Equal("The /test path and get method are already in use by another workflow!", result.ValidationErrors.Single().Message); + } } \ No newline at end of file