diff --git a/Directory.Build.props b/Directory.Build.props index 40e6fdd202b2..9c54e35e6e7f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.1.3 + 2025.1.4 Bit.$(MSBuildProjectName) enable diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 026e36af019a..3b8a0df240b7 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -5,6 +5,7 @@ using Bit.Admin.Utilities; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; @@ -56,7 +57,7 @@ public class OrganizationsController : Controller private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand; private readonly IProviderBillingService _providerBillingService; - private readonly IFeatureService _featureService; + private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand; private readonly IPricingClient _pricingClient; public OrganizationsController( @@ -84,7 +85,8 @@ public OrganizationsController( IProviderOrganizationRepository providerOrganizationRepository, IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, IProviderBillingService providerBillingService, - IFeatureService featureService, IPricingClient pricingClient) + IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand, + IPricingClient pricingClient) { _organizationService = organizationService; _organizationRepository = organizationRepository; @@ -110,7 +112,7 @@ public OrganizationsController( _providerOrganizationRepository = providerOrganizationRepository; _removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand; _providerBillingService = providerBillingService; - _featureService = featureService; + _organizationInitiateDeleteCommand = organizationInitiateDeleteCommand; _pricingClient = pricingClient; } @@ -326,7 +328,7 @@ public async Task DeleteInitiation(Guid id, OrganizationInitiateD var organization = await _organizationRepository.GetByIdAsync(id); if (organization != null) { - await _organizationService.InitiateDeleteAsync(organization, model.AdminEmail); + await _organizationInitiateDeleteCommand.InitiateDeleteAsync(organization, model.AdminEmail); TempData["Success"] = "The request to initiate deletion of the organization has been sent."; } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 5d87aaf4f352..34da3de10ce1 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -14,6 +14,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; @@ -59,6 +60,7 @@ public class OrganizationsController : Controller private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; + private readonly IOrganizationDeleteCommand _organizationDeleteCommand; private readonly IPricingClient _pricingClient; public OrganizationsController( @@ -81,6 +83,7 @@ public OrganizationsController( IDataProtectorTokenFactory orgDeleteTokenDataFactory, IRemoveOrganizationUserCommand removeOrganizationUserCommand, ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand, + IOrganizationDeleteCommand organizationDeleteCommand, IPricingClient pricingClient) { _organizationRepository = organizationRepository; @@ -102,6 +105,7 @@ public OrganizationsController( _orgDeleteTokenDataFactory = orgDeleteTokenDataFactory; _removeOrganizationUserCommand = removeOrganizationUserCommand; _cloudOrganizationSignUpCommand = cloudOrganizationSignUpCommand; + _organizationDeleteCommand = organizationDeleteCommand; _pricingClient = pricingClient; } @@ -311,7 +315,7 @@ await _providerBillingService.ScaleSeats( } } - await _organizationService.DeleteAsync(organization); + await _organizationDeleteCommand.DeleteAsync(organization); } [HttpPost("{id}/delete-recover-token")] @@ -341,7 +345,7 @@ await _providerBillingService.ScaleSeats( } } - await _organizationService.DeleteAsync(organization); + await _organizationDeleteCommand.DeleteAsync(organization); } [HttpPost("{id}/api-key")] diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 4a6f5f5b8a03..b52241c30e2f 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -2,7 +2,6 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; -using Bit.Core; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -139,11 +138,6 @@ public async Task GetBillingAsync(Guid organizationId) [HttpGet("payment-method")] public async Task GetPaymentMethodAsync([FromRoute] Guid organizationId) { - if (!featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI)) - { - return Error.NotFound(); - } - if (!await currentContext.EditPaymentMethods(organizationId)) { return Error.Unauthorized(); @@ -168,11 +162,6 @@ public async Task UpdatePaymentMethodAsync( [FromRoute] Guid organizationId, [FromBody] UpdatePaymentMethodRequestBody requestBody) { - if (!featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI)) - { - return Error.NotFound(); - } - if (!await currentContext.EditPaymentMethods(organizationId)) { return Error.Unauthorized(); @@ -199,11 +188,6 @@ public async Task VerifyBankAccountAsync( [FromRoute] Guid organizationId, [FromBody] VerifyBankAccountRequestBody requestBody) { - if (!featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI)) - { - return Error.NotFound(); - } - if (!await currentContext.EditPaymentMethods(organizationId)) { return Error.Unauthorized(); @@ -229,11 +213,6 @@ public async Task VerifyBankAccountAsync( [HttpGet("tax-information")] public async Task GetTaxInformationAsync([FromRoute] Guid organizationId) { - if (!featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI)) - { - return Error.NotFound(); - } - if (!await currentContext.EditPaymentMethods(organizationId)) { return Error.Unauthorized(); @@ -258,11 +237,6 @@ public async Task UpdateTaxInformationAsync( [FromRoute] Guid organizationId, [FromBody] TaxInformationRequestBody requestBody) { - if (!featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI)) - { - return Error.NotFound(); - } - if (!await currentContext.EditPaymentMethods(organizationId)) { return Error.Unauthorized(); @@ -292,11 +266,6 @@ public async Task RestartSubscriptionAsync([FromRoute] Guid organizatio throw new UnauthorizedAccessException(); } - if (!featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI)) - { - return Error.NotFound(); - } - if (!await currentContext.EditPaymentMethods(organizationId)) { return Error.Unauthorized(); diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 1adf3f67dc13..a34125725932 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -29,6 +29,7 @@ using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Tools.ImportFeatures; using Bit.Core.Tools.ReportFeatures; @@ -175,6 +176,7 @@ public void ConfigureServices(IServiceCollection services) services.AddCoreLocalizationServices(); services.AddBillingOperations(); services.AddReportingServices(); + services.AddImportServices(); // Authorization Handlers services.AddAuthorizationHandlers(); diff --git a/src/Api/Tools/Controllers/ImportCiphersController.cs b/src/Api/Tools/Controllers/ImportCiphersController.cs index 0d07d5bc4718..4f4e76f6e395 100644 --- a/src/Api/Tools/Controllers/ImportCiphersController.cs +++ b/src/Api/Tools/Controllers/ImportCiphersController.cs @@ -7,7 +7,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Vault.Services; +using Bit.Core.Tools.ImportFeatures.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -17,31 +17,30 @@ namespace Bit.Api.Tools.Controllers; [Authorize("Application")] public class ImportCiphersController : Controller { - private readonly ICipherService _cipherService; private readonly IUserService _userService; private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; private readonly ICollectionRepository _collectionRepository; private readonly IAuthorizationService _authorizationService; + private readonly IImportCiphersCommand _importCiphersCommand; public ImportCiphersController( - ICipherService cipherService, IUserService userService, ICurrentContext currentContext, ILogger logger, GlobalSettings globalSettings, ICollectionRepository collectionRepository, IAuthorizationService authorizationService, - IOrganizationRepository organizationRepository) + IImportCiphersCommand importCiphersCommand) { - _cipherService = cipherService; _userService = userService; _currentContext = currentContext; _logger = logger; _globalSettings = globalSettings; _collectionRepository = collectionRepository; _authorizationService = authorizationService; + _importCiphersCommand = importCiphersCommand; } [HttpPost("import")] @@ -57,7 +56,7 @@ public async Task PostImport([FromBody] ImportCiphersRequestModel model) var userId = _userService.GetProperUserId(User).Value; var folders = model.Folders.Select(f => f.ToFolder(userId)).ToList(); var ciphers = model.Ciphers.Select(c => c.ToCipherDetails(userId, false)).ToList(); - await _cipherService.ImportCiphersAsync(folders, ciphers, model.FolderRelationships); + await _importCiphersCommand.ImportIntoIndividualVaultAsync(folders, ciphers, model.FolderRelationships); } [HttpPost("import-organization")] @@ -85,7 +84,7 @@ public async Task PostImport([FromQuery] string organizationId, var userId = _userService.GetProperUserId(User).Value; var ciphers = model.Ciphers.Select(l => l.ToOrganizationCipherDetails(orgId)).ToList(); - await _cipherService.ImportCiphersAsync(collections, ciphers, model.CollectionRelationships, userId); + await _importCiphersCommand.ImportIntoOrganizationalVaultAsync(collections, ciphers, model.CollectionRelationships, userId); } private async Task CheckOrgImportPermission(List collections, Guid orgId) diff --git a/src/Billing/Jobs/JobsHostedService.cs b/src/Billing/Jobs/JobsHostedService.cs index d91ca21520ec..a6e702c66285 100644 --- a/src/Billing/Jobs/JobsHostedService.cs +++ b/src/Billing/Jobs/JobsHostedService.cs @@ -32,5 +32,6 @@ public override async Task StartAsync(CancellationToken cancellationToken) public static void AddJobsServices(IServiceCollection services) { services.AddTransient(); + services.AddTransient(); } } diff --git a/src/Billing/Jobs/SubscriptionCancellationJob.cs b/src/Billing/Jobs/SubscriptionCancellationJob.cs new file mode 100644 index 000000000000..c46581272eeb --- /dev/null +++ b/src/Billing/Jobs/SubscriptionCancellationJob.cs @@ -0,0 +1,58 @@ +using Bit.Billing.Services; +using Bit.Core.Repositories; +using Quartz; +using Stripe; + +namespace Bit.Billing.Jobs; + +public class SubscriptionCancellationJob( + IStripeFacade stripeFacade, + IOrganizationRepository organizationRepository) + : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + var subscriptionId = context.MergedJobDataMap.GetString("subscriptionId"); + var organizationId = new Guid(context.MergedJobDataMap.GetString("organizationId") ?? string.Empty); + + var organization = await organizationRepository.GetByIdAsync(organizationId); + if (organization == null || organization.Enabled) + { + // Organization was deleted or re-enabled by CS, skip cancellation + return; + } + + var subscription = await stripeFacade.GetSubscription(subscriptionId); + if (subscription?.Status != "unpaid") + { + // Subscription is no longer unpaid, skip cancellation + return; + } + + // Cancel the subscription + await stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions()); + + // Void any open invoices + var options = new InvoiceListOptions + { + Status = "open", + Subscription = subscriptionId, + Limit = 100 + }; + var invoices = await stripeFacade.ListInvoices(options); + foreach (var invoice in invoices) + { + await stripeFacade.VoidInvoice(invoice.Id); + } + + while (invoices.HasMore) + { + options.StartingAfter = invoices.Data.Last().Id; + invoices = await stripeFacade.ListInvoices(options); + foreach (var invoice in invoices) + { + await stripeFacade.VoidInvoice(invoice.Id); + } + } + } +} diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 90ac758f794b..f22e97ab0db9 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,9 +1,12 @@ using Bit.Billing.Constants; +using Bit.Billing.Jobs; +using Bit.Core; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; +using Quartz; using Stripe; using Event = Stripe.Event; @@ -19,6 +22,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IUserService _userService; private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationRepository _organizationRepository; + private readonly ISchedulerFactory _schedulerFactory; + private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; public SubscriptionUpdatedHandler( @@ -30,6 +35,8 @@ public SubscriptionUpdatedHandler( IUserService userService, IPushNotificationService pushNotificationService, IOrganizationRepository organizationRepository, + ISchedulerFactory schedulerFactory, + IFeatureService featureService, IPricingClient pricingClient) { _stripeEventService = stripeEventService; @@ -40,6 +47,8 @@ public SubscriptionUpdatedHandler( _userService = userService; _pushNotificationService = pushNotificationService; _organizationRepository = organizationRepository; + _schedulerFactory = schedulerFactory; + _featureService = featureService; _pricingClient = pricingClient; } @@ -58,6 +67,10 @@ public async Task HandleAsync(Event parsedEvent) when organizationId.HasValue: { await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd); + if (subscription.Status == StripeSubscriptionStatus.Unpaid) + { + await ScheduleCancellationJobAsync(subscription.Id, organizationId.Value); + } break; } case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired: @@ -200,4 +213,27 @@ private async Task RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync await _stripeFacade.DeleteSubscriptionDiscount(subscription.Id); } } + + private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId) + { + var isResellerManagedOrgAlertEnabled = _featureService.IsEnabled(FeatureFlagKeys.ResellerManagedOrgAlert); + + if (isResellerManagedOrgAlertEnabled) + { + var scheduler = await _schedulerFactory.GetScheduler(); + + var job = JobBuilder.Create() + .WithIdentity($"cancel-sub-{subscriptionId}", "subscription-cancellations") + .UsingJobData("subscriptionId", subscriptionId) + .UsingJobData("organizationId", organizationId.ToString()) + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity($"cancel-trigger-{subscriptionId}", "subscription-cancellations") + .StartAt(DateTimeOffset.UtcNow.AddDays(7)) + .Build(); + + await scheduler.ScheduleJob(job, trigger); + } + } } diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index e3547d943be7..2d2f109e7702 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -9,6 +9,7 @@ using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Microsoft.Extensions.DependencyInjection.Extensions; +using Quartz; using Stripe; namespace Bit.Billing; @@ -101,6 +102,13 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); + // Add Quartz services first + services.AddQuartz(q => + { + q.UseMicrosoftDependencyInjectionJobFactory(); + }); + services.AddQuartzHostedService(); + // Jobs service Jobs.JobsHostedService.AddJobsServices(services); services.AddHostedService(); diff --git a/src/Core/AdminConsole/Models/Mail/DeviceApprovalRequestedViewModel.cs b/src/Core/AdminConsole/Models/Mail/DeviceApprovalRequestedViewModel.cs new file mode 100644 index 000000000000..7f6c9326191b --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/DeviceApprovalRequestedViewModel.cs @@ -0,0 +1,14 @@ +using Bit.Core.Models.Mail; + +namespace Bit.Core.AdminConsole.Models.Mail; + +public class DeviceApprovalRequestedViewModel : BaseMailModel +{ + public Guid OrganizationId { get; set; } + public string UserNameRequestingAccess { get; set; } + + public string Url => string.Format("{0}/organizations/{1}/settings/device-approvals", + WebVaultUrl, + OrganizationId); +} + diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index 8f2be3231c60..57cfd1e60f47 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -127,28 +127,8 @@ public async Task SignUpOrganizationAsync(Organizati } else if (plan.Type != PlanType.Free) { - if (featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI)) - { - var sale = OrganizationSale.From(organization, signup); - await organizationBillingService.Finalize(sale); - } - else - { - if (signup.PaymentMethodType != null) - { - await paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value, - signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, - signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(), - signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); - } - else - { - await paymentService.PurchaseOrganizationNoPaymentMethod(organization, plan, signup.AdditionalSeats, - signup.PremiumAccessAddon, signup.AdditionalSmSeats.GetValueOrDefault(), - signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); - } - - } + var sale = OrganizationSale.From(organization, signup); + await organizationBillingService.Finalize(sale); } var ownerId = signup.IsFromProvider ? default : signup.Owner.Id; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationDeleteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationDeleteCommand.cs new file mode 100644 index 000000000000..fc4de42beda9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationDeleteCommand.cs @@ -0,0 +1,13 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface IOrganizationDeleteCommand +{ + /// + /// Permanently deletes an organization and performs necessary cleanup. + /// + /// The organization to delete. + /// Thrown when the organization cannot be deleted due to configuration constraints. + Task DeleteAsync(Organization organization); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationInitiateDeleteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationInitiateDeleteCommand.cs new file mode 100644 index 000000000000..a8d211f24504 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationInitiateDeleteCommand.cs @@ -0,0 +1,14 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface IOrganizationInitiateDeleteCommand +{ + /// + /// Initiates a secure deletion process for an organization by requesting confirmation from an organization admin. + /// + /// The organization to be deleted. + /// The email address of the organization admin who will confirm the deletion. + /// Thrown when the specified admin email is invalid or lacks sufficient permissions. + Task InitiateDeleteAsync(Organization organization, string orgAdminEmail); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs new file mode 100644 index 000000000000..185d5c5ac02c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs @@ -0,0 +1,69 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Repositories; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public class OrganizationDeleteCommand : IOrganizationDeleteCommand +{ + private readonly IApplicationCacheService _applicationCacheService; + private readonly ICurrentContext _currentContext; + private readonly IOrganizationRepository _organizationRepository; + private readonly IPaymentService _paymentService; + private readonly IReferenceEventService _referenceEventService; + private readonly ISsoConfigRepository _ssoConfigRepository; + + public OrganizationDeleteCommand( + IApplicationCacheService applicationCacheService, + ICurrentContext currentContext, + IOrganizationRepository organizationRepository, + IPaymentService paymentService, + IReferenceEventService referenceEventService, + ISsoConfigRepository ssoConfigRepository) + { + _applicationCacheService = applicationCacheService; + _currentContext = currentContext; + _organizationRepository = organizationRepository; + _paymentService = paymentService; + _referenceEventService = referenceEventService; + _ssoConfigRepository = ssoConfigRepository; + } + + public async Task DeleteAsync(Organization organization) + { + await ValidateDeleteOrganizationAsync(organization); + + if (!string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) + { + try + { + var eop = !organization.ExpirationDate.HasValue || + organization.ExpirationDate.Value >= DateTime.UtcNow; + await _paymentService.CancelSubscriptionAsync(organization, eop); + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.DeleteAccount, organization, _currentContext)); + } + catch (GatewayException) { } + } + + await _organizationRepository.DeleteAsync(organization); + await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); + } + + private async Task ValidateDeleteOrganizationAsync(Organization organization) + { + var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id); + if (ssoConfig?.GetData()?.MemberDecryptionType == MemberDecryptionType.KeyConnector) + { + throw new BadRequestException("You cannot delete an Organization that is using Key Connector."); + } + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationInitiateDeleteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationInitiateDeleteCommand.cs new file mode 100644 index 000000000000..5979adc376fe --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationInitiateDeleteCommand.cs @@ -0,0 +1,49 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tokens; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public class OrganizationInitiateDeleteCommand : IOrganizationInitiateDeleteCommand +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IUserRepository _userRepository; + private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; + private readonly IMailService _mailService; + + public const string OrganizationAdminNotFoundErrorMessage = "Org admin not found."; + + public OrganizationInitiateDeleteCommand( + IOrganizationUserRepository organizationUserRepository, + IUserRepository userRepository, + IDataProtectorTokenFactory orgDeleteTokenDataFactory, + IMailService mailService) + { + _organizationUserRepository = organizationUserRepository; + _userRepository = userRepository; + _orgDeleteTokenDataFactory = orgDeleteTokenDataFactory; + _mailService = mailService; + } + + public async Task InitiateDeleteAsync(Organization organization, string orgAdminEmail) + { + var orgAdmin = await _userRepository.GetByEmailAsync(orgAdminEmail); + if (orgAdmin == null) + { + throw new BadRequestException(OrganizationAdminNotFoundErrorMessage); + } + var orgAdminOrgUser = await _organizationUserRepository.GetDetailsByUserAsync(orgAdmin.Id, organization.Id); + if (orgAdminOrgUser == null || orgAdminOrgUser.Status is not OrganizationUserStatusType.Confirmed || + (orgAdminOrgUser.Type is not OrganizationUserType.Admin and not OrganizationUserType.Owner)) + { + throw new BadRequestException(OrganizationAdminNotFoundErrorMessage); + } + var token = _orgDeleteTokenDataFactory.Protect(new OrgDeleteTokenable(organization, 1)); + await _mailService.SendInitiateDeleteOrganzationEmailAsync(orgAdminEmail, organization, token); + } +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 0495c4c76ee0..7d73a3c903bc 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -28,8 +28,6 @@ Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, Payment /// Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync(OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey, string privateKey); - Task InitiateDeleteAsync(Organization organization, string orgAdminEmail); - Task DeleteAsync(Organization organization); Task EnableAsync(Guid organizationId, DateTime? expirationDate); Task DisableAsync(Guid organizationId, DateTime? expirationDate); Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index f696c7f5a62e..f08c614cd04d 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business; -using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; @@ -69,7 +68,6 @@ public class OrganizationService : IOrganizationService private readonly IProviderUserRepository _providerUserRepository; private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; - private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; private readonly IProviderRepository _providerRepository; private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; @@ -108,7 +106,6 @@ public OrganizationService( IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, - IDataProtectorTokenFactory orgDeleteTokenDataFactory, IProviderRepository providerRepository, IFeatureService featureService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, @@ -142,7 +139,6 @@ public OrganizationService( _providerUserRepository = providerUserRepository; _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; - _orgDeleteTokenDataFactory = orgDeleteTokenDataFactory; _providerRepository = providerRepository; _orgUserInviteTokenableFactory = orgUserInviteTokenableFactory; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; @@ -675,44 +671,6 @@ await _pushRegistrationService.AddUserRegistrationOrganizationAsync(devices, } } - public async Task InitiateDeleteAsync(Organization organization, string orgAdminEmail) - { - var orgAdmin = await _userRepository.GetByEmailAsync(orgAdminEmail); - if (orgAdmin == null) - { - throw new BadRequestException("Org admin not found."); - } - var orgAdminOrgUser = await _organizationUserRepository.GetDetailsByUserAsync(orgAdmin.Id, organization.Id); - if (orgAdminOrgUser == null || orgAdminOrgUser.Status != OrganizationUserStatusType.Confirmed || - (orgAdminOrgUser.Type != OrganizationUserType.Admin && orgAdminOrgUser.Type != OrganizationUserType.Owner)) - { - throw new BadRequestException("Org admin not found."); - } - var token = _orgDeleteTokenDataFactory.Protect(new OrgDeleteTokenable(organization, 1)); - await _mailService.SendInitiateDeleteOrganzationEmailAsync(orgAdminEmail, organization, token); - } - - public async Task DeleteAsync(Organization organization) - { - await ValidateDeleteOrganizationAsync(organization); - - if (!string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) - { - try - { - var eop = !organization.ExpirationDate.HasValue || - organization.ExpirationDate.Value >= DateTime.UtcNow; - await _paymentService.CancelSubscriptionAsync(organization, eop); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.DeleteAccount, organization, _currentContext)); - } - catch (GatewayException) { } - } - - await _organizationRepository.DeleteAsync(organization); - await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); - } - public async Task EnableAsync(Guid organizationId, DateTime? expirationDate) { var org = await GetOrgById(organizationId); @@ -1965,15 +1923,6 @@ private async Task ValidateCustomPermissionsGrant(Guid organizationId, Per return true; } - private async Task ValidateDeleteOrganizationAsync(Organization organization) - { - var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id); - if (ssoConfig?.GetData()?.MemberDecryptionType == MemberDecryptionType.KeyConnector) - { - throw new BadRequestException("You cannot delete an Organization that is using Key Connector."); - } - } - public async Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId) { if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId.Value) diff --git a/src/Core/Auth/Services/Implementations/AuthRequestService.cs b/src/Core/Auth/Services/Implementations/AuthRequestService.cs index f83c5de1f6d0..5e41e3a679c1 100644 --- a/src/Core/Auth/Services/Implementations/AuthRequestService.cs +++ b/src/Core/Auth/Services/Implementations/AuthRequestService.cs @@ -12,6 +12,7 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; #nullable enable @@ -27,6 +28,9 @@ public class AuthRequestService : IAuthRequestService private readonly IPushNotificationService _pushNotificationService; private readonly IEventService _eventService; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IMailService _mailService; + private readonly IFeatureService _featureService; + private readonly ILogger _logger; public AuthRequestService( IAuthRequestRepository authRequestRepository, @@ -36,7 +40,10 @@ public AuthRequestService( ICurrentContext currentContext, IPushNotificationService pushNotificationService, IEventService eventService, - IOrganizationUserRepository organizationRepository) + IOrganizationUserRepository organizationRepository, + IMailService mailService, + IFeatureService featureService, + ILogger logger) { _authRequestRepository = authRequestRepository; _userRepository = userRepository; @@ -46,6 +53,9 @@ public AuthRequestService( _pushNotificationService = pushNotificationService; _eventService = eventService; _organizationUserRepository = organizationRepository; + _mailService = mailService; + _featureService = featureService; + _logger = logger; } public async Task GetAuthRequestAsync(Guid id, Guid userId) @@ -132,6 +142,8 @@ public async Task CreateAuthRequestAsync(AuthRequestCreateRequestMo { var createdAuthRequest = await CreateAuthRequestAsync(model, user, organizationUser.OrganizationId); firstAuthRequest ??= createdAuthRequest; + + await NotifyAdminsOfDeviceApprovalRequestAsync(organizationUser, user); } // I know this won't be null because I have already validated that at least one organization exists @@ -276,4 +288,19 @@ private static bool IsDateExpired(DateTime savedDate, TimeSpan allowedLifetime) { return DateTime.UtcNow > savedDate.Add(allowedLifetime); } + + private async Task NotifyAdminsOfDeviceApprovalRequestAsync(OrganizationUser organizationUser, User user) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications)) + { + _logger.LogWarning("Skipped sending device approval notification to admins - feature flag disabled"); + return; + } + + var admins = await _organizationUserRepository.GetManyByMinimumRoleAsync( + organizationUser.OrganizationId, + OrganizationUserType.Admin); + var adminEmails = admins.Select(a => a.Email).Distinct().ToList(); + await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync(adminEmails, organizationUser.OrganizationId, user.Email, user.Name); + } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index dd45593ae927..b4ddd734094b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -106,6 +106,7 @@ public static class FeatureFlagKeys public const string AccountDeprovisioning = "pm-10308-account-deprovisioning"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; public const string IntegrationPage = "pm-14505-admin-console-integration-page"; + public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications"; public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection"; @@ -137,7 +138,6 @@ public static class FeatureFlagKeys public const string NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements"; public const string BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain"; public const string NotificationRefresh = "notification-refresh"; - public const string AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api"; public const string PersistPopupView = "persist-popup-view"; public const string CipherKeyEncryption = "cipher-key-encryption"; public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill"; diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 1e0567dae20b..f66742ab84f0 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -61,6 +61,9 @@ + + + diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/NotifyAdminDeviceApprovalRequested.html.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/NotifyAdminDeviceApprovalRequested.html.hbs new file mode 100644 index 000000000000..a54773a15e0d --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/NotifyAdminDeviceApprovalRequested.html.hbs @@ -0,0 +1,23 @@ +{{#>FullHtmlLayout}} + + + + + + + +
+ {{UserNameRequestingAccess}} has sent a device approval request. Review login requests to allow the member to finish logging in. +
+
+
+ + Review request + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/NotifyAdminDeviceApprovalRequested.text.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/NotifyAdminDeviceApprovalRequested.text.hbs new file mode 100644 index 000000000000..e396546646ff --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/NotifyAdminDeviceApprovalRequested.text.hbs @@ -0,0 +1,5 @@ +{{#>BasicTextLayout}} +{{UserNameRequestingAccess}} has sent a device approval request. Review login requests to allow the member to finish logging in. + +{{Url}} +{{/BasicTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/SelfHostNotifyAdminDeviceApprovalRequested.html.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/SelfHostNotifyAdminDeviceApprovalRequested.html.hbs new file mode 100644 index 000000000000..ee7fcf8cadf7 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/SelfHostNotifyAdminDeviceApprovalRequested.html.hbs @@ -0,0 +1,29 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + +
+ {{UserNameRequestingAccess}} has sent a device approval request. Review login requests to allow the member to finish logging in. +
+ To review requests, log in to your self-hosted instance → navigate to the Admin Console → select Device Approvals +
+
+
+ + Review request + +
+
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/SelfHostNotifyAdminDeviceApprovalRequested.text.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/SelfHostNotifyAdminDeviceApprovalRequested.text.hbs new file mode 100644 index 000000000000..e5b412cc8710 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/SelfHostNotifyAdminDeviceApprovalRequested.text.hbs @@ -0,0 +1,7 @@ +{{#>BasicTextLayout}} +{{UserNameRequestingAccess}} has sent a device approval request. Review login requests to allow the member to finish logging in. + +To review requests, log in to your self-hosted instance -> navigate to the Admin Console -> select Device Approvals. + +{{Url}} +{{/BasicTextLayout}} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 558627352020..9d2e6e51e648 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; @@ -52,6 +53,7 @@ public static void AddOrganizationServices(this IServiceCollection services, IGl services.AddOrganizationLicenseCommandsQueries(); services.AddOrganizationDomainCommandsQueries(); services.AddOrganizationSignUpCommands(); + services.AddOrganizationDeleteCommands(); services.AddOrganizationAuthCommands(); services.AddOrganizationUserCommands(); services.AddOrganizationUserCommandsQueries(); @@ -61,6 +63,12 @@ public static void AddOrganizationServices(this IServiceCollection services, IGl private static IServiceCollection AddOrganizationSignUpCommands(this IServiceCollection services) => services.AddScoped(); + private static void AddOrganizationDeleteCommands(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } + private static void AddOrganizationConnectionCommands(this IServiceCollection services) { services.AddScoped(); diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 18113920b77e..09b766e885e6 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -224,27 +224,8 @@ await _organizationUserRepository.GetManyByOrganizationAsync(organization.Id, if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { - if (_featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI)) - { - var sale = OrganizationSale.From(organization, upgrade); - await _organizationBillingService.Finalize(sale); - } - else - { - try - { - paymentIntentClientSecret = await _paymentService.UpgradeFreeOrganizationAsync(organization, - newPlan, upgrade); - success = string.IsNullOrWhiteSpace(paymentIntentClientSecret); - } - catch - { - await _paymentService.CancelAndRecoverChargesAsync(organization); - organization.GatewayCustomerId = null; - await _organizationService.ReplaceAndUpdateCacheAsync(organization); - throw; - } - } + var sale = OrganizationSale.From(organization, upgrade); + await _organizationBillingService.Finalize(sale); } else { diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 09bae38a03d2..77914c0188ee 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -96,5 +96,6 @@ Task SendProviderUpdatePaymentMethod( Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId, string organizationName); Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList); + Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName); } diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index c4ca48d3a3ed..630c5b0bf04a 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -2,6 +2,7 @@ using System.Reflection; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Mail; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Mail; using Bit.Core.Billing.Enums; @@ -1168,6 +1169,23 @@ public async Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string e await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName) + { + var templateName = _globalSettings.SelfHosted ? + "AdminConsole.SelfHostNotifyAdminDeviceApprovalRequested" : + "AdminConsole.NotifyAdminDeviceApprovalRequested"; + var message = CreateDefaultMessage("Review SSO login request for new device", adminEmails); + var model = new DeviceApprovalRequestedViewModel + { + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + UserNameRequestingAccess = GetUserIdentifier(email, userName), + OrganizationId = organizationId, + }; + await AddMessageContentAsync(message, templateName, model); + message.Category = "DeviceApprovalRequested"; + 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/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 1dd8c3f8caf7..157bfd3a6e80 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -933,18 +933,8 @@ public async Task> SignUpPremiumAsync(User user, string paym } else { - var deprecateStripeSourcesAPI = _featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI); - - if (deprecateStripeSourcesAPI) - { - var sale = PremiumUserSale.From(user, paymentMethodType, paymentToken, taxInfo, additionalStorageGb); - await _premiumUserBillingService.Finalize(sale); - } - else - { - paymentIntentClientSecret = await _paymentService.PurchasePremiumAsync(user, paymentMethodType, - paymentToken, additionalStorageGb, taxInfo); - } + var sale = PremiumUserSale.From(user, paymentMethodType, paymentToken, taxInfo, additionalStorageGb); + await _premiumUserBillingService.Finalize(sale); } user.Premium = true; diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 0e07436fd112..13914ddd8653 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -316,5 +316,10 @@ public Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, return Task.FromResult(0); } public Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList) => Task.CompletedTask; + + public Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName) + { + return Task.FromResult(0); + } } diff --git a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs new file mode 100644 index 000000000000..646121db52cb --- /dev/null +++ b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs @@ -0,0 +1,199 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.ImportFeatures.Interfaces; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; + +namespace Bit.Core.Tools.ImportFeatures; + +public class ImportCiphersCommand : IImportCiphersCommand +{ + private readonly ICipherRepository _cipherRepository; + private readonly IFolderRepository _folderRepository; + private readonly IPushNotificationService _pushService; + private readonly IPolicyService _policyService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly ICollectionRepository _collectionRepository; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; + + + public ImportCiphersCommand( + ICipherRepository cipherRepository, + IFolderRepository folderRepository, + ICollectionRepository collectionRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IPushNotificationService pushService, + IPolicyService policyService, + IReferenceEventService referenceEventService, + ICurrentContext currentContext) + { + _cipherRepository = cipherRepository; + _folderRepository = folderRepository; + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _collectionRepository = collectionRepository; + _pushService = pushService; + _policyService = policyService; + _referenceEventService = referenceEventService; + _currentContext = currentContext; + } + + + public async Task ImportIntoIndividualVaultAsync( + List folders, + List ciphers, + IEnumerable> folderRelationships) + { + var userId = folders.FirstOrDefault()?.UserId ?? ciphers.FirstOrDefault()?.UserId; + + // Make sure the user can save new ciphers to their personal vault + var anyPersonalOwnershipPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(userId.Value, PolicyType.PersonalOwnership); + if (anyPersonalOwnershipPolicies) + { + throw new BadRequestException("You cannot import items into your personal vault because you are " + + "a member of an organization which forbids it."); + } + + foreach (var cipher in ciphers) + { + cipher.SetNewId(); + + if (cipher.UserId.HasValue && cipher.Favorite) + { + cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":\"true\"}}"; + } + } + + var userfoldersIds = (await _folderRepository.GetManyByUserIdAsync(userId ?? Guid.Empty)).Select(f => f.Id).ToList(); + + //Assign id to the ones that don't exist in DB + //Need to keep the list order to create the relationships + List newFolders = new List(); + foreach (var folder in folders) + { + if (!userfoldersIds.Contains(folder.Id)) + { + folder.SetNewId(); + newFolders.Add(folder); + } + } + + // Create the folder associations based on the newly created folder ids + foreach (var relationship in folderRelationships) + { + var cipher = ciphers.ElementAtOrDefault(relationship.Key); + var folder = folders.ElementAtOrDefault(relationship.Value); + + if (cipher == null || folder == null) + { + continue; + } + + cipher.Folders = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":" + + $"\"{folder.Id.ToString().ToUpperInvariant()}\"}}"; + } + + // Create it all + await _cipherRepository.CreateAsync(ciphers, newFolders); + + // push + if (userId.HasValue) + { + await _pushService.PushSyncVaultAsync(userId.Value); + } + } + + public async Task ImportIntoOrganizationalVaultAsync( + List collections, + List ciphers, + IEnumerable> collectionRelationships, + Guid importingUserId) + { + var org = collections.Count > 0 ? + await _organizationRepository.GetByIdAsync(collections[0].OrganizationId) : + await _organizationRepository.GetByIdAsync(ciphers.FirstOrDefault(c => c.OrganizationId.HasValue).OrganizationId.Value); + var importingOrgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, importingUserId); + + if (collections.Count > 0 && org != null && org.MaxCollections.HasValue) + { + var collectionCount = await _collectionRepository.GetCountByOrganizationIdAsync(org.Id); + if (org.MaxCollections.Value < (collectionCount + collections.Count)) + { + throw new BadRequestException("This organization can only have a maximum of " + + $"{org.MaxCollections.Value} collections."); + } + } + + // Init. ids for ciphers + foreach (var cipher in ciphers) + { + cipher.SetNewId(); + } + + var organizationCollectionsIds = (await _collectionRepository.GetManyByOrganizationIdAsync(org.Id)).Select(c => c.Id).ToList(); + + //Assign id to the ones that don't exist in DB + //Need to keep the list order to create the relationships + var newCollections = new List(); + var newCollectionUsers = new List(); + + foreach (var collection in collections) + { + if (!organizationCollectionsIds.Contains(collection.Id)) + { + collection.SetNewId(); + newCollections.Add(collection); + newCollectionUsers.Add(new CollectionUser + { + CollectionId = collection.Id, + OrganizationUserId = importingOrgUser.Id, + Manage = true + }); + } + } + + // Create associations based on the newly assigned ids + var collectionCiphers = new List(); + foreach (var relationship in collectionRelationships) + { + var cipher = ciphers.ElementAtOrDefault(relationship.Key); + var collection = collections.ElementAtOrDefault(relationship.Value); + + if (cipher == null || collection == null) + { + continue; + } + + collectionCiphers.Add(new CollectionCipher + { + CipherId = cipher.Id, + CollectionId = collection.Id + }); + } + + // Create it all + await _cipherRepository.CreateAsync(ciphers, newCollections, collectionCiphers, newCollectionUsers); + + // push + await _pushService.PushSyncVaultAsync(importingUserId); + + + if (org != null) + { + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.VaultImported, org, _currentContext)); + } + } +} diff --git a/src/Core/Tools/ImportFeatures/ImportServiceCollectionExtension.cs b/src/Core/Tools/ImportFeatures/ImportServiceCollectionExtension.cs new file mode 100644 index 000000000000..38c88d79943f --- /dev/null +++ b/src/Core/Tools/ImportFeatures/ImportServiceCollectionExtension.cs @@ -0,0 +1,12 @@ +using Bit.Core.Tools.ImportFeatures.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Tools.ImportFeatures; + +public static class ImportServiceCollectionExtension +{ + public static void AddImportServices(this IServiceCollection services) + { + services.AddScoped(); + } +} diff --git a/src/Core/Tools/ImportFeatures/Interfaces/IImportCiphersCommand.cs b/src/Core/Tools/ImportFeatures/Interfaces/IImportCiphersCommand.cs new file mode 100644 index 000000000000..378024d3a059 --- /dev/null +++ b/src/Core/Tools/ImportFeatures/Interfaces/IImportCiphersCommand.cs @@ -0,0 +1,14 @@ +using Bit.Core.Entities; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.Tools.ImportFeatures.Interfaces; + +public interface IImportCiphersCommand +{ + Task ImportIntoIndividualVaultAsync(List folders, List ciphers, + IEnumerable> folderRelationships); + + Task ImportIntoOrganizationalVaultAsync(List collections, List ciphers, + IEnumerable> collectionRelationships, Guid importingUserId); +} diff --git a/src/Core/Vault/Services/ICipherService.cs b/src/Core/Vault/Services/ICipherService.cs index 27b84e4a4786..b332fbb96fee 100644 --- a/src/Core/Vault/Services/ICipherService.cs +++ b/src/Core/Vault/Services/ICipherService.cs @@ -1,5 +1,4 @@ -using Bit.Core.Entities; -using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; namespace Bit.Core.Vault.Services; @@ -28,10 +27,6 @@ Task ShareAsync(Cipher originalCipher, Cipher cipher, Guid organizationId, IEnum Task ShareManyAsync(IEnumerable<(Cipher cipher, DateTime? lastKnownRevisionDate)> ciphers, Guid organizationId, IEnumerable collectionIds, Guid sharingUserId); Task SaveCollectionsAsync(Cipher cipher, IEnumerable collectionIds, Guid savingUserId, bool orgAdmin); - Task ImportCiphersAsync(List folders, List ciphers, - IEnumerable> folderRelationships); - Task ImportCiphersAsync(List collections, List ciphers, - IEnumerable> collectionRelationships, Guid importingUserId); Task SoftDeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false); Task SoftDeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false); Task RestoreAsync(Cipher cipher, Guid restoringUserId, bool orgAdmin = false); diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 196ec6ef3d70..ea4db01fd77b 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -2,7 +2,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Services; using Bit.Core.Context; -using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Platform.Push; @@ -679,152 +678,6 @@ await _collectionCipherRepository.UpdateCollectionsForAdminAsync(cipher.Id, await _pushService.PushSyncCipherUpdateAsync(cipher, collectionIds); } - public async Task ImportCiphersAsync( - List folders, - List ciphers, - IEnumerable> folderRelationships) - { - var userId = folders.FirstOrDefault()?.UserId ?? ciphers.FirstOrDefault()?.UserId; - - // Make sure the user can save new ciphers to their personal vault - var anyPersonalOwnershipPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(userId.Value, PolicyType.PersonalOwnership); - if (anyPersonalOwnershipPolicies) - { - throw new BadRequestException("You cannot import items into your personal vault because you are " + - "a member of an organization which forbids it."); - } - - foreach (var cipher in ciphers) - { - cipher.SetNewId(); - - if (cipher.UserId.HasValue && cipher.Favorite) - { - cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":\"true\"}}"; - } - } - - var userfoldersIds = (await _folderRepository.GetManyByUserIdAsync(userId ?? Guid.Empty)).Select(f => f.Id).ToList(); - - //Assign id to the ones that don't exist in DB - //Need to keep the list order to create the relationships - List newFolders = new List(); - foreach (var folder in folders) - { - if (!userfoldersIds.Contains(folder.Id)) - { - folder.SetNewId(); - newFolders.Add(folder); - } - } - - // Create the folder associations based on the newly created folder ids - foreach (var relationship in folderRelationships) - { - var cipher = ciphers.ElementAtOrDefault(relationship.Key); - var folder = folders.ElementAtOrDefault(relationship.Value); - - if (cipher == null || folder == null) - { - continue; - } - - cipher.Folders = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":" + - $"\"{folder.Id.ToString().ToUpperInvariant()}\"}}"; - } - - // Create it all - await _cipherRepository.CreateAsync(ciphers, newFolders); - - // push - if (userId.HasValue) - { - await _pushService.PushSyncVaultAsync(userId.Value); - } - } - - public async Task ImportCiphersAsync( - List collections, - List ciphers, - IEnumerable> collectionRelationships, - Guid importingUserId) - { - var org = collections.Count > 0 ? - await _organizationRepository.GetByIdAsync(collections[0].OrganizationId) : - await _organizationRepository.GetByIdAsync(ciphers.FirstOrDefault(c => c.OrganizationId.HasValue).OrganizationId.Value); - var importingOrgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, importingUserId); - - if (collections.Count > 0 && org != null && org.MaxCollections.HasValue) - { - var collectionCount = await _collectionRepository.GetCountByOrganizationIdAsync(org.Id); - if (org.MaxCollections.Value < (collectionCount + collections.Count)) - { - throw new BadRequestException("This organization can only have a maximum of " + - $"{org.MaxCollections.Value} collections."); - } - } - - // Init. ids for ciphers - foreach (var cipher in ciphers) - { - cipher.SetNewId(); - } - - var organizationCollectionsIds = (await _collectionRepository.GetManyByOrganizationIdAsync(org.Id)).Select(c => c.Id).ToList(); - - //Assign id to the ones that don't exist in DB - //Need to keep the list order to create the relationships - var newCollections = new List(); - var newCollectionUsers = new List(); - - foreach (var collection in collections) - { - if (!organizationCollectionsIds.Contains(collection.Id)) - { - collection.SetNewId(); - newCollections.Add(collection); - newCollectionUsers.Add(new CollectionUser - { - CollectionId = collection.Id, - OrganizationUserId = importingOrgUser.Id, - Manage = true - }); - } - } - - // Create associations based on the newly assigned ids - var collectionCiphers = new List(); - foreach (var relationship in collectionRelationships) - { - var cipher = ciphers.ElementAtOrDefault(relationship.Key); - var collection = collections.ElementAtOrDefault(relationship.Value); - - if (cipher == null || collection == null) - { - continue; - } - - collectionCiphers.Add(new CollectionCipher - { - CipherId = cipher.Id, - CollectionId = collection.Id - }); - } - - // Create it all - await _cipherRepository.CreateAsync(ciphers, newCollections, collectionCiphers, newCollectionUsers); - - // push - await _pushService.PushSyncVaultAsync(importingUserId); - - - if (org != null) - { - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.VaultImported, org, _currentContext)); - } - } - public async Task SoftDeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false) { if (!orgAdmin && !(await UserCanEditAsync(cipher, deletingUserId))) diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 63c114405fb5..e1369d5366c4 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -40,6 +40,7 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; +using Bit.Core.Tools.ImportFeatures; using Bit.Core.Tools.ReportFeatures; using Bit.Core.Tools.Services; using Bit.Core.Utilities; @@ -128,6 +129,7 @@ public static void AddBaseServices(this IServiceCollection services, IGlobalSett services.AddKeyManagementServices(); services.AddNotificationCenterServices(); services.AddPlatformServices(); + services.AddImportServices(); } public static void AddTokenizers(this IServiceCollection services) diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index dd514803fe44..9370948a8565 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -36,7 +36,13 @@ public static async Task> SignUpAsync(W OwnerKey = ownerKey, Owner = owner, AdditionalSeats = passwordManagerSeats, - PaymentMethodType = paymentMethod + PaymentMethodType = paymentMethod, + PaymentToken = "TOKEN", + TaxInfo = new TaxInfo + { + BillingAddressCountry = "US", + BillingAddressPostalCode = "12345" + } }); Debug.Assert(signUpResult.OrganizationUser is not null); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index b0b87e9371c6..b0906ddc43e1 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; @@ -53,6 +54,7 @@ public class OrganizationsControllerTests : IDisposable private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; + private readonly IOrganizationDeleteCommand _organizationDeleteCommand; private readonly IPricingClient _pricingClient; private readonly OrganizationsController _sut; @@ -77,6 +79,7 @@ public OrganizationsControllerTests() _orgDeleteTokenDataFactory = Substitute.For>(); _removeOrganizationUserCommand = Substitute.For(); _cloudOrganizationSignUpCommand = Substitute.For(); + _organizationDeleteCommand = Substitute.For(); _pricingClient = Substitute.For(); _sut = new OrganizationsController( @@ -99,6 +102,7 @@ public OrganizationsControllerTests() _orgDeleteTokenDataFactory, _removeOrganizationUserCommand, _cloudOrganizationSignUpCommand, + _organizationDeleteCommand, _pricingClient); } @@ -230,6 +234,6 @@ public async Task Delete_OrganizationIsConsolidatedBillingClient_ScalesProviders await _providerBillingService.Received(1) .ScaleSeats(provider, organization.PlanType, -organization.Seats.Value); - await _organizationService.Received(1).DeleteAsync(organization); + await _organizationDeleteCommand.Received(1).DeleteAsync(organization); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommandTests.cs new file mode 100644 index 000000000000..0a83bb89d88e --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommandTests.cs @@ -0,0 +1,53 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations; + +[SutProviderCustomize] +public class OrganizationDeleteCommandTests +{ + [Theory, PaidOrganizationCustomize, BitAutoData] + public async Task Delete_Success(Organization organization, SutProvider sutProvider) + { + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + await sutProvider.Sut.DeleteAsync(organization); + + await organizationRepository.Received().DeleteAsync(organization); + await applicationCacheService.Received().DeleteOrganizationAbilityAsync(organization.Id); + } + + [Theory, PaidOrganizationCustomize, BitAutoData] + public async Task Delete_Fails_KeyConnector(Organization organization, SutProvider sutProvider, + SsoConfig ssoConfig) + { + ssoConfig.Enabled = true; + ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector }); + var ssoConfigRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + ssoConfigRepository.GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(organization)); + + Assert.Contains("You cannot delete an Organization that is using Key Connector.", exception.Message); + + await organizationRepository.DidNotReceiveWithAnyArgs().DeleteAsync(default); + await applicationCacheService.DidNotReceiveWithAnyArgs().DeleteOrganizationAbilityAsync(default); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationInitiateDeleteCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationInitiateDeleteCommandTests.cs new file mode 100644 index 000000000000..41c5b569d479 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationInitiateDeleteCommandTests.cs @@ -0,0 +1,112 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tokens; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations; + +[SutProviderCustomize] +public class OrganizationInitiateDeleteCommandTests +{ + [Theory] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task InitiateDeleteAsync_ValidAdminUser_Success(OrganizationUserType organizationUserType, + Organization organization, User orgAdmin, OrganizationUserOrganizationDetails orgAdminUser, + string token, SutProvider sutProvider) + { + orgAdminUser.Type = organizationUserType; + orgAdminUser.Status = OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency() + .GetByEmailAsync(orgAdmin.Email) + .Returns(orgAdmin); + + sutProvider.GetDependency() + .GetDetailsByUserAsync(orgAdmin.Id, organization.Id) + .Returns(orgAdminUser); + + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns(token); + + await sutProvider.Sut.InitiateDeleteAsync(organization, orgAdmin.Email); + + await sutProvider.GetDependency().Received(1) + .SendInitiateDeleteOrganzationEmailAsync(orgAdmin.Email, organization, token); + } + + [Theory, BitAutoData] + public async Task InitiateDeleteAsync_UserNotFound_ThrowsBadRequest( + Organization organization, string email, SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByEmailAsync(email) + .Returns((User)null); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.InitiateDeleteAsync(organization, email)); + + Assert.Equal(OrganizationInitiateDeleteCommand.OrganizationAdminNotFoundErrorMessage, exception.Message); + } + + [Theory] + [BitAutoData(OrganizationUserType.User)] + [BitAutoData(OrganizationUserType.Custom)] + public async Task InitiateDeleteAsync_UserNotOrgAdmin_ThrowsBadRequest(OrganizationUserType organizationUserType, + Organization organization, User user, OrganizationUserOrganizationDetails orgUser, + SutProvider sutProvider) + { + orgUser.Type = organizationUserType; + orgUser.Status = OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency() + .GetByEmailAsync(user.Email) + .Returns(user); + + sutProvider.GetDependency() + .GetDetailsByUserAsync(user.Id, organization.Id) + .Returns(orgUser); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.InitiateDeleteAsync(organization, user.Email)); + + Assert.Equal(OrganizationInitiateDeleteCommand.OrganizationAdminNotFoundErrorMessage, exception.Message); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Invited)] + [BitAutoData(OrganizationUserStatusType.Revoked)] + [BitAutoData(OrganizationUserStatusType.Accepted)] + public async Task InitiateDeleteAsync_UserNotConfirmed_ThrowsBadRequest( + OrganizationUserStatusType organizationUserStatusType, + Organization organization, User user, OrganizationUserOrganizationDetails orgUser, + SutProvider sutProvider) + { + orgUser.Type = OrganizationUserType.Admin; + orgUser.Status = organizationUserStatusType; + + sutProvider.GetDependency() + .GetByEmailAsync(user.Email) + .Returns(user); + + sutProvider.GetDependency() + .GetDetailsByUserAsync(user.Id, organization.Id) + .Returns(orgUser); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.InitiateDeleteAsync(organization, user.Email)); + + Assert.Equal(OrganizationInitiateDeleteCommand.OrganizationAdminNotFoundErrorMessage, exception.Message); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs index 117af83d195b..a3392a0313d1 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs @@ -1,15 +1,15 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Services; using Bit.Core.Billing.Pricing; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data; -using Bit.Core.Models.StaticStore; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; @@ -63,20 +63,16 @@ await sutProvider.GetDependency().Received(1) Assert.NotNull(result.Organization); Assert.NotNull(result.OrganizationUser); - await sutProvider.GetDependency().Received(1).PurchaseOrganizationAsync( - Arg.Any(), - signup.PaymentMethodType.Value, - signup.PaymentToken, - plan, - signup.AdditionalStorageGb, - signup.AdditionalSeats, - signup.PremiumAccessAddon, - signup.TaxInfo, - false, - signup.AdditionalSmSeats.GetValueOrDefault(), - signup.AdditionalServiceAccounts.GetValueOrDefault(), - signup.UseSecretsManager - ); + await sutProvider.GetDependency().Received(1).Finalize( + Arg.Is(sale => + sale.CustomerSetup.TokenizedPaymentSource.Type == signup.PaymentMethodType.Value && + sale.CustomerSetup.TokenizedPaymentSource.Token == signup.PaymentToken && + sale.CustomerSetup.TaxInformation.Country == signup.TaxInfo.BillingAddressCountry && + sale.CustomerSetup.TaxInformation.PostalCode == signup.TaxInfo.BillingAddressPostalCode && + sale.SubscriptionSetup.PlanType == plan.Type && + sale.SubscriptionSetup.PasswordManagerOptions.Seats == signup.AdditionalSeats && + sale.SubscriptionSetup.PasswordManagerOptions.Storage == signup.AdditionalStorageGb && + sale.SubscriptionSetup.SecretsManagerOptions == null)); } [Theory] @@ -161,20 +157,17 @@ await sutProvider.GetDependency().Received(1) Assert.NotNull(result.Organization); Assert.NotNull(result.OrganizationUser); - await sutProvider.GetDependency().Received(1).PurchaseOrganizationAsync( - Arg.Any(), - signup.PaymentMethodType.Value, - signup.PaymentToken, - Arg.Is(plan), - signup.AdditionalStorageGb, - signup.AdditionalSeats, - signup.PremiumAccessAddon, - signup.TaxInfo, - false, - signup.AdditionalSmSeats.GetValueOrDefault(), - signup.AdditionalServiceAccounts.GetValueOrDefault(), - signup.IsFromSecretsManagerTrial - ); + await sutProvider.GetDependency().Received(1).Finalize( + Arg.Is(sale => + sale.CustomerSetup.TokenizedPaymentSource.Type == signup.PaymentMethodType.Value && + sale.CustomerSetup.TokenizedPaymentSource.Token == signup.PaymentToken && + sale.CustomerSetup.TaxInformation.Country == signup.TaxInfo.BillingAddressCountry && + sale.CustomerSetup.TaxInformation.PostalCode == signup.TaxInfo.BillingAddressPostalCode && + sale.SubscriptionSetup.PlanType == plan.Type && + sale.SubscriptionSetup.PasswordManagerOptions.Seats == signup.AdditionalSeats && + sale.SubscriptionSetup.PasswordManagerOptions.Storage == signup.AdditionalStorageGb && + sale.SubscriptionSetup.SecretsManagerOptions.Seats == signup.AdditionalSmSeats && + sale.SubscriptionSetup.SecretsManagerOptions.ServiceAccounts == signup.AdditionalServiceAccounts)); } [Theory] @@ -270,6 +263,8 @@ public async Task SignUpAsync_Free_ExistingFreeOrgAdmin_ThrowsBadRequest( Owner = new User { Id = Guid.NewGuid() } }; + sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(StaticStore.GetPlan(signup.Plan)); + sutProvider.GetDependency() .GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id) .Returns(1); diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index f5415cd752a8..6eef144e7569 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -6,9 +6,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; -using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Business.Tokenables; -using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; @@ -1441,39 +1439,6 @@ public async Task CanScaleAsync_FailsOnResellerManagedOrganization( Assert.Contains("Seat limit has been reached. Contact your provider to purchase additional seats.", failureMessage); } - [Theory, PaidOrganizationCustomize, BitAutoData] - public async Task Delete_Success(Organization organization, SutProvider sutProvider) - { - var organizationRepository = sutProvider.GetDependency(); - var applicationCacheService = sutProvider.GetDependency(); - - await sutProvider.Sut.DeleteAsync(organization); - - await organizationRepository.Received().DeleteAsync(organization); - await applicationCacheService.Received().DeleteOrganizationAbilityAsync(organization.Id); - } - - [Theory, PaidOrganizationCustomize, BitAutoData] - public async Task Delete_Fails_KeyConnector(Organization organization, SutProvider sutProvider, - SsoConfig ssoConfig) - { - ssoConfig.Enabled = true; - ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector }); - var ssoConfigRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var applicationCacheService = sutProvider.GetDependency(); - - ssoConfigRepository.GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.DeleteAsync(organization)); - - Assert.Contains("You cannot delete an Organization that is using Key Connector.", exception.Message); - - await organizationRepository.DidNotReceiveWithAnyArgs().DeleteAsync(default); - await applicationCacheService.DidNotReceiveWithAnyArgs().DeleteOrganizationAbilityAsync(default); - } - private void RestoreRevokeUser_Setup( Organization organization, OrganizationUser? requestingOrganizationUser, diff --git a/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs b/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs index 4e42125dce95..3894ac90a8d8 100644 --- a/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs +++ b/test/Core.Test/Auth/Services/AuthRequestServiceTests.cs @@ -7,6 +7,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -227,6 +228,14 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .Received() .CreateAsync(createdAuthRequest); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendDeviceApprovalRequestedNotificationEmailAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()); } /// @@ -321,6 +330,115 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .Received(1) .LogUserEventAsync(user.Id, EventType.User_RequestedDeviceApproval); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SendDeviceApprovalRequestedNotificationEmailAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateAuthRequestAsync_AdminApproval_WithAdminNotifications_CreatesForEachOrganization_SendsEmails( + SutProvider sutProvider, + AuthRequestCreateRequestModel createModel, + User user, + OrganizationUser organizationUser1, + OrganizationUserUserDetails admin1, + OrganizationUser organizationUser2, + OrganizationUserUserDetails admin2, + OrganizationUserUserDetails admin3) + { + createModel.Type = AuthRequestType.AdminApproval; + user.Email = createModel.Email; + organizationUser1.UserId = user.Id; + organizationUser2.UserId = user.Id; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications) + .Returns(true); + + sutProvider.GetDependency() + .GetByEmailAsync(user.Email) + .Returns(user); + + sutProvider.GetDependency() + .DeviceType + .Returns(DeviceType.ChromeExtension); + + sutProvider.GetDependency() + .UserId + .Returns(user.Id); + + sutProvider.GetDependency() + .PasswordlessAuth.KnownDevicesOnly + .Returns(false); + + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns(new List + { + organizationUser1, + organizationUser2, + }); + + sutProvider.GetDependency() + .GetManyByMinimumRoleAsync(organizationUser1.OrganizationId, OrganizationUserType.Admin) + .Returns( + [ + admin1, + ]); + + sutProvider.GetDependency() + .GetManyByMinimumRoleAsync(organizationUser2.OrganizationId, OrganizationUserType.Admin) + .Returns( + [ + admin2, + admin3, + ]); + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(c => c.ArgAt(0)); + + var authRequest = await sutProvider.Sut.CreateAuthRequestAsync(createModel); + + Assert.Equal(organizationUser1.OrganizationId, authRequest.OrganizationId); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(o => o.OrganizationId == organizationUser1.OrganizationId)); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(o => o.OrganizationId == organizationUser2.OrganizationId)); + + await sutProvider.GetDependency() + .Received(2) + .CreateAsync(Arg.Any()); + + await sutProvider.GetDependency() + .Received(1) + .LogUserEventAsync(user.Id, EventType.User_RequestedDeviceApproval); + + await sutProvider.GetDependency() + .Received(1) + .SendDeviceApprovalRequestedNotificationEmailAsync( + Arg.Is>(emails => emails.Count() == 1 && emails.Contains(admin1.Email)), + organizationUser1.OrganizationId, + user.Email, + user.Name); + + await sutProvider.GetDependency() + .Received(1) + .SendDeviceApprovalRequestedNotificationEmailAsync( + Arg.Is>(emails => emails.Count() == 2 && emails.Contains(admin2.Email) && emails.Contains(admin3.Email)), + organizationUser2.OrganizationId, + user.Email, + user.Name); } /// diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs index 17d7810f0cc7..8bcee1e8c605 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs @@ -151,7 +151,7 @@ await sutProvider.GetDependency().Received(1).ReplaceAndUp && o.SmServiceAccounts == plan.SecretsManager.BaseServiceAccount + upgrade.AdditionalServiceAccounts)); Assert.True(result.Item1); - Assert.NotNull(result.Item2); + Assert.Null(result.Item2); } [Theory, FreeOrganizationUpgradeCustomize] diff --git a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs new file mode 100644 index 000000000000..1e978562817b --- /dev/null +++ b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs @@ -0,0 +1,181 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Test.AutoFixture.CipherFixtures; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.ImportFeatures; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + + +namespace Bit.Core.Test.Tools.ImportFeatures; + +[UserCipherCustomize] +[SutProviderCustomize] +public class ImportCiphersAsyncCommandTests +{ + [Theory, BitAutoData] + public async Task ImportIntoIndividualVaultAsync_Success( + Guid importingUserId, + List ciphers, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.PersonalOwnership) + .Returns(false); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(importingUserId) + .Returns(new List()); + + var folders = new List { new Folder { UserId = importingUserId } }; + + var folderRelationships = new List>(); + + // Act + await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships); + + // Assert + await sutProvider.GetDependency().Received(1).CreateAsync(ciphers, Arg.Any>()); + await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); + } + + [Theory, BitAutoData] + public async Task ImportIntoIndividualVaultAsync_ThrowsBadRequestException( + List folders, + List ciphers, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + folders.ForEach(f => f.UserId = userId); + ciphers.ForEach(c => c.UserId = userId); + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(userId, PolicyType.PersonalOwnership) + .Returns(true); + + var folderRelationships = new List>(); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships)); + + Assert.Equal("You cannot import items into your personal vault because you are a member of an organization which forbids it.", exception.Message); + } + + [Theory, BitAutoData] + public async Task ImportIntoOrganizationalVaultAsync_Success( + Organization organization, + Guid importingUserId, + OrganizationUser importingOrganizationUser, + List collections, + List ciphers, + SutProvider sutProvider) + { + organization.MaxCollections = null; + importingOrganizationUser.OrganizationId = organization.Id; + + foreach (var collection in collections) + { + collection.OrganizationId = organization.Id; + } + + foreach (var cipher in ciphers) + { + cipher.OrganizationId = organization.Id; + } + + KeyValuePair[] collectionRelationships = { + new(0, 0), + new(1, 1), + new(2, 2) + }; + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .GetByOrganizationAsync(organization.Id, importingUserId) + .Returns(importingOrganizationUser); + + // Set up a collection that already exists in the organization + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organization.Id) + .Returns(new List { collections[0] }); + + await sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId); + + await sutProvider.GetDependency().Received(1).CreateAsync( + ciphers, + Arg.Is>(cols => cols.Count() == collections.Count - 1 && + !cols.Any(c => c.Id == collections[0].Id) && // Check that the collection that already existed in the organization was not added + cols.All(c => collections.Any(x => c.Name == x.Name))), + Arg.Is>(c => c.Count() == ciphers.Count), + Arg.Is>(cus => + cus.Count() == collections.Count - 1 && + !cus.Any(cu => cu.CollectionId == collections[0].Id) && // Check that access was not added for the collection that already existed in the organization + cus.All(cu => cu.OrganizationUserId == importingOrganizationUser.Id && cu.Manage == true))); + await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); + await sutProvider.GetDependency().Received(1).RaiseEventAsync( + Arg.Is(e => e.Type == ReferenceEventType.VaultImported)); + } + + [Theory, BitAutoData] + public async Task ImportIntoOrganizationalVaultAsync_ThrowsBadRequestException( + Organization organization, + Guid importingUserId, + OrganizationUser importingOrganizationUser, + List collections, + List ciphers, + SutProvider sutProvider) + { + organization.MaxCollections = 1; + importingOrganizationUser.OrganizationId = organization.Id; + + foreach (var collection in collections) + { + collection.OrganizationId = organization.Id; + } + + foreach (var cipher in ciphers) + { + cipher.OrganizationId = organization.Id; + } + + KeyValuePair[] collectionRelationships = { + new(0, 0), + new(1, 1), + new(2, 2) + }; + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .GetByOrganizationAsync(organization.Id, importingUserId) + .Returns(importingOrganizationUser); + + // Set up a collection that already exists in the organization + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organization.Id) + .Returns(new List { collections[0] }); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId)); + + Assert.Equal("This organization can only have a maximum of " + + $"{organization.MaxCollections} collections.", exception.Message); + } +} diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index dd34127efe5f..4f02d94c9ccd 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -7,9 +7,6 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.AutoFixture.CipherFixtures; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; @@ -26,64 +23,6 @@ namespace Bit.Core.Test.Services; [SutProviderCustomize] public class CipherServiceTests { - [Theory, BitAutoData] - public async Task ImportCiphersAsync_IntoOrganization_Success( - Organization organization, - Guid importingUserId, - OrganizationUser importingOrganizationUser, - List collections, - List ciphers, - SutProvider sutProvider) - { - organization.MaxCollections = null; - importingOrganizationUser.OrganizationId = organization.Id; - - foreach (var collection in collections) - { - collection.OrganizationId = organization.Id; - } - - foreach (var cipher in ciphers) - { - cipher.OrganizationId = organization.Id; - } - - KeyValuePair[] collectionRelationships = { - new(0, 0), - new(1, 1), - new(2, 2) - }; - - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - - sutProvider.GetDependency() - .GetByOrganizationAsync(organization.Id, importingUserId) - .Returns(importingOrganizationUser); - - // Set up a collection that already exists in the organization - sutProvider.GetDependency() - .GetManyByOrganizationIdAsync(organization.Id) - .Returns(new List { collections[0] }); - - await sutProvider.Sut.ImportCiphersAsync(collections, ciphers, collectionRelationships, importingUserId); - - await sutProvider.GetDependency().Received(1).CreateAsync( - ciphers, - Arg.Is>(cols => cols.Count() == collections.Count - 1 && - !cols.Any(c => c.Id == collections[0].Id) && // Check that the collection that already existed in the organization was not added - cols.All(c => collections.Any(x => c.Name == x.Name))), - Arg.Is>(c => c.Count() == ciphers.Count), - Arg.Is>(cus => - cus.Count() == collections.Count - 1 && - !cus.Any(cu => cu.CollectionId == collections[0].Id) && // Check that access was not added for the collection that already existed in the organization - cus.All(cu => cu.OrganizationUserId == importingOrganizationUser.Id && cu.Manage == true))); - await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); - await sutProvider.GetDependency().Received(1).RaiseEventAsync( - Arg.Is(e => e.Type == ReferenceEventType.VaultImported)); - } - [Theory, BitAutoData] public async Task SaveAsync_WrongRevisionDate_Throws(SutProvider sutProvider, Cipher cipher) { diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index 9474ffb8629a..d01e92ad4c68 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -1,5 +1,6 @@ using AspNetCoreRateLimit; using Bit.Core.Auth.Services; +using Bit.Core.Billing.Services; using Bit.Core.Platform.Push; using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; @@ -247,6 +248,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) var stripePaymentService = services.First(sd => sd.ServiceType == typeof(IPaymentService)); services.Remove(stripePaymentService); services.AddSingleton(Substitute.For()); + + var organizationBillingService = + services.First(sd => sd.ServiceType == typeof(IOrganizationBillingService)); + services.Remove(organizationBillingService); + services.AddSingleton(Substitute.For()); }); foreach (var configureTestService in _configureTestServices)