diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 4bbb5db3f0..2417bf610d 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -9,6 +9,7 @@ using Bit.Admin.Utilities; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; @@ -32,7 +33,6 @@ namespace Bit.Admin.AdminConsole.Controllers; [Authorize] public class OrganizationsController : Controller { - private readonly IOrganizationService _organizationService; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationConnectionRepository _organizationConnectionRepository; @@ -55,9 +55,9 @@ public class OrganizationsController : Controller private readonly IProviderBillingService _providerBillingService; private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand; private readonly IPricingClient _pricingClient; + private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; public OrganizationsController( - IOrganizationService organizationService, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationConnectionRepository organizationConnectionRepository, @@ -79,9 +79,9 @@ public class OrganizationsController : Controller IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, IProviderBillingService providerBillingService, IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand, - IPricingClient pricingClient) + IPricingClient pricingClient, + IResendOrganizationInviteCommand resendOrganizationInviteCommand) { - _organizationService = organizationService; _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _organizationConnectionRepository = organizationConnectionRepository; @@ -104,6 +104,7 @@ public class OrganizationsController : Controller _providerBillingService = providerBillingService; _organizationInitiateDeleteCommand = organizationInitiateDeleteCommand; _pricingClient = pricingClient; + _resendOrganizationInviteCommand = resendOrganizationInviteCommand; } [RequirePermission(Permission.Org_List_View)] @@ -395,7 +396,7 @@ public class OrganizationsController : Controller var organizationUsers = await _organizationUserRepository.GetManyByOrganizationAsync(id, OrganizationUserType.Owner); foreach (var organizationUser in organizationUsers) { - await _organizationService.ResendInviteAsync(id, null, organizationUser.Id, true); + await _resendOrganizationInviteCommand.ResendInviteAsync(id, null, organizationUser.Id, true); } return Json(null); diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index bf49f144ce..2b464c24e2 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -12,6 +12,7 @@ using Bit.Core; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -62,6 +63,7 @@ public class OrganizationUsersController : Controller private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; + private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; @@ -92,7 +94,8 @@ public class OrganizationUsersController : Controller IConfirmOrganizationUserCommand confirmOrganizationUserCommand, IRestoreOrganizationUserCommand restoreOrganizationUserCommand, IInitPendingOrganizationCommand initPendingOrganizationCommand, - IRevokeOrganizationUserCommand revokeOrganizationUserCommand) + IRevokeOrganizationUserCommand revokeOrganizationUserCommand, + IResendOrganizationInviteCommand resendOrganizationInviteCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -116,6 +119,7 @@ public class OrganizationUsersController : Controller _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; _pricingClient = pricingClient; + _resendOrganizationInviteCommand = resendOrganizationInviteCommand; _confirmOrganizationUserCommand = confirmOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; _initPendingOrganizationCommand = initPendingOrganizationCommand; @@ -266,7 +270,7 @@ public class OrganizationUsersController : Controller public async Task Reinvite(Guid orgId, Guid id) { var userId = _userService.GetProperUserId(User); - await _organizationService.ResendInviteAsync(orgId, userId.Value, id); + await _resendOrganizationInviteCommand.ResendInviteAsync(orgId, userId.Value, id); } [HttpPost("{organizationUserId}/accept-init")] diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 6f41016dcd..7bfe5648b6 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -6,6 +6,7 @@ using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; @@ -32,6 +33,7 @@ public class MembersController : Controller private readonly IOrganizationRepository _organizationRepository; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; + private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; public MembersController( IOrganizationUserRepository organizationUserRepository, @@ -45,7 +47,8 @@ public class MembersController : Controller IPaymentService paymentService, IOrganizationRepository organizationRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IRemoveOrganizationUserCommand removeOrganizationUserCommand) + IRemoveOrganizationUserCommand removeOrganizationUserCommand, + IResendOrganizationInviteCommand resendOrganizationInviteCommand) { _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; @@ -59,6 +62,7 @@ public class MembersController : Controller _organizationRepository = organizationRepository; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; + _resendOrganizationInviteCommand = resendOrganizationInviteCommand; } /// @@ -260,7 +264,7 @@ public class MembersController : Controller { return new NotFoundResult(); } - await _organizationService.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id); + await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id); return new OkResult(); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IResendOrganizationInviteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IResendOrganizationInviteCommand.cs new file mode 100644 index 0000000000..645cdb42d2 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IResendOrganizationInviteCommand.cs @@ -0,0 +1,14 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public interface IResendOrganizationInviteCommand +{ + /// + /// Resend an invite to an organization user. + /// + /// The ID of the organization. + /// The ID of the user who is inviting the organization user. + /// The ID of the organization user to resend the invite to. + /// Whether to initialize the organization. + /// This is should only be true when inviting the owner of a new organization. + Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ResendOrganizationInviteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ResendOrganizationInviteCommand.cs new file mode 100644 index 0000000000..7e68af7816 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ResendOrganizationInviteCommand.cs @@ -0,0 +1,56 @@ +using Bit.Core.AdminConsole.Entities; +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 ResendOrganizationInviteCommand : IResendOrganizationInviteCommand +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; + private readonly ILogger _logger; + + public ResendOrganizationInviteCommand( + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + ILogger logger) + { + _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; + _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; + _logger = logger; + } + + public async Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, + bool initOrganization = false) + { + var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if (organizationUser == null || organizationUser.OrganizationId != organizationId || + organizationUser.Status != OrganizationUserStatusType.Invited) + { + throw new BadRequestException("User invalid."); + } + + _logger.LogUserInviteStateDiagnostics(organizationUser); + + var organization = await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId); + if (organization == null) + { + throw new BadRequestException("Organization invalid."); + } + await SendInviteAsync(organizationUser, organization, initOrganization); + } + + private async Task SendInviteAsync(OrganizationUser organizationUser, Organization organization, bool initOrganization) => + await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest( + users: [organizationUser], + organization: organization, + initOrganization: initOrganization)); +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 05c84c731c..6adfc4772f 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -29,7 +29,6 @@ public interface IOrganizationService Task> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites); Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId); - Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false); Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId); Task ImportAsync(Guid organizationId, IEnumerable groups, IEnumerable newUsers, IEnumerable removeUserExternalIds, diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index b1a07338a3..41e4f2f618 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -766,21 +766,6 @@ public class OrganizationService : IOrganizationService return result; } - public async Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, - bool initOrganization = false) - { - var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - if (orgUser == null || orgUser.OrganizationId != organizationId || - orgUser.Status != OrganizationUserStatusType.Invited) - { - throw new BadRequestException("User invalid."); - } - - _logger.LogUserInviteStateDiagnostics(orgUser); - - var org = await GetOrgById(orgUser.OrganizationId); - await SendInviteAsync(orgUser, org, initOrganization); - } private async Task SendInvitesAsync(IEnumerable orgUsers, Organization organization) => await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization)); diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 234d6f1a84..bcbaccca7c 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -186,6 +186,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ResendOrganizationInviteCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ResendOrganizationInviteCommandTests.cs new file mode 100644 index 0000000000..7fad49af24 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ResendOrganizationInviteCommandTests.cs @@ -0,0 +1,137 @@ +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 ResendOrganizationInviteCommandTests +{ + [Theory] + [BitAutoData] + public async Task ResendInviteAsync_WhenValidUserAndOrganization_SendsInvite( + Organization organization, + OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.OrganizationId = organization.Id; + organizationUser.Status = OrganizationUserStatusType.Invited; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + // Act + await sutProvider.Sut.ResendInviteAsync(organization.Id, invitingUserId: null, organizationUser.Id); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendInvitesAsync(Arg.Is(req => + req.Organization == organization && + req.Users.Length == 1 && + req.Users[0] == organizationUser && + req.InitOrganization == false)); + } + + [Theory] + [BitAutoData] + public async Task ResendInviteAsync_WhenInitOrganizationTrue_SendsInviteWithInitFlag( + Organization organization, + OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.OrganizationId = organization.Id; + organizationUser.Status = OrganizationUserStatusType.Invited; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + // Act + await sutProvider.Sut.ResendInviteAsync(organization.Id, invitingUserId: null, organizationUser.Id, initOrganization: true); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendInvitesAsync(Arg.Is(req => + req.Organization == organization && + req.Users.Length == 1 && + req.Users[0] == organizationUser && + req.InitOrganization == true)); + } + + [Theory] + [BitAutoData] + public async Task ResendInviteAsync_WhenOrganizationUserInvalid_ThrowsBadRequest( + Organization organization, + OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.OrganizationId = organization.Id; + organizationUser.Status = OrganizationUserStatusType.Accepted; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + // Act + Assert + var ex = await Assert.ThrowsAsync(() => + sutProvider.Sut.ResendInviteAsync(organization.Id, invitingUserId: null, organizationUser.Id)); + + Assert.Equal("User invalid.", ex.Message); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendInvitesAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task ResendInviteAsync_WhenOrganizationNotFound_ThrowsBadRequest( + Organization organization, + OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.OrganizationId = organization.Id; + organizationUser.Status = OrganizationUserStatusType.Invited; + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns((Organization?)null); + + // Act + Assert + var ex = await Assert.ThrowsAsync(() => + sutProvider.Sut.ResendInviteAsync(organization.Id, invitingUserId: null, organizationUser.Id)); + + Assert.Equal("Organization invalid.", ex.Message); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendInvitesAsync(Arg.Any()); + } +}