diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 55b9caa550..d78c462005 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -71,6 +71,7 @@ public class OrganizationUsersController : BaseAdminConsoleController private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; + private readonly IBulkResendOrganizationInvitesCommand _bulkResendOrganizationInvitesCommand; private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; @@ -105,6 +106,7 @@ public class OrganizationUsersController : BaseAdminConsoleController IInitPendingOrganizationCommand initPendingOrganizationCommand, IRevokeOrganizationUserCommand revokeOrganizationUserCommand, IResendOrganizationInviteCommand resendOrganizationInviteCommand, + IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand, IAdminRecoverAccountCommand adminRecoverAccountCommand, IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand) { @@ -131,6 +133,7 @@ public class OrganizationUsersController : BaseAdminConsoleController _featureService = featureService; _pricingClient = pricingClient; _resendOrganizationInviteCommand = resendOrganizationInviteCommand; + _bulkResendOrganizationInvitesCommand = bulkResendOrganizationInvitesCommand; _automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand; _confirmOrganizationUserCommand = confirmOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; @@ -273,7 +276,17 @@ public class OrganizationUsersController : BaseAdminConsoleController public async Task> BulkReinvite(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { var userId = _userService.GetProperUserId(User); - var result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids); + + IEnumerable> result; + if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud)) + { + result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids); + } + else + { + result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids); + } + return new ListResponseModel( result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2))); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommand.cs new file mode 100644 index 0000000000..c7c80bd937 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommand.cs @@ -0,0 +1,69 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Utilities.DebuggingInstruments; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public class BulkResendOrganizationInvitesCommand : IBulkResendOrganizationInvitesCommand +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; + private readonly ILogger _logger; + + public BulkResendOrganizationInvitesCommand( + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + ILogger logger) + { + _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; + _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; + _logger = logger; + } + + public async Task>> BulkResendInvitesAsync( + Guid organizationId, + Guid? invitingUserId, + IEnumerable organizationUsersId) + { + var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId); + _logger.LogUserInviteStateDiagnostics(orgUsers); + + var org = await _organizationRepository.GetByIdAsync(organizationId); + if (org == null) + { + throw new NotFoundException(); + } + + var validUsers = new List(); + var result = new List>(); + + foreach (var orgUser in orgUsers) + { + if (orgUser.Status != OrganizationUserStatusType.Invited || orgUser.OrganizationId != organizationId) + { + result.Add(Tuple.Create(orgUser, "User invalid.")); + } + else + { + validUsers.Add(orgUser); + } + } + + if (validUsers.Any()) + { + await _sendOrganizationInvitesCommand.SendInvitesAsync( + new SendInvitesRequest(validUsers, org)); + + result.AddRange(validUsers.Select(u => Tuple.Create(u, ""))); + } + + return result; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IBulkResendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IBulkResendOrganizationInvitesCommand.cs new file mode 100644 index 0000000000..342a06fcf9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IBulkResendOrganizationInvitesCommand.cs @@ -0,0 +1,20 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public interface IBulkResendOrganizationInvitesCommand +{ + /// + /// Resend invites to multiple organization users in bulk. + /// + /// The ID of the organization. + /// The ID of the user who is resending the invites. + /// The IDs of the organization users to resend invites to. + /// A tuple containing the OrganizationUser and an error message (empty string if successful) + Task>> BulkResendInvitesAsync( + Guid organizationId, + Guid? invitingUserId, + IEnumerable organizationUsersId); +} + + diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 91030c5151..9cb9159ebb 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -197,6 +197,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index cb03844aa2..43f0123a3f 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -11,6 +11,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; @@ -730,4 +731,68 @@ public class OrganizationUsersControllerTests var problemResult = Assert.IsType>(result); Assert.Equal(StatusCodes.Status500InternalServerError, problemResult.StatusCode); } + + [Theory] + [BitAutoData] + public async Task BulkReinvite_WhenFeatureFlagEnabled_UsesBulkResendOrganizationInvitesCommand( + Guid organizationId, + OrganizationUserBulkRequestModel bulkRequestModel, + List organizationUsers, + Guid userId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().ManageUsers(organizationId).Returns(true); + sutProvider.GetDependency().GetProperUserId(Arg.Any()).Returns(userId); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud) + .Returns(true); + + var expectedResults = organizationUsers.Select(u => Tuple.Create(u, "")).ToList(); + sutProvider.GetDependency() + .BulkResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids) + .Returns(expectedResults); + + // Act + var response = await sutProvider.Sut.BulkReinvite(organizationId, bulkRequestModel); + + // Assert + Assert.Equal(organizationUsers.Count, response.Data.Count()); + + await sutProvider.GetDependency() + .Received(1) + .BulkResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids); + } + + [Theory] + [BitAutoData] + public async Task BulkReinvite_WhenFeatureFlagDisabled_UsesLegacyOrganizationService( + Guid organizationId, + OrganizationUserBulkRequestModel bulkRequestModel, + List organizationUsers, + Guid userId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().ManageUsers(organizationId).Returns(true); + sutProvider.GetDependency().GetProperUserId(Arg.Any()).Returns(userId); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud) + .Returns(false); + + var expectedResults = organizationUsers.Select(u => Tuple.Create(u, "")).ToList(); + sutProvider.GetDependency() + .ResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids) + .Returns(expectedResults); + + // Act + var response = await sutProvider.Sut.BulkReinvite(organizationId, bulkRequestModel); + + // Assert + Assert.Equal(organizationUsers.Count, response.Data.Count()); + + await sutProvider.GetDependency() + .Received(1) + .ResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommandTests.cs new file mode 100644 index 0000000000..caae3a3b12 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommandTests.cs @@ -0,0 +1,113 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +[SutProviderCustomize] +public class BulkResendOrganizationInvitesCommandTests +{ + [Theory] + [BitAutoData] + public async Task BulkResendInvitesAsync_ValidatesUsersAndSendsBatchInvite( + Organization organization, + OrganizationUser validUser1, + OrganizationUser validUser2, + OrganizationUser acceptedUser, + OrganizationUser wrongOrgUser, + SutProvider sutProvider) + { + validUser1.OrganizationId = organization.Id; + validUser1.Status = OrganizationUserStatusType.Invited; + validUser2.OrganizationId = organization.Id; + validUser2.Status = OrganizationUserStatusType.Invited; + acceptedUser.OrganizationId = organization.Id; + acceptedUser.Status = OrganizationUserStatusType.Accepted; + wrongOrgUser.OrganizationId = Guid.NewGuid(); + wrongOrgUser.Status = OrganizationUserStatusType.Invited; + + var users = new List { validUser1, validUser2, acceptedUser, wrongOrgUser }; + var userIds = users.Select(u => u.Id).ToList(); + + sutProvider.GetDependency().GetManyAsync(userIds).Returns(users); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + var result = (await sutProvider.Sut.BulkResendInvitesAsync(organization.Id, null, userIds)).ToList(); + + Assert.Equal(4, result.Count); + Assert.Equal(2, result.Count(r => string.IsNullOrEmpty(r.Item2))); + Assert.Equal(2, result.Count(r => r.Item2 == "User invalid.")); + + await sutProvider.GetDependency() + .Received(1) + .SendInvitesAsync(Arg.Is(req => + req.Organization == organization && + req.Users.Length == 2 && + req.InitOrganization == false)); + } + + [Theory] + [BitAutoData] + public async Task BulkResendInvitesAsync_AllInvalidUsers_DoesNotSendInvites( + Organization organization, + List organizationUsers, + SutProvider sutProvider) + { + foreach (var user in organizationUsers) + { + user.OrganizationId = organization.Id; + user.Status = OrganizationUserStatusType.Confirmed; + } + + var userIds = organizationUsers.Select(u => u.Id).ToList(); + sutProvider.GetDependency().GetManyAsync(userIds).Returns(organizationUsers); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + var result = (await sutProvider.Sut.BulkResendInvitesAsync(organization.Id, null, userIds)).ToList(); + + Assert.Equal(organizationUsers.Count, result.Count); + Assert.All(result, r => Assert.Equal("User invalid.", r.Item2)); + await sutProvider.GetDependency().DidNotReceive() + .SendInvitesAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task BulkResendInvitesAsync_OrganizationNotFound_ThrowsNotFoundException( + Guid organizationId, + List userIds, + List organizationUsers, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetManyAsync(userIds).Returns(organizationUsers); + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns((Organization?)null); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.BulkResendInvitesAsync(organizationId, null, userIds)); + } + + [Theory] + [BitAutoData] + public async Task BulkResendInvitesAsync_EmptyUserList_ReturnsEmpty( + Organization organization, + SutProvider sutProvider) + { + var emptyUserIds = new List(); + sutProvider.GetDependency().GetManyAsync(emptyUserIds).Returns(new List()); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + var result = await sutProvider.Sut.BulkResendInvitesAsync(organization.Id, null, emptyUserIds); + + Assert.Empty(result); + await sutProvider.GetDependency().DidNotReceive() + .SendInvitesAsync(Arg.Any()); + } +}