Guard HTTP fault handling when workflow reload returns null (#7714)

* Guard HTTP fault handling when workflow reload returns null

* Add HTTP fault handler reload guard tests

* Address Greptile review feedback
This commit is contained in:
Sipke Schoorstra 2026-06-14 22:23:38 +02:00 committed by GitHub
parent 48a087e71e
commit 8f721e1ea2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 79 additions and 3 deletions

View file

@ -361,8 +361,8 @@ public class HttpWorkflowsMiddleware(RequestDelegate next)
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 workflowState = (await workflowInstanceManager.FindByIdAsync(workflowExecutionResult.WorkflowState.Id, cancellationToken))?.WorkflowState ?? workflowExecutionResult.WorkflowState;
await httpEndpointFaultHandler.HandleAsync(new(httpContext, workflowState, cancellationToken));
return true;
}

View file

@ -4,18 +4,24 @@ using Elsa.Http.Bookmarks;
using Elsa.Http.Middleware;
using Elsa.Http.Options;
using Elsa.Workflows;
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 Elsa.Workflows.State;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace Elsa.Http.UnitTests.Middleware;
public class HttpWorkflowsMiddlewareTests
{
private static readonly MethodInfo ExecuteWithinTimeoutAsyncMethod = typeof(HttpWorkflowsMiddleware).GetMethod("ExecuteWithinTimeoutAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
private static readonly MethodInfo HandleWorkflowFaultAsyncMethod = GetRequiredPrivateMethod("HandleWorkflowFaultAsync");
private static readonly MethodInfo ExecuteWithinTimeoutAsyncMethod = GetRequiredPrivateMethod("ExecuteWithinTimeoutAsync");
private const string CurrentTenantId = "tenant-a";
private const string OtherTenantId = "tenant-b";
private const string BookmarkHash = "http-endpoint:/colliding:get";
@ -56,6 +62,73 @@ public class HttpWorkflowsMiddlewareTests
Assert.False(filter.TenantAgnostic);
}
[Fact]
public async Task HandleWorkflowFaultAsync_UsesReloadedWorkflowState_WhenAvailable()
{
var workflowState = CreateFaultedWorkflowState("workflow-1");
var reloadedWorkflowState = CreateFaultedWorkflowState(workflowState.Id);
var workflowInstanceManager = Substitute.For<IWorkflowInstanceManager>();
var httpEndpointFaultHandler = Substitute.For<IHttpEndpointFaultHandler>();
var serviceProvider = new ServiceCollection()
.AddSingleton(workflowInstanceManager)
.AddSingleton(httpEndpointFaultHandler)
.BuildServiceProvider();
var httpContext = new DefaultHttpContext();
var workflowInstance = new WorkflowInstance
{
Id = workflowState.Id,
DefinitionId = workflowState.DefinitionId,
DefinitionVersionId = workflowState.DefinitionVersionId,
WorkflowState = reloadedWorkflowState
};
workflowInstanceManager.FindByIdAsync(workflowState.Id, Arg.Any<CancellationToken>()).Returns(Task.FromResult<WorkflowInstance?>(workflowInstance));
var handled = await HandleWorkflowFaultAsync(serviceProvider, httpContext, CreateRunWorkflowResult(workflowState), CancellationToken.None);
Assert.True(handled);
await httpEndpointFaultHandler.Received(1).HandleAsync(Arg.Is<HttpEndpointFaultContext>(context => ReferenceEquals(context.WorkflowState, reloadedWorkflowState)));
}
[Fact]
public async Task HandleWorkflowFaultAsync_FallsBackToExecutionResultState_WhenReloadReturnsNull()
{
var workflowState = CreateFaultedWorkflowState("workflow-2");
var workflowInstanceManager = Substitute.For<IWorkflowInstanceManager>();
var httpEndpointFaultHandler = Substitute.For<IHttpEndpointFaultHandler>();
var serviceProvider = new ServiceCollection()
.AddSingleton(workflowInstanceManager)
.AddSingleton(httpEndpointFaultHandler)
.BuildServiceProvider();
var httpContext = new DefaultHttpContext();
workflowInstanceManager.FindByIdAsync(workflowState.Id, Arg.Any<CancellationToken>()).Returns(Task.FromResult<WorkflowInstance?>(null));
var handled = await HandleWorkflowFaultAsync(serviceProvider, httpContext, CreateRunWorkflowResult(workflowState), CancellationToken.None);
Assert.True(handled);
await httpEndpointFaultHandler.Received(1).HandleAsync(Arg.Is<HttpEndpointFaultContext>(context => ReferenceEquals(context.WorkflowState, workflowState)));
}
private async Task<bool> HandleWorkflowFaultAsync(IServiceProvider serviceProvider, HttpContext httpContext, RunWorkflowResult workflowExecutionResult, CancellationToken cancellationToken)
{
var task = (Task<bool>)HandleWorkflowFaultAsyncMethod.Invoke(_middleware, [serviceProvider, httpContext, workflowExecutionResult, cancellationToken])!;
return await task;
}
private static RunWorkflowResult CreateRunWorkflowResult(WorkflowState workflowState) => new(default!, workflowState, default!, null, Journal.Empty);
private static WorkflowState CreateFaultedWorkflowState(string id) => new()
{
Id = id,
DefinitionId = "definition",
DefinitionVersionId = "definition-version",
Incidents = new List<ActivityIncident>
{
new("activity", "activity-node", "TestActivity", "Boom", null, DateTimeOffset.UtcNow)
}
};
[Fact]
public async Task ExecuteWithinTimeoutAsync_RestoresRequestAbortedAfterSuccess()
{
@ -113,6 +186,9 @@ public class HttpWorkflowsMiddlewareTests
return await task;
}
private static MethodInfo GetRequiredPrivateMethod(string name) =>
typeof(HttpWorkflowsMiddleware).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new MissingMethodException(typeof(HttpWorkflowsMiddleware).FullName, name);
private static IEnumerable<StoredBookmark> CreateCollidingHttpEndpointBookmarks()
{
yield return CreateBookmark("current-tenant-bookmark", CurrentTenantId);