diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index f3d440e0d..29b3ad2da 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -174,12 +174,12 @@ public class UserRepository : Repository, IUserRepository } public async Task DeleteManyAsync(IEnumerable users) { - var list = users.Select(user => user.Id); + var ids = users.Select(user => user.Id); using (var connection = new SqlConnection(ConnectionString)) { await connection.ExecuteAsync( - $"[{Schema}].[{Table}_DeleteById]", - new { Ids = list.ToGuidIdArrayTVP() }, + $"[{Schema}].[{Table}_DeleteByIds]", + new { Ids = JsonSerializer.Serialize(ids) }, commandType: CommandType.StoredProcedure, commandTimeout: 180); } diff --git a/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql b/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql new file mode 100644 index 000000000..eaa309786 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql @@ -0,0 +1,158 @@ +CREATE PROCEDURE [dbo].[User_DeleteByIds] + @Ids NVARCHAR(MAX) +WITH RECOMPILE +AS +BEGIN + SET NOCOUNT ON + -- Declare a table variable to hold the parsed JSON data + DECLARE @ParsedIds TABLE (Id UNIQUEIDENTIFIER); + + -- Parse the JSON input into the table variable + INSERT INTO @ParsedIds (Id) + SELECT value + FROM OPENJSON(@Ids); + + -- Check if the input table is empty + IF (SELECT COUNT(1) FROM @ParsedIds) < 1 + BEGIN + RETURN(-1); + END + + DECLARE @BatchSize INT = 100 + + -- Delete ciphers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION User_DeleteById_Ciphers + + DELETE TOP(@BatchSize) + FROM + [dbo].[Cipher] + WHERE + [UserId] IN (@ParsedIds) + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION User_DeleteById_Ciphers + END + + BEGIN TRANSACTION User_DeleteById + + -- Delete WebAuthnCredentials + DELETE + FROM + [dbo].[WebAuthnCredential] + WHERE + [UserId] IN (@ParsedIds) + + -- Delete folders + DELETE + FROM + [dbo].[Folder] + WHERE + [UserId] IN (@ParsedIds) + + -- Delete AuthRequest, must be before Device + DELETE + FROM + [dbo].[AuthRequest] + WHERE + [UserId] IN (@ParsedIds) + + -- Delete devices + DELETE + FROM + [dbo].[Device] + WHERE + [UserId] IN (@ParsedIds) + + -- Delete collection users + DELETE + CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE + OU.[UserId] IN (@ParsedIds) + + -- Delete group users + DELETE + GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE + OU.[UserId] IN (@ParsedIds) + + -- Delete AccessPolicy + DELETE + AP + FROM + [dbo].[AccessPolicy] AP + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = AP.[OrganizationUserId] + WHERE + [UserId] IN (@ParsedIds) + + -- Delete organization users + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [UserId] IN (@ParsedIds) + + -- Delete provider users + DELETE + FROM + [dbo].[ProviderUser] + WHERE + [UserId] IN (@ParsedIds) + + -- Delete SSO Users + DELETE + FROM + [dbo].[SsoUser] + WHERE + [UserId] IN (@ParsedIds) + + -- Delete Emergency Accesses + DELETE + FROM + [dbo].[EmergencyAccess] + WHERE + [GrantorId] in (@ParsedIds) + OR + [GranteeId] in (@ParsedIds) + + -- Delete Sends + DELETE + FROM + [dbo].[Send] + WHERE + [UserId] IN (@ParsedIds) + + -- Delete Notification Status + DELETE + FROM + [dbo].[NotificationStatus] + WHERE + [UserId] IN (@ParsedIds) + + -- Delete Notification + DELETE + FROM + [dbo].[Notification] + WHERE + [UserId] IN (@ParsedIds) + + -- Finally, delete the user + DELETE + FROM + [dbo].[User] + WHERE + [Id] in (@ParsedIds) + + COMMIT TRANSACTION User_DeleteById +END diff --git a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs index a0e149ab6..066a550fa 100644 --- a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs @@ -92,52 +92,6 @@ public class UserRepositoryTests Assert.True(savedSqlUser == null); } - [CiSkippedTheory, EfUserAutoData] - public async Task DeleteManyAsync_Works_DataMatches(IEnumerable users, List suts, SqlRepo.UserRepository sqlUserRepo) - { - foreach (var sut in suts) - { - List efUserList = new List(); - foreach (var user in users) - { - var postEfUser = await sut.CreateAsync(user); - sut.ClearChangeTracking(); - - var savedEfUser = await sut.GetByIdAsync(postEfUser.Id); - Assert.True(savedEfUser != null); - sut.ClearChangeTracking(); - efUserList.Add(savedEfUser); - } - - await sut.DeleteManyAsync(efUserList); - sut.ClearChangeTracking(); - - foreach (var efUser in efUserList) - { - var savedEfUser = await sut.GetByIdAsync(efUser.Id); - Assert.True(savedEfUser == null); - } - } - List userList = new List(); - - foreach (var user in users) - { - var postSqlUser = await sqlUserRepo.CreateAsync(user); - var savedSqlUser = await sqlUserRepo.GetByIdAsync(postSqlUser.Id); - Assert.True(savedSqlUser != null); - userList.Add(postSqlUser); - - } - await sqlUserRepo.DeleteManyAsync(userList); - - foreach (var user in userList) - { - var savedSqlUser = await sqlUserRepo.GetByIdAsync(user.Id); - Assert.True(savedSqlUser == null); - } - - } - [CiSkippedTheory, EfUserAutoData] public async Task GetByEmailAsync_Works_DataMatches(User user, UserCompare equalityComparer, List suts, SqlRepo.UserRepository sqlUserRepo) diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepoistoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepoistoryTests.cs new file mode 100644 index 000000000..c1e946e6f --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepoistoryTests.cs @@ -0,0 +1,61 @@ +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Repositories; + +public class UserRepositoryTests +{ + [DatabaseTheory, DatabaseData] + public async Task DeleteAsync_Works(IUserRepository userRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + await userRepository.DeleteAsync(user); + + var newUser = await userRepository.GetByIdAsync(user.Id); + Assert.NotNull(newUser); + Assert.NotEqual(newUser.AccountRevisionDate, user.AccountRevisionDate); + } + + [DatabaseTheory, DatabaseData] + public async Task DeleteManyAsync_Works(IUserRepository userRepository) + { + var user1 = await userRepository.CreateAsync(new User + { + Name = "Test User 1", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var user2 = await userRepository.CreateAsync(new User + { + Name = "Test User 2", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + await userRepository.DeleteManyAsync(new List + { + user1, + user2 + }); + + var updatedUser1 = await userRepository.GetByIdAsync(user1.Id); + Assert.NotNull(updatedUser1); + var updatedUser2 = await userRepository.GetByIdAsync(user2.Id); + Assert.NotNull(updatedUser2); + + Assert.NotEqual(updatedUser1.AccountRevisionDate, user1.AccountRevisionDate); + Assert.NotEqual(updatedUser2.AccountRevisionDate, user2.AccountRevisionDate); + } + +}