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 ba11999b09..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 @@ -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(); + + //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); + } 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..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 @@ -48,6 +48,51 @@ 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. 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)); + + if (organizationUnits.Any(x => x.TenantId != CurrentTenant.Id)) + { + throw new AbpException("Organization units of another tenant can not be created, change the current tenant instead!"); + } + + var groups = organizationUnits.GroupBy(x => x.ParentId).ToList(); + + foreach (var group in groups) + { + await ValidateParentTenantAsync(group.Key, CurrentTenant.Id); + + var siblings = await FindChildrenAsync(group.Key); + string lastCode = null; + + foreach (var organizationUnit in group) + { + organizationUnit.Code = lastCode = lastCode == null + ? await GetNextChildCodeAsync(group.Key) + : OrganizationUnit.CalculateNextCode(lastCode); + + await ValidateOrganizationUnitAsync(organizationUnit, siblings); + + 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) { await ValidateOrganizationUnitAsync(organizationUnit); @@ -141,15 +186,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_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 43c97a945a..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 @@ -1,9 +1,16 @@ using Microsoft.AspNetCore.Identity; using Shouldly; using System; +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; @@ -179,4 +186,136 @@ 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(); + } + } + + [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/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.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 new file mode 100644 index 0000000000..c6e6b179c6 --- /dev/null +++ b/modules/identity/test/Volo.Abp.Identity.TestBase/Volo/Abp/Identity/IdentityUserManager_Delete_Tests.cs @@ -0,0 +1,477 @@ +using System; +using System.Security.Claims; +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; + +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 ICurrentTenant CurrentTenant { get; } + protected IDistributedCache DynamicClaimCache { get; } + + protected IdentityUserManager_Delete_Tests() + { + CurrentTenant = GetRequiredService(); + DynamicClaimCache = GetRequiredService>(); + 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.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(); + } + + using (var uow = UnitOfWorkManager.Begin()) + { + var user = await IdentityUserRepository.FindByNormalizedUserNameAsync( + LookupNormalizer.NormalizeName(userName), + includeDetails: false); + + (await IdentityUserManager.DeleteAsync(user)).CheckErrors(); + + 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(); + } + } + + [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(); + } + } + + [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)); + } + } + + [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(); + } + } +}