From a60180230d49cb79126e6475c05d4219baa28138 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Fri, 17 May 2024 14:16:03 -0400 Subject: [PATCH] [AC-2513] Scaling PM seat count with SM seat count (#4040) * For SM Trial orgs, now scaling PM seat count with SM seat count adjustments * Split Billing related organization endpoints into billing owned controller * Updated billing organizations controller to use a primary constructor to reduce boilerplate * Fixed error where ID couldn't be mapped to subscription endpoint guid param * Updated billing OrganizationController endpoints to not manually create the GUID from the string ID * Banished magic string back to the pit from whence it came * Resolved errors in unit tests --- .../Controllers/OrganizationsController.cs | 367 ----------------- .../OrganizationBillingController.cs | 30 +- .../Controllers/OrganizationsController.cs | 385 ++++++++++++++++++ .../OrganizationsControllerTests.cs | 226 ---------- .../OrganizationsControllerTests.cs | 317 ++++++++++++++ 5 files changed, 731 insertions(+), 594 deletions(-) create mode 100644 src/Api/Billing/Controllers/OrganizationsController.cs create mode 100644 test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index a05fe050e..917da4aaf 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -6,7 +6,6 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Request.Organizations; using Bit.Api.Auth.Models.Response.Organizations; -using Bit.Api.Models.Request; using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; @@ -21,20 +20,12 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Models; -using Bit.Core.Billing.Queries; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; -using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -50,7 +41,6 @@ public class OrganizationsController : Controller private readonly IPolicyRepository _policyRepository; private readonly IOrganizationService _organizationService; private readonly IUserService _userService; - private readonly IPaymentService _paymentService; private readonly ICurrentContext _currentContext; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigService _ssoConfigService; @@ -58,17 +48,9 @@ public class OrganizationsController : Controller private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand; private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; - private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; - private readonly ILicensingService _licensingService; - private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; - private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; - private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand; private readonly IPushNotificationService _pushNotificationService; - private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; - private readonly ISubscriberQueries _subscriberQueries; - private readonly IReferenceEventService _referenceEventService; private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly IProviderRepository _providerRepository; private readonly IScaleSeatsCommand _scaleSeatsCommand; @@ -79,7 +61,6 @@ public class OrganizationsController : Controller IPolicyRepository policyRepository, IOrganizationService organizationService, IUserService userService, - IPaymentService paymentService, ICurrentContext currentContext, ISsoConfigRepository ssoConfigRepository, ISsoConfigService ssoConfigService, @@ -87,17 +68,9 @@ public class OrganizationsController : Controller IRotateOrganizationApiKeyCommand rotateOrganizationApiKeyCommand, ICreateOrganizationApiKeyCommand createOrganizationApiKeyCommand, IOrganizationApiKeyRepository organizationApiKeyRepository, - ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, IFeatureService featureService, GlobalSettings globalSettings, - ILicensingService licensingService, - IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, - IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand, - IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand, IPushNotificationService pushNotificationService, - ICancelSubscriptionCommand cancelSubscriptionCommand, - ISubscriberQueries subscriberQueries, - IReferenceEventService referenceEventService, IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand, IProviderRepository providerRepository, IScaleSeatsCommand scaleSeatsCommand) @@ -107,7 +80,6 @@ public class OrganizationsController : Controller _policyRepository = policyRepository; _organizationService = organizationService; _userService = userService; - _paymentService = paymentService; _currentContext = currentContext; _ssoConfigRepository = ssoConfigRepository; _ssoConfigService = ssoConfigService; @@ -115,17 +87,9 @@ public class OrganizationsController : Controller _rotateOrganizationApiKeyCommand = rotateOrganizationApiKeyCommand; _createOrganizationApiKeyCommand = createOrganizationApiKeyCommand; _organizationApiKeyRepository = organizationApiKeyRepository; - _cloudGetOrganizationLicenseQuery = cloudGetOrganizationLicenseQuery; _featureService = featureService; _globalSettings = globalSettings; - _licensingService = licensingService; - _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; - _upgradeOrganizationPlanCommand = upgradeOrganizationPlanCommand; - _addSecretsManagerSubscriptionCommand = addSecretsManagerSubscriptionCommand; _pushNotificationService = pushNotificationService; - _cancelSubscriptionCommand = cancelSubscriptionCommand; - _subscriberQueries = subscriberQueries; - _referenceEventService = referenceEventService; _organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand; _providerRepository = providerRepository; _scaleSeatsCommand = scaleSeatsCommand; @@ -149,83 +113,6 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(organization); } - [HttpGet("{id}/billing")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetBilling(string id) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.ViewBillingHistory(orgIdGuid)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) - { - throw new NotFoundException(); - } - - var billingInfo = await _paymentService.GetBillingAsync(organization); - return new BillingResponseModel(billingInfo); - } - - [HttpGet("{id}/subscription")] - public async Task GetSubscription(string id) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.ViewSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) - { - throw new NotFoundException(); - } - - if (!_globalSettings.SelfHosted && organization.Gateway != null) - { - var subscriptionInfo = await _paymentService.GetSubscriptionAsync(organization); - if (subscriptionInfo == null) - { - throw new NotFoundException(); - } - - var hideSensitiveData = !await _currentContext.EditSubscription(orgIdGuid); - - return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, hideSensitiveData); - } - - if (_globalSettings.SelfHosted) - { - var orgLicense = await _licensingService.ReadOrganizationLicenseAsync(organization); - return new OrganizationSubscriptionResponseModel(organization, orgLicense); - } - - return new OrganizationSubscriptionResponseModel(organization); - } - - [HttpGet("{id}/license")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetLicense(string id, [FromQuery] Guid installationId) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.OrganizationOwner(orgIdGuid)) - { - throw new NotFoundException(); - } - - var org = await _organizationRepository.GetByIdAsync(new Guid(id)); - var license = await _cloudGetOrganizationLicenseQuery.GetLicenseAsync(org, installationId); - if (license == null) - { - throw new NotFoundException(); - } - - return license; - } - [HttpGet("")] public async Task> GetUser() { @@ -268,21 +155,6 @@ public class OrganizationsController : Controller return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false); } - [HttpGet("{id}/billing-status")] - public async Task GetBillingStatus(Guid id) - { - if (!await _currentContext.EditPaymentMethods(id)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(id); - - var risksSubscriptionFailure = await _paymentService.RisksSubscriptionFailure(organization); - - return new OrganizationBillingStatusResponseModel(organization, risksSubscriptionFailure); - } - [HttpPost("")] [SelfHosted(NotSelfHostedOnly = true)] public async Task Post([FromBody] OrganizationCreateRequestModel model) @@ -326,124 +198,6 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(organization); } - [HttpPost("{id}/payment")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostPayment(string id, [FromBody] PaymentRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditPaymentMethods(orgIdGuid)) - { - throw new NotFoundException(); - } - - await _organizationService.ReplacePaymentMethodAsync(orgIdGuid, model.PaymentToken, - model.PaymentMethodType.Value, new TaxInfo - { - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressState = model.State, - BillingAddressCity = model.City, - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - TaxIdNumber = model.TaxId, - }); - } - - [HttpPost("{id}/upgrade")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostUpgrade(string id, [FromBody] OrganizationUpgradeRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - - var (success, paymentIntentClientSecret) = await _upgradeOrganizationPlanCommand.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade()); - - if (model.UseSecretsManager && success) - { - var userId = _userService.GetProperUserId(User).Value; - - await TryGrantOwnerAccessToSecretsManagerAsync(orgIdGuid, userId); - } - - return new PaymentResponseModel { Success = success, PaymentIntentClientSecret = paymentIntentClientSecret }; - } - - [HttpPost("{id}/subscription")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostSubscription(string id, [FromBody] OrganizationSubscriptionUpdateRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) - { - 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 organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization); - await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate); - } - - [HttpPost("{id}/subscribe-secrets-manager")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostSubscribeSecretsManagerAsync(Guid id, [FromBody] SecretsManagerSubscribeRequestModel model) - { - var organization = await _organizationRepository.GetByIdAsync(id); - if (organization == null) - { - throw new NotFoundException(); - } - - if (!await _currentContext.EditSubscription(id)) - { - throw new NotFoundException(); - } - - await _addSecretsManagerSubscriptionCommand.SignUpAsync(organization, model.AdditionalSmSeats, - model.AdditionalServiceAccounts); - - var userId = _userService.GetProperUserId(User).Value; - - await TryGrantOwnerAccessToSecretsManagerAsync(organization.Id, userId); - - var organizationDetails = await _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, - OrganizationUserStatusType.Confirmed); - - return new ProfileOrganizationResponseModel(organizationDetails); - } - - [HttpPost("{id}/seat")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostSeat(string id, [FromBody] OrganizationSeatRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - - var result = await _organizationService.AdjustSeatsAsync(orgIdGuid, model.SeatAdjustment.Value); - return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; - } - [HttpPost("{id}/storage")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostStorage(string id, [FromBody] StorageRequestModel model) @@ -458,67 +212,6 @@ public class OrganizationsController : Controller return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; } - [HttpPost("{id}/verify-bank")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostVerifyBank(string id, [FromBody] OrganizationVerifyBankRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - - await _organizationService.VerifyBankAsync(orgIdGuid, model.Amount1.Value, model.Amount2.Value); - } - - [HttpPost("{id}/cancel")] - public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request) - { - if (!await _currentContext.EditSubscription(id)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(id); - - if (organization == null) - { - throw new NotFoundException(); - } - - var subscription = await _subscriberQueries.GetSubscriptionOrThrow(organization); - - await _cancelSubscriptionCommand.CancelSubscription(subscription, - new OffboardingSurveyResponse - { - UserId = _currentContext.UserId!.Value, - Reason = request.Reason, - Feedback = request.Feedback - }, - organization.IsExpired()); - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent( - ReferenceEventType.CancelSubscription, - organization, - _currentContext) - { - EndOfPeriod = organization.IsExpired() - }); - } - - [HttpPost("{id}/reinstate")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostReinstate(string id) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.EditSubscription(orgIdGuid)) - { - throw new NotFoundException(); - } - - await _organizationService.ReinstateSubscriptionAsync(orgIdGuid); - } - [HttpPost("{id}/leave")] public async Task Leave(string id) { @@ -722,55 +415,6 @@ public class OrganizationsController : Controller }; } - [HttpGet("{id}/tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetTaxInfo(string id) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.OrganizationOwner(orgIdGuid)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) - { - throw new NotFoundException(); - } - - var taxInfo = await _paymentService.GetTaxInfoAsync(organization); - return new TaxInfoResponseModel(taxInfo); - } - - [HttpPut("{id}/tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PutTaxInfo(string id, [FromBody] ExpandedTaxInfoUpdateRequestModel model) - { - var orgIdGuid = new Guid(id); - if (!await _currentContext.OrganizationOwner(orgIdGuid)) - { - throw new NotFoundException(); - } - - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) - { - throw new NotFoundException(); - } - - var taxInfo = new TaxInfo - { - TaxIdNumber = model.TaxId, - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressCity = model.City, - BillingAddressState = model.State, - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - }; - await _paymentService.SaveTaxInfoAsync(organization, taxInfo); - } - [HttpGet("{id}/public-key")] public async Task GetPublicKey(string id) { @@ -912,15 +556,4 @@ public class OrganizationsController : Controller ou.Type is OrganizationUserType.Admin or OrganizationUserType.Owner) .Select(ou => _pushNotificationService.PushSyncOrganizationsAsync(ou.UserId.Value))); } - - private async Task TryGrantOwnerAccessToSecretsManagerAsync(Guid organizationId, Guid userId) - { - var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId); - - if (organizationUser != null) - { - organizationUser.AccessSecretsManager = true; - await _organizationUserRepository.ReplaceAsync(organizationUser); - } - } } diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index d26de8111..a16c0c42f 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -1,5 +1,11 @@ using Bit.Api.Billing.Models.Responses; +using Bit.Api.Models.Response; using Bit.Core.Billing.Queries; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,7 +14,10 @@ namespace Bit.Api.Billing.Controllers; [Route("organizations/{organizationId:guid}/billing")] [Authorize("Application")] public class OrganizationBillingController( - IOrganizationBillingQueries organizationBillingQueries) : Controller + IOrganizationBillingQueries organizationBillingQueries, + ICurrentContext currentContext, + IOrganizationRepository organizationRepository, + IPaymentService paymentService) : Controller { [HttpGet("metadata")] public async Task GetMetadataAsync([FromRoute] Guid organizationId) @@ -24,4 +33,23 @@ public class OrganizationBillingController( return TypedResults.Ok(response); } + + [HttpGet] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task GetBilling(Guid organizationId) + { + if (!await currentContext.ViewBillingHistory(organizationId)) + { + throw new NotFoundException(); + } + + var organization = await organizationRepository.GetByIdAsync(organizationId); + if (organization == null) + { + throw new NotFoundException(); + } + + var billingInfo = await paymentService.GetBillingAsync(organization); + return new BillingResponseModel(billingInfo); + } } diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs new file mode 100644 index 000000000..f418e07f9 --- /dev/null +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -0,0 +1,385 @@ +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.Models.Request; +using Bit.Api.Models.Request.Organizations; +using Bit.Api.Models.Response; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Queries; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Billing.Controllers; + +[Route("organizations")] +[Authorize("Application")] +public class OrganizationsController( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationService organizationService, + IUserService userService, + IPaymentService paymentService, + ICurrentContext currentContext, + ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, + GlobalSettings globalSettings, + ILicensingService licensingService, + IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, + IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand, + IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand, + ICancelSubscriptionCommand cancelSubscriptionCommand, + ISubscriberQueries subscriberQueries, + IReferenceEventService referenceEventService) + : Controller +{ + [HttpGet("{id}/billing-status")] + public async Task GetBillingStatus(Guid id) + { + if (!await currentContext.EditPaymentMethods(id)) + { + throw new NotFoundException(); + } + + var organization = await organizationRepository.GetByIdAsync(id); + + var risksSubscriptionFailure = await paymentService.RisksSubscriptionFailure(organization); + + return new OrganizationBillingStatusResponseModel(organization, risksSubscriptionFailure); + } + + [HttpGet("{id:guid}/subscription")] + public async Task GetSubscription(Guid id) + { + if (!await currentContext.ViewSubscription(id)) + { + throw new NotFoundException(); + } + + var organization = await organizationRepository.GetByIdAsync(id); + if (organization == null) + { + throw new NotFoundException(); + } + + if (!globalSettings.SelfHosted && organization.Gateway != null) + { + var subscriptionInfo = await paymentService.GetSubscriptionAsync(organization); + if (subscriptionInfo == null) + { + throw new NotFoundException(); + } + + var hideSensitiveData = !await currentContext.EditSubscription(id); + + return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, hideSensitiveData); + } + + if (globalSettings.SelfHosted) + { + var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization); + return new OrganizationSubscriptionResponseModel(organization, orgLicense); + } + + return new OrganizationSubscriptionResponseModel(organization); + } + + [HttpGet("{id:guid}/license")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task GetLicense(Guid id, [FromQuery] Guid installationId) + { + if (!await currentContext.OrganizationOwner(id)) + { + throw new NotFoundException(); + } + + var org = await organizationRepository.GetByIdAsync(id); + var license = await cloudGetOrganizationLicenseQuery.GetLicenseAsync(org, installationId); + if (license == null) + { + throw new NotFoundException(); + } + + return license; + } + + [HttpPost("{id:guid}/payment")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostPayment(Guid id, [FromBody] PaymentRequestModel model) + { + if (!await currentContext.EditPaymentMethods(id)) + { + throw new NotFoundException(); + } + + await organizationService.ReplacePaymentMethodAsync(id, model.PaymentToken, + model.PaymentMethodType.Value, new TaxInfo + { + BillingAddressLine1 = model.Line1, + BillingAddressLine2 = model.Line2, + BillingAddressState = model.State, + BillingAddressCity = model.City, + BillingAddressPostalCode = model.PostalCode, + BillingAddressCountry = model.Country, + TaxIdNumber = model.TaxId, + }); + } + + [HttpPost("{id:guid}/upgrade")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostUpgrade(Guid id, [FromBody] OrganizationUpgradeRequestModel model) + { + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + var (success, paymentIntentClientSecret) = await upgradeOrganizationPlanCommand.UpgradePlanAsync(id, model.ToOrganizationUpgrade()); + + if (model.UseSecretsManager && success) + { + var userId = userService.GetProperUserId(User).Value; + + await TryGrantOwnerAccessToSecretsManagerAsync(id, userId); + } + + return new PaymentResponseModel { Success = success, PaymentIntentClientSecret = paymentIntentClientSecret }; + } + + [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(); + } + + organization = await AdjustOrganizationSeatsForSmTrialAsync(id, organization, model); + + var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization); + + await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate); + } + + [HttpPost("{id:guid}/subscription")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostSubscription(Guid id, [FromBody] OrganizationSubscriptionUpdateRequestModel model) + { + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + await organizationService.UpdateSubscription(id, model.SeatAdjustment, model.MaxAutoscaleSeats); + } + + [HttpPost("{id:guid}/subscribe-secrets-manager")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostSubscribeSecretsManagerAsync(Guid id, [FromBody] SecretsManagerSubscribeRequestModel model) + { + var organization = await organizationRepository.GetByIdAsync(id); + if (organization == null) + { + throw new NotFoundException(); + } + + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + await addSecretsManagerSubscriptionCommand.SignUpAsync(organization, model.AdditionalSmSeats, + model.AdditionalServiceAccounts); + + var userId = userService.GetProperUserId(User).Value; + + await TryGrantOwnerAccessToSecretsManagerAsync(organization.Id, userId); + + var organizationDetails = await organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, + OrganizationUserStatusType.Confirmed); + + return new ProfileOrganizationResponseModel(organizationDetails); + } + + [HttpPost("{id:guid}/seat")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostSeat(Guid id, [FromBody] OrganizationSeatRequestModel model) + { + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + var result = await organizationService.AdjustSeatsAsync(id, model.SeatAdjustment.Value); + return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; + } + + [HttpPost("{id:guid}/verify-bank")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostVerifyBank(Guid id, [FromBody] OrganizationVerifyBankRequestModel model) + { + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + await organizationService.VerifyBankAsync(id, model.Amount1.Value, model.Amount2.Value); + } + + [HttpPost("{id}/cancel")] + public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request) + { + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + var organization = await organizationRepository.GetByIdAsync(id); + + if (organization == null) + { + throw new NotFoundException(); + } + + var subscription = await subscriberQueries.GetSubscriptionOrThrow(organization); + + await cancelSubscriptionCommand.CancelSubscription(subscription, + new OffboardingSurveyResponse + { + UserId = currentContext.UserId!.Value, + Reason = request.Reason, + Feedback = request.Feedback + }, + organization.IsExpired()); + + await referenceEventService.RaiseEventAsync(new ReferenceEvent( + ReferenceEventType.CancelSubscription, + organization, + currentContext) + { + EndOfPeriod = organization.IsExpired() + }); + } + + [HttpPost("{id:guid}/reinstate")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostReinstate(Guid id) + { + if (!await currentContext.EditSubscription(id)) + { + throw new NotFoundException(); + } + + await organizationService.ReinstateSubscriptionAsync(id); + } + + [HttpGet("{id:guid}/tax")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task GetTaxInfo(Guid id) + { + if (!await currentContext.OrganizationOwner(id)) + { + throw new NotFoundException(); + } + + var organization = await organizationRepository.GetByIdAsync(id); + if (organization == null) + { + throw new NotFoundException(); + } + + var taxInfo = await paymentService.GetTaxInfoAsync(organization); + return new TaxInfoResponseModel(taxInfo); + } + + [HttpPut("{id:guid}/tax")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PutTaxInfo(Guid id, [FromBody] ExpandedTaxInfoUpdateRequestModel model) + { + if (!await currentContext.OrganizationOwner(id)) + { + throw new NotFoundException(); + } + + var organization = await organizationRepository.GetByIdAsync(id); + if (organization == null) + { + throw new NotFoundException(); + } + + var taxInfo = new TaxInfo + { + TaxIdNumber = model.TaxId, + BillingAddressLine1 = model.Line1, + BillingAddressLine2 = model.Line2, + BillingAddressCity = model.City, + BillingAddressState = model.State, + BillingAddressPostalCode = model.PostalCode, + BillingAddressCountry = model.Country, + }; + await paymentService.SaveTaxInfoAsync(organization, taxInfo); + } + + /// + /// Tries to grant owner access to the Secrets Manager for the organization + /// + /// + /// + private async Task TryGrantOwnerAccessToSecretsManagerAsync(Guid organizationId, Guid userId) + { + var organizationUser = await organizationUserRepository.GetByOrganizationAsync(organizationId, userId); + + if (organizationUser != null) + { + organizationUser.AccessSecretsManager = true; + await organizationUserRepository.ReplaceAsync(organizationUser); + } + } + + /// + /// Adjusts the organization seats for the Secrets Manager trial to match the new seat count for secrets manager + /// + /// + /// + /// + private async Task AdjustOrganizationSeatsForSmTrialAsync(Guid id, Organization organization, + SecretsManagerSubscriptionUpdateRequestModel model) + { + if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId) || + string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId) || + model.SeatAdjustment == 0) + { + return organization; + } + + var subscriptionInfo = await paymentService.GetSubscriptionAsync(organization); + if (subscriptionInfo?.CustomerDiscount?.Id != StripeConstants.CouponIDs.SecretsManagerStandalone) + { + return organization; + } + + await organizationService.UpdateSubscription(id, model.SeatAdjustment, null); + + return await organizationRepository.GetByIdAsync(id); + } +} diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 831212f1b..31bcef0bd 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -1,9 +1,7 @@ using System.Security.Claims; using AutoFixture.Xunit2; using Bit.Api.AdminConsole.Controllers; -using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Models.Request.Accounts; -using Bit.Api.Models.Request.Organizations; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; @@ -16,21 +14,14 @@ using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Billing.Commands; -using Bit.Core.Billing.Queries; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; -using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Services; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; using NSubstitute; -using NSubstitute.ReturnsExtensions; using Xunit; using GlobalSettings = Bit.Core.Settings.GlobalSettings; @@ -43,7 +34,6 @@ public class OrganizationsControllerTests : IDisposable private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationService _organizationService; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IPaymentService _paymentService; private readonly IPolicyRepository _policyRepository; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigService _ssoConfigService; @@ -51,17 +41,9 @@ public class OrganizationsControllerTests : IDisposable private readonly IGetOrganizationApiKeyQuery _getOrganizationApiKeyQuery; private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; - private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; private readonly IFeatureService _featureService; - private readonly ILicensingService _licensingService; - private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; - private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; - private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand; private readonly IPushNotificationService _pushNotificationService; - private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; - private readonly ISubscriberQueries _subscriberQueries; - private readonly IReferenceEventService _referenceEventService; private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly IProviderRepository _providerRepository; private readonly IScaleSeatsCommand _scaleSeatsCommand; @@ -75,7 +57,6 @@ public class OrganizationsControllerTests : IDisposable _organizationRepository = Substitute.For(); _organizationService = Substitute.For(); _organizationUserRepository = Substitute.For(); - _paymentService = Substitute.For(); _policyRepository = Substitute.For(); _ssoConfigRepository = Substitute.For(); _ssoConfigService = Substitute.For(); @@ -83,17 +64,9 @@ public class OrganizationsControllerTests : IDisposable _rotateOrganizationApiKeyCommand = Substitute.For(); _organizationApiKeyRepository = Substitute.For(); _userService = Substitute.For(); - _cloudGetOrganizationLicenseQuery = Substitute.For(); _createOrganizationApiKeyCommand = Substitute.For(); _featureService = Substitute.For(); - _licensingService = Substitute.For(); - _updateSecretsManagerSubscriptionCommand = Substitute.For(); - _upgradeOrganizationPlanCommand = Substitute.For(); - _addSecretsManagerSubscriptionCommand = Substitute.For(); _pushNotificationService = Substitute.For(); - _cancelSubscriptionCommand = Substitute.For(); - _subscriberQueries = Substitute.For(); - _referenceEventService = Substitute.For(); _organizationEnableCollectionEnhancementsCommand = Substitute.For(); _providerRepository = Substitute.For(); _scaleSeatsCommand = Substitute.For(); @@ -104,7 +77,6 @@ public class OrganizationsControllerTests : IDisposable _policyRepository, _organizationService, _userService, - _paymentService, _currentContext, _ssoConfigRepository, _ssoConfigService, @@ -112,17 +84,9 @@ public class OrganizationsControllerTests : IDisposable _rotateOrganizationApiKeyCommand, _createOrganizationApiKeyCommand, _organizationApiKeyRepository, - _cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, - _licensingService, - _updateSecretsManagerSubscriptionCommand, - _upgradeOrganizationPlanCommand, - _addSecretsManagerSubscriptionCommand, _pushNotificationService, - _cancelSubscriptionCommand, - _subscriberQueries, - _referenceEventService, _organizationEnableCollectionEnhancementsCommand, _providerRepository, _scaleSeatsCommand); @@ -193,196 +157,6 @@ public class OrganizationsControllerTests : IDisposable await _organizationService.Received(1).DeleteUserAsync(orgId, user.Id); } - [Theory, AutoData] - public async Task OrganizationsController_PostUpgrade_UserCannotEditSubscription_ThrowsNotFoundException( - Guid organizationId, - OrganizationUpgradeRequestModel model) - { - _currentContext.EditSubscription(organizationId).Returns(false); - - await Assert.ThrowsAsync(() => _sut.PostUpgrade(organizationId.ToString(), model)); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostUpgrade_NonSMUpgrade_ReturnsCorrectResponse( - Guid organizationId, - OrganizationUpgradeRequestModel model, - bool success, - string paymentIntentClientSecret) - { - model.UseSecretsManager = false; - - _currentContext.EditSubscription(organizationId).Returns(true); - - _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) - .Returns(new Tuple(success, paymentIntentClientSecret)); - - var response = await _sut.PostUpgrade(organizationId.ToString(), model); - - Assert.Equal(success, response.Success); - Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostUpgrade_SMUpgrade_ProvidesAccess_ReturnsCorrectResponse( - Guid organizationId, - Guid userId, - OrganizationUpgradeRequestModel model, - bool success, - string paymentIntentClientSecret, - OrganizationUser organizationUser) - { - model.UseSecretsManager = true; - organizationUser.AccessSecretsManager = false; - - _currentContext.EditSubscription(organizationId).Returns(true); - - _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) - .Returns(new Tuple(success, paymentIntentClientSecret)); - - _userService.GetProperUserId(Arg.Any()).Returns(userId); - - _organizationUserRepository.GetByOrganizationAsync(organizationId, userId).Returns(organizationUser); - - var response = await _sut.PostUpgrade(organizationId.ToString(), model); - - Assert.Equal(success, response.Success); - Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); - - await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is(orgUser => - orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true)); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostUpgrade_SMUpgrade_NullOrgUser_ReturnsCorrectResponse( - Guid organizationId, - Guid userId, - OrganizationUpgradeRequestModel model, - bool success, - string paymentIntentClientSecret) - { - model.UseSecretsManager = true; - - _currentContext.EditSubscription(organizationId).Returns(true); - - _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) - .Returns(new Tuple(success, paymentIntentClientSecret)); - - _userService.GetProperUserId(Arg.Any()).Returns(userId); - - _organizationUserRepository.GetByOrganizationAsync(organizationId, userId).ReturnsNull(); - - var response = await _sut.PostUpgrade(organizationId.ToString(), model); - - Assert.Equal(success, response.Success); - Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); - - await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrg_ThrowsNotFoundException( - Guid organizationId, - SecretsManagerSubscribeRequestModel model) - { - _organizationRepository.GetByIdAsync(organizationId).ReturnsNull(); - - await Assert.ThrowsAsync(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model)); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_UserCannotEditSubscription_ThrowsNotFoundException( - Guid organizationId, - SecretsManagerSubscribeRequestModel model, - Organization organization) - { - _organizationRepository.GetByIdAsync(organizationId).Returns(organization); - - _currentContext.EditSubscription(organizationId).Returns(false); - - await Assert.ThrowsAsync(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model)); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_ProvidesAccess_ReturnsCorrectResponse( - Guid organizationId, - SecretsManagerSubscribeRequestModel model, - Organization organization, - Guid userId, - OrganizationUser organizationUser, - OrganizationUserOrganizationDetails organizationUserOrganizationDetails) - { - organizationUser.AccessSecretsManager = false; - - var ssoConfigurationData = new SsoConfigurationData - { - MemberDecryptionType = MemberDecryptionType.KeyConnector, - KeyConnectorUrl = "https://example.com" - }; - - organizationUserOrganizationDetails.Permissions = string.Empty; - organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize(); - - _organizationRepository.GetByIdAsync(organizationId).Returns(organization); - - _currentContext.EditSubscription(organizationId).Returns(true); - - _userService.GetProperUserId(Arg.Any()).Returns(userId); - - _organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).Returns(organizationUser); - - _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed) - .Returns(organizationUserOrganizationDetails); - - var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model); - - Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId); - Assert.Equal(response.Name, organizationUserOrganizationDetails.Name); - - await _addSecretsManagerSubscriptionCommand.Received(1) - .SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts); - await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is(orgUser => - orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true)); - } - - [Theory, AutoData] - public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrgUser_ReturnsCorrectResponse( - Guid organizationId, - SecretsManagerSubscribeRequestModel model, - Organization organization, - Guid userId, - OrganizationUserOrganizationDetails organizationUserOrganizationDetails) - { - var ssoConfigurationData = new SsoConfigurationData - { - MemberDecryptionType = MemberDecryptionType.KeyConnector, - KeyConnectorUrl = "https://example.com" - }; - - organizationUserOrganizationDetails.Permissions = string.Empty; - organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize(); - - _organizationRepository.GetByIdAsync(organizationId).Returns(organization); - - _currentContext.EditSubscription(organizationId).Returns(true); - - _userService.GetProperUserId(Arg.Any()).Returns(userId); - - _organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).ReturnsNull(); - - _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed) - .Returns(organizationUserOrganizationDetails); - - var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model); - - Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId); - Assert.Equal(response.Name, organizationUserOrganizationDetails.Name); - - await _addSecretsManagerSubscriptionCommand.Received(1) - .SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts); - await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); - } - [Theory, AutoData] public async Task EnableCollectionEnhancements_Success(Organization organization) { diff --git a/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs new file mode 100644 index 000000000..b5737837e --- /dev/null +++ b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs @@ -0,0 +1,317 @@ +using System.Security.Claims; +using AutoFixture.Xunit2; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.Billing.Controllers; +using Bit.Api.Models.Request.Organizations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Auth.Services; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Queries; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Services; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Api.Test.Billing.Controllers; + +public class OrganizationsControllerTests : IDisposable +{ + private readonly GlobalSettings _globalSettings; + private readonly ICurrentContext _currentContext; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationService _organizationService; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IPaymentService _paymentService; + private readonly ISsoConfigRepository _ssoConfigRepository; + private readonly IUserService _userService; + private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; + private readonly ILicensingService _licensingService; + private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; + private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand; + private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand; + private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; + private readonly ISubscriberQueries _subscriberQueries; + private readonly IReferenceEventService _referenceEventService; + + private readonly OrganizationsController _sut; + + public OrganizationsControllerTests() + { + _currentContext = Substitute.For(); + _globalSettings = Substitute.For(); + _organizationRepository = Substitute.For(); + _organizationService = Substitute.For(); + _organizationUserRepository = Substitute.For(); + _paymentService = Substitute.For(); + Substitute.For(); + _ssoConfigRepository = Substitute.For(); + Substitute.For(); + _userService = Substitute.For(); + _cloudGetOrganizationLicenseQuery = Substitute.For(); + _licensingService = Substitute.For(); + _updateSecretsManagerSubscriptionCommand = Substitute.For(); + _upgradeOrganizationPlanCommand = Substitute.For(); + _addSecretsManagerSubscriptionCommand = Substitute.For(); + _cancelSubscriptionCommand = Substitute.For(); + _subscriberQueries = Substitute.For(); + _referenceEventService = Substitute.For(); + + _sut = new OrganizationsController( + _organizationRepository, + _organizationUserRepository, + _organizationService, + _userService, + _paymentService, + _currentContext, + _cloudGetOrganizationLicenseQuery, + _globalSettings, + _licensingService, + _updateSecretsManagerSubscriptionCommand, + _upgradeOrganizationPlanCommand, + _addSecretsManagerSubscriptionCommand, + _cancelSubscriptionCommand, + _subscriberQueries, + _referenceEventService); + } + + public void Dispose() + { + _sut?.Dispose(); + } + + [Theory] + [InlineAutoData(true, false)] + [InlineAutoData(false, true)] + [InlineAutoData(false, false)] + public async Task OrganizationsController_UserCanLeaveOrganizationThatDoesntProvideKeyConnector( + bool keyConnectorEnabled, bool userUsesKeyConnector, Guid orgId, User user) + { + var ssoConfig = new SsoConfig + { + Id = default, + Data = new SsoConfigurationData + { + MemberDecryptionType = keyConnectorEnabled + ? MemberDecryptionType.KeyConnector + : MemberDecryptionType.MasterPassword + }.Serialize(), + Enabled = true, + OrganizationId = orgId, + }; + + user.UsesKeyConnector = userUsesKeyConnector; + + _currentContext.OrganizationUser(orgId).Returns(true); + _ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + + await _organizationService.DeleteUserAsync(orgId, user.Id); + await _organizationService.Received(1).DeleteUserAsync(orgId, user.Id); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostUpgrade_UserCannotEditSubscription_ThrowsNotFoundException( + Guid organizationId, + OrganizationUpgradeRequestModel model) + { + _currentContext.EditSubscription(organizationId).Returns(false); + + await Assert.ThrowsAsync(() => _sut.PostUpgrade(organizationId, model)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostUpgrade_NonSMUpgrade_ReturnsCorrectResponse( + Guid organizationId, + OrganizationUpgradeRequestModel model, + bool success, + string paymentIntentClientSecret) + { + model.UseSecretsManager = false; + + _currentContext.EditSubscription(organizationId).Returns(true); + + _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) + .Returns(new Tuple(success, paymentIntentClientSecret)); + + var response = await _sut.PostUpgrade(organizationId, model); + + Assert.Equal(success, response.Success); + Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostUpgrade_SMUpgrade_ProvidesAccess_ReturnsCorrectResponse( + Guid organizationId, + Guid userId, + OrganizationUpgradeRequestModel model, + bool success, + string paymentIntentClientSecret, + OrganizationUser organizationUser) + { + model.UseSecretsManager = true; + organizationUser.AccessSecretsManager = false; + + _currentContext.EditSubscription(organizationId).Returns(true); + + _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) + .Returns(new Tuple(success, paymentIntentClientSecret)); + + _userService.GetProperUserId(Arg.Any()).Returns(userId); + + _organizationUserRepository.GetByOrganizationAsync(organizationId, userId).Returns(organizationUser); + + var response = await _sut.PostUpgrade(organizationId, model); + + Assert.Equal(success, response.Success); + Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); + + await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is(orgUser => + orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostUpgrade_SMUpgrade_NullOrgUser_ReturnsCorrectResponse( + Guid organizationId, + Guid userId, + OrganizationUpgradeRequestModel model, + bool success, + string paymentIntentClientSecret) + { + model.UseSecretsManager = true; + + _currentContext.EditSubscription(organizationId).Returns(true); + + _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) + .Returns(new Tuple(success, paymentIntentClientSecret)); + + _userService.GetProperUserId(Arg.Any()).Returns(userId); + + _organizationUserRepository.GetByOrganizationAsync(organizationId, userId).ReturnsNull(); + + var response = await _sut.PostUpgrade(organizationId, model); + + Assert.Equal(success, response.Success); + Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); + + await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrg_ThrowsNotFoundException( + Guid organizationId, + SecretsManagerSubscribeRequestModel model) + { + _organizationRepository.GetByIdAsync(organizationId).ReturnsNull(); + + await Assert.ThrowsAsync(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_UserCannotEditSubscription_ThrowsNotFoundException( + Guid organizationId, + SecretsManagerSubscribeRequestModel model, + Organization organization) + { + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + _currentContext.EditSubscription(organizationId).Returns(false); + + await Assert.ThrowsAsync(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_ProvidesAccess_ReturnsCorrectResponse( + Guid organizationId, + SecretsManagerSubscribeRequestModel model, + Organization organization, + Guid userId, + OrganizationUser organizationUser, + OrganizationUserOrganizationDetails organizationUserOrganizationDetails) + { + organizationUser.AccessSecretsManager = false; + + var ssoConfigurationData = new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.KeyConnector, + KeyConnectorUrl = "https://example.com" + }; + + organizationUserOrganizationDetails.Permissions = string.Empty; + organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize(); + + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + _currentContext.EditSubscription(organizationId).Returns(true); + + _userService.GetProperUserId(Arg.Any()).Returns(userId); + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).Returns(organizationUser); + + _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed) + .Returns(organizationUserOrganizationDetails); + + var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model); + + Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId); + Assert.Equal(response.Name, organizationUserOrganizationDetails.Name); + + await _addSecretsManagerSubscriptionCommand.Received(1) + .SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts); + await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is(orgUser => + orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrgUser_ReturnsCorrectResponse( + Guid organizationId, + SecretsManagerSubscribeRequestModel model, + Organization organization, + Guid userId, + OrganizationUserOrganizationDetails organizationUserOrganizationDetails) + { + var ssoConfigurationData = new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.KeyConnector, + KeyConnectorUrl = "https://example.com" + }; + + organizationUserOrganizationDetails.Permissions = string.Empty; + organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize(); + + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + _currentContext.EditSubscription(organizationId).Returns(true); + + _userService.GetProperUserId(Arg.Any()).Returns(userId); + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).ReturnsNull(); + + _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed) + .Returns(organizationUserOrganizationDetails); + + var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model); + + Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId); + Assert.Equal(response.Name, organizationUserOrganizationDetails.Name); + + await _addSecretsManagerSubscriptionCommand.Received(1) + .SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts); + await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); + } +}