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 <sipkeschoorstra@outlook.com> * 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 <sipkeschoorstra@outlook.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
parent
21e12b3fbd
commit
1126f536f8
|
|
@ -32,9 +32,14 @@ public class MemoryApplicationStore : IApplicationStore
|
|||
/// <inheritdoc />
|
||||
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
|
|||
/// <inheritdoc />
|
||||
public Task<Application?> 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()
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
using Elsa.Common.Entities;
|
||||
using Elsa.Common.Services;
|
||||
|
||||
namespace Elsa.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Memory counterpart of the EF <c>PerTenantIdentityUniqueness</c> indexes on
|
||||
/// <c>(TenantId, Name)</c> and <c>(TenantId, ClientId)</c>.
|
||||
/// </summary>
|
||||
internal static class MemoryIdentityUniqueness
|
||||
{
|
||||
/// <summary>
|
||||
/// Throws when another Id in the same tenant already owns <paramref name="keySelector"/>.
|
||||
/// Same-Id upserts are allowed so a row can rename itself.
|
||||
/// </summary>
|
||||
public static void EnsureAvailable<T>(
|
||||
MemoryStore<T> store,
|
||||
T entity,
|
||||
Func<T, string?> 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}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ public class MemoryRoleStore : IRoleStore, IRoleStoreWithAtomicDelete
|
|||
/// <inheritdoc />
|
||||
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
|
|||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Role?> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IEnumerable<Role>> 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) =>
|
||||
|
|
|
|||
|
|
@ -32,9 +32,13 @@ public class MemoryUserStore : IUserStore
|
|||
/// <inheritdoc />
|
||||
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
|
|||
/// <inheritdoc />
|
||||
public Task<IEnumerable<User>> 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<IEnumerable<User>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<User?> 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()
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Memory must enforce the same per-tenant application uniqueness that EF Core
|
||||
/// already enforces via <c>PerTenantIdentityUniqueness</c> on
|
||||
/// <c>(TenantId, Name)</c> and <c>(TenantId, ClientId)</c>.
|
||||
/// </summary>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<Application>();
|
||||
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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<Application>(), 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Memory must enforce the same per-tenant role name uniqueness that EF Core
|
||||
/// already enforces via <c>PerTenantIdentityUniqueness</c> on <c>(TenantId, Name)</c>.
|
||||
/// </summary>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<Role>();
|
||||
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<InvalidOperationException>(() => 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<InvalidOperationException>(() =>
|
||||
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<Role>(), new TestTenantAccessor(tenantId));
|
||||
|
||||
private static Role CreateRole(string id, string name, string? tenantId) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
TenantId = tenantId,
|
||||
Permissions = []
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Memory must enforce the same per-tenant user name uniqueness that EF Core
|
||||
/// already enforces via <c>PerTenantIdentityUniqueness</c> on <c>(TenantId, Name)</c>.
|
||||
/// </summary>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<User>();
|
||||
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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<User>(), new TestTenantAccessor(tenantId));
|
||||
|
||||
private static User CreateUser(string id, string name, string? tenantId) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
TenantId = tenantId
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue