fix(alterations): honor tenant isolation in Memory alteration stores (#8106)

* fix(alterations): honor tenant isolation in Memory alteration stores

Stamp ambient tenant on Memory plan/job saves and filter find/count/list
with TenantVisibility so default Alterations persistence matches EF
SetTenantIdFilter / ApplyTenantId. Locks Save under MemoryStore.Sync.

Fixes #8093

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(alterations): refuse Memory Save when Id belongs to another tenant

Fail closed on Save/SaveMany if the ID already exists and is not visible
to the ambient tenant, matching TriggerStore collision style. Visible
same-tenant upsert is unchanged.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(alterations): refuse named-tenant overwrite of agnostic Memory rows

* is visible to every tenant, so EnsureIdAvailable now allows replacing
a * plan/job only when the ambient tenant is also *. Named-tenant Save
and SaveMany fail closed and leave the shared row in place.

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 14:04:46 +02:00 committed by GitHub
parent 326e886a41
commit 373f99d163
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 600 additions and 10 deletions

View file

@ -519,6 +519,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Hosts.SmokeTests", "te
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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Alterations.Core.UnitTests", "test\unit\Elsa.Alterations.Core.UnitTests\Elsa.Alterations.Core.UnitTests.csproj", "{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -2483,6 +2485,18 @@ Global
{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
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Debug|x64.ActiveCfg = Debug|Any CPU
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Debug|x64.Build.0 = Debug|Any CPU
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Debug|x86.ActiveCfg = Debug|Any CPU
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Debug|x86.Build.0 = Debug|Any CPU
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|Any CPU.Build.0 = Release|Any CPU
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|x64.ActiveCfg = Release|Any CPU
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|x64.Build.0 = Release|Any CPU
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|x86.ActiveCfg = Release|Any CPU
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -2689,6 +2703,7 @@ Global
{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}
{EAF427BE-7A07-42D7-A223-70FB1FC91BB2} = {18453B51-25EB-4317-A4B3-B10518252E92}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -1,6 +1,8 @@
using Elsa.Alterations.Core.Contracts;
using Elsa.Alterations.Core.Entities;
using Elsa.Alterations.Core.Filters;
using Elsa.Common.Entities;
using Elsa.Common.Multitenancy;
using Elsa.Common.Services;
namespace Elsa.Alterations.Core.Stores;
@ -8,29 +10,54 @@ namespace Elsa.Alterations.Core.Stores;
/// <summary>
/// A memory-based store for alteration jobs.
/// </summary>
/// <remarks>
/// Ambient tenant is applied here rather than in callers.
/// EF owns that via <c>SetTenantIdFilter</c> / <c>ApplyTenantId</c>; Memory must compensate.
/// Alterations contracts have no TenantAgnostic flag, so isolation always applies (EF query filter).
/// </remarks>
public class MemoryAlterationJobStore : IAlterationJobStore
{
private readonly MemoryStore<AlterationJob> _store;
private readonly ITenantAccessor? _tenantAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="MemoryAlterationJobStore"/> class.
/// </summary>
public MemoryAlterationJobStore(MemoryStore<AlterationJob> store)
public MemoryAlterationJobStore(MemoryStore<AlterationJob> store, ITenantAccessor? tenantAccessor = null)
{
_store = store;
_tenantAccessor = tenantAccessor;
}
/// <inheritdoc />
public Task SaveAsync(AlterationJob job, CancellationToken cancellationToken = default)
{
_store.Save(job, x => x.Id);
lock (_store.Sync)
{
ApplyCurrentTenant(job);
EnsureIdAvailable(job);
_store.Save(job, x => x.Id);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveManyAsync(IEnumerable<AlterationJob> jobs, CancellationToken cancellationToken = default)
{
_store.SaveMany(jobs, x => x.Id);
var list = jobs.ToList();
lock (_store.Sync)
{
foreach (var job in list)
ApplyCurrentTenant(job);
foreach (var job in list)
EnsureIdAvailable(job);
_store.SaveMany(list, x => x.Id);
}
return Task.CompletedTask;
}
@ -62,6 +89,42 @@ public class MemoryAlterationJobStore : IAlterationJobStore
return Task.FromResult(count);
}
/// <remarks>
/// Ambient tenant is applied here rather than in <see cref="AlterationJobFilter.Apply"/>.
/// EF owns that via <c>SetTenantIdFilter</c>; Memory must compensate.
/// </remarks>
private IQueryable<AlterationJob> Filter(IQueryable<AlterationJob> query, AlterationJobFilter filter) =>
filter.Apply(query.WhereVisibleToTenant(CurrentTenantId));
private static IQueryable<AlterationJob> Filter(IQueryable<AlterationJob> query, AlterationJobFilter filter) => filter.Apply(query);
}
private string CurrentTenantId => _tenantAccessor?.TenantId ?? Tenant.DefaultTenantId;
private bool IsVisible(Entity entity) => TenantVisibility.IsVisible(entity.TenantId, CurrentTenantId);
private void EnsureIdAvailable(AlterationJob job)
{
var existing = _store.Find(x => x.Id == job.Id);
if (existing is not null && !CanReplace(existing))
{
throw new InvalidOperationException(
$"An alteration job with ID '{job.Id}' already exists and is not visible to the current tenant.");
}
}
/// <summary>
/// <c>*</c> is visible to every tenant, but only an agnostic writer may replace it.
/// Named tenants may upsert their own visible rows.
/// </summary>
private bool CanReplace(Entity existing) =>
existing.TenantId == Tenant.AgnosticTenantId
? CurrentTenantId == Tenant.AgnosticTenantId
: IsVisible(existing);
private void ApplyCurrentTenant(Entity entity)
{
if (entity.TenantId == Tenant.AgnosticTenantId || _tenantAccessor is null)
return;
entity.TenantId ??= _tenantAccessor.TenantId;
}
}

View file

@ -1,6 +1,8 @@
using Elsa.Alterations.Core.Contracts;
using Elsa.Alterations.Core.Entities;
using Elsa.Alterations.Core.Filters;
using Elsa.Common.Entities;
using Elsa.Common.Multitenancy;
using Elsa.Common.Services;
namespace Elsa.Alterations.Core.Stores;
@ -8,22 +10,35 @@ namespace Elsa.Alterations.Core.Stores;
/// <summary>
/// A memory-based store for alteration plans.
/// </summary>
/// <remarks>
/// Ambient tenant is applied here rather than in callers.
/// EF owns that via <c>SetTenantIdFilter</c> / <c>ApplyTenantId</c>; Memory must compensate.
/// Alterations contracts have no TenantAgnostic flag, so isolation always applies (EF query filter).
/// </remarks>
public class MemoryAlterationPlanStore : IAlterationPlanStore
{
private readonly MemoryStore<AlterationPlan> _store;
private readonly ITenantAccessor? _tenantAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="MemoryAlterationPlanStore"/> class.
/// </summary>
public MemoryAlterationPlanStore(MemoryStore<AlterationPlan> store)
public MemoryAlterationPlanStore(MemoryStore<AlterationPlan> store, ITenantAccessor? tenantAccessor = null)
{
_store = store;
_tenantAccessor = tenantAccessor;
}
/// <inheritdoc />
public Task SaveAsync(AlterationPlan plan, CancellationToken cancellationToken = default)
{
_store.Save(plan, x => x.Id);
lock (_store.Sync)
{
ApplyCurrentTenant(plan);
EnsureIdAvailable(plan);
_store.Save(plan, x => x.Id);
}
return Task.CompletedTask;
}
@ -41,5 +56,42 @@ public class MemoryAlterationPlanStore : IAlterationPlanStore
return Task.FromResult(count);
}
private static IQueryable<AlterationPlan> Filter(IQueryable<AlterationPlan> query, AlterationPlanFilter filter) => filter.Apply(query);
}
/// <remarks>
/// Ambient tenant is applied here rather than in <see cref="AlterationPlanFilter.Apply"/>.
/// EF owns that via <c>SetTenantIdFilter</c>; Memory must compensate.
/// </remarks>
private IQueryable<AlterationPlan> Filter(IQueryable<AlterationPlan> query, AlterationPlanFilter filter) =>
filter.Apply(query.WhereVisibleToTenant(CurrentTenantId));
private string CurrentTenantId => _tenantAccessor?.TenantId ?? Tenant.DefaultTenantId;
private bool IsVisible(Entity entity) => TenantVisibility.IsVisible(entity.TenantId, CurrentTenantId);
private void EnsureIdAvailable(AlterationPlan plan)
{
var existing = _store.Find(x => x.Id == plan.Id);
if (existing is not null && !CanReplace(existing))
{
throw new InvalidOperationException(
$"An alteration plan with ID '{plan.Id}' already exists and is not visible to the current tenant.");
}
}
/// <summary>
/// <c>*</c> is visible to every tenant, but only an agnostic writer may replace it.
/// Named tenants may upsert their own visible rows.
/// </summary>
private bool CanReplace(Entity existing) =>
existing.TenantId == Tenant.AgnosticTenantId
? CurrentTenantId == Tenant.AgnosticTenantId
: IsVisible(existing);
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.Alterations.Core]*</Include>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Alterations.Core\Elsa.Alterations.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,252 @@
using Elsa.Alterations.Core.Entities;
using Elsa.Alterations.Core.Enums;
using Elsa.Alterations.Core.Filters;
using Elsa.Alterations.Core.Stores;
using Elsa.Common.Multitenancy;
using Elsa.Common.Services;
using Elsa.Testing.Shared.Multitenancy;
namespace Elsa.Alterations.Core.UnitTests.Stores;
/// <summary>
/// Memory alteration jobs must honor ambient tenant the same way EF does via
/// <c>SetTenantIdFilter</c> / <c>ApplyTenantId</c>. Contracts have no TenantAgnostic flag.
/// </summary>
public class MemoryAlterationJobStoreTenantIsolationTests
{
[Fact(DisplayName = "FindManyAsync hides other tenants and keeps * visible")]
public async Task FindManyAsync_HidesOtherTenants()
{
var store = CreateStore("tenant-a");
await SeedMixedTenantsAsync(store);
var found = (await store.FindManyAsync(new AlterationJobFilter { PlanId = "plan-1" }, CancellationToken.None)).ToList();
Assert.Equal(2, found.Count);
Assert.Contains(found, x => x.Id == "job-a");
Assert.Contains(found, x => x.Id == "job-star");
Assert.DoesNotContain(found, x => x.Id == "job-b");
}
[Fact(DisplayName = "FindAsync does not return another tenant's row")]
public async Task FindAsync_WhenOtherTenant_ReturnsNull()
{
var store = CreateStore("tenant-a");
await SeedMixedTenantsAsync(store);
var found = await store.FindAsync(new AlterationJobFilter { Id = "job-b" });
Assert.Null(found);
}
[Fact(DisplayName = "FindManyIdsAsync hides other tenants")]
public async Task FindManyIdsAsync_HidesOtherTenants()
{
var store = CreateStore("tenant-a");
await SeedMixedTenantsAsync(store);
var found = (await store.FindManyIdsAsync(new AlterationJobFilter { PlanId = "plan-1" })).ToList();
Assert.Equal(2, found.Count);
Assert.Contains("job-a", found);
Assert.Contains("job-star", found);
Assert.DoesNotContain("job-b", found);
}
[Fact(DisplayName = "CountAsync hides other tenants and keeps * visible")]
public async Task CountAsync_HidesOtherTenants()
{
var store = CreateStore("tenant-a");
await SeedMixedTenantsAsync(store);
var count = await store.CountAsync(new AlterationJobFilter { PlanId = "plan-1" });
Assert.Equal(2, count);
}
[Fact(DisplayName = "SaveAsync refuses to overwrite another tenant's row by Id")]
public async Task SaveAsync_WhenOtherTenantOwnsId_ThrowsAndLeavesExisting()
{
var backing = new MemoryStore<AlterationJob>();
var tenantA = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-a"));
var tenantB = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-b"));
await tenantA.SaveAsync(Job("shared", "tenant-a"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => tenantB.SaveAsync(Job("shared", "tenant-b")));
var remaining = await tenantA.FindAsync(new AlterationJobFilter { Id = "shared" });
Assert.Contains("shared", ex.Message);
Assert.NotNull(remaining);
Assert.Equal("tenant-a", remaining.TenantId);
}
[Fact(DisplayName = "SaveManyAsync refuses to overwrite another tenant's row by Id")]
public async Task SaveManyAsync_WhenOtherTenantOwnsId_ThrowsAndLeavesExisting()
{
var backing = new MemoryStore<AlterationJob>();
var tenantA = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-a"));
var tenantB = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-b"));
await tenantA.SaveAsync(Job("shared", "tenant-a"));
await Assert.ThrowsAsync<InvalidOperationException>(() => tenantB.SaveManyAsync([Job("shared", "tenant-b")]));
var remaining = await tenantA.FindAsync(new AlterationJobFilter { Id = "shared" });
Assert.NotNull(remaining);
Assert.Equal("tenant-a", remaining.TenantId);
}
[Fact(DisplayName = "SaveAsync refuses to overwrite a tenant-agnostic row by Id")]
public async Task SaveAsync_WhenAgnosticRowExists_NamedTenantThrowsAndLeavesExisting()
{
var backing = new MemoryStore<AlterationJob>();
var agnostic = new MemoryAlterationJobStore(backing, new TestTenantAccessor(Tenant.AgnosticTenantId));
var tenantB = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-b"));
await agnostic.SaveAsync(Job("shared", Tenant.AgnosticTenantId));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => tenantB.SaveAsync(Job("shared", "tenant-b")));
var remaining = await agnostic.FindAsync(new AlterationJobFilter { Id = "shared" });
Assert.Contains("shared", ex.Message);
Assert.NotNull(remaining);
Assert.Equal(Tenant.AgnosticTenantId, remaining.TenantId);
}
[Fact(DisplayName = "SaveManyAsync refuses to overwrite a tenant-agnostic row by Id")]
public async Task SaveManyAsync_WhenAgnosticRowExists_NamedTenantThrowsAndLeavesExisting()
{
var backing = new MemoryStore<AlterationJob>();
var agnostic = new MemoryAlterationJobStore(backing, new TestTenantAccessor(Tenant.AgnosticTenantId));
var tenantB = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-b"));
await agnostic.SaveAsync(Job("shared", Tenant.AgnosticTenantId));
await Assert.ThrowsAsync<InvalidOperationException>(() => tenantB.SaveManyAsync([Job("shared", "tenant-b")]));
var remaining = await agnostic.FindAsync(new AlterationJobFilter { Id = "shared" });
Assert.NotNull(remaining);
Assert.Equal(Tenant.AgnosticTenantId, remaining.TenantId);
}
[Fact(DisplayName = "SaveAsync still lets an agnostic writer update a * row")]
public async Task SaveAsync_WhenAmbientIsAgnostic_UpsertsAgnosticRow()
{
var store = CreateStore(Tenant.AgnosticTenantId);
await store.SaveAsync(Job("shared", Tenant.AgnosticTenantId));
var updated = Job("shared", Tenant.AgnosticTenantId);
updated.Status = AlterationJobStatus.Completed;
await store.SaveAsync(updated);
var found = await store.FindAsync(new AlterationJobFilter { Id = "shared" });
Assert.NotNull(found);
Assert.Equal(AlterationJobStatus.Completed, found.Status);
Assert.Equal(Tenant.AgnosticTenantId, found.TenantId);
}
[Fact(DisplayName = "SaveAsync still upserts a visible same-tenant row")]
public async Task SaveAsync_WhenSameTenantOwnsId_Upserts()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(Job("job-a", "tenant-a"));
var updated = Job("job-a", "tenant-a");
updated.Status = AlterationJobStatus.Completed;
await store.SaveAsync(updated);
var found = await store.FindAsync(new AlterationJobFilter { Id = "job-a" });
Assert.NotNull(found);
Assert.Equal(AlterationJobStatus.Completed, found.Status);
Assert.Equal("tenant-a", found.TenantId);
}
[Fact(DisplayName = "SaveAsync stamps the ambient tenant when TenantId is unset")]
public async Task SaveAsync_WhenTenantIdUnset_StampsAmbientTenant()
{
var store = CreateStore("tenant-a");
var job = Job("job-new", tenantId: null);
await store.SaveAsync(job);
Assert.Equal("tenant-a", job.TenantId);
Assert.Equal("tenant-a", (await store.FindAsync(new AlterationJobFilter { Id = "job-new" }))!.TenantId);
}
[Fact(DisplayName = "SaveManyAsync stamps the ambient tenant when TenantId is unset")]
public async Task SaveManyAsync_WhenTenantIdUnset_StampsAmbientTenant()
{
var store = CreateStore("tenant-a");
var jobs = new[]
{
Job("job-1", tenantId: null),
Job("job-2", tenantId: null)
};
await store.SaveManyAsync(jobs);
Assert.All(jobs, job => Assert.Equal("tenant-a", job.TenantId));
var found = (await store.FindManyAsync(new AlterationJobFilter { PlanId = "plan-1" }, CancellationToken.None)).ToList();
Assert.Equal(2, found.Count);
Assert.All(found, job => Assert.Equal("tenant-a", job.TenantId));
}
[Fact(DisplayName = "SaveAsync does not overwrite * or an explicit TenantId")]
public async Task SaveAsync_DoesNotOverwriteAgnosticOrExplicitTenantId()
{
var store = CreateStore("tenant-a");
var agnostic = Job("job-star", Tenant.AgnosticTenantId);
var explicitTenant = Job("job-a", "tenant-a");
await store.SaveAsync(agnostic);
await store.SaveAsync(explicitTenant);
Assert.Equal(Tenant.AgnosticTenantId, agnostic.TenantId);
Assert.Equal("tenant-a", explicitTenant.TenantId);
}
[Fact(DisplayName = "FindManyAsync on the default tenant includes null TenantId rows")]
public async Task FindManyAsync_WhenAmbientIsDefault_IncludesNullTenantId()
{
var backing = new MemoryStore<AlterationJob>();
backing.Save(Job("job-null", tenantId: null), x => x.Id);
backing.Save(Job("job-a", "tenant-a"), x => x.Id);
var store = new MemoryAlterationJobStore(backing, new TestTenantAccessor(Tenant.DefaultTenantId));
var found = (await store.FindManyAsync(new AlterationJobFilter { PlanId = "plan-1" }, CancellationToken.None)).ToList();
Assert.Single(found);
Assert.Equal("job-null", found[0].Id);
}
[Fact(DisplayName = "FindManyAsync on a named tenant hides null TenantId rows")]
public async Task FindManyAsync_WhenAmbientIsNamed_HidesNullTenantId()
{
var backing = new MemoryStore<AlterationJob>();
backing.Save(Job("job-null", tenantId: null), x => x.Id);
backing.Save(Job("job-a", "tenant-a"), x => x.Id);
var store = new MemoryAlterationJobStore(backing, new TestTenantAccessor("tenant-a"));
var found = (await store.FindManyAsync(new AlterationJobFilter { PlanId = "plan-1" }, CancellationToken.None)).ToList();
Assert.Single(found);
Assert.Equal("job-a", found[0].Id);
}
private static MemoryAlterationJobStore CreateStore(string tenantId) =>
new(new MemoryStore<AlterationJob>(), new TestTenantAccessor(tenantId));
private static async Task SeedMixedTenantsAsync(MemoryAlterationJobStore store)
{
await store.SaveAsync(Job("job-a", "tenant-a"));
await store.SaveAsync(Job("job-b", "tenant-b"));
await store.SaveAsync(Job("job-star", Tenant.AgnosticTenantId));
}
private static AlterationJob Job(string id, string? tenantId) =>
new()
{
Id = id,
PlanId = "plan-1",
WorkflowInstanceId = "instance-1",
Status = AlterationJobStatus.Pending,
TenantId = tenantId
};
}

View file

@ -0,0 +1,196 @@
using Elsa.Alterations.Core.Entities;
using Elsa.Alterations.Core.Enums;
using Elsa.Alterations.Core.Filters;
using Elsa.Alterations.Core.Stores;
using Elsa.Common.Multitenancy;
using Elsa.Common.Services;
using Elsa.Testing.Shared.Multitenancy;
namespace Elsa.Alterations.Core.UnitTests.Stores;
/// <summary>
/// Memory alteration plans must honor ambient tenant the same way EF does via
/// <c>SetTenantIdFilter</c> / <c>ApplyTenantId</c>. Contracts have no TenantAgnostic flag.
/// </summary>
public class MemoryAlterationPlanStoreTenantIsolationTests
{
[Fact(DisplayName = "FindAsync hides other tenants and keeps * visible")]
public async Task FindAsync_HidesOtherTenants()
{
var store = CreateStore("tenant-a");
await SeedMixedTenantsAsync(store);
var own = await store.FindAsync(new AlterationPlanFilter { Id = "plan-a" });
var star = await store.FindAsync(new AlterationPlanFilter { Id = "plan-star" });
var other = await store.FindAsync(new AlterationPlanFilter { Id = "plan-b" });
Assert.NotNull(own);
Assert.Equal("plan-a", own.Id);
Assert.NotNull(star);
Assert.Equal("plan-star", star.Id);
Assert.Null(other);
}
[Fact(DisplayName = "CountAsync hides other tenants and keeps * visible")]
public async Task CountAsync_HidesOtherTenants()
{
var store = CreateStore("tenant-a");
await SeedMixedTenantsAsync(store);
var count = await store.CountAsync(new AlterationPlanFilter());
Assert.Equal(2, count);
}
[Fact(DisplayName = "CountAsync does not count another tenant's row by Id")]
public async Task CountAsync_WhenOtherTenant_ReturnsZero()
{
var store = CreateStore("tenant-a");
await SeedMixedTenantsAsync(store);
var count = await store.CountAsync(new AlterationPlanFilter { Id = "plan-b" });
Assert.Equal(0, count);
}
[Fact(DisplayName = "SaveAsync refuses to overwrite another tenant's row by Id")]
public async Task SaveAsync_WhenOtherTenantOwnsId_ThrowsAndLeavesExisting()
{
var backing = new MemoryStore<AlterationPlan>();
var tenantA = new MemoryAlterationPlanStore(backing, new TestTenantAccessor("tenant-a"));
var tenantB = new MemoryAlterationPlanStore(backing, new TestTenantAccessor("tenant-b"));
await tenantA.SaveAsync(Plan("shared", "tenant-a"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => tenantB.SaveAsync(Plan("shared", "tenant-b")));
var remaining = await tenantA.FindAsync(new AlterationPlanFilter { Id = "shared" });
Assert.Contains("shared", ex.Message);
Assert.NotNull(remaining);
Assert.Equal("tenant-a", remaining.TenantId);
}
[Fact(DisplayName = "SaveAsync refuses to overwrite a tenant-agnostic row by Id")]
public async Task SaveAsync_WhenAgnosticRowExists_NamedTenantThrowsAndLeavesExisting()
{
var backing = new MemoryStore<AlterationPlan>();
var agnostic = new MemoryAlterationPlanStore(backing, new TestTenantAccessor(Tenant.AgnosticTenantId));
var tenantB = new MemoryAlterationPlanStore(backing, new TestTenantAccessor("tenant-b"));
await agnostic.SaveAsync(Plan("shared", Tenant.AgnosticTenantId));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => tenantB.SaveAsync(Plan("shared", "tenant-b")));
var remaining = await agnostic.FindAsync(new AlterationPlanFilter { Id = "shared" });
Assert.Contains("shared", ex.Message);
Assert.NotNull(remaining);
Assert.Equal(Tenant.AgnosticTenantId, remaining.TenantId);
}
[Fact(DisplayName = "SaveAsync still lets an agnostic writer update a * row")]
public async Task SaveAsync_WhenAmbientIsAgnostic_UpsertsAgnosticRow()
{
var store = CreateStore(Tenant.AgnosticTenantId);
await store.SaveAsync(Plan("shared", Tenant.AgnosticTenantId));
var updated = Plan("shared", Tenant.AgnosticTenantId);
updated.Status = AlterationPlanStatus.Completed;
await store.SaveAsync(updated);
var found = await store.FindAsync(new AlterationPlanFilter { Id = "shared" });
Assert.NotNull(found);
Assert.Equal(AlterationPlanStatus.Completed, found.Status);
Assert.Equal(Tenant.AgnosticTenantId, found.TenantId);
}
[Fact(DisplayName = "SaveAsync still upserts a visible same-tenant row")]
public async Task SaveAsync_WhenSameTenantOwnsId_Upserts()
{
var store = CreateStore("tenant-a");
await store.SaveAsync(Plan("plan-a", "tenant-a"));
var updated = Plan("plan-a", "tenant-a");
updated.Status = AlterationPlanStatus.Completed;
await store.SaveAsync(updated);
var found = await store.FindAsync(new AlterationPlanFilter { Id = "plan-a" });
Assert.NotNull(found);
Assert.Equal(AlterationPlanStatus.Completed, found.Status);
Assert.Equal("tenant-a", found.TenantId);
}
[Fact(DisplayName = "SaveAsync stamps the ambient tenant when TenantId is unset")]
public async Task SaveAsync_WhenTenantIdUnset_StampsAmbientTenant()
{
var store = CreateStore("tenant-a");
var plan = Plan("plan-new", tenantId: null);
await store.SaveAsync(plan);
Assert.Equal("tenant-a", plan.TenantId);
Assert.Equal("tenant-a", (await store.FindAsync(new AlterationPlanFilter { Id = "plan-new" }))!.TenantId);
}
[Fact(DisplayName = "SaveAsync does not overwrite * or an explicit TenantId")]
public async Task SaveAsync_DoesNotOverwriteAgnosticOrExplicitTenantId()
{
var store = CreateStore("tenant-a");
var agnostic = Plan("plan-star", Tenant.AgnosticTenantId);
var explicitTenant = Plan("plan-a", "tenant-a");
await store.SaveAsync(agnostic);
await store.SaveAsync(explicitTenant);
Assert.Equal(Tenant.AgnosticTenantId, agnostic.TenantId);
Assert.Equal("tenant-a", explicitTenant.TenantId);
}
[Fact(DisplayName = "CountAsync on the default tenant includes null TenantId rows")]
public async Task CountAsync_WhenAmbientIsDefault_IncludesNullTenantId()
{
var store = StoreWithPreassignedRows(Tenant.DefaultTenantId);
var count = await store.CountAsync(new AlterationPlanFilter());
var found = await store.FindAsync(new AlterationPlanFilter { Id = "plan-null" });
Assert.Equal(1, count);
Assert.NotNull(found);
Assert.Equal("plan-null", found.Id);
}
[Fact(DisplayName = "CountAsync on a named tenant hides null TenantId rows")]
public async Task CountAsync_WhenAmbientIsNamed_HidesNullTenantId()
{
var store = StoreWithPreassignedRows("tenant-a");
var count = await store.CountAsync(new AlterationPlanFilter());
var found = await store.FindAsync(new AlterationPlanFilter { Id = "plan-a" });
Assert.Equal(1, count);
Assert.NotNull(found);
Assert.Equal("plan-a", found.Id);
}
private static MemoryAlterationPlanStore CreateStore(string tenantId) =>
new(new MemoryStore<AlterationPlan>(), new TestTenantAccessor(tenantId));
private static MemoryAlterationPlanStore StoreWithPreassignedRows(string ambientTenantId)
{
var backing = new MemoryStore<AlterationPlan>();
backing.Save(Plan("plan-null", tenantId: null), x => x.Id);
backing.Save(Plan("plan-a", "tenant-a"), x => x.Id);
return new MemoryAlterationPlanStore(backing, new TestTenantAccessor(ambientTenantId));
}
private static async Task SeedMixedTenantsAsync(MemoryAlterationPlanStore store)
{
await store.SaveAsync(Plan("plan-a", "tenant-a"));
await store.SaveAsync(Plan("plan-b", "tenant-b"));
await store.SaveAsync(Plan("plan-star", Tenant.AgnosticTenantId));
}
private static AlterationPlan Plan(string id, string? tenantId) =>
new()
{
Id = id,
TenantId = tenantId
};
}