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