diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Assembly.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Assembly.cs new file mode 100644 index 00000000..37aa2a17 --- /dev/null +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Assembly.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Elsa.MongoDb.UnitTests")] diff --git a/src/modules/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs b/src/modules/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs index af4371a0..98819401 100644 --- a/src/modules/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs +++ b/src/modules/persistence/Elsa.Persistence.MongoDb/Common/MongoDbStore.cs @@ -437,19 +437,24 @@ private IQueryable GetQueryableCollection(bool tenantAgnostic = false { var tenant = tenantAccessor.Tenant; var tenantId = tenant?.Id.EmptyToNull(); - queryable = queryable.Where(x => (x as Entity)!.TenantId == tenantId); + queryable = ApplyTenantFilter(queryable, tenantId); } return queryable; } + internal static IQueryable ApplyTenantFilter(IQueryable queryable, string? tenantId) + { + return queryable.Where(x => (x as Entity)!.TenantId == tenantId || (x as Entity)!.TenantId == Tenant.AgnosticTenantId); + } + private void ApplyTenantId(TDocument document) { var tenant = tenantAccessor.Tenant; var tenantId = tenant?.Id; if (document is Entity tenantDocument) - tenantDocument.TenantId = tenantId.EmptyToNull(); + ApplyTenantId(tenantDocument, tenantId); } private void ApplyTenantId(IEnumerable documents) @@ -460,7 +465,19 @@ private void ApplyTenantId(IEnumerable documents) foreach (var document in documents) { if (document is Entity tenantDocument) - tenantDocument.TenantId = tenantId.EmptyToNull(); + ApplyTenantId(tenantDocument, tenantId); } } + + private static void ApplyTenantId(Entity tenantDocument, string? tenantId) + { + if (tenantDocument.TenantId == Tenant.AgnosticTenantId) + return; + + // Preserve explicitly scoped entities and only stamp ambient tenant IDs on unscoped entities. + if (tenantDocument.TenantId != null) + return; + + tenantDocument.TenantId = tenantId.EmptyToNull(); + } } diff --git a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoKeyValueStoreTests.cs b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoKeyValueStoreTests.cs index 904b7972..f826fd86 100644 --- a/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoKeyValueStoreTests.cs +++ b/test/modules/persistence/Elsa.MongoDb.UnitTests/MongoKeyValueStoreTests.cs @@ -1,4 +1,5 @@ using Elsa.Common.Multitenancy; +using Elsa.Common.Entities; using Elsa.KeyValues.Entities; using Elsa.Persistence.MongoDb.Common; using Elsa.Persistence.MongoDb.Modules.Runtime; @@ -35,4 +36,89 @@ public async Task SaveAsync_WithSerializedKeyValuePairDocument_DoesNotThrowExcep Assert.Null(exception); } +} + +public class MongoDbStoreTenantTests +{ + [Fact(DisplayName = "When saving an agnostic entity, preserve the agnostic tenant marker")] + public async Task SaveAsync_WithAgnosticTenant_PreservesAgnosticMarker() + { + var mongoCollectionMock = Substitute.For>(); + mongoCollectionMock.FindOneAndReplaceAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any>() + ) + .Returns(callInfo => callInfo.Arg()); + + var tenantAccessorMock = Substitute.For(); + tenantAccessorMock.Tenant.Returns(new Tenant { Id = "tenant-a", Name = "tenant-a" }); + var store = new MongoDbStore(mongoCollectionMock, tenantAccessorMock); + var entity = new TestEntity { Id = "1", TenantId = Tenant.AgnosticTenantId }; + + await store.SaveAsync(entity); + + Assert.Equal(Tenant.AgnosticTenantId, entity.TenantId); + } + + [Fact(DisplayName = "When saving an unscoped entity under a tenant, stamp ambient tenant")] + public async Task SaveAsync_WithNullTenant_StampsAmbientTenant() + { + var mongoCollectionMock = Substitute.For>(); + mongoCollectionMock.FindOneAndReplaceAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any>() + ) + .Returns(callInfo => callInfo.Arg()); + + var tenantAccessorMock = Substitute.For(); + tenantAccessorMock.Tenant.Returns(new Tenant { Id = "tenant-a", Name = "tenant-a" }); + var store = new MongoDbStore(mongoCollectionMock, tenantAccessorMock); + var entity = new TestEntity { Id = "1", TenantId = null }; + + await store.SaveAsync(entity); + + Assert.Equal("tenant-a", entity.TenantId); + } + + [Fact(DisplayName = "Tenant filter includes tenant-specific and agnostic records")] + public void ApplyTenantFilter_WithConcreteTenant_IncludesAgnostic() + { + var documents = new[] + { + new TestEntity { Id = "specific", TenantId = "tenant-a" }, + new TestEntity { Id = "agnostic", TenantId = Tenant.AgnosticTenantId }, + new TestEntity { Id = "other", TenantId = "tenant-b" }, + new TestEntity { Id = "null", TenantId = null } + }.AsQueryable(); + + var filtered = MongoDbStore.ApplyTenantFilter(documents, "tenant-a"); + var ids = filtered.Select(x => x.Id).ToList(); + + Assert.Contains("specific", ids); + Assert.Contains("agnostic", ids); + Assert.DoesNotContain("other", ids); + Assert.DoesNotContain("null", ids); + } + + [Fact(DisplayName = "Tenant filter includes null and agnostic when ambient tenant is agnostic")] + public void ApplyTenantFilter_WithAgnosticTenant_IncludesNullAndAgnostic() + { + var documents = new[] + { + new TestEntity { Id = "specific", TenantId = "tenant-a" }, + new TestEntity { Id = "agnostic", TenantId = Tenant.AgnosticTenantId }, + new TestEntity { Id = "null", TenantId = null } + }.AsQueryable(); + + var filtered = MongoDbStore.ApplyTenantFilter(documents, null); + var ids = filtered.Select(x => x.Id).ToList(); + + Assert.Contains("agnostic", ids); + Assert.Contains("null", ids); + Assert.DoesNotContain("specific", ids); + } + + public class TestEntity : Entity; } \ No newline at end of file