diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs new file mode 100644 index 0000000000..48fc524769 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs @@ -0,0 +1,129 @@ +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) + { + 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/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()); + } +}