diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index 9482be011a..81c789c9c0 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -9,6 +9,8 @@ using Stripe; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.DependencyInjection.Extensions; using Bit.Admin.Services; +using Bit.Core.Repositories.Noop; +using Bit.Core.SecretsManager.Repositories; #if !OSS using Bit.Commercial.Core.Utilities; @@ -86,6 +88,7 @@ public class Startup services.AddBaseServices(globalSettings); services.AddDefaultServices(globalSettings); services.AddScoped(); + services.AddScoped(); #if OSS services.AddOosServices(); diff --git a/src/Api/Controllers/OrganizationsController.cs b/src/Api/Controllers/OrganizationsController.cs index 5a1569c98e..d0fe7eaf70 100644 --- a/src/Api/Controllers/OrganizationsController.cs +++ b/src/Api/Controllers/OrganizationsController.cs @@ -18,6 +18,7 @@ using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations.Policies; using Bit.Core.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -50,6 +51,7 @@ public class OrganizationsController : Controller private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; private readonly ILicensingService _licensingService; + private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -70,7 +72,8 @@ public class OrganizationsController : Controller ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, IFeatureService featureService, GlobalSettings globalSettings, - ILicensingService licensingService) + ILicensingService licensingService, + IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -91,6 +94,7 @@ public class OrganizationsController : Controller _featureService = featureService; _globalSettings = globalSettings; _licensingService = licensingService; + _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; } [HttpGet("{id}")] @@ -319,10 +323,34 @@ public class OrganizationsController : Controller { throw new NotFoundException(); } - await _organizationService.UpdateSubscription(orgIdGuid, model.SeatAdjustment, model.MaxAutoscaleSeats); } + [HttpPost("{id}/sm-subscription")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostSmSubscription(Guid id, [FromBody] SecretsManagerSubscriptionUpdateRequestModel model) + { + var organization = await _organizationRepository.GetByIdAsync(id); + if (organization == null) + { + throw new NotFoundException(); + } + + if (!await _currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + var secretsManagerPlan = StaticStore.GetSecretsManagerPlan(organization.PlanType); + if (secretsManagerPlan == null) + { + throw new NotFoundException("Invalid Secrets Manager plan."); + } + + var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization, secretsManagerPlan); + await _updateSecretsManagerSubscriptionCommand.UpdateSecretsManagerSubscription(organizationUpdate); + } + [HttpPost("{id}/seat")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostSeat(string id, [FromBody] OrganizationSeatRequestModel model) diff --git a/src/Api/Models/Request/Organizations/SecretsManagerSubscriptionUpdateRequestModel.cs b/src/Api/Models/Request/Organizations/SecretsManagerSubscriptionUpdateRequestModel.cs new file mode 100644 index 0000000000..1bd3cde33d --- /dev/null +++ b/src/Api/Models/Request/Organizations/SecretsManagerSubscriptionUpdateRequestModel.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Entities; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; + +namespace Bit.Api.Models.Request.Organizations; + +public class SecretsManagerSubscriptionUpdateRequestModel +{ + [Required] + public int SeatAdjustment { get; set; } + public int? MaxAutoscaleSeats { get; set; } + public int ServiceAccountAdjustment { get; set; } + public int? MaxAutoscaleServiceAccounts { get; set; } + + public virtual SecretsManagerSubscriptionUpdate ToSecretsManagerSubscriptionUpdate(Organization organization, Plan plan) + { + var newTotalSeats = organization.SmSeats.GetValueOrDefault() + SeatAdjustment; + var newTotalServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + ServiceAccountAdjustment; + var autoscaleSeats = MaxAutoscaleSeats.HasValue && MaxAutoscaleSeats != + organization.MaxAutoscaleSmSeats.GetValueOrDefault(); + var autoscaleServiceAccounts = MaxAutoscaleServiceAccounts.HasValue && + MaxAutoscaleServiceAccounts != + organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault(); + + var orgUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organization.Id, + + SmSeatsAdjustment = SeatAdjustment, + SmSeats = newTotalSeats, + SmSeatsExcludingBase = newTotalSeats - plan.BaseSeats, + + MaxAutoscaleSmSeats = MaxAutoscaleSeats, + + SmServiceAccountsAdjustment = ServiceAccountAdjustment, + SmServiceAccounts = newTotalServiceAccounts, + SmServiceAccountsExcludingBase = newTotalServiceAccounts - plan.BaseServiceAccount.GetValueOrDefault(), + + MaxAutoscaleSmServiceAccounts = MaxAutoscaleServiceAccounts, + + MaxAutoscaleSmSeatsChanged = autoscaleSeats, + MaxAutoscaleSmServiceAccountsChanged = autoscaleServiceAccounts + }; + + return orgUpdate; + } +} diff --git a/src/Api/Models/Response/PlanResponseModel.cs b/src/Api/Models/Response/PlanResponseModel.cs index ee86dde59e..f3b39f1133 100644 --- a/src/Api/Models/Response/PlanResponseModel.cs +++ b/src/Api/Models/Response/PlanResponseModel.cs @@ -55,10 +55,12 @@ public class PlanResponseModel : ResponseModel AdditionalPricePerServiceAccount = plan.AdditionalPricePerServiceAccount; BaseServiceAccount = plan.BaseServiceAccount; - MaxServiceAccount = plan.MaxServiceAccount; + MaxServiceAccounts = plan.MaxServiceAccounts; + MaxAdditionalServiceAccounts = plan.MaxAdditionalServiceAccount; HasAdditionalServiceAccountOption = plan.HasAdditionalServiceAccountOption; MaxProjects = plan.MaxProjects; BitwardenProduct = plan.BitwardenProduct; + StripeServiceAccountPlanId = plan.StripeServiceAccountPlanId; } public PlanType Type { get; set; } @@ -105,10 +107,11 @@ public class PlanResponseModel : ResponseModel public decimal SeatPrice { get; set; } public decimal AdditionalStoragePricePerGb { get; set; } public decimal PremiumAccessOptionPrice { get; set; } - + public string StripeServiceAccountPlanId { get; set; } public decimal? AdditionalPricePerServiceAccount { get; set; } public short? BaseServiceAccount { get; set; } - public short? MaxServiceAccount { get; set; } + public short? MaxServiceAccounts { get; set; } + public short? MaxAdditionalServiceAccounts { get; set; } public bool HasAdditionalServiceAccountOption { get; set; } public short? MaxProjects { get; set; } public BitwardenProductType BitwardenProduct { get; set; } diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs new file mode 100644 index 0000000000..8eee60b2d1 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs @@ -0,0 +1,34 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + +
+ Your organization has reached the Secrets Manager seat limit of {{MaxSeatCount}} and new members cannot be invited. +
+ For more information, please refer to the following help article: + + Member management + +
+
+
+ + Manage subscription + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.text.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.text.hbs new file mode 100644 index 0000000000..325d4c2561 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.text.hbs @@ -0,0 +1,5 @@ +{{#>BasicTextLayout}} +Your organization has reached the Secrets Manager seat limit of {{MaxSeatCount}} and new members cannot be invited. + +For more information, please refer to the following help article: https://bitwarden.com/help/managing-users +{{/BasicTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs new file mode 100644 index 0000000000..43712bc3e3 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs @@ -0,0 +1,34 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + +
+ Your organization has reached the Secrets Manager service accounts limit of {{MaxServiceAccountsCount}}. New service accounts cannot be created +
+ For more information, please refer to the following help article: + + Member management + +
+
+
+ + Manage subscription + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.text.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.text.hbs new file mode 100644 index 0000000000..377c8699bf --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.text.hbs @@ -0,0 +1,5 @@ +{{#>BasicTextLayout}} +Your organization has reached the Secrets Manager service accounts limit of {{MaxServiceAccountsCount}}. New service accounts cannot be created + +For more information, please refer to the following help article: https://bitwarden.com/help/managing-users +{{/BasicTextLayout}} diff --git a/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs b/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs new file mode 100644 index 0000000000..051a7560df --- /dev/null +++ b/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs @@ -0,0 +1,56 @@ +namespace Bit.Core.Models.Business; + +public class SecretsManagerSubscriptionUpdate +{ + public Guid OrganizationId { get; set; } + + /// + /// The seats to be added or removed from the organization + /// + public int SmSeatsAdjustment { get; set; } + + /// + /// The total seats the organization will have after the update, including any base seats included in the plan + /// + public int SmSeats { get; set; } + + /// + /// The seats the organization will have after the update, excluding the base seats included in the plan + /// Usually this is what the organization is billed for + /// + public int SmSeatsExcludingBase { get; set; } + + /// + /// The new autoscale limit for seats, expressed as a total (not an adjustment). + /// This may or may not be the same as the current autoscale limit. + /// + public int? MaxAutoscaleSmSeats { get; set; } + + /// + /// The service accounts to be added or removed from the organization + /// + public int SmServiceAccountsAdjustment { get; set; } + + /// + /// The total service accounts the organization will have after the update, including the base service accounts + /// included in the plan + /// + public int SmServiceAccounts { get; set; } + + /// + /// The seats the organization will have after the update, excluding the base seats included in the plan + /// Usually this is what the organization is billed for + /// + public int SmServiceAccountsExcludingBase { get; set; } + + /// + /// The new autoscale limit for service accounts, expressed as a total (not an adjustment). + /// This may or may not be the same as the current autoscale limit. + /// + public int? MaxAutoscaleSmServiceAccounts { get; set; } + + public bool SmSeatsChanged => SmSeatsAdjustment != 0; + public bool SmServiceAccountsChanged => SmServiceAccountsAdjustment != 0; + public bool MaxAutoscaleSmSeatsChanged { get; set; } + public bool MaxAutoscaleSmServiceAccountsChanged { get; set; } +} diff --git a/src/Core/Models/Business/SubscriptionUpdate.cs b/src/Core/Models/Business/SubscriptionUpdate.cs index 64b43a8de2..1a93bbfa3c 100644 --- a/src/Core/Models/Business/SubscriptionUpdate.cs +++ b/src/Core/Models/Business/SubscriptionUpdate.cs @@ -1,4 +1,5 @@ using Bit.Core.Entities; +using Bit.Core.Enums; using Stripe; namespace Bit.Core.Models.Business; @@ -42,7 +43,15 @@ public class SeatSubscriptionUpdate : SubscriptionUpdate { _plan = plan; _additionalSeats = additionalSeats; - _previousSeats = organization.Seats ?? 0; + switch (plan.BitwardenProduct) + { + case BitwardenProductType.PasswordManager: + _previousSeats = organization.Seats.GetValueOrDefault(); + break; + case BitwardenProductType.SecretsManager: + _previousSeats = organization.SmSeats.GetValueOrDefault(); + break; + } } public override List UpgradeItemsOptions(Subscription subscription) @@ -77,6 +86,52 @@ public class SeatSubscriptionUpdate : SubscriptionUpdate } } +public class ServiceAccountSubscriptionUpdate : SubscriptionUpdate +{ + private long? _prevServiceAccounts; + private readonly StaticStore.Plan _plan; + private readonly long? _additionalServiceAccounts; + protected override List PlanIds => new() { _plan.StripeServiceAccountPlanId }; + + public ServiceAccountSubscriptionUpdate(Organization organization, StaticStore.Plan plan, long? additionalServiceAccounts) + { + _plan = plan; + _additionalServiceAccounts = additionalServiceAccounts; + _prevServiceAccounts = organization.SmServiceAccounts ?? 0; + } + + public override List UpgradeItemsOptions(Subscription subscription) + { + var item = SubscriptionItem(subscription, PlanIds.Single()); + _prevServiceAccounts = item?.Quantity ?? 0; + return new() + { + new SubscriptionItemOptions + { + Id = item?.Id, + Plan = PlanIds.Single(), + Quantity = _additionalServiceAccounts, + Deleted = (item?.Id != null && _additionalServiceAccounts == 0) ? true : (bool?)null, + } + }; + } + + public override List RevertItemsOptions(Subscription subscription) + { + var item = SubscriptionItem(subscription, PlanIds.Single()); + return new() + { + new SubscriptionItemOptions + { + Id = item?.Id, + Plan = PlanIds.Single(), + Quantity = _prevServiceAccounts, + Deleted = _prevServiceAccounts == 0 ? true : (bool?)null, + } + }; + } +} + public class StorageSubscriptionUpdate : SubscriptionUpdate { private long? _prevStorage; diff --git a/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs b/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs new file mode 100644 index 0000000000..1b9c925720 --- /dev/null +++ b/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Models.Mail; + +public class OrganizationServiceAccountsMaxReachedViewModel +{ + public Guid OrganizationId { get; set; } + public int MaxServiceAccountsCount { get; set; } +} diff --git a/src/Core/Models/StaticStore/Plan.cs b/src/Core/Models/StaticStore/Plan.cs index cc781a411c..f542e5e214 100644 --- a/src/Core/Models/StaticStore/Plan.cs +++ b/src/Core/Models/StaticStore/Plan.cs @@ -15,8 +15,11 @@ public class Plan public short? BaseStorageGb { get; set; } public short? MaxCollections { get; set; } public short? MaxUsers { get; set; } + public short? MaxServiceAccounts { get; set; } public bool AllowSeatAutoscale { get; set; } + public bool AllowServiceAccountsAutoscale { get; set; } + public bool HasAdditionalSeatsOption { get; set; } public int? MaxAdditionalSeats { get; set; } public bool HasAdditionalStorageOption { get; set; } @@ -55,7 +58,7 @@ public class Plan public decimal PremiumAccessOptionPrice { get; set; } public decimal? AdditionalPricePerServiceAccount { get; set; } public short? BaseServiceAccount { get; set; } - public short? MaxServiceAccount { get; set; } + public short? MaxAdditionalServiceAccount { get; set; } public bool HasAdditionalServiceAccountOption { get; set; } public short? MaxProjects { get; set; } public BitwardenProductType BitwardenProduct { get; set; } diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 9e36537797..ba2488c7ae 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -15,6 +15,8 @@ using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterpri using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Cloud; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.SelfHosted; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate.Interface; using Bit.Core.SecretsManager.Commands.EnableAccessSecretsManager; using Bit.Core.SecretsManager.Commands.EnableAccessSecretsManager.Interfaces; using Bit.Core.Services; @@ -41,6 +43,7 @@ public static class OrganizationServiceCollectionExtensions services.AddOrganizationGroupCommands(); services.AddOrganizationLicenseCommandsQueries(); services.AddOrganizationDomainCommandsQueries(); + services.AddOrganizationSubscriptionUpdateCommandsQueries(); } private static void AddOrganizationConnectionCommands(this IServiceCollection services) @@ -110,6 +113,11 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); } + private static void AddOrganizationSubscriptionUpdateCommandsQueries(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddTokenizers(this IServiceCollection services) { services.AddSingleton>(serviceProvider => diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptionUpdate/Interface/IUpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptionUpdate/Interface/IUpdateSecretsManagerSubscriptionCommand.cs new file mode 100644 index 0000000000..de5271639e --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptionUpdate/Interface/IUpdateSecretsManagerSubscriptionCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.Models.Business; + +namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate.Interface; + +public interface IUpdateSecretsManagerSubscriptionCommand +{ + Task UpdateSecretsManagerSubscription(SecretsManagerSubscriptionUpdate update); +} diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommand.cs new file mode 100644 index 0000000000..16d0af84e5 --- /dev/null +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommand.cs @@ -0,0 +1,344 @@ +#nullable enable +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate.Interface; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate; + +public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubscriptionCommand +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IPaymentService _paymentService; + private readonly IOrganizationService _organizationService; + private readonly IMailService _mailService; + private readonly ILogger _logger; + private readonly IServiceAccountRepository _serviceAccountRepository; + + public UpdateSecretsManagerSubscriptionCommand( + IOrganizationRepository organizationRepository, + IOrganizationService organizationService, + IOrganizationUserRepository organizationUserRepository, + IPaymentService paymentService, + IMailService mailService, + ILogger logger, + IServiceAccountRepository serviceAccountRepository) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _paymentService = paymentService; + _organizationService = organizationService; + _mailService = mailService; + _logger = logger; + _serviceAccountRepository = serviceAccountRepository; + } + + public async Task UpdateSecretsManagerSubscription(SecretsManagerSubscriptionUpdate update) + { + var organization = await _organizationRepository.GetByIdAsync(update.OrganizationId); + + ValidateOrganization(organization); + + var plan = GetPlanForOrganization(organization); + + if (update.SmSeatsChanged) + { + await ValidateSmSeatsUpdateAsync(organization, update, plan); + } + + if (update.SmServiceAccountsChanged) + { + await ValidateSmServiceAccountsUpdateAsync(organization, update, plan); + } + + if (update.MaxAutoscaleSmSeatsChanged) + { + ValidateMaxAutoscaleSmSeatsUpdateAsync(organization, update.MaxAutoscaleSmSeats.GetValueOrDefault(), plan); + } + + if (update.MaxAutoscaleSmServiceAccountsChanged) + { + ValidateMaxAutoscaleSmServiceAccountUpdate(organization, update.MaxAutoscaleSmServiceAccounts.GetValueOrDefault(), plan); + } + + await FinalizeSubscriptionAdjustmentAsync(organization, plan, update); + + await SendEmailIfAutoscaleLimitReached(organization); + } + + private Plan GetPlanForOrganization(Organization organization) + { + var plan = StaticStore.SecretManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType); + if (plan == null) + { + throw new BadRequestException("Existing plan not found."); + } + return plan; + } + + private static void ValidateOrganization(Organization organization) + { + if (organization == null) + { + throw new NotFoundException("Organization is not found"); + } + + if (!organization.UseSecretsManager) + { + throw new BadRequestException("Organization has no access to Secrets Manager."); + } + } + + private async Task FinalizeSubscriptionAdjustmentAsync(Organization organization, + Plan plan, SecretsManagerSubscriptionUpdate update) + { + if (update.SmSeatsChanged) + { + await ProcessChargesAndRaiseEventsForAdjustSeatsAsync(organization, plan, update); + organization.SmSeats = update.SmSeats; + } + + if (update.SmServiceAccountsChanged) + { + await ProcessChargesAndRaiseEventsForAdjustServiceAccountsAsync(organization, plan, update); + organization.SmServiceAccounts = update.SmServiceAccounts; + } + + if (update.MaxAutoscaleSmSeatsChanged) + { + organization.MaxAutoscaleSmSeats = update.MaxAutoscaleSmSeats; + } + + if (update.MaxAutoscaleSmServiceAccountsChanged) + { + organization.MaxAutoscaleSmServiceAccounts = update.MaxAutoscaleSmServiceAccounts; + } + + await _organizationService.ReplaceAndUpdateCacheAsync(organization); + } + + private async Task ProcessChargesAndRaiseEventsForAdjustSeatsAsync(Organization organization, Plan plan, + SecretsManagerSubscriptionUpdate update) + { + await _paymentService.AdjustSeatsAsync(organization, plan, update.SmSeatsExcludingBase); + + // TODO: call ReferenceEventService - see AC-1481 + } + + private async Task ProcessChargesAndRaiseEventsForAdjustServiceAccountsAsync(Organization organization, Plan plan, + SecretsManagerSubscriptionUpdate update) + { + await _paymentService.AdjustServiceAccountsAsync(organization, plan, + update.SmServiceAccountsExcludingBase); + + // TODO: call ReferenceEventService - see AC-1481 + } + + private async Task SendEmailIfAutoscaleLimitReached(Organization organization) + { + if (organization.SmSeats.HasValue && organization.MaxAutoscaleSmSeats.HasValue && organization.SmSeats == organization.MaxAutoscaleSmSeats) + { + await SendSeatLimitEmailAsync(organization, organization.MaxAutoscaleSmSeats.Value); + } + + if (organization.SmServiceAccounts.HasValue && organization.MaxAutoscaleSmServiceAccounts.HasValue && organization.SmServiceAccounts == organization.MaxAutoscaleSmServiceAccounts) + { + await SendServiceAccountLimitEmailAsync(organization, organization.MaxAutoscaleSmServiceAccounts.Value); + } + } + + private async Task SendSeatLimitEmailAsync(Organization organization, int MaxAutoscaleValue) + { + try + { + var ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id, + OrganizationUserType.Owner)) + .Select(u => u.Email).Distinct(); + + await _mailService.SendSecretsManagerMaxSeatLimitReachedEmailAsync(organization, MaxAutoscaleValue, ownerEmails); + + } + catch (Exception e) + { + _logger.LogError(e, $"Error encountered notifying organization owners of Seats limit reached."); + } + + } + + private async Task SendServiceAccountLimitEmailAsync(Organization organization, int MaxAutoscaleValue) + { + try + { + var ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id, + OrganizationUserType.Owner)) + .Select(u => u.Email).Distinct(); + + await _mailService.SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(organization, MaxAutoscaleValue, ownerEmails); + + } + catch (Exception e) + { + _logger.LogError(e, $"Error encountered notifying organization owners of Service Accounts limit reached."); + } + + } + + private async Task ValidateSmSeatsUpdateAsync(Organization organization, SecretsManagerSubscriptionUpdate update, Plan plan) + { + if (organization.SmSeats == null) + { + throw new BadRequestException("Organization has no Secrets Manager seat limit, no need to adjust seats"); + } + + if (update.MaxAutoscaleSmSeats.HasValue && update.SmSeats > update.MaxAutoscaleSmSeats.Value) + { + throw new BadRequestException("Cannot set max seat autoscaling below seat count."); + } + + if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) + { + throw new BadRequestException("No payment method found."); + } + + if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) + { + throw new BadRequestException("No subscription found."); + } + + if (!plan.HasAdditionalSeatsOption) + { + throw new BadRequestException("Plan does not allow additional Secrets Manager seats."); + } + + if (plan.BaseSeats > update.SmSeats) + { + throw new BadRequestException($"Plan has a minimum of {plan.BaseSeats} Secrets Manager seats."); + } + + if (update.SmSeats <= 0) + { + throw new BadRequestException("You must have at least 1 Secrets Manager seat."); + } + + if (plan.MaxAdditionalSeats.HasValue && update.SmSeatsExcludingBase > plan.MaxAdditionalSeats.Value) + { + throw new BadRequestException($"Organization plan allows a maximum of " + + $"{plan.MaxAdditionalSeats.Value} additional Secrets Manager seats."); + } + + if (organization.SmSeats.Value > update.SmSeats) + { + var currentSeats = await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id); + if (currentSeats > update.SmSeats) + { + throw new BadRequestException($"Your organization currently has {currentSeats} Secrets Manager seats. " + + $"Your plan only allows ({update.SmSeats}) Secrets Manager seats. Remove some Secrets Manager users."); + } + } + } + + private async Task ValidateSmServiceAccountsUpdateAsync(Organization organization, SecretsManagerSubscriptionUpdate update, Plan plan) + { + if (organization.SmServiceAccounts == null) + { + throw new BadRequestException("Organization has no Service Accounts limit, no need to adjust Service Accounts"); + } + + if (update.MaxAutoscaleSmServiceAccounts.HasValue && update.SmServiceAccounts > update.MaxAutoscaleSmServiceAccounts.Value) + { + throw new BadRequestException("Cannot set max Service Accounts autoscaling below Service Accounts count."); + } + + if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) + { + throw new BadRequestException("No payment method found."); + } + + if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) + { + throw new BadRequestException("No subscription found."); + } + + if (!plan.HasAdditionalServiceAccountOption) + { + throw new BadRequestException("Plan does not allow additional Service Accounts."); + } + + if (plan.BaseServiceAccount > update.SmServiceAccounts) + { + throw new BadRequestException($"Plan has a minimum of {plan.BaseServiceAccount} Service Accounts."); + } + + if (update.SmServiceAccounts <= 0) + { + throw new BadRequestException("You must have at least 1 Service Account."); + } + + if (plan.MaxAdditionalServiceAccount.HasValue && update.SmServiceAccountsExcludingBase > plan.MaxAdditionalServiceAccount.Value) + { + throw new BadRequestException($"Organization plan allows a maximum of " + + $"{plan.MaxAdditionalServiceAccount.Value} additional Service Accounts."); + } + + if (!organization.SmServiceAccounts.HasValue || organization.SmServiceAccounts.Value > update.SmServiceAccounts) + { + var currentServiceAccounts = await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(organization.Id); + if (currentServiceAccounts > update.SmServiceAccounts) + { + throw new BadRequestException($"Your organization currently has {currentServiceAccounts} Service Accounts. " + + $"Your plan only allows ({update.SmServiceAccounts}) Service Accounts. Remove some Service Accounts."); + } + } + } + + private void ValidateMaxAutoscaleSmSeatsUpdateAsync(Organization organization, int maxAutoscaleSeats, Plan plan) + { + if (organization.SmSeats.HasValue && maxAutoscaleSeats < organization.SmSeats.Value) + { + throw new BadRequestException($"Cannot set max Secrets Manager seat autoscaling below current Secrets Manager seat count."); + } + + if (plan.MaxUsers.HasValue && maxAutoscaleSeats > plan.MaxUsers) + { + throw new BadRequestException(string.Concat( + $"Your plan has a Secrets Manager seat limit of {plan.MaxUsers}, ", + $"but you have specified a max autoscale count of {maxAutoscaleSeats}.", + "Reduce your max autoscale count.")); + } + + if (!plan.AllowSeatAutoscale) + { + throw new BadRequestException("Your plan does not allow Secrets Manager seat autoscaling."); + } + } + + private void ValidateMaxAutoscaleSmServiceAccountUpdate(Organization organization, int maxAutoscaleServiceAccounts, Plan plan) + { + if (organization.SmServiceAccounts.HasValue && maxAutoscaleServiceAccounts < organization.SmServiceAccounts.Value) + { + throw new BadRequestException( + $"Cannot set max Service Accounts autoscaling below current Service Accounts count."); + } + + if (!plan.AllowServiceAccountsAutoscale) + { + throw new BadRequestException("Your plan does not allow Service Accounts autoscaling."); + } + + if (plan.MaxServiceAccounts.HasValue && maxAutoscaleServiceAccounts > plan.MaxServiceAccounts) + { + throw new BadRequestException(string.Concat( + $"Your plan has a Service Accounts limit of {plan.MaxServiceAccounts}, ", + $"but you have specified a max autoscale count of {maxAutoscaleServiceAccounts}.", + "Reduce your max autoscale count.")); + } + } +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 933127367c..4647234b78 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -55,4 +55,6 @@ public interface IMailService Task SendFailedLoginAttemptsEmailAsync(string email, DateTime utcNow, string ip); Task SendFailedTwoFactorAttemptsEmailAsync(string email, DateTime utcNow, string ip); Task SendUnverifiedOrganizationDomainEmailAsync(IEnumerable adminEmails, string organizationId, string domainName); + Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable ownerEmails); + Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable ownerEmails); } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index ba482e0d59..21140a58c8 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -21,6 +21,9 @@ public interface IPaymentService short additionalStorageGb, TaxInfo taxInfo); Task AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null); Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null); + + Task AdjustServiceAccountsAsync(Organization organization, Plan plan, int additionalServiceAccounts, + DateTime? prorationDate = null); Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false, bool skipInAppPurchaseCheck = false); Task ReinstateSubscriptionAsync(ISubscriber subscriber); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 66fe70a018..28552a1a98 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -896,6 +896,36 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, + IEnumerable ownerEmails) + { + var message = CreateDefaultMessage($"{organization.Name} Secrets Manager Seat Limit Reached", ownerEmails); + var model = new OrganizationSeatsMaxReachedViewModel + { + OrganizationId = organization.Id, + MaxSeatCount = maxSeatCount, + }; + + await AddMessageContentAsync(message, "OrganizationSmSeatsMaxReached", model); + message.Category = "OrganizationSmSeatsMaxReached"; + await _mailDeliveryService.SendEmailAsync(message); + } + + public async Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount, + IEnumerable ownerEmails) + { + var message = CreateDefaultMessage($"{organization.Name} Secrets Manager Service Accounts Limit Reached", ownerEmails); + var model = new OrganizationServiceAccountsMaxReachedViewModel + { + OrganizationId = organization.Id, + MaxServiceAccountsCount = maxSeatCount, + }; + + await AddMessageContentAsync(message, "OrganizationSmServiceAccountsMaxReached", model); + message.Category = "OrganizationSmServiceAccountsMaxReached"; + await _mailDeliveryService.SendEmailAsync(message); + } + private static string GetUserIdentifier(string email, string userName) { return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false); diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index abf420392b..c00d9f9835 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -427,15 +427,15 @@ public class OrganizationService : IOrganizationService if (newSecretsManagerPlan.BaseServiceAccount != null) { if (!organization.SmServiceAccounts.HasValue || - organization.SmServiceAccounts.Value > newSecretsManagerPlan.MaxServiceAccount) + organization.SmServiceAccounts.Value > newSecretsManagerPlan.MaxServiceAccounts) { var currentServiceAccounts = await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(organization.Id); - if (currentServiceAccounts > newSecretsManagerPlan.MaxServiceAccount) + if (currentServiceAccounts > newSecretsManagerPlan.MaxServiceAccounts) { throw new BadRequestException( $"Your organization currently has {currentServiceAccounts} service account seats filled. " + - $"Your new plan only has ({newSecretsManagerPlan.MaxServiceAccount}) service accounts. Remove some service accounts."); + $"Your new plan only has ({newSecretsManagerPlan.MaxServiceAccounts}) service accounts. Remove some service accounts."); } } } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index bc0507de2f..9bd45ff0cf 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -853,6 +853,11 @@ public class StripePaymentService : IPaymentService return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate); } + public Task AdjustServiceAccountsAsync(Organization organization, StaticStore.Plan plan, int additionalServiceAccounts, DateTime? prorationDate = null) + { + return FinalizeSubscriptionChangeAsync(organization, new ServiceAccountSubscriptionUpdate(organization, plan, additionalServiceAccounts), prorationDate); + } + public Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null) { diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 808489efbe..31cc2a4ad5 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -238,4 +238,17 @@ public class NoopMailService : IMailService { return Task.FromResult(0); } + + public Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, + IEnumerable ownerEmails) + { + return Task.FromResult(0); + } + + public Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, + int maxSeatCount, + IEnumerable ownerEmails) + { + return Task.FromResult(0); + } } diff --git a/src/Core/Tools/Enums/ReferenceEventType.cs b/src/Core/Tools/Enums/ReferenceEventType.cs index a8a52e6317..b91e83b6f1 100644 --- a/src/Core/Tools/Enums/ReferenceEventType.cs +++ b/src/Core/Tools/Enums/ReferenceEventType.cs @@ -44,4 +44,8 @@ public enum ReferenceEventType OrganizationCreatedByAdmin, [EnumMember(Value = "sm-service-account-accessed-secret")] SmServiceAccountAccessedSecret, + [EnumMember(Value = "adjust-service-accounts")] + AdjustServiceAccounts, + [EnumMember(Value = "adjust-sm-seats")] + AdjustSmSeats, } diff --git a/src/Core/Tools/Models/Business/ReferenceEvent.cs b/src/Core/Tools/Models/Business/ReferenceEvent.cs index 382d9cf190..b50609752f 100644 --- a/src/Core/Tools/Models/Business/ReferenceEvent.cs +++ b/src/Core/Tools/Models/Business/ReferenceEvent.cs @@ -52,6 +52,7 @@ public class ReferenceEvent public int? Seats { get; set; } public int? PreviousSeats { get; set; } + public int? PreviousServiceAccounts { get; set; } public short? Storage { get; set; } diff --git a/src/Core/Utilities/SecretsManagerPlanStore.cs b/src/Core/Utilities/SecretsManagerPlanStore.cs index d6d0d87aca..c37c424462 100644 --- a/src/Core/Utilities/SecretsManagerPlanStore.cs +++ b/src/Core/Utilities/SecretsManagerPlanStore.cs @@ -45,6 +45,7 @@ public static class SecretsManagerPlanStore SeatPrice = 13, AdditionalPricePerServiceAccount = 0.5M, AllowSeatAutoscale = true, + AllowServiceAccountsAutoscale = true }, new Plan { @@ -83,6 +84,7 @@ public static class SecretsManagerPlanStore SeatPrice = 144, AdditionalPricePerServiceAccount = 6, AllowSeatAutoscale = true, + AllowServiceAccountsAutoscale = true }, new Plan { @@ -113,6 +115,7 @@ public static class SecretsManagerPlanStore SeatPrice = 7, AdditionalPricePerServiceAccount = 0.5M, AllowSeatAutoscale = true, + AllowServiceAccountsAutoscale = true }, new Plan { @@ -145,6 +148,7 @@ public static class SecretsManagerPlanStore SeatPrice = 72, AdditionalPricePerServiceAccount = 6, AllowSeatAutoscale = true, + AllowServiceAccountsAutoscale = true }, new Plan { @@ -158,7 +162,7 @@ public static class SecretsManagerPlanStore BaseServiceAccount = 3, MaxProjects = 3, MaxUsers = 2, - MaxServiceAccount = 3, + MaxServiceAccounts = 3, UpgradeSortOrder = -1, // Always the lowest plan, cannot be upgraded to DisplaySortOrder = -1, AllowSeatAutoscale = false, diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index 5ba20d905b..5029212e6d 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -4,6 +4,8 @@ using AspNetCoreRateLimit; using Bit.Core; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; +using Bit.Core.Repositories.Noop; +using Bit.Core.SecretsManager.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Identity.Utilities; @@ -46,7 +48,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); - services.AddOosServices(); + services.AddScoped(); // Context services.AddScoped(); diff --git a/test/Api.Test/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/Controllers/OrganizationsControllerTests.cs index 1d21e9b735..aa34ba5e44 100644 --- a/test/Api.Test/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/Controllers/OrganizationsControllerTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -40,6 +41,7 @@ public class OrganizationsControllerTests : IDisposable private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand; private readonly IFeatureService _featureService; private readonly ILicensingService _licensingService; + private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly OrganizationsController _sut; @@ -64,12 +66,14 @@ public class OrganizationsControllerTests : IDisposable _updateOrganizationLicenseCommand = Substitute.For(); _featureService = Substitute.For(); _licensingService = Substitute.For(); + _updateSecretsManagerSubscriptionCommand = Substitute.For(); _sut = new OrganizationsController(_organizationRepository, _organizationUserRepository, _policyRepository, _providerRepository, _organizationService, _userService, _paymentService, _currentContext, _ssoConfigRepository, _ssoConfigService, _getOrganizationApiKeyQuery, _rotateOrganizationApiKeyCommand, _createOrganizationApiKeyCommand, _organizationApiKeyRepository, _updateOrganizationLicenseCommand, - _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, _licensingService); + _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, _licensingService, + _updateSecretsManagerSubscriptionCommand); } public void Dispose() diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs new file mode 100644 index 0000000000..1a3ffdfc08 --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs @@ -0,0 +1,745 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptionUpdate; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.OrganizationSubscriptionUpdate; + +[SutProviderCustomize] +public class UpdateSecretsManagerSubscriptionCommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateSecretsManagerSubscription_NoOrganization_Throws( + Guid organizationId, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(organizationId) + .Returns((Organization)null); + + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = null, + SmSeatsAdjustment = 0 + }; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + Assert.Contains("Organization is not found", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task UpdateSecretsManagerSubscription_NoSecretsManagerAccess_ThrowsException( + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + SmSeats = 10, + SmServiceAccounts = 5, + UseSecretsManager = false, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 10 + }; + + sutProvider.GetDependency() + .GetByIdAsync(organizationId) + .Returns(organization); + + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + SmSeatsAdjustment = 1, + MaxAutoscaleSmSeats = 1 + }; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + Assert.Contains("Organization has no access to Secrets Manager.", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task UpdateSecretsManagerSubscription_SeatsAdustmentGreaterThanMaxAutoscaleSeats_ThrowsException( + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + SmSeats = 10, + UseSecretsManager = true, + SmServiceAccounts = 5, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 10, + PlanType = PlanType.EnterpriseAnnually, + }; + var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType); + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = 10, + SmSeatsAdjustment = 15, + SmSeats = organization.SmSeats.GetValueOrDefault() + 10, + SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 10) - plan.BaseSeats, + SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 5, + SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 5) - (int)plan.BaseServiceAccount + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + Assert.Contains("Cannot set max seat autoscaling below seat count.", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task UpdateSecretsManagerSubscription_ServiceAccountsGreaterThanMaxAutoscaleSeats_ThrowsException( + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + SmSeats = 10, + UseSecretsManager = true, + SmServiceAccounts = 5, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 10, + PlanType = PlanType.EnterpriseAnnually, + GatewayCustomerId = "1", + GatewaySubscriptionId = "9" + }; + var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType); + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = 15, + SmSeatsAdjustment = 1, + MaxAutoscaleSmServiceAccounts = 10, + SmServiceAccountsAdjustment = 11, + SmSeats = organization.SmSeats.GetValueOrDefault() + 1, + SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 1) - plan.BaseSeats, + SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 11, + SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 11) - (int)plan.BaseServiceAccount + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + Assert.Contains("Cannot set max Service Accounts autoscaling below Service Accounts count", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task UpdateSecretsManagerSubscription_NullGatewayCustomerId_ThrowsException( + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + SmSeats = 10, + SmServiceAccounts = 5, + UseSecretsManager = true, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 15, + PlanType = PlanType.EnterpriseAnnually + }; + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = 15, + SmSeatsAdjustment = 1, + MaxAutoscaleSmServiceAccounts = 15, + SmServiceAccountsAdjustment = 1 + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + Assert.Contains("No payment method found.", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task UpdateSecretsManagerSubscription_NullGatewaySubscriptionId_ThrowsException( + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + SmSeats = 10, + UseSecretsManager = true, + SmServiceAccounts = 5, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 15, + PlanType = PlanType.EnterpriseAnnually, + GatewayCustomerId = "1" + }; + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = 15, + SmSeatsAdjustment = 1, + MaxAutoscaleSmServiceAccounts = 15, + SmServiceAccountsAdjustment = 1 + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + Assert.Contains("No subscription found.", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task UpdateSecretsManagerSubscription_OrgWithNullSmSeatOnSeatsAdjustment_ThrowsException( + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + SmSeats = null, + UseSecretsManager = true, + SmServiceAccounts = 5, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 15, + PlanType = PlanType.EnterpriseAnnually, + GatewayCustomerId = "1" + }; + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = 15, + SmSeatsAdjustment = 1, + MaxAutoscaleSmServiceAccounts = 15, + SmServiceAccountsAdjustment = 1 + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + + Assert.Contains("Organization has no Secrets Manager seat limit, no need to adjust seats", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData(PlanType.Custom)] + [BitAutoData(PlanType.FamiliesAnnually)] + [BitAutoData(PlanType.FamiliesAnnually2019)] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsAnnually2019)] + public async Task UpdateSecretsManagerSubscription_WithNonSecretsManagerPlanType_ThrowsBadRequestException( + PlanType planType, + Guid organizationId, + SutProvider sutProvider) + { + + var organization = new Organization + { + Id = organizationId, + SmSeats = 10, + UseSecretsManager = true, + SmServiceAccounts = 200, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 300, + PlanType = planType, + GatewayCustomerId = "1", + GatewaySubscriptionId = "2" + }; + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organization.Id, + MaxAutoscaleSmSeats = 15, + SmSeatsAdjustment = 1, + MaxAutoscaleSmServiceAccounts = 300, + SmServiceAccountsAdjustment = 1 + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + Assert.Contains("Existing plan not found", exception.Message, StringComparison.InvariantCultureIgnoreCase); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData(PlanType.Free)] + public async Task UpdateSecretsManagerSubscription_WithHasAdditionalSeatsOptionfalse_ThrowsBadRequestException( + PlanType planType, + Guid organizationId, + SutProvider sutProvider) + { + + var organization = new Organization + { + Id = organizationId, + UseSecretsManager = true, + SmSeats = 10, + SmServiceAccounts = 200, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 300, + PlanType = planType, + GatewayCustomerId = "1", + GatewaySubscriptionId = "2" + }; + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organization.Id, + MaxAutoscaleSmSeats = 15, + SmSeatsAdjustment = 1, + MaxAutoscaleSmServiceAccounts = 300, + SmServiceAccountsAdjustment = 1 + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + Assert.Contains("Plan does not allow additional Secrets Manager seats.", exception.Message, StringComparison.InvariantCultureIgnoreCase); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData(PlanType.Free)] + public async Task UpdateSecretsManagerSubscription_WithHasAdditionalServiceAccountOptionFalse_ThrowsBadRequestException( + PlanType planType, + Guid organizationId, + SutProvider sutProvider) + { + + var organization = new Organization + { + Id = organizationId, + UseSecretsManager = true, + SmSeats = 10, + SmServiceAccounts = 200, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 300, + PlanType = planType, + GatewayCustomerId = "1", + GatewaySubscriptionId = "2" + }; + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organization.Id, + MaxAutoscaleSmSeats = 15, + SmSeatsAdjustment = 0, + MaxAutoscaleSmServiceAccounts = 300, + SmServiceAccountsAdjustment = 1 + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + Assert.Contains("Plan does not allow additional Service Accounts", exception.Message, StringComparison.InvariantCultureIgnoreCase); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually)] + public async Task UpdateSecretsManagerSubscription_ValidInput_Passes( + PlanType planType, + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + UseSecretsManager = true, + SmSeats = 10, + SmServiceAccounts = 200, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 350, + PlanType = planType, + GatewayCustomerId = "1", + GatewaySubscriptionId = "2" + }; + + var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType); + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = 15, + SmSeatsAdjustment = 5, + MaxAutoscaleSmServiceAccounts = 300, + SmServiceAccountsAdjustment = 100, + SmSeats = organization.SmSeats.GetValueOrDefault() + 5, + SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 5) - plan.BaseSeats, + SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 100, + SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 100) - (int)plan.BaseServiceAccount, + MaxAutoscaleSmSeatsChanged = 15 != organization.MaxAutoscaleSeats.GetValueOrDefault(), + MaxAutoscaleSmServiceAccountsChanged = 200 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault() + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + await sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate); + if (organizationUpdate.SmServiceAccountsAdjustment != 0) + { + await sutProvider.GetDependency().Received(1) + .AdjustSeatsAsync(organization, plan, organizationUpdate.SmSeatsExcludingBase); + + // TODO: call ReferenceEventService - see AC-1481 + } + + if (organizationUpdate.SmSeatsAdjustment != 0) + { + await sutProvider.GetDependency().Received(1) + .AdjustServiceAccountsAsync(organization, plan, organizationUpdate.SmServiceAccountsExcludingBase); + + // TODO: call ReferenceEventService - see AC-1481 + } + + if (organizationUpdate.SmSeatsAdjustment != 0) + { + await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync( + Arg.Is(org => org.SmSeats == organizationUpdate.SmSeats)); + } + + if (organizationUpdate.SmServiceAccountsAdjustment != 0) + { + await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync( + Arg.Is(org => + org.SmServiceAccounts == organizationUpdate.SmServiceAccounts)); + } + + if (organizationUpdate.MaxAutoscaleSmSeats != organization.MaxAutoscaleSmSeats) + { + await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync( + Arg.Is(org => + org.MaxAutoscaleSmSeats == organizationUpdate.MaxAutoscaleSmServiceAccounts)); + } + + if (organizationUpdate.MaxAutoscaleSmServiceAccounts != organization.MaxAutoscaleSmServiceAccounts) + { + await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync( + Arg.Is(org => + org.MaxAutoscaleSmServiceAccounts == organizationUpdate.MaxAutoscaleSmServiceAccounts)); + } + + await sutProvider.GetDependency().Received(1).SendSecretsManagerMaxSeatLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmSeats.Value, Arg.Any>()); + await sutProvider.GetDependency().Received(1).SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmServiceAccounts.Value, Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task UpdateSecretsManagerSubscription_ThrowsBadRequestException_WhenMaxAutoscaleSeatsBelowSeatCount( + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + UseSecretsManager = true, + SmSeats = 5, + SmServiceAccounts = 200, + MaxAutoscaleSmSeats = 4, + MaxAutoscaleSmServiceAccounts = 300, + PlanType = PlanType.EnterpriseAnnually, + GatewayCustomerId = "1", + GatewaySubscriptionId = "2" + }; + var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = 4, + SmSeatsAdjustment = 1, + MaxAutoscaleSmServiceAccounts = 300, + SmServiceAccountsAdjustment = 5, + SmSeats = organization.SmSeats.GetValueOrDefault() + 1, + SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 1) - plan.BaseSeats, + SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 5, + SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 5) - (int)plan.BaseServiceAccount + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(update)); + Assert.Contains("Cannot set max seat autoscaling below seat count.", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task UpdateSecretsManagerSubscription_ThrowsBadRequestException_WhenOccupiedSeatsExceedNewSeatTotal( + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + UseSecretsManager = true, + SmSeats = 10, + GatewayCustomerId = "1", + GatewaySubscriptionId = "2", + PlanType = PlanType.EnterpriseAnnually + }; + var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = 7, + SmSeatsAdjustment = -3, + MaxAutoscaleSmServiceAccounts = 300, + SmServiceAccountsAdjustment = 5, + SmSeats = organization.SmSeats.GetValueOrDefault() - 3, + SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() - 3) - plan.BaseSeats, + SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 5, + SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 5) - (int)plan.BaseServiceAccount + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + sutProvider.GetDependency().GetOccupiedSmSeatCountByOrganizationIdAsync(organizationId).Returns(8); + + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(update)); + Assert.Contains("Your organization currently has 8 Secrets Manager seats. Your plan only allows (7) Secrets Manager seats. Remove some Secrets Manager users", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task AdjustServiceAccountsAsync_ThrowsBadRequestException_WhenSmServiceAccountsIsNull( + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + SmSeats = 10, + UseSecretsManager = true, + GatewayCustomerId = "1", + GatewaySubscriptionId = "2", + SmServiceAccounts = null, + PlanType = PlanType.EnterpriseAnnually + }; + var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = 21, + SmSeatsAdjustment = 10, + MaxAutoscaleSmServiceAccounts = 250, + SmServiceAccountsAdjustment = 1, + SmSeats = organization.SmSeats.GetValueOrDefault() + 10, + SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 10) - plan.BaseSeats, + SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 1, + SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 1) - (int)plan.BaseServiceAccount + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(update)); + Assert.Contains("Organization has no Service Accounts limit, no need to adjust Service Accounts", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task AutoscaleSeatsAsync_ThrowsBadRequestException_WhenMaxAutoscaleSeatsExceedPlanMaxUsers( + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + SmSeats = 3, + UseSecretsManager = true, + SmServiceAccounts = 100, + PlanType = PlanType.Free, + GatewayCustomerId = "1", + GatewaySubscriptionId = "2", + }; + + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = 15, + SmSeatsAdjustment = 0, + MaxAutoscaleSmServiceAccounts = 200, + SmServiceAccountsAdjustment = 0, + MaxAutoscaleSmSeatsChanged = 15 != organization.MaxAutoscaleSeats.GetValueOrDefault(), + MaxAutoscaleSmServiceAccountsChanged = 200 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault() + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + Assert.Contains("Your plan has a Secrets Manager seat limit of 2, but you have specified a max autoscale count of 15.Reduce your max autoscale count.", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + [Theory] + [BitAutoData(PlanType.Free)] + public async Task AutoscaleSeatsAsync_ThrowsBadRequestException_WhenPlanDoesNotAllowSeatAutoscale( + PlanType planType, + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + UseSecretsManager = true, + SmSeats = 1, + SmServiceAccounts = 200, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 350, + PlanType = planType, + GatewayCustomerId = "1", + GatewaySubscriptionId = "2" + }; + + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = 1, + SmSeatsAdjustment = 0, + MaxAutoscaleSmServiceAccounts = 300, + SmServiceAccountsAdjustment = 0, + MaxAutoscaleSmSeatsChanged = 1 != organization.MaxAutoscaleSeats.GetValueOrDefault(), + MaxAutoscaleSmServiceAccountsChanged = 200 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault() + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + Assert.Contains("Your plan does not allow Secrets Manager seat autoscaling", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + + } + + [Theory] + [BitAutoData(PlanType.Free)] + public async Task UpdateServiceAccountAutoscaling_ThrowsBadRequestException_WhenPlanDoesNotAllowServiceAccountAutoscale( + PlanType planType, + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + UseSecretsManager = true, + SmSeats = 10, + SmServiceAccounts = 200, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 350, + PlanType = planType, + GatewayCustomerId = "1", + GatewaySubscriptionId = "2" + }; + + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = null, + SmSeatsAdjustment = 0, + MaxAutoscaleSmServiceAccounts = 300, + SmServiceAccountsAdjustment = 0, + MaxAutoscaleSmSeatsChanged = false, + MaxAutoscaleSmServiceAccountsChanged = 300 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault() + }; + + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + Assert.Contains("Your plan does not allow Service Accounts autoscaling.", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + + } + + [Theory] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually)] + public async Task UpdateServiceAccountAutoscaling_WhenCurrentServiceAccountsIsGreaterThanNew_ThrowsBadRequestException( + PlanType planType, + Guid organizationId, + SutProvider sutProvider) + { + var organization = new Organization + { + Id = organizationId, + UseSecretsManager = true, + SmSeats = 10, + SmServiceAccounts = 301, + MaxAutoscaleSmSeats = 20, + MaxAutoscaleSmServiceAccounts = 350, + PlanType = planType, + GatewayCustomerId = "1", + GatewaySubscriptionId = "2" + }; + + var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType); + var organizationUpdate = new SecretsManagerSubscriptionUpdate + { + OrganizationId = organizationId, + MaxAutoscaleSmSeats = 15, + SmSeatsAdjustment = 5, + MaxAutoscaleSmServiceAccounts = 300, + SmServiceAccountsAdjustment = 100, + SmSeats = organization.SmSeats.GetValueOrDefault() + 5, + SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 5) - plan.BaseSeats, + SmServiceAccounts = 300, + SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 100) - (int)plan.BaseServiceAccount, + MaxAutoscaleSmSeatsChanged = 15 != organization.MaxAutoscaleSeats.GetValueOrDefault(), + MaxAutoscaleSmServiceAccountsChanged = 200 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault() + }; + var currentServiceAccounts = 301; + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); + + sutProvider.GetDependency() + .GetServiceAccountCountByOrganizationIdAsync(organization.Id) + .Returns(currentServiceAccounts); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate)); + Assert.Contains("Your organization currently has 301 Service Accounts. Your plan only allows (300) Service Accounts. Remove some Service Accounts", exception.Message); + await sutProvider.GetDependency().Received(1).GetServiceAccountCountByOrganizationIdAsync(organization.Id); + await VerifyDependencyNotCalledAsync(sutProvider); + } + + private static async Task VerifyDependencyNotCalledAsync(SutProvider sutProvider) + { + await sutProvider.GetDependency().DidNotReceive() + .AdjustSeatsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .AdjustServiceAccountsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + // TODO: call ReferenceEventService - see AC-1481 + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAndUpdateCacheAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .SendOrganizationMaxSeatLimitReachedEmailAsync(Arg.Any(), Arg.Any(), + Arg.Any>()); + } +}