From 1126f536f88a997707a971d90dbce039152db97f Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 13 Sep 2026 15:26:08 +0200 Subject: [PATCH] fix(identity): enforce per-tenant uniqueness in Memory identity stores (#8108) * fix(identity): enforce per-tenant uniqueness in Memory identity stores Mirror EF PerTenantIdentityUniqueness on Memory user, role, and application stores so a second Id cannot claim the same Name or ClientId within a tenant. Same-Id upserts may still rename themselves. Co-authored-by: Sipke Schoorstra * fix(identity): keep Memory identity finds from mutating stored rows Clone user, role, and application rows on read so a rejected Save of a Find result cannot leave a colliding name or client id in the store. Co-authored-by: Sipke Schoorstra --------- Co-authored-by: Cursor Agent --- .../Services/MemoryApplicationStore.cs | 23 +++- .../Services/MemoryIdentityUniqueness.cs | 35 +++++ .../Elsa.Identity/Services/MemoryRoleStore.cs | 26 +++- .../Elsa.Identity/Services/MemoryUserStore.cs | 21 ++- .../MemoryApplicationStoreUniquenessTests.cs | 130 ++++++++++++++++++ .../MemoryRoleStoreUniquenessTests.cs | 111 +++++++++++++++ .../MemoryUserStoreUniquenessTests.cs | 105 ++++++++++++++ 7 files changed, 442 insertions(+), 9 deletions(-) create mode 100644 src/modules/Elsa.Identity/Services/MemoryIdentityUniqueness.cs create mode 100644 test/unit/Elsa.Identity.UnitTests/Services/MemoryApplicationStoreUniquenessTests.cs create mode 100644 test/unit/Elsa.Identity.UnitTests/Services/MemoryRoleStoreUniquenessTests.cs create mode 100644 test/unit/Elsa.Identity.UnitTests/Services/MemoryUserStoreUniquenessTests.cs diff --git a/src/modules/Elsa.Identity/Services/MemoryApplicationStore.cs b/src/modules/Elsa.Identity/Services/MemoryApplicationStore.cs index 2c7ce54c1..7ca8f56b0 100644 --- a/src/modules/Elsa.Identity/Services/MemoryApplicationStore.cs +++ b/src/modules/Elsa.Identity/Services/MemoryApplicationStore.cs @@ -32,9 +32,14 @@ public class MemoryApplicationStore : IApplicationStore /// public Task SaveAsync(Application application, CancellationToken cancellationToken = default) { - ApplyCurrentTenant(application); lock (_store.Sync) + { + ApplyCurrentTenant(application); + MemoryIdentityUniqueness.EnsureAvailable(_store, application, x => x.Name, "name"); + MemoryIdentityUniqueness.EnsureAvailable(_store, application, x => x.ClientId, "client id"); _store.Save(application, x => x.Id); + } + return Task.CompletedTask; } @@ -53,7 +58,7 @@ public class MemoryApplicationStore : IApplicationStore /// public Task FindAsync(ApplicationFilter filter, CancellationToken cancellationToken = default) { - var result = _store.Query(query => Filter(query, filter)).FirstOrDefault(); + var result = _store.Query(query => Filter(query, filter)).Select(Clone).FirstOrDefault(); return Task.FromResult(result); } @@ -69,4 +74,18 @@ public class MemoryApplicationStore : IApplicationStore entity.TenantId ??= _tenantAccessor.TenantId; } + + private static Application Clone(Application application) => + new() + { + Id = application.Id, + Name = application.Name, + ClientId = application.ClientId, + TenantId = application.TenantId, + HashedApiKey = application.HashedApiKey, + HashedApiKeySalt = application.HashedApiKeySalt, + HashedClientSecret = application.HashedClientSecret, + HashedClientSecretSalt = application.HashedClientSecretSalt, + Roles = application.Roles.ToList() + }; } diff --git a/src/modules/Elsa.Identity/Services/MemoryIdentityUniqueness.cs b/src/modules/Elsa.Identity/Services/MemoryIdentityUniqueness.cs new file mode 100644 index 000000000..d18ed7729 --- /dev/null +++ b/src/modules/Elsa.Identity/Services/MemoryIdentityUniqueness.cs @@ -0,0 +1,35 @@ +using Elsa.Common.Entities; +using Elsa.Common.Services; + +namespace Elsa.Identity.Services; + +/// +/// Memory counterpart of the EF PerTenantIdentityUniqueness indexes on +/// (TenantId, Name) and (TenantId, ClientId). +/// +internal static class MemoryIdentityUniqueness +{ + /// + /// Throws when another Id in the same tenant already owns . + /// Same-Id upserts are allowed so a row can rename itself. + /// + public static void EnsureAvailable( + MemoryStore store, + T entity, + Func keySelector, + string keyName) + where T : Entity + { + var key = keySelector(entity); + var existing = store.Find(candidate => + candidate.TenantId == entity.TenantId + && candidate.Id != entity.Id + && keySelector(candidate) == key); + + if (existing is not null) + { + throw new InvalidOperationException( + $"A {typeof(T).Name.ToLowerInvariant()} already exists with {keyName} '{key}' in tenant '{entity.TenantId}'."); + } + } +} diff --git a/src/modules/Elsa.Identity/Services/MemoryRoleStore.cs b/src/modules/Elsa.Identity/Services/MemoryRoleStore.cs index 19f104b21..8f0522057 100644 --- a/src/modules/Elsa.Identity/Services/MemoryRoleStore.cs +++ b/src/modules/Elsa.Identity/Services/MemoryRoleStore.cs @@ -26,7 +26,7 @@ public class MemoryRoleStore : IRoleStore, IRoleStoreWithAtomicDelete /// public Task AddAsync(Role role, CancellationToken cancellationToken = default) { - _store.Save(role, GetStorageKey); + Save(role); return Task.CompletedTask; } @@ -55,21 +55,30 @@ public class MemoryRoleStore : IRoleStore, IRoleStoreWithAtomicDelete /// public Task SaveAsync(Role role, CancellationToken cancellationToken = default) { - _store.Save(role, GetStorageKey); + Save(role); return Task.CompletedTask; } + private void Save(Role role) + { + lock (_store.Sync) + { + MemoryIdentityUniqueness.EnsureAvailable(_store, role, x => x.Name, "name"); + _store.Save(role, GetStorageKey); + } + } + /// public Task FindAsync(RoleFilter filter, CancellationToken cancellationToken = default) { - var result = _store.Query(query => Filter(query, filter)).FirstOrDefault(); + var result = _store.Query(query => Filter(query, filter)).Select(Clone).FirstOrDefault(); return Task.FromResult(result); } /// public Task> FindManyAsync(RoleFilter filter, CancellationToken cancellationToken = default) { - var result = _store.Query(query => Filter(query, filter)).ToList().AsEnumerable(); + var result = _store.Query(query => Filter(query, filter)).Select(Clone).ToList().AsEnumerable(); return Task.FromResult(result); } @@ -87,6 +96,15 @@ public class MemoryRoleStore : IRoleStore, IRoleStoreWithAtomicDelete return filter.Apply(queryable); } + private static Role Clone(Role role) => + new() + { + Id = role.Id, + Name = role.Name, + TenantId = role.TenantId, + Permissions = role.Permissions.ToList() + }; + private static string GetStorageKey(Role role) => GetStorageKey(role.TenantId, role.Id); private static string GetStorageKey(string? tenantId, string roleId) => diff --git a/src/modules/Elsa.Identity/Services/MemoryUserStore.cs b/src/modules/Elsa.Identity/Services/MemoryUserStore.cs index aa05bd881..02d5c5546 100644 --- a/src/modules/Elsa.Identity/Services/MemoryUserStore.cs +++ b/src/modules/Elsa.Identity/Services/MemoryUserStore.cs @@ -32,9 +32,13 @@ public class MemoryUserStore : IUserStore /// public Task SaveAsync(User user, CancellationToken cancellationToken = default) { - ApplyCurrentTenant(user); lock (_store.Sync) + { + ApplyCurrentTenant(user); + MemoryIdentityUniqueness.EnsureAvailable(_store, user, x => x.Name, "name"); _store.Save(user, x => x.Id); + } + return Task.CompletedTask; } @@ -53,14 +57,14 @@ public class MemoryUserStore : IUserStore /// public Task> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default) { - var result = _store.Query(query => Filter(query, filter)).ToList(); + var result = _store.Query(query => Filter(query, filter)).Select(Clone).ToList(); return Task.FromResult>(result); } /// public Task FindAsync(UserFilter filter, CancellationToken cancellationToken = default) { - var result = _store.Query(query => Filter(query, filter)).FirstOrDefault(); + var result = _store.Query(query => Filter(query, filter)).Select(Clone).FirstOrDefault(); return Task.FromResult(result); } @@ -76,4 +80,15 @@ public class MemoryUserStore : IUserStore entity.TenantId ??= _tenantAccessor.TenantId; } + + private static User Clone(User user) => + new() + { + Id = user.Id, + Name = user.Name, + TenantId = user.TenantId, + HashedPassword = user.HashedPassword, + HashedPasswordSalt = user.HashedPasswordSalt, + Roles = user.Roles.ToList() + }; } diff --git a/test/unit/Elsa.Identity.UnitTests/Services/MemoryApplicationStoreUniquenessTests.cs b/test/unit/Elsa.Identity.UnitTests/Services/MemoryApplicationStoreUniquenessTests.cs new file mode 100644 index 000000000..91bde013e --- /dev/null +++ b/test/unit/Elsa.Identity.UnitTests/Services/MemoryApplicationStoreUniquenessTests.cs @@ -0,0 +1,130 @@ +using Elsa.Common.Multitenancy; +using Elsa.Common.Services; +using Elsa.Identity.Entities; +using Elsa.Identity.Models; +using Elsa.Identity.Services; +using Elsa.Testing.Shared.Multitenancy; + +namespace Elsa.Identity.UnitTests.Services; + +/// +/// Memory must enforce the same per-tenant application uniqueness that EF Core +/// already enforces via PerTenantIdentityUniqueness on +/// (TenantId, Name) and (TenantId, ClientId). +/// +public class MemoryApplicationStoreUniquenessTests +{ + [Fact(DisplayName = "SaveAsync rejects a different Id that repeats a name in the same tenant")] + public async Task SaveAsync_WhenNameExistsUnderAnotherId_Throws() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(CreateApplication("app-1", "Studio", "client-1", "tenant-a")); + + var exception = await Assert.ThrowsAsync(() => + store.SaveAsync(CreateApplication("app-2", "Studio", "client-2", "tenant-a"))); + + Assert.Contains("already exists", exception.Message); + Assert.Contains("name", exception.Message); + var stored = await store.FindAsync(new ApplicationFilter { Id = "app-1" }); + Assert.NotNull(stored); + Assert.Null(await store.FindAsync(new ApplicationFilter { Id = "app-2" })); + } + + [Fact(DisplayName = "SaveAsync rejects a different Id that repeats a client id in the same tenant")] + public async Task SaveAsync_WhenClientIdExistsUnderAnotherId_Throws() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(CreateApplication("app-1", "Studio", "client-1", "tenant-a")); + + var exception = await Assert.ThrowsAsync(() => + store.SaveAsync(CreateApplication("app-2", "Designer", "client-1", "tenant-a"))); + + Assert.Contains("already exists", exception.Message); + Assert.Contains("client id", exception.Message); + var stored = await store.FindAsync(new ApplicationFilter { Id = "app-1" }); + Assert.NotNull(stored); + Assert.Null(await store.FindAsync(new ApplicationFilter { Id = "app-2" })); + } + + [Fact(DisplayName = "SaveAsync allows the same Id to update its own name and client id")] + public async Task SaveAsync_WhenSameIdUpdatesNameAndClientId_Succeeds() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(CreateApplication("app-1", "Studio", "client-1", "tenant-a")); + + await store.SaveAsync(CreateApplication("app-1", "Designer", "client-2", "tenant-a")); + + var stored = await store.FindAsync(new ApplicationFilter { Id = "app-1" }); + Assert.Equal("Designer", stored!.Name); + Assert.Equal("client-2", stored.ClientId); + } + + [Fact(DisplayName = "SaveAsync allows the same name and client id in different tenants")] + public async Task SaveAsync_WhenNameAndClientIdRepeatInAnotherTenant_Succeeds() + { + var backing = new MemoryStore(); + var tenantA = new MemoryApplicationStore(backing, new TestTenantAccessor("tenant-a")); + var tenantB = new MemoryApplicationStore(backing, new TestTenantAccessor("tenant-b")); + + await tenantA.SaveAsync(CreateApplication("app-a", "Studio", "client-1", "tenant-a")); + await tenantB.SaveAsync(CreateApplication("app-b", "Studio", "client-1", "tenant-b")); + + Assert.Equal("Studio", (await tenantA.FindAsync(new ApplicationFilter { Id = "app-a" }))!.Name); + Assert.Equal("Studio", (await tenantB.FindAsync(new ApplicationFilter { Id = "app-b" }))!.Name); + } + + [Fact(DisplayName = "SaveAsync rejects renaming onto a name another Id already owns")] + public async Task SaveAsync_WhenRenamingOntoAnotherIdsName_Throws() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(CreateApplication("app-1", "Studio", "client-1", "tenant-a")); + await store.SaveAsync(CreateApplication("app-2", "Designer", "client-2", "tenant-a")); + + var exception = await Assert.ThrowsAsync(() => + store.SaveAsync(CreateApplication("app-2", "Studio", "client-2", "tenant-a"))); + + Assert.Contains("already exists", exception.Message); + Assert.Equal("Designer", (await store.FindAsync(new ApplicationFilter { Id = "app-2" }))!.Name); + } + + [Fact(DisplayName = "SaveAsync treats a stamped ambient tenant as the uniqueness tenant")] + public async Task SaveAsync_WhenTenantIdUnset_UsesStampedAmbientTenantForUniqueness() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(CreateApplication("app-1", "Studio", "client-1", tenantId: null)); + + var exception = await Assert.ThrowsAsync(() => + store.SaveAsync(CreateApplication("app-2", "Studio", "client-2", tenantId: null))); + + Assert.Contains("already exists", exception.Message); + Assert.Equal("tenant-a", (await store.FindAsync(new ApplicationFilter { Id = "app-1" }))!.TenantId); + } + + [Fact(DisplayName = "SaveAsync allows the same name for * and a tenant-scoped application")] + public async Task SaveAsync_WhenAgnosticAndTenantScopedShareName_Succeeds() + { + var store = CreateStore("tenant-a"); + + await store.SaveAsync(CreateApplication("app-star", "Studio", "client-star", Tenant.AgnosticTenantId)); + await store.SaveAsync(CreateApplication("app-a", "Studio", "client-a", "tenant-a")); + + Assert.NotNull(await store.FindAsync(new ApplicationFilter { Id = "app-star" })); + Assert.NotNull(await store.FindAsync(new ApplicationFilter { Id = "app-a" })); + } + + private static MemoryApplicationStore CreateStore(string tenantId) => + new(new MemoryStore(), new TestTenantAccessor(tenantId)); + + private static Application CreateApplication(string id, string name, string clientId, string? tenantId) => + new() + { + Id = id, + Name = name, + ClientId = clientId, + HashedApiKey = "", + HashedApiKeySalt = "", + HashedClientSecret = "", + HashedClientSecretSalt = "", + TenantId = tenantId + }; +} diff --git a/test/unit/Elsa.Identity.UnitTests/Services/MemoryRoleStoreUniquenessTests.cs b/test/unit/Elsa.Identity.UnitTests/Services/MemoryRoleStoreUniquenessTests.cs new file mode 100644 index 000000000..034543e8f --- /dev/null +++ b/test/unit/Elsa.Identity.UnitTests/Services/MemoryRoleStoreUniquenessTests.cs @@ -0,0 +1,111 @@ +using Elsa.Common.Services; +using Elsa.Identity.Entities; +using Elsa.Identity.Models; +using Elsa.Identity.Services; +using Elsa.Testing.Shared.Multitenancy; + +namespace Elsa.Identity.UnitTests.Services; + +/// +/// Memory must enforce the same per-tenant role name uniqueness that EF Core +/// already enforces via PerTenantIdentityUniqueness on (TenantId, Name). +/// +public class MemoryRoleStoreUniquenessTests +{ + [Fact(DisplayName = "SaveAsync rejects a different Id that repeats a name in the same tenant")] + public async Task SaveAsync_WhenNameExistsUnderAnotherId_Throws() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(CreateRole("role-1", "Operators", "tenant-a")); + + var exception = await Assert.ThrowsAsync(() => + store.SaveAsync(CreateRole("role-2", "Operators", "tenant-a"))); + + Assert.Contains("already exists", exception.Message); + var stored = (await store.FindManyAsync(new RoleFilter())).ToList(); + Assert.Equal("role-1", Assert.Single(stored).Id); + } + + [Fact(DisplayName = "AddAsync rejects a different Id that repeats a name in the same tenant")] + public async Task AddAsync_WhenNameExistsUnderAnotherId_Throws() + { + var store = CreateStore("tenant-a"); + await store.AddAsync(CreateRole("role-1", "Operators", "tenant-a")); + + var exception = await Assert.ThrowsAsync(() => + store.AddAsync(CreateRole("role-2", "Operators", "tenant-a"))); + + Assert.Contains("already exists", exception.Message); + var stored = (await store.FindManyAsync(new RoleFilter())).ToList(); + Assert.Equal("role-1", Assert.Single(stored).Id); + } + + [Fact(DisplayName = "SaveAsync allows the same Id to update its own name")] + public async Task SaveAsync_WhenSameIdUpdatesName_Succeeds() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(CreateRole("role-1", "Operators", "tenant-a")); + + await store.SaveAsync(CreateRole("role-1", "Operators A", "tenant-a")); + + var stored = await store.FindAsync(new RoleFilter { Id = "role-1" }); + Assert.Equal("Operators A", stored!.Name); + } + + [Fact(DisplayName = "SaveAsync allows the same name in different tenants")] + public async Task SaveAsync_WhenNameRepeatsInAnotherTenant_Succeeds() + { + var backing = new MemoryStore(); + var tenantA = new MemoryRoleStore(backing, new TestTenantAccessor("tenant-a")); + var tenantB = new MemoryRoleStore(backing, new TestTenantAccessor("tenant-b")); + + await tenantA.SaveAsync(CreateRole("operators", "Operators", "tenant-a")); + await tenantB.SaveAsync(CreateRole("operators", "Operators", "tenant-b")); + + Assert.Equal("Operators", (await tenantA.FindAsync(new RoleFilter { Id = "operators" }))!.Name); + Assert.Equal("Operators", (await tenantB.FindAsync(new RoleFilter { Id = "operators" }))!.Name); + } + + [Fact(DisplayName = "SaveAsync leaves the stored name unchanged when a Find result is renamed onto a collision")] + public async Task SaveAsync_WhenFoundRoleRenamedOntoCollision_LeavesStoredNameUnchanged() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(CreateRole("role-1", "Operators", "tenant-a")); + await store.SaveAsync(CreateRole("role-2", "Reviewers", "tenant-a")); + + var found = await store.FindAsync(new RoleFilter { Id = "role-2" }); + found!.Name = "Operators"; + + var exception = await Assert.ThrowsAsync(() => store.SaveAsync(found)); + + Assert.Contains("already exists", exception.Message); + var stored = await store.FindAsync(new RoleFilter { Id = "role-2" }); + Assert.Equal("Reviewers", stored!.Name); + } + + [Fact(DisplayName = "SaveAsync rejects renaming onto a name another Id already owns")] + public async Task SaveAsync_WhenRenamingOntoAnotherIdsName_Throws() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(CreateRole("role-1", "Operators", "tenant-a")); + await store.SaveAsync(CreateRole("role-2", "Reviewers", "tenant-a")); + + var exception = await Assert.ThrowsAsync(() => + store.SaveAsync(CreateRole("role-2", "Operators", "tenant-a"))); + + Assert.Contains("already exists", exception.Message); + Assert.Equal("Reviewers", (await store.FindAsync(new RoleFilter { Id = "role-2" }))!.Name); + } + + private static MemoryRoleStore CreateStore(string tenantId) => + new(new MemoryStore(), new TestTenantAccessor(tenantId)); + + private static Role CreateRole(string id, string name, string? tenantId) => + new() + { + Id = id, + Name = name, + TenantId = tenantId, + Permissions = [] + }; +} diff --git a/test/unit/Elsa.Identity.UnitTests/Services/MemoryUserStoreUniquenessTests.cs b/test/unit/Elsa.Identity.UnitTests/Services/MemoryUserStoreUniquenessTests.cs new file mode 100644 index 000000000..17aee51ac --- /dev/null +++ b/test/unit/Elsa.Identity.UnitTests/Services/MemoryUserStoreUniquenessTests.cs @@ -0,0 +1,105 @@ +using Elsa.Common.Multitenancy; +using Elsa.Common.Services; +using Elsa.Identity.Entities; +using Elsa.Identity.Models; +using Elsa.Identity.Services; +using Elsa.Testing.Shared.Multitenancy; + +namespace Elsa.Identity.UnitTests.Services; + +/// +/// Memory must enforce the same per-tenant user name uniqueness that EF Core +/// already enforces via PerTenantIdentityUniqueness on (TenantId, Name). +/// +public class MemoryUserStoreUniquenessTests +{ + [Fact(DisplayName = "SaveAsync rejects a different Id that repeats a name in the same tenant")] + public async Task SaveAsync_WhenNameExistsUnderAnotherId_Throws() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(CreateUser("user-1", "alice", "tenant-a")); + + var exception = await Assert.ThrowsAsync(() => + store.SaveAsync(CreateUser("user-2", "alice", "tenant-a"))); + + Assert.Contains("already exists", exception.Message); + var stored = (await store.FindManyAsync(new UserFilter())).ToList(); + Assert.Equal("user-1", Assert.Single(stored).Id); + } + + [Fact(DisplayName = "SaveAsync allows the same Id to update its own name")] + public async Task SaveAsync_WhenSameIdUpdatesName_Succeeds() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(CreateUser("user-1", "alice", "tenant-a")); + + await store.SaveAsync(CreateUser("user-1", "bob", "tenant-a")); + + var stored = await store.FindAsync(new UserFilter { Id = "user-1" }); + Assert.Equal("bob", stored!.Name); + } + + [Fact(DisplayName = "SaveAsync allows the same name in different tenants")] + public async Task SaveAsync_WhenNameRepeatsInAnotherTenant_Succeeds() + { + var backing = new MemoryStore(); + var tenantA = new MemoryUserStore(backing, new TestTenantAccessor("tenant-a")); + var tenantB = new MemoryUserStore(backing, new TestTenantAccessor("tenant-b")); + + await tenantA.SaveAsync(CreateUser("user-a", "alice", "tenant-a")); + await tenantB.SaveAsync(CreateUser("user-b", "alice", "tenant-b")); + + Assert.Equal("alice", (await tenantA.FindAsync(new UserFilter { Id = "user-a" }))!.Name); + Assert.Equal("alice", (await tenantB.FindAsync(new UserFilter { Id = "user-b" }))!.Name); + } + + [Fact(DisplayName = "SaveAsync rejects renaming onto a name another Id already owns")] + public async Task SaveAsync_WhenRenamingOntoAnotherIdsName_Throws() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(CreateUser("user-1", "alice", "tenant-a")); + await store.SaveAsync(CreateUser("user-2", "bob", "tenant-a")); + + var exception = await Assert.ThrowsAsync(() => + store.SaveAsync(CreateUser("user-2", "alice", "tenant-a"))); + + Assert.Contains("already exists", exception.Message); + Assert.Equal("bob", (await store.FindAsync(new UserFilter { Id = "user-2" }))!.Name); + } + + [Fact(DisplayName = "SaveAsync treats a stamped ambient tenant as the uniqueness tenant")] + public async Task SaveAsync_WhenTenantIdUnset_UsesStampedAmbientTenantForUniqueness() + { + var store = CreateStore("tenant-a"); + await store.SaveAsync(CreateUser("user-1", "alice", tenantId: null)); + + var exception = await Assert.ThrowsAsync(() => + store.SaveAsync(CreateUser("user-2", "alice", tenantId: null))); + + Assert.Contains("already exists", exception.Message); + Assert.Equal("tenant-a", (await store.FindAsync(new UserFilter { Id = "user-1" }))!.TenantId); + } + + [Fact(DisplayName = "SaveAsync allows the same name for * and a tenant-scoped user")] + public async Task SaveAsync_WhenAgnosticAndTenantScopedShareName_Succeeds() + { + var store = CreateStore("tenant-a"); + + await store.SaveAsync(CreateUser("user-star", "alice", Tenant.AgnosticTenantId)); + await store.SaveAsync(CreateUser("user-a", "alice", "tenant-a")); + + Assert.NotNull(await store.FindAsync(new UserFilter { Id = "user-star" })); + Assert.NotNull(await store.FindAsync(new UserFilter { Id = "user-a" })); + } + + private static MemoryUserStore CreateStore(string tenantId) => + new(new MemoryStore(), new TestTenantAccessor(tenantId)); + + private static User CreateUser(string id, string name, string? tenantId) => + new() + { + Id = id, + Name = name, + TenantId = tenantId + }; +}