diff --git a/src/modules/Elsa.Common/Multitenancy/TenantVisibility.cs b/src/modules/Elsa.Common/Multitenancy/TenantVisibility.cs new file mode 100644 index 000000000..a6b602367 --- /dev/null +++ b/src/modules/Elsa.Common/Multitenancy/TenantVisibility.cs @@ -0,0 +1,35 @@ +using Elsa.Common.Entities; + +namespace Elsa.Common.Multitenancy; + +/// +/// Memory-store counterpart of EF Core SetTenantIdFilter. +/// A row is visible when its matches the ambient tenant, +/// is , or is null while the ambient tenant is the default. +/// +public static class TenantVisibility +{ + /// + /// Returns whether is visible under . + /// + public static bool IsVisible(string? entityTenantId, string ambientTenantId) => + entityTenantId == ambientTenantId + || entityTenantId == Tenant.AgnosticTenantId + || entityTenantId is null && ambientTenantId == Tenant.DefaultTenantId; + + /// + /// Restricts to rows visible to , + /// unless is set (EF IgnoreQueryFilters). + /// + public static IQueryable WhereVisibleToTenant(this IQueryable queryable, string ambientTenantId, bool tenantAgnostic = false) + where T : Entity + { + if (tenantAgnostic) + return queryable; + + return queryable.Where(entity => + entity.TenantId == ambientTenantId + || entity.TenantId == Tenant.AgnosticTenantId + || entity.TenantId == null && ambientTenantId == Tenant.DefaultTenantId); + } +} diff --git a/src/modules/Elsa.Workflows.Management/Stores/MemoryWorkflowDefinitionStore.cs b/src/modules/Elsa.Workflows.Management/Stores/MemoryWorkflowDefinitionStore.cs index ad702e3c1..d55d76123 100644 --- a/src/modules/Elsa.Workflows.Management/Stores/MemoryWorkflowDefinitionStore.cs +++ b/src/modules/Elsa.Workflows.Management/Stores/MemoryWorkflowDefinitionStore.cs @@ -1,4 +1,5 @@ using Elsa.Common.Models; +using Elsa.Common.Multitenancy; using Elsa.Common.Services; using Elsa.Extensions; using Elsa.Workflows.Management.Entities; @@ -10,7 +11,7 @@ namespace Elsa.Workflows.Management.Stores; /// /// A memory implementation of . /// -public class MemoryWorkflowDefinitionStore(MemoryStore store) : IWorkflowDefinitionStore +public class MemoryWorkflowDefinitionStore(MemoryStore store, ITenantAccessor? tenantAccessor = null) : IWorkflowDefinitionStore { /// public Task FindAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) @@ -147,7 +148,9 @@ public class MemoryWorkflowDefinitionStore(MemoryStore store lock (store.Sync) { var workflowDefinitionIds = store.Query(query => Filter(query, filter)).Select(x => x.DefinitionId).Distinct().ToList(); - store.DeleteWhere(x => workflowDefinitionIds.Contains(x.DefinitionId)); + store.DeleteWhere(x => + workflowDefinitionIds.Contains(x.DefinitionId) + && (filter.TenantAgnostic || TenantVisibility.IsVisible(x.TenantId, CurrentTenantId))); return Task.FromResult(workflowDefinitionIds.LongCount()); } } @@ -162,17 +165,31 @@ public class MemoryWorkflowDefinitionStore(MemoryStore store /// public Task CountDistinctAsync(CancellationToken cancellationToken = default) { - return Task.FromResult(store.Count(x => true, x => x.DefinitionId)); + var count = store.Query(query => query.WhereVisibleToTenant(CurrentTenantId)) + .Select(x => x.DefinitionId) + .Distinct() + .LongCount(); + return Task.FromResult(count); } /// public Task GetIsNameUnique(string name, string? definitionId = default, CancellationToken cancellationToken = default) { - var exists = store.Any(x => x.Name == name && x.DefinitionId != definitionId); + var exists = store.Any(x => + x.Name == name + && x.DefinitionId != definitionId + && TenantVisibility.IsVisible(x.TenantId, CurrentTenantId)); return Task.FromResult(!exists); } - private IQueryable Filter(IQueryable queryable, WorkflowDefinitionFilter filter) => filter.Apply(queryable); + /// + /// Ambient tenant is applied here rather than in . + /// EF owns that via SetTenantIdFilter / IgnoreQueryFilters; Memory must compensate. + /// + private IQueryable Filter(IQueryable queryable, WorkflowDefinitionFilter filter) => + filter.Apply(queryable.WhereVisibleToTenant(CurrentTenantId, filter.TenantAgnostic)); + + private string CurrentTenantId => tenantAccessor?.TenantId ?? Tenant.DefaultTenantId; private string GetId(WorkflowDefinition workflowDefinition) => workflowDefinition.Id; } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Stores/MemoryBookmarkStore.cs b/src/modules/Elsa.Workflows.Runtime/Stores/MemoryBookmarkStore.cs index b2115e28e..552a7be67 100644 --- a/src/modules/Elsa.Workflows.Runtime/Stores/MemoryBookmarkStore.cs +++ b/src/modules/Elsa.Workflows.Runtime/Stores/MemoryBookmarkStore.cs @@ -1,4 +1,5 @@ using Elsa.Common.Models; +using Elsa.Common.Multitenancy; using Elsa.Common.Services; using Elsa.Extensions; using Elsa.Workflows.Runtime.Entities; @@ -9,7 +10,7 @@ namespace Elsa.Workflows.Runtime.Stores; /// [UsedImplicitly] -public class MemoryBookmarkStore(MemoryStore store) : IBookmarkStore +public class MemoryBookmarkStore(MemoryStore store, ITenantAccessor? tenantAccessor = null) : IBookmarkStore { /// public ValueTask SaveAsync(StoredBookmark record, CancellationToken cancellationToken = default) @@ -54,5 +55,12 @@ public class MemoryBookmarkStore(MemoryStore store) : IBookmarkS return store.DeleteMany(ids); } - private static IQueryable Filter(IQueryable query, BookmarkFilter filter) => filter.Apply(query); + /// + /// Ambient tenant is applied here rather than in . + /// EF owns that via SetTenantIdFilter / IgnoreQueryFilters; Memory must compensate. + /// + private IQueryable Filter(IQueryable query, BookmarkFilter filter) => + filter.Apply(query.WhereVisibleToTenant(CurrentTenantId, filter.TenantAgnostic)); + + private string CurrentTenantId => tenantAccessor?.TenantId ?? Tenant.DefaultTenantId; } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Stores/MemoryTriggerStore.cs b/src/modules/Elsa.Workflows.Runtime/Stores/MemoryTriggerStore.cs index 55aee2ad7..96fa0abcc 100644 --- a/src/modules/Elsa.Workflows.Runtime/Stores/MemoryTriggerStore.cs +++ b/src/modules/Elsa.Workflows.Runtime/Stores/MemoryTriggerStore.cs @@ -63,14 +63,14 @@ public class MemoryTriggerStore : ITriggerStore /// public ValueTask FindAsync(TriggerFilter filter, CancellationToken cancellationToken = default) { - var entity = _store.Query(filter.Apply).FirstOrDefault(); + var entity = _store.Query(query => Filter(query, filter)).FirstOrDefault(); return new(entity); } /// public ValueTask> FindManyAsync(TriggerFilter filter, CancellationToken cancellationToken = default) { - var entities = _store.Query(filter.Apply); + var entities = _store.Query(query => Filter(query, filter)); return new(entities); } @@ -81,8 +81,8 @@ public class MemoryTriggerStore : ITriggerStore public ValueTask> FindManyAsync(TriggerFilter filter, PageArgs pageArgs, StoredTriggerOrder order, CancellationToken cancellationToken = default) { - var count = _store.Query(filter.Apply).LongCount(); - var result = _store.Query(query => filter.Apply(query).OrderBy(order).Paginate(pageArgs)).ToList(); + var count = _store.Query(query => Filter(query, filter)).LongCount(); + var result = _store.Query(query => Filter(query, filter).OrderBy(order).Paginate(pageArgs)).ToList(); return ValueTask.FromResult(Page.Of(result, count)); } @@ -121,11 +121,20 @@ public class MemoryTriggerStore : ITriggerStore { lock (_store.Sync) { - var ids = _store.Query(filter.Apply).Select(x => x.Id).ToList(); + var ids = _store.Query(query => Filter(query, filter)).Select(x => x.Id).ToList(); return new(_store.DeleteMany(ids)); } } + /// + /// Ambient tenant is applied here rather than in . + /// EF owns that via SetTenantIdFilter / IgnoreQueryFilters; Memory must compensate. + /// + private IQueryable Filter(IQueryable queryable, TriggerFilter filter) => + filter.Apply(queryable.WhereVisibleToTenant(CurrentTenantId, filter.TenantAgnostic)); + + private string CurrentTenantId => _tenantAccessor?.TenantId ?? Tenant.DefaultTenantId; + private void EnsureLogicalKeyAvailable(StoredTrigger record) { var logicalKey = GetLogicalKey(record); diff --git a/test/unit/Elsa.Common.UnitTests/Multitenancy/TenantVisibilityTests.cs b/test/unit/Elsa.Common.UnitTests/Multitenancy/TenantVisibilityTests.cs new file mode 100644 index 000000000..07ff5b958 --- /dev/null +++ b/test/unit/Elsa.Common.UnitTests/Multitenancy/TenantVisibilityTests.cs @@ -0,0 +1,79 @@ +using Elsa.Common.Entities; +using Elsa.Common.Multitenancy; + +namespace Elsa.Common.UnitTests.Multitenancy; + +/// +/// Mirrors EF SetTenantIdFilter: ambient match, *, and null-on-default-tenant. +/// +public class TenantVisibilityTests +{ + [Theory] + [InlineData("tenant-a", "tenant-a", true)] + [InlineData("tenant-a", "tenant-b", false)] + [InlineData(Tenant.AgnosticTenantId, "tenant-a", true)] + [InlineData(Tenant.AgnosticTenantId, Tenant.DefaultTenantId, true)] + [InlineData(null, Tenant.DefaultTenantId, true)] + [InlineData(null, "tenant-a", false)] + [InlineData(Tenant.DefaultTenantId, Tenant.DefaultTenantId, true)] + [InlineData(Tenant.DefaultTenantId, "tenant-a", false)] + public void IsVisible_MatchesSetTenantIdFilter(string? entityTenantId, string ambientTenantId, bool expected) + { + Assert.Equal(expected, TenantVisibility.IsVisible(entityTenantId, ambientTenantId)); + } + + [Fact] + public void WhereVisibleToTenant_WhenNotAgnostic_HidesOtherTenants() + { + var queryable = new[] + { + Entity("tenant-a"), + Entity("tenant-b"), + Entity(Tenant.AgnosticTenantId), + Entity(null) + }.AsQueryable(); + + var visible = queryable.WhereVisibleToTenant("tenant-a").Select(x => x.TenantId).ToList(); + + Assert.Equal(2, visible.Count); + Assert.Contains("tenant-a", visible); + Assert.Contains(Tenant.AgnosticTenantId, visible); + } + + [Fact] + public void WhereVisibleToTenant_WhenAgnostic_ReturnsAllRows() + { + var queryable = new[] + { + Entity("tenant-a"), + Entity("tenant-b") + }.AsQueryable(); + + var visible = queryable.WhereVisibleToTenant("tenant-a", tenantAgnostic: true).ToList(); + + Assert.Equal(2, visible.Count); + } + + [Fact] + public void WhereVisibleToTenant_WhenAmbientIsDefault_IncludesNullTenantId() + { + var queryable = new[] + { + Entity(null), + Entity("tenant-a") + }.AsQueryable(); + + var visible = queryable.WhereVisibleToTenant(Tenant.DefaultTenantId).ToList(); + + Assert.Single(visible); + Assert.Null(visible[0].TenantId); + } + + private static TestEntity Entity(string? tenantId) => new() + { + Id = tenantId ?? "null", + TenantId = tenantId + }; + + private sealed class TestEntity : Entity; +} diff --git a/test/unit/Elsa.Workflows.Management.UnitTests/Stores/MemoryWorkflowDefinitionStoreTenantIsolationTests.cs b/test/unit/Elsa.Workflows.Management.UnitTests/Stores/MemoryWorkflowDefinitionStoreTenantIsolationTests.cs new file mode 100644 index 000000000..09446cb7a --- /dev/null +++ b/test/unit/Elsa.Workflows.Management.UnitTests/Stores/MemoryWorkflowDefinitionStoreTenantIsolationTests.cs @@ -0,0 +1,179 @@ +using Elsa.Common.Multitenancy; +using Elsa.Common.Services; +using Elsa.Testing.Shared.Multitenancy; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Stores; + +namespace Elsa.Workflows.Management.UnitTests.Stores; + +/// +/// Memory must honor ambient tenant + +/// the same way EF does via SetTenantIdFilter / IgnoreQueryFilters. +/// +public class MemoryWorkflowDefinitionStoreTenantIsolationTests +{ + [Fact(DisplayName = "FindManyAsync hides other tenants and keeps * visible")] + public async Task FindManyAsync_WhenNotTenantAgnostic_HidesOtherTenants() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var found = (await store.FindManyAsync(new WorkflowDefinitionFilter())).ToList(); + + Assert.Equal(2, found.Count); + Assert.Contains(found, x => x.Id == "def-a"); + Assert.Contains(found, x => x.Id == "def-star"); + Assert.DoesNotContain(found, x => x.Id == "def-b"); + } + + [Fact(DisplayName = "FindManyAsync with TenantAgnostic returns every tenant")] + public async Task FindManyAsync_WhenTenantAgnostic_ReturnsAllTenants() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var found = (await store.FindManyAsync(new WorkflowDefinitionFilter { TenantAgnostic = true })).ToList(); + + Assert.Equal(3, found.Count); + Assert.Contains(found, x => x.Id == "def-a"); + Assert.Contains(found, x => x.Id == "def-b"); + Assert.Contains(found, x => x.Id == "def-star"); + } + + [Fact(DisplayName = "FindAsync does not return another tenant's row by Id")] + public async Task FindAsync_WhenOtherTenant_ReturnsNull() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var found = await store.FindAsync(new WorkflowDefinitionFilter { Id = "def-b" }); + + Assert.Null(found); + } + + [Fact(DisplayName = "AnyAsync is false when only another tenant matches")] + public async Task AnyAsync_WhenOnlyOtherTenantMatches_ReturnsFalse() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var exists = await store.AnyAsync(new WorkflowDefinitionFilter { Id = "def-b" }); + + Assert.False(exists); + } + + [Fact(DisplayName = "CountDistinctAsync counts only the current tenant's definition IDs")] + public async Task CountDistinctAsync_CountsOnlyVisibleDefinitions() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var count = await store.CountDistinctAsync(); + + Assert.Equal(2, count); + } + + [Fact(DisplayName = "GetIsNameUnique allows the same name in another tenant")] + public async Task GetIsNameUnique_AllowsSameNameInAnotherTenant() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(Definition("def-b", "order", "tenant-b")); + + var unique = await store.GetIsNameUnique("order"); + + Assert.True(unique); + } + + [Fact(DisplayName = "GetIsNameUnique is false when the current tenant already has the name")] + public async Task GetIsNameUnique_WhenCurrentTenantOwnsTheName_ReturnsFalse() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(Definition("def-a", "order", "tenant-a")); + + var unique = await store.GetIsNameUnique("order"); + + Assert.False(unique); + } + + [Fact(DisplayName = "DeleteAsync does not remove another tenant's rows")] + public async Task DeleteAsync_DoesNotDeleteOtherTenantRows() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var deleted = await store.DeleteAsync(new WorkflowDefinitionFilter()); + var remaining = (await store.FindManyAsync(new WorkflowDefinitionFilter { TenantAgnostic = true })).ToList(); + + Assert.Equal(2, deleted); + Assert.Single(remaining); + Assert.Equal("def-b", remaining[0].Id); + } + + [Fact(DisplayName = "DeleteAsync with a shared DefinitionId leaves the other tenant's versions")] + public async Task DeleteAsync_WhenDefinitionIdIsShared_LeavesOtherTenantRows() + { + var backing = new MemoryStore(); + var tenantA = new MemoryWorkflowDefinitionStore(backing, new TestTenantAccessor("tenant-a")); + var tenantB = new MemoryWorkflowDefinitionStore(backing, new TestTenantAccessor("tenant-b")); + await tenantA.SaveAsync(Definition("def-a", "Order", "tenant-a", definitionId: "order")); + await tenantB.SaveAsync(Definition("def-b", "Order", "tenant-b", definitionId: "order")); + + var deleted = await tenantA.DeleteAsync(new WorkflowDefinitionFilter { DefinitionId = "order" }); + var remainingForA = (await tenantA.FindManyAsync(new WorkflowDefinitionFilter { DefinitionId = "order" })).ToList(); + var remainingForB = (await tenantB.FindManyAsync(new WorkflowDefinitionFilter { DefinitionId = "order" })).ToList(); + + Assert.Equal(1, deleted); + Assert.Empty(remainingForA); + Assert.Single(remainingForB); + Assert.Equal("def-b", remainingForB[0].Id); + } + + [Fact(DisplayName = "FindManyAsync on the default tenant includes null TenantId rows")] + public async Task FindManyAsync_WhenAmbientIsDefault_IncludesNullTenantId() + { + var store = CreateStore(Tenant.DefaultTenantId); + await store.SaveAsync(Definition("def-null", "Null", tenantId: null)); + await store.SaveAsync(Definition("def-a", "A", "tenant-a")); + + var found = (await store.FindManyAsync(new WorkflowDefinitionFilter())).ToList(); + + Assert.Single(found); + Assert.Equal("def-null", found[0].Id); + } + + [Fact(DisplayName = "FindManyAsync on a named tenant hides null TenantId rows")] + public async Task FindManyAsync_WhenAmbientIsNamed_HidesNullTenantId() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(Definition("def-null", "Null", tenantId: null)); + await store.SaveAsync(Definition("def-a", "A", "tenant-a")); + + var found = (await store.FindManyAsync(new WorkflowDefinitionFilter())).ToList(); + + Assert.Single(found); + Assert.Equal("def-a", found[0].Id); + } + + private static MemoryWorkflowDefinitionStore CreateStore(string tenantId) => + new(new MemoryStore(), new TestTenantAccessor(tenantId)); + + private static async Task SeedMixedTenantsAsync(MemoryWorkflowDefinitionStore store) + { + await store.SaveAsync(Definition("def-a", "A", "tenant-a")); + await store.SaveAsync(Definition("def-b", "B", "tenant-b")); + await store.SaveAsync(Definition("def-star", "Star", Tenant.AgnosticTenantId)); + } + + private static WorkflowDefinition Definition(string id, string name, string? tenantId, string? definitionId = null) => + new() + { + Id = id, + DefinitionId = definitionId ?? id, + Name = name, + TenantId = tenantId, + Version = 1, + IsLatest = true, + MaterializerName = "Json" + }; +} diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/Stores/MemoryBookmarkStoreTenantIsolationTests.cs b/test/unit/Elsa.Workflows.Runtime.UnitTests/Stores/MemoryBookmarkStoreTenantIsolationTests.cs new file mode 100644 index 000000000..2271344a4 --- /dev/null +++ b/test/unit/Elsa.Workflows.Runtime.UnitTests/Stores/MemoryBookmarkStoreTenantIsolationTests.cs @@ -0,0 +1,114 @@ +using Elsa.Common.Multitenancy; +using Elsa.Common.Services; +using Elsa.Testing.Shared.Multitenancy; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Filters; +using Elsa.Workflows.Runtime.Stores; + +namespace Elsa.Workflows.Runtime.UnitTests.Stores; + +/// +/// Memory must honor ambient tenant + +/// the same way EF does via SetTenantIdFilter / IgnoreQueryFilters. +/// +public class MemoryBookmarkStoreTenantIsolationTests +{ + [Fact(DisplayName = "FindManyAsync hides other tenants and keeps * visible")] + public async Task FindManyAsync_WhenNotTenantAgnostic_HidesOtherTenants() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var found = (await store.FindManyAsync(new BookmarkFilter())).ToList(); + + Assert.Equal(2, found.Count); + Assert.Contains(found, x => x.Id == "bm-a"); + Assert.Contains(found, x => x.Id == "bm-star"); + Assert.DoesNotContain(found, x => x.Id == "bm-b"); + } + + [Fact(DisplayName = "FindManyAsync with TenantAgnostic returns every tenant")] + public async Task FindManyAsync_WhenTenantAgnostic_ReturnsAllTenants() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var found = (await store.FindManyAsync(new BookmarkFilter { TenantAgnostic = true })).ToList(); + + Assert.Equal(3, found.Count); + Assert.Contains(found, x => x.Id == "bm-a"); + Assert.Contains(found, x => x.Id == "bm-b"); + Assert.Contains(found, x => x.Id == "bm-star"); + } + + [Fact(DisplayName = "FindAsync does not return another tenant's row by Id")] + public async Task FindAsync_WhenOtherTenant_ReturnsNull() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var found = await store.FindAsync(new BookmarkFilter { BookmarkId = "bm-b" }); + + Assert.Null(found); + } + + [Fact(DisplayName = "DeleteAsync does not remove another tenant's rows")] + public async Task DeleteAsync_DoesNotDeleteOtherTenantRows() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var deleted = await store.DeleteAsync(new BookmarkFilter()); + var remaining = (await store.FindManyAsync(new BookmarkFilter { TenantAgnostic = true })).ToList(); + + Assert.Equal(2, deleted); + Assert.Single(remaining); + Assert.Equal("bm-b", remaining[0].Id); + } + + [Fact(DisplayName = "FindManyAsync on the default tenant includes null TenantId rows")] + public async Task FindManyAsync_WhenAmbientIsDefault_IncludesNullTenantId() + { + var store = CreateStore(Tenant.DefaultTenantId); + await store.SaveAsync(Bookmark("bm-null", tenantId: null)); + await store.SaveAsync(Bookmark("bm-a", tenantId: "tenant-a")); + + var found = (await store.FindManyAsync(new BookmarkFilter())).ToList(); + + Assert.Single(found); + Assert.Equal("bm-null", found[0].Id); + } + + [Fact(DisplayName = "FindManyAsync on a named tenant hides null TenantId rows")] + public async Task FindManyAsync_WhenAmbientIsNamed_HidesNullTenantId() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(Bookmark("bm-null", tenantId: null)); + await store.SaveAsync(Bookmark("bm-a", tenantId: "tenant-a")); + + var found = (await store.FindManyAsync(new BookmarkFilter())).ToList(); + + Assert.Single(found); + Assert.Equal("bm-a", found[0].Id); + } + + private static MemoryBookmarkStore CreateStore(string tenantId) => + new(new MemoryStore(), new TestTenantAccessor(tenantId)); + + private static async Task SeedMixedTenantsAsync(MemoryBookmarkStore store) + { + await store.SaveAsync(Bookmark("bm-a", tenantId: "tenant-a")); + await store.SaveAsync(Bookmark("bm-b", tenantId: "tenant-b")); + await store.SaveAsync(Bookmark("bm-star", tenantId: Tenant.AgnosticTenantId)); + } + + private static StoredBookmark Bookmark(string id, string? tenantId) => + new() + { + Id = id, + TenantId = tenantId, + Hash = id, + WorkflowInstanceId = "instance-1", + Name = "Elsa.HttpEndpoint" + }; +} diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/Stores/MemoryTriggerStoreTenantIsolationTests.cs b/test/unit/Elsa.Workflows.Runtime.UnitTests/Stores/MemoryTriggerStoreTenantIsolationTests.cs new file mode 100644 index 000000000..e9077a0ce --- /dev/null +++ b/test/unit/Elsa.Workflows.Runtime.UnitTests/Stores/MemoryTriggerStoreTenantIsolationTests.cs @@ -0,0 +1,107 @@ +using Elsa.Common.Multitenancy; +using Elsa.Common.Services; +using Elsa.Testing.Shared.Multitenancy; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Filters; +using Elsa.Workflows.Runtime.Stores; + +namespace Elsa.Workflows.Runtime.UnitTests.Stores; + +/// +/// Memory must honor ambient tenant + +/// the same way EF does via SetTenantIdFilter / IgnoreQueryFilters. +/// +public class MemoryTriggerStoreTenantIsolationTests +{ + [Fact(DisplayName = "FindManyAsync hides other tenants and keeps * visible")] + public async Task FindManyAsync_WhenNotTenantAgnostic_HidesOtherTenants() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var found = (await store.FindManyAsync(new TriggerFilter())).ToList(); + + Assert.Equal(2, found.Count); + Assert.Contains(found, x => x.Id == "id-a"); + Assert.Contains(found, x => x.Id == "id-star"); + Assert.DoesNotContain(found, x => x.Id == "id-b"); + } + + [Fact(DisplayName = "FindManyAsync with TenantAgnostic returns every tenant")] + public async Task FindManyAsync_WhenTenantAgnostic_ReturnsAllTenants() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var found = (await store.FindManyAsync(new TriggerFilter { TenantAgnostic = true })).ToList(); + + Assert.Equal(3, found.Count); + Assert.Contains(found, x => x.Id == "id-a"); + Assert.Contains(found, x => x.Id == "id-b"); + Assert.Contains(found, x => x.Id == "id-star"); + } + + [Fact(DisplayName = "FindAsync does not return another tenant's row by Id")] + public async Task FindAsync_WhenOtherTenant_ReturnsNull() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var found = await store.FindAsync(new TriggerFilter { Id = "id-b" }); + + Assert.Null(found); + } + + [Fact(DisplayName = "DeleteManyAsync does not remove another tenant's rows")] + public async Task DeleteManyAsync_DoesNotDeleteOtherTenantRows() + { + var store = CreateStore("tenant-a"); + await SeedMixedTenantsAsync(store); + + var deleted = await store.DeleteManyAsync(new TriggerFilter()); + var remaining = (await store.FindManyAsync(new TriggerFilter { TenantAgnostic = true })).ToList(); + + Assert.Equal(2, deleted); + Assert.Single(remaining); + Assert.Equal("id-b", remaining[0].Id); + } + + [Fact(DisplayName = "FindManyAsync on the default tenant includes null TenantId rows")] + public async Task FindManyAsync_WhenAmbientIsDefault_IncludesNullTenantId() + { + var store = new MemoryTriggerStore(new MemoryStore()); + var unassigned = Trigger("id-null"); + unassigned.TenantId = null; + var other = Trigger("id-a", hash: "hash-a"); + other.TenantId = "tenant-a"; + await store.SaveAsync(unassigned); + await store.SaveAsync(other); + + var found = (await store.FindManyAsync(new TriggerFilter())).ToList(); + + Assert.Single(found); + Assert.Equal("id-null", found[0].Id); + } + + private static MemoryTriggerStore CreateStore(string tenantId) => + new(new MemoryStore(), new TestTenantAccessor(tenantId)); + + private static async Task SeedMixedTenantsAsync(MemoryTriggerStore store) + { + await store.SaveAsync(Trigger("id-a", tenantId: "tenant-a", hash: "hash-a")); + await store.SaveAsync(Trigger("id-b", tenantId: "tenant-b", hash: "hash-b")); + await store.SaveAsync(Trigger("id-star", tenantId: Tenant.AgnosticTenantId, hash: "hash-star")); + } + + private static StoredTrigger Trigger(string id, string? tenantId = null, string hash = "hash-1") => + new() + { + Id = id, + TenantId = tenantId, + WorkflowDefinitionId = "workflow-1", + WorkflowDefinitionVersionId = "v1", + ActivityId = id, + Hash = hash, + Name = "Elsa.HttpEndpoint" + }; +} diff --git a/test/unit/Elsa.Workflows.Runtime.UnitTests/Stores/MemoryTriggerStoreTests.cs b/test/unit/Elsa.Workflows.Runtime.UnitTests/Stores/MemoryTriggerStoreTests.cs index 624c2f4bc..d8b3dde43 100644 --- a/test/unit/Elsa.Workflows.Runtime.UnitTests/Stores/MemoryTriggerStoreTests.cs +++ b/test/unit/Elsa.Workflows.Runtime.UnitTests/Stores/MemoryTriggerStoreTests.cs @@ -96,7 +96,7 @@ public class MemoryTriggerStoreTests await store.ReplaceAsync([], [tenantA, tenantB]); - var stored = (await store.FindManyAsync(new TriggerFilter())).ToList(); + var stored = (await store.FindManyAsync(new TriggerFilter { TenantAgnostic = true })).ToList(); Assert.Equal(2, stored.Count); Assert.Contains(stored, x => x.Id == "id-a"); Assert.Contains(stored, x => x.Id == "id-b");