using System.Collections; using System.Reflection; 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 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"; private readonly CapturingBookmarkStore _bookmarkStore; private readonly IServiceProvider _serviceProvider; private readonly HttpWorkflowsMiddleware _middleware = new(_ => Task.CompletedTask); public HttpWorkflowsMiddlewareTests() { _bookmarkStore = new(CurrentTenantId, CreateCollidingHttpEndpointBookmarks()); _serviceProvider = new ServiceCollection() .AddSingleton(_bookmarkStore) .AddSingleton() .AddSingleton(new ListRouteTable([new(BookmarkPath)])) .AddSingleton() .BuildServiceProvider(); } [Fact] public async Task InvokeAsync_WithCollidingHttpEndpointBookmarks_UsesTenantScopedBookmarkLookup() { var httpContext = new DefaultHttpContext { RequestServices = _serviceProvider }; httpContext.Request.Path = BookmarkPath; httpContext.Request.Method = HttpMethod.Get.Method; await _middleware.InvokeAsync( httpContext, _serviceProvider, Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = null }), new EmptyHttpWorkflowLookupService()); Assert.NotNull(_bookmarkStore.LastFilter); var filter = _bookmarkStore.LastFilter!; 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(); var httpEndpointFaultHandler = Substitute.For(); 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()).Returns(Task.FromResult(workflowInstance)); var handled = await HandleWorkflowFaultAsync(serviceProvider, httpContext, CreateRunWorkflowResult(workflowState), CancellationToken.None); Assert.True(handled); await httpEndpointFaultHandler.Received(1).HandleAsync(Arg.Is(context => ReferenceEquals(context.WorkflowState, reloadedWorkflowState))); } [Fact] public async Task HandleWorkflowFaultAsync_FallsBackToExecutionResultState_WhenReloadReturnsNull() { var workflowState = CreateFaultedWorkflowState("workflow-2"); var workflowInstanceManager = Substitute.For(); var httpEndpointFaultHandler = Substitute.For(); var serviceProvider = new ServiceCollection() .AddSingleton(workflowInstanceManager) .AddSingleton(httpEndpointFaultHandler) .BuildServiceProvider(); var httpContext = new DefaultHttpContext(); workflowInstanceManager.FindByIdAsync(workflowState.Id, Arg.Any()).Returns(Task.FromResult(null)); var handled = await HandleWorkflowFaultAsync(serviceProvider, httpContext, CreateRunWorkflowResult(workflowState), CancellationToken.None); Assert.True(handled); await httpEndpointFaultHandler.Received(1).HandleAsync(Arg.Is(context => ReferenceEquals(context.WorkflowState, workflowState))); } private async Task HandleWorkflowFaultAsync(IServiceProvider serviceProvider, HttpContext httpContext, RunWorkflowResult workflowExecutionResult, CancellationToken cancellationToken) { var task = (Task)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 { new("activity", "activity-node", "TestActivity", "Boom", null, DateTimeOffset.UtcNow) } }; [Fact] public async Task ExecuteWithinTimeoutAsync_RestoresRequestAbortedAfterSuccess() { using var requestAbortedSource = new CancellationTokenSource(); var httpContext = new DefaultHttpContext { RequestAborted = requestAbortedSource.Token }; var observedToken = CancellationToken.None; var result = await ExecuteWithinTimeoutAsync(async cancellationToken => { observedToken = cancellationToken; Assert.Equal(cancellationToken, httpContext.RequestAborted); await Task.CompletedTask; return 42; }, TimeSpan.FromSeconds(1), httpContext); Assert.Equal(42, result); Assert.NotEqual(requestAbortedSource.Token, observedToken); Assert.Equal(requestAbortedSource.Token, httpContext.RequestAborted); } [Fact] public async Task ExecuteWithinTimeoutAsync_RestoresRequestAbortedAfterFault() { using var requestAbortedSource = new CancellationTokenSource(); var httpContext = new DefaultHttpContext { RequestAborted = requestAbortedSource.Token }; await Assert.ThrowsAsync(() => ExecuteWithinTimeoutAsync(_ => throw new InvalidOperationException("Boom"), TimeSpan.FromSeconds(1), httpContext)); Assert.Equal(requestAbortedSource.Token, httpContext.RequestAborted); } [Fact] public async Task ExecuteWithinTimeoutAsync_RestoresRequestAbortedAfterCancellation() { using var requestAbortedSource = new CancellationTokenSource(); requestAbortedSource.Cancel(); var httpContext = new DefaultHttpContext { RequestAborted = requestAbortedSource.Token }; var observedToken = CancellationToken.None; await Assert.ThrowsAnyAsync(() => ExecuteWithinTimeoutAsync(cancellationToken => { observedToken = cancellationToken; return Task.FromCanceled(cancellationToken); }, TimeSpan.FromSeconds(1), httpContext)); Assert.True(observedToken.IsCancellationRequested); Assert.Equal(requestAbortedSource.Token, httpContext.RequestAborted); } private async Task ExecuteWithinTimeoutAsync(Func> action, TimeSpan? requestTimeout, HttpContext httpContext) { var method = ExecuteWithinTimeoutAsyncMethod.MakeGenericMethod(typeof(T)); var task = (Task)method.Invoke(_middleware, [action, requestTimeout, httpContext])!; 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 CreateCollidingHttpEndpointBookmarks() { yield return CreateBookmark("current-tenant-bookmark", CurrentTenantId); yield return CreateBookmark("other-tenant-bookmark", OtherTenantId); } private const string BookmarkPath = "/colliding"; private static StoredBookmark CreateBookmark(string id, string tenantId) => new() { Id = id, TenantId = tenantId, Hash = BookmarkHash, WorkflowInstanceId = $"{id}-workflow-instance", Payload = new HttpEndpointBookmarkPayload("/colliding", "get") }; private class CapturingBookmarkStore(string currentTenantId, IEnumerable bookmarks) : IBookmarkStore { private readonly ICollection _bookmarks = bookmarks.ToList(); public BookmarkFilter? LastFilter { get; private set; } public ValueTask SaveAsync(StoredBookmark record, CancellationToken cancellationToken = default) { _bookmarks.Add(record); return ValueTask.CompletedTask; } public ValueTask SaveManyAsync(IEnumerable records, CancellationToken cancellationToken) { foreach (var record in records) _bookmarks.Add(record); return ValueTask.CompletedTask; } public ValueTask FindAsync(BookmarkFilter filter, CancellationToken cancellationToken = default) { LastFilter = filter; return new(Filter(filter).FirstOrDefault()); } public ValueTask> FindManyAsync(BookmarkFilter filter, CancellationToken cancellationToken = default) { LastFilter = filter; _ = Filter(filter).ToList(); return new([]); } public ValueTask DeleteAsync(BookmarkFilter filter, CancellationToken cancellationToken = default) { var bookmarksToDelete = Filter(filter).ToList(); foreach (var bookmark in bookmarksToDelete) _bookmarks.Remove(bookmark); return new(bookmarksToDelete.Count); } private IEnumerable Filter(BookmarkFilter filter) { var query = filter.Apply(_bookmarks.AsQueryable()); if (!filter.TenantAgnostic) query = query.Where(x => x.TenantId == currentTenantId); return query; } } private class EmptyHttpWorkflowLookupService : IHttpWorkflowLookupService { public Task FindWorkflowAsync(string bookmarkHash, CancellationToken cancellationToken = default) => Task.FromResult(null); } private class FixedStimulusHasher : IStimulusHasher { public string Hash(string stimulusName, object? payload = null, string? activityInstanceId = null) => BookmarkHash; } private class ExactRouteMatcher : IRouteMatcher { public RouteValueDictionary? Match(string routeTemplate, string route) => routeTemplate == route ? new() : null; } private class ListRouteTable(IEnumerable routes) : IRouteTable { private readonly ICollection _routes = routes.ToList(); public IEnumerator GetEnumerator() => _routes.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); public void Add(string route) => _routes.Add(new(route)); public void Add(HttpRouteData httpRouteData) => _routes.Add(httpRouteData); public void Remove(string route) { foreach (var routeData in _routes.Where(x => x.Route == route).ToList()) _routes.Remove(routeData); } public void AddRange(IEnumerable routes) { foreach (var route in routes) Add(route); } public void RemoveRange(IEnumerable routes) { foreach (var route in routes) Remove(route); } } }