Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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..];
}

Expand Down Expand Up @@ -252,13 +257,16 @@ private async Task<T> ExecuteWithinTimeoutAsync<T>(Func<CancellationToken, Task<
// Replace the original cancellation token with the combined one.
httpContext.RequestAborted = combinedTokenSource.Token;

// Execute the action.
var result = await action(httpContext.RequestAborted);

// Restore the original cancellation token.
httpContext.RequestAborted = originalCancellationToken;

return result;
try
{
// Execute the action.
return await action(httpContext.RequestAborted);
}
finally
{
// Restore the original cancellation token.
httpContext.RequestAborted = originalCancellationToken;
}
}

private HttpRouteData GetMatchingRoute(IServiceProvider serviceProvider, string path)
Expand Down Expand Up @@ -359,8 +367,9 @@ private async Task<bool> HandleWorkflowFaultAsync(IServiceProvider serviceProvid

var httpEndpointFaultHandler = serviceProvider.GetRequiredService<IHttpEndpointFaultHandler>();
var workflowInstanceManager = serviceProvider.GetRequiredService<IWorkflowInstanceManager>();
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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotFoundActivity>()!;
var notFoundActivityDescriptor = activityRegistry.Find<NotFoundActivity>();

if (notFoundActivityDescriptor == null)
throw new JsonException($"Could not deserialize activity type '{activityTypeName}' because the '{nameof(NotFoundActivity)}' descriptor is not registered.");

var notFoundActivityResult = JsonActivityConstructorContextHelper.CreateActivity<NotFoundActivity>(notFoundActivityDescriptor, activityRoot, clonedOptions);
LogExceptionsIfAny(notFoundActivityResult);

Expand Down Expand Up @@ -173,4 +177,4 @@ private JsonSerializerOptions GetClonedWriterOptions(JsonSerializerOptions optio
clonedOptions.Converters.Add(new JsonIgnoreCompositeRootConverterFactory(serviceProvider.GetRequiredService<ActivityWriter>()));
return clonedOptions;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<IRouteMatcher, ThrowingRouteMatcher>()
.AddSingleton<IRouteTable>(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<InvalidOperationException>(() => InvokeExecuteWithinTimeoutAsync<object>(
_ => 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<IWorkflowInstanceManager>();
var faultHandler = Substitute.For<IHttpEndpointFaultHandler>();
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<CancellationToken>()).Returns((WorkflowInstance?)null);

var handled = await InvokeHandleWorkflowFaultAsync(serviceProvider, httpContext, result, CancellationToken.None);

Assert.True(handled);
await faultHandler.Received(1).HandleAsync(Arg.Is<HttpEndpointFaultContext>(x => ReferenceEquals(x.WorkflowState, workflowState)));
}

private static IEnumerable<StoredBookmark> CreateCollidingHttpEndpointBookmarks()
{
yield return CreateBookmark("current-tenant-bookmark", CurrentTenantId);
Expand Down Expand Up @@ -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<HttpRouteData> routes) : IRouteTable
{
private readonly ICollection<HttpRouteData> _routes = routes.ToList();
Expand Down Expand Up @@ -170,4 +258,17 @@ public void RemoveRange(IEnumerable<string> routes)
Remove(route);
}
}

private Task<T> InvokeExecuteWithinTimeoutAsync<T>(Func<CancellationToken, Task<T>> action, TimeSpan? requestTimeout, HttpContext httpContext)
{
var method = typeof(HttpWorkflowsMiddleware).GetMethod("ExecuteWithinTimeoutAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
var genericMethod = method.MakeGenericMethod(typeof(T));
return (Task<T>)genericMethod.Invoke(_middleware, [action, requestTimeout, httpContext])!;
}

private Task<bool> InvokeHandleWorkflowFaultAsync(IServiceProvider serviceProvider, HttpContext httpContext, RunWorkflowResult result, CancellationToken cancellationToken)
{
var method = typeof(HttpWorkflowsMiddleware).GetMethod("HandleWorkflowFaultAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
return (Task<bool>)method.Invoke(_middleware, [serviceProvider, httpContext, result, cancellationToken])!;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<IActivityRegistry>();
var sut = CreateSut(activityRegistry);

// Act
var exception = Assert.Throws<JsonException>(() => 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()
{
Expand Down
Loading