From 9ad6733d56f55742d8547f9c83ea008ffea7fb34 Mon Sep 17 00:00:00 2001 From: Alex Morask Date: Thu, 3 Apr 2025 16:24:32 -0400 Subject: [PATCH] [Tests] BusinessUnitConverterTests --- .../Billing/BusinessUnitConverterTests.cs | 497 ++++++++++++++++++ 1 file changed, 497 insertions(+) create mode 100644 bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs new file mode 100644 index 0000000000..21513aecc7 --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs @@ -0,0 +1,497 @@ +#nullable enable +using System.Text; +using Bit.Commercial.Core.Billing; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Commercial.Core.Test.Billing; + +public class BusinessUnitConverterTests +{ + private readonly IDataProtectionProvider _dataProtectionProvider = Substitute.For(); + private readonly GlobalSettings _globalSettings = new(); + private readonly ILogger _logger = Substitute.For>(); + private readonly IMailService _mailService = Substitute.For(); + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); + private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IProviderOrganizationRepository _providerOrganizationRepository = Substitute.For(); + private readonly IProviderPlanRepository _providerPlanRepository = Substitute.For(); + private readonly IProviderRepository _providerRepository = Substitute.For(); + private readonly IProviderUserRepository _providerUserRepository = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ISubscriberService _subscriberService = Substitute.For(); + private readonly IUserRepository _userRepository = Substitute.For(); + + private BusinessUnitConverter BuildConverter() => new ( + _dataProtectionProvider, + _globalSettings, + _logger, + _mailService, + _organizationRepository, + _organizationUserRepository, + _pricingClient, + _providerOrganizationRepository, + _providerPlanRepository, + _providerRepository, + _providerUserRepository, + _stripeAdapter, + _subscriberService, + _userRepository); + + #region FinalizeConversion + + [Theory, BitAutoData] + public async Task FinalizeConversion_Succeeds_ReturnsProviderId( + Organization organization, + Guid userId, + string providerKey, + string organizationKey) + { + organization.PlanType = PlanType.EnterpriseAnnually2020; + + var enterpriseAnnually2020 = StaticStore.GetPlan(PlanType.EnterpriseAnnually2020); + + var subscription = new Subscription + { + Id = "subscription_id", + CustomerId = "customer_id", + Status = StripeConstants.SubscriptionStatus.Active, + Items = new StripeList + { + Data = [ + new SubscriptionItem + { + Id = "subscription_item_id", + Price = new Price + { + Id = enterpriseAnnually2020.PasswordManager.StripeSeatPlanId + } + } + ] + } + }; + + _subscriberService.GetSubscription(organization).Returns(subscription); + + var user = new User + { + Id = Guid.NewGuid(), + Email = "provider-admin@example.com" + }; + + _userRepository.GetByIdAsync(userId).Returns(user); + + var token = SetupDataProtection(organization, user.Email); + + var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed }; + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) + .Returns(organizationUser); + + var provider = new Provider + { + Type = ProviderType.BusinessUnit, + Status = ProviderStatusType.Pending + }; + + _providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider); + + var providerUser = new ProviderUser + { + Type = ProviderUserType.ProviderAdmin, + Status = ProviderUserStatusType.Invited + }; + + _providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); + + var providerOrganization = new ProviderOrganization(); + + _providerOrganizationRepository.GetByOrganizationId(organization.Id).Returns(providerOrganization); + + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020) + .Returns(enterpriseAnnually2020); + + var enterpriseAnnually = StaticStore.GetPlan(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually) + .Returns(enterpriseAnnually); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey); + + await _stripeAdapter.Received(2).CustomerUpdateAsync(subscription.CustomerId, Arg.Any()); + + var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, enterpriseAnnually.Type); + + await _stripeAdapter.Received(1).SubscriptionUpdateAsync(subscription.Id, Arg.Is( + arguments => + arguments.Items.Count == 2 && + arguments.Items[0].Id == "subscription_item_id" && + arguments.Items[0].Deleted == true && + arguments.Items[1].Price == updatedPriceId && + arguments.Items[1].Quantity == organization.Seats)); + + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.PlanType == PlanType.EnterpriseAnnually && + arguments.Status == OrganizationStatusType.Managed && + arguments.GatewayCustomerId == null && + arguments.GatewaySubscriptionId == null)); + + await _providerOrganizationRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.Key == organizationKey)); + + await _providerRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.Gateway == GatewayType.Stripe && + arguments.GatewayCustomerId == subscription.CustomerId && + arguments.GatewaySubscriptionId == subscription.Id && + arguments.Status == ProviderStatusType.Billable)); + + await _providerUserRepository.Received(1).ReplaceAsync(Arg.Is(arguments => + arguments.Key == providerKey && + arguments.Status == ProviderUserStatusType.Confirmed)); + } + + /* + * Because the validation for finalization is not an applicative like initialization is, + * I'm just testing one specific failure here. I don't see much value in testing every single opportunity for failure. + */ + [Theory, BitAutoData] + public async Task FinalizeConversion_ValidationFails_ThrowsBillingException( + Organization organization, + Guid userId, + string token, + string providerKey, + string organizationKey) + { + organization.PlanType = PlanType.EnterpriseAnnually2020; + + var subscription = new Subscription + { + Status = StripeConstants.SubscriptionStatus.Canceled + }; + + _subscriberService.GetSubscription(organization).Returns(subscription); + + var businessUnitConverter = BuildConverter(); + + await Assert.ThrowsAsync(() => + businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey)); + + await _organizationUserRepository.DidNotReceiveWithAnyArgs() + .GetByOrganizationAsync(Arg.Any(), Arg.Any()); + } + + #endregion + + #region InitiateConversion + + [Theory, BitAutoData] + public async Task InitiateConversion_Succeeds_ReturnsProviderId( + Organization organization, + string providerAdminEmail) + { + organization.PlanType = PlanType.EnterpriseAnnually; + + _subscriberService.GetSubscription(organization).Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Active + }); + + var user = new User + { + Id = Guid.NewGuid(), Email = providerAdminEmail + }; + + _userRepository.GetByEmailAsync(providerAdminEmail).Returns(user); + + var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed }; + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) + .Returns(organizationUser); + + var provider = new Provider { Id = Guid.NewGuid() }; + + _providerRepository.CreateAsync(Arg.Is(argument => + argument.Name == organization.Name && + argument.BillingEmail == organization.BillingEmail && + argument.Status == ProviderStatusType.Pending && + argument.Type == ProviderType.BusinessUnit)).Returns(provider); + + var plan = StaticStore.GetPlan(organization.PlanType); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var token = SetupDataProtection(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail); + + Assert.True(result.IsT0); + + var providerId = result.AsT0; + + Assert.Equal(provider.Id, providerId); + + await _providerOrganizationRepository.Received(1).CreateAsync( + Arg.Is(argument => + argument.ProviderId == provider.Id && + argument.OrganizationId == organization.Id)); + + await _providerPlanRepository.Received(1).CreateAsync( + Arg.Is(argument => + argument.ProviderId == provider.Id && + argument.PlanType == PlanType.EnterpriseAnnually && + argument.SeatMinimum == 0 && + argument.PurchasedSeats == organization.Seats && + argument.AllocatedSeats == organization.Seats)); + + await _providerUserRepository.Received(1).CreateAsync( + Arg.Is(argument => + argument.ProviderId == provider.Id && + argument.UserId == user.Id && + argument.Email == user.Email && + argument.Status == ProviderUserStatusType.Invited && + argument.Type == ProviderUserType.ProviderAdmin)); + + await _mailService.Received(1).SendBusinessUnitConversionInviteAsync( + organization, + token, + user.Email); + } + + [Theory, BitAutoData] + public async Task InitiateConversion_ValidationFails_ReturnsErrors( + Organization organization, + string providerAdminEmail) + { + organization.PlanType = PlanType.TeamsMonthly; + + _subscriberService.GetSubscription(organization).Returns(new Subscription + { + Status = StripeConstants.SubscriptionStatus.Canceled + }); + + var user = new User + { + Id = Guid.NewGuid(), Email = providerAdminEmail + }; + + _providerOrganizationRepository.GetByOrganizationId(organization.Id) + .Returns(new ProviderOrganization()); + + _userRepository.GetByEmailAsync(providerAdminEmail).Returns(user); + + var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Invited }; + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) + .Returns(organizationUser); + + var businessUnitConverter = BuildConverter(); + + var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail); + + Assert.True(result.IsT1); + + var problems = result.AsT1; + + Assert.Contains("Organization must be on an enterprise plan.", problems); + + Assert.Contains("Organization must have a valid subscription.", problems); + + Assert.Contains("Organization is already linked to a provider.", problems); + + Assert.Contains("Provider admin must be a confirmed member of the organization being converted.", problems); + } + + #endregion + + #region ResendConversionInvite + + [Theory, BitAutoData] + public async Task ResendConversionInvite_ConversionInProgress_Succeeds( + Organization organization, + string providerAdminEmail) + { + SetupConversionInProgress(organization, providerAdminEmail); + + var token = SetupDataProtection(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail); + + await _mailService.Received(1).SendBusinessUnitConversionInviteAsync( + organization, + token, + providerAdminEmail); + } + + [Theory, BitAutoData] + public async Task ResendConversionInvite_NoConversionInProgress_DoesNothing( + Organization organization, + string providerAdminEmail) + { + SetupDataProtection(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail); + + await _mailService.DidNotReceiveWithAnyArgs().SendBusinessUnitConversionInviteAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + #endregion + + #region ResetConversion + + [Theory, BitAutoData] + public async Task ResetConversion_ConversionInProgress_Succeeds( + Organization organization, + string providerAdminEmail) + { + var (provider, providerOrganization, providerUser, providerPlan) = SetupConversionInProgress(organization, providerAdminEmail); + + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResetConversion(organization, providerAdminEmail); + + await _providerOrganizationRepository.Received(1) + .DeleteAsync(providerOrganization); + + await _providerUserRepository.Received(1) + .DeleteAsync(providerUser); + + await _providerPlanRepository.Received(1) + .DeleteAsync(providerPlan); + + await _providerRepository.Received(1) + .DeleteAsync(provider); + } + + [Theory, BitAutoData] + public async Task ResetConversion_NoConversionInProgress_DoesNothing( + Organization organization, + string providerAdminEmail) + { + var businessUnitConverter = BuildConverter(); + + await businessUnitConverter.ResetConversion(organization, providerAdminEmail); + + await _providerOrganizationRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + + await _providerUserRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + + await _providerPlanRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + + await _providerRepository.DidNotReceiveWithAnyArgs() + .DeleteAsync(Arg.Any()); + } + + #endregion + + #region Utilities + + private string SetupDataProtection( + Organization organization, + string providerAdminEmail) + { + var dataProtector = new MockDataProtector(organization, providerAdminEmail); + _dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector").Returns(dataProtector); + return dataProtector.Protect(dataProtector.Token); + } + + private (Provider, ProviderOrganization, ProviderUser, ProviderPlan) SetupConversionInProgress( + Organization organization, + string providerAdminEmail) + { + var user = new User { Id = Guid.NewGuid() }; + + _userRepository.GetByEmailAsync(providerAdminEmail).Returns(user); + + var provider = new Provider + { + Id = Guid.NewGuid(), + Type = ProviderType.BusinessUnit, + Status = ProviderStatusType.Pending + }; + + _providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider); + + var providerUser = new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + UserId = user.Id, + Type = ProviderUserType.ProviderAdmin, + Status = ProviderUserStatusType.Invited, + Email = providerAdminEmail + }; + + _providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id) + .Returns(providerUser); + + var providerOrganization = new ProviderOrganization + { + Id = Guid.NewGuid(), + OrganizationId = organization.Id, + ProviderId = provider.Id + }; + + _providerOrganizationRepository.GetByOrganizationId(organization.Id) + .Returns(providerOrganization); + + var providerPlan = new ProviderPlan + { + Id = Guid.NewGuid(), ProviderId = provider.Id, PlanType = PlanType.EnterpriseAnnually + }; + + _providerPlanRepository.GetByProviderId(provider.Id).Returns([providerPlan]); + + return (provider, providerOrganization, providerUser, providerPlan); + } + + #endregion +} + +public class MockDataProtector( + Organization organization, + string providerAdminEmail) : IDataProtector +{ + public string Token = $"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"; + + public IDataProtector CreateProtector(string purpose) => this; + + public byte[] Protect(byte[] plaintext) => Encoding.UTF8.GetBytes(Token); + + public byte[] Unprotect(byte[] protectedData) => Encoding.UTF8.GetBytes(Token); +}