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:
Sipke Schoorstra 2026-09-13 04:16:09 -07:00 committed by GitHub
parent f4762308a2
commit 326e886a41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 501 additions and 39 deletions

View file

@ -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}

View file

@ -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;
}
}

View file

@ -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;
}
}

View 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>

View file

@ -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
};
}

View file

@ -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
};
}