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