diff --git a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs index 53d529d4c5..df61075b37 100644 --- a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs +++ b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs @@ -40,7 +40,6 @@ public async Task InvokeAsync( IHttpWorkflowLookupService httpWorkflowLookupService) { var path = httpContext.Request.Path.Value!.NormalizeRoute(); - var matchingPath = GetMatchingRoute(serviceProvider, path).Route; var basePath = options.Value.BasePath?.ToString().NormalizeRoute(); // If the request path does not match the configured base path to handle workflows, then skip. @@ -53,6 +52,12 @@ public async Task InvokeAsync( } // Strip the base path. + } + + var matchingPath = GetMatchingRoute(serviceProvider, path).Route; + + if (!string.IsNullOrWhiteSpace(basePath)) + { matchingPath = matchingPath[basePath.Length..]; } @@ -252,13 +257,16 @@ private async Task ExecuteWithinTimeoutAsync(Func HandleWorkflowFaultAsync(IServiceProvider serviceProvid var httpEndpointFaultHandler = serviceProvider.GetRequiredService(); var workflowInstanceManager = serviceProvider.GetRequiredService(); - var workflowState = (await workflowInstanceManager.FindByIdAsync(workflowExecutionResult.WorkflowState.Id, cancellationToken))!; - await httpEndpointFaultHandler.HandleAsync(new(httpContext, workflowState.WorkflowState, cancellationToken)); + var workflowInstance = await workflowInstanceManager.FindByIdAsync(workflowExecutionResult.WorkflowState.Id, cancellationToken); + var workflowState = workflowInstance?.WorkflowState ?? workflowExecutionResult.WorkflowState; + await httpEndpointFaultHandler.HandleAsync(new(httpContext, workflowState, cancellationToken)); return true; } diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs index 29da39c2f7..6d0412774e 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs @@ -42,7 +42,11 @@ public override IActivity Read(ref Utf8JsonReader reader, Type typeToConvert, Js // If the activity type is not found, create a NotFoundActivity instead. if (activityDescriptor == null) { - var notFoundActivityDescriptor = activityRegistry.Find()!; + var notFoundActivityDescriptor = activityRegistry.Find(); + + if (notFoundActivityDescriptor == null) + throw new JsonException($"Could not deserialize activity type '{activityTypeName}' because the '{nameof(NotFoundActivity)}' descriptor is not registered."); + var notFoundActivityResult = JsonActivityConstructorContextHelper.CreateActivity(notFoundActivityDescriptor, activityRoot, clonedOptions); LogExceptionsIfAny(notFoundActivityResult); @@ -173,4 +177,4 @@ private JsonSerializerOptions GetClonedWriterOptions(JsonSerializerOptions optio clonedOptions.Converters.Add(new JsonIgnoreCompositeRootConverterFactory(serviceProvider.GetRequiredService())); return clonedOptions; } -} \ No newline at end of file +} diff --git a/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs b/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs index 529b3c2263..8738b85abe 100644 --- a/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs +++ b/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs @@ -1,14 +1,22 @@ using System.Collections; +using System.Reflection; using Elsa.Http.Bookmarks; +using Elsa.Http.Extensions; using Elsa.Http.Middleware; using Elsa.Http.Options; using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Models; using Elsa.Workflows.Runtime; using Elsa.Workflows.Runtime.Entities; using Elsa.Workflows.Runtime.Filters; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Elsa.Workflows.State; namespace Elsa.Http.UnitTests.Middleware; @@ -54,6 +62,81 @@ await _middleware.InvokeAsync( Assert.False(filter.TenantAgnostic); } + [Fact] + public async Task InvokeAsync_WithNonElsaBasePath_SkipsRouteMatching() + { + var nextCalled = false; + var middleware = new HttpWorkflowsMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + var serviceProvider = new ServiceCollection() + .AddSingleton() + .AddSingleton(new ListRouteTable([])) + .BuildServiceProvider(); + var httpContext = new DefaultHttpContext + { + RequestServices = serviceProvider + }; + + httpContext.Request.Path = "/non-elsa"; + httpContext.Request.Method = HttpMethod.Get.Method; + + await middleware.InvokeAsync( + httpContext, + serviceProvider, + Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = "/workflows" }), + new EmptyHttpWorkflowLookupService()); + + Assert.True(nextCalled); + } + + [Fact] + public async Task ExecuteWithinTimeoutAsync_WhenActionThrows_RestoresRequestAborted() + { + var httpContext = new DefaultHttpContext(); + using var originalCancellationTokenSource = new CancellationTokenSource(); + httpContext.RequestAborted = originalCancellationTokenSource.Token; + + await Assert.ThrowsAsync(() => InvokeExecuteWithinTimeoutAsync( + _ => throw new InvalidOperationException("Boom"), + TimeSpan.FromSeconds(1), + httpContext)); + + Assert.Equal(originalCancellationTokenSource.Token, httpContext.RequestAborted); + } + + [Fact] + public async Task HandleWorkflowFaultAsync_WhenReloadReturnsNull_FallsBackToInMemoryWorkflowState() + { + var workflowState = new WorkflowState + { + Id = "workflow-instance-1", + DefinitionId = "definition-1", + DefinitionVersionId = "definition-version-1", + Incidents = [new ActivityIncident()] + }; + var workflowInstanceManager = Substitute.For(); + var faultHandler = Substitute.For(); + var serviceProvider = new ServiceCollection() + .AddSingleton(workflowInstanceManager) + .AddSingleton(faultHandler) + .BuildServiceProvider(); + var httpContext = new DefaultHttpContext + { + RequestServices = serviceProvider + }; + var result = new RunWorkflowResult(null!, workflowState, new Workflow(), null, Journal.Empty); + + workflowInstanceManager.FindByIdAsync(workflowState.Id, Arg.Any()).Returns((WorkflowInstance?)null); + + var handled = await InvokeHandleWorkflowFaultAsync(serviceProvider, httpContext, result, CancellationToken.None); + + Assert.True(handled); + await faultHandler.Received(1).HandleAsync(Arg.Is(x => ReferenceEquals(x.WorkflowState, workflowState))); + } + private static IEnumerable CreateCollidingHttpEndpointBookmarks() { yield return CreateBookmark("current-tenant-bookmark", CurrentTenantId); @@ -140,6 +223,11 @@ private class ExactRouteMatcher : IRouteMatcher public RouteValueDictionary? Match(string routeTemplate, string route) => routeTemplate == route ? new() : null; } + private class ThrowingRouteMatcher : IRouteMatcher + { + public RouteValueDictionary? Match(string routeTemplate, string route) => throw new InvalidOperationException("Route matching should have been skipped."); + } + private class ListRouteTable(IEnumerable routes) : IRouteTable { private readonly ICollection _routes = routes.ToList(); @@ -170,4 +258,17 @@ public void RemoveRange(IEnumerable routes) Remove(route); } } + + private Task InvokeExecuteWithinTimeoutAsync(Func> action, TimeSpan? requestTimeout, HttpContext httpContext) + { + var method = typeof(HttpWorkflowsMiddleware).GetMethod("ExecuteWithinTimeoutAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; + var genericMethod = method.MakeGenericMethod(typeof(T)); + return (Task)genericMethod.Invoke(_middleware, [action, requestTimeout, httpContext])!; + } + + private Task InvokeHandleWorkflowFaultAsync(IServiceProvider serviceProvider, HttpContext httpContext, RunWorkflowResult result, CancellationToken cancellationToken) + { + var method = typeof(HttpWorkflowsMiddleware).GetMethod("HandleWorkflowFaultAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; + return (Task)method.Invoke(_middleware, [serviceProvider, httpContext, result, cancellationToken])!; + } } diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs index 7782aeb133..0c78d5b130 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs @@ -71,6 +71,20 @@ public void When_DeserializeUnknownActivity_Then_ReturnsNotFoundActivity() Assert.True(notFoundActivity.Metadata.ContainsKey("description")); } + [Fact] + public void When_DeserializeUnknownActivity_And_NotFoundDescriptorMissing_Then_ThrowsClearJsonException() + { + // Arrange + var activityRegistry = Substitute.For(); + var sut = CreateSut(activityRegistry); + + // Act + var exception = Assert.Throws(() => Execute(sut, UnknownActivityJson)); + + // Assert + Assert.Equal($"Could not deserialize activity type '{UnknownActivityTypeName}' because the '{nameof(NotFoundActivity)}' descriptor is not registered.", exception.Message); + } + [Fact] public void When_DeserializeWorkflowAsActivity_And_WorkflowDefinitionIdSpecified_Then_FindsAndInstantiatesActivity() {