From 6514b342fc75ed7af14b3255acbc69a18eba8aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 25 Sep 2024 11:02:17 +0100 Subject: [PATCH] [PM-10316] Add Command to Remove User and Delete Data for Organization-Managed Users (#4726) * Add HasVerifiedDomainsAsync method to IOrganizationDomainService * Add GetManagedUserIdsByOrganizationIdAsync method to IOrganizationUserRepository and the corresponding queries * Fix case on the sproc OrganizationUser_ReadManagedIdsByOrganizationId parameter * Update the EF query to use the Email from the User table * dotnet format * Fix IOrganizationDomainService.HasVerifiedDomainsAsync by checking that domains have been Verified and add unit tests * Rename IOrganizationUserRepository.GetManagedUserIdsByOrganizationAsync * Fix domain queries * Add OrganizationUserRepository integration tests * Add summary to IOrganizationDomainService.HasVerifiedDomainsAsync * chore: Rename IOrganizationUserRepository.GetManagedUserIdsByOrganizationAsync to GetManyIdsManagedByOrganizationIdAsync * Add IsManagedByAnyOrganizationAsync method to IUserRepository * Add integration tests for UserRepository.IsManagedByAnyOrganizationAsync * Refactor to IUserService.IsManagedByAnyOrganizationAsync and IOrganizationService.GetUsersOrganizationManagementStatusAsync * chore: Refactor IsManagedByAnyOrganizationAsync method in UserService * Refactor IOrganizationService.GetUsersOrganizationManagementStatusAsync to return IDictionary * Extract IOrganizationService.GetUsersOrganizationManagementStatusAsync into a query * Update comments in OrganizationDomainService to use proper capitalization * Move OrganizationDomainService to AdminConsole ownership and update namespace * feat: Add support for organization domains in enterprise plans * feat: Add HasOrganizationDomains property to OrganizationAbility class * refactor: Update GetOrganizationUsersManagementStatusQuery to use IApplicationCacheService * Remove HasOrganizationDomains and use UseSso to check if Organization can have Verified Domains * Refactor UserService.IsManagedByAnyOrganizationAsync to simply check the UseSso flag * Add new event types for organization user deletion and voluntary departure * Add DeleteManagedOrganizationUserAccountCommand to remove user and delete account * Refactor DeleteManagedOrganizationUserAccountCommand to use orgUser.Id instead of orgUser.UserId.Value * Add DeleteManagedOrganizationUserAccountCommandTests * Remove duplicate sql migration script * Update DeleteManagedOrganizationUserAccountCommand methods to cover all existing checks on OrganizationService * Add unit tests for all user checks * Refactor DeleteManagedOrganizationUserAccountCommand * Set nullable enable annotation on DeleteManagedOrganizationUserAccountCommand * Fix possible null reference * Refactor DeleteManagedOrganizationUserAccountCommand.cs for improved event logging * Use UserRepository.GetByIdAsync instead of UserService.GetUserByIdAsync * Refactor DeleteManagedOrganizationUserAccountCommand.cs for improved error messages * Refactor DeleteManagedOrganizationUserAccountCommand.cs for improved event logging, error handling and reduce database calls * Rename unit tests to correctly describe expected outcome --- src/Core/AdminConsole/Enums/EventType.cs | 4 +- ...teManagedOrganizationUserAccountCommand.cs | 160 ++++++ ...teManagedOrganizationUserAccountCommand.cs | 19 + ...OrganizationServiceCollectionExtensions.cs | 1 + ...agedOrganizationUserAccountCommandTests.cs | 492 ++++++++++++++++++ 5 files changed, 675 insertions(+), 1 deletion(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteManagedOrganizationUserAccountCommand.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/AdminConsole/Enums/EventType.cs index ed3fdb21d..9d9cb0998 100644 --- a/src/Core/AdminConsole/Enums/EventType.cs +++ b/src/Core/AdminConsole/Enums/EventType.cs @@ -46,7 +46,7 @@ public enum EventType : int OrganizationUser_Invited = 1500, OrganizationUser_Confirmed = 1501, OrganizationUser_Updated = 1502, - OrganizationUser_Removed = 1503, + OrganizationUser_Removed = 1503, // Organization user data was deleted OrganizationUser_UpdatedGroups = 1504, OrganizationUser_UnlinkedSso = 1505, OrganizationUser_ResetPassword_Enroll = 1506, @@ -58,6 +58,8 @@ public enum EventType : int OrganizationUser_Restored = 1512, OrganizationUser_ApprovedAuthRequest = 1513, OrganizationUser_RejectedAuthRequest = 1514, + OrganizationUser_Deleted = 1515, // Both user and organization user data were deleted + OrganizationUser_Left = 1516, // User voluntarily left the organization Organization_Updated = 1600, Organization_PurgedVault = 1601, diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs new file mode 100644 index 000000000..d70d061c8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs @@ -0,0 +1,160 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; + +#nullable enable + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganizationUserAccountCommand +{ + private readonly IUserService _userService; + private readonly IEventService _eventService; + private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IUserRepository _userRepository; + private readonly ICurrentContext _currentContext; + private readonly IOrganizationService _organizationService; + public DeleteManagedOrganizationUserAccountCommand( + IUserService userService, + IEventService eventService, + IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, + IOrganizationUserRepository organizationUserRepository, + IUserRepository userRepository, + ICurrentContext currentContext, + IOrganizationService organizationService) + { + _userService = userService; + _eventService = eventService; + _getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; + _organizationUserRepository = organizationUserRepository; + _userRepository = userRepository; + _currentContext = currentContext; + _organizationService = organizationService; + } + + public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId) + { + var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if (organizationUser == null || organizationUser.OrganizationId != organizationId) + { + throw new NotFoundException("Member not found."); + } + + var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, new[] { organizationUserId }); + var hasOtherConfirmedOwners = await _organizationService.HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId }, includeProvider: true); + + await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, managementStatus, hasOtherConfirmedOwners); + + var user = await _userRepository.GetByIdAsync(organizationUser.UserId!.Value); + if (user == null) + { + throw new NotFoundException("Member not found."); + } + + await _userService.DeleteAsync(user); + await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Deleted); + } + + public async Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid? deletingUserId) + { + var orgUsers = await _organizationUserRepository.GetManyAsync(orgUserIds); + var userIds = orgUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId!.Value).ToList(); + var users = await _userRepository.GetManyAsync(userIds); + + var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, orgUserIds); + var hasOtherConfirmedOwners = await _organizationService.HasConfirmedOwnersExceptAsync(organizationId, orgUserIds, includeProvider: true); + + var results = new List<(Guid OrganizationUserId, string? ErrorMessage)>(); + foreach (var orgUserId in orgUserIds) + { + try + { + var orgUser = orgUsers.FirstOrDefault(ou => ou.Id == orgUserId); + if (orgUser == null || orgUser.OrganizationId != organizationId) + { + throw new NotFoundException("Member not found."); + } + + await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, managementStatus, hasOtherConfirmedOwners); + + var user = users.FirstOrDefault(u => u.Id == orgUser.UserId); + if (user == null) + { + throw new NotFoundException("Member not found."); + } + + await _userService.DeleteAsync(user); + results.Add((orgUserId, string.Empty)); + } + catch (Exception ex) + { + results.Add((orgUserId, ex.Message)); + } + } + + await LogDeletedOrganizationUsersAsync(orgUsers, results); + + return results; + } + + private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary managementStatus, bool hasOtherConfirmedOwners) + { + if (!orgUser.UserId.HasValue || orgUser.Status == OrganizationUserStatusType.Invited) + { + throw new BadRequestException("You cannot delete a member with Invited status."); + } + + if (deletingUserId.HasValue && orgUser.UserId.Value == deletingUserId.Value) + { + throw new BadRequestException("You cannot delete yourself."); + } + + if (orgUser.Type == OrganizationUserType.Owner) + { + if (deletingUserId.HasValue && !await _currentContext.OrganizationOwner(organizationId)) + { + throw new BadRequestException("Only owners can delete other owners."); + } + + if (!hasOtherConfirmedOwners) + { + throw new BadRequestException("Organization must have at least one confirmed owner."); + } + } + + if (!managementStatus.TryGetValue(orgUser.Id, out var isManaged) || !isManaged) + { + throw new BadRequestException("Member is not managed by the organization."); + } + } + + private async Task LogDeletedOrganizationUsersAsync( + IEnumerable orgUsers, + IEnumerable<(Guid OrgUserId, string? ErrorMessage)> results) + { + var eventDate = DateTime.UtcNow; + var events = new List<(OrganizationUser OrgUser, EventType Event, DateTime? EventDate)>(); + + foreach (var (orgUserId, errorMessage) in results) + { + var orgUser = orgUsers.FirstOrDefault(ou => ou.Id == orgUserId); + // If the user was not found or there was an error, we skip logging the event + if (orgUser == null || !string.IsNullOrEmpty(errorMessage)) + { + continue; + } + + events.Add((orgUser, EventType.OrganizationUser_Deleted, eventDate)); + } + + if (events.Any()) + { + await _eventService.LogOrganizationUserEventsAsync(events); + } + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteManagedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteManagedOrganizationUserAccountCommand.cs new file mode 100644 index 000000000..d548966aa --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteManagedOrganizationUserAccountCommand.cs @@ -0,0 +1,19 @@ +#nullable enable + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IDeleteManagedOrganizationUserAccountCommand +{ + /// + /// Removes a user from an organization and deletes all of their associated user data. + /// + Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId); + + /// + /// Removes multiple users from an organization and deletes all of their associated user data. + /// + /// + /// An error message for each user that could not be removed, otherwise null. + /// + Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid? deletingUserId); +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 0d623d5b3..dac1268dc 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -91,6 +91,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs new file mode 100644 index 000000000..585c5fc8d --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs @@ -0,0 +1,492 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; + +[SutProviderCustomize] +public class DeleteManagedOrganizationUserAccountCommandTests +{ + [Theory] + [BitAutoData] + public async Task DeleteUserAsync_WithValidUser_DeletesUserAndLogsEvent( + SutProvider sutProvider, User user, Guid deletingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + // Arrange + organizationUser.UserId = user.Id; + + sutProvider.GetDependency() + .GetByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + sutProvider.GetDependency() + .GetUsersOrganizationManagementStatusAsync( + organizationUser.OrganizationId, + Arg.Is>(ids => ids.Contains(organizationUser.Id))) + .Returns(new Dictionary { { organizationUser.Id, true } }); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync( + organizationUser.OrganizationId, + Arg.Is>(ids => ids.Contains(organizationUser.Id)), + includeProvider: Arg.Any()) + .Returns(true); + + // Act + await sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId); + + // Assert + await sutProvider.GetDependency().Received(1).DeleteAsync(user); + await sutProvider.GetDependency().Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Deleted); + } + + [Theory] + [BitAutoData] + public async Task DeleteUserAsync_WithUserNotFound_ThrowsException( + SutProvider sutProvider, + Guid organizationId, Guid organizationUserId) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(organizationUserId) + .Returns((OrganizationUser?)null); + + // Act + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.DeleteUserAsync(organizationId, organizationUserId, null)); + + // Assert + Assert.Equal("Member not found.", exception.Message); + await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().Received(0) + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task DeleteUserAsync_DeletingYourself_ThrowsException( + SutProvider sutProvider, + User user, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser, + Guid deletingUserId) + { + // Arrange + organizationUser.UserId = user.Id = deletingUserId; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + sutProvider.GetDependency().GetByIdAsync(user.Id) + .Returns(user); + + // Act + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); + + // Assert + Assert.Equal("You cannot delete yourself.", exception.Message); + await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().Received(0) + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task DeleteUserAsync_WhenUserIsInvited_ThrowsException( + SutProvider sutProvider, + [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser organizationUser) + { + // Arrange + organizationUser.UserId = null; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + // Act + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, null)); + + // Assert + Assert.Equal("You cannot delete a member with Invited status.", exception.Message); + await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().Received(0) + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task DeleteUserAsync_DeletingOwnerWhenNotOwner_ThrowsException( + SutProvider sutProvider, User user, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, + Guid deletingUserId) + { + // Arrange + organizationUser.UserId = user.Id; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + sutProvider.GetDependency().GetByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .OrganizationOwner(organizationUser.OrganizationId) + .Returns(false); + + // Act + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); + + // Assert + Assert.Equal("Only owners can delete other owners.", exception.Message); + await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().Received(0) + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task DeleteUserAsync_DeletingLastConfirmedOwner_ThrowsException( + SutProvider sutProvider, User user, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, + Guid deletingUserId) + { + // Arrange + organizationUser.UserId = user.Id; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + sutProvider.GetDependency().GetByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .OrganizationOwner(organizationUser.OrganizationId) + .Returns(true); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync( + organizationUser.OrganizationId, + Arg.Is>(ids => ids.Contains(organizationUser.Id)), + includeProvider: Arg.Any()) + .Returns(false); + + // Act + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); + + // Assert + Assert.Equal("Organization must have at least one confirmed owner.", exception.Message); + await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().Received(0) + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task DeleteUserAsync_WithUserNotManaged_ThrowsException( + SutProvider sutProvider, User user, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) + { + // Arrange + organizationUser.UserId = user.Id; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + sutProvider.GetDependency().GetByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .GetUsersOrganizationManagementStatusAsync(organizationUser.OrganizationId, Arg.Any>()) + .Returns(new Dictionary { { organizationUser.Id, false } }); + + // Act + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, null)); + + // Assert + Assert.Equal("Member is not managed by the organization.", exception.Message); + await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().Received(0) + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WithValidUsers_DeletesUsersAndLogsEvents( + SutProvider sutProvider, User user1, User user2, Guid organizationId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2) + { + // Arrange + orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId; + orgUser1.UserId = user1.Id; + orgUser2.UserId = user2.Id; + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(new List { orgUser1, orgUser2 }); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id))) + .Returns(new[] { user1, user2 }); + + sutProvider.GetDependency() + .GetUsersOrganizationManagementStatusAsync(organizationId, Arg.Any>()) + .Returns(new Dictionary { { orgUser1.Id, true }, { orgUser2.Id, true } }); + + // Act + var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, new[] { orgUser1.Id, orgUser2.Id }, null); + + // Assert + Assert.Equal(2, results.Count()); + Assert.All(results, r => Assert.Empty(r.Item2)); + + await sutProvider.GetDependency().Received(1).DeleteAsync(user1); + await sutProvider.GetDependency().Received(1).DeleteAsync(user2); + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync( + Arg.Is>(events => + events.Count(e => e.Item1.Id == orgUser1.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1 + && events.Count(e => e.Item1.Id == orgUser2.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1)); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WhenUserNotFound_ReturnsErrorMessage( + SutProvider sutProvider, + Guid organizationId, + Guid orgUserId) + { + // Act + var result = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, new[] { orgUserId }, null); + + // Assert + Assert.Single(result); + Assert.Equal(orgUserId, result.First().Item1); + Assert.Contains("Member not found.", result.First().Item2); + await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().Received(0) + .LogOrganizationUserEventsAsync(Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WhenDeletingYourself_ReturnsErrorMessage( + SutProvider sutProvider, + User user, [OrganizationUser] OrganizationUser orgUser, Guid deletingUserId) + { + // Arrange + orgUser.UserId = user.Id = deletingUserId; + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(new List { orgUser }); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(ids => ids.Contains(user.Id))) + .Returns(new[] { user }); + + // Act + var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, deletingUserId); + + // Assert + Assert.Single(result); + Assert.Equal(orgUser.Id, result.First().Item1); + Assert.Contains("You cannot delete yourself.", result.First().Item2); + await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().Received(0) + .LogOrganizationUserEventsAsync(Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WhenUserIsInvited_ReturnsErrorMessage( + SutProvider sutProvider, + [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser orgUser) + { + // Arrange + orgUser.UserId = null; + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(new List { orgUser }); + + // Act + var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, null); + + // Assert + Assert.Single(result); + Assert.Equal(orgUser.Id, result.First().Item1); + Assert.Contains("You cannot delete a member with Invited status.", result.First().Item2); + await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().Received(0) + .LogOrganizationUserEventsAsync(Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WhenDeletingOwnerAsNonOwner_ReturnsErrorMessage( + SutProvider sutProvider, User user, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, + Guid deletingUserId) + { + // Arrange + orgUser.UserId = user.Id; + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(new List { orgUser }); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(i => i.Contains(user.Id))) + .Returns(new[] { user }); + + sutProvider.GetDependency() + .OrganizationOwner(orgUser.OrganizationId) + .Returns(false); + + var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, deletingUserId); + + Assert.Single(result); + Assert.Equal(orgUser.Id, result.First().Item1); + Assert.Contains("Only owners can delete other owners.", result.First().Item2); + await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().Received(0) + .LogOrganizationUserEventsAsync(Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WhenDeletingLastOwner_ReturnsErrorMessage( + SutProvider sutProvider, User user, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, + Guid deletingUserId) + { + // Arrange + orgUser.UserId = user.Id; + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(new List { orgUser }); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(i => i.Contains(user.Id))) + .Returns(new[] { user }); + + sutProvider.GetDependency() + .OrganizationOwner(orgUser.OrganizationId) + .Returns(true); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(orgUser.OrganizationId, Arg.Any>(), true) + .Returns(false); + + // Act + var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, deletingUserId); + + // Assert + Assert.Single(result); + Assert.Equal(orgUser.Id, result.First().Item1); + Assert.Contains("Organization must have at least one confirmed owner.", result.First().Item2); + await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().Received(0) + .LogOrganizationUserEventsAsync(Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WhenUserNotManaged_ReturnsErrorMessage( + SutProvider sutProvider, User user, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) + { + // Arrange + orgUser.UserId = user.Id; + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(new List { orgUser }); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser.UserId.Value))) + .Returns(new[] { user }); + + sutProvider.GetDependency() + .GetUsersOrganizationManagementStatusAsync(Arg.Any(), Arg.Any>()) + .Returns(new Dictionary { { orgUser.Id, false } }); + + // Act + var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, null); + + // Assert + Assert.Single(result); + Assert.Equal(orgUser.Id, result.First().Item1); + Assert.Contains("Member is not managed by the organization.", result.First().Item2); + await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().Received(0) + .LogOrganizationUserEventsAsync(Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_MixedValidAndInvalidUsers_ReturnsAppropriateResults( + SutProvider sutProvider, User user1, User user3, + Guid organizationId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser orgUser2, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser3) + { + // Arrange + orgUser1.UserId = user1.Id; + orgUser2.UserId = null; + orgUser3.UserId = user3.Id; + orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = organizationId; + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(new List { orgUser1, orgUser2, orgUser3 }); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user3.Id))) + .Returns(new[] { user1, user3 }); + + sutProvider.GetDependency() + .GetUsersOrganizationManagementStatusAsync(organizationId, Arg.Any>()) + .Returns(new Dictionary { { orgUser1.Id, true }, { orgUser3.Id, false } }); + + // Act + var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, new[] { orgUser1.Id, orgUser2.Id, orgUser3.Id }, null); + + // Assert + Assert.Equal(3, results.Count()); + Assert.Empty(results.First(r => r.Item1 == orgUser1.Id).Item2); + Assert.Equal("You cannot delete a member with Invited status.", results.First(r => r.Item1 == orgUser2.Id).Item2); + Assert.Equal("Member is not managed by the organization.", results.First(r => r.Item1 == orgUser3.Id).Item2); + + await sutProvider.GetDependency().Received(1).DeleteAsync(user1); + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync( + Arg.Is>(events => + events.Count(e => e.Item1.Id == orgUser1.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1)); + } +}