diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index b4abf81ee2..7f11b65d9e 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -6,6 +6,7 @@ using Bit.Admin.Utilities; using Bit.Core; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; @@ -34,14 +35,13 @@ namespace Bit.Admin.AdminConsole.Controllers; public class ProvidersController : Controller { private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationService _organizationService; + private readonly IResellerClientOrganizationSignUpCommand _resellerClientOrganizationSignUpCommand; private readonly IProviderRepository _providerRepository; private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly GlobalSettings _globalSettings; private readonly IApplicationCacheService _applicationCacheService; private readonly IProviderService _providerService; - private readonly IUserService _userService; private readonly ICreateProviderCommand _createProviderCommand; private readonly IFeatureService _featureService; private readonly IProviderPlanRepository _providerPlanRepository; @@ -54,14 +54,13 @@ public class ProvidersController : Controller public ProvidersController( IOrganizationRepository organizationRepository, - IOrganizationService organizationService, + IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand, IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderOrganizationRepository providerOrganizationRepository, IProviderService providerService, GlobalSettings globalSettings, IApplicationCacheService applicationCacheService, - IUserService userService, ICreateProviderCommand createProviderCommand, IFeatureService featureService, IProviderPlanRepository providerPlanRepository, @@ -71,14 +70,13 @@ public class ProvidersController : Controller IStripeAdapter stripeAdapter) { _organizationRepository = organizationRepository; - _organizationService = organizationService; + _resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand; _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; _providerOrganizationRepository = providerOrganizationRepository; _providerService = providerService; _globalSettings = globalSettings; _applicationCacheService = applicationCacheService; - _userService = userService; _createProviderCommand = createProviderCommand; _featureService = featureService; _providerPlanRepository = providerPlanRepository; @@ -459,7 +457,7 @@ public class ProvidersController : Controller } var organization = model.CreateOrganization(provider); - await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted); + await _resellerClientOrganizationSignUpCommand.SignUpResellerClientAsync(organization, model.Owners); await _providerService.AddOrganization(providerId, organization.Id, null); return RedirectToAction("Edit", "Providers", new { id = providerId }); diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs new file mode 100644 index 0000000000..446d7339ca --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs @@ -0,0 +1,130 @@ +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.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public record ResellerClientOrganizationSignUpResponse( + Organization Organization, + OrganizationUser OwnerOrganizationUser); + +/// +/// Command for signing up reseller client organizations in a pending state. +/// +public interface IResellerClientOrganizationSignUpCommand +{ + /// + /// Sign up a reseller client organization. The organization will be created in a pending state + /// (disabled and with Pending status) and the owner will be invited via email. The organization + /// will become active once the owner accepts the invitation. + /// + /// The organization to create. + /// The email of the organization owner who will be invited. + /// A response containing the created pending organization and invited owner user. + Task SignUpResellerClientAsync( + Organization organization, + string ownerEmail); +} + +public class ResellerClientOrganizationSignUpCommand : IResellerClientOrganizationSignUpCommand +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; + private readonly IApplicationCacheService _applicationCacheService; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IEventService _eventService; + private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; + private readonly IPaymentService _paymentService; + + public ResellerClientOrganizationSignUpCommand( + IOrganizationRepository organizationRepository, + IOrganizationApiKeyRepository organizationApiKeyRepository, + IApplicationCacheService applicationCacheService, + IOrganizationUserRepository organizationUserRepository, + IEventService eventService, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + IPaymentService paymentService) + { + _organizationRepository = organizationRepository; + _organizationApiKeyRepository = organizationApiKeyRepository; + _applicationCacheService = applicationCacheService; + _organizationUserRepository = organizationUserRepository; + _eventService = eventService; + _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; + _paymentService = paymentService; + } + + public async Task SignUpResellerClientAsync( + Organization organization, + string ownerEmail) + { + try + { + var createdOrganization = await CreateOrganizationAsync(organization); + var ownerOrganizationUser = await CreateAndInviteOwnerAsync(createdOrganization, ownerEmail); + + await _eventService.LogOrganizationUserEventAsync(ownerOrganizationUser, EventType.OrganizationUser_Invited); + + return new ResellerClientOrganizationSignUpResponse(organization, ownerOrganizationUser); + } + catch + { + await _paymentService.CancelAndRecoverChargesAsync(organization); + + if (organization.Id != default) + { + // Deletes the organization and all related data, including its owner user + await _organizationRepository.DeleteAsync(organization); + await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); + } + + throw; + } + } + + private async Task CreateOrganizationAsync(Organization organization) + { + organization.Id = CoreHelpers.GenerateComb(); + organization.Enabled = false; + organization.Status = OrganizationStatusType.Pending; + + await _organizationRepository.CreateAsync(organization); + await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey + { + OrganizationId = organization.Id, + ApiKey = CoreHelpers.SecureRandomString(30), + Type = OrganizationApiKeyType.Default, + RevisionDate = DateTime.UtcNow, + }); + await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + return organization; + } + + private async Task CreateAndInviteOwnerAsync(Organization organization, string ownerEmail) + { + var ownerOrganizationUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = null, + Email = ownerEmail, + Key = null, + Type = OrganizationUserType.Owner, + Status = OrganizationUserStatusType.Invited, + }; + + await _organizationUserRepository.CreateAsync(ownerOrganizationUser); + + await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest( + users: [ownerOrganizationUser], + organization: organization, + initOrganization: true)); + + return ownerOrganizationUser; + } +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 5fe68bd22e..feae561a19 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -1,5 +1,4 @@ -using System.Security.Claims; -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Auth.Enums; using Bit.Core.Entities; @@ -42,7 +41,6 @@ public interface IOrganizationService Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); Task>> RevokeUsersAsync(Guid organizationId, IEnumerable organizationUserIds, Guid? revokingUserId); - Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted); Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null); Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 4d709bb7cf..d5320b5110 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -1,5 +1,4 @@ -using System.Security.Claims; -using System.Text.Json; +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; @@ -1705,27 +1704,4 @@ public class OrganizationService : IOrganizationService return status; } - - public async Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted) - { - organization.Id = CoreHelpers.GenerateComb(); - organization.Enabled = false; - organization.Status = OrganizationStatusType.Pending; - - await SignUpAsync(organization, default, null, null, true); - - var ownerOrganizationUser = new OrganizationUser - { - OrganizationId = organization.Id, - UserId = null, - Email = ownerEmail, - Key = null, - Type = OrganizationUserType.Owner, - Status = OrganizationUserStatusType.Invited, - }; - await _organizationUserRepository.CreateAsync(ownerOrganizationUser); - - await SendInviteAsync(ownerOrganizationUser, organization, true); - await _eventService.LogOrganizationUserEventAsync(ownerOrganizationUser, EventType.OrganizationUser_Invited); - } } diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 8a02ee68d8..ef78e966f6 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -73,6 +73,7 @@ public static class OrganizationServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationDeleteCommands(this IServiceCollection services) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ResellerClientOrganizationSignUpCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ResellerClientOrganizationSignUpCommandTests.cs new file mode 100644 index 0000000000..55e5698ad4 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ResellerClientOrganizationSignUpCommandTests.cs @@ -0,0 +1,185 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +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.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations.OrganizationSignUp; + +[SutProviderCustomize] +public class ResellerClientOrganizationSignUpCommandTests +{ + [Theory] + [BitAutoData] + public async Task SignUpResellerClientAsync_WithValidParameters_CreatesOrganizationSuccessfully( + Organization organization, + string ownerEmail, + SutProvider sutProvider) + { + var result = await sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail); + + Assert.NotNull(result.Organization); + Assert.False(result.Organization.Enabled); + Assert.Equal(OrganizationStatusType.Pending, result.Organization.Status); + Assert.NotNull(result.OwnerOrganizationUser); + Assert.Equal(ownerEmail, result.OwnerOrganizationUser.Email); + Assert.Equal(OrganizationUserType.Owner, result.OwnerOrganizationUser.Type); + Assert.Equal(OrganizationUserStatusType.Invited, result.OwnerOrganizationUser.Status); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + Arg.Is(o => + o.Id != default && + o.Name == organization.Name && + o.Enabled == false && + o.Status == OrganizationStatusType.Pending + ) + ); + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + Arg.Is(k => + k.OrganizationId == result.Organization.Id && + k.Type == OrganizationApiKeyType.Default && + !string.IsNullOrEmpty(k.ApiKey) + ) + ); + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(Arg.Is(o => o.Id == result.Organization.Id)); + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + Arg.Is(u => + u.OrganizationId == result.Organization.Id && + u.Email == ownerEmail && + u.Type == OrganizationUserType.Owner && + u.Status == OrganizationUserStatusType.Invited && + u.UserId == null + ) + ); + await sutProvider.GetDependency() + .Received(1) + .SendInvitesAsync( + Arg.Is(r => + r.Users.Count() == 1 && + r.Users.First().Email == ownerEmail && + r.Organization.Id == result.Organization.Id && + r.InitOrganization == true + ) + ); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync( + Arg.Is(u => u.Email == ownerEmail), + EventType.OrganizationUser_Invited + ); + } + + [Theory] + [BitAutoData] + public async Task SignUpResellerClientAsync_WhenOrganizationRepositoryThrows_PerformsCleanup( + Organization organization, + string ownerEmail, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .When(x => x.CreateAsync(Arg.Any())) + .Do(_ => throw new Exception()); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail)); + + await AssertCleanupIsPerformed(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task SignUpResellerClientAsync_WhenOrganizationUserCreationFails_PerformsCleanup( + Organization organization, + string ownerEmail, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .When(x => x.CreateAsync(Arg.Any())) + .Do(_ => throw new Exception()); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail)); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Any()); + await AssertCleanupIsPerformed(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task SignUpResellerClientAsync_WhenInvitationSendingFails_PerformsCleanup( + Organization organization, + string ownerEmail, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .When(x => x.SendInvitesAsync(Arg.Any())) + .Do(_ => throw new Exception()); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail)); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Any()); + await AssertCleanupIsPerformed(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task SignUpResellerClientAsync_WhenEventLoggingFails_PerformsCleanup( + Organization organization, + string ownerEmail, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .When(x => x.LogOrganizationUserEventAsync(Arg.Any(), Arg.Any())) + .Do(_ => throw new Exception()); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpResellerClientAsync(organization, ownerEmail)); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .SendInvitesAsync(Arg.Any()); + await AssertCleanupIsPerformed(sutProvider); + } + + private static async Task AssertCleanupIsPerformed(SutProvider sutProvider) + { + await sutProvider.GetDependency() + .Received(1) + .CancelAndRecoverChargesAsync(Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .DeleteOrganizationAbilityAsync(Arg.Any()); + } +}