diff --git a/src/common/Elsa.Testing.Shared/TestApplicationBuilder.cs b/src/common/Elsa.Testing.Shared/TestApplicationBuilder.cs index 9b823c071..7b17247c9 100644 --- a/src/common/Elsa.Testing.Shared/TestApplicationBuilder.cs +++ b/src/common/Elsa.Testing.Shared/TestApplicationBuilder.cs @@ -100,6 +100,14 @@ public class TestApplicationBuilder return ConfigureElsa(elsa => elsa.AddWorkflow()); } + /// + /// Adds activities from the assembly containing the specified type. + /// + public TestApplicationBuilder AddActivitiesFrom() + { + return ConfigureElsa(elsa => elsa.AddActivitiesFrom()); + } + /// /// Add workflows from the specified relative directory. /// diff --git a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs index e674b0266..c6b3655e5 100644 --- a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs +++ b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs @@ -92,6 +92,30 @@ public class HttpEndpoint : Trigger [Input(Description = "Only MIME types in this list are allowed. Leave empty to allow all types", Category = "Upload", UIHint = InputUIHints.MultiText)] public Input> AllowedMimeTypes { get; set; } = default!; + /// + /// A value indicating whether to expose the "Request too large" outcome. + /// + [Input(Description = "A value indicating whether to expose the \"Request too large\" outcome.", Category = "Outcomes")] + public bool ExposeRequestTooLargeOutcome { get; set; } + + /// + /// A value indicating whether to expose the "File too large" outcome. + /// + [Input(Description = "A value indicating whether to expose the \"File too large\" outcome.", Category = "Outcomes")] + public bool ExposeFileTooLargeOutcome { get; set; } + + /// + /// A value indicating whether to expose the "Invalid file extension" outcome. + /// + [Input(Description = "A value indicating whether to expose the \"Invalid file extension\" outcome.", Category = "Outcomes")] + public bool ExposeInvalidFileExtensionOutcome { get; set; } + + /// + /// A value indicating whether to expose the "Invalid file MIME type" outcome. + /// + [Input(Description = "A value indicating whether to expose the \"Invalid file MIME type\" outcome.", Category = "Outcomes")] + public bool ExposeInvalidFileMimeTypeOutcome { get; set; } + /// /// The parsed request content, if any. /// @@ -184,23 +208,45 @@ public class HttpEndpoint : Trigger context.Set(RouteData, routeDictionary); context.Set(QueryStringData, queryStringDictionary); context.Set(Headers, headersDictionary); + + // Validate request size. + if (!ValidateRequestSize(context, httpContext)) + { + await HandleRequestTooLargeAsync(context, httpContext); + return; + } // Read files, if any. var files = ReadFilesAsync(context, request); - if (!await ValidateFileSizesAsync(context, httpContext, files)) - return; + if (files.Any()) + { + if (!ValidateFileSizes(context, httpContext, files)) + { + await HandleFileSizeTooLargeAsync(context, httpContext); + return; + } - if (!await ValidateFileExtensionWhitelistAsync(context, httpContext, files)) - return; + if (!ValidateFileExtensionWhitelist(context, httpContext, files)) + { + await HandleInvalidFileExtensionWhitelistAsync(context, httpContext); + return; + } - if (!await ValidateFileExtensionBlacklistAsync(context, httpContext, files)) - return; + if (!ValidateFileExtensionBlacklist(context, httpContext, files)) + { + await HandleInvalidFileExtensionBlacklistAsync(context, httpContext); + return; + } - if (!await ValidateFileMimeTypesAsync(context, httpContext, files)) - return; + if (!ValidateFileMimeTypes(context, httpContext, files)) + { + await HandleInvalidFileMimeTypesAsync(context, httpContext); + return; + } - Files.Set(context, files.ToArray()); + Files.Set(context, files.ToArray()); + } // Read content, if any. var content = await ParseContentAsync(context, request); @@ -215,9 +261,39 @@ public class HttpEndpoint : Trigger return request.HasFormContentType ? request.Form.Files : new FormFileCollection(); } - private async Task ValidateFileSizesAsync(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files) + private bool ValidateRequestSize(ActivityExecutionContext context, HttpContext httpContext) + { + var requestSizeLimit = RequestSizeLimit.GetOrDefault(context); + + if (!requestSizeLimit.HasValue) + return true; + + var requestSize = httpContext.Request.ContentLength ?? 0; + return requestSize <= requestSizeLimit; + } + + private async Task HandleRequestTooLargeAsync(ActivityExecutionContext context, HttpContext httpContext) + { + var exposeRequestTooLargeOutcome = ExposeRequestTooLargeOutcome; + + if (exposeRequestTooLargeOutcome) + { + await context.CompleteActivityWithOutcomesAsync("Request too large"); + } + else + { + var response = httpContext.Response; + response.StatusCode = StatusCodes.Status413PayloadTooLarge; + await response.WriteAsJsonAsync(new + { + Message = $"The maximum request size allowed is {RequestSizeLimit.Get(context)} bytes." + }); + await response.Body.FlushAsync(); + } + } + + private bool ValidateFileSizes(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files) { - // Validate individual file sizes. var fileSizeLimit = FileSizeLimit.GetOrDefault(context); if (!fileSizeLimit.HasValue) @@ -226,18 +302,30 @@ public class HttpEndpoint : Trigger if (!files.Any(file => file.Length > fileSizeLimit.Value)) return true; - var response = httpContext.Response; - response.StatusCode = StatusCodes.Status413PayloadTooLarge; - await response.WriteAsJsonAsync(new - { - Message = $"The maximum file size allowed is {fileSizeLimit} bytes." - }); - await response.Body.FlushAsync(); - return false; } - private async Task ValidateFileExtensionWhitelistAsync(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files) + private async Task HandleFileSizeTooLargeAsync(ActivityExecutionContext context, HttpContext httpContext) + { + var exposeFileTooLargeOutcome = ExposeFileTooLargeOutcome; + + if (exposeFileTooLargeOutcome) + { + await context.CompleteActivityWithOutcomesAsync("File too large"); + } + else + { + var response = httpContext.Response; + response.StatusCode = StatusCodes.Status413PayloadTooLarge; + await response.WriteAsJsonAsync(new + { + Message = $"The maximum file size allowed is {FileSizeLimit.Get(context)} bytes." + }); + await response.Body.FlushAsync(); + } + } + + private bool ValidateFileExtensionWhitelist(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files) { var allowedFileExtensions = AllowedFileExtensions.GetOrDefault(context); @@ -247,18 +335,28 @@ public class HttpEndpoint : Trigger if (files.All(file => allowedFileExtensions.Contains(System.IO.Path.GetExtension(file.FileName), StringComparer.OrdinalIgnoreCase))) return true; + return false; + } + + private async Task HandleInvalidFileExtensionWhitelistAsync(ActivityExecutionContext context, HttpContext httpContext) + { + if (ExposeInvalidFileExtensionOutcome) + { + await context.CompleteActivityWithOutcomesAsync("Invalid file extension"); + return; + } + var response = httpContext.Response; + var allowedFileExtensions = AllowedFileExtensions.GetOrDefault(context)!; response.StatusCode = StatusCodes.Status415UnsupportedMediaType; await response.WriteAsJsonAsync(new { Message = $"Only the following file extensions are allowed: {string.Join(", ", allowedFileExtensions)}" }); await response.Body.FlushAsync(); - - return false; } - private async Task ValidateFileExtensionBlacklistAsync(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files) + private bool ValidateFileExtensionBlacklist(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files) { var blockedFileExtensions = BlockedFileExtensions.GetOrDefault(context); @@ -268,6 +366,18 @@ public class HttpEndpoint : Trigger if (!files.Any(file => blockedFileExtensions.Contains(System.IO.Path.GetExtension(file.FileName), StringComparer.OrdinalIgnoreCase))) return true; + return false; + } + + private async Task HandleInvalidFileExtensionBlacklistAsync(ActivityExecutionContext context, HttpContext httpContext) + { + if (ExposeInvalidFileExtensionOutcome) + { + await context.CompleteActivityWithOutcomesAsync("Invalid file extension"); + return; + } + + var blockedFileExtensions = BlockedFileExtensions.GetOrDefault(context)!; var response = httpContext.Response; response.StatusCode = StatusCodes.Status415UnsupportedMediaType; await response.WriteAsJsonAsync(new @@ -275,11 +385,9 @@ public class HttpEndpoint : Trigger Message = $"The following file extensions are not allowed: {string.Join(", ", blockedFileExtensions)}" }); await response.Body.FlushAsync(); - - return false; } - private async Task ValidateFileMimeTypesAsync(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files) + private bool ValidateFileMimeTypes(ActivityExecutionContext context, HttpContext httpContext, IFormFileCollection files) { var allowedMimeTypes = AllowedMimeTypes.GetOrDefault(context); @@ -289,6 +397,18 @@ public class HttpEndpoint : Trigger if (files.All(file => allowedMimeTypes.Contains(file.ContentType, StringComparer.OrdinalIgnoreCase))) return true; + return false; + } + + private async Task HandleInvalidFileMimeTypesAsync(ActivityExecutionContext context, HttpContext httpContext) + { + if(ExposeInvalidFileMimeTypeOutcome) + { + await context.CompleteActivityWithOutcomesAsync("Invalid file MIME type"); + return; + } + + var allowedMimeTypes = AllowedMimeTypes.GetOrDefault(context)!; var response = httpContext.Response; response.StatusCode = StatusCodes.Status415UnsupportedMediaType; await response.WriteAsJsonAsync(new @@ -296,8 +416,6 @@ public class HttpEndpoint : Trigger Message = $"Only the following MIME types are allowed: {string.Join(", ", allowedMimeTypes)}" }); await response.Body.FlushAsync(); - - return false; } private async Task ParseContentAsync(ActivityExecutionContext context, HttpRequest httpRequest) diff --git a/src/modules/Elsa.Http/Handlers/DefaultHttpEndpointFaultHandler.cs b/src/modules/Elsa.Http/Handlers/DefaultHttpEndpointFaultHandler.cs index f9be91839..2725d55b1 100644 --- a/src/modules/Elsa.Http/Handlers/DefaultHttpEndpointFaultHandler.cs +++ b/src/modules/Elsa.Http/Handlers/DefaultHttpEndpointFaultHandler.cs @@ -6,35 +6,20 @@ using Elsa.Workflows.Core.Contracts; namespace Elsa.Http.Handlers; -// ReSharper disable once ClassWithVirtualMembersNeverInherited.Global /// /// A default fault handler that writes information about the fault to the . /// -public class DefaultHttpEndpointFaultHandler : IHttpEndpointFaultHandler +public sealed class DefaultHttpEndpointFaultHandler : IHttpEndpointFaultHandler { - private readonly IApiSerializer _apiSerializer; - - /// - /// Initializes a new instance of the class. - /// - public DefaultHttpEndpointFaultHandler(IApiSerializer apiSerializer) - { - _apiSerializer = apiSerializer; - } - /// - public virtual async ValueTask HandleAsync(HttpEndpointFaultContext context) + public ValueTask HandleAsync(HttpEndpointFaultContext context) { var httpContext = context.HttpContext; - var workflowState = context.WorkflowState; var isTimeoutIncident = GetIsTimeoutFault(context); var statusCode = isTimeoutIncident ? StatusCodes.Status408RequestTimeout : StatusCodes.Status500InternalServerError; - - httpContext.Response.ContentType = MediaTypeNames.Application.Json; + httpContext.Response.StatusCode = statusCode; - - var faultedResponse = _apiSerializer.Serialize(workflowState); - await httpContext.Response.WriteAsync(faultedResponse, context.CancellationToken); + return ValueTask.CompletedTask; } private bool GetIsTimeoutFault(HttpEndpointFaultContext context) diff --git a/src/modules/Elsa.Http/Handlers/DetailedHttpEndpointFaultHandler.cs b/src/modules/Elsa.Http/Handlers/DetailedHttpEndpointFaultHandler.cs new file mode 100644 index 000000000..f75d44281 --- /dev/null +++ b/src/modules/Elsa.Http/Handlers/DetailedHttpEndpointFaultHandler.cs @@ -0,0 +1,47 @@ +using System.Net.Mime; +using Elsa.Http.Contracts; +using Elsa.Http.Models; +using Elsa.Workflows.Core.Contracts; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Http.Handlers; + +/// +/// A fault handler that writes detailed information about the fault to the . +/// +public sealed class DetailedHttpEndpointFaultHandler : IHttpEndpointFaultHandler +{ + private readonly IApiSerializer _apiSerializer; + + /// + /// Initializes a new instance of the class. + /// + public DetailedHttpEndpointFaultHandler(IApiSerializer apiSerializer) + { + _apiSerializer = apiSerializer; + } + + /// + public async ValueTask HandleAsync(HttpEndpointFaultContext context) + { + var httpContext = context.HttpContext; + var workflowState = context.WorkflowState; + var isTimeoutIncident = GetIsTimeoutFault(context); + var statusCode = isTimeoutIncident ? StatusCodes.Status408RequestTimeout : StatusCodes.Status500InternalServerError; + + httpContext.Response.ContentType = MediaTypeNames.Application.Json; + httpContext.Response.StatusCode = statusCode; + + var faultedResponse = _apiSerializer.Serialize(workflowState); + await httpContext.Response.WriteAsync(faultedResponse, context.CancellationToken); + } + + private bool GetIsTimeoutFault(HttpEndpointFaultContext context) + { + var workflowState = context.WorkflowState; + var exceptionTypes = new[] { typeof(OperationCanceledException), typeof(TaskCanceledException), typeof(TimeoutException) }; + var timeoutIncident = workflowState.Incidents.FirstOrDefault(x => exceptionTypes.Contains(x.Exception?.Type)); + + return timeoutIncident != null; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs b/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs index 901d04d81..90293274c 100644 --- a/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs +++ b/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs @@ -132,23 +132,7 @@ public class WorkflowsMiddleware // Get settings from the bookmark payload. var foundBookmarkPayload = matchedWorkflow.Payload as HttpEndpointBookmarkPayload; - - // Get the configured request size limit, if any. - var requestSizeLimit = foundBookmarkPayload?.RequestSizeLimit; - - if (requestSizeLimit != null) - { - // If a request size limit was configured, check if the request size exceeds the limit. - var requestSize = request.ContentLength ?? 0; - - if (requestSize > requestSizeLimit) - { - httpContext.Response.StatusCode = 413; // Payload Too Large. - await httpContext.Response.WriteAsJsonAsync(new { message = $"The request size exceeds the configured limit of {requestSizeLimit} bytes." }, cancellationToken: cancellationToken); - return; - } - } - + // Get the configured request timeout, if any. var requestTimeout = foundBookmarkPayload?.RequestTimeout; diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs index 0ebf3ce59..c5ec964e5 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs @@ -180,17 +180,13 @@ public class Flowchart : Container var result = context.Result; logger.LogDebug("Child activity {ActivityId} completed with status {ActivityStatus}", completedActivity.Id, completedActivityContext.Status); - + // If the complete activity's status is anything but "Completed", do not schedule its outbound activities. var scheduleChildren = completedActivityContext.Status == ActivityStatus.Completed; - - // If specific outcomes were provided by the completed activity, use them to find the connection to the next activity. - Func outboundConnectionsQuery = result is Outcomes outcomes - ? connection => connection.Source.Activity == completedActivity && outcomes.Names.Contains(connection.Source.Port) - : connection => connection.Source.Activity == completedActivity; + var outcomeNames = result is Outcomes outcomes ? outcomes.Names : new[] { default(string), "Done" }; // Only query the outbound connections if the completed activity wasn't already completed. - var outboundConnections = Connections.Where(outboundConnectionsQuery).ToList(); + var outboundConnections = Connections.Where(connection => connection.Source.Activity == completedActivity && outcomeNames.Contains(connection.Source.Port)).ToList(); var children = outboundConnections.Select(x => x.Target.Activity).ToList(); var scope = flowchartContext.GetProperty(ScopeProperty, () => new FlowScope()); @@ -206,11 +202,11 @@ public class Flowchart : Container { if (children.Any()) { - if(children.Count == 1) + if (children.Count == 1) logger.LogDebug("Found 1 child for activity {ActivityId}: {ChildActivityId}", completedActivity.Id, children.First().Id); else logger.LogDebug("Found {Count} children for activity {ActivityId}: {ChildActivityIds}", children.Count, completedActivity.Id, children.Select(x => x.Id).ToList()); - + scope.AddActivities(children); // Schedule each child, but only if all of its left inbound activities have already executed. @@ -248,12 +244,12 @@ public class Flowchart : Container ReuseActivityExecutionContextId = joinContext?.Id, PreventDuplicateScheduling = true }; - - if(joinContext != null) + + if (joinContext != null) logger.LogDebug("Next activity {ChildActivityId} is a join activity. Attaching to existing context {JoinContext}", activity.Id, joinContext.Id); else logger.LogDebug("Next activity {ChildActivityId} is a join activity", activity.Id); - + logger.LogDebug("Scheduling child activity {ChildActivityId}", activity.Id); await flowchartContext.ScheduleActivityAsync(activity, scheduleWorkOptions); } @@ -263,7 +259,7 @@ public class Flowchart : Container if (!children.Any()) { logger.LogDebug("No children found for activity {ActivityId}", completedActivity.Id); - + // If there is no pending work, complete the flowchart activity. var hasPendingWork = HasPendingWork(flowchartContext); diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/ConnectionComparer.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/ConnectionComparer.cs index 4c1d84b95..bb01e9adc 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/ConnectionComparer.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/ConnectionComparer.cs @@ -20,6 +20,9 @@ public class ConnectionComparer : IEqualityComparer /// public int GetHashCode(Connection obj) { - return HashCode.Combine(obj.Source.Activity.Id, obj.Target.Activity.Id, obj.Source.Port, obj.Target.Port); + // ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract + // Justification: These can be null when the designer is in an invalid state. For example, if it used a NotFoundActivity that no longer has the same outcomes. + return HashCode.Combine(obj.Source?.Activity?.Id, obj.Target?.Activity?.Id, obj.Source?.Port, obj.Target?.Port); + // ReSharper restore ConditionalAccessQualifierIsNonNullableAccordingToAPIContract } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs index 34d36b681..197477068 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs @@ -35,6 +35,8 @@ public class DefaultActivityInvokerMiddleware : IActivityExecutionMiddleware /// public async ValueTask InvokeAsync(ActivityExecutionContext context) { + context.CancellationToken.ThrowIfCancellationRequested(); + var workflowExecutionContext = context.WorkflowExecutionContext; // Evaluate input properties. diff --git a/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj b/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj index fea91ccf7..ae877f081 100644 --- a/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj +++ b/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj @@ -122,6 +122,8 @@ + + diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/FlowchartNextActivity/Activities/CustomActivity.cs b/test/integration/Elsa.IntegrationTests/Scenarios/FlowchartNextActivity/Activities/CustomActivity.cs new file mode 100644 index 000000000..bd0530934 --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/FlowchartNextActivity/Activities/CustomActivity.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading.Tasks; +using Elsa.Extensions; +using Elsa.Workflows.Core; + +namespace Elsa.IntegrationTests.Scenarios.FlowchartNextActivity.Activities; + +public class CustomActivity : CodeActivity +{ + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + await context.CompleteActivityAsync(); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs b/test/integration/Elsa.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs new file mode 100644 index 000000000..9e115e259 --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs @@ -0,0 +1,37 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Elsa.IntegrationTests.Scenarios.FlowchartNextActivity.Workflows; +using Elsa.Testing.Shared; +using Elsa.Workflows.Core.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Xunit.Abstractions; + +namespace Elsa.IntegrationTests.Scenarios.FlowchartNextActivity; + +public class FlowchartNextActivityTests +{ + private readonly CapturingTextWriter _capturingTextWriter = new(); + private readonly IServiceProvider _services; + private readonly IWorkflowRunner _workflowRunner; + + public FlowchartNextActivityTests(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper) + .WithCapturingTextWriter(_capturingTextWriter) + .AddActivitiesFrom() + .Build(); + + _workflowRunner = _services.GetRequiredService(); + } + + [Fact(DisplayName = "Flowchart only schedules next activity connected to outcome of previous activity.")] + public async Task Test1() + { + await _services.PopulateRegistriesAsync(); + await _workflowRunner.RunAsync(); + var lines = _capturingTextWriter.Lines.ToList(); + Assert.Equal(new[] { "Line 1" }, lines); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/FlowchartNextActivity/Workflows/FlowchartWorkflow.cs b/test/integration/Elsa.IntegrationTests/Scenarios/FlowchartNextActivity/Workflows/FlowchartWorkflow.cs new file mode 100644 index 000000000..916b335a9 --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/FlowchartNextActivity/Workflows/FlowchartWorkflow.cs @@ -0,0 +1,33 @@ +using Elsa.IntegrationTests.Scenarios.FlowchartNextActivity.Activities; +using Elsa.Workflows.Core; +using Elsa.Workflows.Core.Activities; +using Elsa.Workflows.Core.Activities.Flowchart.Activities; +using Elsa.Workflows.Core.Activities.Flowchart.Models; +using Elsa.Workflows.Core.Contracts; + +namespace Elsa.IntegrationTests.Scenarios.FlowchartNextActivity.Workflows; + +public class FlowchartWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + var customActivity = new CustomActivity(); + var writeLine1 = new WriteLine("Line 1"); + var writeLine2 = new WriteLine("Line 2"); + + builder.Root = new Flowchart + { + Activities = + { + customActivity, + writeLine1, + writeLine2 + }, + Connections = + { + new Connection(new Endpoint(customActivity, "Done"), new Endpoint(writeLine1)), + new Connection(new Endpoint(customActivity, "Fake"), new Endpoint(writeLine2)), + } + }; + } +} \ No newline at end of file