diff --git a/src/modules/Elsa.Labels/Contracts/IWorkflowDefinitionLabelQuery.cs b/src/modules/Elsa.Labels/Contracts/IWorkflowDefinitionLabelQuery.cs new file mode 100644 index 000000000..0c77828be --- /dev/null +++ b/src/modules/Elsa.Labels/Contracts/IWorkflowDefinitionLabelQuery.cs @@ -0,0 +1,14 @@ +using Elsa.Labels.Entities; + +namespace Elsa.Labels.Contracts; + +/// +/// Queries workflow definition label relationships for workflow-definition filter providers. +/// +public interface IWorkflowDefinitionLabelQuery +{ + /// + /// Returns workflow definition versions associated with any of the specified label IDs. Matching is based on the workflow definition version ID; an empty or unknown set returns no associations. + /// + Task> FindByLabelIdsAsync(IEnumerable labelIds, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Labels/Features/LabelsFeature.cs b/src/modules/Elsa.Labels/Features/LabelsFeature.cs index 48c924818..6978af6eb 100644 --- a/src/modules/Elsa.Labels/Features/LabelsFeature.cs +++ b/src/modules/Elsa.Labels/Features/LabelsFeature.cs @@ -6,6 +6,7 @@ using Elsa.Features.Services; using Elsa.Labels.Contracts; using Elsa.Labels.Entities; using Elsa.Labels.Services; +using Elsa.Workflows.Management; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Labels.Features; @@ -45,6 +46,7 @@ public class LabelsFeature : FeatureBase .AddMemoryStore() .AddScoped(LabelStore) .AddScoped(WorkflowDefinitionLabelStore) + .AddScoped() ; Services.AddNotificationHandlersFrom(GetType()); diff --git a/src/modules/Elsa.Labels/Services/InMemoryWorkflowDefinitionLabelStore.cs b/src/modules/Elsa.Labels/Services/InMemoryWorkflowDefinitionLabelStore.cs index b7b9e9fe1..943ab5110 100644 --- a/src/modules/Elsa.Labels/Services/InMemoryWorkflowDefinitionLabelStore.cs +++ b/src/modules/Elsa.Labels/Services/InMemoryWorkflowDefinitionLabelStore.cs @@ -7,7 +7,7 @@ namespace Elsa.Labels.Services; /// /// An in-memory store of workflow-label associations. /// -public class InMemoryWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStore +public class InMemoryWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStore, IWorkflowDefinitionLabelQuery { private readonly MemoryStore _store; @@ -47,6 +47,14 @@ public class InMemoryWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStor return Task.FromResult(result); } + /// + public Task> FindByLabelIdsAsync(IEnumerable labelIds, CancellationToken cancellationToken = default) + { + var ids = labelIds.ToHashSet(); + var result = _store.FindMany(x => ids.Contains(x.LabelId)); + return Task.FromResult(result); + } + /// public Task ReplaceAsync(IEnumerable removed, IEnumerable added, CancellationToken cancellationToken = default) { diff --git a/src/modules/Elsa.Labels/Services/WorkflowDefinitionLabelFilterProvider.cs b/src/modules/Elsa.Labels/Services/WorkflowDefinitionLabelFilterProvider.cs new file mode 100644 index 000000000..f3783f3c5 --- /dev/null +++ b/src/modules/Elsa.Labels/Services/WorkflowDefinitionLabelFilterProvider.cs @@ -0,0 +1,46 @@ +using Elsa.Authorization; +using Elsa.Labels.Contracts; +using Elsa.Labels.Permissions; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Exceptions; +using Elsa.Workflows.Management.Filters; + +namespace Elsa.Labels.Services; + +/// +/// Applies label relationships to workflow definition filters. A version qualifies when it has any requested label ID; an unknown label produces no matches. A caller filtering by labels must hold the workflow-definition-label view permission. The configured label store must implement to support this filter. +/// +public class WorkflowDefinitionLabelFilterProvider(IWorkflowDefinitionLabelStore store) : IWorkflowDefinitionFilterProvider +{ + /// + public bool CanApply(WorkflowDefinitionFilter filter) => filter.LabelIds is { Count: > 0 }; + + /// Requires workflow-definition-label view permission when a label filter is present. + public IEnumerable GetRequiredPermissions(WorkflowDefinitionFilter filter) => + CanApply(filter) ? [new(LabelPermissions.WorkflowDefinitionLabels, CoreVerbs.View)] : []; + + /// + public async Task ApplyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) + { + if (filter.LabelIds is not { Count: > 0 }) + { + return; + } + + if (store is not IWorkflowDefinitionLabelQuery query) + { + throw new WorkflowDefinitionFilterNotSupportedException("Filtering workflow definitions by labels is not supported by the configured label store."); + } + + var labelIds = filter.LabelIds.Distinct().ToList(); + var matchingVersionIds = (await query.FindByLabelIdsAsync(labelIds, cancellationToken)) + .Select(x => x.WorkflowDefinitionVersionId) + .Distinct() + .ToList(); + + filter.Ids = filter.Ids == null + ? matchingVersionIds + : filter.Ids.Intersect(matchingVersionIds).ToList(); + filter.LabelIds = null; + } +} diff --git a/src/modules/Elsa.Labels/ShellFeatures/LabelsFeature.cs b/src/modules/Elsa.Labels/ShellFeatures/LabelsFeature.cs index 37d8e9247..60e8a2269 100644 --- a/src/modules/Elsa.Labels/ShellFeatures/LabelsFeature.cs +++ b/src/modules/Elsa.Labels/ShellFeatures/LabelsFeature.cs @@ -5,6 +5,7 @@ using Elsa.Labels.Contracts; using Elsa.Labels.Entities; using Elsa.Labels.Services; using Elsa.Platform.PackageManifest.Generator.Hints; +using Elsa.Workflows.Management; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; @@ -37,7 +38,8 @@ public class LabelsFeature : IShellFeature .AddMemoryStore() .AddMemoryStore() .AddScoped(LabelStore) - .AddScoped(WorkflowDefinitionLabelStore); + .AddScoped(WorkflowDefinitionLabelStore) + .AddScoped(); services.AddNotificationHandlersFrom(GetType()); } diff --git a/src/modules/Elsa.Persistence.EFCore/Modules/Labels/WorkflowDefinitionLabelStore.cs b/src/modules/Elsa.Persistence.EFCore/Modules/Labels/WorkflowDefinitionLabelStore.cs index 7ab6d5f54..fd2ae7563 100644 --- a/src/modules/Elsa.Persistence.EFCore/Modules/Labels/WorkflowDefinitionLabelStore.cs +++ b/src/modules/Elsa.Persistence.EFCore/Modules/Labels/WorkflowDefinitionLabelStore.cs @@ -4,7 +4,7 @@ using Elsa.Labels.Entities; namespace Elsa.Persistence.EFCore.Modules.Labels; /// -public class EFCoreWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStore +public class EFCoreWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStore, IWorkflowDefinitionLabelQuery { private readonly EntityStore _store; @@ -26,6 +26,13 @@ public class EFCoreWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStore public async Task> FindByWorkflowDefinitionVersionIdAsync(string workflowDefinitionVersionId, CancellationToken cancellationToken = default) => await _store.FindManyAsync(x => x.WorkflowDefinitionVersionId == workflowDefinitionVersionId, cancellationToken); + /// + public async Task> FindByLabelIdsAsync(IEnumerable labelIds, CancellationToken cancellationToken = default) + { + var ids = labelIds.ToList(); + return await _store.FindManyAsync(x => ids.Contains(x.LabelId), cancellationToken); + } + /// public async Task ReplaceAsync(IEnumerable removed, IEnumerable added, CancellationToken cancellationToken = default) { diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/List/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/List/Endpoint.cs index 79fa46d92..b7f5bd020 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/List/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/List/Endpoint.cs @@ -5,14 +5,17 @@ using Elsa.Common.Models; using Elsa.Models; using Elsa.Workflows.Api.Models; using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Exceptions; using Elsa.Workflows.Management.Filters; using Elsa.Workflows.Management.Models; using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.List; [PublicAPI] -internal class List(IWorkflowDefinitionStore store, IWorkflowDefinitionLinker linker) : ElsaEndpoint> +internal class List(IWorkflowDefinitionStore store, IWorkflowDefinitionLinker linker, IEnumerable? filterProviders = null) : ElsaEndpoint> { public override void Configure() { @@ -24,12 +27,51 @@ internal class List(IWorkflowDefinitionStore store, IWorkflowDefinitionLinker li { var pageArgs = PageArgs.FromPage(request.Page, request.PageSize); var filter = CreateFilter(request); + try + { + foreach (var filterProvider in filterProviders ?? []) + { + if (!filterProvider.CanApply(filter)) + { + continue; + } + + if (EndpointSecurityOptions.SecurityIsEnabled && !HasRequiredPermissions(filterProvider, filter)) + { + AddError("You do not have permission to filter workflow definitions by labels."); + await Send.ErrorsAsync(StatusCodes.Status403Forbidden, cancellationToken); + return default!; + } + + await filterProvider.ApplyAsync(filter, cancellationToken); + } + } + catch (WorkflowDefinitionFilterNotSupportedException exception) + { + AddError(exception.Message); + await Send.ErrorsAsync(StatusCodes.Status501NotImplemented, cancellationToken); + return default!; + } + + if (filter.LabelIds is { Count: > 0 }) + { + AddError("Filtering workflow definitions by labels is not supported by the configured modules."); + await Send.ErrorsAsync(StatusCodes.Status501NotImplemented, cancellationToken); + return default!; + } + var summaries = await FindAsync(request, filter, pageArgs, cancellationToken); var pagedList = new PagedListResponse(summaries); var response = linker.MapAsync(pagedList); return response; } + private bool HasRequiredPermissions(IWorkflowDefinitionFilterProvider filterProvider, WorkflowDefinitionFilter filter) + { + var evaluator = HttpContext.RequestServices.GetService() ?? PermissionEvaluator.Shared; + return filterProvider.GetRequiredPermissions(filter).All(permission => evaluator.HasPermission(HttpContext.User, permission)); + } + private WorkflowDefinitionFilter CreateFilter(Request request) { var versionOptions = string.IsNullOrWhiteSpace(request.VersionOptions) ? default(VersionOptions?) : VersionOptions.FromString(request.VersionOptions); @@ -41,7 +83,8 @@ internal class List(IWorkflowDefinitionStore store, IWorkflowDefinitionLinker li SearchTerm = request.SearchTerm?.Trim(), MaterializerName = request.MaterializerName, DefinitionIds = request.DefinitionIds, - Ids = request.Ids + Ids = request.Ids, + LabelIds = request.Labels }; } diff --git a/src/modules/Elsa.Workflows.Management/Contracts/IWorkflowDefinitionFilterProvider.cs b/src/modules/Elsa.Workflows.Management/Contracts/IWorkflowDefinitionFilterProvider.cs new file mode 100644 index 000000000..bdd637a92 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Contracts/IWorkflowDefinitionFilterProvider.cs @@ -0,0 +1,30 @@ +using Elsa.Authorization; +using Elsa.Workflows.Management.Filters; + +namespace Elsa.Workflows.Management; + +/// +/// Applies an optional workflow definition filter that is provided by another module. A provider should leave an omitted or empty criterion unchanged, clear each criterion only after successfully applying it, and report unsupported non-empty criteria explicitly. +/// +public interface IWorkflowDefinitionFilterProvider +{ + /// + /// Gets a value indicating whether this provider can apply criteria to the specified filter. + /// + /// The filter to inspect. + bool CanApply(WorkflowDefinitionFilter filter); + + /// + /// Gets the permissions required before this provider can inspect data for the specified filter. + /// + /// The filter to inspect. + IEnumerable GetRequiredPermissions(WorkflowDefinitionFilter filter) => []; + + /// + /// Applies the provider's criteria to the specified filter. + /// + /// Criteria that remain non-empty after all providers have run are reported as unsupported by the API. + /// The filter to update. + /// The cancellation token. + Task ApplyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Workflows.Management/Exceptions/WorkflowDefinitionFilterNotSupportedException.cs b/src/modules/Elsa.Workflows.Management/Exceptions/WorkflowDefinitionFilterNotSupportedException.cs new file mode 100644 index 000000000..d4bfb80fe --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Exceptions/WorkflowDefinitionFilterNotSupportedException.cs @@ -0,0 +1,6 @@ +namespace Elsa.Workflows.Management.Exceptions; + +/// +/// Indicates that a requested workflow definition filter is not supported by the configured modules. The workflow-definition list endpoint reports this condition as HTTP 501. +/// +public class WorkflowDefinitionFilterNotSupportedException(string message) : NotSupportedException(message); diff --git a/src/modules/Elsa.Workflows.Management/Filters/WorkflowDefinitionFilter.cs b/src/modules/Elsa.Workflows.Management/Filters/WorkflowDefinitionFilter.cs index 9b6d6d16f..ee30d7eb9 100644 --- a/src/modules/Elsa.Workflows.Management/Filters/WorkflowDefinitionFilter.cs +++ b/src/modules/Elsa.Workflows.Management/Filters/WorkflowDefinitionFilter.cs @@ -22,6 +22,11 @@ public class WorkflowDefinitionFilter /// public ICollection? Ids { get; set; } + /// + /// Filter by label IDs associated with workflow definition versions. An optional filter provider resolves these IDs into version IDs before the store query is executed; any matching label ID qualifies a version, while an omitted or empty collection leaves the results unfiltered. The workflow-definition list endpoint requires label view permission for a non-empty collection. + /// + public ICollection? LabelIds { get; set; } + /// /// Filter by the ID of the workflow definition. /// diff --git a/test/unit/Elsa.Workflows.Api.UnitTests/Elsa.Workflows.Api.UnitTests.csproj b/test/unit/Elsa.Workflows.Api.UnitTests/Elsa.Workflows.Api.UnitTests.csproj index 59dc5111e..76391e3ef 100644 --- a/test/unit/Elsa.Workflows.Api.UnitTests/Elsa.Workflows.Api.UnitTests.csproj +++ b/test/unit/Elsa.Workflows.Api.UnitTests/Elsa.Workflows.Api.UnitTests.csproj @@ -6,6 +6,7 @@ + diff --git a/test/unit/Elsa.Workflows.Api.UnitTests/Endpoints/WorkflowDefinitions/WorkflowDefinitionLabelFilterTests.cs b/test/unit/Elsa.Workflows.Api.UnitTests/Endpoints/WorkflowDefinitions/WorkflowDefinitionLabelFilterTests.cs new file mode 100644 index 000000000..ef4046e24 --- /dev/null +++ b/test/unit/Elsa.Workflows.Api.UnitTests/Endpoints/WorkflowDefinitions/WorkflowDefinitionLabelFilterTests.cs @@ -0,0 +1,229 @@ +using System.Security.Claims; +using Elsa.Common.Models; +using Elsa.Common.Services; +using Elsa.Labels.Contracts; +using Elsa.Labels.Entities; +using Elsa.Labels.Services; +using Elsa.Models; +using Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.List; +using Elsa.Workflows.Api.Models; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Exceptions; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Models; +using Elsa.Workflows.Management.Stores; +using FastEndpoints; +using Microsoft.AspNetCore.Http; +using NSubstitute; + +namespace Elsa.Workflows.Api.UnitTests.Endpoints.WorkflowDefinitions; + +public class WorkflowDefinitionLabelFilterTests +{ + [Fact] + public async Task List_WithLabelId_ReturnsMatchingVersionsAndTotalCount() + { + var response = await ExecuteAsync(["red"], page: 0, pageSize: 1); + + Assert.Equal(2, response.TotalCount); + var item = Assert.Single(response.Items); + Assert.Contains(item.Id, new[] { "red-version", "red-second-version" }); + + var secondPage = await ExecuteAsync(["red"], page: 1, pageSize: 1); + Assert.Equal(2, secondPage.TotalCount); + Assert.Single(secondPage.Items); + Assert.NotEqual(item.Id, secondPage.Items.Single().Id); + Assert.Contains(secondPage.Items.Single().Id, new[] { "red-version", "red-second-version" }); + } + + [Fact] + public async Task List_WithUnknownLabelId_ReturnsNoResults() + { + var response = await ExecuteAsync(["unknown"]); + + Assert.Empty(response.Items); + Assert.Equal(0, response.TotalCount); + } + + [Fact] + public async Task List_WithoutLabelIds_ReturnsAllVersions() + { + var response = await ExecuteAsync(null, permissions: ["workflows/definitions:view"]); + + Assert.Equal(5, response.TotalCount); + Assert.Equal(5, response.Items.Count); + } + + [Fact] + public async Task List_WithWorkflowPermissionWildcard_ReturnsMatchingVersions() + { + var response = await ExecuteAsync(["red"], permissions: ["workflows/definitions/*:view"]); + + Assert.Equal(2, response.TotalCount); + Assert.Equal(2, response.Items.Count); + } + + [Fact] + public async Task List_WithMultipleLabelIds_UsesAnyMatchingLabel() + { + var response = await ExecuteAsync(["red", "blue"]); + + Assert.Equal(3, response.TotalCount); + Assert.Equal(["blue-version", "red-second-version", "red-version"], response.Items.Select(x => x.Id).Order()); + } + + [Fact] + public async Task List_WithLabelIdsAndVersionIds_IntersectsBothFilters() + { + var response = await ExecuteAsync(["red"], ids: ["red-unlabeled-version", "red-version"]); + + Assert.Equal(1, response.TotalCount); + Assert.Equal("red-version", Assert.Single(response.Items).Id); + } + + [Fact] + public async Task List_WithLabelFilter_RequiresLabelPermissionBeforeQuerying() + { + var workflowDefinitionStore = Substitute.For(); + ConfigureEmptyPage(workflowDefinitionStore); + var labelStore = Substitute.For(); + var labelProvider = new WorkflowDefinitionLabelFilterProvider(labelStore); + var endpoint = Factory.Create( + CreateHttpContext("workflows/definitions:view"), + workflowDefinitionStore, + new TestWorkflowDefinitionLinker(), + new IWorkflowDefinitionFilterProvider[] { labelProvider }); + + await endpoint.ExecuteAsync(new Request { Labels = ["red"] }, CancellationToken.None); + + Assert.Equal(StatusCodes.Status403Forbidden, endpoint.HttpContext.Response.StatusCode); + Assert.Empty(labelStore.ReceivedCalls()); + Assert.Empty(workflowDefinitionStore.ReceivedCalls()); + } + + [Fact] + public async Task List_WithUnrelatedApplicableProvider_LeavesLabelFilterUnsupported() + { + var workflowDefinitionStore = Substitute.For(); + ConfigureEmptyPage(workflowDefinitionStore); + var endpoint = Factory.Create( + CreateHttpContext("workflows/definitions:view", "workflows/definitions/labels:view"), + workflowDefinitionStore, + new TestWorkflowDefinitionLinker(), + new IWorkflowDefinitionFilterProvider[] { new UnrelatedFilterProvider() }); + + await endpoint.ExecuteAsync(new Request { Labels = ["red"] }, CancellationToken.None); + + Assert.Equal(StatusCodes.Status501NotImplemented, endpoint.HttpContext.Response.StatusCode); + Assert.Empty(workflowDefinitionStore.ReceivedCalls()); + } + + [Fact] + public async Task LabelFilterProvider_WithUnsupportedStoreThrowsExplicitly() + { + var provider = new WorkflowDefinitionLabelFilterProvider(Substitute.For()); + var filter = new WorkflowDefinitionFilter { LabelIds = ["red"] }; + + await Assert.ThrowsAsync(() => provider.ApplyAsync(filter)); + } + + [Fact] + public async Task List_WithoutLabelProvider_ReturnsNotImplemented() + { + var store = Substitute.For(); + var endpoint = Factory.Create(new DefaultHttpContext(), store, new TestWorkflowDefinitionLinker(), Array.Empty()); + + await endpoint.ExecuteAsync(new Request { Labels = ["red"] }, CancellationToken.None); + + Assert.Equal(StatusCodes.Status501NotImplemented, endpoint.HttpContext.Response.StatusCode); + Assert.Empty(store.ReceivedCalls()); + } + + [Fact] + public async Task List_WithUnsupportedLabelStore_ReturnsNotImplemented() + { + var store = Substitute.For(); + var labelStore = Substitute.For(); + var provider = new WorkflowDefinitionLabelFilterProvider(labelStore); + var endpoint = Factory.Create( + CreateHttpContext("workflows/definitions:view", "workflows/definitions/labels:view"), + store, + new TestWorkflowDefinitionLinker(), + new IWorkflowDefinitionFilterProvider[] { provider }); + + await endpoint.ExecuteAsync(new Request { Labels = ["red"] }, CancellationToken.None); + + Assert.Equal(StatusCodes.Status501NotImplemented, endpoint.HttpContext.Response.StatusCode); + Assert.Empty(store.ReceivedCalls()); + } + + private static async Task> ExecuteAsync(string[]? labels, string[]? ids = null, int? page = 0, int? pageSize = null, string[]? permissions = null) + { + var memoryStore = new MemoryStore(); + var workflowDefinitionStore = new MemoryWorkflowDefinitionStore(memoryStore); + await workflowDefinitionStore.SaveManyAsync( + [ + new WorkflowDefinition { Id = "red-version", DefinitionId = "red", Name = "Red", MaterializerName = "Json" }, + new WorkflowDefinition { Id = "red-unlabeled-version", DefinitionId = "red", Name = "Red", MaterializerName = "Json" }, + new WorkflowDefinition { Id = "red-second-version", DefinitionId = "red-second", Name = "Red second", MaterializerName = "Json" }, + new WorkflowDefinition { Id = "blue-version", DefinitionId = "blue", Name = "Blue", MaterializerName = "Json" }, + new WorkflowDefinition { Id = "plain-version", DefinitionId = "plain", Name = "Plain", MaterializerName = "Json" } + ]); + + var labelStore = new InMemoryWorkflowDefinitionLabelStore(new MemoryStore()); + await labelStore.SaveManyAsync( + [ + new WorkflowDefinitionLabel { Id = "red-association", WorkflowDefinitionId = "red", WorkflowDefinitionVersionId = "red-version", LabelId = "red" }, + new WorkflowDefinitionLabel { Id = "red-second-association", WorkflowDefinitionId = "red-second", WorkflowDefinitionVersionId = "red-second-version", LabelId = "red" }, + new WorkflowDefinitionLabel { Id = "blue-association", WorkflowDefinitionId = "blue", WorkflowDefinitionVersionId = "blue-version", LabelId = "blue" } + ]); + + var endpoint = Factory.Create( + CreateHttpContext(permissions ?? ["workflows/definitions:view", "workflows/definitions/labels:view"]), + workflowDefinitionStore, + new TestWorkflowDefinitionLinker(), + new IWorkflowDefinitionFilterProvider[] { new WorkflowDefinitionLabelFilterProvider(labelStore) }); + + return await endpoint.ExecuteAsync(new Request { Labels = labels, Ids = ids, Page = page, PageSize = pageSize }, CancellationToken.None); + } + + private sealed class TestWorkflowDefinitionLinker : IWorkflowDefinitionLinker + { + public Task MapAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public PagedListResponse MapAsync(PagedListResponse list, CancellationToken cancellationToken = default) => new() + { + Items = list.Items.Select(x => new LinkedWorkflowDefinitionSummary + { + Id = x.Id, + DefinitionId = x.DefinitionId, + Name = x.Name, + Version = x.Version + }).ToList(), + TotalCount = list.TotalCount + }; + + public Task> MapAsync(List definitions, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private sealed class UnrelatedFilterProvider : IWorkflowDefinitionFilterProvider + { + public bool CanApply(WorkflowDefinitionFilter filter) => true; + + public Task ApplyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private static DefaultHttpContext CreateHttpContext(params string[] permissions) => new() + { + User = new ClaimsPrincipal(new ClaimsIdentity(permissions.Select(x => new Claim(PermissionNames.ClaimType, x)), "test")) + }; + + private static void ConfigureEmptyPage(IWorkflowDefinitionStore store) => + store.FindSummariesAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(Page.Empty())); +}