diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5077f1ba32..19eea71b6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -636,7 +636,9 @@ jobs: setup-ephemeral-environment: name: Setup Ephemeral Environment - needs: build-docker + needs: + - build-artifacts + - build-docker if: | needs.build-artifacts.outputs.has_secrets == 'true' && github.event_name == 'pull_request' diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs index 36a5f2c0a9..8dee75c7c2 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs @@ -3,9 +3,9 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 35a00f4253..4af0e12e64 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 2fc44937a7..ad2d2d2aa1 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -5,12 +5,13 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -53,6 +54,7 @@ public class ProviderService : IProviderService private readonly IApplicationCacheService _applicationCacheService; private readonly IProviderBillingService _providerBillingService; private readonly IPricingClient _pricingClient; + private readonly IProviderClientOrganizationSignUpCommand _providerClientOrganizationSignUpCommand; public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository, @@ -61,7 +63,8 @@ public class ProviderService : IProviderService IOrganizationRepository organizationRepository, GlobalSettings globalSettings, ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService, IDataProtectorTokenFactory providerDeleteTokenDataFactory, - IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient) + IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient, + IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand) { _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; @@ -81,6 +84,7 @@ public class ProviderService : IProviderService _applicationCacheService = applicationCacheService; _providerBillingService = providerBillingService; _pricingClient = pricingClient; + _providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand; } public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) @@ -560,12 +564,12 @@ public class ProviderService : IProviderService ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan); - var (organization, _, defaultCollection) = await _organizationService.SignupClientAsync(organizationSignup); + var signUpResponse = await _providerClientOrganizationSignUpCommand.SignUpClientOrganizationAsync(organizationSignup); var providerOrganization = new ProviderOrganization { ProviderId = providerId, - OrganizationId = organization.Id, + OrganizationId = signUpResponse.Organization.Id, Key = organizationSignup.OwnerKey, }; @@ -574,12 +578,12 @@ public class ProviderService : IProviderService // Give the owner Can Manage access over the default collection // The orgUser is not available when the org is created so we have to do it here as part of the invite - var defaultOwnerAccess = defaultCollection != null + var defaultOwnerAccess = signUpResponse.DefaultCollection != null ? [ new CollectionAccessSelection { - Id = defaultCollection.Id, + Id = signUpResponse.DefaultCollection.Id, HidePasswords = false, ReadOnly = false, Manage = true @@ -587,7 +591,7 @@ public class ProviderService : IProviderService ] : Array.Empty(); - await _organizationService.InviteUsersAsync(organization.Id, user.Id, systemUser: null, + await _organizationService.InviteUsersAsync(signUpResponse.Organization.Id, user.Id, systemUser: null, new (OrganizationUserInvite, string)[] { ( diff --git a/bitwarden_license/src/Commercial.Core/Billing/Models/ProviderClientInvoiceReportRow.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs similarity index 91% rename from bitwarden_license/src/Commercial.Core/Billing/Models/ProviderClientInvoiceReportRow.cs rename to bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs index c78e213c34..eea40577ad 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Models/ProviderClientInvoiceReportRow.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs @@ -1,8 +1,8 @@ using System.Globalization; -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; using CsvHelper.Configuration.Attributes; -namespace Bit.Commercial.Core.Billing.Models; +namespace Bit.Commercial.Core.Billing.Providers.Models; public class ProviderClientInvoiceReportRow { diff --git a/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs similarity index 98% rename from bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs rename to bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs index d27b45af4a..8f6eb07fe1 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs @@ -7,11 +7,12 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -24,7 +25,7 @@ using Microsoft.Extensions.Logging; using OneOf; using Stripe; -namespace Bit.Commercial.Core.Billing; +namespace Bit.Commercial.Core.Billing.Providers.Services; [RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)] public class BusinessUnitConverter( diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs similarity index 99% rename from bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs rename to bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index c8d6505183..8c90d778bc 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -1,5 +1,5 @@ using System.Globalization; -using Bit.Commercial.Core.Billing.Models; +using Bit.Commercial.Core.Billing.Providers.Models; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; @@ -8,14 +8,15 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; @@ -28,12 +29,11 @@ using Braintree; using CsvHelper; using Microsoft.Extensions.Logging; using Stripe; - using static Bit.Core.Billing.Utilities; using Customer = Stripe.Customer; using Subscription = Stripe.Subscription; -namespace Bit.Commercial.Core.Billing; +namespace Bit.Commercial.Core.Billing.Providers.Services; public class ProviderBillingService( IBraintreeGateway braintreeGateway, diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs similarity index 99% rename from bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs rename to bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs index a9dbb6febf..8c55d31f2c 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs @@ -6,7 +6,7 @@ using Bit.Core.Billing; using Bit.Core.Billing.Enums; using Stripe; -namespace Bit.Commercial.Core.Billing; +namespace Bit.Commercial.Core.Billing.Providers.Services; public static class ProviderPriceAdapter { diff --git a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs index 7f8c82e2c9..34f49e0ccc 100644 --- a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs @@ -1,9 +1,9 @@ using Bit.Commercial.Core.AdminConsole.Providers; using Bit.Commercial.Core.AdminConsole.Services; -using Bit.Commercial.Core.Billing; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Services; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Microsoft.Extensions.DependencyInjection; namespace Bit.Commercial.Core.Utilities; diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs index dd40d7d943..5be18116c0 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index c66acfa8ce..cb8a9e8c69 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -6,11 +6,12 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -717,8 +718,8 @@ public class ProviderServiceTests sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignupClientAsync(organizationSignup) - .Returns((organization, null as OrganizationUser, new Collection())); + sutProvider.GetDependency().SignUpClientOrganizationAsync(organizationSignup) + .Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection())); var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); @@ -755,8 +756,8 @@ public class ProviderServiceTests var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignupClientAsync(organizationSignup) - .Returns((organization, null as OrganizationUser, new Collection())); + sutProvider.GetDependency().SignUpClientOrganizationAsync(organizationSignup) + .Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection())); await Assert.ThrowsAsync(() => sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user)); @@ -782,8 +783,8 @@ public class ProviderServiceTests var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignupClientAsync(organizationSignup) - .Returns((organization, null as OrganizationUser, new Collection())); + sutProvider.GetDependency().SignUpClientOrganizationAsync(organizationSignup) + .Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection())); var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); @@ -821,8 +822,8 @@ public class ProviderServiceTests sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignupClientAsync(organizationSignup) - .Returns((organization, null as OrganizationUser, defaultCollection)); + sutProvider.GetDependency().SignUpClientOrganizationAsync(organizationSignup) + .Returns(new ProviderClientOrganizationSignUpResponse(organization, defaultCollection)); var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs similarity index 98% rename from bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs index 5d2d0a2c7c..c27d990213 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs @@ -1,16 +1,16 @@ #nullable enable using System.Text; -using Bit.Commercial.Core.Billing; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -25,7 +25,7 @@ using NSubstitute; using Stripe; using Xunit; -namespace Bit.Commercial.Core.Test.Billing; +namespace Bit.Commercial.Core.Test.Billing.Providers; public class BusinessUnitConverterTests { diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs similarity index 99% rename from bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs index 92094d026e..9af9a71cce 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Net; -using Bit.Commercial.Core.Billing; -using Bit.Commercial.Core.Billing.Models; +using Bit.Commercial.Core.Billing.Providers.Models; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; @@ -10,13 +10,13 @@ using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -40,7 +40,7 @@ using Customer = Stripe.Customer; using PaymentMethod = Stripe.PaymentMethod; using Subscription = Stripe.Subscription; -namespace Bit.Commercial.Core.Test.Billing; +namespace Bit.Commercial.Core.Test.Billing.Providers; [SutProviderCustomize] public class ProviderBillingServiceTests diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs similarity index 97% rename from bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs index 9ecb4b0511..3087d5761c 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs @@ -1,11 +1,11 @@ -using Bit.Commercial.Core.Billing; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; using Stripe; using Xunit; -namespace Bit.Commercial.Core.Test.Billing; +namespace Bit.Commercial.Core.Test.Billing.Providers; public class ProviderPriceAdapterTests { diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Tax/TaxServiceTests.cs similarity index 99% rename from bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Tax/TaxServiceTests.cs index 0a20b34818..f3164a14e0 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Tax/TaxServiceTests.cs @@ -3,7 +3,7 @@ using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; -namespace Bit.Commercial.Core.Test.Billing; +namespace Bit.Commercial.Core.Test.Billing.Tax; [SutProviderCustomize] public class TaxServiceTests diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 6eb81b5956..8cd2222dbf 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -11,7 +11,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.OrganizationConnectionConfigs; diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index dd4332358c..b4abf81ee2 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -10,13 +10,13 @@ using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; -using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index 44eebb8d7d..de9e25fa6f 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -2,8 +2,8 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Providers.Entities; using Bit.Core.Enums; using Bit.SharedWeb.Utilities; diff --git a/src/Admin/AdminConsole/Models/ProviderViewModel.cs b/src/Admin/AdminConsole/Models/ProviderViewModel.cs index bcb96df006..2d4ba5012c 100644 --- a/src/Admin/AdminConsole/Models/ProviderViewModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderViewModel.cs @@ -2,8 +2,8 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Providers.Entities; namespace Bit.Admin.AdminConsole.Models; diff --git a/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs b/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs index 2421710d41..be3a94949f 100644 --- a/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs +++ b/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs @@ -7,7 +7,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Utilities; diff --git a/src/Admin/Billing/Controllers/MigrateProvidersController.cs b/src/Admin/Billing/Controllers/MigrateProvidersController.cs index d4ef105e34..ef5ea2312e 100644 --- a/src/Admin/Billing/Controllers/MigrateProvidersController.cs +++ b/src/Admin/Billing/Controllers/MigrateProvidersController.cs @@ -1,8 +1,8 @@ using Bit.Admin.Billing.Models; using Bit.Admin.Enums; using Bit.Admin.Utilities; -using Bit.Core.Billing.Migration.Models; -using Bit.Core.Billing.Migration.Services; +using Bit.Core.Billing.Providers.Migration.Models; +using Bit.Core.Billing.Providers.Migration.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Admin/Billing/Models/ProviderPlanViewModel.cs b/src/Admin/Billing/Models/ProviderPlanViewModel.cs index 7a50aba286..391c24d6df 100644 --- a/src/Admin/Billing/Models/ProviderPlanViewModel.cs +++ b/src/Admin/Billing/Models/ProviderPlanViewModel.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; namespace Bit.Admin.Billing.Models; diff --git a/src/Admin/Billing/Views/MigrateProviders/Details.cshtml b/src/Admin/Billing/Views/MigrateProviders/Details.cshtml index 303e6d2e45..6ee0344057 100644 --- a/src/Admin/Billing/Views/MigrateProviders/Details.cshtml +++ b/src/Admin/Billing/Views/MigrateProviders/Details.cshtml @@ -1,5 +1,5 @@ @using System.Text.Json -@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult +@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult @{ ViewData["Title"] = "Results"; } diff --git a/src/Admin/Billing/Views/MigrateProviders/Results.cshtml b/src/Admin/Billing/Views/MigrateProviders/Results.cshtml index 45611de80e..94db08db3d 100644 --- a/src/Admin/Billing/Views/MigrateProviders/Results.cshtml +++ b/src/Admin/Billing/Views/MigrateProviders/Results.cshtml @@ -1,4 +1,4 @@ -@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult[] +@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult[] @{ ViewData["Title"] = "Results"; } diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index 11f9e7ce68..5b34e13f6c 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -10,7 +10,7 @@ using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.DependencyInjection.Extensions; using Bit.Admin.Services; using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Migration; +using Bit.Core.Billing.Providers.Migration; #if !OSS using Bit.Commercial.Core.Utilities; diff --git a/src/Admin/Tools/Jobs/DeleteSendsJob.cs b/src/Admin/Tools/Jobs/DeleteSendsJob.cs index dafce03994..7449d2ea01 100644 --- a/src/Admin/Tools/Jobs/DeleteSendsJob.cs +++ b/src/Admin/Tools/Jobs/DeleteSendsJob.cs @@ -2,7 +2,7 @@ using Bit.Core; using Bit.Core.Jobs; using Bit.Core.Tools.Repositories; -using Bit.Core.Tools.Services; +using Bit.Core.Tools.SendFeatures.Commands.Interfaces; using Quartz; namespace Bit.Admin.Tools.Jobs; @@ -32,10 +32,10 @@ public class DeleteSendsJob : BaseJob } using (var scope = _serviceProvider.CreateScope()) { - var sendService = scope.ServiceProvider.GetRequiredService(); + var nonAnonymousSendCommand = scope.ServiceProvider.GetRequiredService(); foreach (var send in sends) { - await sendService.DeleteSendAsync(send); + await nonAnonymousSendCommand.DeleteSendAsync(send); } } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs index b9afde2724..a8882dfaf3 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs @@ -2,13 +2,11 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; -using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -137,7 +135,6 @@ public class OrganizationDomainController : Controller [AllowAnonymous] [HttpPost("domain/sso/verified")] - [RequireFeature(FeatureFlagKeys.VerifiedSsoDomainEndpoint)] public async Task GetVerifiedOrgDomainSsoDetailsAsync( [FromBody] OrganizationDomainSsoDetailsRequestModel model) { diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index f402c927e0..0d498beab1 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -25,7 +25,7 @@ using Bit.Core.Auth.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs index 74d2feff3c..f226ba316e 100644 --- a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs @@ -2,7 +2,7 @@ using Bit.Api.Billing.Models.Requests; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.Business; diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 1ae1f2e655..094ca0a435 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -8,6 +8,7 @@ using Bit.Core; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; @@ -301,8 +302,12 @@ public class OrganizationBillingController( Debug.Assert(org is not null, "This organization has already been found via this same ID, this should be fine."); var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken); var taxInformation = TaxInformation.From(organizationSignup.TaxInfo); - await organizationBillingService.UpdatePaymentMethod(org, paymentSource, taxInformation); await organizationBillingService.Finalize(sale); + var updatedOrg = await organizationRepository.GetByIdAsync(organizationId); + if (updatedOrg != null) + { + await organizationBillingService.UpdatePaymentMethod(updatedOrg, paymentSource, taxInformation); + } return TypedResults.Ok(); } diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index 78e361e8b3..37130d54ce 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -1,10 +1,12 @@ using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; @@ -148,13 +150,33 @@ public class ProviderBillingController( var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); + var getProviderPriceFromStripe = featureService.IsEnabled(FeatureFlagKeys.PM21383_GetProviderPriceFromStripe); + var configuredProviderPlans = await Task.WhenAll(providerPlans.Select(async providerPlan => { var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType); + + decimal unitAmount; + + if (getProviderPriceFromStripe) + { + var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, plan.Type); + var price = await stripeAdapter.PriceGetAsync(priceId); + + unitAmount = price.UnitAmountDecimal.HasValue + ? price.UnitAmountDecimal.Value / 100M + : plan.PasswordManager.ProviderPortalSeatPrice; + } + else + { + unitAmount = plan.PasswordManager.ProviderPortalSeatPrice; + } + return new ConfiguredProviderPlan( providerPlan.Id, providerPlan.ProviderId, plan, + unitAmount, providerPlan.SeatMinimum ?? 0, providerPlan.PurchasedSeats ?? 0, providerPlan.AllocatedSeats ?? 0); diff --git a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs index a2c6827314..e5b868af9a 100644 --- a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs +++ b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Tax.Models; using Stripe; @@ -35,7 +36,7 @@ public record ProviderSubscriptionResponse( .Select(providerPlan => { var plan = providerPlan.Plan; - var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * plan.PasswordManager.ProviderPortalSeatPrice; + var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * providerPlan.Price; var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence; return new ProviderPlanResponse( plan.Name, diff --git a/src/Api/KeyManagement/Validators/SendRotationValidator.cs b/src/Api/KeyManagement/Validators/SendRotationValidator.cs index c39f563b51..10a5d996b7 100644 --- a/src/Api/KeyManagement/Validators/SendRotationValidator.cs +++ b/src/Api/KeyManagement/Validators/SendRotationValidator.cs @@ -12,17 +12,17 @@ namespace Bit.Api.KeyManagement.Validators; /// public class SendRotationValidator : IRotationValidator, IReadOnlyList> { - private readonly ISendService _sendService; + private readonly ISendAuthorizationService _sendAuthorizationService; private readonly ISendRepository _sendRepository; /// /// Instantiates a new /// - /// Enables conversion of to + /// Enables conversion of to /// Retrieves all user s - public SendRotationValidator(ISendService sendService, ISendRepository sendRepository) + public SendRotationValidator(ISendAuthorizationService sendAuthorizationService, ISendRepository sendRepository) { - _sendService = sendService; + _sendAuthorizationService = sendAuthorizationService; _sendRepository = sendRepository; } @@ -44,7 +44,7 @@ public class SendRotationValidator : IRotationValidator _logger; private readonly GlobalSettings _globalSettings; private readonly ICurrentContext _currentContext; @@ -34,7 +38,9 @@ public class SendsController : Controller public SendsController( ISendRepository sendRepository, IUserService userService, - ISendService sendService, + ISendAuthorizationService sendAuthorizationService, + IAnonymousSendCommand anonymousSendCommand, + INonAnonymousSendCommand nonAnonymousSendCommand, ISendFileStorageService sendFileStorageService, ILogger logger, GlobalSettings globalSettings, @@ -42,13 +48,16 @@ public class SendsController : Controller { _sendRepository = sendRepository; _userService = userService; - _sendService = sendService; + _sendAuthorizationService = sendAuthorizationService; + _anonymousSendCommand = anonymousSendCommand; + _nonAnonymousSendCommand = nonAnonymousSendCommand; _sendFileStorageService = sendFileStorageService; _logger = logger; _globalSettings = globalSettings; _currentContext = currentContext; } + #region Anonymous endpoints [AllowAnonymous] [HttpPost("access/{id}")] public async Task Access(string id, [FromBody] SendAccessRequestModel model) @@ -61,18 +70,19 @@ public class SendsController : Controller //} var guid = new Guid(CoreHelpers.Base64UrlDecode(id)); - var (send, passwordRequired, passwordInvalid) = - await _sendService.AccessAsync(guid, model.Password); - if (passwordRequired) + var send = await _sendRepository.GetByIdAsync(guid); + SendAccessResult sendAuthResult = + await _sendAuthorizationService.AccessAsync(send, model.Password); + if (sendAuthResult.Equals(SendAccessResult.PasswordRequired)) { return new UnauthorizedResult(); } - if (passwordInvalid) + if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid)) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } - if (send == null) + if (sendAuthResult.Equals(SendAccessResult.Denied)) { throw new NotFoundException(); } @@ -106,19 +116,19 @@ public class SendsController : Controller throw new BadRequestException("Could not locate send"); } - var (url, passwordRequired, passwordInvalid) = await _sendService.GetSendFileDownloadUrlAsync(send, fileId, + var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, model.Password); - if (passwordRequired) + if (result.Equals(SendAccessResult.PasswordRequired)) { return new UnauthorizedResult(); } - if (passwordInvalid) + if (result.Equals(SendAccessResult.PasswordInvalid)) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } - if (send == null) + if (result.Equals(SendAccessResult.Denied)) { throw new NotFoundException(); } @@ -130,6 +140,45 @@ public class SendsController : Controller }); } + [AllowAnonymous] + [HttpPost("file/validate/azure")] + public async Task AzureValidateFile() + { + return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> + { + { + "Microsoft.Storage.BlobCreated", async (eventGridEvent) => + { + try + { + var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1]; + var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName); + var send = await _sendRepository.GetByIdAsync(new Guid(sendId)); + if (send == null) + { + if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService) + { + await azureSendFileStorageService.DeleteBlobAsync(blobName); + } + return; + } + + await _nonAnonymousSendCommand.ConfirmFileSize(send); + } + catch (Exception e) + { + _logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}"); + return; + } + } + } + }); + } + + #endregion + + #region Non-anonymous endpoints + [HttpGet("{id}")] public async Task Get(string id) { @@ -157,8 +206,8 @@ public class SendsController : Controller { model.ValidateCreation(); var userId = _userService.GetProperUserId(User).Value; - var send = model.ToSend(userId, _sendService); - await _sendService.SaveSendAsync(send); + var send = model.ToSend(userId, _sendAuthorizationService); + await _nonAnonymousSendCommand.SaveSendAsync(send); return new SendResponseModel(send, _globalSettings); } @@ -175,15 +224,15 @@ public class SendsController : Controller throw new BadRequestException("Invalid content. File size hint is required."); } - if (model.FileLength.Value > SendService.MAX_FILE_SIZE) + if (model.FileLength.Value > Constants.FileSize501mb) { - throw new BadRequestException($"Max file size is {SendService.MAX_FILE_SIZE_READABLE}."); + throw new BadRequestException($"Max file size is {SendFileSettingHelper.MAX_FILE_SIZE_READABLE}."); } model.ValidateCreation(); var userId = _userService.GetProperUserId(User).Value; - var (send, data) = model.ToSend(userId, model.File.FileName, _sendService); - var uploadUrl = await _sendService.SaveFileSendAsync(send, data, model.FileLength.Value); + var (send, data) = model.ToSend(userId, model.File.FileName, _sendAuthorizationService); + var uploadUrl = await _nonAnonymousSendCommand.SaveFileSendAsync(send, data, model.FileLength.Value); return new SendFileUploadDataResponseModel { Url = uploadUrl, @@ -230,41 +279,7 @@ public class SendsController : Controller var send = await _sendRepository.GetByIdAsync(new Guid(id)); await Request.GetFileAsync(async (stream) => { - await _sendService.UploadFileToExistingSendAsync(stream, send); - }); - } - - [AllowAnonymous] - [HttpPost("file/validate/azure")] - public async Task AzureValidateFile() - { - return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> - { - { - "Microsoft.Storage.BlobCreated", async (eventGridEvent) => - { - try - { - var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1]; - var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName); - var send = await _sendRepository.GetByIdAsync(new Guid(sendId)); - if (send == null) - { - if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService) - { - await azureSendFileStorageService.DeleteBlobAsync(blobName); - } - return; - } - await _sendService.ValidateSendFile(send); - } - catch (Exception e) - { - _logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}"); - return; - } - } - } + await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send); }); } @@ -279,7 +294,7 @@ public class SendsController : Controller throw new NotFoundException(); } - await _sendService.SaveSendAsync(model.ToSend(send, _sendService)); + await _nonAnonymousSendCommand.SaveSendAsync(model.ToSend(send, _sendAuthorizationService)); return new SendResponseModel(send, _globalSettings); } @@ -294,7 +309,7 @@ public class SendsController : Controller } send.Password = null; - await _sendService.SaveSendAsync(send); + await _nonAnonymousSendCommand.SaveSendAsync(send); return new SendResponseModel(send, _globalSettings); } @@ -308,6 +323,8 @@ public class SendsController : Controller throw new NotFoundException(); } - await _sendService.DeleteSendAsync(send); + await _nonAnonymousSendCommand.DeleteSendAsync(send); } + + #endregion } diff --git a/src/Api/Tools/Models/Request/SendRequestModel.cs b/src/Api/Tools/Models/Request/SendRequestModel.cs index 660ff41e3a..5b3fd7ba31 100644 --- a/src/Api/Tools/Models/Request/SendRequestModel.cs +++ b/src/Api/Tools/Models/Request/SendRequestModel.cs @@ -36,31 +36,31 @@ public class SendRequestModel public bool? Disabled { get; set; } public bool? HideEmail { get; set; } - public Send ToSend(Guid userId, ISendService sendService) + public Send ToSend(Guid userId, ISendAuthorizationService sendAuthorizationService) { var send = new Send { Type = Type, UserId = (Guid?)userId }; - ToSend(send, sendService); + ToSend(send, sendAuthorizationService); return send; } - public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendService sendService) + public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendAuthorizationService sendAuthorizationService) { var send = ToSendBase(new Send { Type = Type, UserId = (Guid?)userId - }, sendService); + }, sendAuthorizationService); var data = new SendFileData(Name, Notes, fileName); return (send, data); } - public Send ToSend(Send existingSend, ISendService sendService) + public Send ToSend(Send existingSend, ISendAuthorizationService sendAuthorizationService) { - existingSend = ToSendBase(existingSend, sendService); + existingSend = ToSendBase(existingSend, sendAuthorizationService); switch (existingSend.Type) { case SendType.File: @@ -125,7 +125,7 @@ public class SendRequestModel } } - private Send ToSendBase(Send existingSend, ISendService sendService) + private Send ToSendBase(Send existingSend, ISendAuthorizationService authorizationService) { existingSend.Key = Key; existingSend.ExpirationDate = ExpirationDate; @@ -133,7 +133,7 @@ public class SendRequestModel existingSend.MaxAccessCount = MaxAccessCount; if (!string.IsNullOrWhiteSpace(Password)) { - existingSend.Password = sendService.HashPassword(Password); + existingSend.Password = authorizationService.HashPassword(Password); } existingSend.Disabled = Disabled.GetValueOrDefault(); existingSend.HideEmail = HideEmail.GetValueOrDefault(); diff --git a/src/Billing/Services/Implementations/ProviderEventService.cs b/src/Billing/Services/Implementations/ProviderEventService.cs index 4e35a6c894..1f6ef741df 100644 --- a/src/Billing/Services/Implementations/ProviderEventService.cs +++ b/src/Billing/Services/Implementations/ProviderEventService.cs @@ -1,8 +1,8 @@ using Bit.Billing.Constants; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Enums; using Bit.Core.Repositories; using Stripe; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs new file mode 100644 index 0000000000..b8802ffd0c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs @@ -0,0 +1,187 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Pricing; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.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; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public record ProviderClientOrganizationSignUpResponse( + Organization Organization, + Collection DefaultCollection); + +public interface IProviderClientOrganizationSignUpCommand +{ + /// + /// Sign up a new client organization for a provider. + /// + /// The signup information. + /// A tuple containing the new organization and its default collection. + Task SignUpClientOrganizationAsync(OrganizationSignup signup); +} + +public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizationSignUpCommand +{ + public const string PlanNullErrorMessage = "Password Manager Plan was null."; + public const string PlanDisabledErrorMessage = "Password Manager Plan is disabled."; + public const string AdditionalSeatsNegativeErrorMessage = "You can't subtract Password Manager seats!"; + + private readonly ICurrentContext _currentContext; + private readonly IPricingClient _pricingClient; + private readonly IReferenceEventService _referenceEventService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; + private readonly IApplicationCacheService _applicationCacheService; + private readonly ICollectionRepository _collectionRepository; + + public ProviderClientOrganizationSignUpCommand( + ICurrentContext currentContext, + IPricingClient pricingClient, + IReferenceEventService referenceEventService, + IOrganizationRepository organizationRepository, + IOrganizationApiKeyRepository organizationApiKeyRepository, + IApplicationCacheService applicationCacheService, + ICollectionRepository collectionRepository) + { + _currentContext = currentContext; + _pricingClient = pricingClient; + _referenceEventService = referenceEventService; + _organizationRepository = organizationRepository; + _organizationApiKeyRepository = organizationApiKeyRepository; + _applicationCacheService = applicationCacheService; + _collectionRepository = collectionRepository; + } + + public async Task SignUpClientOrganizationAsync(OrganizationSignup signup) + { + var plan = await _pricingClient.GetPlanOrThrow(signup.Plan); + + ValidatePlan(plan, signup.AdditionalSeats); + + var organization = new Organization + { + // Pre-generate the org id so that we can save it with the Stripe subscription. + Id = CoreHelpers.GenerateComb(), + Name = signup.Name, + BillingEmail = signup.BillingEmail, + PlanType = plan!.Type, + Seats = signup.AdditionalSeats, + MaxCollections = plan.PasswordManager.MaxCollections, + MaxStorageGb = 1, + UsePolicies = plan.HasPolicies, + UseSso = plan.HasSso, + UseOrganizationDomains = plan.HasOrganizationDomains, + UseGroups = plan.HasGroups, + UseEvents = plan.HasEvents, + UseDirectory = plan.HasDirectory, + UseTotp = plan.HasTotp, + Use2fa = plan.Has2fa, + UseApi = plan.HasApi, + UseResetPassword = plan.HasResetPassword, + SelfHost = plan.HasSelfHost, + UsersGetPremium = plan.UsersGetPremium, + UseCustomPermissions = plan.HasCustomPermissions, + UseScim = plan.HasScim, + Plan = plan.Name, + Gateway = GatewayType.Stripe, + ReferenceData = signup.Owner.ReferenceData, + Enabled = true, + LicenseKey = CoreHelpers.SecureRandomString(20), + PublicKey = signup.PublicKey, + PrivateKey = signup.PrivateKey, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + Status = OrganizationStatusType.Created, + UsePasswordManager = true, + // Secrets Manager not available for purchase with Consolidated Billing. + UseSecretsManager = false, + }; + + var returnValue = await SignUpAsync(organization, signup.CollectionName); + + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext) + { + PlanName = plan.Name, + PlanType = plan.Type, + Seats = returnValue.Organization.Seats, + SignupInitiationPath = signup.InitiationPath, + Storage = returnValue.Organization.MaxStorageGb, + }); + + return returnValue; + } + + private static void ValidatePlan(Plan plan, int additionalSeats) + { + if (plan is null) + { + throw new BadRequestException(PlanNullErrorMessage); + } + + if (plan.Disabled) + { + throw new BadRequestException(PlanDisabledErrorMessage); + } + + if (additionalSeats < 0) + { + throw new BadRequestException(AdditionalSeatsNegativeErrorMessage); + } + } + + /// + /// Private helper method to create a new organization. + /// + private async Task SignUpAsync( + Organization organization, string collectionName) + { + try + { + await _organizationRepository.CreateAsync(organization); + await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey + { + OrganizationId = organization.Id, + ApiKey = CoreHelpers.SecureRandomString(30), + Type = OrganizationApiKeyType.Default, + RevisionDate = DateTime.UtcNow, + }); + await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + Collection defaultCollection = null; + if (!string.IsNullOrWhiteSpace(collectionName)) + { + defaultCollection = new Collection + { + Name = collectionName, + OrganizationId = organization.Id, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + + await _collectionRepository.CreateAsync(defaultCollection, null, null); + } + + return new ProviderClientOrganizationSignUpResponse(organization, defaultCollection); + } + catch + { + if (organization.Id != default) + { + await _organizationRepository.DeleteAsync(organization); + await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); + } + + throw; + } + } +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 8baad23f65..5fe68bd22e 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -18,9 +18,6 @@ public interface IOrganizationService Task AutoAddSeatsAsync(Organization organization, int seatsToAdd); Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment); Task VerifyBankAsync(Guid organizationId, int amount1, int amount2); -#nullable enable - Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup); -#nullable disable /// /// Create a new organization on a self-hosted instance /// diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 4e9d9bdb8a..26ff421328 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -410,66 +410,6 @@ public class OrganizationService : IOrganizationService } } - public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup) - { - var plan = await _pricingClient.GetPlanOrThrow(signup.Plan); - - ValidatePlan(plan, signup.AdditionalSeats, "Password Manager"); - - var organization = new Organization - { - // Pre-generate the org id so that we can save it with the Stripe subscription. - Id = CoreHelpers.GenerateComb(), - Name = signup.Name, - BillingEmail = signup.BillingEmail, - PlanType = plan!.Type, - Seats = signup.AdditionalSeats, - MaxCollections = plan.PasswordManager.MaxCollections, - MaxStorageGb = 1, - UsePolicies = plan.HasPolicies, - UseSso = plan.HasSso, - UseOrganizationDomains = plan.HasOrganizationDomains, - UseGroups = plan.HasGroups, - UseEvents = plan.HasEvents, - UseDirectory = plan.HasDirectory, - UseTotp = plan.HasTotp, - Use2fa = plan.Has2fa, - UseApi = plan.HasApi, - UseResetPassword = plan.HasResetPassword, - SelfHost = plan.HasSelfHost, - UsersGetPremium = plan.UsersGetPremium, - UseCustomPermissions = plan.HasCustomPermissions, - UseScim = plan.HasScim, - Plan = plan.Name, - Gateway = GatewayType.Stripe, - ReferenceData = signup.Owner.ReferenceData, - Enabled = true, - LicenseKey = CoreHelpers.SecureRandomString(20), - PublicKey = signup.PublicKey, - PrivateKey = signup.PrivateKey, - CreationDate = DateTime.UtcNow, - RevisionDate = DateTime.UtcNow, - Status = OrganizationStatusType.Created, - UsePasswordManager = true, - // Secrets Manager not available for purchase with Consolidated Billing. - UseSecretsManager = false, - }; - - var returnValue = await SignUpAsync(organization, default, signup.OwnerKey, signup.CollectionName, false); - - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Seats = returnValue.Item1.Seats, - SignupInitiationPath = signup.InitiationPath, - Storage = returnValue.Item1.MaxStorageGb, - }); - - return returnValue; - } - private async Task ValidateSignUpPoliciesAsync(Guid ownerId) { var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); diff --git a/src/Core/Billing/Entities/ClientOrganizationMigrationRecord.cs b/src/Core/Billing/Providers/Entities/ClientOrganizationMigrationRecord.cs similarity index 88% rename from src/Core/Billing/Entities/ClientOrganizationMigrationRecord.cs rename to src/Core/Billing/Providers/Entities/ClientOrganizationMigrationRecord.cs index 1e719b3ceb..bbb0a90b04 100644 --- a/src/Core/Billing/Entities/ClientOrganizationMigrationRecord.cs +++ b/src/Core/Billing/Providers/Entities/ClientOrganizationMigrationRecord.cs @@ -1,12 +1,12 @@ -using System.ComponentModel.DataAnnotations; +#nullable enable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Utilities; -#nullable enable - -namespace Bit.Core.Billing.Entities; +namespace Bit.Core.Billing.Providers.Entities; public class ClientOrganizationMigrationRecord : ITableObject { diff --git a/src/Core/Billing/Entities/ProviderInvoiceItem.cs b/src/Core/Billing/Providers/Entities/ProviderInvoiceItem.cs similarity index 87% rename from src/Core/Billing/Entities/ProviderInvoiceItem.cs rename to src/Core/Billing/Providers/Entities/ProviderInvoiceItem.cs index 566d7514e7..9d9eeda754 100644 --- a/src/Core/Billing/Entities/ProviderInvoiceItem.cs +++ b/src/Core/Billing/Providers/Entities/ProviderInvoiceItem.cs @@ -1,10 +1,10 @@ -using System.ComponentModel.DataAnnotations; +#nullable enable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Utilities; -#nullable enable - -namespace Bit.Core.Billing.Entities; +namespace Bit.Core.Billing.Providers.Entities; public class ProviderInvoiceItem : ITableObject { diff --git a/src/Core/Billing/Entities/ProviderPlan.cs b/src/Core/Billing/Providers/Entities/ProviderPlan.cs similarity index 86% rename from src/Core/Billing/Entities/ProviderPlan.cs rename to src/Core/Billing/Providers/Entities/ProviderPlan.cs index fd131f64e6..d06c81e9ce 100644 --- a/src/Core/Billing/Entities/ProviderPlan.cs +++ b/src/Core/Billing/Providers/Entities/ProviderPlan.cs @@ -1,10 +1,10 @@ -using Bit.Core.Billing.Enums; +#nullable enable + +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Utilities; -#nullable enable - -namespace Bit.Core.Billing.Entities; +namespace Bit.Core.Billing.Providers.Entities; public class ProviderPlan : ITableObject { diff --git a/src/Core/Billing/Migration/Models/ClientMigrationTracker.cs b/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs similarity index 90% rename from src/Core/Billing/Migration/Models/ClientMigrationTracker.cs rename to src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs index 69398004fd..ae0c28de86 100644 --- a/src/Core/Billing/Migration/Models/ClientMigrationTracker.cs +++ b/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Migration.Models; +namespace Bit.Core.Billing.Providers.Migration.Models; public enum ClientMigrationProgress { diff --git a/src/Core/Billing/Migration/Models/ProviderMigrationResult.cs b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs similarity index 93% rename from src/Core/Billing/Migration/Models/ProviderMigrationResult.cs rename to src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs index 137ba8bd0d..6f3c3be11d 100644 --- a/src/Core/Billing/Migration/Models/ProviderMigrationResult.cs +++ b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs @@ -1,6 +1,6 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; -namespace Bit.Core.Billing.Migration.Models; +namespace Bit.Core.Billing.Providers.Migration.Models; public class ProviderMigrationResult { diff --git a/src/Core/Billing/Migration/Models/ProviderMigrationTracker.cs b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs similarity index 90% rename from src/Core/Billing/Migration/Models/ProviderMigrationTracker.cs rename to src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs index 7bfef8a931..f4708d4cbd 100644 --- a/src/Core/Billing/Migration/Models/ProviderMigrationTracker.cs +++ b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Migration.Models; +namespace Bit.Core.Billing.Providers.Migration.Models; public enum ProviderMigrationProgress { diff --git a/src/Core/Billing/Migration/ServiceCollectionExtensions.cs b/src/Core/Billing/Providers/Migration/ServiceCollectionExtensions.cs similarity index 71% rename from src/Core/Billing/Migration/ServiceCollectionExtensions.cs rename to src/Core/Billing/Providers/Migration/ServiceCollectionExtensions.cs index 109259d59a..1061c82888 100644 --- a/src/Core/Billing/Migration/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Providers/Migration/ServiceCollectionExtensions.cs @@ -1,8 +1,8 @@ -using Bit.Core.Billing.Migration.Services; -using Bit.Core.Billing.Migration.Services.Implementations; +using Bit.Core.Billing.Providers.Migration.Services; +using Bit.Core.Billing.Providers.Migration.Services.Implementations; using Microsoft.Extensions.DependencyInjection; -namespace Bit.Core.Billing.Migration; +namespace Bit.Core.Billing.Providers.Migration; public static class ServiceCollectionExtensions { diff --git a/src/Core/Billing/Migration/Services/IMigrationTrackerCache.cs b/src/Core/Billing/Providers/Migration/Services/IMigrationTrackerCache.cs similarity index 85% rename from src/Core/Billing/Migration/Services/IMigrationTrackerCache.cs rename to src/Core/Billing/Providers/Migration/Services/IMigrationTrackerCache.cs index 6734c69566..70649590df 100644 --- a/src/Core/Billing/Migration/Services/IMigrationTrackerCache.cs +++ b/src/Core/Billing/Providers/Migration/Services/IMigrationTrackerCache.cs @@ -1,8 +1,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.Billing.Migration.Models; +using Bit.Core.Billing.Providers.Migration.Models; -namespace Bit.Core.Billing.Migration.Services; +namespace Bit.Core.Billing.Providers.Migration.Services; public interface IMigrationTrackerCache { diff --git a/src/Core/Billing/Migration/Services/IOrganizationMigrator.cs b/src/Core/Billing/Providers/Migration/Services/IOrganizationMigrator.cs similarity index 72% rename from src/Core/Billing/Migration/Services/IOrganizationMigrator.cs rename to src/Core/Billing/Providers/Migration/Services/IOrganizationMigrator.cs index 7bc9443717..a0548277b4 100644 --- a/src/Core/Billing/Migration/Services/IOrganizationMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/IOrganizationMigrator.cs @@ -1,6 +1,6 @@ using Bit.Core.AdminConsole.Entities; -namespace Bit.Core.Billing.Migration.Services; +namespace Bit.Core.Billing.Providers.Migration.Services; public interface IOrganizationMigrator { diff --git a/src/Core/Billing/Migration/Services/IProviderMigrator.cs b/src/Core/Billing/Providers/Migration/Services/IProviderMigrator.cs similarity index 55% rename from src/Core/Billing/Migration/Services/IProviderMigrator.cs rename to src/Core/Billing/Providers/Migration/Services/IProviderMigrator.cs index 9ca14e7fd9..328c2419f4 100644 --- a/src/Core/Billing/Migration/Services/IProviderMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/IProviderMigrator.cs @@ -1,6 +1,6 @@ -using Bit.Core.Billing.Migration.Models; +using Bit.Core.Billing.Providers.Migration.Models; -namespace Bit.Core.Billing.Migration.Services; +namespace Bit.Core.Billing.Providers.Migration.Services; public interface IProviderMigrator { diff --git a/src/Core/Billing/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs similarity index 96% rename from src/Core/Billing/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs rename to src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs index 920bc55392..ea7d118cfa 100644 --- a/src/Core/Billing/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs @@ -1,11 +1,11 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.Billing.Migration.Models; +using Bit.Core.Billing.Providers.Migration.Models; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; -namespace Bit.Core.Billing.Migration.Services.Implementations; +namespace Bit.Core.Billing.Providers.Migration.Services.Implementations; public class MigrationTrackerDistributedCache( [FromKeyedServices("persistent")] diff --git a/src/Core/Billing/Migration/Services/Implementations/OrganizationMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs similarity index 98% rename from src/Core/Billing/Migration/Services/Implementations/OrganizationMigrator.cs rename to src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs index 204022380d..3b874579e5 100644 --- a/src/Core/Billing/Migration/Services/Implementations/OrganizationMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs @@ -1,10 +1,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Migration.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Migration.Models; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; @@ -12,7 +12,7 @@ using Microsoft.Extensions.Logging; using Stripe; using Plan = Bit.Core.Models.StaticStore.Plan; -namespace Bit.Core.Billing.Migration.Services.Implementations; +namespace Bit.Core.Billing.Providers.Migration.Services.Implementations; public class OrganizationMigrator( IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository, diff --git a/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs similarity index 98% rename from src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs rename to src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs index 384cfca1d1..3a0b579dcf 100644 --- a/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs @@ -3,18 +3,18 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Migration.Models; -using Bit.Core.Billing.Repositories; -using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Migration.Models; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.Extensions.Logging; using Stripe; -namespace Bit.Core.Billing.Migration.Services.Implementations; +namespace Bit.Core.Billing.Providers.Migration.Services.Implementations; public class ProviderMigrator( IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository, diff --git a/src/Core/Billing/Models/AddableOrganization.cs b/src/Core/Billing/Providers/Models/AddableOrganization.cs similarity index 72% rename from src/Core/Billing/Models/AddableOrganization.cs rename to src/Core/Billing/Providers/Models/AddableOrganization.cs index fe6d5458bd..aca7a158b0 100644 --- a/src/Core/Billing/Models/AddableOrganization.cs +++ b/src/Core/Billing/Providers/Models/AddableOrganization.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Models; +namespace Bit.Core.Billing.Providers.Models; public record AddableOrganization( Guid Id, diff --git a/src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs b/src/Core/Billing/Providers/Models/ChangeProviderPlansCommand.cs similarity index 80% rename from src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs rename to src/Core/Billing/Providers/Models/ChangeProviderPlansCommand.cs index 385782c8ad..053d912291 100644 --- a/src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs +++ b/src/Core/Billing/Providers/Models/ChangeProviderPlansCommand.cs @@ -1,7 +1,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Enums; -namespace Bit.Core.Billing.Services.Contracts; +namespace Bit.Core.Billing.Providers.Models; public record ChangeProviderPlanCommand( Provider Provider, diff --git a/src/Core/Billing/Models/ConfiguredProviderPlan.cs b/src/Core/Billing/Providers/Models/ConfiguredProviderPlan.cs similarity index 75% rename from src/Core/Billing/Models/ConfiguredProviderPlan.cs rename to src/Core/Billing/Providers/Models/ConfiguredProviderPlan.cs index 72c1ec5b07..d875106a9e 100644 --- a/src/Core/Billing/Models/ConfiguredProviderPlan.cs +++ b/src/Core/Billing/Providers/Models/ConfiguredProviderPlan.cs @@ -1,11 +1,12 @@ using Bit.Core.Models.StaticStore; -namespace Bit.Core.Billing.Models; +namespace Bit.Core.Billing.Providers.Models; public record ConfiguredProviderPlan( Guid Id, Guid ProviderId, Plan Plan, + decimal Price, int SeatMinimum, int PurchasedSeats, int AssignedSeats); diff --git a/src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs b/src/Core/Billing/Providers/Models/UpdateProviderSeatMinimumsCommand.cs similarity index 89% rename from src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs rename to src/Core/Billing/Providers/Models/UpdateProviderSeatMinimumsCommand.cs index 2d2535b60a..dfd04e6605 100644 --- a/src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs +++ b/src/Core/Billing/Providers/Models/UpdateProviderSeatMinimumsCommand.cs @@ -1,7 +1,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Enums; -namespace Bit.Core.Billing.Services.Contracts; +namespace Bit.Core.Billing.Providers.Models; /// The provider to update the seat minimums for. /// The new seat minimums for the provider. diff --git a/src/Core/Billing/Repositories/IClientOrganizationMigrationRecordRepository.cs b/src/Core/Billing/Providers/Repositories/IClientOrganizationMigrationRecordRepository.cs similarity index 77% rename from src/Core/Billing/Repositories/IClientOrganizationMigrationRecordRepository.cs rename to src/Core/Billing/Providers/Repositories/IClientOrganizationMigrationRecordRepository.cs index 2165984383..53eb51403f 100644 --- a/src/Core/Billing/Repositories/IClientOrganizationMigrationRecordRepository.cs +++ b/src/Core/Billing/Providers/Repositories/IClientOrganizationMigrationRecordRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; using Bit.Core.Repositories; -namespace Bit.Core.Billing.Repositories; +namespace Bit.Core.Billing.Providers.Repositories; public interface IClientOrganizationMigrationRecordRepository : IRepository { diff --git a/src/Core/Billing/Repositories/IProviderInvoiceItemRepository.cs b/src/Core/Billing/Providers/Repositories/IProviderInvoiceItemRepository.cs similarity index 74% rename from src/Core/Billing/Repositories/IProviderInvoiceItemRepository.cs rename to src/Core/Billing/Providers/Repositories/IProviderInvoiceItemRepository.cs index a722d4cf9d..931d8a9186 100644 --- a/src/Core/Billing/Repositories/IProviderInvoiceItemRepository.cs +++ b/src/Core/Billing/Providers/Repositories/IProviderInvoiceItemRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; using Bit.Core.Repositories; -namespace Bit.Core.Billing.Repositories; +namespace Bit.Core.Billing.Providers.Repositories; public interface IProviderInvoiceItemRepository : IRepository { diff --git a/src/Core/Billing/Repositories/IProviderPlanRepository.cs b/src/Core/Billing/Providers/Repositories/IProviderPlanRepository.cs similarity index 64% rename from src/Core/Billing/Repositories/IProviderPlanRepository.cs rename to src/Core/Billing/Providers/Repositories/IProviderPlanRepository.cs index eccbad82bb..d1cf91ea56 100644 --- a/src/Core/Billing/Repositories/IProviderPlanRepository.cs +++ b/src/Core/Billing/Providers/Repositories/IProviderPlanRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; using Bit.Core.Repositories; -namespace Bit.Core.Billing.Repositories; +namespace Bit.Core.Billing.Providers.Repositories; public interface IProviderPlanRepository : IRepository { diff --git a/src/Core/Billing/Services/IBusinessUnitConverter.cs b/src/Core/Billing/Providers/Services/IBusinessUnitConverter.cs similarity index 98% rename from src/Core/Billing/Services/IBusinessUnitConverter.cs rename to src/Core/Billing/Providers/Services/IBusinessUnitConverter.cs index 06ff883eae..99df6b1bef 100644 --- a/src/Core/Billing/Services/IBusinessUnitConverter.cs +++ b/src/Core/Billing/Providers/Services/IBusinessUnitConverter.cs @@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using OneOf; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Providers.Services; public interface IBusinessUnitConverter { diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Providers/Services/IProviderBillingService.cs similarity index 98% rename from src/Core/Billing/Services/IProviderBillingService.cs rename to src/Core/Billing/Providers/Services/IProviderBillingService.cs index b6ddbdd642..b634f1a81c 100644 --- a/src/Core/Billing/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Providers/Services/IProviderBillingService.cs @@ -1,14 +1,14 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Tax.Models; using Bit.Core.Models.Business; using Stripe; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Providers.Services; public interface IProviderBillingService { diff --git a/src/Core/Billing/Services/Contracts/AutomaticTaxFactoryParameters.cs b/src/Core/Billing/Tax/Models/AutomaticTaxFactoryParameters.cs similarity index 93% rename from src/Core/Billing/Services/Contracts/AutomaticTaxFactoryParameters.cs rename to src/Core/Billing/Tax/Models/AutomaticTaxFactoryParameters.cs index 19a4f0bdfa..a58daa9c48 100644 --- a/src/Core/Billing/Services/Contracts/AutomaticTaxFactoryParameters.cs +++ b/src/Core/Billing/Tax/Models/AutomaticTaxFactoryParameters.cs @@ -2,7 +2,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; -namespace Bit.Core.Billing.Services.Contracts; +namespace Bit.Core.Billing.Tax.Models; public class AutomaticTaxFactoryParameters { diff --git a/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs index 90a3bc08ad..c0a31efb3c 100644 --- a/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs +++ b/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; namespace Bit.Core.Billing.Tax.Services; diff --git a/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs index fa110f79d5..6086a16b79 100644 --- a/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs +++ b/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs @@ -1,7 +1,7 @@ #nullable enable using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Services; diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 694521c14e..1c31ffaab4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -150,6 +150,7 @@ public static class FeatureFlagKeys public const string UseOrganizationWarningsService = "use-organization-warnings-service"; public const string PM20322_AllowTrialLength0 = "pm-20322-allow-trial-length-0"; public const string PM21092_SetNonUSBusinessUseToReverseCharge = "pm-21092-set-non-us-business-use-to-reverse-charge"; + public const string PM21383_GetProviderPriceFromStripe = "pm-21383-get-provider-price-from-stripe"; /* Data Insights and Reporting Team */ public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index b016e329bf..2bc05017d5 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -69,8 +69,11 @@ public static class OrganizationServiceCollectionExtensions services.AddBaseOrganizationSubscriptionCommandsQueries(); } - private static IServiceCollection AddOrganizationSignUpCommands(this IServiceCollection services) => + private static void AddOrganizationSignUpCommands(this IServiceCollection services) + { services.AddScoped(); + services.AddScoped(); + } private static void AddOrganizationDeleteCommands(this IServiceCollection services) { diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index cb95732a6e..1ba93da4fa 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -57,4 +57,5 @@ public interface IStripeAdapter Task SetupIntentGet(string id, SetupIntentGetOptions options = null); Task SetupIntentVerifyMicroDeposit(string id, SetupIntentVerifyMicrodepositsOptions options); Task> TestClockListAsync(); + Task PriceGetAsync(string id, PriceGetOptions options = null); } diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index f7f4fea066..fd9f212ee7 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -283,4 +283,7 @@ public class StripeAdapter : IStripeAdapter } return items; } + + public Task PriceGetAsync(string id, PriceGetOptions options = null) + => _priceService.GetAsync(id, options); } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 34be6d59c5..23d06bed2b 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -7,7 +7,7 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Requests; using Bit.Core.Billing.Tax.Responses; using Bit.Core.Billing.Tax.Services; diff --git a/src/Core/Tools/Models/Data/SendAccessResult.cs b/src/Core/Tools/Models/Data/SendAccessResult.cs new file mode 100644 index 0000000000..4516f0d9a2 --- /dev/null +++ b/src/Core/Tools/Models/Data/SendAccessResult.cs @@ -0,0 +1,19 @@ +using Bit.Core.Tools.Entities; + +namespace Bit.Core.Tools.Models.Data; + +/// +/// This enum represents the possible results when attempting to access a . +/// +/// name="Granted">Access is granted for the . +/// name="PasswordRequired">Access is denied, but a password is required to access the . +/// +/// name="PasswordInvalid">Access is denied due to an invalid password. +/// name="Denied">Access is denied for the . +public enum SendAccessResult +{ + Granted, + PasswordRequired, + PasswordInvalid, + Denied +} diff --git a/src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs new file mode 100644 index 0000000000..f41c62f409 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs @@ -0,0 +1,52 @@ +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.SendFeatures.Commands.Interfaces; +using Bit.Core.Tools.Services; + +namespace Bit.Core.Tools.SendFeatures.Commands; + +public class AnonymousSendCommand : IAnonymousSendCommand +{ + private readonly ISendRepository _sendRepository; + private readonly ISendFileStorageService _sendFileStorageService; + private readonly IPushNotificationService _pushNotificationService; + private readonly ISendAuthorizationService _sendAuthorizationService; + + public AnonymousSendCommand( + ISendRepository sendRepository, + ISendFileStorageService sendFileStorageService, + IPushNotificationService pushNotificationService, + ISendAuthorizationService sendAuthorizationService + ) + { + _sendRepository = sendRepository; + _sendFileStorageService = sendFileStorageService; + _pushNotificationService = pushNotificationService; + _sendAuthorizationService = sendAuthorizationService; + } + + // Response: Send, password required, password invalid + public async Task<(string, SendAccessResult)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password) + { + if (send.Type != SendType.File) + { + throw new BadRequestException("Can only get a download URL for a file type of Send"); + } + + var result = _sendAuthorizationService.SendCanBeAccessed(send, password); + + if (!result.Equals(SendAccessResult.Granted)) + { + return (null, result); + } + + send.AccessCount++; + await _sendRepository.ReplaceAsync(send); + await _pushNotificationService.PushSyncSendUpdateAsync(send); + return (await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId), result); + } +} diff --git a/src/Core/Tools/SendFeatures/Commands/Interfaces/IAnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/Interfaces/IAnonymousSendCommand.cs new file mode 100644 index 0000000000..ad23d85170 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Commands/Interfaces/IAnonymousSendCommand.cs @@ -0,0 +1,21 @@ +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Models.Data; + +namespace Bit.Core.Tools.SendFeatures.Commands.Interfaces; + +/// +/// AnonymousSendCommand interface provides methods for managing anonymous Sends. +/// +public interface IAnonymousSendCommand +{ + /// + /// Gets the Send file download URL for a Send object. + /// + /// used to help get file download url and validate file + /// FileId get file download url + /// A hashed and base64-encoded password. This is compared with the send's password to authorize access. + /// Async Task object with Tuple containing the string of download url and + /// to determine if the user can access send. + /// + Task<(string, SendAccessResult)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password); +} diff --git a/src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs new file mode 100644 index 0000000000..58693e619c --- /dev/null +++ b/src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs @@ -0,0 +1,53 @@ +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Models.Data; + +namespace Bit.Core.Tools.SendFeatures.Commands.Interfaces; + +/// +/// NonAnonymousSendCommand interface provides methods for managing non-anonymous Sends. +/// +public interface INonAnonymousSendCommand +{ + /// + /// Saves a to the database. + /// + /// that will save to database + /// Task completes as saves to the database + Task SaveSendAsync(Send send); + + /// + /// Saves the and to the database. + /// + /// that will save to the database + /// that will save to file storage + /// Length of file help with saving to file storage + /// Task object for async operations with file upload url + Task SaveFileSendAsync(Send send, SendFileData data, long fileLength); + + /// + /// Upload a file to an existing . + /// + /// of file to be uploaded. The position + /// will be set to 0 before uploading the file. + /// used to help with uploading file + /// Task completes after saving and metadata to the file storage + Task UploadFileToExistingSendAsync(Stream stream, Send send); + + /// + /// Deletes a from the database and file storage. + /// + /// is used to delete from database and file storage + /// Task completes once has been deleted from database and file storage. + Task DeleteSendAsync(Send send); + + /// + /// Stores the confirmed file size of a send; when the file size cannot be confirmed, the send is deleted. + /// + /// The this command acts upon + /// when the file is confirmed, otherwise + /// + /// When a file size cannot be confirmed, we assume we're working with a rogue client. The send is deleted out of + /// an abundance of caution. + /// + Task ConfirmFileSize(Send send); +} diff --git a/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs new file mode 100644 index 0000000000..00da0a911f --- /dev/null +++ b/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs @@ -0,0 +1,180 @@ +using System.Text.Json; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.SendFeatures.Commands.Interfaces; +using Bit.Core.Tools.Services; +using Bit.Core.Utilities; + +namespace Bit.Core.Tools.SendFeatures.Commands; + +public class NonAnonymousSendCommand : INonAnonymousSendCommand +{ + private readonly ISendRepository _sendRepository; + private readonly ISendFileStorageService _sendFileStorageService; + private readonly IPushNotificationService _pushNotificationService; + private readonly ISendValidationService _sendValidationService; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; + private readonly ISendCoreHelperService _sendCoreHelperService; + + public NonAnonymousSendCommand(ISendRepository sendRepository, + ISendFileStorageService sendFileStorageService, + IPushNotificationService pushNotificationService, + ISendAuthorizationService sendAuthorizationService, + ISendValidationService sendValidationService, + IReferenceEventService referenceEventService, + ICurrentContext currentContext, + ISendCoreHelperService sendCoreHelperService) + { + _sendRepository = sendRepository; + _sendFileStorageService = sendFileStorageService; + _pushNotificationService = pushNotificationService; + _sendValidationService = sendValidationService; + _referenceEventService = referenceEventService; + _currentContext = currentContext; + _sendCoreHelperService = sendCoreHelperService; + } + + public async Task SaveSendAsync(Send send) + { + // Make sure user can save Sends + await _sendValidationService.ValidateUserCanSaveAsync(send.UserId, send); + + if (send.Id == default(Guid)) + { + await _sendRepository.CreateAsync(send); + await _pushNotificationService.PushSyncSendCreateAsync(send); + await _referenceEventService.RaiseEventAsync(new ReferenceEvent + { + Id = send.UserId ?? default, + Type = ReferenceEventType.SendCreated, + Source = ReferenceEventSource.User, + SendType = send.Type, + MaxAccessCount = send.MaxAccessCount, + HasPassword = !string.IsNullOrWhiteSpace(send.Password), + SendHasNotes = send.Data?.Contains("Notes"), + ClientId = _currentContext.ClientId, + ClientVersion = _currentContext.ClientVersion + }); + } + else + { + send.RevisionDate = DateTime.UtcNow; + await _sendRepository.UpsertAsync(send); + await _pushNotificationService.PushSyncSendUpdateAsync(send); + } + } + + public async Task SaveFileSendAsync(Send send, SendFileData data, long fileLength) + { + if (send.Type != SendType.File) + { + throw new BadRequestException("Send is not of type \"file\"."); + } + + if (fileLength < 1) + { + throw new BadRequestException("No file data."); + } + + var storageBytesRemaining = await _sendValidationService.StorageRemainingForSendAsync(send); + + if (storageBytesRemaining < fileLength) + { + throw new BadRequestException("Not enough storage available."); + } + + var fileId = _sendCoreHelperService.SecureRandomString(32, useUpperCase: false, useSpecial: false); + + try + { + data.Id = fileId; + data.Size = fileLength; + data.Validated = false; + send.Data = JsonSerializer.Serialize(data, + JsonHelpers.IgnoreWritingNull); + await SaveSendAsync(send); + return await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId); + } + catch + { + // Clean up since this is not transactional + await _sendFileStorageService.DeleteFileAsync(send, fileId); + throw; + } + } + public async Task UploadFileToExistingSendAsync(Stream stream, Send send) + { + if (stream.Position > 0) + { + stream.Position = 0; + } + + if (send?.Data == null) + { + throw new BadRequestException("Send does not have file data"); + } + + if (send.Type != SendType.File) + { + throw new BadRequestException("Not a File Type Send."); + } + + var data = JsonSerializer.Deserialize(send.Data); + + if (data.Validated) + { + throw new BadRequestException("File has already been uploaded."); + } + + await _sendFileStorageService.UploadNewFileAsync(stream, send, data.Id); + + if (!await ConfirmFileSize(send)) + { + throw new BadRequestException("File received does not match expected file length."); + } + } + public async Task DeleteSendAsync(Send send) + { + await _sendRepository.DeleteAsync(send); + if (send.Type == Enums.SendType.File) + { + var data = JsonSerializer.Deserialize(send.Data); + await _sendFileStorageService.DeleteFileAsync(send, data.Id); + } + await _pushNotificationService.PushSyncSendDeleteAsync(send); + } + + public async Task ConfirmFileSize(Send send) + { + var fileData = JsonSerializer.Deserialize(send.Data); + + var (valid, realSize) = await _sendFileStorageService.ValidateFileAsync(send, fileData.Id, fileData.Size, SendFileSettingHelper.FILE_SIZE_LEEWAY); + + if (!valid || realSize > SendFileSettingHelper.FILE_SIZE_LEEWAY) + { + // File reported differs in size from that promised. Must be a rogue client. Delete Send + await DeleteSendAsync(send); + return false; + } + + // Update Send data if necessary + if (realSize != fileData.Size) + { + fileData.Size = realSize.Value; + } + fileData.Validated = true; + send.Data = JsonSerializer.Serialize(fileData, + JsonHelpers.IgnoreWritingNull); + await SaveSendAsync(send); + + return valid; + } + +} diff --git a/src/Core/Tools/SendFeatures/SendServiceCollectionExtension.cs b/src/Core/Tools/SendFeatures/SendServiceCollectionExtension.cs new file mode 100644 index 0000000000..02327adaac --- /dev/null +++ b/src/Core/Tools/SendFeatures/SendServiceCollectionExtension.cs @@ -0,0 +1,18 @@ +using Bit.Core.Tools.SendFeatures.Commands; +using Bit.Core.Tools.SendFeatures.Commands.Interfaces; +using Bit.Core.Tools.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Tools.SendFeatures; + +public static class SendServiceCollectionExtension +{ + public static void AddSendServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } +} diff --git a/src/Core/Tools/Services/Implementations/AzureSendFileStorageService.cs b/src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs similarity index 100% rename from src/Core/Tools/Services/Implementations/AzureSendFileStorageService.cs rename to src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs diff --git a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendAuthorizationService.cs b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendAuthorizationService.cs new file mode 100644 index 0000000000..9acf987ac5 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendAuthorizationService.cs @@ -0,0 +1,28 @@ +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Models.Data; + +namespace Bit.Core.Tools.Services; + +/// +/// Send Authorization service is responsible for checking if a Send can be accessed. +/// +public interface ISendAuthorizationService +{ + /// + /// Checks if a can be accessed while updating the , pushing a notification, and sending a reference event. + /// + /// used to determine access + /// A hashed and base64-encoded password. This is compared with the send's password to authorize access. + /// will be returned to determine if the user can access send. + /// + Task AccessAsync(Send send, string password); + SendAccessResult SendCanBeAccessed(Send send, + string password); + + /// + /// Hashes the password using the password hasher. + /// + /// Password to be hashed + /// Hashed password of the password given + string HashPassword(string password); +} diff --git a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendCoreHelperService.cs b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendCoreHelperService.cs new file mode 100644 index 0000000000..a09d7c3c60 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendCoreHelperService.cs @@ -0,0 +1,17 @@ +namespace Bit.Core.Tools.Services; + +/// +/// This interface provides helper methods for generating secure random strings. Making +/// it easier to mock the service in unit tests. +/// +public interface ISendCoreHelperService +{ + /// + /// Securely generates a random string of the specified length. + /// + /// Desired string length to be returned + /// Desired casing for the string + /// Determines if special characters will be used in string + /// A secure random string with the desired parameters + string SecureRandomString(int length, bool useUpperCase, bool useSpecial); +} diff --git a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs new file mode 100644 index 0000000000..29bc0c6a6a --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs @@ -0,0 +1,71 @@ +using Bit.Core.Enums; +using Bit.Core.Tools.Entities; + +namespace Bit.Core.Tools.Services; + +/// +/// Send File Storage Service is responsible for uploading, deleting, and validating files +/// whether they are in local storage or in cloud storage. +/// +public interface ISendFileStorageService +{ + FileUploadType FileUploadType { get; } + /// + /// Uploads a new file to the storage. + /// + /// of the file + /// for the file + /// File id + /// Task completes once and have been saved to the database + Task UploadNewFileAsync(Stream stream, Send send, string fileId); + /// + /// Deletes a file from the storage. + /// + /// used to delete file + /// File id of file to be deleted + /// Task completes once has been deleted to the database + Task DeleteFileAsync(Send send, string fileId); + /// + /// Deletes all files for a specific organization. + /// + /// used to delete all files pertaining to organization + /// Task completes after running code to delete files by organization id + Task DeleteFilesForOrganizationAsync(Guid organizationId); + /// + /// Deletes all files for a specific user. + /// + /// used to delete all files pertaining to user + /// Task completes after running code to delete files by user id + Task DeleteFilesForUserAsync(Guid userId); + /// + /// Gets the download URL for a file. + /// + /// used to help get download url for file + /// File id to help get download url for file + /// Download url as a string + Task GetSendFileDownloadUrlAsync(Send send, string fileId); + /// + /// Gets the upload URL for a file. + /// + /// used to help get upload url for file + /// File id to help get upload url for file + /// File upload url as string + Task GetSendFileUploadUrlAsync(Send send, string fileId); + /// + /// Validates the file size of a file in the storage. + /// + /// used to help validate file + /// File id to identify which file to validate + /// Expected file size of the file + /// + /// Send file size tolerance in bytes. When an uploaded file's `expectedFileSize` + /// is outside of the leeway, the storage operation fails. + /// + /// + /// ❌ Fill this in with an explanation of the error thrown when `leeway` is incorrect + /// + /// Task object for async operations with Tuple of boolean that determines if file was valid and long that + /// the actual file size of the file. + /// + Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway); +} diff --git a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendValidationService.cs b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendValidationService.cs new file mode 100644 index 0000000000..24d31c5cfe --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendValidationService.cs @@ -0,0 +1,35 @@ +using Bit.Core.Tools.Entities; + +namespace Bit.Core.Tools.Services; + +public interface ISendValidationService +{ + /// + /// Validates a file can be saved by specified user. + /// + /// needed to validate file for specific user + /// needed to help validate file + /// Task completes when a conditional statement has been met it will return out of the method or + /// throw a BadRequestException. + /// + Task ValidateUserCanSaveAsync(Guid? userId, Send send); + + /// + /// Validates a file can be saved by specified user with different policy based on feature flag + /// + /// needed to validate file for specific user + /// needed to help validate file + /// Task completes when a conditional statement has been met it will return out of the method or + /// throw a BadRequestException. + /// + Task ValidateUserCanSaveAsync_vNext(Guid? userId, Send send); + + /// + /// Calculates the remaining storage for a Send. + /// + /// needed to help calculate remaining storage + /// Long with the remaining bytes for storage or will throw a BadRequestException if user cannot access + /// file or email is not verified. + /// + Task StorageRemainingForSendAsync(Send send); +} diff --git a/src/Core/Tools/Services/Implementations/LocalSendStorageService.cs b/src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs similarity index 100% rename from src/Core/Tools/Services/Implementations/LocalSendStorageService.cs rename to src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs diff --git a/src/Core/Tools/SendFeatures/Services/SendAuthorizationService.cs b/src/Core/Tools/SendFeatures/Services/SendAuthorizationService.cs new file mode 100644 index 0000000000..101a33754e --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/SendAuthorizationService.cs @@ -0,0 +1,101 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Tools.Services; + +public class SendAuthorizationService : ISendAuthorizationService +{ + private readonly ISendRepository _sendRepository; + private readonly IPasswordHasher _passwordHasher; + private readonly IPushNotificationService _pushNotificationService; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; + + public SendAuthorizationService( + ISendRepository sendRepository, + IPasswordHasher passwordHasher, + IPushNotificationService pushNotificationService, + IReferenceEventService referenceEventService, + ICurrentContext currentContext) + { + _sendRepository = sendRepository; + _passwordHasher = passwordHasher; + _pushNotificationService = pushNotificationService; + _referenceEventService = referenceEventService; + _currentContext = currentContext; + } + + public SendAccessResult SendCanBeAccessed(Send send, + string password) + { + var now = DateTime.UtcNow; + if (send == null || send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount || + send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < now || send.Disabled || + send.DeletionDate < now) + { + return SendAccessResult.Denied; + } + if (!string.IsNullOrWhiteSpace(send.Password)) + { + if (string.IsNullOrWhiteSpace(password)) + { + return SendAccessResult.PasswordRequired; + } + var passwordResult = _passwordHasher.VerifyHashedPassword(new User(), send.Password, password); + if (passwordResult == PasswordVerificationResult.SuccessRehashNeeded) + { + send.Password = HashPassword(password); + } + if (passwordResult == PasswordVerificationResult.Failed) + { + return SendAccessResult.PasswordInvalid; + } + } + + return SendAccessResult.Granted; + } + + public async Task AccessAsync(Send sendToBeAccessed, string password) + { + var accessResult = SendCanBeAccessed(sendToBeAccessed, password); + + if (!accessResult.Equals(SendAccessResult.Granted)) + { + return accessResult; + } + + if (sendToBeAccessed.Type != SendType.File) + { + // File sends are incremented during file download + sendToBeAccessed.AccessCount++; + } + + await _sendRepository.ReplaceAsync(sendToBeAccessed); + await _pushNotificationService.PushSyncSendUpdateAsync(sendToBeAccessed); + await _referenceEventService.RaiseEventAsync(new ReferenceEvent + { + Id = sendToBeAccessed.UserId ?? default, + Type = ReferenceEventType.SendAccessed, + Source = ReferenceEventSource.User, + SendType = sendToBeAccessed.Type, + MaxAccessCount = sendToBeAccessed.MaxAccessCount, + HasPassword = !string.IsNullOrWhiteSpace(sendToBeAccessed.Password), + SendHasNotes = sendToBeAccessed.Data?.Contains("Notes"), + ClientId = _currentContext.ClientId, + ClientVersion = _currentContext.ClientVersion + }); + return accessResult; + } + + public string HashPassword(string password) + { + return _passwordHasher.HashPassword(new User(), password); + } +} diff --git a/src/Core/Tools/SendFeatures/Services/SendCoreHelperService.cs b/src/Core/Tools/SendFeatures/Services/SendCoreHelperService.cs new file mode 100644 index 0000000000..122759f8f0 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/SendCoreHelperService.cs @@ -0,0 +1,12 @@ +using Bit.Core.Utilities; + +namespace Bit.Core.Tools.Services; + +public class SendCoreHelperService : ISendCoreHelperService +{ + public string SecureRandomString(int length, bool useUpperCase, bool useSpecial) + { + return CoreHelpers.SecureRandomString(length, upper: useUpperCase, special: useSpecial); + } + +} diff --git a/src/Core/Tools/SendFeatures/Services/SendFileSettingHelper.cs b/src/Core/Tools/SendFeatures/Services/SendFileSettingHelper.cs new file mode 100644 index 0000000000..ef3f210ff8 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/SendFileSettingHelper.cs @@ -0,0 +1,26 @@ +using Bit.Core.Tools.Entities; + +namespace Bit.Core.Tools.SendFeatures; + +/// +/// SendFileSettingHelper is a static class that provides constants and helper methods (if needed) for managing file +/// settings. +/// +public static class SendFileSettingHelper +{ + /// + /// The leeway for the file size. This is the calculated 1 megabyte of cushion when doing comparisons of file sizes + /// within the system. + /// + public const long FILE_SIZE_LEEWAY = 1024L * 1024L; // 1MB + /// + /// The maximum file size for a file uploaded in a . Units are calculated in bytes but + /// represent 501 megabytes. 1 megabyte is added for cushion to account for file size. + /// + public const long MAX_FILE_SIZE = Constants.FileSize501mb; + + /// + /// String of the expected file size and to be used when needing to communicate the file size to the client/user. + /// + public const string MAX_FILE_SIZE_READABLE = "500 MB"; +} diff --git a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs new file mode 100644 index 0000000000..f1e8855def --- /dev/null +++ b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs @@ -0,0 +1,142 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Tools.Entities; +using Bit.Core.Utilities; + +namespace Bit.Core.Tools.Services; + +public class SendValidationService : ISendValidationService +{ + + private readonly IUserRepository _userRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IPolicyService _policyService; + private readonly IFeatureService _featureService; + private readonly IUserService _userService; + private readonly GlobalSettings _globalSettings; + private readonly ICurrentContext _currentContext; + private readonly IPolicyRequirementQuery _policyRequirementQuery; + + + + public SendValidationService( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IPolicyService policyService, + IFeatureService featureService, + IUserService userService, + IPolicyRequirementQuery policyRequirementQuery, + GlobalSettings globalSettings, + + ICurrentContext currentContext) + { + _userRepository = userRepository; + _organizationRepository = organizationRepository; + _policyService = policyService; + _featureService = featureService; + _userService = userService; + _policyRequirementQuery = policyRequirementQuery; + _globalSettings = globalSettings; + _currentContext = currentContext; + } + + public async Task ValidateUserCanSaveAsync(Guid? userId, Send send) + { + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + await ValidateUserCanSaveAsync_vNext(userId, send); + return; + } + + if (!userId.HasValue || (!_currentContext.Organizations?.Any() ?? true)) + { + return; + } + + var anyDisableSendPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(userId.Value, + PolicyType.DisableSend); + if (anyDisableSendPolicies) + { + throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."); + } + + if (send.HideEmail.GetValueOrDefault()) + { + var sendOptionsPolicies = await _policyService.GetPoliciesApplicableToUserAsync(userId.Value, PolicyType.SendOptions); + if (sendOptionsPolicies.Any(p => CoreHelpers.LoadClassFromJsonData(p.PolicyData)?.DisableHideEmail ?? false)) + { + throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send."); + } + } + } + + public async Task ValidateUserCanSaveAsync_vNext(Guid? userId, Send send) + { + if (!userId.HasValue) + { + return; + } + + var disableSendRequirement = await _policyRequirementQuery.GetAsync(userId.Value); + if (disableSendRequirement.DisableSend) + { + throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."); + } + + var sendOptionsRequirement = await _policyRequirementQuery.GetAsync(userId.Value); + if (sendOptionsRequirement.DisableHideEmail && send.HideEmail.GetValueOrDefault()) + { + throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send."); + } + } + + public async Task StorageRemainingForSendAsync(Send send) + { + var storageBytesRemaining = 0L; + if (send.UserId.HasValue) + { + var user = await _userRepository.GetByIdAsync(send.UserId.Value); + if (!await _userService.CanAccessPremium(user)) + { + throw new BadRequestException("You must have premium status to use file Sends."); + } + + if (!user.EmailVerified) + { + throw new BadRequestException("You must confirm your email to use file Sends."); + } + + if (user.Premium) + { + storageBytesRemaining = user.StorageBytesRemaining(); + } + else + { + // Users that get access to file storage/premium from their organization get the default + // 1 GB max storage. + short limit = _globalSettings.SelfHosted ? (short)10240 : (short)1; + storageBytesRemaining = user.StorageBytesRemaining(limit); + } + } + else if (send.OrganizationId.HasValue) + { + var org = await _organizationRepository.GetByIdAsync(send.OrganizationId.Value); + if (!org.MaxStorageGb.HasValue) + { + throw new BadRequestException("This organization cannot use file sends."); + } + + storageBytesRemaining = org.StorageBytesRemaining(); + } + + return storageBytesRemaining; + } +} diff --git a/src/Core/Tools/Services/ISendService.cs b/src/Core/Tools/Services/ISendService.cs deleted file mode 100644 index 2c20851ce8..0000000000 --- a/src/Core/Tools/Services/ISendService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Models.Data; - -namespace Bit.Core.Tools.Services; - -public interface ISendService -{ - Task DeleteSendAsync(Send send); - Task SaveSendAsync(Send send); - Task SaveFileSendAsync(Send send, SendFileData data, long fileLength); - Task UploadFileToExistingSendAsync(Stream stream, Send send); - Task<(Send, bool, bool)> AccessAsync(Guid sendId, string password); - string HashPassword(string password); - Task<(string, bool, bool)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password); - Task ValidateSendFile(Send send); -} diff --git a/src/Core/Tools/Services/ISendStorageService.cs b/src/Core/Tools/Services/ISendStorageService.cs deleted file mode 100644 index 4bf2aa3892..0000000000 --- a/src/Core/Tools/Services/ISendStorageService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Bit.Core.Enums; -using Bit.Core.Tools.Entities; - -namespace Bit.Core.Tools.Services; - -public interface ISendFileStorageService -{ - FileUploadType FileUploadType { get; } - Task UploadNewFileAsync(Stream stream, Send send, string fileId); - Task DeleteFileAsync(Send send, string fileId); - Task DeleteFilesForOrganizationAsync(Guid organizationId); - Task DeleteFilesForUserAsync(Guid userId); - Task GetSendFileDownloadUrlAsync(Send send, string fileId); - Task GetSendFileUploadUrlAsync(Send send, string fileId); - Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway); -} diff --git a/src/Core/Tools/Services/Implementations/SendService.cs b/src/Core/Tools/Services/Implementations/SendService.cs deleted file mode 100644 index e09787d7eb..0000000000 --- a/src/Core/Tools/Services/Implementations/SendService.cs +++ /dev/null @@ -1,383 +0,0 @@ -using System.Text.Json; -using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -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.Services; -using Bit.Core.Settings; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.Repositories; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Identity; - -namespace Bit.Core.Tools.Services; - -public class SendService : ISendService -{ - public const long MAX_FILE_SIZE = Constants.FileSize501mb; - public const string MAX_FILE_SIZE_READABLE = "500 MB"; - private readonly ISendRepository _sendRepository; - private readonly IUserRepository _userRepository; - private readonly IPolicyService _policyService; - private readonly IUserService _userService; - private readonly IOrganizationRepository _organizationRepository; - private readonly ISendFileStorageService _sendFileStorageService; - private readonly IPasswordHasher _passwordHasher; - private readonly IPushNotificationService _pushService; - private readonly IReferenceEventService _referenceEventService; - private readonly GlobalSettings _globalSettings; - private readonly ICurrentContext _currentContext; - private readonly IPolicyRequirementQuery _policyRequirementQuery; - private readonly IFeatureService _featureService; - - private const long _fileSizeLeeway = 1024L * 1024L; // 1MB - - public SendService( - ISendRepository sendRepository, - IUserRepository userRepository, - IUserService userService, - IOrganizationRepository organizationRepository, - ISendFileStorageService sendFileStorageService, - IPasswordHasher passwordHasher, - IPushNotificationService pushService, - IReferenceEventService referenceEventService, - GlobalSettings globalSettings, - IPolicyService policyService, - ICurrentContext currentContext, - IPolicyRequirementQuery policyRequirementQuery, - IFeatureService featureService) - { - _sendRepository = sendRepository; - _userRepository = userRepository; - _userService = userService; - _policyService = policyService; - _organizationRepository = organizationRepository; - _sendFileStorageService = sendFileStorageService; - _passwordHasher = passwordHasher; - _pushService = pushService; - _referenceEventService = referenceEventService; - _globalSettings = globalSettings; - _currentContext = currentContext; - _policyRequirementQuery = policyRequirementQuery; - _featureService = featureService; - } - - public async Task SaveSendAsync(Send send) - { - // Make sure user can save Sends - await ValidateUserCanSaveAsync(send.UserId, send); - - if (send.Id == default(Guid)) - { - await _sendRepository.CreateAsync(send); - await _pushService.PushSyncSendCreateAsync(send); - await RaiseReferenceEventAsync(send, ReferenceEventType.SendCreated); - } - else - { - send.RevisionDate = DateTime.UtcNow; - await _sendRepository.UpsertAsync(send); - await _pushService.PushSyncSendUpdateAsync(send); - } - } - - public async Task SaveFileSendAsync(Send send, SendFileData data, long fileLength) - { - if (send.Type != SendType.File) - { - throw new BadRequestException("Send is not of type \"file\"."); - } - - if (fileLength < 1) - { - throw new BadRequestException("No file data."); - } - - var storageBytesRemaining = await StorageRemainingForSendAsync(send); - - if (storageBytesRemaining < fileLength) - { - throw new BadRequestException("Not enough storage available."); - } - - var fileId = Utilities.CoreHelpers.SecureRandomString(32, upper: false, special: false); - - try - { - data.Id = fileId; - data.Size = fileLength; - data.Validated = false; - send.Data = JsonSerializer.Serialize(data, - JsonHelpers.IgnoreWritingNull); - await SaveSendAsync(send); - return await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId); - } - catch - { - // Clean up since this is not transactional - await _sendFileStorageService.DeleteFileAsync(send, fileId); - throw; - } - } - - public async Task UploadFileToExistingSendAsync(Stream stream, Send send) - { - if (send?.Data == null) - { - throw new BadRequestException("Send does not have file data"); - } - - if (send.Type != SendType.File) - { - throw new BadRequestException("Not a File Type Send."); - } - - var data = JsonSerializer.Deserialize(send.Data); - - if (data.Validated) - { - throw new BadRequestException("File has already been uploaded."); - } - - await _sendFileStorageService.UploadNewFileAsync(stream, send, data.Id); - - if (!await ValidateSendFile(send)) - { - throw new BadRequestException("File received does not match expected file length."); - } - } - - public async Task ValidateSendFile(Send send) - { - var fileData = JsonSerializer.Deserialize(send.Data); - - var (valid, realSize) = await _sendFileStorageService.ValidateFileAsync(send, fileData.Id, fileData.Size, _fileSizeLeeway); - - if (!valid || realSize > MAX_FILE_SIZE) - { - // File reported differs in size from that promised. Must be a rogue client. Delete Send - await DeleteSendAsync(send); - return false; - } - - // Update Send data if necessary - if (realSize != fileData.Size) - { - fileData.Size = realSize.Value; - } - fileData.Validated = true; - send.Data = JsonSerializer.Serialize(fileData, - JsonHelpers.IgnoreWritingNull); - await SaveSendAsync(send); - - return valid; - } - - public async Task DeleteSendAsync(Send send) - { - await _sendRepository.DeleteAsync(send); - if (send.Type == Enums.SendType.File) - { - var data = JsonSerializer.Deserialize(send.Data); - await _sendFileStorageService.DeleteFileAsync(send, data.Id); - } - await _pushService.PushSyncSendDeleteAsync(send); - } - - public (bool grant, bool passwordRequiredError, bool passwordInvalidError) SendCanBeAccessed(Send send, - string password) - { - var now = DateTime.UtcNow; - if (send == null || send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount || - send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < now || send.Disabled || - send.DeletionDate < now) - { - return (false, false, false); - } - if (!string.IsNullOrWhiteSpace(send.Password)) - { - if (string.IsNullOrWhiteSpace(password)) - { - return (false, true, false); - } - var passwordResult = _passwordHasher.VerifyHashedPassword(new User(), send.Password, password); - if (passwordResult == PasswordVerificationResult.SuccessRehashNeeded) - { - send.Password = HashPassword(password); - } - if (passwordResult == PasswordVerificationResult.Failed) - { - return (false, false, true); - } - } - - return (true, false, false); - } - - // Response: Send, password required, password invalid - public async Task<(string, bool, bool)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password) - { - if (send.Type != SendType.File) - { - throw new BadRequestException("Can only get a download URL for a file type of Send"); - } - - var (grantAccess, passwordRequired, passwordInvalid) = SendCanBeAccessed(send, password); - - if (!grantAccess) - { - return (null, passwordRequired, passwordInvalid); - } - - send.AccessCount++; - await _sendRepository.ReplaceAsync(send); - await _pushService.PushSyncSendUpdateAsync(send); - return (await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId), false, false); - } - - // Response: Send, password required, password invalid - public async Task<(Send, bool, bool)> AccessAsync(Guid sendId, string password) - { - var send = await _sendRepository.GetByIdAsync(sendId); - var (grantAccess, passwordRequired, passwordInvalid) = SendCanBeAccessed(send, password); - - if (!grantAccess) - { - return (null, passwordRequired, passwordInvalid); - } - - // TODO: maybe move this to a simple ++ sproc? - if (send.Type != SendType.File) - { - // File sends are incremented during file download - send.AccessCount++; - } - - await _sendRepository.ReplaceAsync(send); - await _pushService.PushSyncSendUpdateAsync(send); - await RaiseReferenceEventAsync(send, ReferenceEventType.SendAccessed); - return (send, false, false); - } - - private async Task RaiseReferenceEventAsync(Send send, ReferenceEventType eventType) - { - await _referenceEventService.RaiseEventAsync(new ReferenceEvent - { - Id = send.UserId ?? default, - Type = eventType, - Source = ReferenceEventSource.User, - SendType = send.Type, - MaxAccessCount = send.MaxAccessCount, - HasPassword = !string.IsNullOrWhiteSpace(send.Password), - SendHasNotes = send.Data?.Contains("Notes"), - ClientId = _currentContext.ClientId, - ClientVersion = _currentContext.ClientVersion - }); - } - - public string HashPassword(string password) - { - return _passwordHasher.HashPassword(new User(), password); - } - - private async Task ValidateUserCanSaveAsync(Guid? userId, Send send) - { - if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) - { - await ValidateUserCanSaveAsync_vNext(userId, send); - return; - } - - if (!userId.HasValue || (!_currentContext.Organizations?.Any() ?? true)) - { - return; - } - - var anyDisableSendPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(userId.Value, - PolicyType.DisableSend); - if (anyDisableSendPolicies) - { - throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."); - } - - if (send.HideEmail.GetValueOrDefault()) - { - var sendOptionsPolicies = await _policyService.GetPoliciesApplicableToUserAsync(userId.Value, PolicyType.SendOptions); - if (sendOptionsPolicies.Any(p => CoreHelpers.LoadClassFromJsonData(p.PolicyData)?.DisableHideEmail ?? false)) - { - throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send."); - } - } - } - - private async Task ValidateUserCanSaveAsync_vNext(Guid? userId, Send send) - { - if (!userId.HasValue) - { - return; - } - - var disableSendRequirement = await _policyRequirementQuery.GetAsync(userId.Value); - if (disableSendRequirement.DisableSend) - { - throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."); - } - - var sendOptionsRequirement = await _policyRequirementQuery.GetAsync(userId.Value); - if (sendOptionsRequirement.DisableHideEmail && send.HideEmail.GetValueOrDefault()) - { - throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send."); - } - } - - private async Task StorageRemainingForSendAsync(Send send) - { - var storageBytesRemaining = 0L; - if (send.UserId.HasValue) - { - var user = await _userRepository.GetByIdAsync(send.UserId.Value); - if (!await _userService.CanAccessPremium(user)) - { - throw new BadRequestException("You must have premium status to use file Sends."); - } - - if (!user.EmailVerified) - { - throw new BadRequestException("You must confirm your email to use file Sends."); - } - - if (user.Premium) - { - storageBytesRemaining = user.StorageBytesRemaining(); - } - else - { - // Users that get access to file storage/premium from their organization get the default - // 1 GB max storage. - storageBytesRemaining = user.StorageBytesRemaining( - _globalSettings.SelfHosted ? (short)10240 : (short)1); - } - } - else if (send.OrganizationId.HasValue) - { - var org = await _organizationRepository.GetByIdAsync(send.OrganizationId.Value); - if (!org.MaxStorageGb.HasValue) - { - throw new BadRequestException("This organization cannot use file sends."); - } - - storageBytesRemaining = org.StorageBytesRemaining(); - } - - return storageBytesRemaining; - } -} diff --git a/src/Infrastructure.Dapper/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs b/src/Infrastructure.Dapper/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs index 155abdb4b4..e43eb9a71f 100644 --- a/src/Infrastructure.Dapper/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs +++ b/src/Infrastructure.Dapper/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs @@ -1,6 +1,6 @@ using System.Data; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; using Dapper; diff --git a/src/Infrastructure.Dapper/Billing/Repositories/ProviderInvoiceItemRepository.cs b/src/Infrastructure.Dapper/Billing/Repositories/ProviderInvoiceItemRepository.cs index 69a4be1ef8..cf5ac07ead 100644 --- a/src/Infrastructure.Dapper/Billing/Repositories/ProviderInvoiceItemRepository.cs +++ b/src/Infrastructure.Dapper/Billing/Repositories/ProviderInvoiceItemRepository.cs @@ -1,6 +1,6 @@ using System.Data; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; using Dapper; diff --git a/src/Infrastructure.Dapper/Billing/Repositories/ProviderPlanRepository.cs b/src/Infrastructure.Dapper/Billing/Repositories/ProviderPlanRepository.cs index f8448f4198..52977c9d3c 100644 --- a/src/Infrastructure.Dapper/Billing/Repositories/ProviderPlanRepository.cs +++ b/src/Infrastructure.Dapper/Billing/Repositories/ProviderPlanRepository.cs @@ -1,6 +1,6 @@ using System.Data; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; using Dapper; diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index d48fe95096..ba374ae988 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Repositories; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Repositories; using Bit.Core.KeyManagement.Repositories; using Bit.Core.NotificationCenter.Repositories; diff --git a/src/Infrastructure.EntityFramework/Billing/Models/ClientOrganizationMigrationRecord.cs b/src/Infrastructure.EntityFramework/Billing/Models/ClientOrganizationMigrationRecord.cs index 4271df292a..6d77fd9ed9 100644 --- a/src/Infrastructure.EntityFramework/Billing/Models/ClientOrganizationMigrationRecord.cs +++ b/src/Infrastructure.EntityFramework/Billing/Models/ClientOrganizationMigrationRecord.cs @@ -2,7 +2,7 @@ namespace Bit.Infrastructure.EntityFramework.Billing.Models; -public class ClientOrganizationMigrationRecord : Core.Billing.Entities.ClientOrganizationMigrationRecord +public class ClientOrganizationMigrationRecord : Core.Billing.Providers.Entities.ClientOrganizationMigrationRecord { } @@ -11,6 +11,6 @@ public class ClientOrganizationMigrationRecordProfile : Profile { public ClientOrganizationMigrationRecordProfile() { - CreateMap().ReverseMap(); + CreateMap().ReverseMap(); } } diff --git a/src/Infrastructure.EntityFramework/Billing/Models/ProviderInvoiceItem.cs b/src/Infrastructure.EntityFramework/Billing/Models/ProviderInvoiceItem.cs index 1eea0bf9d2..1bea786f21 100644 --- a/src/Infrastructure.EntityFramework/Billing/Models/ProviderInvoiceItem.cs +++ b/src/Infrastructure.EntityFramework/Billing/Models/ProviderInvoiceItem.cs @@ -4,7 +4,7 @@ using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; namespace Bit.Infrastructure.EntityFramework.Billing.Models; // ReSharper disable once ClassWithVirtualMembersNeverInherited.Global -public class ProviderInvoiceItem : Core.Billing.Entities.ProviderInvoiceItem +public class ProviderInvoiceItem : Core.Billing.Providers.Entities.ProviderInvoiceItem { public virtual Provider Provider { get; set; } } @@ -13,6 +13,6 @@ public class ProviderInvoiceItemMapperProfile : Profile { public ProviderInvoiceItemMapperProfile() { - CreateMap().ReverseMap(); + CreateMap().ReverseMap(); } } diff --git a/src/Infrastructure.EntityFramework/Billing/Models/ProviderPlan.cs b/src/Infrastructure.EntityFramework/Billing/Models/ProviderPlan.cs index 4dbbfe71d7..c9ba4c813e 100644 --- a/src/Infrastructure.EntityFramework/Billing/Models/ProviderPlan.cs +++ b/src/Infrastructure.EntityFramework/Billing/Models/ProviderPlan.cs @@ -4,7 +4,7 @@ using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; namespace Bit.Infrastructure.EntityFramework.Billing.Models; // ReSharper disable once ClassWithVirtualMembersNeverInherited.Global -public class ProviderPlan : Core.Billing.Entities.ProviderPlan +public class ProviderPlan : Core.Billing.Providers.Entities.ProviderPlan { public virtual Provider Provider { get; set; } } @@ -13,6 +13,6 @@ public class ProviderPlanMapperProfile : Profile { public ProviderPlanMapperProfile() { - CreateMap().ReverseMap(); + CreateMap().ReverseMap(); } } diff --git a/src/Infrastructure.EntityFramework/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs b/src/Infrastructure.EntityFramework/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs index c7c9a6118b..4a9a82c9dc 100644 --- a/src/Infrastructure.EntityFramework/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs +++ b/src/Infrastructure.EntityFramework/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs @@ -1,6 +1,6 @@ using AutoMapper; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderInvoiceItemRepository.cs b/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderInvoiceItemRepository.cs index 87e960e123..ed729070ae 100644 --- a/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderInvoiceItemRepository.cs +++ b/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderInvoiceItemRepository.cs @@ -1,6 +1,6 @@ using AutoMapper; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using LinqToDB; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderPlanRepository.cs b/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderPlanRepository.cs index 386f7115d7..e022527d64 100644 --- a/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderPlanRepository.cs +++ b/src/Infrastructure.EntityFramework/Billing/Repositories/ProviderPlanRepository.cs @@ -1,6 +1,6 @@ using AutoMapper; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index c9f0406a58..22818517d3 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Repositories; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Repositories; using Bit.Core.Enums; using Bit.Core.KeyManagement.Repositories; diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 9fbc14444f..598d93b177 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -43,6 +43,7 @@ using Bit.Core.Settings; using Bit.Core.Tokens; using Bit.Core.Tools.ImportFeatures; using Bit.Core.Tools.ReportFeatures; +using Bit.Core.Tools.SendFeatures; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Core.Vault; @@ -123,7 +124,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddLoginServices(); services.AddScoped(); services.AddVaultServices(); @@ -132,6 +133,7 @@ public static class ServiceCollectionExtensions services.AddNotificationCenterServices(); services.AddPlatformServices(); services.AddImportServices(); + services.AddSendServices(); } public static void AddTokenizers(this IServiceCollection services) diff --git a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 0b5f5c1f01..44ad5088cd 100644 --- a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -5,7 +5,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Test.Common.AutoFixture; diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 7d0a57ea45..3484c9a995 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -21,7 +21,7 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs index 8ddd92a5fa..c7c749effd 100644 --- a/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs @@ -6,7 +6,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index 36990c7f9a..a082caa469 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -1,14 +1,17 @@ using Bit.Api.Billing.Controllers; using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; +using Bit.Commercial.Core.Billing.Providers.Services; +using Bit.Core; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; @@ -285,6 +288,19 @@ public class ProviderBillingControllerTests Discount = new Discount { Coupon = new Coupon { PercentOff = 10 } }, TaxIds = new StripeList { Data = [new TaxId { Value = "123456789" }] } }, + Items = new StripeList + { + Data = [ + new SubscriptionItem + { + Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise } + }, + new SubscriptionItem + { + Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } + } + ] + }, Status = "unpaid", }; @@ -330,11 +346,21 @@ public class ProviderBillingControllerTests } }; + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PM21383_GetProviderPriceFromStripe) + .Returns(true); + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); foreach (var providerPlan in providerPlans) { - sutProvider.GetDependency().GetPlanOrThrow(providerPlan.PlanType).Returns(StaticStore.GetPlan(providerPlan.PlanType)); + var plan = StaticStore.GetPlan(providerPlan.PlanType); + sutProvider.GetDependency().GetPlanOrThrow(providerPlan.PlanType).Returns(plan); + var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType); + sutProvider.GetDependency().PriceGetAsync(priceId) + .Returns(new Price + { + UnitAmountDecimal = plan.PasswordManager.ProviderPortalSeatPrice * 100 + }); } var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); diff --git a/test/Api.Test/KeyManagement/Validators/SendRotationValidatorTests.cs b/test/Api.Test/KeyManagement/Validators/SendRotationValidatorTests.cs index 842343ba33..7bab587cf0 100644 --- a/test/Api.Test/KeyManagement/Validators/SendRotationValidatorTests.cs +++ b/test/Api.Test/KeyManagement/Validators/SendRotationValidatorTests.cs @@ -23,11 +23,11 @@ public class SendRotationValidatorTests public async Task ValidateAsync_Success() { // Arrange - var sendService = Substitute.For(); + var sendAuthorizationService = Substitute.For(); var sendRepository = Substitute.For(); var sut = new SendRotationValidator( - sendService, + sendAuthorizationService, sendRepository ); @@ -52,11 +52,11 @@ public class SendRotationValidatorTests public async Task ValidateAsync_SendNotReturnedFromRepository_NotIncludedInOutput() { // Arrange - var sendService = Substitute.For(); + var sendAuthorizationService = Substitute.For(); var sendRepository = Substitute.For(); var sut = new SendRotationValidator( - sendService, + sendAuthorizationService, sendRepository ); @@ -76,11 +76,11 @@ public class SendRotationValidatorTests public async Task ValidateAsync_InputMissingUserSend_Throws() { // Arrange - var sendService = Substitute.For(); + var sendAuthorizationService = Substitute.For(); var sendRepository = Substitute.For(); var sut = new SendRotationValidator( - sendService, + sendAuthorizationService, sendRepository ); diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs index f784448e50..b1fa5c9260 100644 --- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs @@ -10,7 +10,9 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.SendFeatures.Commands.Interfaces; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Mvc; @@ -26,7 +28,9 @@ public class SendsControllerTests : IDisposable private readonly GlobalSettings _globalSettings; private readonly IUserService _userService; private readonly ISendRepository _sendRepository; - private readonly ISendService _sendService; + private readonly INonAnonymousSendCommand _nonAnonymousSendCommand; + private readonly IAnonymousSendCommand _anonymousSendCommand; + private readonly ISendAuthorizationService _sendAuthorizationService; private readonly ISendFileStorageService _sendFileStorageService; private readonly ILogger _logger; private readonly ICurrentContext _currentContext; @@ -35,7 +39,9 @@ public class SendsControllerTests : IDisposable { _userService = Substitute.For(); _sendRepository = Substitute.For(); - _sendService = Substitute.For(); + _nonAnonymousSendCommand = Substitute.For(); + _anonymousSendCommand = Substitute.For(); + _sendAuthorizationService = Substitute.For(); _sendFileStorageService = Substitute.For(); _globalSettings = new GlobalSettings(); _logger = Substitute.For>(); @@ -44,7 +50,9 @@ public class SendsControllerTests : IDisposable _sut = new SendsController( _sendRepository, _userService, - _sendService, + _sendAuthorizationService, + _anonymousSendCommand, + _nonAnonymousSendCommand, _sendFileStorageService, _logger, _globalSettings, @@ -68,7 +76,8 @@ public class SendsControllerTests : IDisposable send.Data = JsonSerializer.Serialize(new Dictionary()); send.HideEmail = true; - _sendService.AccessAsync(id, null).Returns((send, false, false)); + _sendRepository.GetByIdAsync(Arg.Any()).Returns(send); + _sendAuthorizationService.AccessAsync(send, null).Returns(SendAccessResult.Granted); _userService.GetUserByIdAsync(Arg.Any()).Returns(user); var request = new SendAccessRequestModel(); diff --git a/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs b/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs index 59fb35d32e..8049667011 100644 --- a/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs +++ b/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs @@ -34,11 +34,11 @@ public class SendRequestModelTests Type = SendType.Text, }; - var sendService = Substitute.For(); - sendService.HashPassword(Arg.Any()) + var sendAuthorizationService = Substitute.For(); + sendAuthorizationService.HashPassword(Arg.Any()) .Returns((info) => $"hashed_{(string)info[0]}"); - var send = sendRequest.ToSend(Guid.NewGuid(), sendService); + var send = sendRequest.ToSend(Guid.NewGuid(), sendAuthorizationService); Assert.Equal(deletionDate, send.DeletionDate); Assert.False(send.Disabled); diff --git a/test/Billing.Test/Services/ProviderEventServiceTests.cs b/test/Billing.Test/Services/ProviderEventServiceTests.cs index e080dd8288..7d95157bd2 100644 --- a/test/Billing.Test/Services/ProviderEventServiceTests.cs +++ b/test/Billing.Test/Services/ProviderEventServiceTests.cs @@ -4,10 +4,10 @@ using Bit.Billing.Test.Utilities; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Utilities; diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ProviderClientOrganizationSignUpCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ProviderClientOrganizationSignUpCommandTests.cs new file mode 100644 index 0000000000..b13c7e5b65 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ProviderClientOrganizationSignUpCommandTests.cs @@ -0,0 +1,169 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.Billing.Enums; +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; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations.OrganizationSignUp; + +[SutProviderCustomize] +public class ProviderClientOrganizationSignUpCommandTests +{ + [Theory] + [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.EnterpriseMonthly)] + public async Task SignupClientAsync_ValidParameters_CreatesOrganizationSuccessfully( + PlanType planType, + OrganizationSignup signup, + string collectionName, + SutProvider sutProvider) + { + signup.Plan = planType; + signup.AdditionalSeats = 15; + signup.CollectionName = collectionName; + + var plan = StaticStore.GetPlan(signup.Plan); + sutProvider.GetDependency() + .GetPlanOrThrow(signup.Plan) + .Returns(plan); + + var result = await sutProvider.Sut.SignUpClientOrganizationAsync(signup); + + Assert.NotNull(result.Organization); + Assert.NotNull(result.DefaultCollection); + Assert.Equal(collectionName, result.DefaultCollection.Name); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + Arg.Is(o => + o.Name == signup.Name && + o.BillingEmail == signup.BillingEmail && + o.PlanType == plan.Type && + o.Seats == signup.AdditionalSeats && + o.MaxCollections == plan.PasswordManager.MaxCollections && + o.UsePasswordManager == true && + o.UseSecretsManager == false && + o.Status == OrganizationStatusType.Created + ) + ); + + await sutProvider.GetDependency() + .Received(1) + .RaiseEventAsync(Arg.Is(referenceEvent => + referenceEvent.Type == ReferenceEventType.Signup && + referenceEvent.PlanName == plan.Name && + referenceEvent.PlanType == plan.Type && + referenceEvent.Seats == result.Organization.Seats && + referenceEvent.Storage == result.Organization.MaxStorageGb && + referenceEvent.SignupInitiationPath == signup.InitiationPath + )); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + Arg.Is(c => + c.Name == collectionName && + c.OrganizationId == result.Organization.Id + ), + Arg.Any>(), + Arg.Any>() + ); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync( + Arg.Is(k => + k.OrganizationId == result.Organization.Id && + k.Type == OrganizationApiKeyType.Default + ) + ); + + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(Arg.Is(o => o.Id == result.Organization.Id)); + } + + [Theory] + [BitAutoData] + public async Task SignupClientAsync_NullPlan_ThrowsBadRequestException( + OrganizationSignup signup, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetPlanOrThrow(signup.Plan) + .Returns((Plan)null); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpClientOrganizationAsync(signup)); + + Assert.Contains(ProviderClientOrganizationSignUpCommand.PlanNullErrorMessage, exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SignupClientAsync_NegativeAdditionalSeats_ThrowsBadRequestException( + OrganizationSignup signup, + SutProvider sutProvider) + { + signup.Plan = PlanType.TeamsMonthly; + signup.AdditionalSeats = -5; + + var plan = StaticStore.GetPlan(signup.Plan); + sutProvider.GetDependency() + .GetPlanOrThrow(signup.Plan) + .Returns(plan); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpClientOrganizationAsync(signup)); + + Assert.Contains(ProviderClientOrganizationSignUpCommand.AdditionalSeatsNegativeErrorMessage, exception.Message); + } + + [Theory] + [BitAutoData(PlanType.TeamsAnnually)] + public async Task SignupClientAsync_WhenExceptionIsThrown_CleanupIsPerformed( + PlanType planType, + OrganizationSignup signup, + SutProvider sutProvider) + { + signup.Plan = planType; + + var plan = StaticStore.GetPlan(signup.Plan); + sutProvider.GetDependency() + .GetPlanOrThrow(signup.Plan) + .Returns(plan); + + sutProvider.GetDependency() + .When(x => x.CreateAsync(Arg.Any())) + .Do(_ => throw new Exception()); + + var thrownException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpClientOrganizationAsync(signup)); + + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(Arg.Is(o => o.Name == signup.Name)); + + await sutProvider.GetDependency() + .Received(1) + .DeleteOrganizationAbilityAsync(Arg.Any()); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index c138cfac2e..18f1f79900 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -9,7 +9,6 @@ using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Context; -using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -177,47 +176,6 @@ public class OrganizationServiceTests referenceEvent.Users == expectedNewUsersCount)); } - [Theory, BitAutoData] - public async Task SignupClientAsync_Succeeds( - OrganizationSignup signup, - SutProvider sutProvider) - { - signup.Plan = PlanType.TeamsMonthly; - - var plan = StaticStore.GetPlan(signup.Plan); - - sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(plan); - - var (organization, _, _) = await sutProvider.Sut.SignupClientAsync(signup); - - await sutProvider.GetDependency().Received(1).CreateAsync(Arg.Is(org => - org.Id == organization.Id && - org.Name == signup.Name && - org.Plan == plan.Name && - org.PlanType == plan.Type && - org.UsePolicies == plan.HasPolicies && - org.PublicKey == signup.PublicKey && - org.PrivateKey == signup.PrivateKey && - org.UseSecretsManager == false)); - - await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Is(orgApiKey => - orgApiKey.OrganizationId == organization.Id)); - - await sutProvider.GetDependency().Received(1) - .UpsertOrganizationAbilityAsync(organization); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateAsync(default); - - await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Is(c => c.Name == signup.CollectionName && c.OrganizationId == organization.Id), null, null); - - await sutProvider.GetDependency().Received(1).RaiseEventAsync(Arg.Is( - re => - re.Type == ReferenceEventType.Signup && - re.PlanType == plan.Type)); - } - [Theory] [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] diff --git a/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs b/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs index 8de51b1745..d9d2679bca 100644 --- a/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs +++ b/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs @@ -2,7 +2,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Entities; using Bit.Test.Common.AutoFixture; diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index fa1dd60617..7d8a059d76 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -1,7 +1,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Requests; using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; diff --git a/test/Core.Test/Tools/Services/AnonymousSendCommandTests.cs b/test/Core.Test/Tools/Services/AnonymousSendCommandTests.cs new file mode 100644 index 0000000000..3101273225 --- /dev/null +++ b/test/Core.Test/Tools/Services/AnonymousSendCommandTests.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.SendFeatures.Commands; +using Bit.Core.Tools.Services; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Tools.Services; + +public class AnonymousSendCommandTests +{ + private readonly ISendRepository _sendRepository; + private readonly ISendFileStorageService _sendFileStorageService; + private readonly IPushNotificationService _pushNotificationService; + private readonly ISendAuthorizationService _sendAuthorizationService; + private readonly AnonymousSendCommand _anonymousSendCommand; + + public AnonymousSendCommandTests() + { + _sendRepository = Substitute.For(); + _sendFileStorageService = Substitute.For(); + _pushNotificationService = Substitute.For(); + _sendAuthorizationService = Substitute.For(); + + _anonymousSendCommand = new AnonymousSendCommand( + _sendRepository, + _sendFileStorageService, + _pushNotificationService, + _sendAuthorizationService); + } + + [Fact] + public async Task GetSendFileDownloadUrlAsync_Success_ReturnsDownloadUrl() + { + // Arrange + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + AccessCount = 0, + Data = JsonSerializer.Serialize(new { Id = "fileId123" }) + }; + var fileId = "fileId123"; + var password = "testPassword"; + var expectedUrl = "https://example.com/download"; + + _sendAuthorizationService + .SendCanBeAccessed(send, password) + .Returns(SendAccessResult.Granted); + + _sendFileStorageService + .GetSendFileDownloadUrlAsync(send, fileId) + .Returns(expectedUrl); + + // Act + var result = + await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, password); + + // Assert + Assert.Equal(expectedUrl, result.Item1); + Assert.Equal(1, send.AccessCount); + + await _sendRepository.Received(1).ReplaceAsync(send); + await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); + } + + [Fact] + public async Task GetSendFileDownloadUrlAsync_AccessDenied_ReturnsNullWithReasons() + { + // Arrange + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + AccessCount = 0 + }; + var fileId = "fileId123"; + var password = "wrongPassword"; + + _sendAuthorizationService + .SendCanBeAccessed(send, password) + .Returns(SendAccessResult.Denied); + + // Act + var result = + await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, password); + + // Assert + Assert.Null(result.Item1); + Assert.Equal(SendAccessResult.Denied, result.Item2); + Assert.Equal(0, send.AccessCount); + + await _sendRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default); + await _pushNotificationService.DidNotReceiveWithAnyArgs().PushSyncSendUpdateAsync(default); + } + + [Fact] + public async Task GetSendFileDownloadUrlAsync_NotFileSend_ThrowsBadRequestException() + { + // Arrange + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.Text + }; + var fileId = "fileId123"; + var password = "testPassword"; + + // Act & Assert + await Assert.ThrowsAsync(() => + _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, password)); + } +} diff --git a/test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs b/test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs new file mode 100644 index 0000000000..15e7d57651 --- /dev/null +++ b/test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs @@ -0,0 +1,1111 @@ +using System.Text.Json; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.CurrentContextFixtures; +using Bit.Core.Test.Tools.AutoFixture.SendFixtures; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.SendFeatures; +using Bit.Core.Tools.SendFeatures.Commands; +using Bit.Core.Tools.Services; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Tools.Services; + +[SutProviderCustomize] +[CurrentContextCustomize] +[UserSendCustomize] +public class NonAnonymousSendCommandTests +{ + private readonly ISendRepository _sendRepository; + private readonly ISendFileStorageService _sendFileStorageService; + private readonly IPushNotificationService _pushNotificationService; + private readonly ISendAuthorizationService _sendAuthorizationService; + private readonly ISendValidationService _sendValidationService; + private readonly IFeatureService _featureService; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; + private readonly ISendCoreHelperService _sendCoreHelperService; + private readonly NonAnonymousSendCommand _nonAnonymousSendCommand; + + public NonAnonymousSendCommandTests() + { + _sendRepository = Substitute.For(); + _sendFileStorageService = Substitute.For(); + _pushNotificationService = Substitute.For(); + _sendAuthorizationService = Substitute.For(); + _featureService = Substitute.For(); + _sendValidationService = Substitute.For(); + _referenceEventService = Substitute.For(); + _currentContext = Substitute.For(); + _sendCoreHelperService = Substitute.For(); + + _nonAnonymousSendCommand = new NonAnonymousSendCommand( + _sendRepository, + _sendFileStorageService, + _pushNotificationService, + _sendAuthorizationService, + _sendValidationService, + _referenceEventService, + _currentContext, + _sendCoreHelperService + ); + } + + // Disable Send policy check + [Theory] + [InlineData(SendType.File)] + [InlineData(SendType.Text)] + public async Task SaveSendAsync_DisableSend_Applies_throws(SendType sendType) + { + // Arrange + var send = new Send + { + Id = default, + Type = sendType, + UserId = Guid.NewGuid() + }; + + var user = new User + { + Id = send.UserId.Value, + Email = "test@example.com" + }; + + // Configure validation service to throw when DisableSend policy applies + _sendValidationService.ValidateUserCanSaveAsync(send.UserId.Value, send) + .Throws(new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send.")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveSendAsync(send)); + + Assert.Contains("Enterprise Policy", exception.Message); + + // Verify the validation service was called + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(send.UserId.Value, send); + + // Verify repository was not called since exception was thrown + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + } + + [Theory] + [InlineData(true)] // New Send (Id is default) + [InlineData(false)] // Existing Send (Id is not default) + public async Task SaveSendAsync_DisableSend_DoesntApply_success(bool isNewSend) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = isNewSend ? default : Guid.NewGuid(), + Type = SendType.Text, + UserId = userId, + Data = "Text with Notes" + }; + + var initialDate = DateTime.UtcNow.AddMinutes(-5); + send.RevisionDate = initialDate; + + // Configure validation service to NOT throw (policy doesn't apply) + _sendValidationService.ValidateUserCanSaveAsync(userId, send).Returns(Task.CompletedTask); + + // Set up context for reference event + _currentContext.ClientId.Returns("test-client"); + _currentContext.ClientVersion.Returns(Version.Parse("1.0.0")); + + // Act + await _nonAnonymousSendCommand.SaveSendAsync(send); + + // Assert + // Verify validation was checked + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + if (isNewSend) + { + // For new Sends + await _sendRepository.Received(1).CreateAsync(send); + await _pushNotificationService.Received(1).PushSyncSendCreateAsync(send); + await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => + e.Id == userId && + e.Type == ReferenceEventType.SendCreated && + e.Source == ReferenceEventSource.User && + e.SendType == send.Type && + e.SendHasNotes == true && + e.ClientId == "test-client" && + e.ClientVersion == Version.Parse("1.0.0"))); + } + else + { + // For existing Sends + await _sendRepository.Received(1).UpsertAsync(send); + Assert.NotEqual(initialDate, send.RevisionDate); + await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + } + + [Theory] + [InlineData(true)] // New Send (Id is default) + [InlineData(false)] // Existing Send (Id is not default) + public async Task SaveSendAsync_DisableHideEmail_Applies_throws(bool isNewSend) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = isNewSend ? default : Guid.NewGuid(), + Type = SendType.Text, + UserId = userId, + HideEmail = true + }; + + // Configure validation service to throw when HideEmail policy applies + _sendValidationService.ValidateUserCanSaveAsync(userId, send) + .Throws(new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveSendAsync(send)); + + Assert.Contains("hide your email address", exception.Message); + + // Verify validation was called + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + // Verify repository was not called (exception prevented save) + if (isNewSend) + { + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + } + else + { + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + } + + // Verify push notification wasn't sent + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Theory] + [InlineData(true)] // New Send (Id is default) + [InlineData(false)] // Existing Send (Id is not default) + public async Task SaveSendAsync_DisableHideEmail_DoesntApply_success(bool isNewSend) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = isNewSend ? default : Guid.NewGuid(), + Type = SendType.Text, + UserId = userId, + HideEmail = true // Setting HideEmail to true + }; + + var initialDate = DateTime.UtcNow.AddMinutes(-5); + send.RevisionDate = initialDate; + + // Configure validation service to NOT throw (policy doesn't apply) + _sendValidationService.ValidateUserCanSaveAsync(userId, send).Returns(Task.CompletedTask); + + // Set up context for reference event + _currentContext.ClientId.Returns("test-client"); + _currentContext.ClientVersion.Returns(Version.Parse("1.0.0")); + + // Act + await _nonAnonymousSendCommand.SaveSendAsync(send); + + // Assert + // Verify validation was checked + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + if (isNewSend) + { + // For new Sends + await _sendRepository.Received(1).CreateAsync(send); + await _pushNotificationService.Received(1).PushSyncSendCreateAsync(send); + await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => + e.Id == userId && + e.Type == ReferenceEventType.SendCreated && + e.Source == ReferenceEventSource.User && + e.SendType == send.Type && + e.HasPassword == false && + e.ClientId == "test-client" && + e.ClientVersion == Version.Parse("1.0.0"))); + } + else + { + // For existing Sends + await _sendRepository.Received(1).UpsertAsync(send); + Assert.NotEqual(initialDate, send.RevisionDate); + await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + } + + [Theory] + [InlineData(SendType.File)] + [InlineData(SendType.Text)] + public async Task SaveSendAsync_DisableSend_Applies_Throws_vNext(SendType sendType) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = default, + Type = sendType, + UserId = userId + }; + + // Configure validation service to throw when DisableSend policy applies in vNext implementation + _sendValidationService.ValidateUserCanSaveAsync(userId, send) + .Returns(Task.FromException(new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."))); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveSendAsync(send)); + + Assert.Contains("Enterprise Policy", exception.Message); + + // Verify validation service was called + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + // Verify repository and notification methods were not called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + + [Theory] + [InlineData(true)] // New Send (Id is default) + [InlineData(false)] // Existing Send (Id is not default) + public async Task SaveSendAsync_DisableSend_DoesntApply_Success_vNext(bool isNewSend) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = isNewSend ? default : Guid.NewGuid(), + Type = SendType.Text, + UserId = userId, + Data = "Text with Notes" + }; + + var initialDate = DateTime.UtcNow.AddMinutes(-5); + send.RevisionDate = initialDate; + + // Configure validation service to return success for vNext implementation + _sendValidationService.ValidateUserCanSaveAsync(userId, send).Returns(Task.CompletedTask); + + // Set up context for reference event + _currentContext.ClientId.Returns("test-client"); + _currentContext.ClientVersion.Returns(Version.Parse("1.0.0")); + + // Enable feature flag for policy requirements (vNext path) + _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + // Act + await _nonAnonymousSendCommand.SaveSendAsync(send); + + // Assert + // Verify validation was checked with vNext path + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + if (isNewSend) + { + // For new Sends + await _sendRepository.Received(1).CreateAsync(send); + await _pushNotificationService.Received(1).PushSyncSendCreateAsync(send); + await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => + e.Id == userId && + e.Type == ReferenceEventType.SendCreated && + e.Source == ReferenceEventSource.User && + e.SendType == send.Type && + e.SendHasNotes == true && + e.ClientId == "test-client" && + e.ClientVersion == Version.Parse("1.0.0"))); + } + else + { + // For existing Sends + await _sendRepository.Received(1).UpsertAsync(send); + Assert.NotEqual(initialDate, send.RevisionDate); + await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + } + + // Send Options Policy - Disable Hide Email check + [Theory] + [InlineData(true)] // New Send (Id is default) + [InlineData(false)] // Existing Send (Id is not default) + public async Task SaveSendAsync_DisableHideEmail_Applies_Throws_vNext(bool isNewSend) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = isNewSend ? default : Guid.NewGuid(), + Type = SendType.Text, + UserId = userId, + HideEmail = true + }; + + // Enable feature flag for policy requirements (vNext path) + _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + // Configure validation service to throw when DisableHideEmail policy applies in vNext implementation + _sendValidationService.ValidateUserCanSaveAsync(userId, send) + .Throws(new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveSendAsync(send)); + + Assert.Contains("hide your email address", exception.Message); + + // Verify validation was called + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + // Verify repository was not called (exception prevented save) + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + + // Verify push notification wasn't sent + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + + // Verify reference event service wasn't called + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + + [Theory] + [InlineData(true)] // New Send (Id is default) + [InlineData(false)] // Existing Send (Id is not default) + public async Task SaveSendAsync_DisableHideEmail_Applies_ButEmailNotHidden_Success_vNext(bool isNewSend) + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = isNewSend ? default : Guid.NewGuid(), + Type = SendType.Text, + UserId = userId, + HideEmail = false // Email is not hidden, so policy doesn't block + }; + + var initialDate = DateTime.UtcNow.AddMinutes(-5); + send.RevisionDate = initialDate; + + // Enable feature flag for policy requirements (vNext path) + _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + // Configure validation service to allow saves when HideEmail is false + _sendValidationService.ValidateUserCanSaveAsync(userId, send).Returns(Task.CompletedTask); + + // Set up context for reference event + _currentContext.ClientId.Returns("test-client"); + _currentContext.ClientVersion.Returns(Version.Parse("1.0.0")); + + // Act + await _nonAnonymousSendCommand.SaveSendAsync(send); + + // Assert + // Verify validation was called with vNext path + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + if (isNewSend) + { + // For new Sends + await _sendRepository.Received(1).CreateAsync(send); + await _pushNotificationService.Received(1).PushSyncSendCreateAsync(send); + await _referenceEventService.Received(1).RaiseEventAsync(Arg.Is(e => + e.Id == userId && + e.Type == ReferenceEventType.SendCreated && + e.Source == ReferenceEventSource.User && + e.SendType == send.Type && + e.ClientId == "test-client" && + e.ClientVersion == Version.Parse("1.0.0"))); + } + else + { + // For existing Sends + await _sendRepository.Received(1).UpsertAsync(send); + Assert.NotEqual(initialDate, send.RevisionDate); + await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + } + + [Fact] + public async Task SaveSendAsync_ExistingSend_Updates() + { + // Arrange + var userId = Guid.NewGuid(); + var sendId = Guid.NewGuid(); + var send = new Send + { + Id = sendId, + Type = SendType.Text, + UserId = userId, + Data = "Some text data" + }; + + var initialDate = DateTime.UtcNow.AddMinutes(-5); + send.RevisionDate = initialDate; + + // Act + await _nonAnonymousSendCommand.SaveSendAsync(send); + + // Assert + // Verify validation was called + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + // Verify repository was called with updated send + await _sendRepository.Received(1).UpsertAsync(send); + + // Check that the revision date was updated + Assert.NotEqual(initialDate, send.RevisionDate); + + // Verify push notification was sent for the update + await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); + + // Verify no reference event was raised (only happens for new sends) + await _referenceEventService.DidNotReceive().RaiseEventAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_TextType_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.Text, // Text type instead of File + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 1024L; // 1KB + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("not of type \"file\"", exception.Message); + + // Verify no further methods were called + await _sendValidationService.DidNotReceive().StorageRemainingForSendAsync(Arg.Any()); + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_EmptyFile_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 0L; // Empty file + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("No file data", exception.Message); + + // Verify no methods were called after validation failed + await _sendValidationService.DidNotReceive().StorageRemainingForSendAsync(Arg.Any()); + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_UserCannotAccessPremium_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 1024L; // 1KB + + // Configure validation service to throw when checking storage + _sendValidationService.StorageRemainingForSendAsync(send) + .Throws(new BadRequestException("You must have premium status to use file Sends.")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("premium status", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_UserHasUnconfirmedEmail_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 1024L; // 1KB + + // Configure validation service to pass storage check + _sendValidationService.StorageRemainingForSendAsync(send).Returns(10240L); // 10KB remaining + + // Configure validation service to throw when checking user can save + _sendValidationService.When(x => x.ValidateUserCanSaveAsync(userId, send)) + .Throw(new BadRequestException("You must confirm your email before creating a Send.")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("confirm your email", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify SaveSendAsync attempted to be called, triggering email validation + await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); + + // Verify no repository or notification methods were called after validation failed + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_UserCanAccessPremium_HasNoStorage_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 1024L; // 1KB + + // Configure validation service to return 0 storage remaining + _sendValidationService.StorageRemainingForSendAsync(send).Returns(0L); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("Not enough storage available", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_UserCanAccessPremium_StorageFull_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 1024L; // 1KB + + // Configure validation service to return less storage remaining than needed + _sendValidationService.StorageRemainingForSendAsync(send).Returns(512L); // Only 512 bytes available + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("Not enough storage available", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_UserCanAccessPremium_IsNotPremium_IsSelfHosted_GiantFile_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 15L * 1024L * 1024L * 1024L; // 15GB + + // Configure validation service to return large but insufficient storage (10GB for self-hosted non-premium) + _sendValidationService.StorageRemainingForSendAsync(send) + .Returns(10L * 1024L * 1024L * 1024L); // 10GB remaining (self-hosted default) + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("Not enough storage available", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_UserCanAccessPremium_IsNotPremium_IsNotSelfHosted_TwoGigabyteFile_ThrowsBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 2L * 1024L * 1024L * 1024L; // 2GB + + // Configure validation service to return 1GB storage (cloud non-premium default) + _sendValidationService.StorageRemainingForSendAsync(send) + .Returns(1L * 1024L * 1024L * 1024L); // 1GB remaining (cloud default) + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("Not enough storage available", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsNull_ThrowsBadRequest() + { + // Arrange + var organizationId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + OrganizationId = organizationId + }; + + var fileData = new SendFileData + { + FileName = "test.txt" + }; + + const long fileLength = 1000; + + // Set up validation service to return 0 storage remaining + // This simulates the case when an organization's max storage is null + _sendValidationService.StorageRemainingForSendAsync(send).Returns(0L); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Equal("Not enough storage available.", exception.Message); + + // Verify the method was called exactly once + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + } + + [Fact] + public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsNull_TwoGBFile_ThrowsBadRequest() + { + // Arrange + var orgId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + OrganizationId = orgId, + UserId = null + }; + var fileData = new SendFileData(); + var fileLength = 2L * 1024L * 1024L * 1024L; // 2GB + + // Configure validation service to throw BadRequest when checking storage for org without storage + _sendValidationService.StorageRemainingForSendAsync(send) + .Throws(new BadRequestException("This organization cannot use file sends.")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("This organization cannot use file sends", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsOneGB_TwoGBFile_ThrowsBadRequest() + { + // Arrange + var orgId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + OrganizationId = orgId, + UserId = null + }; + var fileData = new SendFileData(); + var fileLength = 2L * 1024L * 1024L * 1024L; // 2GB + + // Configure validation service to return 1GB storage (org's max storage limit) + _sendValidationService.StorageRemainingForSendAsync(send) + .Returns(1L * 1024L * 1024L * 1024L); // 1GB remaining + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + Assert.Contains("Not enough storage available", exception.Message); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify no further methods were called + await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); + await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); + await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); + } + + [Fact] + public async Task SaveFileSendAsync_HasEnoughStorage_Success() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 500L * 1024L; // 500KB + var expectedFileId = "generatedfileid"; + var expectedUploadUrl = "https://upload.example.com/url"; + + // Configure storage validation to return more storage than needed + _sendValidationService.StorageRemainingForSendAsync(send) + .Returns(1024L * 1024L); // 1MB remaining + + // Configure file storage service to return upload URL + _sendFileStorageService.GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()) + .Returns(expectedUploadUrl); + + // Set up string generator to return predictable file ID + _sendCoreHelperService.SecureRandomString(32, false, false) + .Returns(expectedFileId); + + // Act + var result = await _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength); + + // Assert + Assert.Equal(expectedUploadUrl, result); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify upload URL was requested + await _sendFileStorageService.Received(1).GetSendFileUploadUrlAsync(send, expectedFileId); + } + + [Fact] + public async Task SaveFileSendAsync_HasEnoughStorage_SendFileThrows_CleansUp() + { + // Arrange + var userId = Guid.NewGuid(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = userId + }; + var fileData = new SendFileData(); + var fileLength = 500L * 1024L; // 500KB + var expectedFileId = "generatedfileid"; + + // Configure storage validation to return more storage than needed + _sendValidationService.StorageRemainingForSendAsync(send) + .Returns(1024L * 1024L); // 1MB remaining + + // Set up string generator to return predictable file ID + _sendCoreHelperService.SecureRandomString(32, false, false) + .Returns(expectedFileId); + + // Configure file storage service to throw exception when getting upload URL + _sendFileStorageService.GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()) + .Throws(new Exception("Storage service unavailable")); + + // Act & Assert + await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); + + // Verify storage validation was called + await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); + + // Verify file was cleaned up after failure + await _sendFileStorageService.Received(1).DeleteFileAsync(send, expectedFileId); + } + + [Fact] + public async Task UpdateFileToExistingSendAsync_SendNull_ThrowsBadRequest() + { + // Arrange + Stream stream = new MemoryStream(); + Send send = null; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send)); + + Assert.Equal("Send does not have file data", exception.Message); + + // Verify no interactions with storage service + await _sendFileStorageService.DidNotReceiveWithAnyArgs().UploadNewFileAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateFileToExistingSendAsync_SendDataNull_ThrowsBadRequest() + { + // Arrange + Stream stream = new MemoryStream(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.File, + UserId = Guid.NewGuid(), + Data = null // Send exists but has null Data property + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send)); + + Assert.Equal("Send does not have file data", exception.Message); + + // Verify no interactions with storage service + await _sendFileStorageService.DidNotReceiveWithAnyArgs().UploadNewFileAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateFileToExistingSendAsync_NotFileType_ThrowsBadRequest() + { + // Arrange + Stream stream = new MemoryStream(); + var send = new Send + { + Id = Guid.NewGuid(), + Type = SendType.Text, // Not a file type + UserId = Guid.NewGuid(), + Data = "{\"someData\":\"value\"}" // Has data, but not file data + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send)); + + Assert.Equal("Not a File Type Send.", exception.Message); + + // Verify no interactions with storage service + await _sendFileStorageService.DidNotReceiveWithAnyArgs().UploadNewFileAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateFileToExistingSendAsync_StreamPositionRestToZero_Success() + { + // Arrange + var stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 }); + stream.Position = 2; + var sendId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var fileId = "existingfileid123"; + + var sendFileData = new SendFileData { Id = fileId, Size = 1000, Validated = false }; + var send = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.File, + Data = JsonSerializer.Serialize(sendFileData) + }; + + // Setup validation to succeed + _sendFileStorageService.ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, SendFileSettingHelper.FILE_SIZE_LEEWAY).Returns((true, sendFileData.Size)); + + // Act + await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send); + + // Assert + // Verify file was uploaded with correct parameters + await _sendFileStorageService.Received(1).UploadNewFileAsync( + Arg.Is(s => s == stream && s.Position == 0), // Ensure stream position is reset + Arg.Is(s => s.Id == sendId && s.UserId == userId), + Arg.Is(id => id == fileId) + ); + } + + + [Fact] + public async Task UploadFileToExistingSendAsync_Success() + { + // Arrange + var stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 }); + stream.Position = 2; // Simulate a non-zero position + var sendId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var fileId = "existingfileid123"; + + var sendFileData = new SendFileData { Id = fileId, Size = 1000, Validated = false }; + var send = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.File, + Data = JsonSerializer.Serialize(sendFileData) + }; + + _sendFileStorageService.ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, SendFileSettingHelper.FILE_SIZE_LEEWAY).Returns((true, sendFileData.Size)); + + // Act + await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send); + + // Assert + // Verify file was uploaded with correct parameters + await _sendFileStorageService.Received(1).UploadNewFileAsync( + Arg.Is(s => s == stream && s.Position == 0), // Ensure stream position is reset + Arg.Is(s => s.Id == sendId && s.UserId == userId), + Arg.Is(id => id == fileId) + ); + } + + [Fact] + public async Task UpdateFileToExistingSendAsync_InvalidSize_ThrowsBadRequest() + { + // Arrange + var stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 }); + var sendId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var fileId = "existingfileid123"; + + var sendFileData = new SendFileData { Id = fileId, Size = 1000, Validated = false }; + var send = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.File, + Data = JsonSerializer.Serialize(sendFileData) + }; + + // Configure storage service to upload successfully + _sendFileStorageService.UploadNewFileAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + // Configure validation to fail due to file size mismatch + _nonAnonymousSendCommand.ConfirmFileSize(send) + .Returns(false); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send)); + + Assert.Equal("File received does not match expected file length.", exception.Message); + } +} diff --git a/test/Core.Test/Tools/Services/SendAuthorizationServiceTests.cs b/test/Core.Test/Tools/Services/SendAuthorizationServiceTests.cs new file mode 100644 index 0000000000..9b2637d030 --- /dev/null +++ b/test/Core.Test/Tools/Services/SendAuthorizationServiceTests.cs @@ -0,0 +1,175 @@ +using Bit.Core.Context; +using Bit.Core.Platform.Push; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.Services; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Tools.Services; + +public class SendAuthorizationServiceTests +{ + private readonly ISendRepository _sendRepository; + private readonly IPasswordHasher _passwordHasher; + private readonly IPushNotificationService _pushNotificationService; + private readonly IReferenceEventService _referenceEventService; + private readonly ICurrentContext _currentContext; + private readonly SendAuthorizationService _sendAuthorizationService; + + public SendAuthorizationServiceTests() + { + _sendRepository = Substitute.For(); + _passwordHasher = Substitute.For>(); + _pushNotificationService = Substitute.For(); + _referenceEventService = Substitute.For(); + _currentContext = Substitute.For(); + + _sendAuthorizationService = new SendAuthorizationService( + _sendRepository, + _passwordHasher, + _pushNotificationService, + _referenceEventService, + _currentContext); + } + + + [Fact] + public void SendCanBeAccessed_Success_ReturnsTrue() + { + // Arrange + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + MaxAccessCount = 10, + AccessCount = 5, + ExpirationDate = DateTime.UtcNow.AddYears(1), + DeletionDate = DateTime.UtcNow.AddYears(1), + Disabled = false, + Password = "hashedPassword123" + }; + + const string password = "TEST"; + + _passwordHasher + .VerifyHashedPassword(Arg.Any(), send.Password, password) + .Returns(PasswordVerificationResult.Success); + + // Act + var result = + _sendAuthorizationService.SendCanBeAccessed(send, password); + + // Assert + Assert.Equal(SendAccessResult.Granted, result); + } + + [Fact] + public void SendCanBeAccessed_NullMaxAccess_Success() + { + // Arrange + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + MaxAccessCount = null, + AccessCount = 5, + ExpirationDate = DateTime.UtcNow.AddYears(1), + DeletionDate = DateTime.UtcNow.AddYears(1), + Disabled = false, + Password = "hashedPassword123" + }; + + const string password = "TEST"; + + _passwordHasher + .VerifyHashedPassword(Arg.Any(), send.Password, password) + .Returns(PasswordVerificationResult.Success); + + // Act + var result = _sendAuthorizationService.SendCanBeAccessed(send, password); + + // Assert + Assert.Equal(SendAccessResult.Granted, result); + } + + [Fact] + public void SendCanBeAccessed_NullSend_DoesNotGrantAccess() + { + // Arrange + _passwordHasher + .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") + .Returns(PasswordVerificationResult.Success); + + // Act + var result = + _sendAuthorizationService.SendCanBeAccessed(null, "TEST"); + + // Assert + Assert.Equal(SendAccessResult.Denied, result); + } + + [Fact] + public void SendCanBeAccessed_RehashNeeded_RehashesPassword() + { + // Arrange + var now = DateTime.UtcNow; + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + MaxAccessCount = null, + AccessCount = 5, + ExpirationDate = now.AddYears(1), + DeletionDate = now.AddYears(1), + Disabled = false, + Password = "TEST" + }; + + _passwordHasher + .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") + .Returns(PasswordVerificationResult.SuccessRehashNeeded); + + // Act + var result = + _sendAuthorizationService.SendCanBeAccessed(send, "TEST"); + + // Assert + _passwordHasher + .Received(1) + .HashPassword(Arg.Any(), "TEST"); + + Assert.Equal(SendAccessResult.Granted, result); + } + + [Fact] + public void SendCanBeAccessed_VerifyFailed_PasswordInvalidReturnsTrue() + { + // Arrange + var now = DateTime.UtcNow; + var send = new Send + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + MaxAccessCount = null, + AccessCount = 5, + ExpirationDate = now.AddYears(1), + DeletionDate = now.AddYears(1), + Disabled = false, + Password = "TEST" + }; + + _passwordHasher + .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") + .Returns(PasswordVerificationResult.Failed); + + // Act + var result = + _sendAuthorizationService.SendCanBeAccessed(send, "TEST"); + + // Assert + Assert.Equal(SendAccessResult.PasswordInvalid, result); + } +} diff --git a/test/Core.Test/Tools/Services/SendServiceTests.cs b/test/Core.Test/Tools/Services/SendServiceTests.cs deleted file mode 100644 index 86d476340d..0000000000 --- a/test/Core.Test/Tools/Services/SendServiceTests.cs +++ /dev/null @@ -1,867 +0,0 @@ -using System.Text; -using System.Text.Json; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.Services; -using Bit.Core.Entities; -using Bit.Core.Exceptions; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Platform.Push; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Core.Test.AutoFixture.CurrentContextFixtures; -using Bit.Core.Test.Entities; -using Bit.Core.Test.Tools.AutoFixture.SendFixtures; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.Repositories; -using Bit.Core.Tools.Services; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Identity; -using NSubstitute; -using NSubstitute.ExceptionExtensions; -using Xunit; - -using GlobalSettings = Bit.Core.Settings.GlobalSettings; - -namespace Bit.Core.Test.Tools.Services; - -[SutProviderCustomize] -[CurrentContextCustomize] -[UserSendCustomize] -public class SendServiceTests -{ - private void SaveSendAsync_Setup(SendType sendType, bool disableSendPolicyAppliesToUser, - SutProvider sutProvider, Send send) - { - send.Id = default; - send.Type = sendType; - - sutProvider.GetDependency().AnyPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.DisableSend).Returns(disableSendPolicyAppliesToUser); - } - - // Disable Send policy check - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableSend_Applies_throws(SendType sendType, - SutProvider sutProvider, Send send) - { - SaveSendAsync_Setup(sendType, disableSendPolicyAppliesToUser: true, sutProvider, send); - - await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send)); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableSend_DoesntApply_success(SendType sendType, - SutProvider sutProvider, Send send) - { - SaveSendAsync_Setup(sendType, disableSendPolicyAppliesToUser: false, sutProvider, send); - - await sutProvider.Sut.SaveSendAsync(send); - - await sutProvider.GetDependency().Received(1).CreateAsync(send); - } - - // Send Options Policy - Disable Hide Email check - - private void SaveSendAsync_HideEmail_Setup(bool disableHideEmailAppliesToUser, - SutProvider sutProvider, Send send, Policy policy) - { - send.HideEmail = true; - - var sendOptions = new SendOptionsPolicyData - { - DisableHideEmail = disableHideEmailAppliesToUser - }; - policy.Data = JsonSerializer.Serialize(sendOptions, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }); - - sutProvider.GetDependency().GetPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.SendOptions).Returns(new List() - { - new() { PolicyType = policy.Type, PolicyData = policy.Data, OrganizationId = policy.OrganizationId, PolicyEnabled = policy.Enabled } - }); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableHideEmail_Applies_throws(SendType sendType, - SutProvider sutProvider, Send send, Policy policy) - { - SaveSendAsync_Setup(sendType, false, sutProvider, send); - SaveSendAsync_HideEmail_Setup(true, sutProvider, send, policy); - - await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send)); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableHideEmail_DoesntApply_success(SendType sendType, - SutProvider sutProvider, Send send, Policy policy) - { - SaveSendAsync_Setup(sendType, false, sutProvider, send); - SaveSendAsync_HideEmail_Setup(false, sutProvider, send, policy); - - await sutProvider.Sut.SaveSendAsync(send); - - await sutProvider.GetDependency().Received(1).CreateAsync(send); - } - - // Disable Send policy check - vNext - private void SaveSendAsync_Setup_vNext(SutProvider sutProvider, Send send, - DisableSendPolicyRequirement disableSendPolicyRequirement, SendOptionsPolicyRequirement sendOptionsPolicyRequirement) - { - sutProvider.GetDependency().GetAsync(send.UserId!.Value) - .Returns(disableSendPolicyRequirement); - sutProvider.GetDependency().GetAsync(send.UserId!.Value) - .Returns(sendOptionsPolicyRequirement); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); - - // Should not be called in these tests - sutProvider.GetDependency().AnyPoliciesApplicableToUserAsync( - Arg.Any(), Arg.Any()).ThrowsAsync(); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableSend_Applies_Throws_vNext(SendType sendType, - SutProvider sutProvider, [NewUserSendCustomize] Send send) - { - send.Type = sendType; - SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement { DisableSend = true }, new SendOptionsPolicyRequirement()); - - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send)); - Assert.Contains("Due to an Enterprise Policy, you are only able to delete an existing Send.", - exception.Message); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableSend_DoesntApply_Success_vNext(SendType sendType, - SutProvider sutProvider, [NewUserSendCustomize] Send send) - { - send.Type = sendType; - SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement()); - - await sutProvider.Sut.SaveSendAsync(send); - - await sutProvider.GetDependency().Received(1).CreateAsync(send); - } - - // Send Options Policy - Disable Hide Email check - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableHideEmail_Applies_Throws_vNext(SendType sendType, - SutProvider sutProvider, [NewUserSendCustomize] Send send) - { - send.Type = sendType; - SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement { DisableHideEmail = true }); - send.HideEmail = true; - - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send)); - Assert.Contains("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.", exception.Message); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableHideEmail_Applies_ButEmailNotHidden_Success_vNext(SendType sendType, - SutProvider sutProvider, [NewUserSendCustomize] Send send) - { - send.Type = sendType; - SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement { DisableHideEmail = true }); - send.HideEmail = false; - - await sutProvider.Sut.SaveSendAsync(send); - - await sutProvider.GetDependency().Received(1).CreateAsync(send); - } - - [Theory] - [BitAutoData(SendType.File)] - [BitAutoData(SendType.Text)] - public async Task SaveSendAsync_DisableHideEmail_DoesntApply_Success_vNext(SendType sendType, - SutProvider sutProvider, [NewUserSendCustomize] Send send) - { - send.Type = sendType; - SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement()); - send.HideEmail = true; - - await sutProvider.Sut.SaveSendAsync(send); - - await sutProvider.GetDependency().Received(1).CreateAsync(send); - } - - [Theory] - [BitAutoData] - public async Task SaveSendAsync_ExistingSend_Updates(SutProvider sutProvider, - Send send) - { - send.Id = Guid.NewGuid(); - - var now = DateTime.UtcNow; - await sutProvider.Sut.SaveSendAsync(send); - - Assert.True(send.RevisionDate - now < TimeSpan.FromSeconds(1)); - - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(send); - - await sutProvider.GetDependency() - .Received(1) - .PushSyncSendUpdateAsync(send); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_TextType_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - send.Type = SendType.Text; - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 0) - ); - - Assert.Contains("not of type \"file\"", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_EmptyFile_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - send.Type = SendType.File; - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 0) - ); - - Assert.Contains("no file data", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_UserCannotAccessPremium_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(false); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 1) - ); - - Assert.Contains("must have premium", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_UserHasUnconfirmedEmail_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = false, - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 1) - ); - - Assert.Contains("must confirm your email", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_UserCanAccessPremium_HasNoStorage_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = true, - Premium = true, - MaxStorageGb = null, - Storage = 0, - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 1) - ); - - Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_UserCanAccessPremium_StorageFull_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = true, - Premium = true, - MaxStorageGb = 2, - Storage = 2 * UserTests.Multiplier, - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 1) - ); - - Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_UserCanAccessPremium_IsNotPremium_IsSelfHosted_GiantFile_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = true, - Premium = false, - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - sutProvider.GetDependency() - .SelfHosted = true; - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 11000 * UserTests.Multiplier) - ); - - Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_UserCanAccessPremium_IsNotPremium_IsNotSelfHosted_TwoGigabyteFile_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = true, - Premium = false, - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - sutProvider.GetDependency() - .SelfHosted = false; - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 2 * UserTests.Multiplier) - ); - - Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsNull_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var org = new Organization - { - Id = Guid.NewGuid(), - MaxStorageGb = null, - }; - - send.UserId = null; - send.OrganizationId = org.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(org.Id) - .Returns(org); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 1) - ); - - Assert.Contains("organization cannot use file sends", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsNull_TwoGBFile_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var org = new Organization - { - Id = Guid.NewGuid(), - MaxStorageGb = null, - }; - - send.UserId = null; - send.OrganizationId = org.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(org.Id) - .Returns(org); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 1) - ); - - Assert.Contains("organization cannot use file sends", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsOneGB_TwoGBFile_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var org = new Organization - { - Id = Guid.NewGuid(), - MaxStorageGb = 1, - }; - - send.UserId = null; - send.OrganizationId = org.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(org.Id) - .Returns(org); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, null, 2 * UserTests.Multiplier) - ); - - Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_HasEnoughStorage_Success(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = true, - MaxStorageGb = 10, - }; - - var data = new SendFileData - { - - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - var testUrl = "https://test.com/"; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - sutProvider.GetDependency() - .GetSendFileUploadUrlAsync(send, Arg.Any()) - .Returns(testUrl); - - var utcNow = DateTime.UtcNow; - - var url = await sutProvider.Sut.SaveFileSendAsync(send, data, 1 * UserTests.Multiplier); - - Assert.Equal(testUrl, url); - Assert.True(send.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); - - await sutProvider.GetDependency() - .Received(1) - .GetSendFileUploadUrlAsync(send, Arg.Any()); - - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(send); - - await sutProvider.GetDependency() - .Received(1) - .PushSyncSendUpdateAsync(send); - } - - [Theory] - [BitAutoData] - public async Task SaveFileSendAsync_HasEnoughStorage_SendFileThrows_CleansUp(SutProvider sutProvider, - Send send) - { - var user = new User - { - Id = Guid.NewGuid(), - EmailVerified = true, - MaxStorageGb = 10, - }; - - var data = new SendFileData - { - - }; - - send.UserId = user.Id; - send.Type = SendType.File; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .CanAccessPremium(user) - .Returns(true); - - sutProvider.GetDependency() - .GetSendFileUploadUrlAsync(send, Arg.Any()) - .Returns(callInfo => throw new Exception("Problem")); - - var utcNow = DateTime.UtcNow; - - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.SaveFileSendAsync(send, data, 1 * UserTests.Multiplier) - ); - - Assert.True(send.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); - Assert.Equal("Problem", exception.Message); - - await sutProvider.GetDependency() - .Received(1) - .GetSendFileUploadUrlAsync(send, Arg.Any()); - - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(send); - - await sutProvider.GetDependency() - .Received(1) - .PushSyncSendUpdateAsync(send); - - await sutProvider.GetDependency() - .Received(1) - .DeleteFileAsync(send, Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task UpdateFileToExistingSendAsync_SendNull_ThrowsBadRequest(SutProvider sutProvider) - { - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(), null) - ); - - Assert.Contains("does not have file data", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task UpdateFileToExistingSendAsync_SendDataNull_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - send.Data = null; - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(), send) - ); - - Assert.Contains("does not have file data", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task UpdateFileToExistingSendAsync_NotFileType_ThrowsBadRequest(SutProvider sutProvider, - Send send) - { - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(), send) - ); - - Assert.Contains("not a file type send", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); - } - - [Theory] - [BitAutoData] - public async Task UpdateFileToExistingSendAsync_Success(SutProvider sutProvider, - Send send) - { - var fileContents = "Test file content"; - - var sendFileData = new SendFileData - { - Id = "TEST", - Size = fileContents.Length, - Validated = false, - }; - - send.Type = SendType.File; - send.Data = JsonSerializer.Serialize(sendFileData); - - sutProvider.GetDependency() - .ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, Arg.Any()) - .Returns((true, sendFileData.Size)); - - await sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(Encoding.UTF8.GetBytes(fileContents)), send); - } - - [Theory] - [BitAutoData] - public async Task UpdateFileToExistingSendAsync_InvalidSize(SutProvider sutProvider, - Send send) - { - var fileContents = "Test file content"; - - var sendFileData = new SendFileData - { - Id = "TEST", - Size = fileContents.Length, - }; - - send.Type = SendType.File; - send.Data = JsonSerializer.Serialize(sendFileData); - - sutProvider.GetDependency() - .ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, Arg.Any()) - .Returns((false, sendFileData.Size)); - - var badRequest = await Assert.ThrowsAsync(() => - sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(Encoding.UTF8.GetBytes(fileContents)), send) - ); - } - - [Theory] - [BitAutoData] - public void SendCanBeAccessed_Success(SutProvider sutProvider, Send send) - { - var now = DateTime.UtcNow; - send.MaxAccessCount = 10; - send.AccessCount = 5; - send.ExpirationDate = now.AddYears(1); - send.DeletionDate = now.AddYears(1); - send.Disabled = false; - - sutProvider.GetDependency>() - .VerifyHashedPassword(Arg.Any(), send.Password, "TEST") - .Returns(PasswordVerificationResult.Success); - - var (grant, passwordRequiredError, passwordInvalidError) - = sutProvider.Sut.SendCanBeAccessed(send, "TEST"); - - Assert.True(grant); - Assert.False(passwordRequiredError); - Assert.False(passwordInvalidError); - } - - [Theory] - [BitAutoData] - public void SendCanBeAccessed_NullMaxAccess_Success(SutProvider sutProvider, - Send send) - { - var now = DateTime.UtcNow; - send.MaxAccessCount = null; - send.AccessCount = 5; - send.ExpirationDate = now.AddYears(1); - send.DeletionDate = now.AddYears(1); - send.Disabled = false; - - sutProvider.GetDependency>() - .VerifyHashedPassword(Arg.Any(), send.Password, "TEST") - .Returns(PasswordVerificationResult.Success); - - var (grant, passwordRequiredError, passwordInvalidError) - = sutProvider.Sut.SendCanBeAccessed(send, "TEST"); - - Assert.True(grant); - Assert.False(passwordRequiredError); - Assert.False(passwordInvalidError); - } - - [Theory] - [BitAutoData] - public void SendCanBeAccessed_NullSend_DoesNotGrantAccess(SutProvider sutProvider) - { - sutProvider.GetDependency>() - .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") - .Returns(PasswordVerificationResult.Success); - - var (grant, passwordRequiredError, passwordInvalidError) - = sutProvider.Sut.SendCanBeAccessed(null, "TEST"); - - Assert.False(grant); - Assert.False(passwordRequiredError); - Assert.False(passwordInvalidError); - } - - [Theory] - [BitAutoData] - public void SendCanBeAccessed_NullPassword_PasswordRequiredErrorReturnsTrue(SutProvider sutProvider, - Send send) - { - var now = DateTime.UtcNow; - send.MaxAccessCount = null; - send.AccessCount = 5; - send.ExpirationDate = now.AddYears(1); - send.DeletionDate = now.AddYears(1); - send.Disabled = false; - send.Password = "HASH"; - - sutProvider.GetDependency>() - .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") - .Returns(PasswordVerificationResult.Success); - - var (grant, passwordRequiredError, passwordInvalidError) - = sutProvider.Sut.SendCanBeAccessed(send, null); - - Assert.False(grant); - Assert.True(passwordRequiredError); - Assert.False(passwordInvalidError); - } - - [Theory] - [BitAutoData] - public void SendCanBeAccessed_RehashNeeded_RehashesPassword(SutProvider sutProvider, - Send send) - { - var now = DateTime.UtcNow; - send.MaxAccessCount = null; - send.AccessCount = 5; - send.ExpirationDate = now.AddYears(1); - send.DeletionDate = now.AddYears(1); - send.Disabled = false; - send.Password = "TEST"; - - sutProvider.GetDependency>() - .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") - .Returns(PasswordVerificationResult.SuccessRehashNeeded); - - var (grant, passwordRequiredError, passwordInvalidError) - = sutProvider.Sut.SendCanBeAccessed(send, "TEST"); - - sutProvider.GetDependency>() - .Received(1) - .HashPassword(Arg.Any(), "TEST"); - - Assert.True(grant); - Assert.False(passwordRequiredError); - Assert.False(passwordInvalidError); - } - - [Theory] - [BitAutoData] - public void SendCanBeAccessed_VerifyFailed_PasswordInvalidReturnsTrue(SutProvider sutProvider, - Send send) - { - var now = DateTime.UtcNow; - send.MaxAccessCount = null; - send.AccessCount = 5; - send.ExpirationDate = now.AddYears(1); - send.DeletionDate = now.AddYears(1); - send.Disabled = false; - send.Password = "TEST"; - - sutProvider.GetDependency>() - .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") - .Returns(PasswordVerificationResult.Failed); - - var (grant, passwordRequiredError, passwordInvalidError) - = sutProvider.Sut.SendCanBeAccessed(send, "TEST"); - - Assert.False(grant); - Assert.False(passwordRequiredError); - Assert.True(passwordInvalidError); - } -}