fix(labels): honor tenant isolation in InMemory label stores (#8102)
* fix(labels): honor tenant isolation in InMemory label stores Stamp ambient TenantId on save and filter find/list/delete/replace through TenantVisibility so Memory Labels match EF SetTenantIdFilter and ApplyTenantId. Labels contracts have no TenantAgnostic flag. Closes #8089 Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * test(labels): account for * visibility when asserting tenant-b leftovers Tenant B correctly sees tenant-agnostic associations; assert those rows remain alongside the other tenant's data after delete/replace. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix(labels): delete visible Memory rows in one locked step Find-then-Delete(id) could remove another tenant's same-ID replacement that landed between the visibility check and the remove. DeleteWhere under MemoryStore.Sync keeps the check and removal together. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
parent
f4762308a2
commit
326e886a41
16
Elsa.sln
16
Elsa.sln
|
|
@ -514,8 +514,11 @@ EndProject
|
|||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.Persistence.EFCore.UnitTests", "test\unit\Elsa.UserTasks.Persistence.EFCore.UnitTests\Elsa.UserTasks.Persistence.EFCore.UnitTests.csproj", "{A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.Persistence.ConformanceTests", "test\unit\Elsa.UserTasks.Persistence.ConformanceTests\Elsa.UserTasks.Persistence.ConformanceTests.csproj", "{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Hosts.SmokeTests", "test\integration\Elsa.Hosts.SmokeTests\Elsa.Hosts.SmokeTests.csproj", "{0BE4BC01-D02D-4185-A433-E33780584261}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Labels.UnitTests", "test\unit\Elsa.Labels.UnitTests\Elsa.Labels.UnitTests.csproj", "{ED8D7C78-154D-4124-8C0A-E63785A862E5}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -2468,6 +2471,18 @@ Global
|
|||
{0BE4BC01-D02D-4185-A433-E33780584261}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0BE4BC01-D02D-4185-A433-E33780584261}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0BE4BC01-D02D-4185-A433-E33780584261}.Release|x86.Build.0 = Release|Any CPU
|
||||
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{ED8D7C78-154D-4124-8C0A-E63785A862E5}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
@ -2673,6 +2688,7 @@ Global
|
|||
{A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E} = {AB07AAEB-2C7A-1088-880A-08DB82DA218D}
|
||||
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F} = {18453B51-25EB-4317-A4B3-B10518252E92}
|
||||
{0BE4BC01-D02D-4185-A433-E33780584261} = {90031D64-CA0F-46D0-9AF4-8DC023A5FFCD}
|
||||
{ED8D7C78-154D-4124-8C0A-E63785A862E5} = {18453B51-25EB-4317-A4B3-B10518252E92}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using Elsa.Common.Entities;
|
||||
using Elsa.Common.Models;
|
||||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Common.Services;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Labels.Contracts;
|
||||
|
|
@ -9,62 +11,92 @@ namespace Elsa.Labels.Services;
|
|||
/// <summary>
|
||||
/// An in-memory store of labels.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ambient tenant is applied here rather than in callers.
|
||||
/// EF owns that via <c>SetTenantIdFilter</c> / <c>ApplyTenantId</c>; Memory must compensate.
|
||||
/// Labels contracts have no TenantAgnostic flag, so isolation always applies (EF query filter).
|
||||
/// </remarks>
|
||||
public class InMemoryLabelStore : ILabelStore
|
||||
{
|
||||
private readonly MemoryStore<Label> _labelStore;
|
||||
private readonly MemoryStore<WorkflowDefinitionLabel> _workflowDefinitionLabelStore;
|
||||
private readonly ITenantAccessor? _tenantAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor.
|
||||
/// </summary>
|
||||
public InMemoryLabelStore(MemoryStore<Label> labelStore, MemoryStore<WorkflowDefinitionLabel> workflowDefinitionLabelStore)
|
||||
public InMemoryLabelStore(
|
||||
MemoryStore<Label> labelStore,
|
||||
MemoryStore<WorkflowDefinitionLabel> workflowDefinitionLabelStore,
|
||||
ITenantAccessor? tenantAccessor = null)
|
||||
{
|
||||
_labelStore = labelStore;
|
||||
_workflowDefinitionLabelStore = workflowDefinitionLabelStore;
|
||||
_tenantAccessor = tenantAccessor;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveAsync(Label record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_labelStore.Save(record, x => x.Id);
|
||||
ApplyCurrentTenant(record);
|
||||
lock (_labelStore.Sync)
|
||||
_labelStore.Save(record, x => x.Id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveManyAsync(IEnumerable<Label> records, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_labelStore.SaveMany(records, x => x.Id);
|
||||
var list = records.ToList();
|
||||
|
||||
foreach (var record in list)
|
||||
ApplyCurrentTenant(record);
|
||||
|
||||
lock (_labelStore.Sync)
|
||||
_labelStore.SaveMany(list, x => x.Id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> DeleteAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_workflowDefinitionLabelStore.DeleteWhere(x => x.LabelId == id);
|
||||
var result = _labelStore.Delete(id);
|
||||
return Task.FromResult(result);
|
||||
lock (_labelStore.Sync)
|
||||
lock (_workflowDefinitionLabelStore.Sync)
|
||||
{
|
||||
var deleted = _labelStore.DeleteWhere(x => x.Id == id && IsVisible(x));
|
||||
|
||||
if (deleted == 0)
|
||||
return Task.FromResult(false);
|
||||
|
||||
_workflowDefinitionLabelStore.DeleteWhere(x => x.LabelId == id && IsVisible(x));
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> DeleteManyAsync(IEnumerable<string> ids, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var idList = ids.ToList();
|
||||
_workflowDefinitionLabelStore.DeleteWhere(x => idList.Contains(x.LabelId));
|
||||
var result = _labelStore.DeleteMany(idList);
|
||||
return Task.FromResult(result);
|
||||
|
||||
lock (_labelStore.Sync)
|
||||
lock (_workflowDefinitionLabelStore.Sync)
|
||||
{
|
||||
var deleted = _labelStore.DeleteWhere(x => idList.Contains(x.Id) && IsVisible(x));
|
||||
_workflowDefinitionLabelStore.DeleteWhere(x => idList.Contains(x.LabelId) && IsVisible(x));
|
||||
return Task.FromResult(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Label?> FindByIdAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var record = _labelStore.Find(x => x.Id == id);
|
||||
return Task.FromResult(record);
|
||||
return Task.FromResult(FindVisibleLabel(id));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Page<Label>> ListAsync(PageArgs? pageArgs = default, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _labelStore.List().AsQueryable().OrderBy(x => x.Name);
|
||||
var query = _labelStore.List().AsQueryable().WhereVisibleToTenant(CurrentTenantId).OrderBy(x => x.Name);
|
||||
var page = query.ToPage(pageArgs);
|
||||
return Task.FromResult(page);
|
||||
}
|
||||
|
|
@ -73,6 +105,22 @@ public class InMemoryLabelStore : ILabelStore
|
|||
public Task<IEnumerable<Label>> FindManyByIdAsync(IEnumerable<string> ids, CancellationToken cancellationToken)
|
||||
{
|
||||
var idList = ids.ToList();
|
||||
return Task.FromResult(_labelStore.FindMany(x => idList.Contains(x.Id)));
|
||||
var records = _labelStore.Query(query => query.WhereVisibleToTenant(CurrentTenantId).Where(x => idList.Contains(x.Id)));
|
||||
return Task.FromResult(records);
|
||||
}
|
||||
}
|
||||
|
||||
private Label? FindVisibleLabel(string id) =>
|
||||
_labelStore.Query(query => query.WhereVisibleToTenant(CurrentTenantId).Where(x => x.Id == id)).FirstOrDefault();
|
||||
|
||||
private bool IsVisible(Entity entity) => TenantVisibility.IsVisible(entity.TenantId, CurrentTenantId);
|
||||
|
||||
private string CurrentTenantId => _tenantAccessor?.TenantId ?? Tenant.DefaultTenantId;
|
||||
|
||||
private void ApplyCurrentTenant(Entity entity)
|
||||
{
|
||||
if (entity.TenantId == Tenant.AgnosticTenantId || _tenantAccessor is null)
|
||||
return;
|
||||
|
||||
entity.TenantId ??= _tenantAccessor.TenantId;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using Elsa.Common.Entities;
|
||||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Common.Services;
|
||||
using Elsa.Labels.Contracts;
|
||||
using Elsa.Labels.Entities;
|
||||
|
|
@ -7,43 +9,58 @@ namespace Elsa.Labels.Services;
|
|||
/// <summary>
|
||||
/// An in-memory store of workflow-label associations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ambient tenant is applied here rather than in callers.
|
||||
/// EF owns that via <c>SetTenantIdFilter</c> / <c>ApplyTenantId</c>; Memory must compensate.
|
||||
/// Labels contracts have no TenantAgnostic flag, so isolation always applies (EF query filter).
|
||||
/// </remarks>
|
||||
public class InMemoryWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStore, IWorkflowDefinitionLabelQuery
|
||||
{
|
||||
private readonly MemoryStore<WorkflowDefinitionLabel> _store;
|
||||
private readonly ITenantAccessor? _tenantAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor.
|
||||
/// </summary>
|
||||
public InMemoryWorkflowDefinitionLabelStore(MemoryStore<WorkflowDefinitionLabel> store)
|
||||
public InMemoryWorkflowDefinitionLabelStore(MemoryStore<WorkflowDefinitionLabel> store, ITenantAccessor? tenantAccessor = null)
|
||||
{
|
||||
_store = store;
|
||||
_tenantAccessor = tenantAccessor;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveAsync(WorkflowDefinitionLabel record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store.Save(record, x => x.Id);
|
||||
ApplyCurrentTenant(record);
|
||||
lock (_store.Sync)
|
||||
_store.Save(record, x => x.Id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveManyAsync(IEnumerable<WorkflowDefinitionLabel> records, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store.SaveMany(records, x => x.Id);
|
||||
var list = records.ToList();
|
||||
|
||||
foreach (var record in list)
|
||||
ApplyCurrentTenant(record);
|
||||
|
||||
lock (_store.Sync)
|
||||
_store.SaveMany(list, x => x.Id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> DeleteAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _store.Delete(id);
|
||||
return Task.FromResult(result);
|
||||
lock (_store.Sync)
|
||||
return Task.FromResult(_store.DeleteWhere(x => x.Id == id && IsVisible(x)) > 0);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IEnumerable<WorkflowDefinitionLabel>> FindByWorkflowDefinitionVersionIdAsync(string workflowDefinitionVersionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _store.FindMany(x => x.WorkflowDefinitionVersionId == workflowDefinitionVersionId);
|
||||
var result = _store.Query(query => query.WhereVisibleToTenant(CurrentTenantId).Where(x => x.WorkflowDefinitionVersionId == workflowDefinitionVersionId));
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
|
|
@ -51,52 +68,67 @@ public class InMemoryWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStor
|
|||
public Task<IEnumerable<WorkflowDefinitionLabel>> FindByLabelIdsAsync(IEnumerable<string> labelIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = labelIds.ToHashSet();
|
||||
var result = _store.FindMany(x => ids.Contains(x.LabelId));
|
||||
var result = _store.Query(query => query.WhereVisibleToTenant(CurrentTenantId).Where(x => ids.Contains(x.LabelId)));
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ReplaceAsync(IEnumerable<WorkflowDefinitionLabel> removed, IEnumerable<WorkflowDefinitionLabel> added, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store.DeleteMany(removed, x => x.Id);
|
||||
_store.SaveMany(added, x => x.Id);
|
||||
var removedIds = removed.Select(x => x.Id).ToHashSet();
|
||||
var addedList = added.ToList();
|
||||
|
||||
foreach (var record in addedList)
|
||||
ApplyCurrentTenant(record);
|
||||
|
||||
lock (_store.Sync)
|
||||
{
|
||||
_store.DeleteWhere(x => removedIds.Contains(x.Id) && IsVisible(x));
|
||||
_store.SaveMany(addedList, x => x.Id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> DeleteByWorkflowDefinitionIdAsync(string workflowDefinitionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _store.DeleteWhere(x => x.WorkflowDefinitionId == workflowDefinitionId);
|
||||
return Task.FromResult(result);
|
||||
lock (_store.Sync)
|
||||
return Task.FromResult(_store.DeleteWhere(x => x.WorkflowDefinitionId == workflowDefinitionId && IsVisible(x)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> DeleteByWorkflowDefinitionVersionIdAsync(string workflowDefinitionVersionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _store.DeleteWhere(x => x.WorkflowDefinitionVersionId == workflowDefinitionVersionId);
|
||||
return Task.FromResult(result);
|
||||
lock (_store.Sync)
|
||||
return Task.FromResult(_store.DeleteWhere(x => x.WorkflowDefinitionVersionId == workflowDefinitionVersionId && IsVisible(x)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> DeleteByWorkflowDefinitionIdsAsync(IEnumerable<string> workflowDefinitionIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = workflowDefinitionIds.ToList();
|
||||
var result = _store.DeleteWhere(x => ids.Contains(x.WorkflowDefinitionId));
|
||||
return Task.FromResult(result);
|
||||
lock (_store.Sync)
|
||||
return Task.FromResult(_store.DeleteWhere(x => ids.Contains(x.WorkflowDefinitionId) && IsVisible(x)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> DeleteByWorkflowDefinitionVersionIdsAsync(IEnumerable<string> workflowDefinitionVersionIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = workflowDefinitionVersionIds.ToList();
|
||||
var result = _store.DeleteWhere(x => ids.Contains(x.WorkflowDefinitionVersionId));
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private Task<long> DeleteManyAsync(IEnumerable<string> ids, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _store.DeleteMany(ids);
|
||||
return Task.FromResult(result);
|
||||
lock (_store.Sync)
|
||||
return Task.FromResult(_store.DeleteWhere(x => ids.Contains(x.WorkflowDefinitionVersionId) && IsVisible(x)));
|
||||
}
|
||||
|
||||
}
|
||||
private bool IsVisible(Entity entity) => TenantVisibility.IsVisible(entity.TenantId, CurrentTenantId);
|
||||
|
||||
private string CurrentTenantId => _tenantAccessor?.TenantId ?? Tenant.DefaultTenantId;
|
||||
|
||||
private void ApplyCurrentTenant(Entity entity)
|
||||
{
|
||||
if (entity.TenantId == Tenant.AgnosticTenantId || _tenantAccessor is null)
|
||||
return;
|
||||
|
||||
entity.TenantId ??= _tenantAccessor.TenantId;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
test/unit/Elsa.Labels.UnitTests/Elsa.Labels.UnitTests.csproj
Normal file
12
test/unit/Elsa.Labels.UnitTests/Elsa.Labels.UnitTests.csproj
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Include>[Elsa.Labels]*</Include>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\modules\Elsa.Labels\Elsa.Labels.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Common.Services;
|
||||
using Elsa.Labels.Entities;
|
||||
using Elsa.Labels.Services;
|
||||
using Elsa.Testing.Shared.Multitenancy;
|
||||
|
||||
namespace Elsa.Labels.UnitTests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Memory Labels must honor ambient tenant the same way EF does via
|
||||
/// <c>SetTenantIdFilter</c> / <c>ApplyTenantId</c>. Contracts have no TenantAgnostic flag.
|
||||
/// </summary>
|
||||
public class InMemoryLabelStoreTenantIsolationTests
|
||||
{
|
||||
[Fact(DisplayName = "ListAsync hides other tenants and keeps * visible")]
|
||||
public async Task ListAsync_HidesOtherTenants()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = (await store.ListAsync()).Items.ToList();
|
||||
|
||||
Assert.Equal(2, found.Count);
|
||||
Assert.Contains(found, x => x.Id == "label-a");
|
||||
Assert.Contains(found, x => x.Id == "label-star");
|
||||
Assert.DoesNotContain(found, x => x.Id == "label-b");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindByIdAsync does not return another tenant's row")]
|
||||
public async Task FindByIdAsync_WhenOtherTenant_ReturnsNull()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = await store.FindByIdAsync("label-b");
|
||||
|
||||
Assert.Null(found);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindManyByIdAsync hides other tenants")]
|
||||
public async Task FindManyByIdAsync_HidesOtherTenants()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = (await store.FindManyByIdAsync(["label-a", "label-b", "label-star"], CancellationToken.None)).ToList();
|
||||
|
||||
Assert.Equal(2, found.Count);
|
||||
Assert.Contains(found, x => x.Id == "label-a");
|
||||
Assert.Contains(found, x => x.Id == "label-star");
|
||||
Assert.DoesNotContain(found, x => x.Id == "label-b");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DeleteAsync does not remove another tenant's rows or associations")]
|
||||
public async Task DeleteAsync_DoesNotDeleteOtherTenantRows()
|
||||
{
|
||||
var labels = new MemoryStore<Label>();
|
||||
var associations = new MemoryStore<WorkflowDefinitionLabel>();
|
||||
var tenantA = new InMemoryLabelStore(labels, associations, new TestTenantAccessor("tenant-a"));
|
||||
var tenantB = new InMemoryLabelStore(labels, associations, new TestTenantAccessor("tenant-b"));
|
||||
var tenantBAssociations = new InMemoryWorkflowDefinitionLabelStore(associations, new TestTenantAccessor("tenant-b"));
|
||||
await SeedMixedTenantsAsync(tenantA);
|
||||
await tenantBAssociations.SaveAsync(Association("assoc-b", "label-b", "tenant-b"));
|
||||
|
||||
var deleted = await tenantA.DeleteAsync("label-b");
|
||||
var remainingForB = await tenantB.FindByIdAsync("label-b");
|
||||
var remainingAssociations = (await tenantBAssociations.FindByLabelIdsAsync(["label-b"])).ToList();
|
||||
|
||||
Assert.False(deleted);
|
||||
Assert.NotNull(remainingForB);
|
||||
Assert.Equal("label-b", remainingForB.Id);
|
||||
Assert.Single(remainingAssociations);
|
||||
Assert.Equal("assoc-b", remainingAssociations[0].Id);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DeleteManyAsync does not wipe other tenants' rows")]
|
||||
public async Task DeleteManyAsync_DoesNotWipeOtherTenantRows()
|
||||
{
|
||||
var labels = new MemoryStore<Label>();
|
||||
var associations = new MemoryStore<WorkflowDefinitionLabel>();
|
||||
var tenantA = new InMemoryLabelStore(labels, associations, new TestTenantAccessor("tenant-a"));
|
||||
var tenantB = new InMemoryLabelStore(labels, associations, new TestTenantAccessor("tenant-b"));
|
||||
await SeedMixedTenantsAsync(tenantA);
|
||||
|
||||
var deleted = await tenantA.DeleteManyAsync(["label-a", "label-b", "label-star"]);
|
||||
var remainingForA = (await tenantA.ListAsync()).Items.ToList();
|
||||
var remainingForB = await tenantB.FindByIdAsync("label-b");
|
||||
|
||||
Assert.Equal(2, deleted);
|
||||
Assert.Empty(remainingForA);
|
||||
Assert.NotNull(remainingForB);
|
||||
Assert.Equal("label-b", remainingForB.Id);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DeleteAsync leaves a same-ID row after another tenant replaces it")]
|
||||
public async Task DeleteAsync_WhenSameIdWasReplacedByOtherTenant_LeavesReplacement()
|
||||
{
|
||||
var labels = new MemoryStore<Label>();
|
||||
var associations = new MemoryStore<WorkflowDefinitionLabel>();
|
||||
var tenantA = new InMemoryLabelStore(labels, associations, new TestTenantAccessor("tenant-a"));
|
||||
var tenantB = new InMemoryLabelStore(labels, associations, new TestTenantAccessor("tenant-b"));
|
||||
await tenantA.SaveAsync(new Label { Id = "shared", Name = "A", TenantId = "tenant-a" });
|
||||
await tenantB.SaveAsync(new Label { Id = "shared", Name = "B", TenantId = "tenant-b" });
|
||||
|
||||
var deleted = await tenantA.DeleteAsync("shared");
|
||||
var remaining = await tenantB.FindByIdAsync("shared");
|
||||
|
||||
Assert.False(deleted);
|
||||
Assert.NotNull(remaining);
|
||||
Assert.Equal("tenant-b", remaining.TenantId);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "SaveAsync stamps the ambient tenant when TenantId is unset")]
|
||||
public async Task SaveAsync_WhenTenantIdUnset_StampsAmbientTenant()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
var label = new Label { Id = "label-new", Name = "New" };
|
||||
|
||||
await store.SaveAsync(label);
|
||||
|
||||
Assert.Equal("tenant-a", label.TenantId);
|
||||
Assert.Equal("tenant-a", (await store.FindByIdAsync("label-new"))!.TenantId);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "SaveAsync does not overwrite * or an explicit TenantId")]
|
||||
public async Task SaveAsync_DoesNotOverwriteAgnosticOrExplicitTenantId()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
var agnostic = new Label { Id = "label-star", Name = "Star", TenantId = Tenant.AgnosticTenantId };
|
||||
var explicitTenant = new Label { Id = "label-a", Name = "A", TenantId = "tenant-a" };
|
||||
|
||||
await store.SaveAsync(agnostic);
|
||||
await store.SaveAsync(explicitTenant);
|
||||
|
||||
Assert.Equal(Tenant.AgnosticTenantId, agnostic.TenantId);
|
||||
Assert.Equal("tenant-a", explicitTenant.TenantId);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ListAsync on the default tenant includes null TenantId rows")]
|
||||
public async Task ListAsync_WhenAmbientIsDefault_IncludesNullTenantId()
|
||||
{
|
||||
var store = StoreWithPreassignedRows(Tenant.DefaultTenantId);
|
||||
|
||||
var found = (await store.ListAsync()).Items.ToList();
|
||||
|
||||
Assert.Single(found);
|
||||
Assert.Equal("label-null", found[0].Id);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ListAsync on a named tenant hides null TenantId rows")]
|
||||
public async Task ListAsync_WhenAmbientIsNamed_HidesNullTenantId()
|
||||
{
|
||||
var store = StoreWithPreassignedRows("tenant-a");
|
||||
|
||||
var found = (await store.ListAsync()).Items.ToList();
|
||||
|
||||
Assert.Single(found);
|
||||
Assert.Equal("label-a", found[0].Id);
|
||||
}
|
||||
|
||||
private static InMemoryLabelStore CreateStore(string tenantId) =>
|
||||
new(new MemoryStore<Label>(), new MemoryStore<WorkflowDefinitionLabel>(), new TestTenantAccessor(tenantId));
|
||||
|
||||
private static InMemoryLabelStore StoreWithPreassignedRows(string ambientTenantId)
|
||||
{
|
||||
var labels = new MemoryStore<Label>();
|
||||
labels.Save(new Label { Id = "label-null", Name = "Null", TenantId = null }, x => x.Id);
|
||||
labels.Save(new Label { Id = "label-a", Name = "A", TenantId = "tenant-a" }, x => x.Id);
|
||||
return new InMemoryLabelStore(labels, new MemoryStore<WorkflowDefinitionLabel>(), new TestTenantAccessor(ambientTenantId));
|
||||
}
|
||||
|
||||
private static async Task SeedMixedTenantsAsync(InMemoryLabelStore store)
|
||||
{
|
||||
await store.SaveAsync(new Label { Id = "label-a", Name = "A", TenantId = "tenant-a" });
|
||||
await store.SaveAsync(new Label { Id = "label-b", Name = "B", TenantId = "tenant-b" });
|
||||
await store.SaveAsync(new Label { Id = "label-star", Name = "Star", TenantId = Tenant.AgnosticTenantId });
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionLabel Association(string id, string labelId, string tenantId) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
LabelId = labelId,
|
||||
WorkflowDefinitionId = "order",
|
||||
WorkflowDefinitionVersionId = "order:1",
|
||||
TenantId = tenantId
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
using Elsa.Common.Multitenancy;
|
||||
using Elsa.Common.Services;
|
||||
using Elsa.Labels.Entities;
|
||||
using Elsa.Labels.Services;
|
||||
using Elsa.Testing.Shared.Multitenancy;
|
||||
|
||||
namespace Elsa.Labels.UnitTests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Memory workflow-definition labels must honor ambient tenant the same way EF does via
|
||||
/// <c>SetTenantIdFilter</c> / <c>ApplyTenantId</c>. Contracts have no TenantAgnostic flag.
|
||||
/// </summary>
|
||||
public class InMemoryWorkflowDefinitionLabelStoreTenantIsolationTests
|
||||
{
|
||||
[Fact(DisplayName = "FindByWorkflowDefinitionVersionIdAsync hides other tenants and keeps * visible")]
|
||||
public async Task FindByWorkflowDefinitionVersionIdAsync_HidesOtherTenants()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = (await store.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
|
||||
|
||||
Assert.Equal(2, found.Count);
|
||||
Assert.Contains(found, x => x.Id == "assoc-a");
|
||||
Assert.Contains(found, x => x.Id == "assoc-star");
|
||||
Assert.DoesNotContain(found, x => x.Id == "assoc-b");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindByLabelIdsAsync hides other tenants")]
|
||||
public async Task FindByLabelIdsAsync_HidesOtherTenants()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
await SeedMixedTenantsAsync(store);
|
||||
|
||||
var found = (await store.FindByLabelIdsAsync(["red"])).ToList();
|
||||
|
||||
Assert.Equal(2, found.Count);
|
||||
Assert.Contains(found, x => x.Id == "assoc-a");
|
||||
Assert.Contains(found, x => x.Id == "assoc-star");
|
||||
Assert.DoesNotContain(found, x => x.Id == "assoc-b");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DeleteAsync does not remove another tenant's row")]
|
||||
public async Task DeleteAsync_DoesNotDeleteOtherTenantRows()
|
||||
{
|
||||
var backing = new MemoryStore<WorkflowDefinitionLabel>();
|
||||
var tenantA = new InMemoryWorkflowDefinitionLabelStore(backing, new TestTenantAccessor("tenant-a"));
|
||||
var tenantB = new InMemoryWorkflowDefinitionLabelStore(backing, new TestTenantAccessor("tenant-b"));
|
||||
await SeedMixedTenantsAsync(tenantA);
|
||||
|
||||
var deleted = await tenantA.DeleteAsync("assoc-b");
|
||||
var remainingForB = (await tenantB.FindByLabelIdsAsync(["red"])).ToList();
|
||||
|
||||
Assert.False(deleted);
|
||||
Assert.Contains(remainingForB, x => x.Id == "assoc-b");
|
||||
Assert.Contains(remainingForB, x => x.Id == "assoc-star");
|
||||
Assert.DoesNotContain(remainingForB, x => x.Id == "assoc-a");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DeleteAsync leaves a same-ID row after another tenant replaces it")]
|
||||
public async Task DeleteAsync_WhenSameIdWasReplacedByOtherTenant_LeavesReplacement()
|
||||
{
|
||||
var backing = new MemoryStore<WorkflowDefinitionLabel>();
|
||||
var tenantA = new InMemoryWorkflowDefinitionLabelStore(backing, new TestTenantAccessor("tenant-a"));
|
||||
var tenantB = new InMemoryWorkflowDefinitionLabelStore(backing, new TestTenantAccessor("tenant-b"));
|
||||
await tenantA.SaveAsync(Association("shared", "tenant-a"));
|
||||
await tenantB.SaveAsync(Association("shared", "tenant-b"));
|
||||
|
||||
var deleted = await tenantA.DeleteAsync("shared");
|
||||
var remaining = (await tenantB.FindByLabelIdsAsync(["red"])).ToList();
|
||||
|
||||
Assert.False(deleted);
|
||||
Assert.Single(remaining);
|
||||
Assert.Equal("shared", remaining[0].Id);
|
||||
Assert.Equal("tenant-b", remaining[0].TenantId);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DeleteByWorkflowDefinitionIdAsync leaves the other tenant's associations")]
|
||||
public async Task DeleteByWorkflowDefinitionIdAsync_WhenDefinitionIdIsShared_LeavesOtherTenantRows()
|
||||
{
|
||||
var backing = new MemoryStore<WorkflowDefinitionLabel>();
|
||||
var tenantA = new InMemoryWorkflowDefinitionLabelStore(backing, new TestTenantAccessor("tenant-a"));
|
||||
var tenantB = new InMemoryWorkflowDefinitionLabelStore(backing, new TestTenantAccessor("tenant-b"));
|
||||
await tenantA.SaveAsync(Association("assoc-a", "tenant-a"));
|
||||
await tenantB.SaveAsync(Association("assoc-b", "tenant-b"));
|
||||
|
||||
var deleted = await tenantA.DeleteByWorkflowDefinitionIdAsync("order");
|
||||
var remainingForA = (await tenantA.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
|
||||
var remainingForB = (await tenantB.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
|
||||
|
||||
Assert.Equal(1, deleted);
|
||||
Assert.Empty(remainingForA);
|
||||
Assert.Single(remainingForB);
|
||||
Assert.Equal("assoc-b", remainingForB[0].Id);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ReplaceAsync does not delete another tenant's rows")]
|
||||
public async Task ReplaceAsync_DoesNotDeleteOtherTenantRows()
|
||||
{
|
||||
var backing = new MemoryStore<WorkflowDefinitionLabel>();
|
||||
var tenantA = new InMemoryWorkflowDefinitionLabelStore(backing, new TestTenantAccessor("tenant-a"));
|
||||
var tenantB = new InMemoryWorkflowDefinitionLabelStore(backing, new TestTenantAccessor("tenant-b"));
|
||||
await SeedMixedTenantsAsync(tenantA);
|
||||
|
||||
await tenantA.ReplaceAsync(
|
||||
[Association("assoc-a", "tenant-a"), Association("assoc-b", "tenant-b")],
|
||||
[Association("assoc-a2", "tenant-a")]);
|
||||
|
||||
var remainingForA = (await tenantA.FindByWorkflowDefinitionVersionIdAsync("order:1")).ToList();
|
||||
var remainingForB = (await tenantB.FindByLabelIdsAsync(["red"])).ToList();
|
||||
|
||||
Assert.Contains(remainingForA, x => x.Id == "assoc-a2");
|
||||
Assert.Contains(remainingForA, x => x.Id == "assoc-star");
|
||||
Assert.DoesNotContain(remainingForA, x => x.Id == "assoc-a");
|
||||
Assert.Contains(remainingForB, x => x.Id == "assoc-b");
|
||||
Assert.Contains(remainingForB, x => x.Id == "assoc-star");
|
||||
Assert.DoesNotContain(remainingForB, x => x.Id == "assoc-a");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "SaveAsync stamps the ambient tenant when TenantId is unset")]
|
||||
public async Task SaveAsync_WhenTenantIdUnset_StampsAmbientTenant()
|
||||
{
|
||||
var store = CreateStore("tenant-a");
|
||||
var association = Association("assoc-new", tenantId: null);
|
||||
|
||||
await store.SaveAsync(association);
|
||||
|
||||
Assert.Equal("tenant-a", association.TenantId);
|
||||
var found = (await store.FindByLabelIdsAsync(["red"])).Single();
|
||||
Assert.Equal("tenant-a", found.TenantId);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "FindByLabelIdsAsync on the default tenant includes null TenantId rows")]
|
||||
public async Task FindByLabelIdsAsync_WhenAmbientIsDefault_IncludesNullTenantId()
|
||||
{
|
||||
var backing = new MemoryStore<WorkflowDefinitionLabel>();
|
||||
backing.Save(Association("assoc-null", tenantId: null), x => x.Id);
|
||||
backing.Save(Association("assoc-a", "tenant-a"), x => x.Id);
|
||||
var store = new InMemoryWorkflowDefinitionLabelStore(backing, new TestTenantAccessor(Tenant.DefaultTenantId));
|
||||
|
||||
var found = (await store.FindByLabelIdsAsync(["red"])).ToList();
|
||||
|
||||
Assert.Single(found);
|
||||
Assert.Equal("assoc-null", found[0].Id);
|
||||
}
|
||||
|
||||
private static InMemoryWorkflowDefinitionLabelStore CreateStore(string tenantId) =>
|
||||
new(new MemoryStore<WorkflowDefinitionLabel>(), new TestTenantAccessor(tenantId));
|
||||
|
||||
private static async Task SeedMixedTenantsAsync(InMemoryWorkflowDefinitionLabelStore store)
|
||||
{
|
||||
await store.SaveAsync(Association("assoc-a", "tenant-a"));
|
||||
await store.SaveAsync(Association("assoc-b", "tenant-b"));
|
||||
await store.SaveAsync(Association("assoc-star", Tenant.AgnosticTenantId));
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionLabel Association(string id, string? tenantId) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
LabelId = "red",
|
||||
WorkflowDefinitionId = "order",
|
||||
WorkflowDefinitionVersionId = "order:1",
|
||||
TenantId = tenantId
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue