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:
parent
326e886a41
commit
373f99d163
15
Elsa.sln
15
Elsa.sln
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue