From 4196f1c816ffd5fa895fc2ecc24254c5c340ddf9 Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 10 Aug 2026 13:35:12 +0800 Subject: [PATCH 1/3] Remove all related data when deleting a user - Clear password histories and passkeys, load the collections if they are missing - Delete sessions, user delegations and link users with UserDeletedEventHandler - Add OrganizationUnitManager.CreateManyAsync --- .../Volo/Abp/Identity/IdentityUserManager.cs | 23 +- .../Abp/Identity/OrganizationUnitManager.cs | 50 +++- .../Abp/Identity/UserDeletedEventHandler.cs | 52 +++++ .../IdentityUserManager_Delete_Tests.cs | 5 + .../Identity/OrganizationUnitManager_Tests.cs | 53 +++++ .../IdentityUserManager_Delete_Tests.cs | 8 + .../IdentityUserManager_Delete_Tests.cs | 217 ++++++++++++++++++ 7 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/UserDeletedEventHandler.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/IdentityUserManager_Delete_Tests.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs index ba11999b09..e136f71f73 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs @@ -98,13 +98,32 @@ public class IdentityUserManager : UserManager, IDomainService public async override Task DeleteAsync(IdentityUser user) { + //The user may have been loaded without details. + await UserRepository.EnsureCollectionLoadedAsync(user, x => x.Claims, CancellationToken); + await UserRepository.EnsureCollectionLoadedAsync(user, x => x.Roles, CancellationToken); + await UserRepository.EnsureCollectionLoadedAsync(user, x => x.Tokens, CancellationToken); + await UserRepository.EnsureCollectionLoadedAsync(user, x => x.Logins, CancellationToken); + await UserRepository.EnsureCollectionLoadedAsync(user, x => x.OrganizationUnits, CancellationToken); + await UserRepository.EnsureCollectionLoadedAsync(user, x => x.PasswordHistories, CancellationToken); + await UserRepository.EnsureCollectionLoadedAsync(user, x => x.Passkeys, CancellationToken); + user.Claims.Clear(); user.Roles.Clear(); user.Tokens.Clear(); user.Logins.Clear(); user.OrganizationUnits.Clear(); - await IdentityLinkUserRepository.DeleteAsync(new IdentityLinkUserInfo(user.Id, user.TenantId), CancellationToken); - await UpdateAsync(user); + user.PasswordHistories.Clear(); + user.Passkeys.Clear(); + + //UserDeletedEventHandler deletes them after the changes are saved, this keeps + //them gone for the rest of the current unit of work. They are in the host database. + using (CurrentTenant.Change(null)) + { + await IdentityLinkUserRepository.DeleteAsync(new IdentityLinkUserInfo(user.Id, user.TenantId), CancellationToken); + } + + //Soft deleting an entity reloads its original values. + (await UpdateAsync(user)).CheckErrors(); return await base.DeleteAsync(user); } diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/OrganizationUnitManager.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/OrganizationUnitManager.cs index cb8fa17a77..efc78184a9 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/OrganizationUnitManager.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/OrganizationUnitManager.cs @@ -48,6 +48,42 @@ public class OrganizationUnitManager : DomainService await OrganizationUnitRepository.InsertAsync(organizationUnit); } + /// + /// Creates the given organization units by querying the siblings of a parent once instead of once + /// per organization unit. The parents must already exist. Custom validation should be added by + /// overriding . + /// + [UnitOfWork] + public virtual async Task CreateManyAsync(List organizationUnits) + { + Check.NotNull(organizationUnits, nameof(organizationUnits)); + + foreach (var group in organizationUnits.GroupBy(x => new { x.TenantId, x.ParentId })) + { + //Siblings, codes and the database of a group belong to its own tenant. + using (CurrentTenant.Change(group.Key.TenantId)) + { + await ValidateParentTenantAsync(group.Key.ParentId, group.Key.TenantId); + + var siblings = await FindChildrenAsync(group.Key.ParentId); + var lastCode = siblings.OrderBy(x => x.Code).LastOrDefault()?.Code; + + foreach (var organizationUnit in group) + { + await ValidateOrganizationUnitAsync(organizationUnit, siblings); + + organizationUnit.Code = lastCode = lastCode == null + ? await GetNextChildCodeAsync(group.Key.ParentId) + : OrganizationUnit.CalculateNextCode(lastCode); + + siblings.Add(organizationUnit); + } + + await OrganizationUnitRepository.InsertManyAsync(group.ToList()); + } + } + } + public virtual async Task UpdateAsync(OrganizationUnit organizationUnit) { await ValidateOrganizationUnitAsync(organizationUnit); @@ -141,15 +177,21 @@ public class OrganizationUnitManager : DomainService protected virtual async Task ValidateOrganizationUnitAsync(OrganizationUnit organizationUnit) { - var siblings = (await FindChildrenAsync(organizationUnit.ParentId)) - .Where(ou => ou.Id != organizationUnit.Id) - .ToList(); + await ValidateOrganizationUnitAsync(organizationUnit, await FindChildrenAsync(organizationUnit.ParentId)); + } - if (siblings.Any(ou => ou.DisplayName == organizationUnit.DisplayName)) + /// + /// Validates the organization unit against the given siblings, so they are not queried again. + /// + protected virtual Task ValidateOrganizationUnitAsync(OrganizationUnit organizationUnit, List siblings) + { + if (siblings.Any(ou => ou.Id != organizationUnit.Id && ou.DisplayName == organizationUnit.DisplayName)) { throw new BusinessException(IdentityErrorCodes.DuplicateOrganizationUnitDisplayName) .WithData("0", organizationUnit.DisplayName); } + + return Task.CompletedTask; } protected virtual async Task ValidateParentTenantAsync(Guid? parentId, Guid? tenantId) diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/UserDeletedEventHandler.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/UserDeletedEventHandler.cs new file mode 100644 index 0000000000..42d1e30dc0 --- /dev/null +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/UserDeletedEventHandler.cs @@ -0,0 +1,52 @@ +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Entities.Events; +using Volo.Abp.EventBus; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; + +namespace Volo.Abp.Identity; + +//Sessions, user delegations and link users have no navigation from IdentityUser, +//so clearing the user's collections doesn't cover them. +public class UserDeletedEventHandler : + ILocalEventHandler>, + ITransientDependency +{ + protected IIdentitySessionRepository IdentitySessionRepository { get; } + protected IIdentityUserDelegationRepository IdentityUserDelegationRepository { get; } + protected IIdentityLinkUserRepository IdentityLinkUserRepository { get; } + protected ICurrentTenant CurrentTenant { get; } + + public UserDeletedEventHandler( + IIdentitySessionRepository identitySessionRepository, + IIdentityUserDelegationRepository identityUserDelegationRepository, + IIdentityLinkUserRepository identityLinkUserRepository, + ICurrentTenant currentTenant) + { + IdentitySessionRepository = identitySessionRepository; + IdentityUserDelegationRepository = identityUserDelegationRepository; + IdentityLinkUserRepository = identityLinkUserRepository; + CurrentTenant = currentTenant; + } + + [UnitOfWork] + public virtual async Task HandleEventAsync(EntityDeletedEventData eventData) + { + var user = eventData.Entity; + + await IdentitySessionRepository.DeleteAllAsync(user.Id); + + var delegations = await IdentityUserDelegationRepository.GetListAsync(sourceUserId: user.Id, targetUserId: null); + delegations.AddRange(await IdentityUserDelegationRepository.GetListAsync(sourceUserId: null, targetUserId: user.Id)); + //A delegation of the user to itself is returned by both queries. + await IdentityUserDelegationRepository.DeleteManyAsync(delegations.DistinctBy(x => x.Id).ToList()); + + //Link users are stored in the host database. + using (CurrentTenant.Change(null)) + { + await IdentityLinkUserRepository.DeleteAsync(new IdentityLinkUserInfo(user.Id, user.TenantId)); + } + } +} diff --git a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs new file mode 100644 index 0000000000..d01350db95 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs @@ -0,0 +1,5 @@ +namespace Volo.Abp.Identity; + +public class IdentityUserManager_Delete_Tests : IdentityUserManager_Delete_Tests +{ +} diff --git a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_Tests.cs b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_Tests.cs index 43c97a945a..0c0083c88d 100644 --- a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_Tests.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Identity; using Shouldly; using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Volo.Abp.Data; @@ -179,4 +180,56 @@ public class OrganizationUnitManager_Tests : AbpIdentityDomainTestBase } } } + + [Fact] + public async Task CreateManyAsync() + { + List organizationUnits; + + using (var uow = _unitOfWorkManager.Begin()) + { + var lastRootCode = (await _organizationUnitRepository.GetChildrenAsync(null)) + .OrderBy(x => x.Code).Last().Code; + + organizationUnits = Enumerable.Range(0, 5) + .Select(_ => new OrganizationUnit(_guidGenerator.Create(), $"batch-{Guid.NewGuid():N}")) + .ToList(); + + await _organizationUnitManager.CreateManyAsync(organizationUnits); + await uow.CompleteAsync(); + + foreach (var organizationUnit in organizationUnits) + { + lastRootCode = OrganizationUnit.CalculateNextCode(lastRootCode); + organizationUnit.Code.ShouldBe(lastRootCode); + } + } + + using (var uow = _unitOfWorkManager.Begin()) + { + foreach (var organizationUnit in organizationUnits) + { + (await _organizationUnitRepository.GetAsync(organizationUnit.Id)).Code.ShouldBe(organizationUnit.Code); + } + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task CreateManyAsync_Should_Not_Allow_Duplicate_Display_Name_In_The_Batch() + { + using (var uow = _unitOfWorkManager.Begin()) + { + var displayName = $"batch-duplicate-{Guid.NewGuid():N}"; + + await Should.ThrowAsync(async () => + await _organizationUnitManager.CreateManyAsync([ + new OrganizationUnit(_guidGenerator.Create(), displayName), + new OrganizationUnit(_guidGenerator.Create(), displayName) + ])); + + await uow.CompleteAsync(); + } + } } diff --git a/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/IdentityUserManager_Delete_Tests.cs b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/IdentityUserManager_Delete_Tests.cs new file mode 100644 index 0000000000..415727d35c --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/IdentityUserManager_Delete_Tests.cs @@ -0,0 +1,8 @@ +using Xunit; + +namespace Volo.Abp.Identity.MongoDB; + +[Collection(MongoTestCollection.Name)] +public class IdentityUserManager_Delete_Tests : IdentityUserManager_Delete_Tests +{ +} diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs new file mode 100644 index 0000000000..73f7043db7 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs @@ -0,0 +1,217 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Shouldly; +using Volo.Abp.Data; +using Volo.Abp.Modularity; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity; + +public abstract class IdentityUserManager_Delete_Tests : AbpIdentityTestBase + where TStartupModule : IAbpModule +{ + protected IdentityUserManager IdentityUserManager { get; } + protected IIdentityUserRepository IdentityUserRepository { get; } + protected IOrganizationUnitRepository OrganizationUnitRepository { get; } + protected IdentityLinkUserManager IdentityLinkUserManager { get; } + protected IIdentitySessionRepository IdentitySessionRepository { get; } + protected IIdentityUserDelegationRepository IdentityUserDelegationRepository { get; } + protected ILookupNormalizer LookupNormalizer { get; } + protected IUnitOfWorkManager UnitOfWorkManager { get; } + protected IDataFilter DataFilter { get; } + + protected IdentityUserManager_Delete_Tests() + { + IdentityUserManager = GetRequiredService(); + IdentityUserRepository = GetRequiredService(); + OrganizationUnitRepository = GetRequiredService(); + IdentityLinkUserManager = GetRequiredService(); + IdentitySessionRepository = GetRequiredService(); + IdentityUserDelegationRepository = GetRequiredService(); + LookupNormalizer = GetRequiredService(); + UnitOfWorkManager = GetRequiredService(); + DataFilter = GetRequiredService(); + } + + [Fact] + public virtual async Task DeleteAsync_Should_Remove_All_Related_Data() + { + var userId = Guid.NewGuid(); + var linkedUserId = Guid.NewGuid(); + + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentityUserManager.CreateAsync( + new IdentityUser(userId, $"delete-{userId:N}", $"delete-{userId:N}@abp.io"))).CheckErrors(); + (await IdentityUserManager.CreateAsync( + new IdentityUser(linkedUserId, $"linked-{linkedUserId:N}", $"linked-{linkedUserId:N}@abp.io"))).CheckErrors(); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await IdentityUserManager.GetByIdAsync(userId); + + await IdentityUserManager.AddClaimAsync(user, new Claim("test", "test")); + await IdentityUserManager.AddLoginAsync(user, new UserLoginInfo("test", "test", "test")); + await IdentityUserManager.AddToRoleAsync(user, "moderator"); + user.SetToken("test", "test", "test"); + user.AddPasswordHistory("test"); + user.AddPasskey([1, 2, 3], new IdentityPasskeyData()); + await IdentityUserManager.AddToOrganizationUnitAsync( + user, + await OrganizationUnitRepository.GetAsync(LookupNormalizer.NormalizeName("OU11"))); + await IdentityLinkUserManager.LinkAsync( + new IdentityLinkUserInfo(userId), + new IdentityLinkUserInfo(linkedUserId)); + + await IdentitySessionRepository.InsertAsync(new IdentitySession( + Guid.NewGuid(), $"session-{userId:N}", "Web", "Chrome", userId, null, "MyApp", "127.0.0.1", DateTime.UtcNow)); + await IdentityUserDelegationRepository.InsertAsync(new IdentityUserDelegation( + Guid.NewGuid(), userId, linkedUserId, DateTime.UtcNow, DateTime.UtcNow.AddDays(1))); + await IdentityUserDelegationRepository.InsertAsync(new IdentityUserDelegation( + Guid.NewGuid(), linkedUserId, userId, DateTime.UtcNow, DateTime.UtcNow.AddDays(1))); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await IdentityUserManager.GetByIdAsync(userId); + + user.Claims.Count.ShouldBeGreaterThan(0); + user.Logins.Count.ShouldBeGreaterThan(0); + user.Roles.Count.ShouldBeGreaterThan(0); + user.Tokens.Count.ShouldBeGreaterThan(0); + user.OrganizationUnits.Count.ShouldBeGreaterThan(0); + user.PasswordHistories.Count.ShouldBeGreaterThan(0); + user.Passkeys.Count.ShouldBeGreaterThan(0); + + (await IdentityUserManager.DeleteAsync(user)).CheckErrors(); + + await uow.CompleteAsync(); + } + + //The user is soft deleted, disable the filter to see what is left behind. + using (var uow = UnitOfWorkManager.Begin()) + using (DataFilter.Disable()) + { + var deletedUser = await IdentityUserRepository.FindAsync(userId); + deletedUser.ShouldNotBeNull(); + + deletedUser.Claims.Count.ShouldBe(0); + deletedUser.Logins.Count.ShouldBe(0); + deletedUser.Roles.Count.ShouldBe(0); + deletedUser.Tokens.Count.ShouldBe(0); + deletedUser.OrganizationUnits.Count.ShouldBe(0); + deletedUser.PasswordHistories.Count.ShouldBe(0); + deletedUser.Passkeys.Count.ShouldBe(0); + + (await IdentityLinkUserManager.IsLinkedAsync( + new IdentityLinkUserInfo(userId), + new IdentityLinkUserInfo(linkedUserId))).ShouldBeFalse(); + + (await IdentitySessionRepository.GetCountAsync(userId: userId)).ShouldBe(0); + (await IdentityUserDelegationRepository.GetListAsync(sourceUserId: userId, targetUserId: null)).ShouldBeEmpty(); + (await IdentityUserDelegationRepository.GetListAsync(sourceUserId: null, targetUserId: userId)).ShouldBeEmpty(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public virtual async Task DeleteAsync_Should_Remove_Related_Data_Of_A_User_Loaded_Without_Details() + { + var userId = Guid.NewGuid(); + var userName = $"no-details-{userId:N}"; + + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentityUserManager.CreateAsync( + new IdentityUser(userId, userName, $"{userName}@abp.io"))).CheckErrors(); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await IdentityUserManager.GetByIdAsync(userId); + await IdentityUserManager.AddToRoleAsync(user, "moderator"); + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await IdentityUserRepository.FindByNormalizedUserNameAsync( + LookupNormalizer.NormalizeName(userName), + includeDetails: false); + + (await IdentityUserManager.DeleteAsync(user)).CheckErrors(); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + using (DataFilter.Disable()) + { + var deletedUser = await IdentityUserRepository.FindAsync(userId); + deletedUser.ShouldNotBeNull(); + deletedUser.Roles.Count.ShouldBe(0); + + await uow.CompleteAsync(); + } + } + + [Fact] + public virtual async Task Deleting_A_User_Through_The_Repository_Should_Remove_Sessions_Delegations_And_Links() + { + var userId = Guid.NewGuid(); + var linkedUserId = Guid.NewGuid(); + + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentityUserManager.CreateAsync( + new IdentityUser(userId, $"repo-{userId:N}", $"repo-{userId:N}@abp.io"))).CheckErrors(); + (await IdentityUserManager.CreateAsync( + new IdentityUser(linkedUserId, $"repo-linked-{linkedUserId:N}", $"repo-linked-{linkedUserId:N}@abp.io"))).CheckErrors(); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + await IdentityLinkUserManager.LinkAsync( + new IdentityLinkUserInfo(userId), + new IdentityLinkUserInfo(linkedUserId)); + + await IdentitySessionRepository.InsertAsync(new IdentitySession( + Guid.NewGuid(), $"repo-session-{userId:N}", "Web", "Chrome", userId, null, "MyApp", "127.0.0.1", DateTime.UtcNow)); + await IdentityUserDelegationRepository.InsertAsync(new IdentityUserDelegation( + Guid.NewGuid(), userId, linkedUserId, DateTime.UtcNow, DateTime.UtcNow.AddDays(1))); + + await uow.CompleteAsync(); + } + + //Custom code may delete the user without using IdentityUserManager. + using (var uow = UnitOfWorkManager.Begin()) + { + await IdentityUserRepository.DeleteAsync(await IdentityUserRepository.GetAsync(userId)); + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentitySessionRepository.GetCountAsync(userId: userId)).ShouldBe(0); + (await IdentityUserDelegationRepository.GetListAsync(sourceUserId: userId, targetUserId: null)).ShouldBeEmpty(); + (await IdentityLinkUserManager.IsLinkedAsync( + new IdentityLinkUserInfo(userId), + new IdentityLinkUserInfo(linkedUserId))).ShouldBeFalse(); + + await uow.CompleteAsync(); + } + } +} From dd396d1c8eb521fb383ca653414ecd688c66c33f Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 10 Aug 2026 15:17:35 +0800 Subject: [PATCH 2/3] Do not validate the user while deleting it --- .../Volo/Abp/Identity/IdentityUserManager.cs | 7 +- .../IdentityUserManager_Delete_Tests.cs | 144 ++++++++++++++++++ 2 files changed, 147 insertions(+), 4 deletions(-) diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs index e136f71f73..f1906087b7 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs @@ -115,15 +115,14 @@ public class IdentityUserManager : UserManager, IDomainService user.PasswordHistories.Clear(); user.Passkeys.Clear(); - //UserDeletedEventHandler deletes them after the changes are saved, this keeps - //them gone for the rest of the current unit of work. They are in the host database. + //They are in the host database and deleting them here covers the current unit of work. using (CurrentTenant.Change(null)) { await IdentityLinkUserRepository.DeleteAsync(new IdentityLinkUserInfo(user.Id, user.TenantId), CancellationToken); } - //Soft deleting an entity reloads its original values. - (await UpdateAsync(user)).CheckErrors(); + //Soft deleting reloads the original values, the store saves the changes without validating the user. + (await Store.UpdateAsync(user, CancellationToken)).CheckErrors(); return await base.DeleteAsync(user); } diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs index 73f7043db7..0f874c836a 100644 --- a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs @@ -4,7 +4,11 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Shouldly; using Volo.Abp.Data; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Caching; using Volo.Abp.Modularity; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; using Volo.Abp.Uow; using Xunit; @@ -22,9 +26,13 @@ public abstract class IdentityUserManager_Delete_Tests : AbpIden protected ILookupNormalizer LookupNormalizer { get; } protected IUnitOfWorkManager UnitOfWorkManager { get; } protected IDataFilter DataFilter { get; } + protected ICurrentTenant CurrentTenant { get; } + protected IDistributedCache DynamicClaimCache { get; } protected IdentityUserManager_Delete_Tests() { + CurrentTenant = GetRequiredService(); + DynamicClaimCache = GetRequiredService>(); IdentityUserManager = GetRequiredService(); IdentityUserRepository = GetRequiredService(); OrganizationUnitRepository = GetRequiredService(); @@ -214,4 +222,140 @@ public abstract class IdentityUserManager_Delete_Tests : AbpIden await uow.CompleteAsync(); } } + + [Fact] + public virtual async Task Should_Delete_A_User_That_Does_Not_Pass_The_User_Validators() + { + var userId = Guid.NewGuid(); + + //Insert with the repository, so the user name is not validated. + using (var uow = UnitOfWorkManager.Begin()) + { + var user = new IdentityUser(userId, $"invalid user name {userId:N}", $"invalid-{userId:N}@abp.io"); + user.AddPasswordHistory("test"); + await IdentityUserRepository.InsertAsync(user); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentityUserManager.DeleteAsync(await IdentityUserRepository.GetAsync(userId))).CheckErrors(); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + using (DataFilter.Disable()) + { + var deletedUser = await IdentityUserRepository.FindAsync(userId); + deletedUser.IsDeleted.ShouldBeTrue(); + deletedUser.PasswordHistories.Count.ShouldBe(0); + + await uow.CompleteAsync(); + } + } + + [Fact] + public virtual async Task Should_Remove_The_Related_Data_Of_A_Tenant_User_When_The_Unit_Of_Work_Completes_In_The_Host() + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + using (var uow = UnitOfWorkManager.Begin()) + using (CurrentTenant.Change(tenantId)) + { + await IdentityUserRepository.InsertAsync( + new IdentityUser(userId, $"tenant-{userId:N}", $"tenant-{userId:N}@abp.io", tenantId)); + await IdentitySessionRepository.InsertAsync(new IdentitySession( + Guid.NewGuid(), $"tenant-session-{userId:N}", "Web", "Chrome", userId, tenantId, "MyApp", "127.0.0.1", DateTime.UtcNow)); + await IdentityUserDelegationRepository.InsertAsync(new IdentityUserDelegation( + Guid.NewGuid(), userId, Guid.NewGuid(), DateTime.UtcNow, DateTime.UtcNow.AddDays(1), tenantId)); + + await uow.CompleteAsync(); + } + + //The event is published while the unit of work completes, the current tenant is the host then. + using (var uow = UnitOfWorkManager.Begin()) + { + using (CurrentTenant.Change(tenantId)) + { + await IdentityUserRepository.DeleteAsync(await IdentityUserRepository.GetAsync(userId)); + } + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + using (CurrentTenant.Change(tenantId)) + { + (await IdentitySessionRepository.GetCountAsync(userId: userId)).ShouldBe(0); + (await IdentityUserDelegationRepository.GetListAsync(sourceUserId: userId, targetUserId: null)).ShouldBeEmpty(); + + await uow.CompleteAsync(); + } + } + + [Fact] + public virtual async Task Should_Remove_The_Dynamic_Claims_Cache_Of_A_Deleted_User() + { + var userId = Guid.NewGuid(); + var cacheKey = AbpDynamicClaimCacheItem.CalculateCacheKey(userId, null); + + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentityUserManager.CreateAsync( + new IdentityUser(userId, $"claims-cache-{userId:N}", $"claims-cache-{userId:N}@abp.io"))).CheckErrors(); + + await uow.CompleteAsync(); + } + + await DynamicClaimCache.SetAsync(cacheKey, new AbpDynamicClaimCacheItem()); + (await DynamicClaimCache.GetAsync(cacheKey)).ShouldNotBeNull(); + + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentityUserManager.DeleteAsync(await IdentityUserRepository.GetAsync(userId))).CheckErrors(); + + await uow.CompleteAsync(); + } + + (await DynamicClaimCache.GetAsync(cacheKey)).ShouldBeNull(); + } + + [Fact] + public virtual async Task Deleting_A_Stale_User_Should_Throw_A_Concurrency_Exception() + { + var userId = Guid.NewGuid(); + IdentityUser staleUser; + + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentityUserManager.CreateAsync( + new IdentityUser(userId, $"stale-{userId:N}", $"stale-{userId:N}@abp.io"))).CheckErrors(); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + staleUser = await IdentityUserRepository.GetAsync(userId); + await uow.CompleteAsync(); + } + + //Change the user, so the instance loaded above has an old concurrency stamp. + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await IdentityUserRepository.GetAsync(userId); + user.Name = "Changed"; + await IdentityUserRepository.UpdateAsync(user); + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + await Should.ThrowAsync( + async () => await IdentityUserManager.DeleteAsync(staleUser)); + } + } } From 3da670f32d9372a56fab024a5808859ff3e07346 Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 10 Aug 2026 20:22:17 +0800 Subject: [PATCH 3/3] Carry the tenant id in the GDPR events - Delete the link users after the user is saved, a concurrency failure kept them deleted - Create organization units of the current tenant only and insert them after every group is valid --- .../Volo.Abp.Gdpr.Abstractions.csproj | 1 + .../Abp/Gdpr/AbpGdprAbstractionsModule.cs | 4 + .../Gdpr/GdprUserDataDeletionRequestedEto.cs | 13 +- .../Volo/Abp/Gdpr/GdprUserDataPreparedEto.cs | 13 +- .../Volo/Abp/Gdpr/GdprUserDataRequestedEto.cs | 13 +- .../Volo/Abp/Identity/IdentityUserManager.cs | 7 +- .../Abp/Identity/OrganizationUnitManager.cs | 45 ++++--- ...rganizationUnitManager_CreateMany_Tests.cs | 5 + .../Identity/OrganizationUnitManager_Tests.cs | 86 +++++++++++++ ...rganizationUnitManager_CreateMany_Tests.cs | 8 ++ .../IdentityUserManager_Delete_Tests.cs | 118 +++++++++++++++++- ...rganizationUnitManager_CreateMany_Tests.cs | 54 ++++++++ 12 files changed, 339 insertions(+), 28 deletions(-) create mode 100644 modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/OrganizationUnitManager_CreateMany_Tests.cs create mode 100644 modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs diff --git a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo.Abp.Gdpr.Abstractions.csproj b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo.Abp.Gdpr.Abstractions.csproj index e918718925..1dce223a34 100644 --- a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo.Abp.Gdpr.Abstractions.csproj +++ b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo.Abp.Gdpr.Abstractions.csproj @@ -12,6 +12,7 @@ + diff --git a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/AbpGdprAbstractionsModule.cs b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/AbpGdprAbstractionsModule.cs index 3cb53c8be2..ed8f682641 100644 --- a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/AbpGdprAbstractionsModule.cs +++ b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/AbpGdprAbstractionsModule.cs @@ -1,7 +1,11 @@ using Volo.Abp.Modularity; +using Volo.Abp.EventBus.Abstractions; namespace Volo.Abp.Gdpr; +[DependsOn( + typeof(AbpEventBusAbstractionsModule) +)] public class AbpGdprAbstractionsModule : AbpModule { } \ No newline at end of file diff --git a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataDeletionRequestedEto.cs b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataDeletionRequestedEto.cs index aa7b721117..e9520d9580 100644 --- a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataDeletionRequestedEto.cs +++ b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataDeletionRequestedEto.cs @@ -1,9 +1,18 @@ using System; +using Volo.Abp.EventBus; namespace Volo.Abp.Gdpr; [Serializable] -public class GdprUserDataDeletionRequestedEto +public class GdprUserDataDeletionRequestedEto : IEventDataMayHaveTenantId { + public Guid? TenantId { get; set; } + public Guid UserId { get; set; } -} \ No newline at end of file + + public bool IsMultiTenant(out Guid? tenantId) + { + tenantId = TenantId; + return TenantId.HasValue; + } +} diff --git a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataPreparedEto.cs b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataPreparedEto.cs index d52e12783c..26f075d894 100644 --- a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataPreparedEto.cs +++ b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataPreparedEto.cs @@ -1,13 +1,22 @@ using System; +using Volo.Abp.EventBus; namespace Volo.Abp.Gdpr; [Serializable] -public class GdprUserDataPreparedEto +public class GdprUserDataPreparedEto : IEventDataMayHaveTenantId { + public Guid? TenantId { get; set; } + public Guid RequestId { get; set; } public string Provider { get; set; } = default!; public GdprDataInfo Data { get; set; } = default!; -} \ No newline at end of file + + public bool IsMultiTenant(out Guid? tenantId) + { + tenantId = TenantId; + return TenantId.HasValue; + } +} diff --git a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataRequestedEto.cs b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataRequestedEto.cs index b341ab2659..3403b0d514 100644 --- a/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataRequestedEto.cs +++ b/framework/src/Volo.Abp.Gdpr.Abstractions/Volo/Abp/Gdpr/GdprUserDataRequestedEto.cs @@ -1,11 +1,20 @@ using System; +using Volo.Abp.EventBus; namespace Volo.Abp.Gdpr; [Serializable] -public class GdprUserDataRequestedEto +public class GdprUserDataRequestedEto : IEventDataMayHaveTenantId { + public Guid? TenantId { get; set; } + public Guid UserId { get; set; } public Guid RequestId { get; set; } -} \ No newline at end of file + + public bool IsMultiTenant(out Guid? tenantId) + { + tenantId = TenantId; + return TenantId.HasValue; + } +} diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs index f1906087b7..8e327439af 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IdentityUserManager.cs @@ -115,15 +115,16 @@ public class IdentityUserManager : UserManager, IDomainService user.PasswordHistories.Clear(); user.Passkeys.Clear(); + //Soft deleting reloads the original values, the store saves the changes without validating the user. + //Nothing else is deleted before this succeeds, it is where the user is checked for concurrency. + (await Store.UpdateAsync(user, CancellationToken)).CheckErrors(); + //They are in the host database and deleting them here covers the current unit of work. using (CurrentTenant.Change(null)) { await IdentityLinkUserRepository.DeleteAsync(new IdentityLinkUserInfo(user.Id, user.TenantId), CancellationToken); } - //Soft deleting reloads the original values, the store saves the changes without validating the user. - (await Store.UpdateAsync(user, CancellationToken)).CheckErrors(); - return await base.DeleteAsync(user); } diff --git a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/OrganizationUnitManager.cs b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/OrganizationUnitManager.cs index efc78184a9..f6272f6878 100644 --- a/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/OrganizationUnitManager.cs +++ b/modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/OrganizationUnitManager.cs @@ -50,38 +50,47 @@ public class OrganizationUnitManager : DomainService /// /// Creates the given organization units by querying the siblings of a parent once instead of once - /// per organization unit. The parents must already exist. Custom validation should be added by - /// overriding . + /// per organization unit. They all have to belong to the current tenant and their parents must + /// already exist. is used for the first organization unit of a + /// parent, the codes of the rest follow it. Custom validation should be added by overriding + /// . /// [UnitOfWork] public virtual async Task CreateManyAsync(List organizationUnits) { Check.NotNull(organizationUnits, nameof(organizationUnits)); - foreach (var group in organizationUnits.GroupBy(x => new { x.TenantId, x.ParentId })) + if (organizationUnits.Any(x => x.TenantId != CurrentTenant.Id)) { - //Siblings, codes and the database of a group belong to its own tenant. - using (CurrentTenant.Change(group.Key.TenantId)) - { - await ValidateParentTenantAsync(group.Key.ParentId, group.Key.TenantId); + throw new AbpException("Organization units of another tenant can not be created, change the current tenant instead!"); + } - var siblings = await FindChildrenAsync(group.Key.ParentId); - var lastCode = siblings.OrderBy(x => x.Code).LastOrDefault()?.Code; + var groups = organizationUnits.GroupBy(x => x.ParentId).ToList(); + + foreach (var group in groups) + { + await ValidateParentTenantAsync(group.Key, CurrentTenant.Id); - foreach (var organizationUnit in group) - { - await ValidateOrganizationUnitAsync(organizationUnit, siblings); + var siblings = await FindChildrenAsync(group.Key); + string lastCode = null; - organizationUnit.Code = lastCode = lastCode == null - ? await GetNextChildCodeAsync(group.Key.ParentId) - : OrganizationUnit.CalculateNextCode(lastCode); + foreach (var organizationUnit in group) + { + organizationUnit.Code = lastCode = lastCode == null + ? await GetNextChildCodeAsync(group.Key) + : OrganizationUnit.CalculateNextCode(lastCode); - siblings.Add(organizationUnit); - } + await ValidateOrganizationUnitAsync(organizationUnit, siblings); - await OrganizationUnitRepository.InsertManyAsync(group.ToList()); + siblings.Add(organizationUnit); } } + + //Nothing is inserted before every group is validated. + foreach (var group in groups) + { + await OrganizationUnitRepository.InsertManyAsync(group.ToList()); + } } public virtual async Task UpdateAsync(OrganizationUnit organizationUnit) diff --git a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs new file mode 100644 index 0000000000..94f6dce0eb --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs @@ -0,0 +1,5 @@ +namespace Volo.Abp.Identity; + +public class OrganizationUnitManager_CreateMany_Tests : OrganizationUnitManager_CreateMany_Tests +{ +} diff --git a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_Tests.cs b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_Tests.cs index 0c0083c88d..a9c51678c2 100644 --- a/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.Domain.Tests/Volo/Abp/Identity/OrganizationUnitManager_Tests.cs @@ -5,6 +5,12 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Threading; +using Volo.Abp.Security.Claims; +using Volo.Abp.Identity.Localization; +using Volo.Abp.Caching; +using Microsoft.Extensions.Localization; using Volo.Abp.Guids; using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; @@ -232,4 +238,84 @@ public class OrganizationUnitManager_Tests : AbpIdentityDomainTestBase await uow.CompleteAsync(); } } + + [Fact] + public async Task CreateManyAsync_Should_Not_Create_Organization_Units_Of_Another_Tenant() + { + using (var uow = _unitOfWorkManager.Begin()) + { + await Should.ThrowAsync(async () => + await _organizationUnitManager.CreateManyAsync([ + new OrganizationUnit(_guidGenerator.Create(), "another-tenant", null, Guid.NewGuid()) + ])); + + await uow.CompleteAsync(); + } + } + + [Fact] + public async Task CreateManyAsync_Should_Use_The_Overridden_Extension_Points() + { + var manager = new TestOrganizationUnitManager( + _organizationUnitRepository, + GetRequiredService>(), + _identityRoleRepository, + GetRequiredService>(), + GetRequiredService()) + { + LazyServiceProvider = GetRequiredService() + }; + + using (var uow = _unitOfWorkManager.Begin()) + { + await Should.ThrowAsync(async () => + await manager.CreateManyAsync([new OrganizationUnit(_guidGenerator.Create(), "rejected-by-the-override")])); + + await manager.CreateManyAsync([ + new OrganizationUnit(_guidGenerator.Create(), $"extension-point-1-{Guid.NewGuid():N}"), + new OrganizationUnit(_guidGenerator.Create(), $"extension-point-2-{Guid.NewGuid():N}") + ]); + + await uow.CompleteAsync(); + } + + //Every organization unit is validated, the code generator is only used for the first one of a parent. + //Both calls above created a root organization unit, so the code generator was used twice. + manager.ValidateCallCount.ShouldBe(3); + manager.GetNextChildCodeCallCount.ShouldBe(2); + } + + public class TestOrganizationUnitManager : OrganizationUnitManager + { + public int ValidateCallCount { get; private set; } + public int GetNextChildCodeCallCount { get; private set; } + + public TestOrganizationUnitManager( + IOrganizationUnitRepository organizationUnitRepository, + IStringLocalizer localizer, + IIdentityRoleRepository identityRoleRepository, + IDistributedCache dynamicClaimCache, + ICancellationTokenProvider cancellationTokenProvider) + : base(organizationUnitRepository, localizer, identityRoleRepository, dynamicClaimCache, cancellationTokenProvider) + { + } + + public override async Task GetNextChildCodeAsync(Guid? parentId) + { + GetNextChildCodeCallCount++; + return await base.GetNextChildCodeAsync(parentId); + } + + protected override async Task ValidateOrganizationUnitAsync(OrganizationUnit organizationUnit, List siblings) + { + ValidateCallCount++; + + if (organizationUnit.DisplayName == "rejected-by-the-override") + { + throw new BusinessException(IdentityErrorCodes.DuplicateOrganizationUnitDisplayName); + } + + await base.ValidateOrganizationUnitAsync(organizationUnit, siblings); + } + } } diff --git a/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/OrganizationUnitManager_CreateMany_Tests.cs b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/OrganizationUnitManager_CreateMany_Tests.cs new file mode 100644 index 0000000000..35f8004c5b --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.MongoDB.Tests/Volo/Abp/Identity/MongoDB/OrganizationUnitManager_CreateMany_Tests.cs @@ -0,0 +1,8 @@ +using Xunit; + +namespace Volo.Abp.Identity.MongoDB; + +[Collection(MongoTestCollection.Name)] +public class OrganizationUnitManager_CreateMany_Tests : OrganizationUnitManager_CreateMany_Tests +{ +} diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs index 0f874c836a..c6e6b179c6 100644 --- a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs @@ -148,7 +148,18 @@ public abstract class IdentityUserManager_Delete_Tests : AbpIden using (var uow = UnitOfWorkManager.Begin()) { var user = await IdentityUserManager.GetByIdAsync(userId); - await IdentityUserManager.AddToRoleAsync(user, "moderator"); + + (await IdentityUserManager.AddClaimAsync(user, new Claim("test", "test"))).CheckErrors(); + (await IdentityUserManager.AddLoginAsync(user, new UserLoginInfo("test", "test", "test"))).CheckErrors(); + (await IdentityUserManager.AddToRoleAsync(user, "moderator")).CheckErrors(); + user.SetToken("test", "test", "test"); + user.AddPasswordHistory("test"); + user.AddPasskey([1, 2, 3], new IdentityPasskeyData()); + await IdentityUserManager.AddToOrganizationUnitAsync( + user, + await OrganizationUnitRepository.GetAsync(LookupNormalizer.NormalizeName("OU11"))); + await IdentityUserRepository.UpdateAsync(user); + await uow.CompleteAsync(); } @@ -163,12 +174,20 @@ public abstract class IdentityUserManager_Delete_Tests : AbpIden await uow.CompleteAsync(); } + //Every collection of the user has to be loaded before it is cleared. using (var uow = UnitOfWorkManager.Begin()) using (DataFilter.Disable()) { var deletedUser = await IdentityUserRepository.FindAsync(userId); deletedUser.ShouldNotBeNull(); + + deletedUser.Claims.Count.ShouldBe(0); + deletedUser.Logins.Count.ShouldBe(0); deletedUser.Roles.Count.ShouldBe(0); + deletedUser.Tokens.Count.ShouldBe(0); + deletedUser.OrganizationUnits.Count.ShouldBe(0); + deletedUser.PasswordHistories.Count.ShouldBe(0); + deletedUser.Passkeys.Count.ShouldBe(0); await uow.CompleteAsync(); } @@ -358,4 +377,101 @@ public abstract class IdentityUserManager_Delete_Tests : AbpIden async () => await IdentityUserManager.DeleteAsync(staleUser)); } } + + [Fact] + public virtual async Task Deleting_A_User_Through_The_Repository_Should_Not_Clear_Its_Own_Collections() + { + var userId = Guid.NewGuid(); + + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentityUserManager.CreateAsync( + new IdentityUser(userId, $"repo-collections-{userId:N}", $"repo-collections-{userId:N}@abp.io"))).CheckErrors(); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await IdentityUserManager.GetByIdAsync(userId); + (await IdentityUserManager.AddToRoleAsync(user, "moderator")).CheckErrors(); + user.AddPasswordHistory("test"); + await IdentityUserRepository.UpdateAsync(user); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + await IdentityUserRepository.DeleteAsync(await IdentityUserRepository.GetAsync(userId)); + + await uow.CompleteAsync(); + } + + //UserDeletedEventHandler only covers the aggregates that have no navigation from the user, + //the collections of the user itself are cleared by IdentityUserManager.DeleteAsync. + using (var uow = UnitOfWorkManager.Begin()) + using (DataFilter.Disable()) + { + var deletedUser = await IdentityUserRepository.FindAsync(userId); + deletedUser.Roles.Count.ShouldBe(1); + deletedUser.PasswordHistories.Count.ShouldBe(1); + + await uow.CompleteAsync(); + } + } + + [Fact] + public virtual async Task Deleting_A_Stale_User_Should_Not_Delete_Its_Link_Users() + { + var userId = Guid.NewGuid(); + var linkedUserId = Guid.NewGuid(); + IdentityUser staleUser; + + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentityUserManager.CreateAsync( + new IdentityUser(userId, $"stale-link-{userId:N}", $"stale-link-{userId:N}@abp.io"))).CheckErrors(); + (await IdentityUserManager.CreateAsync( + new IdentityUser(linkedUserId, $"stale-linked-{linkedUserId:N}", $"stale-linked-{linkedUserId:N}@abp.io"))).CheckErrors(); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + await IdentityLinkUserManager.LinkAsync( + new IdentityLinkUserInfo(userId), + new IdentityLinkUserInfo(linkedUserId)); + + staleUser = await IdentityUserRepository.GetAsync(userId); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await IdentityUserRepository.GetAsync(userId); + user.Name = "Changed"; + await IdentityUserRepository.UpdateAsync(user); + + await uow.CompleteAsync(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + await Should.ThrowAsync( + async () => await IdentityUserManager.DeleteAsync(staleUser)); + } + + //The user is still there, so its link users must be there as well. + using (var uow = UnitOfWorkManager.Begin()) + { + (await IdentityLinkUserManager.IsLinkedAsync( + new IdentityLinkUserInfo(userId), + new IdentityLinkUserInfo(linkedUserId))).ShouldBeTrue(); + + await uow.CompleteAsync(); + } + } } diff --git a/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs new file mode 100644 index 0000000000..347d91b9c9 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/OrganizationUnitManager_CreateMany_Tests.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Shouldly; +using Volo.Abp.Guids; +using Volo.Abp.Modularity; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.Identity; + +public abstract class OrganizationUnitManager_CreateMany_Tests : AbpIdentityTestBase + where TStartupModule : IAbpModule +{ + protected OrganizationUnitManager OrganizationUnitManager { get; } + protected IOrganizationUnitRepository OrganizationUnitRepository { get; } + protected ILookupNormalizer LookupNormalizer { get; } + protected IUnitOfWorkManager UnitOfWorkManager { get; } + protected IGuidGenerator GuidGenerator { get; } + + protected OrganizationUnitManager_CreateMany_Tests() + { + OrganizationUnitManager = GetRequiredService(); + OrganizationUnitRepository = GetRequiredService(); + LookupNormalizer = GetRequiredService(); + UnitOfWorkManager = GetRequiredService(); + GuidGenerator = GetRequiredService(); + } + + [Fact] + public virtual async Task Should_Not_Insert_Anything_When_A_Group_Is_Not_Valid() + { + var validDisplayName = $"valid-{Guid.NewGuid():N}"; + + using (var uow = UnitOfWorkManager.Begin()) + { + var parent = await OrganizationUnitRepository.GetAsync("OU1"); + + //The second group is not valid, OU11 is already a child of OU1. + await Should.ThrowAsync(async () => + await OrganizationUnitManager.CreateManyAsync([ + new OrganizationUnit(GuidGenerator.Create(), validDisplayName), + new OrganizationUnit(GuidGenerator.Create(), "OU11", parent.Id) + ])); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + (await OrganizationUnitRepository.GetAsync(validDisplayName)).ShouldBeNull(); + + await uow.CompleteAsync(); + } + } +}