diff --git a/Elsa.sln b/Elsa.sln index d5b8e012b..1e94d989d 100644 --- a/Elsa.sln +++ b/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} diff --git a/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationJobStore.cs b/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationJobStore.cs index 1af9f76f0..e4d541e76 100644 --- a/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationJobStore.cs +++ b/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationJobStore.cs @@ -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; /// /// A memory-based store for alteration jobs. /// +/// +/// Ambient tenant is applied here rather than in callers. +/// EF owns that via SetTenantIdFilter / ApplyTenantId; Memory must compensate. +/// Alterations contracts have no TenantAgnostic flag, so isolation always applies (EF query filter). +/// public class MemoryAlterationJobStore : IAlterationJobStore { private readonly MemoryStore _store; + private readonly ITenantAccessor? _tenantAccessor; /// /// Initializes a new instance of the class. /// - public MemoryAlterationJobStore(MemoryStore store) + public MemoryAlterationJobStore(MemoryStore store, ITenantAccessor? tenantAccessor = null) { _store = store; + _tenantAccessor = tenantAccessor; } /// 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; } /// public Task SaveManyAsync(IEnumerable 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); } + /// + /// Ambient tenant is applied here rather than in . + /// EF owns that via SetTenantIdFilter; Memory must compensate. + /// + private IQueryable Filter(IQueryable query, AlterationJobFilter filter) => + filter.Apply(query.WhereVisibleToTenant(CurrentTenantId)); - private static IQueryable Filter(IQueryable query, AlterationJobFilter filter) => filter.Apply(query); -} \ No newline at end of file + 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."); + } + } + + /// + /// * is visible to every tenant, but only an agnostic writer may replace it. + /// Named tenants may upsert their own visible rows. + /// + 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; + } +} diff --git a/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationPlanStore.cs b/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationPlanStore.cs index 9657c5864..bfde620ea 100644 --- a/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationPlanStore.cs +++ b/src/modules/Elsa.Alterations.Core/Stores/MemoryAlterationPlanStore.cs @@ -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; /// /// A memory-based store for alteration plans. /// +/// +/// Ambient tenant is applied here rather than in callers. +/// EF owns that via SetTenantIdFilter / ApplyTenantId; Memory must compensate. +/// Alterations contracts have no TenantAgnostic flag, so isolation always applies (EF query filter). +/// public class MemoryAlterationPlanStore : IAlterationPlanStore { private readonly MemoryStore _store; + private readonly ITenantAccessor? _tenantAccessor; /// /// Initializes a new instance of the class. /// - public MemoryAlterationPlanStore(MemoryStore store) + public MemoryAlterationPlanStore(MemoryStore store, ITenantAccessor? tenantAccessor = null) { _store = store; + _tenantAccessor = tenantAccessor; } - + /// 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 Filter(IQueryable query, AlterationPlanFilter filter) => filter.Apply(query); -} \ No newline at end of file + /// + /// Ambient tenant is applied here rather than in . + /// EF owns that via SetTenantIdFilter; Memory must compensate. + /// + private IQueryable Filter(IQueryable 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."); + } + } + + /// + /// * is visible to every tenant, but only an agnostic writer may replace it. + /// Named tenants may upsert their own visible rows. + /// + 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; + } +} diff --git a/test/unit/Elsa.Alterations.Core.UnitTests/Elsa.Alterations.Core.UnitTests.csproj b/test/unit/Elsa.Alterations.Core.UnitTests/Elsa.Alterations.Core.UnitTests.csproj new file mode 100644 index 000000000..03e0a764d --- /dev/null +++ b/test/unit/Elsa.Alterations.Core.UnitTests/Elsa.Alterations.Core.UnitTests.csproj @@ -0,0 +1,12 @@ + + + + [Elsa.Alterations.Core]* + + + + + + + + diff --git a/test/unit/Elsa.Alterations.Core.UnitTests/Stores/MemoryAlterationJobStoreTenantIsolationTests.cs b/test/unit/Elsa.Alterations.Core.UnitTests/Stores/MemoryAlterationJobStoreTenantIsolationTests.cs new file mode 100644 index 000000000..ab152522f --- /dev/null +++ b/test/unit/Elsa.Alterations.Core.UnitTests/Stores/MemoryAlterationJobStoreTenantIsolationTests.cs @@ -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; + +/// +/// Memory alteration jobs must honor ambient tenant the same way EF does via +/// SetTenantIdFilter / ApplyTenantId. Contracts have no TenantAgnostic flag. +/// +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(); + 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(() => 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(); + 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(() => 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(); + 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(() => 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(); + 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(() => 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(); + 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(); + 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(), 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 + }; +} diff --git a/test/unit/Elsa.Alterations.Core.UnitTests/Stores/MemoryAlterationPlanStoreTenantIsolationTests.cs b/test/unit/Elsa.Alterations.Core.UnitTests/Stores/MemoryAlterationPlanStoreTenantIsolationTests.cs new file mode 100644 index 000000000..2efc30483 --- /dev/null +++ b/test/unit/Elsa.Alterations.Core.UnitTests/Stores/MemoryAlterationPlanStoreTenantIsolationTests.cs @@ -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; + +/// +/// Memory alteration plans must honor ambient tenant the same way EF does via +/// SetTenantIdFilter / ApplyTenantId. Contracts have no TenantAgnostic flag. +/// +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(); + 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(() => 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(); + 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(() => 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(), new TestTenantAccessor(tenantId)); + + private static MemoryAlterationPlanStore StoreWithPreassignedRows(string ambientTenantId) + { + var backing = new MemoryStore(); + 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 + }; +}